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, CreateBotPrincipalRequest,
CreateBotTokenRequest, CreateBotTokenRequest,
CreateClientTokenRequest, CreateClientTokenRequest,
CreateSelfBotPrincipalRequest,
IssuedBotTokenResponse, IssuedBotTokenResponse,
IssuedClientTokenResponse, IssuedClientTokenResponse,
LoginRequest, LoginRequest,
@@ -58,6 +59,7 @@ def _to_bot_principal_response(bot: BotPrincipal) -> BotPrincipalResponse:
return BotPrincipalResponse( return BotPrincipalResponse(
id=bot.id, id=bot.id,
name=bot.name, name=bot.name,
owner_user_id=bot.owner_user_id,
permission=bot.permission, permission=bot.permission,
description=bot.description, description=bot.description,
is_active=bot.is_active, is_active=bot.is_active,
@@ -94,7 +96,7 @@ def _raise_auth_error(exc: ValueError) -> NoReturn:
detail = str(exc) detail = str(exc)
if "잘못된 사용자명 또는 비밀번호" in detail: if "잘못된 사용자명 또는 비밀번호" in detail:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail) from exc 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 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) from exc
if "찾을 수 없습니다" in detail: if "찾을 수 없습니다" in detail:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc 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) _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)]) @router.get("/bots", response_model=list[BotPrincipalResponse], dependencies=[Depends(require_admin)])
def list_bot_principals( def list_bot_principals(
is_active: bool | None = None, is_active: bool | None = None,
+1
View File
@@ -57,6 +57,7 @@ class TokenPrincipal:
class BotPrincipal: class BotPrincipal:
id: str id: str
name: str name: str
owner_user_id: str
permission: str permission: str
description: str | None = None description: str | None = None
is_active: bool = True is_active: bool = True
+7 -1
View File
@@ -63,13 +63,19 @@ class PortfolioRepository(Protocol):
self, self,
*, *,
name: str, name: str,
owner_user_id: str,
permission: str, permission: str,
description: str | None = None, description: str | None = None,
) -> BotPrincipal: ... ) -> BotPrincipal: ...
def get_bot_principal(self, bot_principal_id: str) -> 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: ... def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal: ...
+76 -14
View File
@@ -48,6 +48,7 @@ _SELECT_BOT_PRINCIPAL = """
SELECT SELECT
id, id,
name, name,
owner_user_id,
permission, permission,
description, description,
is_active, is_active,
@@ -161,6 +162,7 @@ class PostgresPortfolioRepository:
CREATE TABLE IF NOT EXISTS bot_principals ( CREATE TABLE IF NOT EXISTS bot_principals (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
owner_user_id TEXT,
permission TEXT NOT NULL, permission TEXT NOT NULL,
description TEXT, description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE, 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 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("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("UPDATE bot_principals SET permission = 'read' WHERE permission IS NULL")
cur.execute("ALTER TABLE bot_principals ALTER COLUMN permission SET NOT 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( cur.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
@@ -198,6 +238,12 @@ class PostgresPortfolioRepository:
ON bot_principals (is_active, created_at DESC) 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( cur.execute(
""" """
CREATE TABLE IF NOT EXISTS strategy_sets ( CREATE TABLE IF NOT EXISTS strategy_sets (
@@ -333,12 +379,13 @@ class PostgresPortfolioRepository:
return BotPrincipal( return BotPrincipal(
id=row[0], id=row[0],
name=row[1], name=row[1],
permission=row[2], owner_user_id=row[2],
description=row[3] if len(row) > 3 else None, permission=row[3],
is_active=bool(row[4]) if len(row) > 4 else True, description=row[4] if len(row) > 4 else None,
created_at=row[5] if len(row) > 5 else None, is_active=bool(row[5]) if len(row) > 5 else True,
updated_at=row[6] if len(row) > 6 else None, created_at=row[6] if len(row) > 6 else None,
deactivated_at=row[7] if len(row) > 7 else None, updated_at=row[7] if len(row) > 7 else None,
deactivated_at=row[8] if len(row) > 8 else None,
) )
@staticmethod @staticmethod
@@ -594,6 +641,7 @@ class PostgresPortfolioRepository:
self, self,
*, *,
name: str, name: str,
owner_user_id: str,
permission: str, permission: str,
description: str | None = None, description: str | None = None,
) -> BotPrincipal: ) -> BotPrincipal:
@@ -602,15 +650,18 @@ class PostgresPortfolioRepository:
cur.execute("SELECT 1 FROM bot_principals WHERE name = %s", (name,)) cur.execute("SELECT 1 FROM bot_principals WHERE name = %s", (name,))
if cur.fetchone() is not None: if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 bot name입니다: {name}") 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()) bot_id = str(uuid.uuid4())
cur.execute( cur.execute(
""" """
INSERT INTO bot_principals (id, name, permission, description) INSERT INTO bot_principals (id, name, owner_user_id, permission, description)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s, %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_id, name, permission, description), (bot_id, name, owner_user_id, permission, description),
) )
row = cur.fetchone() row = cur.fetchone()
return self._row_to_bot_principal(row) return self._row_to_bot_principal(row)
@@ -622,13 +673,24 @@ class PostgresPortfolioRepository:
row = cur.fetchone() row = cur.fetchone()
return self._row_to_bot_principal(row) 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() self.ensure_schema()
query = _SELECT_BOT_PRINCIPAL query = _SELECT_BOT_PRINCIPAL
params: list[object] = [] params: list[object] = []
clauses: list[str] = []
if is_active is not None: if is_active is not None:
query += "WHERE is_active = %s " clauses.append("is_active = %s")
params.append(is_active) 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" query += "ORDER BY created_at DESC, name ASC"
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
cur.execute(query, tuple(params)) cur.execute(query, tuple(params))
@@ -643,7 +705,7 @@ class PostgresPortfolioRepository:
UPDATE bot_principals UPDATE bot_principals
SET is_active = FALSE, deactivated_at = NOW(), updated_at = NOW() SET is_active = FALSE, deactivated_at = NOW(), updated_at = NOW()
WHERE id = %s 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,), (bot_principal_id,),
) )
@@ -691,7 +753,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
cur.execute( 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 FROM auth_tokens t
LEFT JOIN bot_principals b ON b.id = t.bot_principal_id LEFT JOIN bot_principals b ON b.id = t.bot_principal_id
WHERE token_hash = %s WHERE token_hash = %s
+27 -3
View File
@@ -67,7 +67,30 @@ class IssuedClientTokenResponse(BaseModel):
class CreateBotPrincipalRequest(BaseModel): class CreateBotPrincipalRequest(BaseModel):
name: str = Field(min_length=1) 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 description: str | None = None
@field_validator("name") @field_validator("name")
@@ -82,7 +105,8 @@ class CreateBotPrincipalRequest(BaseModel):
class BotPrincipalResponse(BaseModel): class BotPrincipalResponse(BaseModel):
id: str id: str
name: str name: str
permission: Literal["read", "trade-write", "admin"] owner_user_id: str
permission: Literal["read", "trade-write"]
description: str | None = None description: str | None = None
is_active: bool is_active: bool
created_at: datetime | None = None created_at: datetime | None = None
@@ -92,7 +116,7 @@ class BotPrincipalResponse(BaseModel):
class CreateBotTokenRequest(BaseModel): class CreateBotTokenRequest(BaseModel):
name: str = Field(min_length=1) 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) expires_in_days: int | None = Field(default=None, ge=1, le=3650)
@field_validator("name") @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.domain.models import AuthToken, BotPrincipal, User
from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository 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 @dataclass
@@ -105,6 +111,16 @@ class AuthService:
def create_bot_principal(self, payload: CreateBotPrincipalRequest) -> BotPrincipal: def create_bot_principal(self, payload: CreateBotPrincipalRequest) -> BotPrincipal:
return self._repository.create_bot_principal( return self._repository.create_bot_principal(
name=payload.name, 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, permission=payload.permission,
description=payload.description, description=payload.description,
) )
@@ -112,6 +128,10 @@ class AuthService:
def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]: def list_bot_principals(self, *, is_active: bool | None = None) -> list[BotPrincipal]:
return self._repository.list_bot_principals(is_active=is_active) 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: def get_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
return self._repository.get_bot_principal(bot_principal_id) return self._repository.get_bot_principal(bot_principal_id)
@@ -142,14 +162,48 @@ class AuthService:
) )
return IssuedBotToken(token=raw_token, auth_token=auth_token) 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]: def list_bot_tokens(self, bot_principal_id: str) -> list[AuthToken]:
self._repository.get_bot_principal(bot_principal_id) self._repository.get_bot_principal(bot_principal_id)
return self._repository.list_bot_auth_tokens(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: def revoke_bot_token(self, bot_principal_id: str, token_id: str) -> None:
self._repository.get_bot_principal(bot_principal_id) self._repository.get_bot_principal(bot_principal_id)
self._repository.revoke_bot_auth_token(bot_principal_id, token_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 @staticmethod
def _build_expiration(expires_in_days: int | None, default_days: int) -> datetime: 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 days = expires_in_days if expires_in_days is not None else default_days
@@ -15,8 +15,10 @@
- 시스템은 `admin` 권한을 제외한 모든 사용자/토큰 요청에서 `owner_user_id`가 인증 주체와 일치하는 포트폴리오만 조회/변경/삭제 가능해야 한다. - 시스템은 `admin` 권한을 제외한 모든 사용자/토큰 요청에서 `owner_user_id`가 인증 주체와 일치하는 포트폴리오만 조회/변경/삭제 가능해야 한다.
- 시스템은 `admin` 권한 요청에서 소유권 제약 없이 포트폴리오 전체를 조회/변경/삭제할 수 있어야 한다. - 시스템은 `admin` 권한 요청에서 소유권 제약 없이 포트폴리오 전체를 조회/변경/삭제할 수 있어야 한다.
- 시스템은 소유권 불일치로 거부된 요청에 대해 `403 Forbidden`을 반환해야 한다. - 시스템은 소유권 불일치로 거부된 요청에 대해 `403 Forbidden`을 반환해야 한다.
- 시스템은 인증 컨텍스트에 사용자 식별자가 없는 토큰이 소유권이 필요한 포트폴리오 작업을 요청하면 `403 Forbidden`을 반환해야 한다. - 시스템은 봇 principal에 소유 사용자 식별자(`bot_principals.owner_user_id`)를 저장해야 하며, 봇 토큰 인증 시 해당 값을 실효 사용자 식별자로 해석해야 한다.
- 시스템은 사용자 귀속 정보가 해석되지 않는 토큰이 소유권이 필요한 포트폴리오 작업을 요청하면 `403 Forbidden`을 반환해야 한다.
- 시스템은 기존 포트폴리오 데이터에 대해 마이그레이션 시 `owner_user_id`를 null 허용으로 추가한 뒤, 백필 완료 후 not null 제약을 적용할 수 있어야 한다. - 시스템은 기존 포트폴리오 데이터에 대해 마이그레이션 시 `owner_user_id`를 null 허용으로 추가한 뒤, 백필 완료 후 not null 제약을 적용할 수 있어야 한다.
- 시스템은 기존 봇 principal 데이터에 대해 마이그레이션 시 `bot_principals.owner_user_id`를 null 허용으로 추가한 뒤, 백필 완료 후 not null 및 FK(`users.id`) 제약을 적용할 수 있어야 한다.
## Acceptance Criteria ## Acceptance Criteria
1. Given 인증된 일반 사용자(또는 해당 사용자에 귀속된 토큰)가 `POST /portfolios`를 호출할 때, When 포트폴리오가 생성되면, Then 저장된 `owner_user_id`는 요청자 사용자 식별자와 동일해야 하며 요청 본문으로 다른 소유주를 지정할 수 없어야 한다. 1. Given 인증된 일반 사용자(또는 해당 사용자에 귀속된 토큰)가 `POST /portfolios`를 호출할 때, When 포트폴리오가 생성되면, Then 저장된 `owner_user_id`는 요청자 사용자 식별자와 동일해야 하며 요청 본문으로 다른 소유주를 지정할 수 없어야 한다.
@@ -24,12 +26,13 @@
3. Given 일반 사용자 A가 목록 조회를 수행할 때, When `GET /portfolios`를 호출하면, Then 응답 목록에는 A 소유 포트폴리오만 포함되어야 한다. 3. Given 일반 사용자 A가 목록 조회를 수행할 때, When `GET /portfolios`를 호출하면, Then 응답 목록에는 A 소유 포트폴리오만 포함되어야 한다.
4. Given `admin` 권한 사용자가 존재할 때, When `GET /portfolios` 또는 특정 포트폴리오 변경 API를 호출하면, Then 시스템은 소유권 검사로 요청을 거부하지 않아야 한다. 4. Given `admin` 권한 사용자가 존재할 때, When `GET /portfolios` 또는 특정 포트폴리오 변경 API를 호출하면, Then 시스템은 소유권 검사로 요청을 거부하지 않아야 한다.
5. Given 마이그레이션 적용 전 생성된 레거시 포트폴리오가 존재할 때, When ownership 마이그레이션을 적용하면, Then 데이터 손실 없이 `owner_user_id`가 백필되고 이후 읽기/쓰기 API가 실패 없이 동작해야 한다. 5. Given 마이그레이션 적용 전 생성된 레거시 포트폴리오가 존재할 때, When ownership 마이그레이션을 적용하면, Then 데이터 손실 없이 `owner_user_id`가 백필되고 이후 읽기/쓰기 API가 실패 없이 동작해야 한다.
6. Given 인증 컨텍스트에 사용자 식별자가 없는 bot 토큰이 포트폴리오 생성/수정/삭제를 요청할 때, When 소유권이 필요한 API를 호출하면, Then 시스템은 `403`을 반환해야 한다. 6. Given 사용자 A에 귀속된 bot 토큰이 존재할 때, When bot 토큰이 `GET /portfolios/{portfolio_id}` 또는 변경 API를 호출하면, Then A 소유 포트폴리오에는 접근 가능하고 A가 아닌 사용자 소유 포트폴리오에는 `403`을 반환해야 한다.
7. Given 인증 컨텍스트에 사용자 식별자가 해석되지 않는 토큰(예: DB 비연결 정적 토큰)이 포트폴리오 생성/수정/삭제를 요청할 때, When 소유권이 필요한 API를 호출하면, Then 시스템은 `403`을 반환해야 한다.
## Impacted Area ## Impacted Area
- Docs: `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md`, `docs/technical/auth-and-token-lifecycle.md`, `docs/technical/api-contracts.md`, `docs/technical/persistence-and-migrations.md`, `docs/technical/portfolio-ownership-and-auth-design.md` - Docs: `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md`, `docs/technical/auth-and-token-lifecycle.md`, `docs/technical/api-contracts.md`, `docs/technical/persistence-and-migrations.md`, `docs/technical/portfolio-ownership-and-auth-design.md`
- Modules: `app/core/auth.py`, `app/api/portfolios.py`, `app/services/portfolio_service.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`, `app/domain/models.py`, `app/schemas/portfolio.py`, `migrations/versions/*` - Modules: `app/core/auth.py`, `app/api/portfolios.py`, `app/services/portfolio_service.py`, `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py`, `app/domain/models.py`, `app/schemas/portfolio.py`, `migrations/versions/*`
- Tests: `tests/test_app.py`, `tests/test_auth.py`, `tests/test_cli.py`, `tests/test_domain_services.py` - Tests: `tests/test_portfolio_ownership.py`, `tests/test_users.py`, `tests/test_auth.py`, `tests/test_portfolio_comments.py`
## Notes ## Notes
- 본 requirement는 인증 서비스 분리 여부와 무관하게 portfolio-hub 내부에서 반드시 충족되어야 하는 리소스 소유권 계약이다. - 본 requirement는 인증 서비스 분리 여부와 무관하게 portfolio-hub 내부에서 반드시 충족되어야 하는 리소스 소유권 계약이다.
@@ -72,3 +75,39 @@
#### Validation Plan #### Validation Plan
- 사용자별 소유권 격리 통합 테스트, admin 우회 테스트, 마이그레이션 백필 테스트를 통해 계약 충족 여부를 확인한다. - 사용자별 소유권 격리 통합 테스트, admin 우회 테스트, 마이그레이션 백필 테스트를 통해 계약 충족 여부를 확인한다.
### CHG-20260414-01
- Date: 2026-04-14
- Author: GitHub Copilot
#### Change Summary
- 봇 principal의 사용자 귀속(`bot_principals.owner_user_id`) 및 봇 토큰 실효 사용자 매핑 규칙을 requirement에 추가했다.
#### Intent
- 사용자별 봇 bearer token이 해당 사용자 포트폴리오 경계 내에서만 동작하도록 계약을 명확히 하기 위함이다.
#### Acceptance Criteria Delta
- Added:
- 사용자 귀속 bot 토큰의 소유권 허용/거부 기준 (AC6)
- 사용자 식별자 미해석 토큰 거부 기준 (AC7)
- Updated:
- Requirement 본문의 bot 토큰 처리 규칙
- Removed:
- 없음
#### Impacted Area Delta
- Docs:
- `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md`
- `docs/SRS/index.md`
- `docs/SRS/traceability-matrix.md`
- Modules:
- `app/repositories/postgres.py`
- `app/services/auth_service.py`
- `app/schemas/auth.py`
- `migrations/versions/20260414_0011_bot_principal_owner_user_id.py`
- Tests:
- `tests/test_portfolio_ownership.py`
- `tests/test_users.py`
#### Validation Plan
- 사용자 귀속 bot 토큰의 owner 허용/타 사용자 거부 시나리오와 봇 owner 백필 마이그레이션 적용 검증을 수행한다.
+1 -1
View File
@@ -27,11 +27,11 @@
| REQ-004 | 포트폴리오 소유주 기반 접근 제어 | Interface | Proposed | High | Justin | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | | REQ-004 | 포트폴리오 소유주 기반 접근 제어 | Interface | Proposed | High | Justin | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` |
## Recent Change Log Summary ## Recent Change Log Summary
- 2026-04-14 — `REQ-004`에 사용자 귀속 봇 principal(`bot_principals.owner_user_id`) 및 봇 토큰 실효 사용자 매핑 규칙(AC6, AC7)을 추가했다.
- 2026-04-13 — `REQ-004` 포트폴리오 소유주(`owner_user_id`) 및 소유권 기반 접근 제어 requirement를 추가했다. - 2026-04-13 — `REQ-004` 포트폴리오 소유주(`owner_user_id`) 및 소유권 기반 접근 제어 requirement를 추가했다.
- 2026-04-13 — `REQ-003`에서 태그 필터 기반 조회의 결정성 유지 책임을 소비 서비스(stock-picker)로 이관했다. - 2026-04-13 — `REQ-003`에서 태그 필터 기반 조회의 결정성 유지 책임을 소비 서비스(stock-picker)로 이관했다.
- 2026-04-13 — `REQ-003`에서 `sourceVersion`/`sourceTimestamp` 요구를 제거하고 태그 필터 기반 조회 결정성 계약으로 정리했다. - 2026-04-13 — `REQ-003`에서 `sourceVersion`/`sourceTimestamp` 요구를 제거하고 태그 필터 기반 조회 결정성 계약으로 정리했다.
- 2026-04-13 — `REQ-003`의 소비자 표현을 특정 서비스 전용에서 태그 기반 조회 소비 서비스 일반 계약으로 정리했다. - 2026-04-13 — `REQ-003`의 소비자 표현을 특정 서비스 전용에서 태그 기반 조회 소비 서비스 일반 계약으로 정리했다.
- 2026-04-13 — `REQ-002`의 소비자 표현을 특정 서비스 전용에서 다운스트림 소비자 일반 계약으로 정리했다.
## Status Transition Evidence ## Status Transition Evidence
- `Proposed` → `Approved`: Owner가 지정되고 requirement 문서 리뷰가 완료되며 acceptance criteria가 누락 없이 검토되어야 한다. - `Proposed` → `Approved`: Owner가 지정되고 requirement 문서 리뷰가 완료되며 acceptance criteria가 누락 없이 검토되어야 한다.
+1 -1
View File
@@ -14,7 +14,7 @@
| REQ-001 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-001_portfolio-comment-history.md` | #2 | TBD | TBD | Approved | In Progress | 2026-04-10 | | REQ-001 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-001_portfolio-comment-history.md` | #2 | TBD | TBD | Approved | In Progress | 2026-04-10 |
| REQ-002 | AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9 | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 | | REQ-002 | AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9 | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 |
| REQ-003 | AC1, AC2, AC3, AC4, AC5 | `docs/SRS/Data/REQ-003_portfolio-required-fields.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 | | REQ-003 | AC1, AC2, AC3, AC4, AC5 | `docs/SRS/Data/REQ-003_portfolio-required-fields.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 |
| REQ-004 | AC1, AC2, AC3, AC4, AC5, AC6 | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | TBD | TBD | `tests/test_portfolio_ownership.py`, `tests/test_auth.py`, `tests/test_portfolio_comments.py`, local `pytest` pass (2026-04-14) | Proposed | In Progress | 2026-04-14 | | REQ-004 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | TBD | TBD | `tests/test_portfolio_ownership.py`, `tests/test_users.py`, `tests/test_auth.py`, `tests/test_portfolio_comments.py`, local `pytest` pass (2026-04-14) | Proposed | In Progress | 2026-04-14 |
## Update Rules ## Update Rules
- REQ ID must exactly match the SRS requirement file prefix. - REQ ID must exactly match the SRS requirement file prefix.
+46 -4
View File
@@ -32,10 +32,16 @@
- `DELETE /auth/tokens/{token_id}` - `DELETE /auth/tokens/{token_id}`
- `POST /auth/bots` - `POST /auth/bots`
- `GET /auth/bots` - `GET /auth/bots`
- `GET /auth/bots/{bot_id}`
- `POST /auth/bots/{bot_id}/tokens` - `POST /auth/bots/{bot_id}/tokens`
- `GET /auth/bots/{bot_id}/tokens` - `GET /auth/bots/{bot_id}/tokens`
- `DELETE /auth/bots/{bot_id}/tokens/{token_id}` - `DELETE /auth/bots/{bot_id}/tokens/{token_id}`
- `DELETE /auth/bots/{bot_id}` - `DELETE /auth/bots/{bot_id}`
- `POST /auth/me/bots`
- `GET /auth/me/bots`
- `POST /auth/me/bots/{bot_id}/tokens`
- `GET /auth/me/bots/{bot_id}/tokens`
- `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}`
### 포트폴리오 ### 포트폴리오
- `GET /portfolios` - `GET /portfolios`
@@ -427,18 +433,46 @@ curl -X POST "http://localhost:8000/auth/tokens" \
- `GET /auth/bots/{bot_id}/tokens`: admin only, `AuthTokenResponse[]` - `GET /auth/bots/{bot_id}/tokens`: admin only, `AuthTokenResponse[]`
- `DELETE /auth/bots/{bot_id}/tokens/{token_id}`: admin only, `204` - `DELETE /auth/bots/{bot_id}/tokens/{token_id}`: admin only, `204`
소유권/권한 제약:
- bot principal은 `owner_user_id`를 필수로 가진다.
- bot permission은 `read` 또는 `trade-write`만 허용한다.
- bot 토큰 인증 컨텍스트의 실효 `user_id`는 bot owner(`owner_user_id`)로 해석된다.
#### 사용자 전용(Self-service) 봇 관리
- `POST /auth/me/bots`: DB-issued `read` 이상 사용자 토큰, `CreateSelfBotPrincipalRequest` -> `BotPrincipalResponse`
- `GET /auth/me/bots`: DB-issued `read` 이상 사용자 토큰, 본인 소유 bot 목록
- `POST /auth/me/bots/{bot_id}/tokens`: DB-issued `read` 이상 사용자 토큰, `CreateBotTokenRequest` -> `IssuedBotTokenResponse`
- `GET /auth/me/bots/{bot_id}/tokens`: DB-issued `read` 이상 사용자 토큰, `AuthTokenResponse[]`
- `DELETE /auth/me/bots/{bot_id}/tokens/{token_id}`: DB-issued `read` 이상 사용자 토큰, `204`
제약:
- `/auth/me/bots*` 경로는 요청자 본인 소유 bot(`owner_user_id == auth_context.user_id`)에만 허용된다.
- 타 사용자 소유 bot 접근 시 `403`.
대표 요청: 대표 요청:
```bash ```bash
curl -X POST "http://localhost:8000/auth/bots" \ curl -X POST "http://localhost:8000/auth/bots" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \ -H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"name": "rebalance-bot", "permission": "trade-write", "description": "nightly jobs"}' -d '{"name": "rebalance-bot", "owner_user_id": "user-1", "permission": "trade-write", "description": "nightly jobs"}'
curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \ curl -X POST "http://localhost:8000/auth/bots/bot-1/tokens" \
-H "Authorization: Bearer <ADMIN_TOKEN>" \ -H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"name": "nightly", "permission": "trade-write", "expires_in_days": 90}' -d '{"name": "nightly", "permission": "trade-write", "expires_in_days": 90}'
curl -X POST "http://localhost:8000/auth/me/bots" \
-H "Authorization: Bearer <USER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "my-bot", "permission": "read", "description": "personal automation"}'
curl -X POST "http://localhost:8000/auth/me/bots/bot-1/tokens" \
-H "Authorization: Bearer <USER_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "personal-key", "permission": "read", "expires_in_days": 30}'
``` ```
### 사용자 ### 사용자
@@ -543,13 +577,20 @@ curl -X POST "http://localhost:8000/users" \
`CreateBotPrincipalRequest` `CreateBotPrincipalRequest`
- `name: str` - `name: str`
- `permission: "read" | "trade-write" | "admin"` - `owner_user_id: str`
- `permission: "read" | "trade-write"`
- `description: str | null`
`CreateSelfBotPrincipalRequest`
- `name: str`
- `permission: "read" | "trade-write"`
- `description: str | null` - `description: str | null`
`CreateBotTokenRequest` `CreateBotTokenRequest`
- `name: str` - `name: str`
- `permission: "read" | "trade-write" | "admin" | null` - `permission: "read" | "trade-write" | null`
- `expires_in_days: int | null` - `expires_in_days: int | null`
`CreateUserRequest` `CreateUserRequest`
@@ -628,7 +669,8 @@ curl -X POST "http://localhost:8000/users" \
- `id: str` - `id: str`
- `name: str` - `name: str`
- `permission: "read" | "trade-write" | "admin"` - `owner_user_id: str`
- `permission: "read" | "trade-write"`
- `description: str | null` - `description: str | null`
- `is_active: bool` - `is_active: bool`
- `created_at: datetime | null` - `created_at: datetime | null`
@@ -33,6 +33,31 @@
- 환경변수 기반 정적 토큰: `PORTFOLIOHUB_*_TOKENS` - 환경변수 기반 정적 토큰: `PORTFOLIOHUB_*_TOKENS`
- DB 저장 토큰: 로그인 세션/클라이언트 토큰/봇 토큰 - DB 저장 토큰: 로그인 세션/클라이언트 토큰/봇 토큰
봇 토큰 사용자 컨텍스트 정책:
- 봇 principal은 `owner_user_id`를 가진다.
- 봇 토큰 인증 시 실효 사용자 식별자는 `bot_principals.owner_user_id`로 해석한다.
- 따라서 소유권이 필요한 포트폴리오 API에서 봇 토큰은 소유 사용자 경계 내에서만 동작한다.
## 봇 관리 API 정책
- Admin 경로:
- `POST /auth/bots`
- `GET /auth/bots`
- `GET /auth/bots/{bot_id}`
- `DELETE /auth/bots/{bot_id}`
- `POST/GET/DELETE /auth/bots/{bot_id}/tokens*`
- Self-service 경로(사용자 본인 전용):
- `POST /auth/me/bots`
- `GET /auth/me/bots`
- `POST/GET/DELETE /auth/me/bots/{bot_id}/tokens*`
제약:
- self-service 경로는 DB 사용자 컨텍스트가 있는 사용자 토큰에서만 허용한다.
- self-service 경로는 요청자의 소유 봇(`owner_user_id == auth_context.user_id`)에만 허용한다.
- 봇 권한은 `read` 또는 `trade-write`만 허용한다.
## 토큰 라이프사이클 ## 토큰 라이프사이클
1. 사용자 로그인(`POST /auth/login`) 또는 토큰 발급 API 호출 1. 사용자 로그인(`POST /auth/login`) 또는 토큰 발급 API 호출
2. 토큰 해시 저장 및 prefix 관리 2. 토큰 해시 저장 및 prefix 관리
@@ -75,6 +75,7 @@ FOREIGN KEY (owner_user_id) REFERENCES users(id);
- 2단계: admin이 아니면 ownership 검사 수행 - 2단계: admin이 아니면 ownership 검사 수행
- ownership 검사 기준: `portfolio.owner_user_id == auth_context.user_id` - ownership 검사 기준: `portfolio.owner_user_id == auth_context.user_id`
- `auth_context.user_id`가 없는 요청은 ownership 필요한 엔드포인트에서 `403` - `auth_context.user_id`가 없는 요청은 ownership 필요한 엔드포인트에서 `403`
- 봇 토큰(`token_type=bot`)의 `auth_context.user_id`는 `bot_principals.owner_user_id`로 해석
## Endpoint Policy Matrix ## Endpoint Policy Matrix
@@ -92,6 +93,16 @@ FOREIGN KEY (owner_user_id) REFERENCES users(id);
| `DELETE /portfolios/{portfolio_id}` | admin | admin only | 기존 정책 유지 | | `DELETE /portfolios/{portfolio_id}` | admin | admin only | 기존 정책 유지 |
| `POST/GET/PATCH/DELETE /portfolios/{id}/comments*` | read or trade-write | 적용 | 상위 포트폴리오 ownership을 재사용 | | `POST/GET/PATCH/DELETE /portfolios/{id}/comments*` | read or trade-write | 적용 | 상위 포트폴리오 ownership을 재사용 |
## Bot Principal Ownership 정책
- `bot_principals.owner_user_id`는 필수이며 `users.id` FK를 가진다.
- 봇 principal 생성 정책:
- Admin: 임의 사용자 owner로 생성 가능
- Self-service 사용자: 본인 owner로만 생성 가능
- 봇 토큰 관리 정책:
- Admin 경로는 전체 봇 대상으로 관리 가능
- Self-service 경로는 본인 소유 봇 대상으로만 관리 가능
## 서비스/리포지토리 반영 포인트 ## 서비스/리포지토리 반영 포인트
- Domain model: - Domain model:
- `Portfolio`에 `owner_user_id` 추가 - `Portfolio`에 `owner_user_id` 추가
@@ -116,13 +127,16 @@ FOREIGN KEY (owner_user_id) REFERENCES users(id);
1. 기존 portfolios row가 존재할 때 owner_user_id 백필 후 null row가 0건인지 검증 1. 기존 portfolios row가 존재할 때 owner_user_id 백필 후 null row가 0건인지 검증
2. users가 없는 상태에서 migration 실행 시 실패 동작 검증 2. users가 없는 상태에서 migration 실행 시 실패 동작 검증
3. downgrade 시 인덱스/제약 해제가 순서대로 수행되는지 검증 3. downgrade 시 인덱스/제약 해제가 순서대로 수행되는지 검증
4. 기존 bot_principals row가 존재할 때 owner_user_id 백필 후 null row가 0건인지 검증
5. bot_principals.owner_user_id FK(`users.id`) 제약 생성/해제 검증
## API Authorization ## API Authorization
1. 사용자 A 소유 portfolio를 사용자 B가 조회하면 403 1. 사용자 A 소유 portfolio를 사용자 B가 조회하면 403
2. 사용자 A 소유 portfolio를 사용자 B가 수정/매매/리셋하면 403 2. 사용자 A 소유 portfolio를 사용자 B가 수정/매매/리셋하면 403
3. admin이 타 사용자 portfolio 조회/수정 가능 3. admin이 타 사용자 portfolio 조회/수정 가능
4. 일반 사용자의 `GET /portfolios`는 본인 소유만 반환 4. 일반 사용자의 `GET /portfolios`는 본인 소유만 반환
5. user_id 없는 bot principal 요청이 ownership 필요 API 호출 시 403 5. 사용자 A에 귀속된 bot 토큰은 A 소유 portfolio만 접근 가능
6. 사용자 B가 A 소유 bot의 self-service 토큰 발급/조회/폐기를 시도하면 403
## API Creation ## API Creation
1. `POST /portfolios`에서 owner_user_id가 인증 사용자로 저장되는지 검증 1. `POST /portfolios`에서 owner_user_id가 인증 사용자로 저장되는지 검증
@@ -0,0 +1,97 @@
"""restore bot principal owner user id
Revision ID: 20260414_0011
Revises: 20260413_0010
Create Date: 2026-04-14 10:30:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260414_0011"
down_revision = "20260413_0010"
branch_labels = None
depends_on = None
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 _fk_exists(inspector, table_name: str, fk_name: str) -> bool:
return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _column_exists(inspector, "bot_principals", "owner_user_id"):
op.add_column("bot_principals", sa.Column("owner_user_id", sa.Text(), nullable=True))
bind.execute(
sa.text(
"""
WITH fallback_owner AS (
SELECT id
FROM users
ORDER BY created_at ASC, id ASC
LIMIT 1
)
UPDATE bot_principals b
SET owner_user_id = f.id
FROM fallback_owner f
WHERE b.owner_user_id IS NULL
"""
)
)
unresolved = bind.execute(sa.text("SELECT COUNT(*) FROM bot_principals WHERE owner_user_id IS NULL")).scalar()
if unresolved and int(unresolved) > 0:
raise RuntimeError("bot_principals.owner_user_id backfill failed: users 테이블에 fallback 소유주가 필요합니다")
op.alter_column("bot_principals", "owner_user_id", existing_type=sa.Text(), nullable=False)
inspector = inspect(bind)
if not _index_exists(inspector, "bot_principals", "idx_bot_principals_owner_active_created"):
op.create_index(
"idx_bot_principals_owner_active_created",
"bot_principals",
["owner_user_id", "is_active", sa.text("created_at DESC")],
unique=False,
)
inspector = inspect(bind)
if not _fk_exists(inspector, "bot_principals", "fk_bot_principals_owner_user"):
op.create_foreign_key(
"fk_bot_principals_owner_user",
"bot_principals",
"users",
["owner_user_id"],
["id"],
ondelete="RESTRICT",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _fk_exists(inspector, "bot_principals", "fk_bot_principals_owner_user"):
op.drop_constraint("fk_bot_principals_owner_user", "bot_principals", type_="foreignkey")
inspector = inspect(bind)
if _index_exists(inspector, "bot_principals", "idx_bot_principals_owner_active_created"):
op.drop_index("idx_bot_principals_owner_active_created", table_name="bot_principals")
inspector = inspect(bind)
if _column_exists(inspector, "bot_principals", "owner_user_id"):
op.drop_column("bot_principals", "owner_user_id")
+1 -1
View File
@@ -119,7 +119,7 @@ class FakeRepo:
def get_bot_principal(self, bot_principal_id: str): def get_bot_principal(self, bot_principal_id: str):
raise ValueError("봇을 찾을 수 없습니다") raise ValueError("봇을 찾을 수 없습니다")
def list_bot_principals(self, *, is_active=None): def list_bot_principals(self, *, is_active=None, owner_user_id=None):
return [] return []
def deactivate_bot_principal(self, *args, **kwargs): def deactivate_bot_principal(self, *args, **kwargs):
+19
View File
@@ -108,6 +108,25 @@ def test_non_owner_gets_403_on_db_auth(monkeypatch) -> None:
assert response.json()["detail"] == "Portfolio ownership mismatch" assert response.json()["detail"] == "Portfolio ownership mismatch"
def test_owner_bound_bot_token_can_read_owned_portfolio(monkeypatch) -> None:
token = "owner-bot-token"
principal = TokenPrincipal(
token_id="t-bot-1",
user_id="owner-1",
permission="read",
token_type="bot",
bot_principal_id="bot-1",
)
repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={hash_token(token): principal})
client = _configure_client(monkeypatch, repo)
response = client.get("/portfolios/p1", headers={"Authorization": f"Bearer {token}"})
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["portfolio"]["owner_user_id"] == "owner-1"
def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> None: def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> None:
repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={}) repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={})
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
+85 -4
View File
@@ -163,21 +163,33 @@ class FakeUserRepo:
if token.expires_at is not None and token.expires_at <= datetime.now(timezone.utc): if token.expires_at is not None and token.expires_at <= datetime.now(timezone.utc):
return None return None
token.last_used_at = datetime.now(timezone.utc) token.last_used_at = datetime.now(timezone.utc)
if token.bot_principal_id is not None:
bot = self.get_bot_principal(token.bot_principal_id)
return TokenPrincipal(
token_id=principal.token_id,
user_id=bot.owner_user_id,
permission=principal.permission,
token_type=principal.token_type,
bot_principal_id=principal.bot_principal_id,
)
return principal return principal
def create_bot_principal( def create_bot_principal(
self, self,
*, *,
name: str, name: str,
owner_user_id: str,
permission: str, permission: str,
description: str | None = None, description: str | None = None,
) -> BotPrincipal: ) -> BotPrincipal:
if any(item.name == name for item in self.bots.values()): if any(item.name == name for item in self.bots.values()):
raise ValueError(f"이미 존재하는 bot name입니다: {name}") raise ValueError(f"이미 존재하는 bot name입니다: {name}")
self.get_user(owner_user_id)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
bot = BotPrincipal( bot = BotPrincipal(
id=f"bot-{self.next_bot_id}", id=f"bot-{self.next_bot_id}",
name=name, name=name,
owner_user_id=owner_user_id,
permission=permission, permission=permission,
description=description, description=description,
is_active=True, is_active=True,
@@ -194,10 +206,17 @@ class FakeUserRepo:
raise ValueError("봇을 찾을 수 없습니다") raise ValueError("봇을 찾을 수 없습니다")
return bot return bot
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]:
items = list(self.bots.values()) items = list(self.bots.values())
if is_active is not None: if is_active is not None:
items = [item for item in items if item.is_active == is_active] items = [item for item in items if item.is_active == is_active]
if owner_user_id is not None:
items = [item for item in items if item.owner_user_id == owner_user_id]
return sorted(items, key=lambda item: item.name) return sorted(items, key=lambda item: item.name)
def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal: def deactivate_bot_principal(self, bot_principal_id: str) -> BotPrincipal:
@@ -340,7 +359,12 @@ def test_admin_can_create_bot_and_issue_token(monkeypatch) -> None:
bot_response = client.post( bot_response = client.post(
"/auth/bots", "/auth/bots",
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "market-bot", "permission": "trade-write", "description": "external execution bot"}, json={
"name": "market-bot",
"owner_user_id": admin.id,
"permission": "trade-write",
"description": "external execution bot",
},
) )
assert bot_response.status_code == 201 assert bot_response.status_code == 201
bot_id = bot_response.json()["id"] bot_id = bot_response.json()["id"]
@@ -373,7 +397,7 @@ def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
bot_response = client.post( bot_response = client.post(
"/auth/bots", "/auth/bots",
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "ops-bot", "permission": "read"}, json={"name": "ops-bot", "owner_user_id": admin.id, "permission": "read"},
) )
assert bot_response.status_code == 201 assert bot_response.status_code == 201
bot_id = bot_response.json()["id"] bot_id = bot_response.json()["id"]
@@ -396,4 +420,61 @@ def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
app.dependency_overrides.clear() app.dependency_overrides.clear()
assert denied_response.status_code == 401 assert denied_response.status_code == 401
assert denied_response.json()["detail"] == "Invalid token" assert denied_response.json()["detail"] == "Invalid token"
def test_user_can_create_and_issue_owned_bot_token(monkeypatch) -> None:
repo = FakeUserRepo()
user = repo.create_user("alice", password_hash=hash_password("alice123!"), permission="trade-write")
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "alice", "password": "alice123!"})
assert login_response.status_code == 200
user_token = login_response.json()["access_token"]
bot_response = client.post(
"/auth/me/bots",
headers={"Authorization": f"Bearer {user_token}"},
json={"name": "alice-bot", "permission": "read", "description": "personal bot"},
)
assert bot_response.status_code == 201
bot_id = bot_response.json()["id"]
assert bot_response.json()["owner_user_id"] == user.id
list_response = client.get("/auth/me/bots", headers={"Authorization": f"Bearer {user_token}"})
assert list_response.status_code == 200
assert [item["id"] for item in list_response.json()] == [bot_id]
issue_response = client.post(
f"/auth/me/bots/{bot_id}/tokens",
headers={"Authorization": f"Bearer {user_token}"},
json={"name": "alice-key", "permission": "read"},
)
app.dependency_overrides.clear()
assert issue_response.status_code == 201
payload = issue_response.json()
assert payload["auth_token"]["token_type"] == "bot"
assert payload["auth_token"]["bot_principal_id"] == bot_id
def test_user_cannot_issue_token_for_other_users_bot(monkeypatch) -> None:
repo = FakeUserRepo()
owner = repo.create_user("owner", password_hash=hash_password("owner123!"), permission="trade-write")
other = repo.create_user("other", password_hash=hash_password("other123!"), permission="trade-write")
bot = repo.create_bot_principal(name="owner-bot", owner_user_id=owner.id, permission="read")
client = _client_with_repo(monkeypatch, repo)
login_response = client.post("/auth/login", json={"username": "other", "password": "other123!"})
assert login_response.status_code == 200
other_token = login_response.json()["access_token"]
issue_response = client.post(
f"/auth/me/bots/{bot.id}/tokens",
headers={"Authorization": f"Bearer {other_token}"},
json={"name": "invalid-key", "permission": "read"},
)
app.dependency_overrides.clear()
assert issue_response.status_code == 403
assert issue_response.json()["detail"] == "봇 접근 권한이 없습니다"