feat(auth): owner-bound bot tokens and self-service bot management

This commit is contained in:
2026-04-14 00:34:44 +09:00
parent 9787cf3159
commit 2dab17cbb1
16 changed files with 576 additions and 35 deletions
+76 -14
View File
@@ -48,6 +48,7 @@ _SELECT_BOT_PRINCIPAL = """
SELECT
id,
name,
owner_user_id,
permission,
description,
is_active,
@@ -161,6 +162,7 @@ class PostgresPortfolioRepository:
CREATE TABLE IF NOT EXISTS bot_principals (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
owner_user_id TEXT,
permission TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
@@ -171,9 +173,47 @@ class PostgresPortfolioRepository:
"""
)
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS bot_principal_id TEXT")
cur.execute("ALTER TABLE bot_principals ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
cur.execute("ALTER TABLE bot_principals ADD COLUMN IF NOT EXISTS permission TEXT")
cur.execute("UPDATE bot_principals SET permission = 'read' WHERE permission IS NULL")
cur.execute("ALTER TABLE bot_principals ALTER COLUMN permission SET NOT NULL")
cur.execute(
"""
UPDATE bot_principals
SET owner_user_id = fallback.id
FROM (
SELECT id
FROM users
ORDER BY created_at ASC, id ASC
LIMIT 1
) AS fallback
WHERE owner_user_id IS NULL
"""
)
cur.execute("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL")
unresolved_bot_owners = int(cur.fetchone()[0])
if unresolved_bot_owners > 0:
raise RuntimeError("bot_principals.owner_user_id backfill failed: users 테이블에 소유주가 필요합니다")
cur.execute("ALTER TABLE bot_principals ALTER COLUMN owner_user_id SET NOT NULL")
cur.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_bot_principals_owner_user'
) THEN
ALTER TABLE bot_principals
ADD CONSTRAINT fk_bot_principals_owner_user
FOREIGN KEY (owner_user_id)
REFERENCES users(id)
ON DELETE RESTRICT;
END IF;
END
$$
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
@@ -198,6 +238,12 @@ class PostgresPortfolioRepository:
ON bot_principals (is_active, created_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_bot_principals_owner_active_created
ON bot_principals (owner_user_id, is_active, created_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS strategy_sets (
@@ -333,12 +379,13 @@ class PostgresPortfolioRepository:
return BotPrincipal(
id=row[0],
name=row[1],
permission=row[2],
description=row[3] if len(row) > 3 else None,
is_active=bool(row[4]) if len(row) > 4 else True,
created_at=row[5] if len(row) > 5 else None,
updated_at=row[6] if len(row) > 6 else None,
deactivated_at=row[7] if len(row) > 7 else None,
owner_user_id=row[2],
permission=row[3],
description=row[4] if len(row) > 4 else None,
is_active=bool(row[5]) if len(row) > 5 else True,
created_at=row[6] if len(row) > 6 else None,
updated_at=row[7] if len(row) > 7 else None,
deactivated_at=row[8] if len(row) > 8 else None,
)
@staticmethod
@@ -594,6 +641,7 @@ class PostgresPortfolioRepository:
self,
*,
name: str,
owner_user_id: str,
permission: str,
description: str | None = None,
) -> BotPrincipal:
@@ -602,15 +650,18 @@ class PostgresPortfolioRepository:
cur.execute("SELECT 1 FROM bot_principals WHERE name = %s", (name,))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
cur.execute("SELECT 1 FROM users WHERE id = %s", (owner_user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
bot_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO bot_principals (id, name, permission, description)
VALUES (%s, %s, %s, %s)
RETURNING id, name, permission, description, is_active, created_at, updated_at, deactivated_at
INSERT INTO bot_principals (id, name, owner_user_id, permission, description)
VALUES (%s, %s, %s, %s, %s)
RETURNING id, name, owner_user_id, permission, description, is_active, created_at, updated_at, deactivated_at
""",
(bot_id, name, permission, description),
(bot_id, name, owner_user_id, permission, description),
)
row = cur.fetchone()
return self._row_to_bot_principal(row)
@@ -622,13 +673,24 @@ class PostgresPortfolioRepository:
row = cur.fetchone()
return self._row_to_bot_principal(row)
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]:
def list_bot_principals(
self,
*,
is_active: bool | None = None,
owner_user_id: str | None = None,
) -> list[BotPrincipal]:
self.ensure_schema()
query = _SELECT_BOT_PRINCIPAL
params: list[object] = []
clauses: list[str] = []
if is_active is not None:
query += "WHERE is_active = %s "
clauses.append("is_active = %s")
params.append(is_active)
if owner_user_id is not None:
clauses.append("owner_user_id = %s")
params.append(owner_user_id)
if clauses:
query += "WHERE " + " AND ".join(clauses) + " "
query += "ORDER BY created_at DESC, name ASC"
with self._connect().cursor() as cur:
cur.execute(query, tuple(params))
@@ -643,7 +705,7 @@ class PostgresPortfolioRepository:
UPDATE bot_principals
SET is_active = FALSE, deactivated_at = NOW(), updated_at = NOW()
WHERE id = %s
RETURNING id, name, permission, description, is_active, created_at, updated_at, deactivated_at
RETURNING id, name, owner_user_id, permission, description, is_active, created_at, updated_at, deactivated_at
""",
(bot_principal_id,),
)
@@ -691,7 +753,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, t.user_id, t.permission, t.token_type, t.bot_principal_id
SELECT t.id, COALESCE(b.owner_user_id, t.user_id), t.permission, t.token_type, t.bot_principal_id
FROM auth_tokens t
LEFT JOIN bot_principals b ON b.id = t.bot_principal_id
WHERE token_hash = %s