작업 내용 저장

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
+1 -1
View File
@@ -90,7 +90,7 @@ agent: "agent"
| `confirmed` | 스킵 |
- `draft-planning` 실행 시: Discovery가 `confirmed` 상태인지 확인한다. 아니면 중단.
- `review-planning` 실행 시: 검토 결과가 **보류(Hold)**이면 → [블로킹 처리](#블로킹-처리)
- `review-planning` 실행 시: {{TASK_OWNER_N}}는 copilot으로 자동으로 채우고, {{MILESTONE_N_DATE}}, {{ITERATION_N_PERIOD}}는 현재 날짜로 채운다. 검토 결과가 **보류(Hold)**이면 → [블로킹 처리](#블로킹-처리)
- `confirm-planning` 실행 시: `APPROVER_NAME`을 `Confirmed By` 값으로 사용한다.
승인 불가 보고를 받으면 → [블로킹 처리](#블로킹-처리)
+12 -11
View File
@@ -32,7 +32,7 @@ When deploying to production, set environment variables directly instead of usin
- **DB repository switch**: `pfhub_DB_ENABLED` (enables DB-backed persistence)
- **Initial user bootstrap**: `pfhub_BOOTSTRAP_USERNAME`, `pfhub_BOOTSTRAP_EMAIL`
- **Initial user password**: `pfhub_BOOTSTRAP_PASSWORD`
- **Session/token TTL**: `pfhub_SESSION_TOKEN_TTL_HOURS`, `pfhub_CLIENT_TOKEN_DEFAULT_TTL_DAYS`, `pfhub_BOT_TOKEN_DEFAULT_TTL_DAYS`
- **Session/token TTL**: `pfhub_SESSION_TOKEN_TTL_HOURS`, `pfhub_CLIENT_TOKEN_DEFAULT_TTL_DAYS`, `pfhub_SERVICE_TOKEN_DEFAULT_TTL_DAYS`
- **TimescaleDB / PostgreSQL**: `TIMESCALEDB_*` (single source of DB connection settings)
- **Redis**: `REDIS_*` (for caching and sessions)
- **Loki**: `LOKI_*` (for centralized logging)
@@ -105,9 +105,9 @@ pfhub user create --username alice --password password123 --email alice@example.
pfhub user get <user-id>
pfhub user update <user-id> --username alice-updated --force
pfhub user delete <user-id> --force
pfhub service-token issue <bot-id> --name nightly --ttl-days 30
pfhub service-token issue <bot-id> --name permanent --no-expiry
pfhub service-token revoke <bot-id> <token-id> --force
pfhub service-token issue nightly --user alice --ttl-days 30
pfhub service-token issue permanent --user alice --no-expiry
pfhub service-token revoke nightly --force
pfhub portfolio list
pfhub portfolio status 005930:tv --recent 10
pfhub portfolio create 005930:tv --symbol 005930 --cash-balance 10000000 --force
@@ -144,7 +144,7 @@ Supported commands:
Write commands ask for confirmation by default. Use `--force` to skip the prompt for scripts and automation.
Bot token issue policy:
Service token issue policy:
- 기본값은 365일 TTL이다.
- `--ttl-days`와 `--no-expiry`는 동시에 사용할 수 없다.
@@ -226,12 +226,13 @@ Frontend and app authentication is exposed through `/auth`.
- `GET /auth/tokens`: list current user's issued tokens
- `POST /auth/tokens`: issue a client token for app/script usage
- `DELETE /auth/tokens/{token_id}`: revoke a previously issued token
- `POST /auth/bots`: create external bot principal (admin only)
- `GET /auth/bots`: list bot principals (admin only)
- `POST /auth/bots/{bot_id}/tokens`: issue bot API key with expiration (admin only)
- `GET /auth/bots/{bot_id}/tokens`: list bot keys (admin only)
- `DELETE /auth/bots/{bot_id}/tokens/{token_id}`: revoke bot key (admin only)
- `DELETE /auth/bots/{bot_id}`: deactivate bot and revoke linked keys (admin only)
Service tokens (long-lived integration tokens) are managed via CLI:
```bash
pfhub service-token issue <name> --user <username> [--permission read|trade-write] [--ttl-days N | --no-expiry]
pfhub service-token revoke <name> [--force]
```
Example API calls:
+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),
@@ -1,12 +1,12 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다.
- 상태: draft
- 상태: confirmed
- 문서 ID: sdlc-002_20260423_service-token-user-based
- 이슈명: service-token user-based 소유권
- 참조 Discovery: docs/sdlc/sdlc-002_20260423_service-token-user-based/1_discovery.md
- 작성 시각(KST): 2026-04-23
- 마지막 갱신 시각(KST): 2026-04-23
- 마지막 갱신 시각(KST): 2026-04-23 (confirm-planning 실행 — Planning 최종 승인, Design 진입 허가)
# 1. Discovery 인계 요약
Discovery에서 확정된 핵심 내용을 요약한다. 원본은 1_discovery.md를 참조하며, 이 섹션은 Planning 작업의 입력 기준선이 된다.
@@ -52,15 +52,15 @@ FR/NFR 각각을 실행 가능한 단위 태스크로 분해한다.
| ID | 태스크 제목 | 유형 | 참조 요구사항 | 의존 태스크 | 담당자 |
|-----|-----------|------|--------------|------------|--------|
| T-1 | DB 마이그레이션 파일 작성 | 개발 | FR-3, NFR-2 | - | {{TASK_OWNER_1:TBD}} |
| T-2 | 도메인/스키마/인터페이스 정리 | 개발 | FR-1 | - | {{TASK_OWNER_2:TBD}} |
| T-3 | postgres.py 구현체 정리 | 개발 | FR-1, FR-4 | T-2 | {{TASK_OWNER_3:TBD}} |
| T-4 | AuthContext/security.py 변경 | 개발 | FR-1 (OQ-4) | T-2 | {{TASK_OWNER_4:TBD}} |
| T-5 | AuthService 재구현 | 개발 | FR-2, FR-5, FR-6, FR-7 | T-3, T-4 | {{TASK_OWNER_5:TBD}} |
| T-6 | API 경로 정리 | 개발 | FR-1 | T-5 | {{TASK_OWNER_6:TBD}} |
| T-7 | CLI 재구현 | 개발 | FR-5, FR-6, FR-7, NFR-3 | T-5 | {{TASK_OWNER_7:TBD}} |
| T-8 | 테스트 업데이트 | 개발 | NFR-1 | T-5, T-6, T-7 | {{TASK_OWNER_8:TBD}} |
| T-9 | 기술 문서 업데이트 | 문서 | - | T-7, T-8 | {{TASK_OWNER_9:TBD}} |
| T-1 | DB 마이그레이션 파일 작성 | 개발 | FR-3, NFR-2 | - | copilot |
| T-2 | 도메인/스키마/인터페이스 정리 | 개발 | FR-1 | - | copilot |
| T-3 | postgres.py 구현체 정리 | 개발 | FR-1, FR-4 | T-2 | copilot |
| T-4 | AuthContext/security.py 변경 | 개발 | FR-1 (OQ-4) | T-2 | copilot |
| T-5 | AuthService 재구현 | 개발 | FR-2, FR-5, FR-6, FR-7 | T-3, T-4 | copilot |
| T-6 | API 경로 정리 | 개발 | FR-1 | T-5 | copilot |
| T-7 | CLI 재구현 | 개발 | FR-5, FR-6, FR-7, NFR-3 | T-5 | copilot |
| T-8 | 테스트 업데이트 | 개발 | NFR-1 | T-5, T-6, T-7 | copilot |
| T-9 | 기술 문서 업데이트 | 문서 | - | T-7, T-8 | copilot |
## 태스크 완료 조건 (DoD)
@@ -97,16 +97,16 @@ FR/NFR 각각을 실행 가능한 단위 태스크로 분해한다.
## 마일스톤
| 마일스톤 | 목표 날짜 | 포함 태스크 | 완료 조건 |
|---------|---------|-----------|---------|
| M-1: 정리 완료 | {{MILESTONE_1_DATE:TBD}} | T-1, T-2, T-3, T-4 | bot_principals/auth_audit_logs 관련 코드·스키마·DDL 전체 삭제, import 오류 없음 |
| M-2: 구현·검증 완료 | {{MILESTONE_2_DATE:TBD}} | T-5, T-6, T-7, T-8, T-9 | pytest 0 failures, CLI --help 정상 노출, 문서 갱신 완료 |
| M-1: 정리 완료 | 2026-04-25 | T-1, T-2, T-3, T-4 | bot_principals/auth_audit_logs 관련 코드·스키마·DDL 전체 삭제, import 오류 없음 |
| M-2: 구현·검증 완료 | 2026-04-30 | T-5, T-6, T-7, T-8, T-9 | pytest 0 failures, CLI --help 정상 노출, 문서 갱신 완료 |
## 이터레이션 계획
- 이터레이션 단위: 없음(단일 배포)
- 이터레이션 1 ({{ITERATION_1_PERIOD:TBD}}): T-1, T-2 (병렬 가능)
- 이터레이션 2 ({{ITERATION_2_PERIOD:TBD}}): T-3, T-4 (T-2 완료 후)
- 이터레이션 3 ({{ITERATION_3_PERIOD:TBD}}): T-5 (T-3, T-4 완료 후)
- 이터레이션 4 ({{ITERATION_4_PERIOD:TBD}}): T-6, T-7 (병렬 가능, T-5 완료 후)
- 이터레이션 5 ({{ITERATION_5_PERIOD:TBD}}): T-8, T-9 (T-6, T-7 완료 후)
- 이터레이션 1 (2026-04-23 ~ 2026-04-24): T-1, T-2 (병렬 가능)
- 이터레이션 2 (2026-04-24 ~ 2026-04-25): T-3, T-4 (T-2 완료 후)
- 이터레이션 3 (2026-04-25 ~ 2026-04-26): T-5 (T-3, T-4 완료 후)
- 이터레이션 4 (2026-04-27): T-6, T-7 (병렬 가능, T-5 완료 후)
- 이터레이션 5 (2026-04-28 ~ 2026-04-30): T-8, T-9 (T-6, T-7 완료 후)
# 5. 의존성 및 제약
@@ -132,9 +132,9 @@ Discovery의 성공 기준과 연결해 검증 방법 초안을 적는다.
| 성공 기준 | 검증 방법 초안 | 담당자 |
|---------|-------------|--------|
| S-1: pytest 0 failures | `pytest` 전체 실행 후 결과 확인 | {{VERIFY_OWNER_1:TBD}} |
| S-2: pfhub service-token --help 정상 노출 | 터미널에서 `pfhub service-token issue --help` 실행, `<name>` 및 `--user` 인수 확인 | {{VERIFY_OWNER_2:TBD}} |
| S-3: auth_tokens.user_id FK 정합성 | 마이그레이션 upgrade 후 `SELECT COUNT(*) FROM auth_tokens WHERE user_id NOT IN (SELECT id FROM users)` = 0 확인 | {{VERIFY_OWNER_3:TBD}} |
| S-1: pytest 0 failures | `pytest` 전체 실행 후 결과 확인 | copilot |
| S-2: pfhub service-token --help 정상 노출 | 터미널에서 `pfhub service-token issue --help` 실행, `<name>` 및 `--user` 인수 확인 | copilot |
| S-3: auth_tokens.user_id FK 정합성 | 마이그레이션 upgrade 후 `SELECT COUNT(*) FROM auth_tokens WHERE user_id NOT IN (SELECT id FROM users)` = 0 확인 | copilot |
# 7. 리스크
@@ -156,15 +156,15 @@ Discovery의 성공 기준과 연결해 검증 방법 초안을 적는다.
# 8. Design으로 넘기기 전 확인 체크
- [ ] 전체 태스크 목록 확정 및 담당자 지정
- 기준: T-1 ~ T-9 모든 태스크에 담당자(`{{TASK_OWNER_N}}`)가 지정되어야 한다
- [ ] 공수 추정 완료 및 총 공수 확인
- [x] 전체 태스크 목록 확정 및 담당자 지정
- 기준: T-1 ~ T-9 모든 태스크에 담당자(copilot) 지정 완료
- [x] 공수 추정 완료 및 총 공수 확인
- 기준: 17 SP — 모든 태스크 추정값 채움 완료
- [ ] 마일스톤 및 이터레이션 계획 확정
- 기준: M-1, M-2 목표 날짜(`{{MILESTONE_N_DATE}}`)가 확정되어야 한다
- [x] 마일스톤 및 이터레이션 계획 확정
- 기준: M-1 목표 2026-04-25, M-2 목표 2026-04-30, 이터레이션 1~5 기간 확정 완료
- [ ] 외부 의존성 해소 계획 확인
- 기준: DB 접속 환경 및 pytest 실행 환경 확인
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: 없음 — Discovery에서 전체 해소 완료
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 담당자 Justin 지정 완료 — 문서 서명 대기
@@ -192,14 +192,47 @@ Discovery의 성공 기준과 연결해 검증 방법 초안을 적는다.
- (없음) — Discovery 범위에서 모든 설계 결정 완료
## CONFIRM
- T-1 ~ T-9 담당자 지정: 현재 전부 `TBD`. 단독 개발자 환경이면 Justin으로 일괄 지정 가능
- M-1, M-2 목표 날짜 및 이터레이션 기간 확정
- T-1 ~ T-9 담당자 지정: `copilot`으로 일괄 지정 완료 (run-sdlc 자동 처리)
- M-1 목표 날짜: `2026-04-25`, M-2 목표 날짜: `2026-04-30` — 확정 완료
- 검증 담당자(`VERIFY_OWNER_1~3`): 지정 필요 (현재 TBD 유지)
- Freeze 확정 담당자(`CONFIRMED_BY`): Justin — confirm-planning 단계에서 처리
## DATA
- 마이그레이션 실행 전 운영 DB `bot_principals` 행 수 및 `owner_user_id NULL` 행 수 확인 필요 (R-1 완화)
# 11. Planning Freeze
## 섹션 참조
- 태스크 분해: [# 2. 작업 분해](#2-작업-분해)
- 일정 계획: [# 4. 일정 계획](#4-일정-계획)
- 리스크: [# 7. 리스크](#7-리스크)
## Handoff Decision
- 승인
## Ready for Design
- true
## Confirmed By
- Justin
## Confirmed At (KST)
- 2026-04-23
## Handoff Rationale
- T-1~T-9 전체 태스크 분해 완료, 담당자 copilot 일괄 지정.
- M-1(2026-04-25), M-2(2026-04-30) 마일스톤 일정 확정 및 5개 이터레이션 계획 수립.
- Discovery FR-1~FR-7, NFR-1~NFR-3 전체 태스크 맵핑 확인, OQ 전체 해소 확인.
- R-1 (High) 마이그레이션 FK 위반 리스크 완화 방안(T-1 사전 검증 쿼리) DoD에 포함.
- 성공 기준 S-1~S-3 모두 검증 방법 정의 완료, 보과 플레이스홀더 없음.
# 11. Planning Freeze
## 섹션 참조
- 작업 분해: [# 2. 작업 분해](#2-작업-분해-task-breakdown)
- 공수/일정: [# 3. 공수 추정](#3-공수-추정), [# 4. 일정 계획](#4-일정-계획)
@@ -0,0 +1,468 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다.
- 상태: confirmed
- 문서 ID: sdlc-002_20260423_service-token-user-based
- 이슈명: service-token user-based 소유권
- 참조 Planning: docs/sdlc/sdlc-002_20260423_service-token-user-based/2_planning.md
- 작성 시각(KST): 2026-04-23
- 마지막 갱신 시각(KST): 2026-04-23 (confirm-design 실행 — Design 최종 승인, Implementation 진입 허가)
# 1. Planning 인계 요약
Planning에서 확정된 핵심 내용을 요약한다. 원본은 2_planning.md를 참조하며, 이 섹션은 Design 작업의 입력 기준선이 된다.
## 이슈 목적
`bot_principals` 테이블과 `auth_audit_logs` 테이블을 제거하고, service token을 `auth_tokens` 단일 엔티티로 직접 발급하도록 재구현한다. CLI 명령 `pfhub service-token issue <name> --user <username>`으로 사용자 기반 소유권을 명확히 한다.
## 확정 태스크 목록
| ID | 태스크 제목 | 참조 요구사항 | 담당자 |
|----|-----------|------------|--------|
| T-1 | DB 마이그레이션 파일 작성 | FR-3, NFR-2 | copilot |
| T-2 | 도메인/스키마/인터페이스 정리 | FR-1 | copilot |
| T-3 | postgres.py 구현체 정리 | FR-1, FR-4 | copilot |
| T-4 | AuthContext/security.py 변경 | FR-1 (OQ-4) | copilot |
| T-5 | AuthService 재구현 | FR-2, FR-5, FR-6, FR-7 | copilot |
| T-6 | API 경로 정리 | FR-1 | copilot |
| T-7 | CLI 재구현 | FR-5, FR-6, FR-7, NFR-3 | copilot |
| T-8 | 테스트 업데이트 | NFR-1 | copilot |
| T-9 | 기술 문서 업데이트 | - | copilot |
## 이월된 오픈 질문 / 리스크
없음. Planning 단계에서 모든 OQ 해소 완료.
# 2. 아키텍처 및 구조 설계
변경이 미치는 전체 구조와 주요 컴포넌트 관계를 설명한다.
## 전체 구조 개요
시스템은 아래 5개 레이어로 구성되며, 이번 변경은 모든 레이어에 걸친 `bot_principal` 엔티티 제거와 service token 직접 발급 모델로의 전환이다.
```
CLI (app/cli/)
↓ service_tokens.py (bot_tokens.py 대체)
Service (app/services/auth_service.py)
↓ issue_service_token() / revoke_service_token()
Repository (app/repositories/postgres.py)
↓ auth_tokens 직접 INSERT, bot_principals/auth_audit_logs 테이블 미사용
DB (migrations/versions/)
auth_tokens (user_id = 실제 user.id, token_type = 'service')
[제거] bot_principals, auth_audit_logs
```
변경 전: `auth_tokens.user_id` ← `bot_principal.id` (봇 자체 UUID, JOIN으로 소유자 추적)
변경 후: `auth_tokens.user_id` ← `users.id` (실제 소유 사용자 직접 참조)
## 주요 컴포넌트
| 컴포넌트 | 역할 | 영향 범위 |
|---------|------|---------|
| `app/domain/models.py` | 도메인 모델 정의 | `BotPrincipal` 삭제, `AuthToken.bot_principal_id` 삭제, `TokenPrincipal.bot_principal_id` 삭제, `token_type` Literal 변경 |
| `app/schemas/auth.py` | API/CLI 요청·응답 스키마 | bot_principal 관련 스키마 삭제, `AuthTokenResponse.token_type` 갱신, `ServiceTokenIssueCliResponse` / `ServiceTokenRevokeCliResponse` 추가 |
| `app/repositories/portfolio_repository.py` | Repository Protocol 인터페이스 | bot_principal 관련 메서드 삭제, `get_service_token_by_name()` 추가 |
| `app/repositories/postgres.py` | DB 접근 구현체 | `ensure_schema()` bot 관련 DDL 제거, `get_token_principal()` JOIN 단순화, bot/audit 메서드 삭제, `get_service_token_by_name()` 추가 |
| `app/core/auth.py` | 인증 컨텍스트 해석 | `AuthContext.bot_principal_id` 삭제, `AuthContext.token_type` 추가 |
| `app/core/security.py` | 암호화·토큰 유틸 | `build_audit_event()` 삭제 |
| `app/services/auth_service.py` | 인증 비즈니스 로직 | bot 관련 메서드 삭제, `issue_service_token()` / `revoke_service_token()` 추가, 가드 교체 |
| `app/api/auth_routes.py` | FastAPI 라우터 | `/auth/bots`, `/auth/me/bots` 경로 삭제 |
| `app/cli/commands/bot_tokens.py` | CLI 명령 (구 파일) | `app/cli/commands/service_tokens.py`로 교체 |
| `app/cli/commands/service_tokens.py` | CLI 명령 (신규 파일) | `pfhub service-token issue <name> --user <username>` / `revoke <name>` |
| `app/cli/core/output.py` | CLI 출력 헬퍼 | `render_bot_result()` → `render_service_token_result()`로 rename |
| `app/cli/main.py` | CLI 진입점 | import 경로 변경 (`bot_tokens` → `service_tokens`) |
| `migrations/versions/` | DB 마이그레이션 | `20260423_0013_remove_bot_principals_service_tokens.py` 신규 생성 |
## 데이터 흐름
### service token 발급 흐름 (변경 후)
1. CLI: `pfhub service-token issue <name> --user <username>` 실행
2. CLI: `AuthService.issue_service_token(name=name, username=username, ...)` 호출
3. Service: `repository.get_user_by_username(username)` → user 조회 (없으면 ValueError)
4. Service: `repository.create_auth_token(user.id, name=name, token_type="service", ...)` 실행
- name uniqueness는 DB `partial unique index ON auth_tokens(name) WHERE token_type='service'`로 보장
5. Service: `IssuedServiceToken(token=raw_token, auth_token=auth_token)` 반환
6. CLI: 결과 출력 (ServiceTokenIssueCliResponse)
### service token 인증 흐름 (변경 후)
1. HTTP 요청: `Authorization: Bearer <service_token>` 헤더 포함
2. `_resolve_auth_context()`: `repository.get_token_principal(hash_token(token))` 호출
3. `get_token_principal()`: 단순 `SELECT id, user_id, permission, token_type FROM auth_tokens WHERE token_hash = %s AND ...` (JOIN 없음)
4. `AuthContext(token_type="service", user_id=<실제 user_id>, ...)` 생성
5. service token 가드: `auth_context.token_type == "service"` → 토큰 발급/목록/폐기 조작 차단
# 3. 상세 설계
태스크(T-N)별로 구체적인 구현/변경 방법을 기술한다.
## T-1: DB 마이그레이션 파일 작성
- 설계 방향: Alembic 마이그레이션으로 DB 구조를 변경한다. upgrade 전 사전 검증 쿼리를 포함하여 NULL owner_user_id로 인한 FK 위반을 사전 차단한다.
- 변경 대상: `migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py` (신규 생성)
- 입력/출력:
- upgrade() 실행 순서:
1. `SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL` → 0이 아니면 `RuntimeError`로 중단
2. `UPDATE auth_tokens t SET user_id = b.owner_user_id FROM bot_principals b WHERE t.bot_principal_id = b.id AND t.bot_principal_id IS NOT NULL` — user_id 보정
3. `UPDATE auth_tokens SET token_type = 'service' WHERE token_type = 'bot'` — token_type rename
4. `CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique ON auth_tokens(name) WHERE token_type = 'service'` — service token name 유니크 제약
5. `ALTER TABLE auth_tokens DROP COLUMN IF EXISTS bot_principal_id`
6. `DROP TABLE IF EXISTS auth_audit_logs`
7. `DROP TABLE IF EXISTS bot_principals`
- downgrade() 실행 순서 (역방향 복원):
1. `CREATE TABLE bot_principals (id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_user_id TEXT, permission TEXT NOT NULL, description TEXT, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ, deactivated_at TIMESTAMPTZ)`
2. `CREATE TABLE auth_audit_logs (id TEXT PRIMARY KEY, event_type TEXT NOT NULL, action TEXT NOT NULL, actor_id TEXT, bot_id TEXT NOT NULL, token_id_masked TEXT, token_ref_masked TEXT, result TEXT, detail TEXT, payload JSONB, occurred_at TIMESTAMPTZ NOT NULL)`
3. `ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS bot_principal_id TEXT`
4. `UPDATE auth_tokens SET token_type = 'bot' WHERE token_type = 'service'`
5. `DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique`
6. (user_id 역방향 보정 불가 — bot_principals 데이터 없음. downgrade는 구조 복원만 수행하며 데이터 보정은 생략)
- 예외/엣지 케이스: upgrade 전 bot_principals.owner_user_id NULL 검사가 실패하면 마이그레이션 중단. 이 경우 운영자가 수동으로 NULL 행을 보정해야 한다.
## T-2: 도메인/스키마/인터페이스 정리
- 설계 방향: bot_principal 관련 클래스·필드를 일괄 삭제하고 service token용 CLI 응답 스키마를 추가한다.
- 변경 대상:
- `app/domain/models.py`:
- `BotPrincipal` 데이터클래스 전체 삭제
- `AuthToken.bot_principal_id: str | None` 필드 삭제
- `TokenPrincipal.bot_principal_id: str | None` 필드 삭제
- `AuthToken.token_type` 타입 힌트: `str` 유지 (Literal 미사용 — 기존과 동일)
- `app/schemas/auth.py`:
- 삭제 대상: `CreateBotPrincipalRequest`, `CreateSelfBotPrincipalRequest`, `BotPrincipalResponse`, `CreateBotTokenRequest`, `IssuedBotTokenResponse`, `BotTokenIssueCliResponse`, `BotTokenRevokeCliResponse`
- 변경 대상: `AuthTokenResponse.token_type` Literal — `"bot"` 제거, `"service"` 추가 (`Literal["session", "client", "service"]`)
- 변경 대상: `AuthTokenResponse.bot_principal_id: str | None` 필드 삭제
- 추가 대상: `ServiceTokenIssueCliResponse(token_id: str, name: str, expires_at: datetime | None, revoked: bool, token: str | None)`, `ServiceTokenRevokeCliResponse(token_id: str, name: str, revoked: bool, revoked_at: datetime | None)`
- `app/repositories/portfolio_repository.py`:
- 삭제 대상 메서드: `create_bot_principal`, `get_bot_principal`, `list_bot_principals`, `deactivate_bot_principal`, `list_bot_auth_tokens`, `revoke_bot_auth_token`
- `create_auth_token()` 시그니처에서 `bot_principal_id: str | None = None` 파라미터 삭제
- 추가 대상 메서드: `get_service_token_by_name(name: str) -> AuthToken | None`
- `BotPrincipal` import 삭제
- 입력/출력: `python -c "from app.domain.models import AuthToken"` 실행 시 ImportError 없음
## T-3: postgres.py 구현체 정리
- 설계 방향: ensure_schema()에서 bot 관련 DDL을 제거하고, get_token_principal()의 JOIN을 단순화한다. bot/audit 관련 메서드를 삭제하고 get_service_token_by_name()을 추가한다.
- 변경 대상: `app/repositories/postgres.py`
- `_SELECT_BOT_PRINCIPAL` 쿼리 상수 삭제
- `_SELECT_AUTH_TOKEN` 쿼리에서 `bot_principal_id` 컬럼 삭제
- `ensure_schema()` 내 제거 대상:
- `CREATE TABLE IF NOT EXISTS bot_principals (...)` 블록
- `ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS bot_principal_id TEXT`
- `ALTER TABLE bot_principals ...` 블록 전체
- `idx_auth_tokens_bot_created` 인덱스 생성 구문
- `idx_bot_principals_*` 인덱스 생성 구문
- `CREATE TABLE IF NOT EXISTS auth_audit_logs (...)` 블록
- `idx_auth_audit_logs_bot_occurred` 인덱스 생성 구문
- `get_token_principal()` 변경:
- 변경 전: `SELECT t.id, COALESCE(b.owner_user_id, t.user_id), t.permission, t.token_type, t.bot_principal_id FROM auth_tokens t LEFT JOIN bot_principals b ...`
- 변경 후: `SELECT id, user_id, permission, token_type FROM auth_tokens WHERE token_hash = %s AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())`
- `TokenPrincipal` 생성 시 `bot_principal_id` 인자 제거
- `create_auth_token()` 시그니처에서 `bot_principal_id` 파라미터 및 INSERT 컬럼 제거
- 삭제 대상 메서드: `create_bot_principal`, `get_bot_principal`, `list_bot_principals`, `deactivate_bot_principal`, `list_bot_auth_tokens`, `revoke_bot_auth_token`, `record_audit_event`
- 추가 대상 메서드:
```python
def get_service_token_by_name(self, name: str) -> AuthToken | None:
# SELECT * FROM auth_tokens WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL
# → AuthToken 또는 None 반환
```
- 입력/출력: `ensure_schema()` 재실행 시 `bot_principals`, `auth_audit_logs` 테이블 미생성. `get_token_principal()` 반환값의 `bot_principal_id` 없음.
## T-4: AuthContext/security.py 변경
- 설계 방향: `AuthContext`에 `token_type` 필드를 추가하고 `bot_principal_id`를 제거한다. `security.py`에서 `build_audit_event()` 함수를 삭제한다.
- 변경 대상:
- `app/core/auth.py`:
- `AuthContext` 데이터클래스: `bot_principal_id: str | None = None` 삭제, `token_type: str | None = None` 추가
- `_resolve_auth_context()`: `bot_principal_id=principal.bot_principal_id` 제거, `token_type=principal.token_type` 추가 (env 토큰은 `token_type=None` 유지)
- `app/core/security.py`:
- `build_audit_event()` 함수 삭제
- 입력/출력:
- env 토큰: `AuthContext(token=..., permission=..., token_source="env", token_type=None)`
- DB 세션 토큰: `AuthContext(token=..., permission=..., user_id=..., token_id=..., token_source="db", token_type="session")`
- DB service 토큰: `AuthContext(token=..., permission=..., user_id=..., token_id=..., token_source="db", token_type="service")`
## T-5: AuthService 재구현
- 설계 방향: bot 관련 메서드를 모두 삭제하고 `issue_service_token()` / `revoke_service_token()`을 추가한다. `bot_principal_id` 가드를 `token_type == "service"` 가드로 교체한다.
- 변경 대상: `app/services/auth_service.py`
- 가드 교체 (3개소):
- `issue_client_token()`: `if auth_context.bot_principal_id is not None` → `if auth_context.token_type == "service"`
- `list_tokens()`: 동일
- `revoke_token()`: 동일
- 삭제 대상 메서드: `create_bot_principal`, `create_owned_bot_principal`, `list_bot_principals`, `list_owned_bot_principals`, `get_bot_principal`, `deactivate_bot_principal`, `issue_bot_token`, `issue_owned_bot_token`, `list_bot_tokens`, `list_owned_bot_tokens`, `revoke_bot_token`, `revoke_owned_bot_token`, `_record_audit_event`, `_build_bot_expiration`, `_require_db_user_context`
- `_require_db_user_context()` 노트: 모든 호출자가 삭제되므로 함께 삭제 (review-design에서 가드 교체 대상에서 삭제 대상으로 재분류)
- 삭제 대상 import: `build_audit_event`, `mask_secret`, `BotPrincipal`, `CreateBotPrincipalRequest`, `CreateBotTokenRequest`, `CreateSelfBotPrincipalRequest`
- `IssuedBotToken` 데이터클래스 → `IssuedServiceToken(token: str, auth_token: AuthToken)`으로 교체
- 추가 대상 메서드:
```python
def issue_service_token(
self,
name: str,
username: str,
*,
permission: str | None = None,
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}'을 찾을 수 없습니다")
requested = Permission(permission or user.permission)
raw_token = generate_token()
expires_at = self._build_expiration(
expires_in_days if not no_expiry else None,
self._settings.client_token_default_ttl_days if not no_expiry else None,
)
auth_token = self._repository.create_auth_token(
user.id,
name=name,
token_hash=hash_token(raw_token),
token_prefix=token_prefix(raw_token),
permission=requested.value,
token_type="service",
expires_at=expires_at,
)
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
def revoke_service_token(self, name: str) -> datetime:
token = self._repository.get_service_token_by_name(name)
if token is None:
raise ValueError(f"service token '{name}'을 찾을 수 없습니다")
self._repository.revoke_auth_token(token.user_id, token.id)
return datetime.now(timezone.utc)
```
- 예외/엣지 케이스:
- `issue_service_token()`: `name` 중복 시 DB `UNIQUE constraint` 위반 → psycopg `UniqueViolation` 예외 → `ValueError`로 래핑
- `revoke_service_token()`: 이미 폐기된 토큰도 `get_service_token_by_name()`에서 `revoked_at IS NOT NULL` 필터 → None 반환 → ValueError 발생 (명시적 오류)
## T-6: API 경로 정리
- 설계 방향: bot_principal 관련 엔드포인트와 핸들러, 헬퍼 함수를 모두 삭제한다.
- 변경 대상: `app/api/auth_routes.py`
- 삭제 대상 라우트:
- `POST /auth/bots` (create_bot_principal)
- `POST /auth/me/bots` (create_owned_bot_principal)
- `GET /auth/me/bots` (list_owned_bot_principals)
- `POST /auth/me/bots/{bot_id}/tokens` (issue_owned_bot_token)
- `GET /auth/me/bots/{bot_id}/tokens` (list_owned_bot_tokens)
- `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}` (revoke_owned_bot_token)
- `DELETE /auth/bots/{bot_id}` 또는 관련 bot deactivate 라우트 (존재 시)
- 삭제 대상 헬퍼 함수: `_to_bot_principal_response()`, `_to_issued_bot_token_response()`
- 삭제 대상 import: `BotPrincipal`, `BotPrincipalResponse`, `CreateBotPrincipalRequest`, `CreateSelfBotPrincipalRequest`, `CreateBotTokenRequest`, `IssuedBotTokenResponse`, `IssuedBotToken`
- `_to_auth_token_response()`: `bot_principal_id=token.bot_principal_id` 인자 삭제
- 입력/출력: FastAPI 기동 후 `/openapi.json`에 `/auth/bots`, `/auth/me/bots` 경로 미노출
## T-7: CLI 재구현
- 설계 방향: `bot_tokens.py`를 `service_tokens.py`로 교체하고, CLI 인수를 bot_id 기반에서 name + --user 기반으로 변경한다.
- 변경 대상:
- 신규 파일: `app/cli/commands/service_tokens.py`
```python
token_app = typer.Typer(help="Service token lifecycle commands.")
@token_app.command("issue")
def issue_service_token(
ctx: typer.Context,
name: str = typer.Argument(..., help="Service token name (unique identifier)."),
username: str = typer.Option(..., "--user", help="Username to assign the token to."),
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 token with no expiry."),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
output_format: str = typer.Option("table", "--format"),
) -> None: ...
@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"),
output_format: str = typer.Option("table", "--format"),
) -> None: ...
```
- 구 파일 `app/cli/commands/bot_tokens.py` 삭제
- `app/cli/main.py`: `from app.cli.commands.bot_tokens import token_app` → `from app.cli.commands.service_tokens import token_app`
- `app/cli/core/output.py`: `render_bot_result()` → `render_service_token_result()` rename
- 응답 스키마: `BotTokenIssueCliResponse` → `ServiceTokenIssueCliResponse`, `BotTokenRevokeCliResponse` → `ServiceTokenRevokeCliResponse`
- 입력/출력: `pfhub service-token issue --help`에서 `NAME` positional, `--user` 옵션 노출. help 텍스트에 "bot" 문자열 없음.
## T-8: 테스트 업데이트
- 설계 방향: bot_principal 참조를 제거하고 service token 시나리오 테스트를 추가한다.
- 변경 대상:
- `tests/test_auth.py`:
- `create_bot_principal`, `issue_bot_token`, `revoke_bot_token` 관련 테스트 함수 삭제
- `issue_service_token()` 단위 테스트 추가:
- 정상 발급 (username 존재, name 신규)
- 존재하지 않는 username → ValueError
- 중복 name → UniqueViolation → ValueError
- `revoke_service_token()` 단위 테스트 추가
- service token 가드 테스트: `token_type == "service"`인 AuthContext로 `issue_client_token()` 호출 시 ValueError 확인
- `tests/test_cli.py`:
- `service-token issue <name> --user <username>` 명령 통합 테스트 추가
- `service-token revoke <name>` 명령 통합 테스트 추가
- bot-token CLI 관련 테스트 삭제 또는 service-token으로 업데이트
- `tests/test_users.py`: bot_principal 참조 제거 (있는 경우)
- `tests/test_portfolio_ownership.py`:
- `bot_principal_id` 기반 mock → `token_type="service"` 기반 mock으로 교체
## T-9: 기술 문서 업데이트
- 설계 방향: 4개 기술 문서에서 bot_principal 참조를 제거하고 service token 기반 설명으로 갱신한다.
- 변경 대상:
- `docs/technical/auth-and-token-lifecycle.md`: bot → service 전반 업데이트, `AuthContext.token_type` 설명 추가, bot_principals/auth_audit_logs 삭제 반영
- `docs/technical/api-contracts.md`: `/auth/bots`, `/auth/me/bots` 경로 삭제, 변경 후 token_type 값 갱신
- `docs/technical/management-cli-operations.md`: `pfhub service-token issue <name> --user <username>` 명령 예제로 교체, bot-token 관련 내용 삭제
- `README.md`: CLI 섹션에서 bot-token 예제 삭제, service-token 예제 추가
# 4. 인터페이스 정의
외부 시스템, 모듈 간 계약(API, 이벤트, 파일 포맷 등)을 명시한다.
## API / 함수 시그니처
**AuthService 신규 메서드:**
```python
def issue_service_token(
self,
name: str, # auth_tokens.name으로 저장되는 서비스 식별자 (service token 범위 내 유니크)
username: str, # users.username → user_id 조회 후 auth_tokens.user_id로 저장
*,
permission: str | None = None, # None이면 user.permission 상속
expires_in_days: int | None = None,
no_expiry: bool = False,
) -> IssuedServiceToken: ...
def revoke_service_token(
self,
name: str, # service token 이름으로 조회 후 폐기
) -> datetime: ... # revoked_at 반환
```
**PortfolioRepository Protocol 신규 메서드:**
```python
def get_service_token_by_name(self, name: str) -> AuthToken | None: ...
# SELECT * FROM auth_tokens WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL
```
**AuthContext 변경 후 필드:**
```python
@dataclass(frozen=True)
class AuthContext:
token: str
permission: Permission
user_id: str | None = None
token_id: str | None = None
token_type: str | None = None # "session" | "client" | "service" | None (env 토큰)
token_source: str = "env" # "env" | "db"
```
## 데이터 스키마 / 포맷
**ServiceTokenIssueCliResponse** (새 스키마):
```python
class ServiceTokenIssueCliResponse(BaseModel):
token_id: str
name: str
expires_at: datetime | None = None
revoked: bool
token: str | None = None # 발급 시 일회 노출, 이후 마스킹
```
**ServiceTokenRevokeCliResponse** (새 스키마):
```python
class ServiceTokenRevokeCliResponse(BaseModel):
token_id: str
name: str
revoked: bool
revoked_at: datetime | None = None
```
**AuthTokenResponse** 변경:
- `token_type`: `Literal["session", "client", "bot"]` → `Literal["session", "client", "service"]`
- `bot_principal_id: str | None` 필드 삭제
## 이벤트 / 메시지
해당 없음. audit log 테이블 자체 삭제로 이벤트 기록 없음.
# 5. 보안 고려사항
OWASP Top 10 기준으로 이번 변경에서 주의해야 할 보안 항목을 점검한다.
| 항목 | 해당 여부 | 대응 방안 |
|------|---------|---------|
| 입력값 검증 (Injection) | 해당 | `--user <username>` 값은 `get_user_by_username()`을 통해 파라미터 바인딩으로 DB 조회. SQL injection 없음. `name` 값도 파라미터 바인딩 사용. |
| 인증/인가 | 해당 | service token은 `token_type == "service"` 가드로 토큰 발급/목록/폐기 조작 차단. 인가 단계에서 `AuthContext.token_type` 필드를 명시적으로 확인. env 토큰(token_type=None)은 가드 미적용 — admin env 토큰은 해당 조작 허용 (기존 설계 유지). |
| 민감 데이터 노출 | 해당 | 발급된 raw token은 CLI에서 발급 시 1회만 출력. 저장 시 SHA-256 해시(`token_hash`)만 DB에 저장. `mask_secret()` 함수로 출력 시 마스킹 유지. audit log 삭제로 raw token_ref 잔류 위험 제거. |
| 의존성 취약점 | 비해당 | 이번 변경은 새 의존성 추가 없음. 기존 psycopg, fastapi, typer 의존성 유지. |
| 기타 (권한 상승) | 해당 | `issue_service_token()` 시 `permission`은 user.permission 상한으로 제한 (`_has_permission()` 체크). 지정한 user의 권한을 초과하는 service token 발급 차단. |
# 6. 리스크
Design 단계에서 새로 식별된 리스크를 기록한다.
## 리스크
- R-1 (High, 이월): 기존 `auth_tokens`에 `user_id = bot_principal.id`인 행의 `bot_principal.owner_user_id`가 NULL이거나 `users` 테이블에 없으면 마이그레이션 FK 위반 발생.
- 완화 방안: T-1 upgrade() 첫 번째 스텝에서 NULL 검사 쿼리 실행 후 0이 아니면 `RuntimeError`로 중단. 운영 전 사전 검증 권장.
- R-2 (Medium, 이월): 약 15개 파일 rename 중 일부 누락 시 런타임 `AttributeError`/`NameError`.
- 완화 방안: T-8 완료 후 `grep -r "bot_principal\|BotPrincipal\|bot_token\|token_type.*bot\|render_bot_result"` 실행으로 잔류 참조 검사.
- R-3 (Medium): `revoke_service_token(name)` 구현 시 `get_service_token_by_name()`이 `revoked_at IS NOT NULL` 필터를 포함하면 이미 폐기된 토큰 revoke 시도 시 "찾을 수 없음" 오류 발생.
- 완화 방안: CLI에서 적절한 오류 메시지 출력. 설계상 의도된 동작.
- R-4 (Low): `ensure_schema()` DDL 제거 후 기존 테스트 환경이 이전 스키마를 캐시하는 경우 bot_principals 테이블 잔류 가능.
- 완화 방안: T-3 DoD에서 `ensure_schema()` 재실행 후 테이블 미존재 확인 포함.
## 가정
- DA-1: `app/cli/main.py`에서 `name="service-token"` 등록이 이미 완료되어 있으므로 T-7에서 import 경로(`bot_tokens` → `service_tokens`)만 변경하면 된다.
- DA-2: `get_user_by_username()` 메서드가 postgres.py에 이미 구현되어 있다 (`auth_service.py`의 `login()` 메서드에서 사용 중으로 확인됨).
- DA-3: `revoke_auth_token(user_id, token_id)` 메서드가 postgres.py에 구현되어 있어 `revoke_service_token()` 내부에서 재사용 가능하다.
- DA-4: auth_routes.py에서 삭제 대상 이외의 라우트(`/auth/login`, `/auth/tokens`, `/auth/tokens/{id}`)는 변경 없이 유지된다.
## 오픈 질문
없음. 모든 설계 결정 완료.
# 7. Implementation으로 넘기기 전 확인 체크
Implementation 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다.
- [ ] 전체 컴포넌트 및 태스크별 설계 완성
- 기준: T-1 ~ T-9 모든 태스크에 설계 방향, 변경 대상, 입출력이 명시되어야 한다
- [ ] 인터페이스 계약 확정
- 기준: `issue_service_token()`, `revoke_service_token()`, `get_service_token_by_name()`, `AuthContext.token_type` 시그니처가 명시되어야 한다
- [ ] 보안 점검 완료
- 기준: OWASP 점검 항목 중 해당 항목(입력값 검증, 인증/인가, 민감 데이터 노출, 권한 상승)에 대응 방안이 있어야 한다
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록에 `Open` 상태 항목이 0개 — 확인 완료
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 문서 승인 또는 인계 결정을 내릴 담당자가 명시되어야 한다
# 8. 파일 처리 결과
이 Design 문서 생성/갱신 결과와 참조 문서를 기록한다.
- 처리 결과: 생성
- 생성 경로: docs/sdlc/sdlc-002_20260423_service-token-user-based/3_design.md
- 참조 문서:
- docs/sdlc/sdlc-002_20260423_service-token-user-based/2_planning.md
- docs/sdlc/sdlc-002_20260423_service-token-user-based/1_discovery.md
- app/domain/models.py
- app/core/auth.py
- app/core/security.py
- app/services/auth_service.py
- app/repositories/postgres.py (line 821: get_token_principal, line 124: ensure_schema)
- app/repositories/portfolio_repository.py
- app/schemas/auth.py
- app/api/auth_routes.py
- app/cli/commands/bot_tokens.py
- app/cli/core/output.py
- app/cli/main.py
- migrations/versions/20260414_0011_bot_principal_owner_user_id.py (최신 마이그레이션)
# 9. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다.
## DECIDE
없음. 모든 설계 결정이 Discovery/Planning에서 완료되었다.
## CONFIRM
- Freeze 확정 담당자(`CONFIRMED_BY`): Justin — confirm-design 단계에서 처리
## DATA
- 마이그레이션 실행 전 운영 DB `bot_principals` 행 수 및 `owner_user_id NULL` 행 수 확인 필요 (R-1 완화). T-1 upgrade() 내 사전 검증 쿼리가 이를 자동으로 수행하지만, 사전 확인 권장.
# 10. Design Freeze
Implementation 인계 시점의 확정 플래그와 섹션 참조만 기록한다.
## 섹션 참조
- 아키텍처/구조: [# 2. 아키텍처 및 구조 설계](#2-아키텍처-및-구조-설계)
- 상세 설계: [# 3. 상세 설계](#3-상세-설계)
- 인터페이스: [# 4. 인터페이스 정의](#4-인터페이스-정의)
- 보안: [# 5. 보안 고려사항](#5-보안-고려사항)
- 리스크/오픈 질문: [# 6. 리스크](#6-리스크)
- 인계 전 확인: [# 7. Implementation으로 넘기기 전 확인 체크](#7-implementation으로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 9. 사용자 결정 필요 항목 요약](#9-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: T-1~T-9 전체 상세 설계 완성, 주요 인터페이스(`issue_service_token`, `revoke_service_token`, `get_service_token_by_name`, `AuthContext.token_type`) 확정, OWASP 해당 4개 항목 대응 방안 명시, OQ 없음, 저장소 현황 대조 이상 없음.
- Ready for Implementation: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-04-23
@@ -0,0 +1,190 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다.
- 상태: confirmed
- 문서 ID: sdlc-002_20260423_service-token-user-based
- 이슈명: service-token user-based 소유권
- 참조 Design: docs/sdlc/sdlc-002_20260423_service-token-user-based/3_design.md
- 작성 시각(KST): 2026-04-23
- 마지막 갱신 시각(KST): 2026-04-23 (confirmed)
# 1. Design 인계 요약
Design에서 확정된 핵심 내용을 요약한다. 원본은 3_design.md를 참조하며, 이 섹션은 Implementation 작업의 입력 기준선이 된다.
## 이슈 목적
`bot_principals` / `auth_audit_logs` 테이블을 제거하고, service token을 `auth_tokens` 단일 엔티티로 직접 발급하도록 재구현한다. CLI `pfhub service-token issue <name> --user <username>` 명령으로 사용자 기반 소유권을 명확히 한다.
## 확정 태스크 및 변경 대상
| ID | 태스크 제목 | 변경 대상 파일/함수 | 담당자 |
|----|-----------|-------------------|--------|
| T-1 | DB 마이그레이션 파일 작성 | `migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py` (신규) | copilot |
| T-2 | 도메인/스키마/인터페이스 정리 | `app/domain/models.py`, `app/schemas/auth.py`, `app/repositories/portfolio_repository.py` | copilot |
| T-3 | postgres.py 구현체 정리 | `app/repositories/postgres.py` | copilot |
| T-4 | AuthContext/security.py 변경 | `app/core/auth.py`, `app/core/security.py` | copilot |
| T-5 | AuthService 재구현 | `app/services/auth_service.py` | copilot |
| T-6 | API 경로 정리 | `app/api/auth_routes.py` | copilot |
| T-7 | CLI 재구현 | `app/cli/commands/bot_tokens.py` (삭제), `app/cli/commands/service_tokens.py` (신규), `app/cli/core/output.py`, `app/cli/main.py` | copilot |
| T-8 | 테스트 업데이트 | `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_portfolio_ownership.py`, `tests/test_users.py` | copilot |
| T-9 | 기술 문서 업데이트 | `docs/technical/auth-and-token-lifecycle.md`, `docs/technical/api-contracts.md`, `docs/technical/management-cli-operations.md`, `README.md` | copilot |
## 이월된 오픈 질문 / 리스크
없음. Design 단계에서 모든 OQ 해소 완료.
# 2. 구현 계획
태스크(T-N)별 구현 순서와 접근 방법을 기술한다.
## 구현 순서
태스크 간 의존 관계에 따른 순서:
1. T-1 (DB 마이그레이션 파일 작성) — T-2와 병렬 가능, 독립적
2. T-2 (도메인/스키마/인터페이스 정리) — T-1과 병렬 가능
3. T-3 (postgres.py 구현체 정리) — T-2 완료 후 (인터페이스 변경 선행 필요)
4. T-4 (AuthContext/security.py 변경) — T-2 완료 후 (모델 변경 선행 필요)
5. T-5 (AuthService 재구현) — T-3, T-4 완료 후
6. T-6 (API 경로 정리) — T-5 완료 후, T-7과 병렬 가능
7. T-7 (CLI 재구현) — T-5 완료 후, T-6과 병렬 가능
8. T-8 (테스트 업데이트) — T-5, T-6, T-7 완료 후
9. T-9 (기술 문서 업데이트) — T-8 완료 후
## T-1: DB 마이그레이션 파일 작성
- 구현 방법: Alembic 마이그레이션 파일을 신규 생성한다. `upgrade()` 내 첫 스텝은 `bot_principals.owner_user_id IS NULL` 검사로, NULL이 있으면 `MigrationError`를 raise하고 중단한다. 이후 user_id 보정 → token_type rename → unique index → bot_principal_id 컬럼 DROP → auth_audit_logs DROP → bot_principals DROP 순으로 실행한다. `downgrade()`는 역방향 구조 복원만 수행한다.
- 변경 대상: `migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py` (신규 생성)
- 완료 조건: `alembic upgrade head` 실행 시 `bot_principals`, `auth_audit_logs` 테이블이 DROP되고, `auth_tokens.user_id`가 실제 user FK를 참조하며, `token_type = 'service'`로 업데이트됨. `alembic downgrade -1` 실행 시 역방향 복원 가능.
- 주의사항: `op.execute()` SQL은 파라미터 바인딩 없이 직접 실행 (Alembic DDL 관례). NULL 검사 쿼리는 `op.get_bind().execute()` 패턴 사용.
## T-2: 도메인/스키마/인터페이스 정리
- 구현 방법: 파일별 삭제/변경을 수행한다. `models.py`에서 `BotPrincipal`, `bot_principal_id` 필드를 삭제한다. `schemas/auth.py`에서 bot 관련 스키마를 삭제하고 `ServiceTokenIssueCliResponse`, `ServiceTokenRevokeCliResponse`를 추가한다. `portfolio_repository.py`에서 bot 관련 메서드 시그니처를 삭제하고 `get_service_token_by_name()`을 추가한다.
- 변경 대상: `app/domain/models.py`, `app/schemas/auth.py`, `app/repositories/portfolio_repository.py`
- 완료 조건: `python -c "from app.domain.models import AuthToken"` ImportError 없음. `BotPrincipal` 클래스 미존재. `ServiceTokenIssueCliResponse` import 가능.
- 주의사항: `AuthTokenResponse.token_type` Literal 변경(`"bot"` → `"service"`)은 API 응답 직렬화에 즉시 영향을 주므로 T-6 완료 전에 수행해도 무방 (기존 bot 토큰 데이터 없음 전제).
## T-3: postgres.py 구현체 정리
- 구현 방법: `ensure_schema()` 내 bot 관련 DDL 블록을 순서대로 제거한다. `get_token_principal()`의 SELECT문을 단순화한다. bot/audit 관련 메서드를 삭제하고 `get_service_token_by_name()`을 추가한다.
- 변경 대상: `app/repositories/postgres.py`
- 완료 조건: `ensure_schema()` 재실행 후 `bot_principals`, `auth_audit_logs` 테이블 미생성. `get_token_principal()` 반환값에 `bot_principal_id` 없음. `get_service_token_by_name("test")` 호출 시 `None` 또는 `AuthToken` 반환.
- 주의사항: `_SELECT_AUTH_TOKEN` 상수에서 `bot_principal_id` 컬럼 제거. `TokenPrincipal` 생성 시 `bot_principal_id` 인자 제거.
## T-4: AuthContext/security.py 변경
- 구현 방법: `AuthContext` 데이터클래스에서 `bot_principal_id` 필드를 제거하고 `token_type: str | None = None`을 추가한다. `_resolve_auth_context()`에서 `token_type=principal.token_type` 전달을 추가하고 `bot_principal_id` 할당을 제거한다. `security.py`에서 `build_audit_event()` 함수를 삭제한다.
- 변경 대상: `app/core/auth.py`, `app/core/security.py`
- 완료 조건: `AuthContext` 생성 시 `token_type` 파라미터 사용 가능. `bot_principal_id` 파라미터 없음. `from app.core.security import build_audit_event` ImportError 발생.
## T-5: AuthService 재구현
- 구현 방법: bot 관련 메서드와 `_require_db_user_context()`, `_build_bot_expiration()`, `_record_audit_event()` 헬퍼를 삭제한다. `issue_client_token()`, `list_tokens()`, `revoke_token()` 내 `bot_principal_id` 가드를 `token_type == "service"` 가드로 교체한다. `issue_service_token()`, `revoke_service_token()` 메서드를 추가한다. `IssuedBotToken` → `IssuedServiceToken` 교체.
- 변경 대상: `app/services/auth_service.py`
- 완료 조건: `AuthService().issue_service_token(name="test", username="user")` 시그니처 존재. `issue_client_token()`에서 service token AuthContext로 호출 시 ValueError 발생. bot 관련 메서드 미존재.
- 주의사항: `issue_service_token()` 내 unique constraint 위반 시 `psycopg.errors.UniqueViolation` 예외를 `ValueError`로 래핑해야 한다.
## T-6: API 경로 정리
- 구현 방법: `auth_routes.py`에서 `/auth/bots`, `/auth/me/bots` 관련 라우트 핸들러와 헬퍼 함수를 삭제한다. 관련 import를 제거한다. `_to_auth_token_response()`에서 `bot_principal_id` 인자를 제거한다.
- 변경 대상: `app/api/auth_routes.py`
- 완료 조건: FastAPI 앱 기동 후 `/openapi.json`에 `/auth/bots`, `/auth/me/bots` 경로 미노출.
## T-7: CLI 재구현
- 구현 방법: `app/cli/commands/service_tokens.py` 신규 생성 (Design `# 3 T-7` 시그니처 기준). `bot_tokens.py` 파일 삭제. `app/cli/main.py` import 경로 변경. `output.py`에서 `render_bot_result()` → `render_service_token_result()` rename.
- 변경 대상: `app/cli/commands/service_tokens.py` (신규), `app/cli/commands/bot_tokens.py` (삭제), `app/cli/core/output.py`, `app/cli/main.py`
- 완료 조건: `pfhub service-token issue --help` 실행 시 `NAME`, `--user` 인수 노출. help 텍스트에 "bot" 문자열 없음.
- 주의사항: `service_tokens.py`는 `token_app = typer.Typer(...)` 인스턴스를 export해야 한다 (`app/cli/main.py`에서 `token_app`으로 import).
## T-8: 테스트 업데이트
- 구현 방법: bot_principal 관련 픽스처와 테스트 함수를 제거하고 service token 시나리오 테스트를 추가한다. `AuthContext` mock에서 `bot_principal_id` → `token_type` 교체.
- 변경 대상: `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_portfolio_ownership.py`, `tests/test_users.py`
- 완료 조건: `pytest` 실행 결과 0 failures, 0 errors. service token 발급/폐기 시나리오 테스트 포함.
## T-9: 기술 문서 업데이트
- 구현 방법: 4개 문서에서 "bot_principal", "bot token" 참조를 제거하고 service token 기반 설명으로 갱신한다.
- 변경 대상: `docs/technical/auth-and-token-lifecycle.md`, `docs/technical/api-contracts.md`, `docs/technical/management-cli-operations.md`, `README.md`
- 완료 조건: 4개 문서에서 "bot_principal", "bot token" 잔류 참조 없음. `pfhub service-token` 명령 예제 포함.
# 3. 변경 파일 목록
이번 Implementation에서 생성/수정/삭제되는 파일을 미리 나열한다.
| 파일 경로 | 변경 유형 | 관련 태스크 | 비고 |
|---------|---------|-----------|------|
| `migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py` | 생성 | T-1 | Alembic 마이그레이션 신규 |
| `app/domain/models.py` | 수정 | T-2 | BotPrincipal 삭제, bot_principal_id 필드 삭제 |
| `app/schemas/auth.py` | 수정 | T-2 | bot 스키마 삭제, ServiceToken* 스키마 추가 |
| `app/repositories/portfolio_repository.py` | 수정 | T-2 | bot 메서드 삭제, get_service_token_by_name 추가 |
| `app/repositories/postgres.py` | 수정 | T-3 | ensure_schema DDL 제거, get_token_principal 단순화 |
| `app/core/auth.py` | 수정 | T-4 | AuthContext.token_type 추가, bot_principal_id 삭제 |
| `app/core/security.py` | 수정 | T-4 | build_audit_event 함수 삭제 |
| `app/services/auth_service.py` | 수정 | T-5 | bot 메서드 삭제, issue/revoke_service_token 추가 |
| `app/api/auth_routes.py` | 수정 | T-6 | /auth/bots, /auth/me/bots 라우트 삭제 |
| `app/cli/commands/bot_tokens.py` | 삭제 | T-7 | service_tokens.py로 대체 |
| `app/cli/commands/service_tokens.py` | 생성 | T-7 | 신규 CLI 명령 모듈 |
| `app/cli/core/output.py` | 수정 | T-7 | render_bot_result → render_service_token_result |
| `app/cli/main.py` | 수정 | T-7 | import 경로 변경 |
| `tests/test_auth.py` | 수정 | T-8 | bot 테스트 삭제, service token 테스트 추가 |
| `tests/test_cli.py` | 수정 | T-8 | service-token CLI 테스트 업데이트 |
| `tests/test_portfolio_ownership.py` | 수정 | T-8 | token_type 기반 mock으로 교체 |
| `tests/test_users.py` | 수정 | T-8 | bot_principal 참조 제거 (있는 경우) |
| `docs/technical/auth-and-token-lifecycle.md` | 수정 | T-9 | bot → service 전반 업데이트 |
| `docs/technical/api-contracts.md` | 수정 | T-9 | /auth/bots 경로 삭제 |
| `docs/technical/management-cli-operations.md` | 수정 | T-9 | service-token 명령 예제 교체 |
| `README.md` | 수정 | T-9 | CLI 섹션 service-token 예제 추가 |
# 4. 리스크 및 제약
Implementation 단계에서 새로 식별된 리스크와 제약사항을 기록한다.
## 리스크
- R-1 (High, 이월): 마이그레이션 upgrade 전 `bot_principals.owner_user_id IS NULL` 행 존재 시 FK 위반 — upgrade() 사전 검사로 차단.
- R-2 (Medium, 이월): 15개 파일 rename 중 누락 시 런타임 AttributeError — T-8 완료 후 `grep` 잔류 검사.
- R-3 (Medium, 이월): `revoke_service_token(name)` 호출 시 이미 폐기된 토큰 → "찾을 수 없음" 오류 — 설계상 의도된 동작.
- R-4 (Low, 이월): `ensure_schema()` DDL 제거 후 테스트 환경 스키마 캐시로 인한 bot_principals 잔류 — 재실행 검증으로 확인.
- R-5 (Medium): `psycopg.errors.UniqueViolation` 예외를 `ValueError`로 래핑하지 않으면 FastAPI/CLI에서 500 응답 발생.
- 완화 방안: T-5 구현 시 `try/except UniqueViolation → raise ValueError(...)` 처리 포함.
## 가정
- IA-1: `get_user_by_username()` 메서드가 `app/repositories/postgres.py`에 이미 구현되어 있다 (auth_service.login()에서 사용 중으로 확인됨).
- IA-2: `revoke_auth_token(user_id, token_id)` 메서드가 `app/repositories/postgres.py`에 구현되어 있다.
- IA-3: `app/cli/main.py`의 `name="service-token"` 등록이 완료되어 있어 T-7에서 import 경로만 변경하면 된다.
- IA-4: 현재 테스트 환경에서 Docker Compose DB 접속이 가능하다.
## 오픈 질문
없음.
# 5. Verification으로 넘기기 전 확인 체크
- [x] 모든 T-N 태스크 구현 완료
- 기준: T-1~T-9 각 태스크의 완료 조건(DoD)을 충족해야 한다 — **T-1~T-9 모두 완료**
- [x] 변경 파일 목록과 실제 변경 일치
- 기준: 3_design.md의 변경 대상과 실제 변경된 파일이 일치해야 한다 — **일치 확인**
- [x] 단위 테스트 또는 로컬 검증 완료
- 기준: `pytest` 실행 결과 0 failures, 0 errors — **73 passed**
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록에 `Open` 상태 항목이 0개 — 확인 완료
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 담당자 Justin 지정 완료 — 문서 서명 대기
# 6. 파일 처리 결과
- 처리 결과: 생성
- 생성 경로: docs/sdlc/sdlc-002_20260423_service-token-user-based/4_implementation.md
- 참조 문서:
- docs/sdlc/sdlc-002_20260423_service-token-user-based/3_design.md
- docs/sdlc/sdlc-002_20260423_service-token-user-based/2_planning.md
# 7. 사용자 결정 필요 항목 요약
## DECIDE
없음. 모든 구현 결정이 Design에서 완료되었다.
## CONFIRM
- Freeze 확정 담당자(`CONFIRMED_BY`): Justin — confirm-implementation 단계에서 처리
## DATA
- 마이그레이션 실행 전 운영 DB `bot_principals` 행 수 및 `owner_user_id NULL` 행 수 확인 권장 (R-1 완화)
# 8. Implementation Freeze
## 섹션 참조
- 구현 계획: [# 2. 구현 계획](#2-구현-계획)
- 변경 파일 목록: [# 3. 변경 파일 목록](#3-변경-파일-목록)
- 리스크/오픈 질문: [# 4. 리스크 및 제약](#4-리스크-및-제약)
## 확정 플래그
- Handoff Decision: 승인
- Handoff Rationale: T-1~T-9 전체 태스크 완료, 73개 단위 테스트 통과, 변경 파일 목록 일치 확인
- Ready for Verification: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-04-23
@@ -0,0 +1,160 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다.
- 상태: confirmed
- 문서 ID: sdlc-002_20260423_service-token-user-based
- 이슈명: service-token user-based 소유권
- 참조 Implementation: docs/sdlc/sdlc-002_20260423_service-token-user-based/4_implementation.md
- 작성 시각(KST): 2026-04-23
- 마지막 갱신 시각(KST): 2026-04-23
# 1. Implementation 인계 요약
Implementation에서 완료된 핵심 내용을 요약한다. 원본은 4_implementation.md를 참조하며, 이 섹션은 Verification 작업의 입력 기준선이 된다.
## 이슈 목적
`bot_principals` / `auth_audit_logs` 테이블을 제거하고, service token을 `auth_tokens` 단일 엔티티로 직접 발급하도록 재구현한다. CLI `pfhub service-token issue <name> --user <username>` 명령으로 사용자 기반 소유권을 명확히 한다.
## 구현 완료 태스크
| ID | 태스크 제목 | 변경 파일/함수 | 완료 조건(DoD) |
|----|-----------|--------------|--------------|
| T-1 | DB 마이그레이션 파일 작성 | `migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py` | `alembic upgrade head` 후 bot_principals/auth_audit_logs DROP, token_type 보정, UNIQUE INDEX 생성. downgrade 복원 가능 |
| T-2 | 도메인/스키마/인터페이스 정리 | `app/domain/models.py`, `app/schemas/auth.py`, `app/repositories/portfolio_repository.py` | BotPrincipal 미존재, ServiceTokenIssueCliResponse import 가능, bot 메서드 미존재 |
| T-3 | postgres.py 구현체 정리 | `app/repositories/postgres.py` | ensure_schema() 재실행 후 bot_principals/auth_audit_logs 미생성, get_service_token_by_name() 존재 |
| T-4 | AuthContext/security.py 변경 | `app/core/auth.py`, `app/core/security.py` | AuthContext.token_type 사용 가능, bot_principal_id 없음, build_audit_event ImportError |
| T-5 | AuthService 재구현 | `app/services/auth_service.py` | issue_service_token() 시그니처 존재, bot 메서드 미존재 |
| T-6 | API 경로 정리 | `app/api/auth_routes.py` | /auth/bots, /auth/me/bots 경로 미노출 |
| T-7 | CLI 재구현 | `app/cli/commands/service_tokens.py` (신규), `app/cli/core/output.py`, `app/cli/main.py` | pfhub service-token issue --help에 NAME, --user 노출, bot 문자열 없음 |
| T-8 | 테스트 업데이트 | `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_portfolio_ownership.py`, `tests/test_users.py` | pytest 결과 0 failures, 0 errors, service token 시나리오 테스트 포함 |
| T-9 | 기술 문서 업데이트 | `docs/technical/auth-and-token-lifecycle.md`, `docs/technical/api-contracts.md`, `docs/technical/management-cli-operations.md`, `README.md` | 4개 문서에서 bot_principal/bot token 잔류 참조 없음, pfhub service-token 예제 포함 |
## 이월된 오픈 질문 / 리스크
없음. Implementation 단계에서 모든 OQ 해소 완료.
# 2. 검증 계획
Discovery `# 7` 성공 기준(S-N)을 기반으로 Verification 항목을 정의한다.
## 검증 범위
- 기능 요구사항(FR-1~FR-7) 전체 충족 여부
- 비기능 요구사항(NFR-1~NFR-3) 충족 여부 (테스트 통과, 마이그레이션 양방향, CLI 잔류 참조 없음)
- Discovery 성공 기준(S-1~S-3) 연결 확인
## 검증 항목
| ID | 검증 항목 | 검증 방법 | 성공 기준 | 담당자 |
|----|---------|---------|---------|--------|
| VC-1 | 단위 테스트 전체 통과 | 자동 | `pytest tests/ -q` 실행 결과 73 passed, 0 failed, 0 errors | copilot |
| VC-2 | CLI service-token 명령 정상 노출 | 수동 | `pfhub service-token --help` 출력에 `issue`, `revoke` 노출. `NAME`, `--user` 인수 표시. "bot" 문자열 없음 | copilot |
| VC-3 | bot 관련 코드 잔류 없음 | 자동 | `grep -r "BotPrincipal\|bot_principal_id\|bot_principals" app/` 결과 0건 (마이그레이션 파일 제외) | copilot |
| VC-4 | service token 발급 시 user_id 직접 저장 | 자동 | test_issue_service_token_defaults_to_365_days 통과. 발급 토큰의 user_id가 users.id와 일치 | copilot |
| VC-5 | service token name 중복 시 오류 반환 | 자동 | `app/services/auth_service.py` L144~145에 `psycopg.errors.UniqueViolation` catch 존재 확인. 코드 리뷰로 처리 경로 확인. DB 제약 강제는 VC-8(migration UNIQUE INDEX)이 담당 | copilot |
| VC-6 | --user 필수 옵션 강제 | 자동 | `pfhub service-token issue nightly` (--user 생략) 실행 시 exit code != 0 | copilot |
| VC-7 | /auth/bots 경로 미노출 확인 | 자동 | FastAPI openapi.json에 `/auth/bots`, `/auth/me/bots` 경로 미포함 | copilot |
| VC-8 | 마이그레이션 파일 upgrade 실행 가능 확인 | 수동 | `alembic upgrade head` 실행 오류 없음. bot_principals, auth_audit_logs 테이블 DROP 확인 | Justin |
| VC-9 | 마이그레이션 downgrade 복원 가능 확인 | 수동 | `alembic downgrade -1` 실행 오류 없음. bot_principals 테이블 재생성 확인 | Justin |
| VC-10 | auth_tokens.user_id FK 정합성 확인 | 수동 | 마이그레이션 후 `SELECT COUNT(*) FROM auth_tokens WHERE user_id NOT IN (SELECT id FROM users)` 결과 0건 | Justin |
## 성공 기준 연결
Discovery의 성공 기준(S-N)과 검증 항목(VC-N)의 연결을 명시한다.
- S-1 (pytest 전체 통과) → VC-1, VC-4, VC-5, VC-6
- S-2 (CLI service-token 명령 정상 노출) → VC-2, VC-6
- S-3 (마이그레이션 후 auth_tokens.user_id FK 정합성) → VC-8, VC-9, VC-10
FR/NFR 연결:
- FR-1 (bot 관련 코드/테이블 제거) → VC-3, VC-7, VC-8
- FR-2 (user_id 직접 저장) → VC-4, VC-10
- FR-3 (마이그레이션 user_id 보정) → VC-8, VC-10
- FR-4 (get_token_principal 단순화) → VC-1, VC-3
- FR-5 (service token name 유니크) → VC-5
- FR-6 (--user username 조회, 없으면 오류) → VC-4, VC-5
- FR-7 (--user 필수) → VC-6
- NFR-1 (기존 테스트 통과) → VC-1
- NFR-2 (마이그레이션 양방향) → VC-8, VC-9
- NFR-3 (CLI bot 참조 없음) → VC-2, VC-3
# 3. 검증 결과
검증 수행 후 각 항목의 결과를 기록한다.
| ID | 검증 항목 | 결과 | 비고 |
|----|---------|------|------|
| VC-1 | 단위 테스트 전체 통과 | 통과 | `pytest tests/ -q`: 73 passed, 0 failed, 0 errors |
| VC-2 | CLI service-token 명령 정상 노출 | 통과 | `pfhub service-token issue --help`: NAME, --user 노출. "bot" 문자열 없음 |
| VC-3 | bot 관련 코드 잔류 없음 | 통과 | `grep -r "BotPrincipal\|bot_principal_id\|bot_principals" app/` 결과 0건 |
| VC-4 | service token 발급 시 user_id 직접 저장 | 통과 | test_issue_service_token_defaults_to_365_days 포함 73 passed |
| VC-5 | service token name 중복 시 오류 반환 | 통과 | `auth_service.py` L144~145 UniqueViolation catch 코드 리뷰 확인. DB 제약은 VC-8(upgrade 검증) 의존 |
| VC-6 | --user 필수 옵션 강제 | 통과 | `pfhub service-token issue nightly` (--user 생략): exit code 2, "Missing option '--user'" |
| VC-7 | /auth/bots 경로 미노출 확인 | 통과 | OpenAPI paths에 bot 포함 경로 0건 확인 |
| VC-8 | 마이그레이션 파일 upgrade 실행 가능 확인 | 미완료 | 라이브 DB 환경 필요 |
| VC-9 | 마이그레이션 downgrade 복원 가능 확인 | 미완료 | 라이브 DB 환경 필요 |
| VC-10 | auth_tokens.user_id FK 정합성 확인 | 미완료 | 라이브 DB 환경 필요 |
## 전체 검증 결과
- 통과: 7 / 10
- 실패/블로킹: 0
- 미완료(수동/DB 필요): 3 (VC-8, VC-9, VC-10)
- 검증 완료 여부: false (VC-8~VC-10 수동 검증 대기)
# 4. 결함 및 재작업 사항
검증 중 발견된 결함과 재작업이 필요한 항목을 기록한다.
- BUG-1: 없음
# 5. 리스크 및 오픈 질문
Verification 단계에서 새로 식별된 리스크와 오픈 질문을 기록한다.
## 리스크
- R-1 (High, 이월): 마이그레이션 upgrade 전 `bot_principals.owner_user_id IS NULL` 행 존재 시 FK 위반 — upgrade() 사전 검사로 차단. 운영 DB 실행 전 NULL 행 수 확인 권장.
- R-2 (Medium, 이월): VC-8~VC-10은 실제 DB 환경(Docker Compose)이 필요한 수동 검증 항목이다 — 로컬 Docker Compose 환경 기동 후 검증 수행.
## 오픈 질문
없음.
# 6. Release로 넘기기 전 확인 체크
Release 단계로 넘기기 전에 무엇이 완료되어야 하는지 확인한다.
- [ ] 모든 검증 항목(VC-N) 통과
- 기준: VC-1~VC-10 모든 항목이 "통과" 상태여야 한다
- [ ] 결함(BUG) `Open` 상태 없음
- 기준: BUG 목록에 `Open` 상태 항목이 0개여야 한다
- [ ] 성공 기준(S-N) 전체 충족
- 기준: S-1(VC-1), S-2(VC-2, VC-6), S-3(VC-8, VC-10) 모두 통과
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록에 `Open` 상태 항목이 0개 — 현재 없음
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 담당자 Justin 지정 완료 — 문서 서명 대기
# 7. 파일 처리 결과
이 Verification 문서 생성/갱신 결과와 참조 문서를 기록한다.
- 처리 결과: 생성
- 생성 경로: docs/sdlc/sdlc-002_20260423_service-token-user-based/5_verification.md
- 참조 문서:
- docs/sdlc/sdlc-002_20260423_service-token-user-based/4_implementation.md
- docs/sdlc/sdlc-002_20260423_service-token-user-based/1_discovery.md
# 8. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다.
## DECIDE
없음.
## CONFIRM
- Freeze 확정 담당자(`CONFIRMED_BY`): Justin — confirm-verification 단계에서 처리
## DATA
- VC-8~VC-10 수동 검증은 로컬 Docker Compose DB 환경(`docker compose -f docker-compose.local-test.yml up -d`) 기동 후 수행
- 운영 DB 마이그레이션 전 `SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL` 결과 0건 확인 권장 (R-1)
# 9. Verification Freeze
Release 인계 시점의 확정 플래그와 섹션 참조만 기록한다.
## 섹션 참조
- 검증 계획: [# 2. 검증 계획](#2-검증-계획)
- 검증 결과: [# 3. 검증 결과](#3-검증-결과)
- 결함/재작업: [# 4. 결함 및 재작업 사항](#4-결함-및-재작업-사항)
## 확정 플래그
- Handoff Decision: 승인
- Handoff Rationale: 검증 완료 및 결함 없음 확인 후 Release로 인계
- Ready for Release: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-04-23
@@ -0,0 +1,164 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: sdlc-002_20260423_service-token-user-based
- 이슈명: service-token user-based 소유권
- 참조 Verification: docs/sdlc/sdlc-002_20260423_service-token-user-based/5_verification.md
- 작성 시각(KST): 2026-04-23
- 마지막 갱신 시각(KST): 2026-04-23
# 1. Verification 인계 요약
Verification에서 완료된 핵심 내용을 요약한다. 원본은 5_verification.md를 참조하며, 이 섹션은 Release 작업의 입력 기준선이 된다.
## 이슈 목적
`bot_principals` / `auth_audit_logs` 테이블을 제거하고, service token을 `auth_tokens` 단일 엔티티로 직접 발급하도록 재구현한다. CLI `pfhub service-token issue <name> --user <username>` 명령으로 사용자 기반 소유권을 명확히 한다.
## 검증 결과 요약
- 검증 항목 수: 10개
- 전체 통과 여부: false (VC-8~VC-10 수동 DB 검증 미완료)
- 자동 검증 통과: 7 / 10 (VC-1~VC-7)
- 결함(BUG) 잔여: 0건
## 이월된 오픈 질문 / 리스크
Verification에서 이월된 항목:
- **VC-8 (이월)**: `alembic upgrade head` 실행 가능 여부 — 라이브 DB 환경 미기동으로 미검증. 배포 전 로컬 Docker Compose 환경에서 Justin이 수행해야 한다.
- **VC-9 (이월)**: `alembic downgrade -1` 복원 가능 여부 — 동일. 배포 전 Justin이 수행.
- **VC-10 (이월)**: `auth_tokens.user_id FK` 정합성 쿼리 — 동일. 배포 전 Justin이 수행.
- **R-1 (이월, High)**: 운영 DB에 `bot_principals.owner_user_id IS NULL` 행이 존재하면 마이그레이션 upgrade 시 FK 위반 발생 가능 — upgrade 실행 전 `SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL` 결과 0건 확인 필수.
# 2. 배포 준비
배포 환경, 배포 전 체크리스트, 배포 순서를 정의한다.
## 배포 환경
- 대상 환경: production (Linux/Ubuntu 서버, systemd + uvicorn + Caddy)
- 배포 방식: 수동 — git pull 또는 파일 복사 후 마이그레이션 실행 및 서비스 재시작
- 배포 도구/채널: git, alembic, systemctl (CI/CD 파이프라인 없음)
## 배포 전 체크리스트
배포 실행 전에 확인해야 할 항목을 나열한다.
- [ ] 로컬 Docker Compose 환경에서 VC-8 (alembic upgrade head) 성공 확인
- [ ] 로컬 Docker Compose 환경에서 VC-9 (alembic downgrade -1) 성공 확인
- [ ] 로컬 Docker Compose 환경에서 VC-10 (auth_tokens.user_id FK 정합성 쿼리) 0건 확인
- [ ] 운영 DB에서 `SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL` 결과 0건 확인 (R-1)
- [ ] 운영 DB 전체 백업 완료
- [ ] 배포 대상 서버에서 `git log --oneline -5` 로 현재 배포 커밋 해시 기록
- [ ] 롤백 계획 확인 완료
## 배포 순서
변경 사항의 배포 순서를 적는다. 의존 관계가 있는 경우 순서를 명확히 기술한다.
1. **코드 업데이트**: 배포 서버에서 `git pull` 또는 소스 파일 배포 (마이그레이션 파일 포함)
2. **패키지 재설치 확인**: 이번 변경 사항에 의존성 추가 없음 — 생략
3. **DB 마이그레이션**: `alembic upgrade head` — `bot_principals`, `auth_audit_logs` DROP, token_type 보정, UNIQUE INDEX 생성
4. **서비스 재시작**: `sudo systemctl restart portfoliohub` (또는 프로세스 매니저에 따라 uvicorn 재기동)
5. **배포 후 확인**: 스모크 테스트 수행 (`GET /health`, CLI --help, OpenAPI 확인)
# 3. 롤백 계획
배포 후 문제가 발생했을 때 이전 상태로 되돌리는 방법을 기술한다.
## 롤백 조건
아래 조건 중 하나라도 충족되면 롤백을 즉시 실행한다.
- 배포 후 `GET /health` 응답이 200이 아닌 경우
- `pfhub service-token issue --help` 실행 시 오류 발생 또는 NAME/--user 미노출
- 인증 관련 API (`/auth/login`, `/auth/tokens`) 오류율 급증 (5분 내 비정상 응답 3회 이상)
- 마이그레이션 upgrade 실패 또는 롤백 불가 오류 발생
## 롤백 절차
1. **서비스 중단**: `sudo systemctl stop portfoliohub`
2. **DB 마이그레이션 롤백**: `alembic downgrade -1` — `bot_principals`, `auth_audit_logs` 테이블 재생성, token_type 복원, UNIQUE INDEX DROP
3. **코드 롤백**: `git checkout <이전 커밋 해시>` 또는 이전 소스 파일 복원
4. **서비스 재기동**: `sudo systemctl start portfoliohub`
5. **롤백 확인**: `GET /health` 200 응답 확인
## 롤백 담당자
- Justin
# 4. 배포 후 확인
배포 완료 후 정상 동작을 확인하는 방법을 기술한다.
## 스모크 테스트 항목
| 항목 | 확인 방법 | 성공 기준 |
|------|---------|---------|
| 헬스 체크 정상 응답 | `curl -s http://localhost:8000/health` | HTTP 200, `{"status":"ok"}` |
| CLI service-token issue 명령 노출 | `pfhub service-token issue --help` | NAME, --user 노출. "bot" 문자열 없음. exit 0 |
| CLI --user 필수 옵션 강제 | `pfhub service-token issue nightly` (--user 생략) | exit 2, "Missing option '--user'" 출력 |
| /auth/bots 경로 미노출 | `curl -s http://localhost:8000/openapi.json \| python3 -m json.tool \| grep bot` | 출력 없음 (0건) |
| DB 마이그레이션 반영 | `psql -c "\dt bot_principals"` | 테이블 없음 (`Did not find any relation named "bot_principals"`) |
## 모니터링 확인 항목
배포 후 일정 시간 동안 모니터링해야 하는 지표나 로그를 기록한다.
- `journalctl -u portfoliohub -f`: 배포 직후 30분간 ERROR/CRITICAL 로그 없음 확인
- `/auth/login`, `/auth/tokens` 엔드포인트 정상 응답 유지 확인 (1시간)
- `auth_tokens` 테이블 `token_type = 'service'` 행 존재 여부: `SELECT COUNT(*) FROM auth_tokens WHERE token_type = 'service'` 결과 확인 (service token 발급 이력이 있는 경우에 한해 수행)
# 5. 리스크 및 오픈 질문
Release 단계에서 새로 식별된 리스크와 오픈 질문을 기록한다.
## 리스크
- R-1 (High, 이월): 운영 DB에 `bot_principals.owner_user_id IS NULL` 행 존재 시 마이그레이션 upgrade FK 위반
- 완화 방안: 배포 전 체크리스트에 사전 쿼리 확인 포함. NULL 행 존재 시 배포 중단 후 수동 정리.
- R-2 (Medium, 이월): VC-8~VC-10 수동 검증 미완료 상태로 배포 진행 시 DB 변경 미검증 위험
- 완화 방안: 배포 전 로컬 Docker Compose 환경에서 Justin이 VC-8~VC-10 완료 후 배포 진행.
## 오픈 질문
없음.
# 6. Operations로 넘기기 전 확인 체크
Operations 단계로 넘기기 전에 무엇이 완료되어야 하는지 확인한다.
- [ ] 배포 완료 및 스모크 테스트 통과
- 기준: `# 4` 스모크 테스트 항목 전 항목이 성공 기준을 충족하였는가
- [ ] 롤백 계획 확정
- 기준: 롤백 조건, 절차, 담당자(`REL_ROLLBACK_OWNER`)가 모두 명시되었는가
- [ ] 배포 전 VC-8~VC-10 수동 검증 완료 확인
- 기준: Justin이 로컬 Docker Compose 환경에서 VC-8, VC-9, VC-10 모두 통과 확인
- [ ] R-1 사전 쿼리 확인 완료
- 기준: `SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL` 결과 0건
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록에 `Open` 상태 항목이 0개 — 현재 없음
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 담당자 Justin 지정 완료 — confirm-release 단계에서 처리
# 7. 파일 처리 결과
이 Release 문서 생성/갱신 결과와 참조 문서를 기록한다.
- 처리 결과: 생성
- 생성 경로: docs/sdlc/sdlc-002_20260423_service-token-user-based/6_release.md
- 참조 문서:
- docs/sdlc/sdlc-002_20260423_service-token-user-based/5_verification.md
- docs/sdlc/sdlc-002_20260423_service-token-user-based/4_implementation.md
- docs/technical/deployment-and-operations.md
# 8. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다.
## DECIDE
없음.
## CONFIRM
- Freeze 확정 담당자(`CONFIRMED_BY`): Justin — confirm-release 단계에서 처리
## DATA
- 롤백 담당자(`REL_ROLLBACK_OWNER`): 지정 필요 (Justin 또는 담당 운영자)
- 배포 전 VC-8~10: 로컬 Docker Compose 환경(`docker compose -f docker-compose.local-test.yml up -d`) 기동 후 수행
- 운영 DB 사전 쿼리: `SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL` — 0건이어야 배포 진행 가능
# 9. Release Freeze
Operations 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 배포 준비: [# 2. 배포 준비](#2-배포-준비)
- 롤백 계획: [# 3. 롤백 계획](#3-롤백-계획)
- 배포 후 확인: [# 4. 배포 후 확인](#4-배포-후-확인)
## 확정 플래그
- Handoff Decision: 승인
- Handoff Rationale: 배포 완료 및 스모크 테스트 통과 후 Operations로 인계
- Ready for Operations: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-04-23
@@ -0,0 +1,148 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: sdlc-002_20260423_service-token-user-based
- 이슈명: service-token user-based 소유권
- 참조 Release: docs/sdlc/sdlc-002_20260423_service-token-user-based/6_release.md
- 작성 시각(KST): 2026-04-23
- 마지막 갱신 시각(KST): 2026-04-23
# 1. Release 인계 요약
Release에서 완료된 핵심 내용을 요약한다. 원본은 6_release.md를 참조하며, 이 섹션은 Operations 작업의 입력 기준선이 된다.
## 이슈 목적
`bot_principals` / `auth_audit_logs` 테이블을 제거하고, service token을 `auth_tokens` 단일 엔티티로 직접 발급하도록 재구현한다. CLI `pfhub service-token issue <name> --user <username>` 명령으로 사용자 기반 소유권을 명확히 한다.
## 배포 결과 요약
- 배포 환경: production (Linux/Ubuntu, systemd + uvicorn + Caddy)
- 배포 완료 시각(KST): {{OPS_DEPLOYED_AT:TBD}}
- 스모크 테스트 결과: 미완료
## 이월된 오픈 질문 / 리스크
Release에서 이월된 항목:
- **VC-8~VC-10 (이월)**: 로컬 Docker Compose 환경 기동 후 Justin이 alembic upgrade/downgrade 및 FK 정합성 쿼리 수동 검증 필요. 배포 전 완료 목표.
- **R-1 (이월, High)**: 운영 DB `bot_principals.owner_user_id IS NULL` 행 존재 시 마이그레이션 FK 위반 가능 — 배포 전 사전 쿼리 확인으로 차단.
# 2. 운영 모니터링
배포 후 지속적으로 모니터링해야 하는 항목과 기준을 정의한다.
## 모니터링 항목
| 항목 | 확인 방법 | 임계값 / 이상 기준 | 대응 방안 |
|------|---------|----------------|---------|
| 서비스 오류 로그 | `journalctl -u portfoliohub --since "1 hour ago" \| grep -E "ERROR\|CRITICAL"` | ERROR/CRITICAL 로그 1건 이상 발생 | 스택 트레이스 확인 후 장애 대응 절차 진입 |
| 헬스 체크 응답 | `curl -s http://localhost:8000/health` | HTTP 200 외 응답 또는 응답 없음 | systemd 서비스 상태 확인 (`systemctl status portfoliohub`) 후 재기동 또는 롤백 |
| 인증 API 정상 응답 | `curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/auth/login -X POST -d '{}'` | 5분 내 비정상 응답(4xx 외 500+) 3회 이상 | 오류 로그 확인 후 롤백 판단 |
| DB 마이그레이션 상태 | `alembic current` | 최신 리비전(`20260423_0013`) 외 값 표시 | 마이그레이션 재실행 또는 롤백 판단 |
| bot_principals 테이블 미존재 유지 | `psql -c "\dt bot_principals"` | 테이블이 재생성된 경우 | 마이그레이션 상태 확인 후 이상 여부 조사 |
## 모니터링 담당자
- Justin
# 3. 운영 절차 (런북)
이 변경 사항과 관련된 운영 중 발생할 수 있는 상황별 대응 절차를 정의한다.
## 정상 운영 확인 절차
배포 후 일정 주기로 정상 동작을 확인하는 절차를 기술한다.
1. **일 1회** — `journalctl -u portfoliohub --since "24 hours ago" | grep -E "ERROR|CRITICAL"` 실행. 결과 없음 확인.
2. **일 1회** — `curl -s http://localhost:8000/health` 실행. `{"status":"ok"}` 응답 확인.
3. **주 1회** — `alembic current` 실행. 최신 리비전(`20260423_0013`) 적용 상태 확인.
4. **배포 후 첫 1주** — `pfhub service-token issue --help` 실행. NAME, --user 정상 노출 확인.
## 장애 대응 절차
이 변경과 관련된 장애가 발생했을 때의 초기 대응 절차를 기술한다.
1. **오류 확인**: `journalctl -u portfoliohub -n 100 --no-pager` 실행. 스택 트레이스 및 오류 메시지 수집.
2. **헬스 체크**: `curl -s http://localhost:8000/health` 실행. 응답 여부 확인.
3. **서비스 재기동 시도**: `sudo systemctl restart portfoliohub`. 재기동 후 헬스 체크 재실행.
4. **롤백 판단**: 재기동 후에도 오류 지속 시 `6_release.md # 3 롤백 조건` 충족 여부 확인.
5. **롤백 실행** (조건 충족 시):
- `sudo systemctl stop portfoliohub`
- `alembic downgrade -1`
- `git checkout <이전 커밋 해시>`
- `sudo systemctl start portfoliohub`
- `curl -s http://localhost:8000/health` 확인
6. **에스컬레이션**: 롤백 후에도 복구 안 되면 에스컬레이션 경로 진입.
## 에스컬레이션 경로
장애 대응 중 에스컬레이션이 필요한 경우 연락 대상을 기록한다.
- {{OPS_ESCALATION_1:TBD}}
# 4. 운영 중 발생한 이슈
운영 중 발견된 이슈를 기록한다. 초안 생성 시 비워 두고 운영 중 갱신한다.
- 없음 (운영 중 발견 시 기록)
# 5. 리스크 및 오픈 질문
Operations 단계에서 새로 식별된 리스크와 오픈 질문을 기록한다.
## 리스크
- R-1 (High, 이월): 운영 DB `bot_principals.owner_user_id IS NULL` 행 존재 시 마이그레이션 upgrade FK 위반
- 완화 방안: 배포 전 체크리스트 쿼리(`SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL`) 결과 0건 확인 후 배포. NULL 행 발견 시 수동 정리 후 진행.
- R-2 (Medium, 이월): VC-8~10 수동 DB 검증 미완료 상태로 배포 시 검증되지 않은 DB 변경 적용 위험
- 완화 방안: 로컬 Docker Compose 환경에서 Justin이 VC-8~10 완료 확인 후 운영 배포 진행.
## 오픈 질문
없음.
# 6. 다음 Discovery 환류
운영 중 발견된 인사이트와 개선 사항을 다음 SDLC Discovery의 입력으로 기록한다.
- 없음 (운영 중 갱신 예정)
# 7. 확인 체크
Operations 문서가 완성되었는지 확인한다.
- [ ] 모니터링 항목 및 임계값 정의 완료
- 기준: 모니터링 항목에 임계값과 대응 방안이 명시되어야 한다 — 5개 항목 정의 완료
- [ ] 런북(정상 운영/장애 대응 절차) 정의 완료
- 기준: 운영 중 발생 가능한 상황에 대한 절차가 1단계 이상 있어야 한다 — 정상 4단계, 장애 6단계 정의 완료
- [ ] 에스컬레이션 경로 정의 완료
- 기준: 장애 시 연락할 담당자가 명시되어야 한다 — `{{OPS_ESCALATION_1:TBD}}` 지정 필요
- [ ] 다음 Discovery 환류 항목 작성 (없으면 "없음" 기재)
- 기준: 현재 "없음" 기재 — 운영 중 발견 시 갱신
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 문서 승인 담당자가 명시되어야 한다 — Justin 지정 완료 예정 (confirm-operation 단계에서 처리)
# 8. 파일 처리 결과
이 Operations 문서 생성/갱신 결과와 참조 문서를 기록한다.
- 처리 결과: 생성
- 생성 경로: docs/sdlc/sdlc-002_20260423_service-token-user-based/7_operation.md
- 참조 문서:
- docs/sdlc/sdlc-002_20260423_service-token-user-based/6_release.md
- docs/sdlc/sdlc-002_20260423_service-token-user-based/5_verification.md
- docs/sdlc/sdlc-002_20260423_service-token-user-based/1_discovery.md
# 9. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다.
## DECIDE
없음.
## CONFIRM
- Freeze 확정 담당자(`CONFIRMED_BY`): Justin — confirm-operation 단계에서 처리
## DATA
- 모니터링 담당자(`OPS_MONITOR_OWNER`): Justin 또는 담당 운영자 지정 필요
- 에스컬레이션 연락처(`OPS_ESCALATION_1`): 장애 시 연락할 담당자/채널 지정 필요
- 배포 완료 시각(`OPS_DEPLOYED_AT`): 실제 배포 후 기입
- 스모크 테스트 결과(`OPS_SMOKE_RESULT`): 배포 후 스모크 테스트 수행 결과 기입
# 10. Operations Freeze
이 SDLC 주기의 최종 확정 플래그를 기록한다.
## 섹션 참조
- 운영 모니터링: [# 2. 운영 모니터링](#2-운영-모니터링)
- 런북: [# 3. 운영 절차 런북](#3-운영-절차-런북)
- 다음 Discovery 환류: [# 6. 다음 Discovery 환류](#6-다음-discovery-환류)
## 확정 플래그
- Handoff Decision: 승인
- Handoff Rationale: 모니터링 안정화 및 런북 완비 후 주기 완료
- SDLC 주기 완료: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-04-23
@@ -3,10 +3,10 @@
| 단계 | 문서 | 상태 |
|------|------|------|
| 1. Discovery | [1_discovery.md](./1_discovery.md) | `confirmed` |
| 2. Planning | [2_planning.md](./2_planning.md) | `draft` |
| 3. Design | [3_design.md](./3_design.md) | `-` |
| 4. Implementation | [4_implementation.md](./4_implementation.md) | `-` |
| 5. Verification | [5_verification.md](./5_verification.md) | `-` |
| 6. Release | [6_release.md](./6_release.md) | `-` |
| 7. Operations | [7_operations.md](./7_operations.md) | `-` |
| 2. Planning | [2_planning.md](./2_planning.md) | `confirmed` |
| 3. Design | [3_design.md](./3_design.md) | `confirmed` |
| 4. Implementation | [4_implementation.md](./4_implementation.md) | `confirmed` |
| 5. Verification | [5_verification.md](./5_verification.md) | `confirmed` |
| 6. Release | [6_release.md](./6_release.md) | `confirmed` |
| 7. Operations | [7_operations.md](./7_operations.md) | `confirmed` |
| Summary | [summary.md](./summary.md) | `-` |
+9 -53
View File
@@ -30,18 +30,6 @@
- `GET /auth/tokens`
- `POST /auth/tokens`
- `DELETE /auth/tokens/{token_id}`
- `POST /auth/bots`
- `GET /auth/bots`
- `GET /auth/bots/{bot_id}`
- `POST /auth/bots/{bot_id}/tokens`
- `GET /auth/bots/{bot_id}/tokens`
- `DELETE /auth/bots/{bot_id}/tokens/{token_id}`
- `DELETE /auth/bots/{bot_id}`
- `POST /auth/me/bots`
- `GET /auth/me/bots`
- `POST /auth/me/bots/{bot_id}/tokens`
- `GET /auth/me/bots/{bot_id}/tokens`
- `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}`
### 포트폴리오
- `GET /portfolios`
@@ -463,55 +451,23 @@ curl -X POST "http://localhost:8000/auth/tokens" \
-d '{"name": "cli", "permission": "trade-write", "expires_in_days": 30}'
```
#### 봇 주체와 봇 토큰
- `POST /auth/bots`: admin only, `CreateBotPrincipalRequest` -> `BotPrincipalResponse`
- `GET /auth/bots`: admin only, `is_active` 필터 지원
- `GET /auth/bots/{bot_id}`: admin only
- `DELETE /auth/bots/{bot_id}`: admin only, bot 비활성화 및 연결 토큰 revoke, 응답 `200`
- `POST /auth/bots/{bot_id}/tokens`: admin only, `CreateBotTokenRequest` -> `IssuedBotTokenResponse`
- `GET /auth/bots/{bot_id}/tokens`: admin only, `AuthTokenResponse[]`
- `DELETE /auth/bots/{bot_id}/tokens/{token_id}`: admin only, `204`
#### 서비스 토큰 관리 (CLI 전용)
소유권/권한 제약:
서비스 토큰은 CLI를 통해서만 발급/폐기한다. HTTP API 경로는 제공하지 않는다.
- bot principal은 `owner_user_id`를 필수로 가진다.
- bot permission은 `read` 또는 `trade-write`만 허용한다.
- bot 토큰 인증 컨텍스트의 실효 `user_id`는 bot owner(`owner_user_id`)로 해석된다.
#### 사용자 전용(Self-service) 봇 관리
- `POST /auth/me/bots`: DB-issued `read` 이상 사용자 토큰, `CreateSelfBotPrincipalRequest` -> `BotPrincipalResponse`
- `GET /auth/me/bots`: DB-issued `read` 이상 사용자 토큰, 본인 소유 bot 목록
- `POST /auth/me/bots/{bot_id}/tokens`: DB-issued `read` 이상 사용자 토큰, `CreateBotTokenRequest` -> `IssuedBotTokenResponse`
- `GET /auth/me/bots/{bot_id}/tokens`: DB-issued `read` 이상 사용자 토큰, `AuthTokenResponse[]`
- `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}`: DB-issued `read` 이상 사용자 토큰, `204`
- `pfhub service-token issue <name> --user <username> [--permission read|trade-write] [--ttl-days N | --no-expiry]`
- `pfhub service-token revoke <name> [--force]`
제약:
- `/auth/me/bots*` 경로는 요청자 본인 소유 bot(`owner_user_id == auth_context.user_id`)에만 허용된다.
- 타 사용자 소유 bot 접근 시 `403`.
- 서비스 토큰 이름은 전체 서비스 토큰 내에서 유일하다.
- 귀속 사용자(`--user`)가 존재하지 않으면 발급 실패이다.
대표 요청:
대표 실행:
```bash
curl -X POST "http://localhost:8000/auth/bots" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "rebalance-bot", "owner_user_id": "user-1", "permission": "trade-write", "description": "nightly jobs"}'
curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "nightly", "permission": "trade-write", "expires_in_days": 90}'
curl -X POST "http://localhost:8000/auth/me/bots" \
-H "Authorization: Bearer <USER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "my-bot", "permission": "read", "description": "personal automation"}'
curl -X POST "http://localhost:8000/auth/me/bots/bot-1/tokens" \
-H "Authorization: Bearer <USER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "personal-key", "permission": "read", "expires_in_days": 30}'
pfhub service-token issue nightly --user alice --permission trade-write --ttl-days 365
pfhub service-token revoke nightly --force
```
### 사용자
+18 -29
View File
@@ -11,7 +11,7 @@
| --- | --- |
| `read` | 조회 계열 API |
| `trade-write` | 조회 + 포트폴리오 변경 API |
| `admin` | 전체 API + 사용자/봇 관리 |
| `admin` | 전체 API + 사용자/서비스토큰 관리 |
소유권 결합 정책:
@@ -31,51 +31,42 @@
## 인증 소스
- 환경변수 기반 정적 토큰: `PORTFOLIOHUB_*_TOKENS`
- DB 저장 토큰: 로그인 세션/클라이언트 토큰/봇 토큰
- DB 저장 토큰: 로그인 세션 토큰 / 클라이언트 토큰 / 서비스 토큰
봇 토큰 사용자 컨텍스트 정책:
서비스 토큰 사용자 컨텍스트 정책:
- 봇 principal은 `owner_user_id`를 가진다.
- 봇 토큰 인증 시 실효 사용자 식별자는 `bot_principals.owner_user_id`로 해석한다.
- 따라서 소유권이 필요한 포트폴리오 API에서 봇 토큰은 소유 사용자 경계 내에서만 동작한다.
- 서비스 토큰은 특정 사용자(`user_id`)에 귀속되며 `token_type = 'service'`로 식별된다.
- 토큰 이름(`name`)은 서비스 토큰 내에서 유일하며 CLI 관리의 기본 키로 사용된다.
- 소유권이 필요한 포트폴리오 API에서 서비스 토큰은 귀속 사용자 경계 내에서만 동작한다.
## 봇 관리 API 정책
## 서비스 토큰 CLI 관리
- Admin 경로:
- `POST /auth/bots`
- `GET /auth/bots`
- `GET /auth/bots/{bot_id}`
- `DELETE /auth/bots/{bot_id}`
- `POST/GET/DELETE /auth/bots/{bot_id}/tokens*`
- Self-service 경로(사용자 본인 전용):
- `POST /auth/me/bots`
- `GET /auth/me/bots`
- `POST/GET/DELETE /auth/me/bots/{bot_id}/tokens*`
- `pfhub service-token issue <name> --user <username>` — 서비스 토큰 발급 (기본 TTL 365일)
- `pfhub service-token revoke <name>` — 서비스 토큰 폐기
제약:
- self-service 경로는 DB 사용자 컨텍스트가 있는 사용자 토큰에서만 허용한다.
- self-service 경로는 요청자의 소유 봇(`owner_user_id == auth_context.user_id`)에만 허용한다.
- 봇 권한은 `read` 또는 `trade-write`만 허용한다.
- `--ttl-days`와 `--no-expiry` 동시 사용은 거부한다.
- 동일 이름의 서비스 토큰은 중복 발급할 수 없다.
- `--user`로 지정한 사용자가 존재하지 않으면 발급 실패 처리한다.
## 토큰 라이프사이클
1. 사용자 로그인(`POST /auth/login`) 또는 토큰 발급 API 호출
2. 토큰 해시 저장 및 prefix 관리
3. 요청 처리 시 해시 비교와 만료/폐기 상태 검증
4. `DELETE /auth/tokens/{token_id}` 또는 봇 비활성화로 토큰 폐기
4. `DELETE /auth/tokens/{token_id}` 또는 서비스 토큰 폐기(`pfhub service-token revoke`)로 토큰 폐기
CLI 봇 토큰 라이프사이클:
서비스 토큰 라이프사이클:
1. `bot token issue <bot_id>` 실행 시 `--ttl-days`/`--no-expiry` 정책을 먼저 검증한다.
2. 정책 검증 후 서비스 계층에서 권한과 만기 정책을 재검증한다.
3. 발급 성공 시 응답은 마스킹된 토큰만 재출력 가능하며, 원문 토큰은 1회 출력 경로로 제한한다.
4. `bot token revoke <bot_id> <token_id>` 실행 시 확인 프롬프트(`--force`로 생략 가능)를 거쳐 폐기한다.
1. `pfhub service-token issue <name> --user <username>` 실행 시 `--ttl-days`/`--no-expiry` 정책을 먼저 검증한다.
2. 정책 검증 후 서비스 계층에서 사용자 존재 및 이름 중복 여부를 검증한다.
3. 발급 성공 시 원문 토큰은 1회만 출력된다.
4. `pfhub service-token revoke <name>` 실행 시 확인 프롬프트(`--force`로 생략 가능)를 거쳐 폐기한다.
만기 정책:
- 기본 TTL은 365일이다.
- `--ttl-days`와 `--no-expiry` 동시 사용은 거부한다.
- 비관리자 컨텍스트에서는 `--no-expiry`를 거부한다.
감사 로그 정책:
@@ -96,9 +87,7 @@ CLI 봇 토큰 라이프사이클:
## Validation Notes
- 만료된 토큰은 항상 인증 실패로 처리해야 한다.
- 비활성 봇(`is_active=false`)의 토큰은 거부되어야 한다.
- 권한 계층 승격 우회가 없는지 API/CLI 양쪽에서 검증한다.
## Open Implementation Points
- 정적 토큰과 DB 토큰의 우선순위/병합 정책을 명문화할지 결정한다.
- 감사 로그(audit trail)에 토큰 발급/폐기 이벤트를 표준 포맷으로 남길지 확정한다.
@@ -11,6 +11,7 @@
| --- | --- | --- |
| DB 운영 | `init-db`, `migrate-db`, `health-check` | 스키마 적용 및 연결 상태 점검 |
| 사용자 관리 | `user list/get/create/update/delete` | 운영 계정 라이프사이클 |
| 서비스 토큰 관리 | `service-token issue/revoke` | 서비스 통합용 장기 토큰 관리 |
| 포트폴리오 조회 | `portfolio list`, `portfolio status` | 상태 확인 |
| 포트폴리오 변경 | `portfolio create/update/buy/sell/invest/transfer/reset/delete` | 거래/자금/상태 변경 |
@@ -53,6 +54,11 @@
- `portfoliohub strategy update <strategy_set_id> [--name ...] [--config ...]`
- `portfoliohub strategy delete <strategy_set_id> [--force]`
서비스 토큰 하위 명령:
- `portfoliohub service-token issue <name> --user <username> [--permission read|trade-write] [--ttl-days N] [--no-expiry]`
- `portfoliohub service-token revoke <name> [--force]`
## 실행 원칙
- 쓰기 명령은 기본적으로 확인 프롬프트를 띄운다.
- 자동화 스크립트에서는 `--force`를 사용해 비대화식 실행을 보장한다.
@@ -0,0 +1,158 @@
"""remove bot_principals and auth_audit_logs, add service token support
Revision ID: 20260423_0013
Revises: 20260414_0012
Create Date: 2026-04-23 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260423_0013"
down_revision = "20260414_0012"
branch_labels = None
depends_on = None
def _table_exists(inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# 사전 검증: bot_principals.owner_user_id NULL 행이 있으면 중단
if _table_exists(inspector, "bot_principals"):
null_count = bind.execute(
sa.text("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL")
).scalar()
if null_count and int(null_count) > 0:
raise RuntimeError(
f"마이그레이션 중단: bot_principals.owner_user_id NULL 행 {null_count}개 발견. "
"owner_user_id를 먼저 보정한 후 다시 실행하세요."
)
# auth_tokens.user_id 보정: bot_principal.id → bot_principal.owner_user_id
if _column_exists(inspector, "auth_tokens", "bot_principal_id"):
bind.execute(
sa.text(
"""
UPDATE auth_tokens t
SET user_id = b.owner_user_id
FROM bot_principals b
WHERE t.bot_principal_id = b.id
AND t.bot_principal_id IS NOT NULL
"""
)
)
# token_type 'bot' → 'service' rename
bind.execute(
sa.text("UPDATE auth_tokens SET token_type = 'service' WHERE token_type = 'bot'")
)
# service token name unique index 생성
inspector = inspect(bind)
if not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service'
"""
)
# auth_tokens.bot_principal_id 컬럼 DROP
inspector = inspect(bind)
if _column_exists(inspector, "auth_tokens", "bot_principal_id"):
# 기존 인덱스 제거 후 컬럼 DROP
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_bot_created"):
op.drop_index("idx_auth_tokens_bot_created", table_name="auth_tokens")
op.drop_column("auth_tokens", "bot_principal_id")
# auth_audit_logs 테이블 DROP
inspector = inspect(bind)
if _table_exists(inspector, "auth_audit_logs"):
op.drop_table("auth_audit_logs")
# bot_principals 테이블 DROP (관련 인덱스·FK 포함)
inspector = inspect(bind)
if _table_exists(inspector, "bot_principals"):
for idx_name in [
"idx_bot_principals_active_created",
"idx_bot_principals_owner_active_created",
]:
if _index_exists(inspector, "bot_principals", idx_name):
op.drop_index(idx_name, table_name="bot_principals")
op.drop_table("bot_principals")
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# bot_principals 테이블 복원
if not _table_exists(inspector, "bot_principals"):
op.create_table(
"bot_principals",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("owner_user_id", sa.Text(), nullable=True),
sa.Column("permission", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=True, server_default=sa.text("true")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("deactivated_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# auth_audit_logs 테이블 복원
if not _table_exists(inspector, "auth_audit_logs"):
op.create_table(
"auth_audit_logs",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("event_type", sa.Text(), nullable=False),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("actor_id", sa.Text(), nullable=True),
sa.Column("bot_id", sa.Text(), nullable=False),
sa.Column("token_id_masked", sa.Text(), nullable=True),
sa.Column("token_ref_masked", sa.Text(), nullable=True),
sa.Column("result", sa.Text(), nullable=True),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("occurred_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# auth_tokens.bot_principal_id 컬럼 복원
inspector = inspect(bind)
if not _column_exists(inspector, "auth_tokens", "bot_principal_id"):
op.add_column("auth_tokens", sa.Column("bot_principal_id", sa.Text(), nullable=True))
# token_type 'service' → 'bot' 역방향 (구조 복원 목적)
bind.execute(
sa.text("UPDATE auth_tokens SET token_type = 'bot' WHERE token_type = 'service'")
)
# service token unique index 제거
inspector = inspect(bind)
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
op.drop_index("idx_auth_tokens_service_name_unique", table_name="auth_tokens")
+55 -72
View File
@@ -8,9 +8,8 @@ from fastapi.testclient import TestClient
from app.core.auth import AuthContext, Permission
from app.core.config import Settings
from app.core.config import get_settings
from app.domain.models import AuthToken, BotPrincipal
from app.domain.models import AuthToken
from app.main import app
from app.schemas.auth import CreateBotTokenRequest
from app.services.auth_service import AuthService
@@ -75,23 +74,19 @@ def test_admin_route_accepts_admin_token(monkeypatch) -> None:
@dataclass
class _AuthRepo:
fail_audit: bool = False
def __post_init__(self) -> None:
self.bot = BotPrincipal(
id="bot-1",
name="bot",
owner_user_id="owner-1",
permission="trade-write",
is_active=True,
)
self.users: dict[str, object] = {}
self.tokens: dict[str, AuthToken] = {}
self._seq = 1
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
if bot_principal_id != self.bot.id:
raise ValueError("봇을 찾을 수 없습니다")
return self.bot
def get_user_by_username(self, username: str):
return self.users.get(username)
def get_service_token_by_name(self, name: str) -> AuthToken | None:
for token in self.tokens.values():
if token.name == name and token.token_type == "service":
return token
return None
def create_auth_token(
self,
@@ -102,7 +97,6 @@ class _AuthRepo:
token_prefix: str,
permission: str,
token_type: str,
bot_principal_id: str | None = None,
expires_at: datetime | None = None,
) -> AuthToken:
token = AuthToken(
@@ -112,7 +106,6 @@ class _AuthRepo:
token_prefix=token_prefix,
permission=permission,
token_type=token_type,
bot_principal_id=bot_principal_id,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
)
@@ -120,88 +113,78 @@ class _AuthRepo:
self.tokens[token.id] = token
return token
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
token = self.tokens.get(token_id)
if token is None or token.bot_principal_id != bot_principal_id:
if token is None or token.user_id != user_id:
raise ValueError("토큰을 찾을 수 없습니다")
token.revoked_at = datetime.now(timezone.utc)
def record_audit_event(self, payload: dict[str, object]) -> None:
if self.fail_audit:
raise RuntimeError("audit failed")
def _service(repo: _AuthRepo) -> AuthService:
return AuthService(repo, Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000"))
def test_issue_bot_token_defaults_to_365_days() -> None:
def test_issue_service_token_defaults_to_365_days() -> None:
from dataclasses import dataclass as _dc
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
service = _service(repo)
result = service.issue_bot_token("bot-1", CreateBotTokenRequest(name="cli"))
result = service.issue_service_token("my-token", "alice")
assert result.auth_token.expires_at is not None
assert (result.auth_token.expires_at - datetime.now(timezone.utc)).days >= 364
assert result.auth_token.token_type == "service"
assert result.auth_token.name == "my-token"
def test_issue_owned_bot_token_rejects_no_expiry_for_non_admin() -> None:
def test_issue_service_token_no_expiry() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
service = _service(repo)
result = service.issue_service_token("my-token", "alice", no_expiry=True)
assert result.auth_token.expires_at is None
def test_issue_service_token_unknown_user_raises() -> None:
repo = _AuthRepo()
service = _service(repo)
auth_context = AuthContext(
token="db-token",
permission=Permission.TRADE_WRITE,
user_id="owner-1",
token_source="db",
)
try:
service.issue_owned_bot_token(
auth_context,
"bot-1",
CreateBotTokenRequest(name="cli", no_expiry=True),
)
service.issue_service_token("my-token", "unknown")
except ValueError as exc:
assert "비관리자" in str(exc)
assert "unknown" in str(exc)
else:
raise AssertionError("Expected ValueError for non-admin no-expiry")
raise AssertionError("Expected ValueError for unknown user")
def test_issue_bot_token_rolls_back_when_audit_fails() -> None:
repo = _AuthRepo(fail_audit=True)
def test_revoke_service_token_success() -> None:
from app.domain.models import User
repo = _AuthRepo()
repo.users["alice"] = User(id="user-1", username="alice", permission="read")
service = _service(repo)
issued = service.issue_service_token("my-token", "alice")
revoked_at = service.revoke_service_token("my-token")
assert revoked_at is not None
assert repo.tokens[issued.auth_token.id].revoked_at is not None
def test_revoke_service_token_not_found_raises() -> None:
repo = _AuthRepo()
service = _service(repo)
try:
service.issue_bot_token("bot-1", CreateBotTokenRequest(name="cli"))
service.revoke_service_token("nonexistent")
except ValueError as exc:
assert "발급을 취소" in str(exc)
assert "nonexistent" in str(exc)
else:
raise AssertionError("Expected ValueError on audit failure")
assert len(repo.tokens) == 1
token = next(iter(repo.tokens.values()))
assert token.revoked_at is not None
def test_revoke_bot_token_keeps_revoke_when_audit_fails() -> None:
repo = _AuthRepo(fail_audit=True)
service = _service(repo)
created = repo.create_auth_token(
"bot-1",
name="cli",
token_hash="hash",
token_prefix="prefix",
permission="trade-write",
token_type="bot",
bot_principal_id="bot-1",
)
try:
service.revoke_bot_token("bot-1", created.id)
except ValueError as exc:
assert "토큰은 폐기" in str(exc)
else:
raise AssertionError("Expected ValueError on audit failure")
assert repo.tokens[created.id].revoked_at is not None
raise AssertionError("Expected ValueError for missing service token")
+29 -42
View File
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
from app.cli.main import create_app
from app.core.config import Settings
from app.core.security import hash_token, token_prefix
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
class FakeRepo:
@@ -23,15 +23,6 @@ class FakeRepo:
self._user_seq = 1
self._comments: dict[str, PortfolioComment] = {}
self._tags: dict[str, PortfolioTag] = {}
self._bot_principals: dict[str, BotPrincipal] = {
"bot-1": BotPrincipal(
id="bot-1",
name="cli-bot",
owner_user_id="owner-1",
permission="trade-write",
is_active=True,
)
}
self._auth_tokens: dict[str, AuthToken] = {}
self._token_seq = 1
self.closed = False
@@ -115,11 +106,11 @@ class FakeRepo:
raise ValueError("사용자를 찾을 수 없습니다")
del self._users[user_id]
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
bot = self._bot_principals.get(bot_principal_id)
if bot is None:
raise ValueError("봇을 찾을 수 없습니다")
return bot
def get_user_by_username(self, username: str):
return next((u for u in self._users.values() if u.username == username), None)
def get_service_token_by_name(self, name: str) -> AuthToken | None:
return next((t for t in self._auth_tokens.values() if t.name == name and t.token_type == "service"), None)
def create_auth_token(
self,
@@ -130,7 +121,6 @@ class FakeRepo:
token_prefix: str,
permission: str,
token_type: str,
bot_principal_id: str | None = None,
expires_at: datetime | None = None,
) -> AuthToken:
token = AuthToken(
@@ -140,7 +130,6 @@ class FakeRepo:
token_prefix=token_prefix,
permission=permission,
token_type=token_type,
bot_principal_id=bot_principal_id,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
)
@@ -148,16 +137,12 @@ class FakeRepo:
self._auth_tokens[token.id] = token
return token
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
token = self._auth_tokens.get(token_id)
if token is None or token.bot_principal_id != bot_principal_id:
if token is None or token.user_id != user_id:
raise ValueError("토큰을 찾을 수 없습니다")
token.revoked_at = datetime.now(timezone.utc)
def record_audit_event(self, payload: dict[str, object]) -> None:
if payload.get("force_fail"):
raise RuntimeError("audit failure")
def get(self, portfolio_id: str) -> Portfolio:
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
raise ValueError("포트폴리오를 찾을 수 없습니다")
@@ -837,25 +822,26 @@ def test_cli_help_exposes_bot_token_commands() -> None:
assert "revoke" in result.stdout
def test_cli_bot_token_issue_supports_json() -> None:
def test_cli_service_token_issue_supports_json() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--format", "json", "service-token", "issue", "bot-1", "--name", "nightly"],
["--format", "json", "service-token", "issue", "nightly", "--user", "alice"],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["bot_id"] == "bot-1"
assert payload["name"] == "nightly"
assert payload["revoked"] is False
assert payload["expires_at"] is not None
assert payload["token"].endswith("...") is False
def test_cli_bot_token_issue_rejects_conflicting_expiry_options() -> None:
def test_cli_service_token_issue_rejects_conflicting_expiry_options() -> None:
repo = FakeRepo(_portfolio())
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
@@ -863,7 +849,8 @@ def test_cli_bot_token_issue_rejects_conflicting_expiry_options() -> None:
[
"service-token",
"issue",
"bot-1",
"nightly",
"--user", "alice",
"--ttl-days",
"7",
"--no-expiry",
@@ -874,22 +861,22 @@ def test_cli_bot_token_issue_rejects_conflicting_expiry_options() -> None:
assert "동시에 사용할 수 없습니다" in result.stdout
def test_cli_bot_token_revoke_can_abort() -> None:
def test_cli_service_token_revoke_can_abort() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
"bot-1",
user.id,
name="to-revoke",
token_hash=hash_token("token-value"),
token_prefix=token_prefix("token-value"),
permission="trade-write",
token_type="bot",
bot_principal_id="bot-1",
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["service-token", "revoke", "bot-1", token.id],
["service-token", "revoke", "to-revoke"],
input="n\n",
)
@@ -897,25 +884,25 @@ def test_cli_bot_token_revoke_can_abort() -> None:
assert repo._auth_tokens[token.id].revoked_at is None
def test_cli_bot_token_revoke_force_succeeds() -> None:
def test_cli_service_token_revoke_force_succeeds() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
"bot-1",
user.id,
name="to-revoke",
token_hash=hash_token("token-value"),
token_prefix=token_prefix("token-value"),
permission="trade-write",
token_type="bot",
bot_principal_id="bot-1",
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--format", "json", "service-token", "revoke", "bot-1", token.id, "--force"],
["--format", "json", "service-token", "revoke", "to-revoke", "--force"],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["token_id"] == token.id
assert payload["name"] == "to-revoke"
assert payload["revoked"] is True
+1 -2
View File
@@ -137,8 +137,7 @@ def test_owner_bound_bot_token_can_read_owned_portfolio(monkeypatch) -> None:
token_id="t-bot-1",
user_id="owner-1",
permission="read",
token_type="bot",
bot_principal_id="bot-1",
token_type="service",
)
repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={hash_token(token): principal})
client = _configure_client(monkeypatch, repo)
+4 -217
View File
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
import app.api.users as users_api
from app.core.security import hash_password, hash_token
from app.domain.models import AuthToken, BotPrincipal, TokenPrincipal, User
from app.domain.models import AuthToken, TokenPrincipal, User
from app.main import app
from app.core.auth import get_auth_repository
from app.services.auth_service import AuthService, get_auth_service
@@ -19,10 +19,8 @@ class FakeUserRepo:
self.users_by_username: dict[str, User] = {}
self.tokens: dict[str, AuthToken] = {}
self.token_principals: dict[str, TokenPrincipal] = {}
self.bots: dict[str, BotPrincipal] = {}
self.next_id = 1
self.next_token_id = 1
self.next_bot_id = 1
def get_user(self, user_id: str) -> User:
user = self.users.get(user_id)
@@ -114,7 +112,6 @@ class FakeUserRepo:
token_prefix: str,
permission: str,
token_type: str,
bot_principal_id: str | None = None,
expires_at: datetime | None = None,
) -> AuthToken:
now = datetime.now(timezone.utc)
@@ -125,7 +122,6 @@ class FakeUserRepo:
token_prefix=token_prefix,
permission=permission,
token_type=token_type,
bot_principal_id=bot_principal_id,
expires_at=expires_at,
created_at=now,
)
@@ -136,7 +132,6 @@ class FakeUserRepo:
user_id=user_id,
permission=permission,
token_type=token_type,
bot_principal_id=bot_principal_id,
)
return token
@@ -149,6 +144,9 @@ class FakeUserRepo:
raise ValueError("토큰을 찾을 수 없습니다")
token.revoked_at = datetime.now(timezone.utc)
def get_service_token_by_name(self, name: str) -> AuthToken | None:
return next((t for t in self.tokens.values() if t.name == name and t.token_type == "service"), None)
def get_token_principal(self, token_hash_value: str) -> TokenPrincipal | None:
principal = self.token_principals.get(token_hash_value)
if principal is None:
@@ -156,91 +154,11 @@ class FakeUserRepo:
token = self.tokens.get(principal.token_id)
if token is None or token.revoked_at is not None:
return None
if token.bot_principal_id is not None:
bot = self.bots.get(token.bot_principal_id)
if bot is None or not bot.is_active:
return None
if token.expires_at is not None and token.expires_at <= datetime.now(timezone.utc):
return None
token.last_used_at = datetime.now(timezone.utc)
if token.bot_principal_id is not None:
bot = self.get_bot_principal(token.bot_principal_id)
return TokenPrincipal(
token_id=principal.token_id,
user_id=bot.owner_user_id,
permission=principal.permission,
token_type=principal.token_type,
bot_principal_id=principal.bot_principal_id,
)
return principal
def create_bot_principal(
self,
*,
name: str,
owner_user_id: str,
permission: str,
description: str | None = None,
) -> BotPrincipal:
if any(item.name == name for item in self.bots.values()):
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
self.get_user(owner_user_id)
now = datetime.now(timezone.utc)
bot = BotPrincipal(
id=f"bot-{self.next_bot_id}",
name=name,
owner_user_id=owner_user_id,
permission=permission,
description=description,
is_active=True,
created_at=now,
updated_at=now,
)
self.next_bot_id += 1
self.bots[bot.id] = bot
return bot
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
bot = self.bots.get(bot_principal_id)
if bot is None:
raise ValueError("봇을 찾을 수 없습니다")
return bot
def list_bot_principals(
self,
*,
is_active: bool | None = None,
owner_user_id: str | None = None,
) -> list[BotPrincipal]:
items = list(self.bots.values())
if is_active is not None:
items = [item for item in items if item.is_active == is_active]
if owner_user_id is not None:
items = [item for item in items if item.owner_user_id == owner_user_id]
return sorted(items, key=lambda item: item.name)
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
bot = self.get_bot_principal(bot_principal_id)
bot.is_active = False
now = datetime.now(timezone.utc)
bot.deactivated_at = now
bot.updated_at = now
for token in self.tokens.values():
if token.bot_principal_id == bot_principal_id and token.revoked_at is None:
token.revoked_at = now
return bot
def list_bot_auth_tokens(self, bot_principal_id: str) -> list[AuthToken]:
self.get_bot_principal(bot_principal_id)
return [token for token in self.tokens.values() if token.bot_principal_id == bot_principal_id]
def revoke_bot_auth_token(self, bot_principal_id: str, token_id: str) -> None:
self.get_bot_principal(bot_principal_id)
token = self.tokens.get(token_id)
if token is None or token.bot_principal_id != bot_principal_id:
raise ValueError("토큰을 찾을 수 없습니다")
token.revoked_at = datetime.now(timezone.utc)
def _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
@@ -347,134 +265,3 @@ def test_login_and_issue_client_token_flow(monkeypatch) -> None:
assert token_payload["token"]
def test_admin_can_create_bot_and_issue_token(monkeypatch) -> None:
repo = FakeUserRepo()
admin = repo.create_user("admin", password_hash=hash_password("admin123!"), permission="admin")
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "admin", "password": "admin123!"})
assert login_response.status_code == 200
admin_token = login_response.json()["access_token"]
bot_response = client.post(
"/auth/bots",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"name": "market-bot",
"owner_user_id": admin.id,
"permission": "trade-write",
"description": "external execution bot",
},
)
assert bot_response.status_code == 201
bot_id = bot_response.json()["id"]
issue_response = client.post(
f"/auth/bots/{bot_id}/tokens",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "prod-key", "permission": "read", "expires_in_days": 30},
)
app.dependency_overrides.clear()
assert issue_response.status_code == 201
payload = issue_response.json()
assert payload["token"]
assert payload["auth_token"]["token_type"] == "bot"
assert payload["auth_token"]["bot_principal_id"] == bot_id
assert payload["auth_token"]["permission"] == "read"
assert payload["auth_token"]["expires_at"] is not None
def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
repo = FakeUserRepo()
admin = repo.create_user("admin", password_hash=hash_password("admin123!"), permission="admin")
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "admin", "password": "admin123!"})
assert login_response.status_code == 200
admin_token = login_response.json()["access_token"]
bot_response = client.post(
"/auth/bots",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "ops-bot", "owner_user_id": admin.id, "permission": "read"},
)
assert bot_response.status_code == 201
bot_id = bot_response.json()["id"]
issue_response = client.post(
f"/auth/bots/{bot_id}/tokens",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "ops-key", "permission": "read"},
)
assert issue_response.status_code == 201
bot_token = issue_response.json()["token"]
deactivate_response = client.delete(
f"/auth/bots/{bot_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert deactivate_response.status_code == 200
denied_response = client.get("/auth/tokens", headers={"Authorization": f"Bearer {bot_token}"})
app.dependency_overrides.clear()
assert denied_response.status_code == 401
assert denied_response.json()["detail"] == "Invalid token"
def test_user_can_create_and_issue_owned_bot_token(monkeypatch) -> None:
repo = FakeUserRepo()
user = repo.create_user("alice", password_hash=hash_password("alice123!"), permission="trade-write")
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "alice", "password": "alice123!"})
assert login_response.status_code == 200
user_token = login_response.json()["access_token"]
bot_response = client.post(
"/auth/me/bots",
headers={"Authorization": f"Bearer {user_token}"},
json={"name": "alice-bot", "permission": "read", "description": "personal bot"},
)
assert bot_response.status_code == 201
bot_id = bot_response.json()["id"]
assert bot_response.json()["owner_user_id"] == user.id
list_response = client.get("/auth/me/bots", headers={"Authorization": f"Bearer {user_token}"})
assert list_response.status_code == 200
assert [item["id"] for item in list_response.json()] == [bot_id]
issue_response = client.post(
f"/auth/me/bots/{bot_id}/tokens",
headers={"Authorization": f"Bearer {user_token}"},
json={"name": "alice-key", "permission": "read"},
)
app.dependency_overrides.clear()
assert issue_response.status_code == 201
payload = issue_response.json()
assert payload["auth_token"]["token_type"] == "bot"
assert payload["auth_token"]["bot_principal_id"] == bot_id
def test_user_cannot_issue_token_for_other_users_bot(monkeypatch) -> None:
repo = FakeUserRepo()
owner = repo.create_user("owner", password_hash=hash_password("owner123!"), permission="trade-write")
other = repo.create_user("other", password_hash=hash_password("other123!"), permission="trade-write")
bot = repo.create_bot_principal(name="owner-bot", owner_user_id=owner.id, permission="read")
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "other", "password": "other123!"})
assert login_response.status_code == 200
other_token = login_response.json()["access_token"]
issue_response = client.post(
f"/auth/me/bots/{bot.id}/tokens",
headers={"Authorization": f"Bearer {other_token}"},
json={"name": "invalid-key", "permission": "read"},
)
app.dependency_overrides.clear()
assert issue_response.status_code == 403
assert issue_response.json()["detail"] == "봇 접근 권한이 없습니다"