작업 내용 저장
This commit is contained in:
@@ -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` 값으로 사용한다.
|
||||
승인 불가 보고를 받으면 → [블로킹 처리](#블로킹-처리)
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
@@ -145,167 +118,3 @@ def revoke_token(
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -60,28 +58,3 @@ def mask_secret(value: str, *, prefix: int = 6, suffix: int = 4, mask_char: str
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) | `-` |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 사용자
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"] == "봇 접근 권한이 없습니다"
|
||||
Reference in New Issue
Block a user