작업 내용 저장
This commit is contained in:
+23
-292
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user