diff --git a/.env.example b/.env.example index 3e6b788..507773e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 38285e2..32c0293 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/api/auth_routes.py b/app/api/auth_routes.py index 3eb4701..835a156 100644 --- a/app/api/auth_routes.py +++ b/app/api/auth_routes.py @@ -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) \ No newline at end of file diff --git a/app/core/auth.py b/app/core/auth.py index 04c8312..d2b49d8 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -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", ) diff --git a/app/core/config.py b/app/core/config.py index 0b9de3c..c6a9b03 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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") diff --git a/app/domain/models.py b/app/domain/models.py index 85a52aa..023d38d 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -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 diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 05f6fb5..b7076a6 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -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: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 194879a..a6ace20 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -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() diff --git a/app/schemas/auth.py b/app/schemas/auth.py index bea1e52..609f2c5 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -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 \ No newline at end of file diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 0ad5d65..a4ef1cb 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -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), diff --git a/docs/api-examples.md b/docs/api-examples.md index 6e5e766..b5aba89 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -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 " \ + -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 " +``` + +### 6.7 POST /auth/bots/{bot_id}/tokens + +Request: + +```bash +curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \ + -H "Authorization: Bearer " \ + -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 " +``` + +### 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 " +``` + +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 " +``` + +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. diff --git a/docs/db-schema-spec.md b/docs/db-schema-spec.md index 60a35d8..346ef74 100644 --- a/docs/db-schema-spec.md +++ b/docs/db-schema-spec.md @@ -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 | diff --git a/docs/portfoliohub-spec.md b/docs/portfoliohub-spec.md index 8913f90..41c4907 100644 --- a/docs/portfoliohub-spec.md +++ b/docs/portfoliohub-spec.md @@ -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: diff --git a/migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py b/migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py new file mode 100644 index 0000000..d25f439 --- /dev/null +++ b/migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py @@ -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") diff --git a/tests/test_users.py b/tests/test_users.py index 40ba07d..cfdf920 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -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") @@ -259,4 +326,76 @@ def test_login_and_issue_client_token_flow(monkeypatch) -> None: token_payload = token_response.json() assert token_payload["auth_token"]["name"] == "mobile-app" assert token_payload["auth_token"]["token_type"] == "client" - assert token_payload["token"] \ No newline at end of file + 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" \ No newline at end of file