작업 내용 저장

This commit is contained in:
2026-04-23 09:56:08 +00:00
parent 514b49cb98
commit d383c7086f
29 changed files with 1654 additions and 1372 deletions
+23 -292
View File
@@ -3,16 +3,15 @@
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import datetime
from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -36,7 +35,6 @@ _SELECT_AUTH_TOKEN = """
token_prefix,
permission,
token_type,
bot_principal_id,
expires_at,
last_used_at,
revoked_at,
@@ -44,20 +42,6 @@ _SELECT_AUTH_TOKEN = """
FROM auth_tokens
"""
_SELECT_BOT_PRINCIPAL = """
SELECT
id,
name,
owner_user_id,
permission,
description,
is_active,
created_at,
updated_at,
deactivated_at
FROM bot_principals
"""
_SELECT_PORTFOLIO = """
SELECT
portfolio_id,
@@ -149,7 +133,6 @@ 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,
@@ -157,63 +140,6 @@ class PostgresPortfolioRepository:
)
"""
)
cur.execute(
"""
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,
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("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
@@ -228,20 +154,9 @@ class PostgresPortfolioRepository:
)
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 INDEX IF NOT EXISTS idx_bot_principals_owner_active_created
ON bot_principals (owner_user_id, is_active, created_at DESC)
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service'
"""
)
cur.execute(
@@ -345,30 +260,6 @@ class PostgresPortfolioRepository:
ON portfolio_tags (tag_value, portfolio_id)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS auth_audit_logs (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
action TEXT NOT NULL,
actor_id TEXT NOT NULL,
bot_id TEXT NOT NULL,
token_id_masked TEXT NOT NULL,
token_ref_masked TEXT NOT NULL,
result TEXT NOT NULL,
detail TEXT,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_audit_logs_bot_occurred
ON auth_audit_logs (bot_id, occurred_at DESC)
"""
)
cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
if cur.fetchone()[0]:
cur.execute(
@@ -405,27 +296,10 @@ class PostgresPortfolioRepository:
token_prefix=row[3],
permission=row[4],
token_type=row[5],
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],
name=row[1],
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,
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,
)
@staticmethod
@@ -657,31 +531,25 @@ 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()
with self._connect().cursor() as cur:
if token_type != "bot":
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("봇을 찾을 수 없습니다")
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_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, bot_principal_id, expires_at
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%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
expires_at, last_used_at, revoked_at, created_at
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, bot_principal_id, expires_at),
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@@ -707,129 +575,28 @@ class PostgresPortfolioRepository:
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
def create_bot_principal(
self,
*,
name: str,
owner_user_id: str,
permission: str,
description: str | None = None,
) -> BotPrincipal:
def get_service_token_by_name(self, name: str) -> AuthToken | None:
self.ensure_schema()
with self._connect().cursor() as cur:
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, 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, owner_user_id, permission, description),
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service'",
(name,),
)
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,
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:
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))
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, name, owner_user_id, permission, 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("토큰을 찾을 수 없습니다")
if row is None:
return None
return self._row_to_auth_token(row)
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, COALESCE(b.owner_user_id, t.user_id), t.permission, t.token_type, t.bot_principal_id
SELECT t.id, t.user_id, t.permission, t.token_type
FROM auth_tokens t
LEFT JOIN bot_principals b ON b.id = t.bot_principal_id
WHERE token_hash = %s
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,),
)
@@ -842,44 +609,8 @@ class PostgresPortfolioRepository:
user_id=row[1],
permission=row[2],
token_type=row[3],
bot_principal_id=row[4],
)
def record_audit_event(self, payload: dict[str, Any]) -> None:
self.ensure_schema()
event_id = str(uuid.uuid4())
occurred_at_raw = payload.get("occurred_at")
occurred_at = datetime.now(timezone.utc)
if isinstance(occurred_at_raw, str):
try:
occurred_at = datetime.fromisoformat(occurred_at_raw)
except ValueError:
occurred_at = datetime.now(timezone.utc)
with self._connect().cursor() as cur:
cur.execute(
"""
INSERT INTO auth_audit_logs (
id, event_type, action, actor_id, bot_id,
token_id_masked, token_ref_masked, result, detail, payload, occurred_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s)
""",
(
event_id,
str(payload.get("event_type", "auth.bot_token")),
str(payload.get("action", "")),
str(payload.get("actor_id", "unknown")),
str(payload.get("bot_id", "unknown")),
str(payload.get("token_id", "")),
str(payload.get("token_ref", "")),
str(payload.get("result", "unknown")),
payload.get("detail"),
json.dumps(payload, ensure_ascii=True),
occurred_at,
),
)
def get(self, portfolio_id: str) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur: