feat(auth): add admin-managed bot API keys with expiry and revocation

This commit is contained in:
2026-04-03 13:12:29 +00:00
parent 06b1805b92
commit 6998936858
15 changed files with 935 additions and 34 deletions
+188 -14
View File
@@ -12,7 +12,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -36,6 +36,7 @@ _SELECT_AUTH_TOKEN = """
token_prefix,
permission,
token_type,
bot_principal_id,
expires_at,
last_used_at,
revoked_at,
@@ -43,6 +44,19 @@ _SELECT_AUTH_TOKEN = """
FROM auth_tokens
"""
_SELECT_BOT_PRINCIPAL = """
SELECT
id,
owner_user_id,
name,
description,
is_active,
created_at,
updated_at,
deactivated_at
FROM bot_principals
"""
_SELECT_PORTFOLIO = """
SELECT
portfolio_id,
@@ -132,6 +146,7 @@ class PostgresPortfolioRepository:
token_hash TEXT NOT NULL UNIQUE,
permission TEXT NOT NULL,
token_type TEXT NOT NULL,
bot_principal_id TEXT,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
@@ -139,6 +154,21 @@ class PostgresPortfolioRepository:
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS bot_principals (
id TEXT PRIMARY KEY,
owner_user_id TEXT NOT NULL,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deactivated_at TIMESTAMPTZ
)
"""
)
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS bot_principal_id TEXT")
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
@@ -151,6 +181,18 @@ class PostgresPortfolioRepository:
ON auth_tokens (token_hash)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_bot_created
ON auth_tokens (bot_principal_id, created_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_bot_principals_active_created
ON bot_principals (is_active, created_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS strategy_sets (
@@ -249,10 +291,26 @@ class PostgresPortfolioRepository:
token_prefix=row[3],
permission=row[4],
token_type=row[5],
expires_at=row[6] if len(row) > 6 else None,
last_used_at=row[7] if len(row) > 7 else None,
revoked_at=row[8] if len(row) > 8 else None,
created_at=row[9] if len(row) > 9 else None,
bot_principal_id=row[6] if len(row) > 6 else None,
expires_at=row[7] if len(row) > 7 else None,
last_used_at=row[8] if len(row) > 8 else None,
revoked_at=row[9] if len(row) > 9 else None,
created_at=row[10] if len(row) > 10 else None,
)
@staticmethod
def _row_to_bot_principal(row: Any) -> BotPrincipal:
if row is None:
raise ValueError("봇을 찾을 수 없습니다")
return BotPrincipal(
id=row[0],
owner_user_id=row[1],
name=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,
)
@staticmethod
@@ -452,6 +510,7 @@ class PostgresPortfolioRepository:
token_prefix: str,
permission: str,
token_type: str,
bot_principal_id: str | None = None,
expires_at: datetime | None = None,
) -> AuthToken:
self.ensure_schema()
@@ -459,14 +518,22 @@ class PostgresPortfolioRepository:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
if bot_principal_id is not None:
cur.execute("SELECT 1 FROM bot_principals WHERE id = %s", (bot_principal_id,))
if cur.fetchone() is None:
raise ValueError("봇을 찾을 수 없습니다")
token_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO auth_tokens (id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, user_id, name, token_prefix, permission, token_type, expires_at, last_used_at, revoked_at, created_at
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type, bot_principal_id, expires_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
id, user_id, name, token_prefix, permission, token_type,
bot_principal_id, expires_at, last_used_at, revoked_at, created_at
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, bot_principal_id, expires_at),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@@ -492,16 +559,117 @@ class PostgresPortfolioRepository:
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
def create_bot_principal(
self,
*,
owner_user_id: str,
name: str,
description: str | None = None,
) -> BotPrincipal:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (owner_user_id,))
if cur.fetchone() is None:
raise ValueError("소유 사용자를 찾을 수 없습니다")
cur.execute("SELECT 1 FROM bot_principals WHERE name = %s", (name,))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
bot_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO bot_principals (id, owner_user_id, name, description)
VALUES (%s, %s, %s, %s)
RETURNING id, owner_user_id, name, description, is_active, created_at, updated_at, deactivated_at
""",
(bot_id, owner_user_id, name, description),
)
row = cur.fetchone()
return self._row_to_bot_principal(row)
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_BOT_PRINCIPAL + "WHERE id = %s", (bot_principal_id,))
row = cur.fetchone()
return self._row_to_bot_principal(row)
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]:
self.ensure_schema()
query = _SELECT_BOT_PRINCIPAL
params: list[object] = []
if is_active is not None:
query += "WHERE is_active = %s "
params.append(is_active)
query += "ORDER BY created_at DESC, name ASC"
with self._connect().cursor() as cur:
cur.execute(query, tuple(params))
rows = cur.fetchall()
return [self._row_to_bot_principal(row) for row in rows]
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE bot_principals
SET is_active = FALSE, deactivated_at = NOW(), updated_at = NOW()
WHERE id = %s
RETURNING id, owner_user_id, name, description, is_active, created_at, updated_at, deactivated_at
""",
(bot_principal_id,),
)
row = cur.fetchone()
if row is None:
raise ValueError("봇을 찾을 수 없습니다")
cur.execute(
"""
UPDATE auth_tokens
SET revoked_at = NOW()
WHERE bot_principal_id = %s AND revoked_at IS NULL
""",
(bot_principal_id,),
)
return self._row_to_bot_principal(row)
def list_bot_auth_tokens(self, bot_principal_id: str) -> list[AuthToken]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE bot_principal_id = %s ORDER BY created_at DESC, id ASC",
(bot_principal_id,),
)
rows = cur.fetchall()
return [self._row_to_auth_token(row) for row in rows]
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE auth_tokens
SET revoked_at = NOW()
WHERE id = %s AND bot_principal_id = %s
RETURNING id
""",
(token_id, bot_principal_id),
)
row = cur.fetchone()
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, user_id, permission, token_type
FROM auth_tokens
SELECT t.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
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
AND t.revoked_at IS NULL
AND (t.expires_at IS NULL OR t.expires_at > NOW())
AND (t.bot_principal_id IS NULL OR b.is_active = TRUE)
""",
(token_hash,),
)
@@ -509,7 +677,13 @@ class PostgresPortfolioRepository:
if row is None:
return None
cur.execute("UPDATE auth_tokens SET last_used_at = NOW() WHERE id = %s", (row[0],))
return TokenPrincipal(token_id=row[0], user_id=row[1], permission=row[2], token_type=row[3])
return TokenPrincipal(
token_id=row[0],
user_id=row[1],
permission=row[2],
token_type=row[3],
bot_principal_id=row[4],
)
def get(self, portfolio_id: str) -> Portfolio:
self.ensure_schema()