feat(auth): owner-bound bot tokens and self-service bot management

This commit is contained in:
2026-04-14 00:34:44 +09:00
parent 9787cf3159
commit 2dab17cbb1
16 changed files with 576 additions and 35 deletions
+78 -1
View File
@@ -14,6 +14,7 @@ from app.schemas.auth import (
CreateBotPrincipalRequest,
CreateBotTokenRequest,
CreateClientTokenRequest,
CreateSelfBotPrincipalRequest,
IssuedBotTokenResponse,
IssuedClientTokenResponse,
LoginRequest,
@@ -58,6 +59,7 @@ def _to_bot_principal_response(bot: BotPrincipal) -> BotPrincipalResponse:
return BotPrincipalResponse(
id=bot.id,
name=bot.name,
owner_user_id=bot.owner_user_id,
permission=bot.permission,
description=bot.description,
is_active=bot.is_active,
@@ -94,7 +96,7 @@ def _raise_auth_error(exc: ValueError) -> NoReturn:
detail = str(exc)
if "잘못된 사용자명 또는 비밀번호" in detail:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail) from exc
if "초과" in detail:
if "초과" in detail or "권한이 없습니다" in detail:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) from exc
if "찾을 수 없습니다" in detail:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
@@ -156,6 +158,81 @@ def create_bot_principal(
_raise_auth_error(exc)
@router.post("/me/bots", response_model=BotPrincipalResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_read)])
def create_owned_bot_principal(
payload: CreateSelfBotPrincipalRequest,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> BotPrincipalResponse:
try:
return _to_bot_principal_response(service.create_owned_bot_principal(auth_context, payload))
except ValueError as exc:
_raise_auth_error(exc)
@router.get("/me/bots", response_model=list[BotPrincipalResponse], dependencies=[Depends(require_read)])
def list_owned_bot_principals(
auth_context: AuthContext = Depends(require_read),
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_owned_bot_principals(auth_context, is_active=is_active)
]
except ValueError as exc:
_raise_auth_error(exc)
@router.post(
"/me/bots/{bot_id}/tokens",
response_model=IssuedBotTokenResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_read)],
)
def issue_owned_bot_token(
bot_id: str,
payload: CreateBotTokenRequest,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> IssuedBotTokenResponse:
try:
return _to_issued_bot_token_response(service.issue_owned_bot_token(auth_context, bot_id, payload))
except ValueError as exc:
_raise_auth_error(exc)
@router.get("/me/bots/{bot_id}/tokens", response_model=list[AuthTokenResponse], dependencies=[Depends(require_read)])
def list_owned_bot_tokens(
bot_id: str,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> list[AuthTokenResponse]:
try:
return [_to_auth_token_response(token) for token in service.list_owned_bot_tokens(auth_context, bot_id)]
except ValueError as exc:
_raise_auth_error(exc)
@router.delete(
"/me/bots/{bot_id}/tokens/{token_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_read)],
)
def revoke_owned_bot_token(
bot_id: str,
token_id: str,
auth_context: AuthContext = Depends(require_read),
service: AuthService = Depends(get_auth_service),
) -> Response:
try:
service.revoke_owned_bot_token(auth_context, bot_id, token_id)
except ValueError as exc:
_raise_auth_error(exc)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/bots", response_model=list[BotPrincipalResponse], dependencies=[Depends(require_admin)])
def list_bot_principals(
is_active: bool | None = None,
+1
View File
@@ -57,6 +57,7 @@ class TokenPrincipal:
class BotPrincipal:
id: str
name: str
owner_user_id: str
permission: str
description: str | None = None
is_active: bool = True
+7 -1
View File
@@ -63,13 +63,19 @@ class PortfolioRepository(Protocol):
self,
*,
name: str,
owner_user_id: str,
permission: 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 list_bot_principals(
self,
*,
is_active: bool | None = None,
owner_user_id: str | None = None,
) -> list[BotPrincipal]: ...
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal: ...
+76 -14
View File
@@ -48,6 +48,7 @@ _SELECT_BOT_PRINCIPAL = """
SELECT
id,
name,
owner_user_id,
permission,
description,
is_active,
@@ -161,6 +162,7 @@ class PostgresPortfolioRepository:
CREATE TABLE IF NOT EXISTS bot_principals (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
owner_user_id TEXT,
permission TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
@@ -171,9 +173,47 @@ class PostgresPortfolioRepository:
"""
)
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS bot_principal_id TEXT")
cur.execute("ALTER TABLE bot_principals ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
cur.execute("ALTER TABLE bot_principals ADD COLUMN IF NOT EXISTS permission TEXT")
cur.execute("UPDATE bot_principals SET permission = 'read' WHERE permission IS NULL")
cur.execute("ALTER TABLE bot_principals ALTER COLUMN permission SET NOT NULL")
cur.execute(
"""
UPDATE bot_principals
SET owner_user_id = fallback.id
FROM (
SELECT id
FROM users
ORDER BY created_at ASC, id ASC
LIMIT 1
) AS fallback
WHERE owner_user_id IS NULL
"""
)
cur.execute("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL")
unresolved_bot_owners = int(cur.fetchone()[0])
if unresolved_bot_owners > 0:
raise RuntimeError("bot_principals.owner_user_id backfill failed: users 테이블에 소유주가 필요합니다")
cur.execute("ALTER TABLE bot_principals ALTER COLUMN owner_user_id SET NOT NULL")
cur.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_bot_principals_owner_user'
) THEN
ALTER TABLE bot_principals
ADD CONSTRAINT fk_bot_principals_owner_user
FOREIGN KEY (owner_user_id)
REFERENCES users(id)
ON DELETE RESTRICT;
END IF;
END
$$
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
@@ -198,6 +238,12 @@ class PostgresPortfolioRepository:
ON bot_principals (is_active, created_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_bot_principals_owner_active_created
ON bot_principals (owner_user_id, is_active, created_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS strategy_sets (
@@ -333,12 +379,13 @@ class PostgresPortfolioRepository:
return BotPrincipal(
id=row[0],
name=row[1],
permission=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,
owner_user_id=row[2],
permission=row[3],
description=row[4] if len(row) > 4 else None,
is_active=bool(row[5]) if len(row) > 5 else True,
created_at=row[6] if len(row) > 6 else None,
updated_at=row[7] if len(row) > 7 else None,
deactivated_at=row[8] if len(row) > 8 else None,
)
@staticmethod
@@ -594,6 +641,7 @@ class PostgresPortfolioRepository:
self,
*,
name: str,
owner_user_id: str,
permission: str,
description: str | None = None,
) -> BotPrincipal:
@@ -602,15 +650,18 @@ class PostgresPortfolioRepository:
cur.execute("SELECT 1 FROM bot_principals WHERE name = %s", (name,))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
cur.execute("SELECT 1 FROM users WHERE id = %s", (owner_user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
bot_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO bot_principals (id, name, permission, description)
VALUES (%s, %s, %s, %s)
RETURNING id, name, permission, description, is_active, created_at, updated_at, deactivated_at
INSERT INTO bot_principals (id, name, owner_user_id, permission, description)
VALUES (%s, %s, %s, %s, %s)
RETURNING id, name, owner_user_id, permission, description, is_active, created_at, updated_at, deactivated_at
""",
(bot_id, name, permission, description),
(bot_id, name, owner_user_id, permission, description),
)
row = cur.fetchone()
return self._row_to_bot_principal(row)
@@ -622,13 +673,24 @@ class PostgresPortfolioRepository:
row = cur.fetchone()
return self._row_to_bot_principal(row)
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]:
def list_bot_principals(
self,
*,
is_active: bool | None = None,
owner_user_id: str | None = None,
) -> list[BotPrincipal]:
self.ensure_schema()
query = _SELECT_BOT_PRINCIPAL
params: list[object] = []
clauses: list[str] = []
if is_active is not None:
query += "WHERE is_active = %s "
clauses.append("is_active = %s")
params.append(is_active)
if owner_user_id is not None:
clauses.append("owner_user_id = %s")
params.append(owner_user_id)
if clauses:
query += "WHERE " + " AND ".join(clauses) + " "
query += "ORDER BY created_at DESC, name ASC"
with self._connect().cursor() as cur:
cur.execute(query, tuple(params))
@@ -643,7 +705,7 @@ class PostgresPortfolioRepository:
UPDATE bot_principals
SET is_active = FALSE, deactivated_at = NOW(), updated_at = NOW()
WHERE id = %s
RETURNING id, name, permission, description, is_active, created_at, updated_at, deactivated_at
RETURNING id, name, owner_user_id, permission, description, is_active, created_at, updated_at, deactivated_at
""",
(bot_principal_id,),
)
@@ -691,7 +753,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, t.user_id, t.permission, t.token_type, t.bot_principal_id
SELECT t.id, COALESCE(b.owner_user_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
+27 -3
View File
@@ -67,7 +67,30 @@ class IssuedClientTokenResponse(BaseModel):
class CreateBotPrincipalRequest(BaseModel):
name: str = Field(min_length=1)
permission: Literal["read", "trade-write", "admin"]
owner_user_id: str = Field(min_length=1)
permission: Literal["read", "trade-write"]
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
@field_validator("owner_user_id")
@classmethod
def normalize_owner_user_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("owner_user_id는 비어 있을 수 없습니다")
return normalized
class CreateSelfBotPrincipalRequest(BaseModel):
name: str = Field(min_length=1)
permission: Literal["read", "trade-write"]
description: str | None = None
@field_validator("name")
@@ -82,7 +105,8 @@ class CreateBotPrincipalRequest(BaseModel):
class BotPrincipalResponse(BaseModel):
id: str
name: str
permission: Literal["read", "trade-write", "admin"]
owner_user_id: str
permission: Literal["read", "trade-write"]
description: str | None = None
is_active: bool
created_at: datetime | None = None
@@ -92,7 +116,7 @@ class BotPrincipalResponse(BaseModel):
class CreateBotTokenRequest(BaseModel):
name: str = Field(min_length=1)
permission: Literal["read", "trade-write", "admin"] | None = None
permission: Literal["read", "trade-write"] | None = None
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
@field_validator("name")
+55 -1
View File
@@ -13,7 +13,13 @@ from app.core.security import generate_token, hash_token, token_prefix, verify_p
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 CreateBotPrincipalRequest, CreateBotTokenRequest, CreateClientTokenRequest, LoginRequest
from app.schemas.auth import (
CreateBotPrincipalRequest,
CreateBotTokenRequest,
CreateClientTokenRequest,
CreateSelfBotPrincipalRequest,
LoginRequest,
)
@dataclass
@@ -105,6 +111,16 @@ class AuthService:
def create_bot_principal(self, payload: CreateBotPrincipalRequest) -> BotPrincipal:
return self._repository.create_bot_principal(
name=payload.name,
owner_user_id=payload.owner_user_id,
permission=payload.permission,
description=payload.description,
)
def create_owned_bot_principal(self, auth_context: AuthContext, payload: CreateSelfBotPrincipalRequest) -> BotPrincipal:
user_id = self._require_db_user_context(auth_context)
return self._repository.create_bot_principal(
name=payload.name,
owner_user_id=user_id,
permission=payload.permission,
description=payload.description,
)
@@ -112,6 +128,10 @@ class AuthService:
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]:
return self._repository.list_bot_principals(is_active=is_active)
def list_owned_bot_principals(self, auth_context: AuthContext, *, is_active: bool | None = None) -> list[BotPrincipal]:
user_id = self._require_db_user_context(auth_context)
return self._repository.list_bot_principals(is_active=is_active, owner_user_id=user_id)
def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
return self._repository.get_bot_principal(bot_principal_id)
@@ -142,14 +162,48 @@ class AuthService:
)
return IssuedBotToken(token=raw_token, auth_token=auth_token)
def issue_owned_bot_token(
self,
auth_context: AuthContext,
bot_principal_id: str,
payload: CreateBotTokenRequest,
) -> IssuedBotToken:
user_id = self._require_db_user_context(auth_context)
bot = self._repository.get_bot_principal(bot_principal_id)
if bot.owner_user_id != user_id:
raise ValueError("봇 접근 권한이 없습니다")
return self.issue_bot_token(bot_principal_id, payload)
def list_bot_tokens(self, bot_principal_id: str) -> list[AuthToken]:
self._repository.get_bot_principal(bot_principal_id)
return self._repository.list_bot_auth_tokens(bot_principal_id)
def list_owned_bot_tokens(self, auth_context: AuthContext, bot_principal_id: str) -> list[AuthToken]:
user_id = self._require_db_user_context(auth_context)
bot = self._repository.get_bot_principal(bot_principal_id)
if bot.owner_user_id != user_id:
raise ValueError("봇 접근 권한이 없습니다")
return self._repository.list_bot_auth_tokens(bot_principal_id)
def revoke_bot_token(self, bot_principal_id: str, token_id: str) -> None:
self._repository.get_bot_principal(bot_principal_id)
self._repository.revoke_bot_auth_token(bot_principal_id, token_id)
def revoke_owned_bot_token(self, auth_context: AuthContext, bot_principal_id: str, token_id: str) -> None:
user_id = self._require_db_user_context(auth_context)
bot = self._repository.get_bot_principal(bot_principal_id)
if bot.owner_user_id != user_id:
raise ValueError("봇 접근 권한이 없습니다")
self._repository.revoke_bot_auth_token(bot_principal_id, token_id)
@staticmethod
def _require_db_user_context(auth_context: AuthContext) -> str:
if auth_context.bot_principal_id is not None:
raise ValueError("봇 토큰으로는 사용자 전용 봇 관리를 수행할 수 없습니다")
if auth_context.user_id is None:
raise ValueError("DB 사용자 컨텍스트에서만 사용자 전용 봇 관리를 수행할 수 있습니다")
return auth_context.user_id
@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