작업 내용 저장

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
+2 -193
View File
@@ -7,21 +7,16 @@ from typing import NoReturn
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.core.auth import AuthContext, require_admin, require_read
from app.domain.models import AuthToken, BotPrincipal, User
from app.domain.models import AuthToken, User
from app.schemas.auth import (
AuthTokenResponse,
BotPrincipalResponse,
CreateBotPrincipalRequest,
CreateBotTokenRequest,
CreateClientTokenRequest,
CreateSelfBotPrincipalRequest,
IssuedBotTokenResponse,
IssuedClientTokenResponse,
LoginRequest,
TokenResponse,
)
from app.schemas.user import UserResponse
from app.services.auth_service import AuthService, IssuedBotToken, IssuedClientToken, LoginResult, get_auth_service
from app.services.auth_service import AuthService, IssuedClientToken, LoginResult, get_auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -47,7 +42,6 @@ def _to_auth_token_response(token: AuthToken) -> AuthTokenResponse:
token_prefix=token.token_prefix,
permission=token.permission,
token_type=token.token_type,
bot_principal_id=token.bot_principal_id,
expires_at=token.expires_at,
last_used_at=token.last_used_at,
revoked_at=token.revoked_at,
@@ -55,20 +49,6 @@ def _to_auth_token_response(token: AuthToken) -> AuthTokenResponse:
)
def _to_bot_principal_response(bot: BotPrincipal) -> BotPrincipalResponse:
return BotPrincipalResponse(
id=bot.id,
name=bot.name,
owner_user_id=bot.owner_user_id,
permission=bot.permission,
description=bot.description,
is_active=bot.is_active,
created_at=bot.created_at,
updated_at=bot.updated_at,
deactivated_at=bot.deactivated_at,
)
def _to_login_response(result: LoginResult) -> TokenResponse:
return TokenResponse(
access_token=result.token,
@@ -85,13 +65,6 @@ def _to_issued_token_response(result: IssuedClientToken) -> IssuedClientTokenRes
)
def _to_issued_bot_token_response(result: IssuedBotToken) -> IssuedBotTokenResponse:
return IssuedBotTokenResponse(
token=result.token,
auth_token=_to_auth_token_response(result.auth_token),
)
def _raise_auth_error(exc: ValueError) -> NoReturn:
detail = str(exc)
if "잘못된 사용자명 또는 비밀번호" in detail:
@@ -144,168 +117,4 @@ def revoke_token(
service.revoke_token(auth_context, token_id)
except ValueError as exc:
_raise_auth_error(exc)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/bots", response_model=BotPrincipalResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_admin)])
def create_bot_principal(
payload: CreateBotPrincipalRequest,
service: AuthService = Depends(get_auth_service),
) -> BotPrincipalResponse:
try:
return _to_bot_principal_response(service.create_bot_principal(payload))
except ValueError as exc:
_raise_auth_error(exc)
@router.post("/me/bots", response_model=BotPrincipalResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_read)])
def create_owned_bot_principal(
payload: CreateSelfBotPrincipalRequest,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> BotPrincipalResponse:
try:
return _to_bot_principal_response(service.create_owned_bot_principal(auth_context, payload))
except ValueError as exc:
_raise_auth_error(exc)
@router.get("/me/bots", response_model=list[BotPrincipalResponse], dependencies=[Depends(require_read)])
def list_owned_bot_principals(
auth_context: AuthContext = Depends(require_read),
is_active: bool | None = None,
service: AuthService = Depends(get_auth_service),
) -> list[BotPrincipalResponse]:
try:
return [
_to_bot_principal_response(bot)
for bot in service.list_owned_bot_principals(auth_context, is_active=is_active)
]
except ValueError as exc:
_raise_auth_error(exc)
@router.post(
"/me/bots/{bot_id}/tokens",
response_model=IssuedBotTokenResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_read)],
)
def issue_owned_bot_token(
bot_id: str,
payload: CreateBotTokenRequest,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> IssuedBotTokenResponse:
try:
return _to_issued_bot_token_response(service.issue_owned_bot_token(auth_context, bot_id, payload))
except ValueError as exc:
_raise_auth_error(exc)
@router.get("/me/bots/{bot_id}/tokens", response_model=list[AuthTokenResponse], dependencies=[Depends(require_read)])
def list_owned_bot_tokens(
bot_id: str,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> list[AuthTokenResponse]:
try:
return [_to_auth_token_response(token) for token in service.list_owned_bot_tokens(auth_context, bot_id)]
except ValueError as exc:
_raise_auth_error(exc)
@router.delete(
"/me/bots/{bot_id}/tokens/{token_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_read)],
)
def revoke_owned_bot_token(
bot_id: str,
token_id: str,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> Response:
try:
service.revoke_owned_bot_token(auth_context, bot_id, token_id)
except ValueError as exc:
_raise_auth_error(exc)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/bots", response_model=list[BotPrincipalResponse], dependencies=[Depends(require_admin)])
def list_bot_principals(
is_active: bool | None = None,
service: AuthService = Depends(get_auth_service),
) -> list[BotPrincipalResponse]:
try:
return [_to_bot_principal_response(bot) for bot in service.list_bot_principals(is_active=is_active)]
except ValueError as exc:
_raise_auth_error(exc)
@router.get("/bots/{bot_id}", response_model=BotPrincipalResponse, dependencies=[Depends(require_admin)])
def get_bot_principal(
bot_id: str,
service: AuthService = Depends(get_auth_service),
) -> BotPrincipalResponse:
try:
return _to_bot_principal_response(service.get_bot_principal(bot_id))
except ValueError as exc:
_raise_auth_error(exc)
@router.delete("/bots/{bot_id}", response_model=BotPrincipalResponse, dependencies=[Depends(require_admin)])
def deactivate_bot_principal(
bot_id: str,
service: AuthService = Depends(get_auth_service),
) -> BotPrincipalResponse:
try:
return _to_bot_principal_response(service.deactivate_bot_principal(bot_id))
except ValueError as exc:
_raise_auth_error(exc)
@router.post(
"/bots/{bot_id}/tokens",
response_model=IssuedBotTokenResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def issue_bot_token(
bot_id: str,
payload: CreateBotTokenRequest,
service: AuthService = Depends(get_auth_service),
) -> IssuedBotTokenResponse:
try:
return _to_issued_bot_token_response(service.issue_bot_token(bot_id, payload))
except ValueError as exc:
_raise_auth_error(exc)
@router.get("/bots/{bot_id}/tokens", response_model=list[AuthTokenResponse], dependencies=[Depends(require_admin)])
def list_bot_tokens(
bot_id: str,
service: AuthService = Depends(get_auth_service),
) -> list[AuthTokenResponse]:
try:
return [_to_auth_token_response(token) for token in service.list_bot_tokens(bot_id)]
except ValueError as exc:
_raise_auth_error(exc)
@router.delete(
"/bots/{bot_id}/tokens/{token_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_admin)],
)
def revoke_bot_token(
bot_id: str,
token_id: str,
service: AuthService = Depends(get_auth_service),
) -> Response:
try:
service.revoke_bot_token(bot_id, token_id)
except ValueError as exc:
_raise_auth_error(exc)
return Response(status_code=status.HTTP_204_NO_CONTENT)
-115
View File
@@ -1,115 +0,0 @@
"""Bot token CLI commands."""
from __future__ import annotations
from datetime import datetime
import typer
from app.cli.core.utils import execute_command
from app.cli.core.output import mask_token_text, render_bot_result
from app.schemas.auth import (
BotTokenIssueCliResponse,
BotTokenRevokeCliResponse,
CreateBotTokenRequest,
)
from app.services.auth_service import AuthService
bot_app = typer.Typer(help="Bot principal and token commands.")
token_app = typer.Typer(help="Bot token lifecycle commands.")
bot_app.add_typer(token_app, name="token")
_DEFAULT_BOT_TOKEN_TTL_DAYS = 365
def _confirm(force: bool, message: str) -> None:
if force:
return
confirmed = typer.confirm(message)
if not confirmed:
raise typer.Exit(code=0)
def _parse_issue_policy(ttl_days: int | None, no_expiry: bool) -> int | None:
if no_expiry and ttl_days is not None:
raise ValueError("--ttl-days와 --no-expiry는 동시에 사용할 수 없습니다")
if ttl_days is not None and ttl_days <= 0:
raise ValueError("--ttl-days는 1 이상의 정수여야 합니다")
if ttl_days is None and not no_expiry:
return _DEFAULT_BOT_TOKEN_TTL_DAYS
return ttl_days
def _to_issue_response(bot_id: str, issued, output_token: str | None) -> BotTokenIssueCliResponse:
return BotTokenIssueCliResponse(
bot_id=bot_id,
token_id=issued.auth_token.id,
expires_at=issued.auth_token.expires_at,
revoked=issued.auth_token.revoked_at is not None,
token=output_token,
)
def _to_revoke_response(bot_id: str, token_id: str, revoked_at: datetime) -> BotTokenRevokeCliResponse:
return BotTokenRevokeCliResponse(
bot_id=bot_id,
token_id=token_id,
revoked=True,
revoked_at=revoked_at,
)
def _with_service(ctx: typer.Context, callback):
app_context = ctx.obj
repository = app_context.repository_factory(app_context.settings)
service = AuthService(repository, app_context.settings)
try:
return execute_command(app_context.console, callback=lambda: callback(service, app_context))
finally:
if callable(getattr(type(repository), "close", None)):
repository.close()
@token_app.command("issue")
def issue_bot_token(
ctx: typer.Context,
bot_id: str = typer.Argument(..., help="Bot principal identifier."),
name: str = typer.Option("cli", help="Token display name."),
permission: str | None = typer.Option(None, help="Token permission: read or trade-write."),
ttl_days: int | None = typer.Option(None, "--ttl-days", help="Token TTL in days."),
no_expiry: bool = typer.Option(False, "--no-expiry", help="Issue a token with no expiry (admin only)."),
) -> None:
def _issue_with_policy(service: AuthService, app_context):
expires_in_days = _parse_issue_policy(ttl_days, no_expiry)
payload = CreateBotTokenRequest(
name=name,
permission=permission,
expires_in_days=expires_in_days,
no_expiry=no_expiry,
)
return service.issue_bot_token(bot_id, payload)
issued = _with_service(ctx, _issue_with_policy)
response = _to_issue_response(bot_id, issued, issued.token)
render_bot_result(ctx.obj.console, ctx.obj.output_format, response)
if ctx.obj.output_format != "json":
ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}")
@token_app.command("revoke")
def revoke_bot_token(
ctx: typer.Context,
bot_id: str = typer.Argument(..., help="Bot principal identifier."),
token_id: str = typer.Argument(..., help="Token identifier to revoke."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
_confirm(force, f"Revoke bot token {token_id} for bot {bot_id}?")
revoked_at = _with_service(
ctx,
lambda service, app_context: service.revoke_bot_token(bot_id, token_id),
)
response = _to_revoke_response(bot_id, token_id, revoked_at)
render_bot_result(ctx.obj.console, ctx.obj.output_format, response)
+79
View File
@@ -0,0 +1,79 @@
"""Service token CLI commands."""
from __future__ import annotations
import typer
from app.cli.core.output import mask_token_text, render_service_token_result
from app.schemas.auth import ServiceTokenIssueCliResponse, ServiceTokenRevokeCliResponse
from app.services.auth_service import AuthService
token_app = typer.Typer(help="Service token lifecycle commands.")
def _with_service(ctx: typer.Context, callback):
app_context = ctx.obj
repository = app_context.repository_factory(app_context.settings)
service = AuthService(repository, app_context.settings)
try:
from app.cli.core.utils import execute_command
return execute_command(app_context.console, callback=lambda: callback(service, app_context))
finally:
if callable(getattr(type(repository), "close", None)):
repository.close()
@token_app.command("issue")
def issue_service_token(
ctx: typer.Context,
name: str = typer.Argument(..., help="Unique service token name."),
user: str = typer.Option(..., "--user", help="Username to associate with this service token."),
permission: str = typer.Option("read", "--permission", help="Token permission: read or trade-write."),
ttl_days: int | None = typer.Option(None, "--ttl-days", help="Token TTL in days. Default: 365."),
no_expiry: bool = typer.Option(False, "--no-expiry", help="Issue a token with no expiry."),
) -> None:
def _issue(service: AuthService, app_context):
return service.issue_service_token(
name,
user,
permission=permission,
expires_in_days=ttl_days,
no_expiry=no_expiry,
)
issued = _with_service(ctx, _issue)
response = ServiceTokenIssueCliResponse(
token_id=issued.auth_token.id,
name=name,
expires_at=issued.auth_token.expires_at,
revoked=issued.auth_token.revoked_at is not None,
token=issued.token,
)
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
if ctx.obj.output_format != "json":
ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}")
@token_app.command("revoke")
def revoke_service_token(
ctx: typer.Context,
name: str = typer.Argument(..., help="Service token name to revoke."),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
) -> None:
if not force:
confirmed = typer.confirm(f"서비스 토큰 '{name}'을 폐기하시겠습니까?")
if not confirmed:
raise typer.Exit(code=0)
def _revoke(service: AuthService, app_context):
return service.revoke_service_token(name)
revoked_at = _with_service(ctx, _revoke)
response = ServiceTokenRevokeCliResponse(
token_id="",
name=name,
revoked=True,
revoked_at=revoked_at,
)
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
+1 -1
View File
@@ -22,7 +22,7 @@ def _mask_payload(payload: dict[str, Any]) -> dict[str, Any]:
return masked
def render_bot_result(console: Console, output_format: str, payload: Any) -> None:
def render_service_token_result(console: Console, output_format: str, payload: Any) -> None:
plain = payload.model_dump(mode="json") if hasattr(payload, "model_dump") else payload
if isinstance(plain, dict):
render_output(console, output_format, _mask_payload(plain))
+1 -1
View File
@@ -9,7 +9,7 @@ import typer
from rich.console import Console
from app.cli.commands.health import register_health_commands
from app.cli.commands.bot_tokens import token_app
from app.cli.commands.service_tokens import token_app
from app.cli.commands.portfolio import portfolio_app
from app.cli.commands.strategy import strategy_app
from app.cli.commands.user import user_app
+2 -2
View File
@@ -26,7 +26,7 @@ class AuthContext:
permission: Permission
user_id: str | None = None
token_id: str | None = None
bot_principal_id: str | None = None
token_type: str | None = None
token_source: str = "env"
def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]:
@@ -64,7 +64,7 @@ def _resolve_auth_context(token: str, settings: Settings, repository: PortfolioR
permission=Permission(principal.permission),
user_id=principal.user_id,
token_id=principal.token_id,
bot_principal_id=principal.bot_principal_id,
token_type=principal.token_type,
token_source="db",
)
+1 -28
View File
@@ -6,8 +6,6 @@ import base64
import hashlib
import hmac
import secrets
from datetime import datetime, timezone
from typing import Any
def hash_password(password: str) -> str:
@@ -59,29 +57,4 @@ def mask_secret(value: str, *, prefix: int = 6, suffix: int = 4, mask_char: str
if len(text) <= prefix + suffix:
return mask_char * len(text)
hidden = len(text) - prefix - suffix
return f"{text[:prefix]}{mask_char * hidden}{text[-suffix:]}"
def build_audit_event(
*,
action: str,
actor_id: str,
bot_id: str,
token_id: str,
token_ref: str,
result: str,
detail: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"event_type": "auth.bot_token",
"action": action,
"actor_id": actor_id,
"bot_id": bot_id,
"token_id": mask_secret(token_id, prefix=6, suffix=4),
"token_ref": token_ref,
"result": result,
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
if detail:
payload["detail"] = detail
return payload
return f"{text[:prefix]}{mask_char * hidden}{text[-suffix:]}"
-15
View File
@@ -37,7 +37,6 @@ class AuthToken:
token_prefix: str
permission: str
token_type: str
bot_principal_id: str | None = None
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
@@ -50,20 +49,6 @@ class TokenPrincipal:
user_id: str
permission: str
token_type: str
bot_principal_id: str | None = None
@dataclass
class BotPrincipal:
id: str
name: str
owner_user_id: str
permission: str
description: str | None = None
is_active: bool = True
created_at: datetime | None = None
updated_at: datetime | None = None
deactivated_at: datetime | None = None
@dataclass
+2 -25
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Protocol
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
class PortfolioRepository(Protocol):
@@ -51,7 +51,6 @@ class PortfolioRepository(Protocol):
token_prefix: str,
permission: str,
token_type: str,
bot_principal_id: str | None = None,
expires_at: datetime | None = None,
) -> AuthToken: ...
@@ -59,29 +58,7 @@ class PortfolioRepository(Protocol):
def revoke_auth_token(self, user_id: str, token_id: str) -> None: ...
def create_bot_principal(
self,
*,
name: str,
owner_user_id: str,
permission: 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,
owner_user_id: str | 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_service_token_by_name(self, name: str) -> AuthToken | None: ...
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
+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:
+6 -71
View File
@@ -51,8 +51,7 @@ class AuthTokenResponse(BaseModel):
name: str
token_prefix: str
permission: Literal["read", "trade-write", "admin"]
token_type: Literal["session", "client", "bot"]
bot_principal_id: str | None = None
token_type: Literal["session", "client", "service"]
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
@@ -65,86 +64,22 @@ class IssuedClientTokenResponse(BaseModel):
auth_token: AuthTokenResponse
class CreateBotPrincipalRequest(BaseModel):
name: str = Field(min_length=1)
owner_user_id: str = Field(min_length=1)
permission: Literal["read", "trade-write"]
description: str | None = None
@field_validator("name")
@classmethod
def normalize_bot_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("name은 비어 있을 수 없습니다")
return normalized
@field_validator("owner_user_id")
@classmethod
def normalize_owner_user_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("owner_user_id는 비어 있을 수 없습니다")
return normalized
class CreateSelfBotPrincipalRequest(BaseModel):
name: str = Field(min_length=1)
permission: Literal["read", "trade-write"]
description: str | None = None
@field_validator("name")
@classmethod
def normalize_bot_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("name은 비어 있을 수 없습니다")
return normalized
class BotPrincipalResponse(BaseModel):
id: str
name: str
owner_user_id: str
permission: Literal["read", "trade-write"]
description: str | None = None
is_active: bool
created_at: datetime | None = None
updated_at: datetime | None = None
deactivated_at: datetime | None = None
class CreateBotTokenRequest(BaseModel):
name: str = Field(min_length=1)
permission: Literal["read", "trade-write"] | None = None
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
no_expiry: bool = False
@field_validator("name")
@classmethod
def normalize_token_name(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("name은 비어 있을 수 없습니다")
return normalized
class IssuedBotTokenResponse(BaseModel):
class IssuedServiceToken(BaseModel):
token: str
token_type: Literal["bearer"] = "bearer"
auth_token: AuthTokenResponse
class BotTokenIssueCliResponse(BaseModel):
bot_id: str
class ServiceTokenIssueCliResponse(BaseModel):
token_id: str
name: str
expires_at: datetime | None = None
revoked: bool
token: str | None = None
class BotTokenRevokeCliResponse(BaseModel):
bot_id: str
class ServiceTokenRevokeCliResponse(BaseModel):
token_id: str
name: str
revoked: bool
revoked_at: datetime | None = None
+46 -167
View File
@@ -4,22 +4,20 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import Depends
import psycopg.errors
from app.core.auth import AuthContext, Permission, _has_permission
from app.core.config import Settings, get_settings
from app.core.security import build_audit_event, generate_token, hash_token, mask_secret, token_prefix, verify_password
from app.core.security import generate_token, hash_token, token_prefix, verify_password
from app.domain.services import normalize_expiry_days
from app.domain.models import AuthToken, BotPrincipal, User
from app.domain.models import AuthToken, User
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.auth import (
CreateBotPrincipalRequest,
CreateBotTokenRequest,
CreateClientTokenRequest,
CreateSelfBotPrincipalRequest,
LoginRequest,
)
@@ -38,7 +36,7 @@ class IssuedClientToken:
@dataclass
class IssuedBotToken:
class IssuedServiceToken:
token: str
auth_token: AuthToken
@@ -73,8 +71,8 @@ class AuthService:
return LoginResult(token=raw_token, auth_token=auth_token, user=refreshed_user)
def issue_client_token(self, auth_context: AuthContext, payload: CreateClientTokenRequest) -> IssuedClientToken:
if auth_context.bot_principal_id is not None:
raise ValueError("봇 토큰으로는 사용자 클라이언트 토큰을 발급할 수 없습니다")
if auth_context.token_type == "service":
raise ValueError("서비스 토큰으로는 사용자 클라이언트 토큰을 발급할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 클라이언트 토큰을 발급할 수 있습니다")
@@ -97,187 +95,68 @@ class AuthService:
return IssuedClientToken(token=raw_token, auth_token=auth_token)
def list_tokens(self, auth_context: AuthContext) -> list[AuthToken]:
if auth_context.bot_principal_id is not None:
raise ValueError("봇 토큰으로는 사용자 토큰 목록을 조회할 수 없습니다")
if auth_context.token_type == "service":
raise ValueError("서비스 토큰으로는 사용자 토큰 목록을 조회할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 토큰 목록을 조회할 수 있습니다")
return self._repository.list_auth_tokens(auth_context.user_id)
def revoke_token(self, auth_context: AuthContext, token_id: str) -> None:
if auth_context.bot_principal_id is not None:
raise ValueError("봇 토큰으로는 사용자 토큰을 폐기할 수 없습니다")
if auth_context.token_type == "service":
raise ValueError("서비스 토큰으로는 사용자 토큰을 폐기할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 토큰을 폐기할 수 있습니다")
self._repository.revoke_auth_token(auth_context.user_id, token_id)
def create_bot_principal(self, payload: CreateBotPrincipalRequest) -> BotPrincipal:
return self._repository.create_bot_principal(
name=payload.name,
owner_user_id=payload.owner_user_id,
permission=payload.permission,
description=payload.description,
)
def create_owned_bot_principal(self, auth_context: AuthContext, payload: CreateSelfBotPrincipalRequest) -> BotPrincipal:
user_id = self._require_db_user_context(auth_context)
return self._repository.create_bot_principal(
name=payload.name,
owner_user_id=user_id,
permission=payload.permission,
description=payload.description,
)
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]:
return self._repository.list_bot_principals(is_active=is_active)
def list_owned_bot_principals(self, auth_context: AuthContext, *, is_active: bool | None = None) -> list[BotPrincipal]:
user_id = self._require_db_user_context(auth_context)
return self._repository.list_bot_principals(is_active=is_active, owner_user_id=user_id)
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
return self._repository.get_bot_principal(bot_principal_id)
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
return self._repository.deactivate_bot_principal(bot_principal_id)
def issue_bot_token(
def issue_service_token(
self,
bot_principal_id: str,
payload: CreateBotTokenRequest,
name: str,
username: str,
*,
actor_permission: Permission = Permission.ADMIN,
actor_id: str | None = None,
) -> IssuedBotToken:
bot = self._repository.get_bot_principal(bot_principal_id)
if not bot.is_active:
raise ValueError("비활성화된 봇에는 토큰을 발급할 수 없습니다")
permission: str = "read",
expires_in_days: int | None = None,
no_expiry: bool = False,
) -> IssuedServiceToken:
user = self._repository.get_user_by_username(username)
if user is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {username}")
bot_permission = Permission(bot.permission)
requested = Permission(payload.permission or bot.permission)
if not _has_permission(bot_permission, requested):
raise ValueError("요청한 토큰 권한이 봇 권한을 초과합니다")
expires_at: datetime | None
if no_expiry:
if expires_in_days is not None:
raise ValueError("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다")
expires_at = None
else:
days = normalize_expiry_days(expires_in_days, default_days=365)
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
raw_token = generate_token()
expires_at = self._build_bot_expiration(
payload.expires_in_days,
no_expiry=payload.no_expiry,
actor_permission=actor_permission,
)
auth_token = self._repository.create_auth_token(
bot.id,
name=payload.name,
token_hash=hash_token(raw_token),
token_prefix=token_prefix(raw_token),
permission=requested.value,
token_type="bot",
bot_principal_id=bot.id,
expires_at=expires_at,
)
try:
self._record_audit_event(
build_audit_event(
action="bot_token.issue",
actor_id=actor_id or bot.owner_user_id,
bot_id=bot.id,
token_id=auth_token.id,
token_ref=mask_secret(raw_token, prefix=6, suffix=4),
result="success",
)
auth_token = self._repository.create_auth_token(
user.id,
name=name,
token_hash=hash_token(raw_token),
token_prefix=token_prefix(raw_token),
permission=permission,
token_type="service",
expires_at=expires_at,
)
except Exception as exc:
self._repository.revoke_bot_auth_token(bot.id, auth_token.id)
raise ValueError("감사 로그 기록에 실패하여 토큰 발급을 취소했습니다") from exc
return IssuedBotToken(token=raw_token, auth_token=auth_token)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
def issue_owned_bot_token(
self,
auth_context: AuthContext,
bot_principal_id: str,
payload: CreateBotTokenRequest,
) -> IssuedBotToken:
user_id = self._require_db_user_context(auth_context)
bot = self._repository.get_bot_principal(bot_principal_id)
if bot.owner_user_id != user_id:
raise ValueError("봇 접근 권한이 없습니다")
return self.issue_bot_token(
bot_principal_id,
payload,
actor_permission=auth_context.permission,
actor_id=user_id,
)
def list_bot_tokens(self, bot_principal_id: str) -> list[AuthToken]:
self._repository.get_bot_principal(bot_principal_id)
return self._repository.list_bot_auth_tokens(bot_principal_id)
def list_owned_bot_tokens(self, auth_context: AuthContext, bot_principal_id: str) -> list[AuthToken]:
user_id = self._require_db_user_context(auth_context)
bot = self._repository.get_bot_principal(bot_principal_id)
if bot.owner_user_id != user_id:
raise ValueError("봇 접근 권한이 없습니다")
return self._repository.list_bot_auth_tokens(bot_principal_id)
def revoke_bot_token(self, bot_principal_id: str, token_id: str, *, actor_id: str | None = None) -> datetime:
bot = self._repository.get_bot_principal(bot_principal_id)
self._repository.revoke_bot_auth_token(bot_principal_id, token_id)
revoked_at = datetime.now(timezone.utc)
try:
self._record_audit_event(
build_audit_event(
action="bot_token.revoke",
actor_id=actor_id or bot.owner_user_id,
bot_id=bot.id,
token_id=token_id,
token_ref=mask_secret(token_id, prefix=6, suffix=4),
result="success",
)
)
except Exception as exc:
raise ValueError("토큰은 폐기되었으나 감사 로그 기록에 실패했습니다") from exc
return revoked_at
def revoke_owned_bot_token(self, auth_context: AuthContext, bot_principal_id: str, token_id: str) -> None:
user_id = self._require_db_user_context(auth_context)
bot = self._repository.get_bot_principal(bot_principal_id)
if bot.owner_user_id != user_id:
raise ValueError("봇 접근 권한이 없습니다")
self.revoke_bot_token(bot_principal_id, token_id, actor_id=user_id)
@staticmethod
def _require_db_user_context(auth_context: AuthContext) -> str:
if auth_context.bot_principal_id is not None:
raise ValueError("봇 토큰으로는 사용자 전용 봇 관리를 수행할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 사용자 전용 봇 관리를 수행할 수 있습니다")
return auth_context.user_id
def revoke_service_token(self, name: str) -> datetime:
existing = self._repository.get_service_token_by_name(name)
if existing is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
self._repository.revoke_auth_token(existing.user_id, existing.id)
return datetime.now(timezone.utc)
@staticmethod
def _build_expiration(expires_in_days: int | None, default_days: int) -> datetime:
days = normalize_expiry_days(expires_in_days, default_days=default_days)
return datetime.now(timezone.utc) + timedelta(days=days)
@staticmethod
def _build_bot_expiration(
expires_in_days: int | None,
*,
no_expiry: bool,
actor_permission: Permission,
) -> datetime | None:
if no_expiry:
if expires_in_days is not None:
raise ValueError("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다")
if actor_permission is not Permission.ADMIN:
raise ValueError("비관리자는 --no-expiry를 사용할 수 없습니다")
return None
days = normalize_expiry_days(expires_in_days, default_days=365)
return datetime.now(timezone.utc) + timedelta(days=days)
def _record_audit_event(self, payload: dict[str, Any]) -> None:
recorder = getattr(self._repository, "record_audit_event", None)
if callable(recorder):
recorder(payload)
def get_auth_service(
settings: Settings = Depends(get_settings),