feat(auth): owner-bound bot tokens and self-service bot management
This commit is contained in:
+78
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user