feat(auth): add admin-managed bot API keys with expiry and revocation

This commit is contained in:
2026-04-03 13:12:29 +00:00
parent 06b1805b92
commit 6998936858
15 changed files with 935 additions and 34 deletions
+124 -4
View File
@@ -6,11 +6,21 @@ from typing import NoReturn
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.core.auth import AuthContext, require_read
from app.domain.models import AuthToken, User
from app.schemas.auth import AuthTokenResponse, CreateClientTokenRequest, IssuedClientTokenResponse, LoginRequest, TokenResponse
from app.core.auth import AuthContext, require_admin, require_read
from app.domain.models import AuthToken, BotPrincipal, User
from app.schemas.auth import (
AuthTokenResponse,
BotPrincipalResponse,
CreateBotPrincipalRequest,
CreateBotTokenRequest,
CreateClientTokenRequest,
IssuedBotTokenResponse,
IssuedClientTokenResponse,
LoginRequest,
TokenResponse,
)
from app.schemas.user import UserResponse
from app.services.auth_service import AuthService, IssuedClientToken, LoginResult, get_auth_service
from app.services.auth_service import AuthService, IssuedBotToken, IssuedClientToken, LoginResult, get_auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -36,6 +46,7 @@ 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,
@@ -43,6 +54,19 @@ def _to_auth_token_response(token: AuthToken) -> AuthTokenResponse:
)
def _to_bot_principal_response(bot: BotPrincipal) -> BotPrincipalResponse:
return BotPrincipalResponse(
id=bot.id,
owner_user_id=bot.owner_user_id,
name=bot.name,
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,
@@ -59,6 +83,13 @@ 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:
@@ -111,4 +142,93 @@ 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.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)
+2
View File
@@ -26,6 +26,7 @@ class AuthContext:
permission: Permission
user_id: str | None = None
token_id: str | None = None
bot_principal_id: str | None = None
token_source: str = "env"
def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]:
@@ -63,6 +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_source="db",
)
+1
View File
@@ -30,6 +30,7 @@ class Settings(BaseSettings):
db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED")
session_token_ttl_hours: int = Field(default=12, validation_alias="PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS")
client_token_default_ttl_days: int = Field(default=365, validation_alias="PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS")
bot_token_default_ttl_days: int = Field(default=365, validation_alias="PORTFOLIOHUB_BOT_TOKEN_DEFAULT_TTL_DAYS")
# External services configuration
timescaledb_host: str = Field(default="localhost", validation_alias="TIMESCALEDB_HOST")
+14
View File
@@ -37,6 +37,7 @@ 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
@@ -49,6 +50,19 @@ class TokenPrincipal:
user_id: str
permission: str
token_type: str
bot_principal_id: str | None = None
@dataclass
class BotPrincipal:
id: str
owner_user_id: str
name: 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
+20 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Protocol
from app.domain.models import AuthToken, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
class PortfolioRepository(Protocol):
@@ -51,6 +51,7 @@ class PortfolioRepository(Protocol):
token_prefix: str,
permission: str,
token_type: str,
bot_principal_id: str | None = None,
expires_at: datetime | None = None,
) -> AuthToken: ...
@@ -58,6 +59,24 @@ class PortfolioRepository(Protocol):
def revoke_auth_token(self, user_id: str, token_id: str) -> None: ...
def create_bot_principal(
self,
*,
owner_user_id: str,
name: 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) -> 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_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
def get(self, portfolio_id: str) -> Portfolio: ...
+188 -14
View File
@@ -12,7 +12,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -36,6 +36,7 @@ _SELECT_AUTH_TOKEN = """
token_prefix,
permission,
token_type,
bot_principal_id,
expires_at,
last_used_at,
revoked_at,
@@ -43,6 +44,19 @@ _SELECT_AUTH_TOKEN = """
FROM auth_tokens
"""
_SELECT_BOT_PRINCIPAL = """
SELECT
id,
owner_user_id,
name,
description,
is_active,
created_at,
updated_at,
deactivated_at
FROM bot_principals
"""
_SELECT_PORTFOLIO = """
SELECT
portfolio_id,
@@ -132,6 +146,7 @@ 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,
@@ -139,6 +154,21 @@ class PostgresPortfolioRepository:
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS bot_principals (
id TEXT PRIMARY KEY,
owner_user_id TEXT NOT NULL,
name TEXT NOT NULL UNIQUE,
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(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
@@ -151,6 +181,18 @@ class PostgresPortfolioRepository:
ON auth_tokens (token_hash)
"""
)
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 TABLE IF NOT EXISTS strategy_sets (
@@ -249,10 +291,26 @@ class PostgresPortfolioRepository:
token_prefix=row[3],
permission=row[4],
token_type=row[5],
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,
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],
owner_user_id=row[1],
name=row[2],
description=row[3] if len(row) > 3 else None,
is_active=bool(row[4]) if len(row) > 4 else True,
created_at=row[5] if len(row) > 5 else None,
updated_at=row[6] if len(row) > 6 else None,
deactivated_at=row[7] if len(row) > 7 else None,
)
@staticmethod
@@ -452,6 +510,7 @@ 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()
@@ -459,14 +518,22 @@ class PostgresPortfolioRepository:
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("봇을 찾을 수 없습니다")
token_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO auth_tokens (id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, user_id, name, token_prefix, permission, token_type, expires_at, last_used_at, revoked_at, created_at
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type, bot_principal_id, expires_at
)
VALUES (%s, %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
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, bot_principal_id, expires_at),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@@ -492,16 +559,117 @@ class PostgresPortfolioRepository:
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
def create_bot_principal(
self,
*,
owner_user_id: str,
name: str,
description: str | None = None,
) -> BotPrincipal:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (owner_user_id,))
if cur.fetchone() is None:
raise ValueError("소유 사용자를 찾을 수 없습니다")
cur.execute("SELECT 1 FROM bot_principals WHERE name = %s", (name,))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
bot_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO bot_principals (id, owner_user_id, name, description)
VALUES (%s, %s, %s, %s)
RETURNING id, owner_user_id, name, description, is_active, created_at, updated_at, deactivated_at
""",
(bot_id, owner_user_id, name, description),
)
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) -> list[BotPrincipal]:
self.ensure_schema()
query = _SELECT_BOT_PRINCIPAL
params: list[object] = []
if is_active is not None:
query += "WHERE is_active = %s "
params.append(is_active)
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, owner_user_id, name, 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("토큰을 찾을 수 없습니다")
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, user_id, permission, token_type
FROM auth_tokens
SELECT t.id, t.user_id, t.permission, t.token_type, t.bot_principal_id
FROM auth_tokens t
LEFT JOIN bot_principals b ON b.id = t.bot_principal_id
WHERE token_hash = %s
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
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,),
)
@@ -509,7 +677,13 @@ class PostgresPortfolioRepository:
if row is None:
return None
cur.execute("UPDATE auth_tokens SET last_used_at = NOW() WHERE id = %s", (row[0],))
return TokenPrincipal(token_id=row[0], user_id=row[1], permission=row[2], token_type=row[3])
return TokenPrincipal(
token_id=row[0],
user_id=row[1],
permission=row[2],
token_type=row[3],
bot_principal_id=row[4],
)
def get(self, portfolio_id: str) -> Portfolio:
self.ensure_schema()
+47 -1
View File
@@ -51,7 +51,8 @@ class AuthTokenResponse(BaseModel):
name: str
token_prefix: str
permission: Literal["read", "trade-write", "admin"]
token_type: Literal["session", "client"]
token_type: Literal["session", "client", "bot"]
bot_principal_id: str | None = None
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
@@ -59,6 +60,51 @@ class AuthTokenResponse(BaseModel):
class IssuedClientTokenResponse(BaseModel):
token: str
token_type: Literal["bearer"] = "bearer"
auth_token: AuthTokenResponse
class CreateBotPrincipalRequest(BaseModel):
owner_user_id: str = Field(min_length=1)
name: str = Field(min_length=1)
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
owner_user_id: str
name: str
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", "admin"] | None = None
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
@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):
token: str
token_type: Literal["bearer"] = "bearer"
auth_token: AuthTokenResponse
+70 -7
View File
@@ -10,10 +10,10 @@ from fastapi import Depends
from app.core.auth import AuthContext, Permission, _has_permission
from app.core.config import Settings, get_settings
from app.core.security import generate_token, hash_token, token_prefix, verify_password
from app.domain.models import AuthToken, User
from app.domain.models import AuthToken, BotPrincipal, User
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.auth import CreateClientTokenRequest, LoginRequest
from app.schemas.auth import CreateBotPrincipalRequest, CreateBotTokenRequest, CreateClientTokenRequest, LoginRequest
@dataclass
@@ -29,6 +29,12 @@ class IssuedClientToken:
auth_token: AuthToken
@dataclass
class IssuedBotToken:
token: str
auth_token: AuthToken
def get_auth_service_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository:
return get_portfolio_repository_from_settings(settings)
@@ -59,6 +65,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.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 클라이언트 토큰을 발급할 수 있습니다")
@@ -67,11 +75,7 @@ class AuthService:
raise ValueError("요청한 토큰 권한이 현재 권한을 초과합니다")
raw_token = generate_token()
expires_at = None
if payload.expires_in_days is not None:
expires_at = datetime.now(timezone.utc) + timedelta(days=payload.expires_in_days)
else:
expires_at = datetime.now(timezone.utc) + timedelta(days=self._settings.client_token_default_ttl_days)
expires_at = self._build_expiration(payload.expires_in_days, self._settings.client_token_default_ttl_days)
auth_token = self._repository.create_auth_token(
auth_context.user_id,
@@ -85,15 +89,74 @@ 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.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.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:
self._repository.get_user(payload.owner_user_id)
return self._repository.create_bot_principal(
owner_user_id=payload.owner_user_id,
name=payload.name,
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 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(self, bot_principal_id: str, payload: CreateBotTokenRequest) -> IssuedBotToken:
bot = self._repository.get_bot_principal(bot_principal_id)
if not bot.is_active:
raise ValueError("비활성화된 봇에는 토큰을 발급할 수 없습니다")
owner = self._repository.get_user(bot.owner_user_id)
owner_permission = Permission(owner.permission)
requested = Permission(payload.permission or owner.permission)
if not _has_permission(owner_permission, requested):
raise ValueError("요청한 토큰 권한이 봇 소유자의 권한을 초과합니다")
raw_token = generate_token()
expires_at = self._build_expiration(payload.expires_in_days, self._settings.bot_token_default_ttl_days)
auth_token = self._repository.create_auth_token(
owner.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,
)
return IssuedBotToken(token=raw_token, auth_token=auth_token)
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 revoke_bot_token(self, bot_principal_id: str, token_id: str) -> None:
self._repository.get_bot_principal(bot_principal_id)
self._repository.revoke_bot_auth_token(bot_principal_id, token_id)
@staticmethod
def _build_expiration(expires_in_days: int | None, default_days: int) -> datetime:
days = expires_in_days if expires_in_days is not None else default_days
return datetime.now(timezone.utc) + timedelta(days=days)
def get_auth_service(
settings: Settings = Depends(get_settings),