작업 내용 저장
This commit is contained in:
+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:
|
||||
@@ -144,168 +117,4 @@ def revoke_token(
|
||||
service.revoke_token(auth_context, token_id)
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/bots", response_model=BotPrincipalResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_admin)])
|
||||
def create_bot_principal(
|
||||
payload: CreateBotPrincipalRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> BotPrincipalResponse:
|
||||
try:
|
||||
return _to_bot_principal_response(service.create_bot_principal(payload))
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.post("/me/bots", response_model=BotPrincipalResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_read)])
|
||||
def create_owned_bot_principal(
|
||||
payload: CreateSelfBotPrincipalRequest,
|
||||
auth_context: AuthContext = Depends(require_read),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> BotPrincipalResponse:
|
||||
try:
|
||||
return _to_bot_principal_response(service.create_owned_bot_principal(auth_context, payload))
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.get("/me/bots", response_model=list[BotPrincipalResponse], dependencies=[Depends(require_read)])
|
||||
def list_owned_bot_principals(
|
||||
auth_context: AuthContext = Depends(require_read),
|
||||
is_active: bool | None = None,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> list[BotPrincipalResponse]:
|
||||
try:
|
||||
return [
|
||||
_to_bot_principal_response(bot)
|
||||
for bot in service.list_owned_bot_principals(auth_context, is_active=is_active)
|
||||
]
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/me/bots/{bot_id}/tokens",
|
||||
response_model=IssuedBotTokenResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_read)],
|
||||
)
|
||||
def issue_owned_bot_token(
|
||||
bot_id: str,
|
||||
payload: CreateBotTokenRequest,
|
||||
auth_context: AuthContext = Depends(require_read),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> IssuedBotTokenResponse:
|
||||
try:
|
||||
return _to_issued_bot_token_response(service.issue_owned_bot_token(auth_context, bot_id, payload))
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.get("/me/bots/{bot_id}/tokens", response_model=list[AuthTokenResponse], dependencies=[Depends(require_read)])
|
||||
def list_owned_bot_tokens(
|
||||
bot_id: str,
|
||||
auth_context: AuthContext = Depends(require_read),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> list[AuthTokenResponse]:
|
||||
try:
|
||||
return [_to_auth_token_response(token) for token in service.list_owned_bot_tokens(auth_context, bot_id)]
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/me/bots/{bot_id}/tokens/{token_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_read)],
|
||||
)
|
||||
def revoke_owned_bot_token(
|
||||
bot_id: str,
|
||||
token_id: str,
|
||||
auth_context: AuthContext = Depends(require_read),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> Response:
|
||||
try:
|
||||
service.revoke_owned_bot_token(auth_context, bot_id, token_id)
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/bots", response_model=list[BotPrincipalResponse], dependencies=[Depends(require_admin)])
|
||||
def list_bot_principals(
|
||||
is_active: bool | None = None,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> list[BotPrincipalResponse]:
|
||||
try:
|
||||
return [_to_bot_principal_response(bot) for bot in service.list_bot_principals(is_active=is_active)]
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}", response_model=BotPrincipalResponse, dependencies=[Depends(require_admin)])
|
||||
def get_bot_principal(
|
||||
bot_id: str,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> BotPrincipalResponse:
|
||||
try:
|
||||
return _to_bot_principal_response(service.get_bot_principal(bot_id))
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.delete("/bots/{bot_id}", response_model=BotPrincipalResponse, dependencies=[Depends(require_admin)])
|
||||
def deactivate_bot_principal(
|
||||
bot_id: str,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> BotPrincipalResponse:
|
||||
try:
|
||||
return _to_bot_principal_response(service.deactivate_bot_principal(bot_id))
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/bots/{bot_id}/tokens",
|
||||
response_model=IssuedBotTokenResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def issue_bot_token(
|
||||
bot_id: str,
|
||||
payload: CreateBotTokenRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> IssuedBotTokenResponse:
|
||||
try:
|
||||
return _to_issued_bot_token_response(service.issue_bot_token(bot_id, payload))
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.get("/bots/{bot_id}/tokens", response_model=list[AuthTokenResponse], dependencies=[Depends(require_admin)])
|
||||
def list_bot_tokens(
|
||||
bot_id: str,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> list[AuthTokenResponse]:
|
||||
try:
|
||||
return [_to_auth_token_response(token) for token in service.list_bot_tokens(bot_id)]
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/bots/{bot_id}/tokens/{token_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def revoke_bot_token(
|
||||
bot_id: str,
|
||||
token_id: str,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> Response:
|
||||
try:
|
||||
service.revoke_bot_token(bot_id, token_id)
|
||||
except ValueError as exc:
|
||||
_raise_auth_error(exc)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
+1
-28
@@ -6,8 +6,6 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
@@ -59,29 +57,4 @@ def mask_secret(value: str, *, prefix: int = 6, suffix: int = 4, mask_char: str
|
||||
if len(text) <= prefix + suffix:
|
||||
return mask_char * len(text)
|
||||
hidden = len(text) - prefix - suffix
|
||||
return f"{text[:prefix]}{mask_char * hidden}{text[-suffix:]}"
|
||||
|
||||
|
||||
def build_audit_event(
|
||||
*,
|
||||
action: str,
|
||||
actor_id: str,
|
||||
bot_id: str,
|
||||
token_id: str,
|
||||
token_ref: str,
|
||||
result: str,
|
||||
detail: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"event_type": "auth.bot_token",
|
||||
"action": action,
|
||||
"actor_id": actor_id,
|
||||
"bot_id": bot_id,
|
||||
"token_id": mask_secret(token_id, prefix=6, suffix=4),
|
||||
"token_ref": token_ref,
|
||||
"result": result,
|
||||
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if detail:
|
||||
payload["detail"] = detail
|
||||
return payload
|
||||
return f"{text[:prefix]}{mask_char * hidden}{text[-suffix:]}"
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user