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,
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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가 누락 없이 검토되어야 한다.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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"] == "봇 접근 권한이 없습니다"
|
||||||
Reference in New Issue
Block a user