feat(auth): add admin-managed bot API keys with expiry and revocation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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")
|
||||
+141
-2
@@ -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"]
|
||||
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"
|
||||
Reference in New Issue
Block a user