작업 내용 저장

This commit is contained in:
2026-04-23 09:56:08 +00:00
parent 514b49cb98
commit d383c7086f
29 changed files with 1654 additions and 1372 deletions
+46 -167
View File
@@ -4,22 +4,20 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import Depends
import psycopg.errors
from app.core.auth import AuthContext, Permission, _has_permission
from app.core.config import Settings, get_settings
from app.core.security import build_audit_event, generate_token, hash_token, mask_secret, token_prefix, verify_password
from app.core.security import generate_token, hash_token, token_prefix, verify_password
from app.domain.services import normalize_expiry_days
from app.domain.models import AuthToken, BotPrincipal, User
from app.domain.models import AuthToken, User
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.auth import (
CreateBotPrincipalRequest,
CreateBotTokenRequest,
CreateClientTokenRequest,
CreateSelfBotPrincipalRequest,
LoginRequest,
)
@@ -38,7 +36,7 @@ class IssuedClientToken:
@dataclass
class IssuedBotToken:
class IssuedServiceToken:
token: str
auth_token: AuthToken
@@ -73,8 +71,8 @@ class AuthService:
return LoginResult(token=raw_token, auth_token=auth_token, user=refreshed_user)
def issue_client_token(self, auth_context: AuthContext, payload: CreateClientTokenRequest) -> IssuedClientToken:
if auth_context.bot_principal_id is not None:
raise ValueError("봇 토큰으로는 사용자 클라이언트 토큰을 발급할 수 없습니다")
if auth_context.token_type == "service":
raise ValueError("서비스 토큰으로는 사용자 클라이언트 토큰을 발급할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 클라이언트 토큰을 발급할 수 있습니다")
@@ -97,187 +95,68 @@ class AuthService:
return IssuedClientToken(token=raw_token, auth_token=auth_token)
def list_tokens(self, auth_context: AuthContext) -> list[AuthToken]:
if auth_context.bot_principal_id is not None:
raise ValueError("봇 토큰으로는 사용자 토큰 목록을 조회할 수 없습니다")
if auth_context.token_type == "service":
raise ValueError("서비스 토큰으로는 사용자 토큰 목록을 조회할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 토큰 목록을 조회할 수 있습니다")
return self._repository.list_auth_tokens(auth_context.user_id)
def revoke_token(self, auth_context: AuthContext, token_id: str) -> None:
if auth_context.bot_principal_id is not None:
raise ValueError("봇 토큰으로는 사용자 토큰을 폐기할 수 없습니다")
if auth_context.token_type == "service":
raise ValueError("서비스 토큰으로는 사용자 토큰을 폐기할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 토큰을 폐기할 수 있습니다")
self._repository.revoke_auth_token(auth_context.user_id, token_id)
def create_bot_principal(self, payload: CreateBotPrincipalRequest) -> BotPrincipal:
return self._repository.create_bot_principal(
name=payload.name,
owner_user_id=payload.owner_user_id,
permission=payload.permission,
description=payload.description,
)
def create_owned_bot_principal(self, auth_context: AuthContext, payload: CreateSelfBotPrincipalRequest) -> BotPrincipal:
user_id = self._require_db_user_context(auth_context)
return self._repository.create_bot_principal(
name=payload.name,
owner_user_id=user_id,
permission=payload.permission,
description=payload.description,
)
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]:
return self._repository.list_bot_principals(is_active=is_active)
def list_owned_bot_principals(self, auth_context: AuthContext, *, is_active: bool | None = None) -> list[BotPrincipal]:
user_id = self._require_db_user_context(auth_context)
return self._repository.list_bot_principals(is_active=is_active, owner_user_id=user_id)
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
return self._repository.get_bot_principal(bot_principal_id)
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
return self._repository.deactivate_bot_principal(bot_principal_id)
def issue_bot_token(
def issue_service_token(
self,
bot_principal_id: str,
payload: CreateBotTokenRequest,
name: str,
username: str,
*,
actor_permission: Permission = Permission.ADMIN,
actor_id: str | None = None,
) -> IssuedBotToken:
bot = self._repository.get_bot_principal(bot_principal_id)
if not bot.is_active:
raise ValueError("비활성화된 봇에는 토큰을 발급할 수 없습니다")
permission: str = "read",
expires_in_days: int | None = None,
no_expiry: bool = False,
) -> IssuedServiceToken:
user = self._repository.get_user_by_username(username)
if user is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {username}")
bot_permission = Permission(bot.permission)
requested = Permission(payload.permission or bot.permission)
if not _has_permission(bot_permission, requested):
raise ValueError("요청한 토큰 권한이 봇 권한을 초과합니다")
expires_at: datetime | None
if no_expiry:
if expires_in_days is not None:
raise ValueError("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다")
expires_at = None
else:
days = normalize_expiry_days(expires_in_days, default_days=365)
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
raw_token = generate_token()
expires_at = self._build_bot_expiration(
payload.expires_in_days,
no_expiry=payload.no_expiry,
actor_permission=actor_permission,
)
auth_token = self._repository.create_auth_token(
bot.id,
name=payload.name,
token_hash=hash_token(raw_token),
token_prefix=token_prefix(raw_token),
permission=requested.value,
token_type="bot",
bot_principal_id=bot.id,
expires_at=expires_at,
)
try:
self._record_audit_event(
build_audit_event(
action="bot_token.issue",
actor_id=actor_id or bot.owner_user_id,
bot_id=bot.id,
token_id=auth_token.id,
token_ref=mask_secret(raw_token, prefix=6, suffix=4),
result="success",
)
auth_token = self._repository.create_auth_token(
user.id,
name=name,
token_hash=hash_token(raw_token),
token_prefix=token_prefix(raw_token),
permission=permission,
token_type="service",
expires_at=expires_at,
)
except Exception as exc:
self._repository.revoke_bot_auth_token(bot.id, auth_token.id)
raise ValueError("감사 로그 기록에 실패하여 토큰 발급을 취소했습니다") from exc
return IssuedBotToken(token=raw_token, auth_token=auth_token)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
def issue_owned_bot_token(
self,
auth_context: AuthContext,
bot_principal_id: str,
payload: CreateBotTokenRequest,
) -> IssuedBotToken:
user_id = self._require_db_user_context(auth_context)
bot = self._repository.get_bot_principal(bot_principal_id)
if bot.owner_user_id != user_id:
raise ValueError("봇 접근 권한이 없습니다")
return self.issue_bot_token(
bot_principal_id,
payload,
actor_permission=auth_context.permission,
actor_id=user_id,
)
def list_bot_tokens(self, bot_principal_id: str) -> list[AuthToken]:
self._repository.get_bot_principal(bot_principal_id)
return self._repository.list_bot_auth_tokens(bot_principal_id)
def list_owned_bot_tokens(self, auth_context: AuthContext, bot_principal_id: str) -> list[AuthToken]:
user_id = self._require_db_user_context(auth_context)
bot = self._repository.get_bot_principal(bot_principal_id)
if bot.owner_user_id != user_id:
raise ValueError("봇 접근 권한이 없습니다")
return self._repository.list_bot_auth_tokens(bot_principal_id)
def revoke_bot_token(self, bot_principal_id: str, token_id: str, *, actor_id: str | None = None) -> datetime:
bot = self._repository.get_bot_principal(bot_principal_id)
self._repository.revoke_bot_auth_token(bot_principal_id, token_id)
revoked_at = datetime.now(timezone.utc)
try:
self._record_audit_event(
build_audit_event(
action="bot_token.revoke",
actor_id=actor_id or bot.owner_user_id,
bot_id=bot.id,
token_id=token_id,
token_ref=mask_secret(token_id, prefix=6, suffix=4),
result="success",
)
)
except Exception as exc:
raise ValueError("토큰은 폐기되었으나 감사 로그 기록에 실패했습니다") from exc
return revoked_at
def revoke_owned_bot_token(self, auth_context: AuthContext, bot_principal_id: str, token_id: str) -> None:
user_id = self._require_db_user_context(auth_context)
bot = self._repository.get_bot_principal(bot_principal_id)
if bot.owner_user_id != user_id:
raise ValueError("봇 접근 권한이 없습니다")
self.revoke_bot_token(bot_principal_id, token_id, actor_id=user_id)
@staticmethod
def _require_db_user_context(auth_context: AuthContext) -> str:
if auth_context.bot_principal_id is not None:
raise ValueError("봇 토큰으로는 사용자 전용 봇 관리를 수행할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 사용자 전용 봇 관리를 수행할 수 있습니다")
return auth_context.user_id
def revoke_service_token(self, name: str) -> datetime:
existing = self._repository.get_service_token_by_name(name)
if existing is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
self._repository.revoke_auth_token(existing.user_id, existing.id)
return datetime.now(timezone.utc)
@staticmethod
def _build_expiration(expires_in_days: int | None, default_days: int) -> datetime:
days = normalize_expiry_days(expires_in_days, default_days=default_days)
return datetime.now(timezone.utc) + timedelta(days=days)
@staticmethod
def _build_bot_expiration(
expires_in_days: int | None,
*,
no_expiry: bool,
actor_permission: Permission,
) -> datetime | None:
if no_expiry:
if expires_in_days is not None:
raise ValueError("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다")
if actor_permission is not Permission.ADMIN:
raise ValueError("비관리자는 --no-expiry를 사용할 수 없습니다")
return None
days = normalize_expiry_days(expires_in_days, default_days=365)
return datetime.now(timezone.utc) + timedelta(days=days)
def _record_audit_event(self, payload: dict[str, Any]) -> None:
recorder = getattr(self._repository, "record_audit_event", None)
if callable(recorder):
recorder(payload)
def get_auth_service(
settings: Settings = Depends(get_settings),