feat: 주요 CLI, 인증, 서비스 로직 및 문서 개선

This commit is contained in:
2026-04-15 00:48:22 +09:00
parent 36fbb0879e
commit 0e9d2a16d8
17 changed files with 864 additions and 18 deletions
+115
View File
@@ -0,0 +1,115 @@
"""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)
+30
View File
@@ -0,0 +1,30 @@
"""Output helpers dedicated to auth/bot CLI flows."""
from __future__ import annotations
from typing import Any
from rich.console import Console
from app.cli.core.utils import render_output
from app.core.security import mask_secret
def mask_token_text(raw_token: str) -> str:
return mask_secret(raw_token, prefix=6, suffix=4)
def _mask_payload(payload: dict[str, Any]) -> dict[str, Any]:
masked = dict(payload)
token = masked.get("token")
if isinstance(token, str) and token:
masked["token"] = mask_token_text(token)
return masked
def render_bot_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))
return
render_output(console, output_format, plain)
+2
View File
@@ -9,6 +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 bot_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
@@ -49,6 +50,7 @@ def create_app(
app.add_typer(portfolio_app, name="portfolio")
app.add_typer(strategy_app, name="strategy")
app.add_typer(user_app, name="user")
app.add_typer(bot_app, name="bot")
register_health_commands(app)
return app
+38 -1
View File
@@ -6,6 +6,8 @@ import base64
import hashlib
import hmac
import secrets
from datetime import datetime, timezone
from typing import Any
def hash_password(password: str) -> str:
@@ -47,4 +49,39 @@ def hash_token(token: str) -> str:
def token_prefix(token: str) -> str:
return token[:8]
return token[:8]
def mask_secret(value: str, *, prefix: int = 6, suffix: int = 4, mask_char: str = "*") -> str:
text = (value or "").strip()
if not text:
return ""
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
+7
View File
@@ -15,6 +15,13 @@ _INVEST_ACTION_MAP = {
}
def normalize_expiry_days(expires_in_days: int | None, *, default_days: int) -> int:
days = default_days if expires_in_days is None else int(expires_in_days)
if days <= 0:
raise ValueError("만료일은 1일 이상이어야 합니다")
return days
class PortfolioDomainRepository(Protocol):
def get(self, portfolio_id: str) -> Portfolio: ...
def get_or_create(
+59
View File
@@ -345,6 +345,30 @@ 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(
@@ -821,6 +845,41 @@ class PostgresPortfolioRepository:
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:
+17 -1
View File
@@ -118,6 +118,7 @@ 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
@@ -131,4 +132,19 @@ class CreateBotTokenRequest(BaseModel):
class IssuedBotTokenResponse(BaseModel):
token: str
token_type: Literal["bearer"] = "bearer"
auth_token: AuthTokenResponse
auth_token: AuthTokenResponse
class BotTokenIssueCliResponse(BaseModel):
bot_id: str
token_id: str
expires_at: datetime | None = None
revoked: bool
token: str | None = None
class BotTokenRevokeCliResponse(BaseModel):
bot_id: str
token_id: str
revoked: bool
revoked_at: datetime | None = None
+77 -8
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import Depends
from app.core.auth import AuthContext, Permission, _has_permission
from app.core.config import Settings, get_settings
from app.core.security import generate_token, hash_token, token_prefix, verify_password
from app.core.security import build_audit_event, generate_token, hash_token, mask_secret, token_prefix, verify_password
from app.domain.services import normalize_expiry_days
from app.domain.models import AuthToken, BotPrincipal, User
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
@@ -138,7 +140,14 @@ class AuthService:
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
return self._repository.deactivate_bot_principal(bot_principal_id)
def issue_bot_token(self, bot_principal_id: str, payload: CreateBotTokenRequest) -> IssuedBotToken:
def issue_bot_token(
self,
bot_principal_id: str,
payload: CreateBotTokenRequest,
*,
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("비활성화된 봇에는 토큰을 발급할 수 없습니다")
@@ -149,7 +158,11 @@ class AuthService:
raise ValueError("요청한 토큰 권한이 봇 권한을 초과합니다")
raw_token = generate_token()
expires_at = self._build_expiration(payload.expires_in_days, self._settings.bot_token_default_ttl_days)
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,
@@ -160,6 +173,20 @@ class AuthService:
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",
)
)
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)
def issue_owned_bot_token(
@@ -172,7 +199,12 @@ class AuthService:
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)
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)
@@ -185,16 +217,31 @@ class AuthService:
raise ValueError("봇 접근 권한이 없습니다")
return self._repository.list_bot_auth_tokens(bot_principal_id)
def revoke_bot_token(self, bot_principal_id: str, token_id: str) -> None:
self._repository.get_bot_principal(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._repository.revoke_bot_auth_token(bot_principal_id, token_id)
self.revoke_bot_token(bot_principal_id, token_id, actor_id=user_id)
@staticmethod
def _require_db_user_context(auth_context: AuthContext) -> str:
@@ -206,9 +253,31 @@ class AuthService:
@staticmethod
def _build_expiration(expires_in_days: int | None, default_days: int) -> datetime:
days = expires_in_days if expires_in_days is not None else default_days
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),