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
+20 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Protocol
from app.domain.models import AuthToken, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
class PortfolioRepository(Protocol):
@@ -51,6 +51,7 @@ class PortfolioRepository(Protocol):
token_prefix: str,
permission: str,
token_type: str,
bot_principal_id: str | None = None,
expires_at: datetime | None = None,
) -> AuthToken: ...
@@ -58,6 +59,24 @@ class PortfolioRepository(Protocol):
def revoke_auth_token(self, user_id: str, token_id: str) -> None: ...
def create_bot_principal(
self,
*,
owner_user_id: str,
name: str,
description: str | None = None,
) -> BotPrincipal: ...
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal: ...
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]: ...
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal: ...
def list_bot_auth_tokens(self, bot_principal_id: str) -> list[AuthToken]: ...
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None: ...
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
def get(self, portfolio_id: str) -> Portfolio: ...
+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()