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
+1
View File
@@ -12,6 +12,7 @@ PORTFOLIOHUB_BOOTSTRAP_EMAIL=
PORTFOLIOHUB_BOOTSTRAP_PASSWORD=
PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS=12
PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS=365
PORTFOLIOHUB_BOT_TOKEN_DEFAULT_TTL_DAYS=365
# TimescaleDB / PostgreSQL connection settings
TIMESCALEDB_HOST=localhost
+7 -1
View File
@@ -32,7 +32,7 @@ When deploying to production, set environment variables directly instead of usin
- **DB repository switch**: `PORTFOLIOHUB_DB_ENABLED` (enables DB-backed persistence)
- **Initial user bootstrap**: `PORTFOLIOHUB_BOOTSTRAP_USERNAME`, `PORTFOLIOHUB_BOOTSTRAP_EMAIL`
- **Initial user password**: `PORTFOLIOHUB_BOOTSTRAP_PASSWORD`
- **Session/token TTL**: `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS`, `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS`
- **Session/token TTL**: `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS`, `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS`, `PORTFOLIOHUB_BOT_TOKEN_DEFAULT_TTL_DAYS`
- **TimescaleDB / PostgreSQL**: `TIMESCALEDB_*` (single source of DB connection settings)
- **Redis**: `REDIS_*` (for caching and sessions)
- **Loki**: `LOKI_*` (for centralized logging)
@@ -177,6 +177,12 @@ 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)
Example API calls:
+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:
@@ -112,3 +143,92 @@ 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.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
@@ -62,3 +63,48 @@ 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),
+123
View File
@@ -323,6 +323,7 @@ Response 201:
"token_prefix": "abcd1234",
"permission": "read",
"token_type": "client",
"bot_principal_id": null,
"expires_at": "2026-05-03T00:00:00Z",
"last_used_at": null,
"revoked_at": null,
@@ -353,6 +354,128 @@ Response 204:
- No content.
### 6.5 POST /auth/bots
Request:
```bash
curl -X POST "http://localhost:8000/auth/bots" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"owner_user_id": "user-1",
"name": "market-bot",
"description": "External trading bot"
}'
```
Response 201:
```json
{
"id": "bot-1",
"owner_user_id": "user-1",
"name": "market-bot",
"description": "External trading bot",
"is_active": true,
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z",
"deactivated_at": null
}
```
### 6.6 GET /auth/bots
Request:
```bash
curl -X GET "http://localhost:8000/auth/bots?is_active=true" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
### 6.7 POST /auth/bots/{bot_id}/tokens
Request:
```bash
curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"name": "prod-key",
"permission": "read",
"expires_in_days": 30
}'
```
Response 201:
```json
{
"token": "plain-bot-token",
"token_type": "bearer",
"auth_token": {
"id": "token-2",
"user_id": "user-1",
"name": "prod-key",
"token_prefix": "efgh5678",
"permission": "read",
"token_type": "bot",
"bot_principal_id": "bot-1",
"expires_at": "2026-05-03T00:00:00Z",
"last_used_at": null,
"revoked_at": null,
"created_at": "2026-04-03T00:00:00Z"
}
}
```
### 6.8 GET /auth/bots/{bot_id}/tokens
Request:
```bash
curl -X GET "http://localhost:8000/auth/bots/bot-1/tokens" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
### 6.9 DELETE /auth/bots/{bot_id}/tokens/{token_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/auth/bots/bot-1/tokens/token-2" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
Response 204:
- No content.
### 6.10 DELETE /auth/bots/{bot_id}
Request:
```bash
curl -X DELETE "http://localhost:8000/auth/bots/bot-1" \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
Response 200:
```json
{
"id": "bot-1",
"owner_user_id": "user-1",
"name": "market-bot",
"description": "External trading bot",
"is_active": false,
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T01:00:00Z",
"deactivated_at": "2026-04-03T01:00:00Z"
}
```
## 7. Users
All user management endpoints require an admin token.
+46 -4
View File
@@ -5,13 +5,14 @@
This document defines the database schema in an AI-agent-friendly format.
- Source of truth: Alembic revisions in `migrations/versions`
- Current baseline revision: `20260403_0005`
- Current baseline revision: `20260403_0006`
- Baseline files:
- `migrations/versions/20260403_0001_initial_portfolio_schema.py`
- `migrations/versions/20260403_0002_strategy_set_columns.py`
- `migrations/versions/20260403_0003_portfolio_strategy_refs.py`
- `migrations/versions/20260403_0004_users_table.py`
- `migrations/versions/20260403_0005_auth_user_fields_and_tokens.py`
- `migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py`
## 2. Scope
@@ -20,6 +21,7 @@ Schema objects covered by baseline revision:
- `strategy_sets`
- `users`
- `auth_tokens`
- `bot_principals`
- `portfolios`
- `portfolio_ledger`
- secondary indexes
@@ -76,7 +78,8 @@ Columns:
| token_prefix | Text | NO | - | Display prefix for issued token |
| token_hash | Text | NO | - | Unique SHA-256 token hash |
| permission | Text | NO | - | Effective token permission |
| token_type | Text | NO | - | `session` or `client` |
| token_type | Text | NO | - | `session`, `client`, or `bot` |
| bot_principal_id | Text | YES | - | Logical reference to `bot_principals.id` |
| expires_at | DateTime(timezone=True) | YES | - | Expiration time |
| last_used_at | DateTime(timezone=True) | YES | - | Last successful use |
| revoked_at | DateTime(timezone=True) | YES | - | Revocation time |
@@ -87,6 +90,30 @@ Constraints:
- Primary key: `id`
- Unique: `uq_auth_tokens_token_hash` on (`token_hash`)
### 4.3 bot_principals
Purpose:
- Stores external bot identities for admin-managed API key issuance and lifecycle control.
Columns:
| Column | Type | Null | Default | Constraints / Notes |
|---|---|---|---|---|
| id | Text | NO | - | Primary Key |
| owner_user_id | Text | NO | - | Logical owner reference to users.id |
| name | Text | NO | - | Unique bot name |
| description | Text | YES | - | Optional operator description |
| is_active | Boolean | NO | TRUE | When false, bot tokens are rejected |
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
| deactivated_at | DateTime(timezone=True) | YES | - | Deactivation time |
Constraints:
- Primary key: `id`
- Unique: `uq_bot_principals_name` on (`name`)
### 4.3 strategy_sets
Purpose:
@@ -199,14 +226,28 @@ Constraints:
- Unique: no
- Intent: resolve bearer tokens efficiently.
### 5.4 idx_portfolio_ledger_portfolio_time
### 5.4 idx_auth_tokens_bot_created
- Table: `auth_tokens`
- Columns/expressions: (`bot_principal_id`, `created_at`)
- Unique: no
- Intent: fetch bot-issued token lists efficiently.
### 5.5 idx_bot_principals_active_created
- Table: `bot_principals`
- Columns/expressions: (`is_active`, `created_at`)
- Unique: no
- Intent: speed operational filtering of active/inactive bots.
### 5.6 idx_portfolio_ledger_portfolio_time
- Table: `portfolio_ledger`
- Columns/expressions: (`portfolio_id`, `recorded_at DESC`)
- Unique: no
- Intent: fetch recent ledger entries per portfolio efficiently.
### 5.5 idx_portfolios_broker_status_updated
### 5.7 idx_portfolios_broker_status_updated
- Table: `portfolios`
- Columns/expressions: (`broker`, `status`, `updated_at DESC`)
@@ -250,3 +291,4 @@ When schema changes:
| 20260403_0003 | migrations/versions/20260403_0003_portfolio_strategy_refs.py | Remove embedded portfolio strategy fields, add `active_strategy_id` and `avabile_strategies` |
| 20260403_0004 | migrations/versions/20260403_0004_users_table.py | Add `users` table with unique username/email and created_at index |
| 20260403_0005 | migrations/versions/20260403_0005_auth_user_fields_and_tokens.py | Add password/permission/login fields to `users` and create `auth_tokens` table |
| 20260403_0006 | migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py | Add `bot_principals` table and `auth_tokens.bot_principal_id` for bot API key management |
+72
View File
@@ -81,6 +81,7 @@ Core settings:
- `PORTFOLIOHUB_DB_ENABLED`: enables DB repository selection when `true`
- `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS`: login session token TTL in hours
- `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS`: default client token TTL in days
- `PORTFOLIOHUB_BOT_TOKEN_DEFAULT_TTL_DAYS`: default bot token TTL in days
Database settings (active source):
@@ -284,6 +285,54 @@ Note: Since strategy sets are identified by (name, config) uniqueness, updating
- Response `204`: no content
- Response `404`: token not found
#### Create Bot Principal
- `POST /auth/bots`
- Auth: `admin`
- Body: `CreateBotPrincipalRequest`
- Response `201`: `BotPrincipalResponse`
#### List Bot Principals
- `GET /auth/bots`
- Auth: `admin`
- Query params:
- `is_active` (optional bool)
- Response `200`: array of `BotPrincipalResponse`
#### Get Bot Principal
- `GET /auth/bots/{bot_id}`
- Auth: `admin`
- Response `200`: `BotPrincipalResponse`
- Response `404`: bot not found
#### Deactivate Bot Principal
- `DELETE /auth/bots/{bot_id}`
- Auth: `admin`
- Response `200`: `BotPrincipalResponse`
- Behavior: deactivates bot and revokes all active tokens linked to the bot
#### Issue Bot Token
- `POST /auth/bots/{bot_id}/tokens`
- Auth: `admin`
- Body: `CreateBotTokenRequest`
- Response `201`: `IssuedBotTokenResponse`
#### List Bot Tokens
- `GET /auth/bots/{bot_id}/tokens`
- Auth: `admin`
- Response `200`: array of `AuthTokenResponse`
#### Revoke Bot Token
- `DELETE /auth/bots/{bot_id}/tokens/{token_id}`
- Auth: `admin`
- Response `204`: no content
### 7.6 User Endpoints
All user management endpoints are admin-only.
@@ -386,6 +435,18 @@ Bootstrap rule:
`CreateClientTokenRequest`
- `name: str`
- `permission: "read" | "trade-write" | "admin" | null`
- `expires_in_days: int | null`
`CreateBotPrincipalRequest`
- `owner_user_id: str`
- `name: str`
- `description: str | null`
`CreateBotTokenRequest`
- `name: str`
- `permission: "read" | "trade-write" | "admin" | null`
- `expires_in_days: int | null`
@@ -465,6 +526,17 @@ Bootstrap rule:
- `detail: str`
`BotPrincipalResponse`
- `id: str`
- `owner_user_id: str`
- `name: str`
- `description: str | null`
- `is_active: bool`
- `created_at: datetime | null`
- `updated_at: datetime | null`
- `deactivated_at: datetime | null`
## 9. CLI Specification
Global option:
@@ -0,0 +1,79 @@
"""add bot principals and bot token linkage
Revision ID: 20260403_0006
Revises: 20260403_0005
Create Date: 2026-04-03 13:30:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260403_0006"
down_revision = "20260403_0005"
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:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def _index_exists(inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "bot_principals"):
op.create_table(
"bot_principals",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("owner_user_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE")),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
sa.Column("deactivated_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", name="uq_bot_principals_name"),
)
inspector = inspect(bind)
if _table_exists(inspector, "auth_tokens") and not _column_exists(inspector, "auth_tokens", "bot_principal_id"):
op.add_column("auth_tokens", sa.Column("bot_principal_id", sa.Text(), nullable=True))
inspector = inspect(bind)
if _table_exists(inspector, "auth_tokens") and not _index_exists(inspector, "auth_tokens", "idx_auth_tokens_bot_created"):
op.create_index("idx_auth_tokens_bot_created", "auth_tokens", ["bot_principal_id", "created_at"])
if _table_exists(inspector, "bot_principals") and not _index_exists(inspector, "bot_principals", "idx_bot_principals_active_created"):
op.create_index("idx_bot_principals_active_created", "bot_principals", ["is_active", "created_at"])
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "auth_tokens") and _index_exists(inspector, "auth_tokens", "idx_auth_tokens_bot_created"):
op.drop_index("idx_auth_tokens_bot_created", table_name="auth_tokens")
if _table_exists(inspector, "auth_tokens") and _column_exists(inspector, "auth_tokens", "bot_principal_id"):
op.drop_column("auth_tokens", "bot_principal_id")
inspector = inspect(bind)
if _table_exists(inspector, "bot_principals") and _index_exists(inspector, "bot_principals", "idx_bot_principals_active_created"):
op.drop_index("idx_bot_principals_active_created", table_name="bot_principals")
if _table_exists(inspector, "bot_principals"):
op.drop_table("bot_principals")
+140 -1
View File
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
import app.api.users as users_api
from app.core.security import hash_password, hash_token
from app.domain.models import AuthToken, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, 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,8 +19,10 @@ 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)
@@ -112,6 +114,7 @@ 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)
@@ -122,6 +125,7 @@ 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,
)
@@ -132,6 +136,7 @@ class FakeUserRepo:
user_id=user_id,
permission=permission,
token_type=token_type,
bot_principal_id=bot_principal_id,
)
return token
@@ -151,11 +156,73 @@ 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)
return principal
def create_bot_principal(
self,
*,
owner_user_id: str,
name: str,
description: str | None = None,
) -> BotPrincipal:
self.get_user(owner_user_id)
if any(item.name == name for item in self.bots.values()):
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
now = datetime.now(timezone.utc)
bot = BotPrincipal(
id=f"bot-{self.next_bot_id}",
owner_user_id=owner_user_id,
name=name,
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) -> 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]
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")
@@ -260,3 +327,75 @@ def test_login_and_issue_client_token_flow(monkeypatch) -> None:
assert token_payload["auth_token"]["name"] == "mobile-app"
assert token_payload["auth_token"]["token_type"] == "client"
assert token_payload["token"]
def test_admin_can_create_bot_and_issue_token(monkeypatch) -> None:
repo = FakeUserRepo()
owner = repo.create_user("bot-owner", password_hash=hash_password("password123"), permission="trade-write")
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={"owner_user_id": owner.id, "name": "market-bot", "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"]["expires_at"] is not None
def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
repo = FakeUserRepo()
owner = repo.create_user("bot-owner", password_hash=hash_password("password123"), permission="read")
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={"owner_user_id": owner.id, "name": "ops-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": "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"