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
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
- 시스템은 `admin` 권한을 제외한 모든 사용자/토큰 요청에서 `owner_user_id`가 인증 주체와 일치하는 포트폴리오만 조회/변경/삭제 가능해야 한다.
|
||||
- 시스템은 `admin` 권한 요청에서 소유권 제약 없이 포트폴리오 전체를 조회/변경/삭제할 수 있어야 한다.
|
||||
- 시스템은 소유권 불일치로 거부된 요청에 대해 `403 Forbidden`을 반환해야 한다.
|
||||
- 시스템은 인증 컨텍스트에 사용자 식별자가 없는 토큰이 소유권이 필요한 포트폴리오 작업을 요청하면 `403 Forbidden`을 반환해야 한다.
|
||||
- 시스템은 봇 principal에 소유 사용자 식별자(`bot_principals.owner_user_id`)를 저장해야 하며, 봇 토큰 인증 시 해당 값을 실효 사용자 식별자로 해석해야 한다.
|
||||
- 시스템은 사용자 귀속 정보가 해석되지 않는 토큰이 소유권이 필요한 포트폴리오 작업을 요청하면 `403 Forbidden`을 반환해야 한다.
|
||||
- 시스템은 기존 포트폴리오 데이터에 대해 마이그레이션 시 `owner_user_id`를 null 허용으로 추가한 뒤, 백필 완료 후 not null 제약을 적용할 수 있어야 한다.
|
||||
- 시스템은 기존 봇 principal 데이터에 대해 마이그레이션 시 `bot_principals.owner_user_id`를 null 허용으로 추가한 뒤, 백필 완료 후 not null 및 FK(`users.id`) 제약을 적용할 수 있어야 한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given 인증된 일반 사용자(또는 해당 사용자에 귀속된 토큰)가 `POST /portfolios`를 호출할 때, When 포트폴리오가 생성되면, Then 저장된 `owner_user_id`는 요청자 사용자 식별자와 동일해야 하며 요청 본문으로 다른 소유주를 지정할 수 없어야 한다.
|
||||
@@ -24,12 +26,13 @@
|
||||
3. Given 일반 사용자 A가 목록 조회를 수행할 때, When `GET /portfolios`를 호출하면, Then 응답 목록에는 A 소유 포트폴리오만 포함되어야 한다.
|
||||
4. Given `admin` 권한 사용자가 존재할 때, When `GET /portfolios` 또는 특정 포트폴리오 변경 API를 호출하면, Then 시스템은 소유권 검사로 요청을 거부하지 않아야 한다.
|
||||
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
|
||||
- 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/*`
|
||||
- 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
|
||||
- 본 requirement는 인증 서비스 분리 여부와 무관하게 portfolio-hub 내부에서 반드시 충족되어야 하는 리소스 소유권 계약이다.
|
||||
@@ -72,3 +75,39 @@
|
||||
|
||||
#### Validation Plan
|
||||
- 사용자별 소유권 격리 통합 테스트, 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
@@ -27,11 +27,11 @@
|
||||
| REQ-004 | 포트폴리오 소유주 기반 접근 제어 | Interface | Proposed | High | Justin | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` |
|
||||
|
||||
## 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-003`에서 태그 필터 기반 조회의 결정성 유지 책임을 소비 서비스(stock-picker)로 이관했다.
|
||||
- 2026-04-13 — `REQ-003`에서 `sourceVersion`/`sourceTimestamp` 요구를 제거하고 태그 필터 기반 조회 결정성 계약으로 정리했다.
|
||||
- 2026-04-13 — `REQ-003`의 소비자 표현을 특정 서비스 전용에서 태그 기반 조회 소비 서비스 일반 계약으로 정리했다.
|
||||
- 2026-04-13 — `REQ-002`의 소비자 표현을 특정 서비스 전용에서 다운스트림 소비자 일반 계약으로 정리했다.
|
||||
|
||||
## Status Transition Evidence
|
||||
- `Proposed` → `Approved`: Owner가 지정되고 requirement 문서 리뷰가 완료되며 acceptance criteria가 누락 없이 검토되어야 한다.
|
||||
|
||||
@@ -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-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-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
|
||||
- REQ ID must exactly match the SRS requirement file prefix.
|
||||
|
||||
@@ -32,10 +32,16 @@
|
||||
- `DELETE /auth/tokens/{token_id}`
|
||||
- `POST /auth/bots`
|
||||
- `GET /auth/bots`
|
||||
- `GET /auth/bots/{bot_id}`
|
||||
- `POST /auth/bots/{bot_id}/tokens`
|
||||
- `GET /auth/bots/{bot_id}/tokens`
|
||||
- `DELETE /auth/bots/{bot_id}/tokens/{token_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`
|
||||
@@ -427,18 +433,46 @@ curl -X POST "http://localhost:8000/auth/tokens" \
|
||||
- `GET /auth/bots/{bot_id}/tokens`: admin only, `AuthTokenResponse[]`
|
||||
- `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
|
||||
curl -X POST "http://localhost:8000/auth/bots" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-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" \
|
||||
-H "Authorization: Bearer <ADMIN_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-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`
|
||||
|
||||
- `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`
|
||||
|
||||
`CreateBotTokenRequest`
|
||||
|
||||
- `name: str`
|
||||
- `permission: "read" | "trade-write" | "admin" | null`
|
||||
- `permission: "read" | "trade-write" | null`
|
||||
- `expires_in_days: int | null`
|
||||
|
||||
`CreateUserRequest`
|
||||
@@ -628,7 +669,8 @@ curl -X POST "http://localhost:8000/users" \
|
||||
|
||||
- `id: str`
|
||||
- `name: str`
|
||||
- `permission: "read" | "trade-write" | "admin"`
|
||||
- `owner_user_id: str`
|
||||
- `permission: "read" | "trade-write"`
|
||||
- `description: str | null`
|
||||
- `is_active: bool`
|
||||
- `created_at: datetime | null`
|
||||
|
||||
@@ -33,6 +33,31 @@
|
||||
- 환경변수 기반 정적 토큰: `PORTFOLIOHUB_*_TOKENS`
|
||||
- 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 호출
|
||||
2. 토큰 해시 저장 및 prefix 관리
|
||||
|
||||
@@ -75,6 +75,7 @@ FOREIGN KEY (owner_user_id) REFERENCES users(id);
|
||||
- 2단계: admin이 아니면 ownership 검사 수행
|
||||
- ownership 검사 기준: `portfolio.owner_user_id == auth_context.user_id`
|
||||
- `auth_context.user_id`가 없는 요청은 ownership 필요한 엔드포인트에서 `403`
|
||||
- 봇 토큰(`token_type=bot`)의 `auth_context.user_id`는 `bot_principals.owner_user_id`로 해석
|
||||
|
||||
## Endpoint Policy Matrix
|
||||
|
||||
@@ -92,6 +93,16 @@ FOREIGN KEY (owner_user_id) REFERENCES users(id);
|
||||
| `DELETE /portfolios/{portfolio_id}` | admin | admin only | 기존 정책 유지 |
|
||||
| `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:
|
||||
- `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건인지 검증
|
||||
2. users가 없는 상태에서 migration 실행 시 실패 동작 검증
|
||||
3. downgrade 시 인덱스/제약 해제가 순서대로 수행되는지 검증
|
||||
4. 기존 bot_principals row가 존재할 때 owner_user_id 백필 후 null row가 0건인지 검증
|
||||
5. bot_principals.owner_user_id FK(`users.id`) 제약 생성/해제 검증
|
||||
|
||||
## API Authorization
|
||||
1. 사용자 A 소유 portfolio를 사용자 B가 조회하면 403
|
||||
2. 사용자 A 소유 portfolio를 사용자 B가 수정/매매/리셋하면 403
|
||||
3. admin이 타 사용자 portfolio 조회/수정 가능
|
||||
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
|
||||
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")
|
||||
@@ -119,7 +119,7 @@ class FakeRepo:
|
||||
def get_bot_principal(self, bot_principal_id: str):
|
||||
raise ValueError("봇을 찾을 수 없습니다")
|
||||
|
||||
def list_bot_principals(self, *, is_active=None):
|
||||
def list_bot_principals(self, *, is_active=None, owner_user_id=None):
|
||||
return []
|
||||
|
||||
def deactivate_bot_principal(self, *args, **kwargs):
|
||||
|
||||
@@ -108,6 +108,25 @@ def test_non_owner_gets_403_on_db_auth(monkeypatch) -> None:
|
||||
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:
|
||||
repo = FakeRepo(portfolio=_portfolio("owner-1"), principals={})
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
+85
-4
@@ -163,21 +163,33 @@ class FakeUserRepo:
|
||||
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)
|
||||
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
|
||||
|
||||
def create_bot_principal(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
owner_user_id: str,
|
||||
permission: str,
|
||||
description: str | None = None,
|
||||
) -> BotPrincipal:
|
||||
if any(item.name == name for item in self.bots.values()):
|
||||
raise ValueError(f"이미 존재하는 bot name입니다: {name}")
|
||||
self.get_user(owner_user_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
bot = BotPrincipal(
|
||||
id=f"bot-{self.next_bot_id}",
|
||||
name=name,
|
||||
owner_user_id=owner_user_id,
|
||||
permission=permission,
|
||||
description=description,
|
||||
is_active=True,
|
||||
@@ -194,10 +206,17 @@ class FakeUserRepo:
|
||||
raise ValueError("봇을 찾을 수 없습니다")
|
||||
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())
|
||||
if is_active is not None:
|
||||
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)
|
||||
|
||||
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(
|
||||
"/auth/bots",
|
||||
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
|
||||
bot_id = bot_response.json()["id"]
|
||||
@@ -373,7 +397,7 @@ def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
|
||||
bot_response = client.post(
|
||||
"/auth/bots",
|
||||
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
|
||||
bot_id = bot_response.json()["id"]
|
||||
@@ -396,4 +420,61 @@ def test_deactivated_bot_token_is_rejected(monkeypatch) -> None:
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
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"] == "봇 접근 권한이 없습니다"
|
||||
Reference in New Issue
Block a user