Merge pull request #19 from fourblocks2020/feat/dcy-011-daemon-crud-self-service-credential-api
dcy-011: 데몬 CRUD + 사용자 self-service 인증정보 관리 API — BAT-022 (REQ-052/053/054)
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""POST/PATCH /v1/admin/daemons — admin daemon CRUD (REQ-053)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, require_admin
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import DaemonAdminResponse, DaemonCreateRequest, DaemonUpdateRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/admin", tags=["v1-admin"])
|
||||
|
||||
|
||||
def _get_repo(settings=None):
|
||||
from app.core.config import get_settings as _gs
|
||||
return get_portfolio_repository_from_settings(settings or _gs())
|
||||
|
||||
|
||||
def _to_response(daemon) -> DaemonAdminResponse:
|
||||
return DaemonAdminResponse(
|
||||
id=daemon.id,
|
||||
daemon_slug=daemon.daemon_slug,
|
||||
display_name=daemon.display_name,
|
||||
owner_user_id=daemon.owner_user_id,
|
||||
is_active=daemon.is_active,
|
||||
)
|
||||
|
||||
|
||||
def _daemon_error_to_http(exc: ValueError) -> HTTPException:
|
||||
detail = str(exc)
|
||||
if "찾을 수 없습니다" in detail:
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
||||
if "이미 존재하는 daemon slug" in detail:
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
if "사용자를 찾을 수 없습니다" in detail:
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /v1/admin/daemons — 데몬 생성 (REQ-053 AC-1/2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/daemons", response_model=DaemonAdminResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_daemon(
|
||||
body: DaemonCreateRequest,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> DaemonAdminResponse:
|
||||
"""Create a new daemon and assign owner (admin only). REQ-053 AC-1/2."""
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
daemon = repo.create_daemon(
|
||||
body.owner_user_id,
|
||||
daemon_slug=body.daemon_slug,
|
||||
display_name=body.display_name,
|
||||
)
|
||||
return _to_response(daemon)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /v1/admin/daemons/{daemon_id} — display_name/owner 변경 (REQ-053 AC-3/4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.patch("/daemons/{daemon_id}", response_model=DaemonAdminResponse)
|
||||
def update_daemon(
|
||||
daemon_id: str,
|
||||
body: DaemonUpdateRequest,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> DaemonAdminResponse:
|
||||
"""Update daemon display_name and/or owner.
|
||||
|
||||
Owner change auto-invalidates all broker credentials for this daemon (REQ-053 AC-4).
|
||||
"""
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
existing = repo.get_daemon_by_id(daemon_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
|
||||
|
||||
if body.owner_user_id is not None and body.owner_user_id != existing.owner_user_id:
|
||||
# Owner change: update owner + invalidate credentials
|
||||
try:
|
||||
daemon = repo.update_daemon_owner(daemon_id, owner_user_id=body.owner_user_id)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
# Apply display_name change if also requested
|
||||
if body.display_name is not None:
|
||||
try:
|
||||
daemon = repo.update_daemon(daemon_id, display_name=body.display_name)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
else:
|
||||
# No owner change — update display_name only if provided
|
||||
if body.display_name is not None:
|
||||
try:
|
||||
daemon = repo.update_daemon(daemon_id, display_name=body.display_name)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
else:
|
||||
daemon = existing
|
||||
|
||||
return _to_response(daemon)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /v1/admin/daemons/{daemon_id}/deactivate (REQ-053 AC-6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/daemons/{daemon_id}/deactivate", response_model=DaemonAdminResponse)
|
||||
def deactivate_daemon(
|
||||
daemon_id: str,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> DaemonAdminResponse:
|
||||
"""Deactivate daemon and auto-revoke bound service tokens (REQ-053 AC-6, REQ-022)."""
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
existing = repo.get_daemon_by_id(daemon_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
|
||||
|
||||
daemon = repo.update_daemon(daemon_id, is_active=False)
|
||||
# REQ-022: auto-revoke service tokens bound to this daemon
|
||||
revoked = repo.revoke_tokens_by_daemon_id(daemon_id)
|
||||
logger.info(
|
||||
"daemon %s deactivated; revoked %d service token(s)",
|
||||
daemon_id,
|
||||
revoked,
|
||||
)
|
||||
return _to_response(daemon)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /v1/admin/daemons/{daemon_id}/activate (REQ-053 AC-7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/daemons/{daemon_id}/activate", response_model=DaemonAdminResponse)
|
||||
def activate_daemon(
|
||||
daemon_id: str,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> DaemonAdminResponse:
|
||||
"""Re-activate a deactivated daemon. REQ-053 AC-7."""
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
existing = repo.get_daemon_by_id(daemon_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
|
||||
|
||||
daemon = repo.update_daemon(daemon_id, is_active=True)
|
||||
return _to_response(daemon)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /v1/admin/daemons/{daemon_id} — 하드 삭제 미제공 → 405 (REQ-053 AC-8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/daemons/{daemon_id}", status_code=status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
def delete_daemon_not_allowed(
|
||||
daemon_id: str,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
) -> Response:
|
||||
"""Hard delete is not supported — deactivate instead. REQ-053 AC-8."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
detail="Hard delete not supported. Use POST /v1/admin/daemons/{daemon_id}/deactivate instead.",
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""GET/PUT/DELETE /v1/daemons/{daemon_id}/credential — user-owned credential management (REQ-052)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.encryption import encrypt_payload
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import CredentialMetaResponse, CredentialSetRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/daemons", tags=["v1"])
|
||||
|
||||
|
||||
def _get_repo(settings=None):
|
||||
from app.core.config import get_settings as _gs
|
||||
return get_portfolio_repository_from_settings(settings or _gs())
|
||||
|
||||
|
||||
def _require_user_token(auth: AuthContext) -> None:
|
||||
"""Reject service-token access to credential management endpoints.
|
||||
|
||||
REQ-052 AC-10: service-token → 403 (service-token is read-only /v1/user_info only).
|
||||
"""
|
||||
if auth.token_source == "db" and auth.token_type == "service":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Service token cannot access credential management endpoints",
|
||||
)
|
||||
|
||||
|
||||
def _require_daemon_ownership(auth: AuthContext, daemon, daemon_id: str) -> None:
|
||||
"""Check caller owns the daemon.
|
||||
|
||||
REQ-052 AC-6/AC-9: user_id != owner_user_id → 403.
|
||||
admin role also subject to ownership check (AC-9).
|
||||
"""
|
||||
if auth.user_id is None or daemon.owner_user_id != auth.user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Ownership scope violation: you do not own this daemon",
|
||||
)
|
||||
|
||||
|
||||
def _get_daemon_or_404(repo, daemon_id: str):
|
||||
daemon = repo.get_daemon_by_id(daemon_id)
|
||||
if daemon is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
|
||||
return daemon
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/daemons/{daemon_id}/credential — 메타데이터 조회 (REQ-052 AC-4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{daemon_id}/credential", response_model=CredentialMetaResponse)
|
||||
def get_credential_meta(
|
||||
daemon_id: str,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> CredentialMetaResponse:
|
||||
"""Return credential metadata for caller-owned daemon. Secret never returned (REQ-052, REQ-054).
|
||||
|
||||
REQ-052 AC-4: 200 + metadata (broker, account_ref, credential_version, last_rotated_at, has_credential).
|
||||
REQ-052 AC-7: 401 if no token.
|
||||
REQ-052 AC-10: 403 if service-token.
|
||||
REQ-052 AC-6/9: 403 if caller does not own daemon.
|
||||
REQ-052 AC-8: 404 if daemon does not exist.
|
||||
"""
|
||||
_require_user_token(auth)
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
daemon = _get_daemon_or_404(repo, daemon_id)
|
||||
_require_daemon_ownership(auth, daemon, daemon_id)
|
||||
|
||||
# Default broker is "kis" (single-broker assumption per design)
|
||||
broker = "kis"
|
||||
cred = repo.get_broker_credential_by_daemon(daemon_id, broker)
|
||||
if cred is None:
|
||||
return CredentialMetaResponse(
|
||||
daemon_id=daemon_id,
|
||||
broker=broker,
|
||||
account_ref="",
|
||||
has_credential=False,
|
||||
credential_version=0,
|
||||
last_rotated_at=None,
|
||||
)
|
||||
return CredentialMetaResponse(
|
||||
daemon_id=daemon_id,
|
||||
broker=cred.broker,
|
||||
account_ref=cred.account_ref,
|
||||
has_credential=True,
|
||||
credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at,
|
||||
)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /v1/daemons/{daemon_id}/credential — 등록/회전 (REQ-052 AC-1/2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.put("/{daemon_id}/credential", response_model=CredentialMetaResponse)
|
||||
def set_credential(
|
||||
daemon_id: str,
|
||||
body: CredentialSetRequest,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> CredentialMetaResponse:
|
||||
"""Set or rotate broker credentials for caller-owned daemon.
|
||||
|
||||
Fernet-encrypts app_key/app_secret before storing.
|
||||
Response contains metadata only — secret never returned (REQ-052 AC-1, REQ-054 AC-1).
|
||||
REQ-052 AC-2: rotation increments credential_version.
|
||||
"""
|
||||
_require_user_token(auth)
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
daemon = _get_daemon_or_404(repo, daemon_id)
|
||||
_require_daemon_ownership(auth, daemon, daemon_id)
|
||||
|
||||
# Fernet encrypt payload — REQ-054 AC-5: only encrypted stored in DB
|
||||
enc_key = settings.require_credential_encryption_key()
|
||||
# REQ-054 AC-4: log must not contain app_key/app_secret values
|
||||
logger.info(
|
||||
"credential set/rotate for daemon %s broker %s",
|
||||
daemon_id,
|
||||
body.broker,
|
||||
)
|
||||
payload_json = json.dumps({"app_key": body.app_key, "app_secret": body.app_secret})
|
||||
encrypted = encrypt_payload(enc_key, payload_json)
|
||||
|
||||
cred = repo.upsert_broker_credential(
|
||||
auth.user_id, # type: ignore[arg-type]
|
||||
body.broker,
|
||||
account_ref=body.account_ref,
|
||||
encrypted_payload=encrypted,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
return CredentialMetaResponse(
|
||||
daemon_id=daemon_id,
|
||||
broker=cred.broker,
|
||||
account_ref=cred.account_ref,
|
||||
has_credential=True,
|
||||
credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at,
|
||||
)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /v1/daemons/{daemon_id}/credential — 삭제 (REQ-052 AC-5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/{daemon_id}/credential", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_credential(
|
||||
daemon_id: str,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> Response:
|
||||
"""Delete broker credential for caller-owned daemon.
|
||||
|
||||
REQ-052 AC-5: 204 after deletion; 204 also if credential did not exist (idempotent).
|
||||
"""
|
||||
_require_user_token(auth)
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
daemon = _get_daemon_or_404(repo, daemon_id)
|
||||
_require_daemon_ownership(auth, daemon, daemon_id)
|
||||
|
||||
# Default broker is "kis"
|
||||
broker = "kis"
|
||||
repo.delete_broker_credential(
|
||||
auth.user_id, # type: ignore[arg-type]
|
||||
broker,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
@@ -11,7 +11,9 @@ from app.api.auth_routes import router as auth_router
|
||||
from app.api.portfolios import router as portfolio_router
|
||||
from app.api.strategy_sets import router as strategy_sets_router
|
||||
from app.api.users import router as users_router
|
||||
from app.api.v1_admin_daemons import router as v1_admin_daemons_router
|
||||
from app.api.v1_assets import router as v1_assets_router
|
||||
from app.api.v1_daemon_credential import router as v1_daemon_credential_router
|
||||
from app.api.v1_daemons import router as v1_daemons_router
|
||||
from app.api.v1_portfolios import router as v1_portfolios_router
|
||||
from app.api.v1_tags import router as v1_tags_router
|
||||
@@ -81,7 +83,9 @@ def create_app(
|
||||
app.include_router(portfolio_router)
|
||||
app.include_router(strategy_sets_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(v1_admin_daemons_router)
|
||||
app.include_router(v1_assets_router)
|
||||
app.include_router(v1_daemon_credential_router)
|
||||
app.include_router(v1_daemons_router)
|
||||
app.include_router(v1_portfolios_router)
|
||||
app.include_router(v1_tags_router)
|
||||
|
||||
@@ -114,6 +114,21 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]: ...
|
||||
|
||||
def get_broker_credential_by_daemon(
|
||||
self,
|
||||
daemon_id: str,
|
||||
broker: str,
|
||||
) -> BrokerCredential | None: ...
|
||||
|
||||
def delete_credentials_by_daemon_id(self, daemon_id: str) -> int: ...
|
||||
|
||||
def update_daemon_owner(
|
||||
self,
|
||||
daemon_id: str,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
) -> Daemon: ...
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio: ...
|
||||
|
||||
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: ...
|
||||
|
||||
@@ -2343,6 +2343,91 @@ class PostgresPortfolioRepository:
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
def get_broker_credential_by_daemon(
|
||||
self,
|
||||
daemon_id: str,
|
||||
broker: str,
|
||||
) -> BrokerCredential | None:
|
||||
"""Return a single BrokerCredential by (daemon_id, broker) PK, or None if absent (REQ-052)."""
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
FROM broker_credentials
|
||||
WHERE daemon_id = %s AND broker = %s
|
||||
""",
|
||||
(daemon_id, broker),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_broker_credential(row)
|
||||
|
||||
def delete_credentials_by_daemon_id(self, daemon_id: str) -> int:
|
||||
"""Delete ALL broker credentials for daemon_id (owner re-assignment invalidation — REQ-053 AC-4).
|
||||
|
||||
Returns number of rows deleted.
|
||||
"""
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM broker_credentials WHERE daemon_id = %s",
|
||||
(daemon_id,),
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
def update_daemon_owner(
|
||||
self,
|
||||
daemon_id: str,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
) -> Daemon:
|
||||
"""Change daemon owner and invalidate all existing BrokerCredentials (REQ-053 AC-4).
|
||||
|
||||
Owner change and credential invalidation are executed atomically in a single
|
||||
transaction (BAT-022 QC Major fix). If either statement fails the entire
|
||||
transaction is rolled back, preventing the state where the owner has changed but
|
||||
the previous owner's credentials remain (credential leak — dcy-011 OQ-3).
|
||||
|
||||
Execution order inside the transaction:
|
||||
1. DELETE FROM broker_credentials WHERE daemon_id = %s (credential first)
|
||||
2. UPDATE daemons SET owner_user_id = %s ... (owner second)
|
||||
This order is also safe if the transaction machinery is somehow bypassed: if
|
||||
only step 1 completes the owner is unchanged and credentials are gone (no leak);
|
||||
if only step 2 completes the old credentials are still present but the caller
|
||||
still owns them (no cross-owner leak).
|
||||
"""
|
||||
self.ensure_schema()
|
||||
conn = self._connect()
|
||||
# Use conn.transaction() (not `with conn:`) so the shared connection stays open
|
||||
# after the block (psycopg3 closes the connection when used as a context manager).
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id FROM daemons WHERE id = %s",
|
||||
(daemon_id,),
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("데몬을 찾을 수 없습니다")
|
||||
# Step 1: delete credentials first (safe-order)
|
||||
cur.execute(
|
||||
"DELETE FROM broker_credentials WHERE daemon_id = %s",
|
||||
(daemon_id,),
|
||||
)
|
||||
# Step 2: update owner
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE daemons
|
||||
SET owner_user_id = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
|
||||
""",
|
||||
(owner_user_id, daemon_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_daemon(row)
|
||||
|
||||
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
|
||||
@@ -326,3 +326,52 @@ class DaemonResponse(BaseModel):
|
||||
class DaemonListResponse(BaseModel):
|
||||
daemons: list[DaemonResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET/PUT/DELETE /v1/daemons/{daemon_id}/credential (REQ-052)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CredentialSetRequest(BaseModel):
|
||||
"""Credential write request — app_key/app_secret/account_ref inputs only.
|
||||
|
||||
Fernet-encrypted server-side; secret never returned in any response.
|
||||
"""
|
||||
app_key: str = Field(..., min_length=1)
|
||||
app_secret: str = Field(..., min_length=1)
|
||||
account_ref: str = Field(..., min_length=1)
|
||||
broker: str = Field(default="kis", min_length=1)
|
||||
|
||||
|
||||
class CredentialMetaResponse(BaseModel):
|
||||
"""Credential metadata response — secret fields intentionally absent (REQ-052, REQ-054)."""
|
||||
daemon_id: str
|
||||
broker: str
|
||||
account_ref: str
|
||||
has_credential: bool
|
||||
credential_version: int
|
||||
last_rotated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST/PATCH /v1/admin/daemons (REQ-053)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DaemonCreateRequest(BaseModel):
|
||||
daemon_slug: str = Field(..., min_length=1)
|
||||
display_name: str | None = None
|
||||
owner_user_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class DaemonUpdateRequest(BaseModel):
|
||||
display_name: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
|
||||
|
||||
class DaemonAdminResponse(BaseModel):
|
||||
"""Admin daemon response — no credential fields (REQ-054)."""
|
||||
id: str
|
||||
daemon_slug: str
|
||||
display_name: str | None = None
|
||||
owner_user_id: str
|
||||
is_active: bool
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# BAT-021 Index
|
||||
|
||||
- Profile: standard
|
||||
- Status: in-progress
|
||||
- Status: release-candidate
|
||||
- Included REQ: REQ-047
|
||||
- Discovery: dcy-010
|
||||
- Created: 2026-06-28
|
||||
@@ -20,6 +20,11 @@ BAT-020(PR #17, d4acfd4)에서 Step1/Step2 마이그레이션 revision 코드가
|
||||
4. 운영 적용 런북 작성
|
||||
5. 신규/갱신 단위 테스트 추가
|
||||
|
||||
## Notes
|
||||
|
||||
- PR #18 머지(mergeCommit 7164f9e), 실 dev PG 통합검증 AC-1~7 PASS, QC(opus) PASS-WITH-NOTES(blocker 0). REQ-047 confirm-req-implemented 완료.
|
||||
- **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트 잔여.** 런북: `docs/runbooks/bat-021-req047-step2-migration-runbook.md`, 배포순서 Step1→backfill→Step2→코드배포 엄수, alembic stamp 사전확인.
|
||||
|
||||
## Documents
|
||||
|
||||
- [planning.md](./planning.md)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# BAT-022 Design
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-022
|
||||
|
||||
## OQ-1 해소: 인증정보 관리 엔드포인트 경로 (REQ-052)
|
||||
|
||||
**확정: `/v1/daemons/{daemon_id}/credential`**
|
||||
|
||||
근거:
|
||||
- v1 컨벤션: `/v1/daemons/{daemon_id}`가 daemon 리소스 계층 루트. credential은 daemon의 하위 리소스이므로 중첩 경로가 REST 의미에 맞는다.
|
||||
- 소유 스코프 자연성: daemon_id가 경로에 있어 소유 검증(`daemon.owner_user_id == auth.user_id`) 경로 파라미터에서 바로 가능.
|
||||
- dcy-009 UI 흐름: 사용자가 자기 데몬을 선택한 뒤 인증정보를 관리하는 흐름과 일치.
|
||||
- 기존 `/v1/daemons` GET이 daemon 계층을 쓰므로 일관성 유지.
|
||||
|
||||
메서드:
|
||||
- `GET /v1/daemons/{daemon_id}/credential` — 메타데이터 조회 (has_credential, version, last_rotated_at)
|
||||
- `PUT /v1/daemons/{daemon_id}/credential` — 등록/회전 (upsert, 응답 메타데이터만)
|
||||
- `DELETE /v1/daemons/{daemon_id}/credential` — 삭제 (204)
|
||||
|
||||
브로커 지정: PUT 요청 body에 `broker` 필드(기본값 `"kis"`). 현재 단일 broker 가정이므로 경로에 broker를 두지 않는다. 다중 broker 지원 필요 시 `/v1/daemons/{daemon_id}/credentials/{broker}` 로 확장 경로로 분리 가능(하위 호환).
|
||||
|
||||
## OQ-2 해소: admin 데몬 CRUD 경로 (REQ-053)
|
||||
|
||||
**확정: `/v1/admin/daemons`**
|
||||
|
||||
근거:
|
||||
- dcy-006 패턴: `/v1/admin/...` prefix를 admin 전용 관리 API의 네임스페이스로 일관되게 사용한다. dcy-006에서 ledger 회계 통합 경로는 `/v1/portfolios/...`를 사용했으나, admin 전용 mutate는 다른 서비스(users.py 등)도 `/users`에 admin 게이트를 두는 방식. 여기서 `/v1/daemons` 기존 경로에 POST/PATCH를 추가하면 GET(caller-scoped, REQ-045)과 POST(admin-only, REQ-053)가 같은 경로에 혼재되어 권한 경계가 불명확해진다.
|
||||
- `/v1/admin/daemons`는 admin 전용 write 작업임을 경로 자체로 명시.
|
||||
- trex-web dcy-009 소비자가 admin 경로를 별도 컨텍스트에서 관리하기 쉽다.
|
||||
|
||||
메서드:
|
||||
- `POST /v1/admin/daemons` → 201 DaemonAdminResponse (생성)
|
||||
- `PATCH /v1/admin/daemons/{daemon_id}` → 200 DaemonAdminResponse (display_name 변경, owner 재할당)
|
||||
- `POST /v1/admin/daemons/{daemon_id}/deactivate` → 200 DaemonAdminResponse
|
||||
- `POST /v1/admin/daemons/{daemon_id}/activate` → 200 DaemonAdminResponse
|
||||
- `DELETE /v1/admin/daemons/{daemon_id}` → 405 Method Not Allowed (명시적 미제공)
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
| --- | --- |
|
||||
| `app/api/v1_admin_daemons.py` | REQ-053 admin daemon CRUD 라우터 |
|
||||
| `app/api/v1_daemon_credential.py` | REQ-052 소유 스코프 인증정보 관리 라우터 |
|
||||
|
||||
### 변경 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
| --- | --- |
|
||||
| `app/schemas/v1.py` | REQ-052 응답/요청 스키마 + REQ-053 응답/요청 스키마 추가 |
|
||||
| `app/repositories/postgres.py` | `get_broker_credential_by_daemon`, `delete_credentials_by_daemon_id`, `update_daemon_owner` 추가 |
|
||||
| `app/repositories/portfolio_repository.py` | Protocol에 신규 메서드 추가 |
|
||||
| `app/main.py` | 신규 라우터 include |
|
||||
|
||||
### 신규 테스트 파일
|
||||
|
||||
| 파일 | 커버 |
|
||||
| --- | --- |
|
||||
| `tests/test_bat022_req052_credential_api.py` | REQ-052 AC-1~10 |
|
||||
| `tests/test_bat022_req053_admin_daemon_crud.py` | REQ-053 AC-1~12 |
|
||||
| `tests/test_bat022_req054_security.py` | REQ-054 AC-1~7 |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **소유 스코프 게이트 위치**: 라우터 내에서 daemon 조회 후 `daemon.owner_user_id != auth.user_id` 즉시 403. 서비스 레이어 분리 없이 라우터에서 직접 처리(기존 v1_portfolios.py 패턴 동일).
|
||||
|
||||
2. **service-token 403 (REQ-052 AC-10)**: `auth.token_source == "db" and auth.token_type == "service"` 이면 403. env-token(token_source="env")은 token_type이 None이므로 이 조건에 해당하지 않는다. env admin token으로 credential 관리는 허용하지 않는다(user_id가 없으므로 소유 스코프 검사에서 403 발생).
|
||||
|
||||
3. **admin도 타인 credential write 403 (REQ-052 AC-9)**: admin role이라도 `auth.user_id != daemon.owner_user_id`이면 403. 인증정보 소유 스코프는 admin role을 우선시하지 않는다. admin은 REQ-053(`/v1/admin/daemons`)으로 데몬 provisioning만 담당.
|
||||
|
||||
4. **owner 재할당 + credential 무효화 (REQ-053 AC-4)**: `update_daemon_owner()`에서 먼저 daemon의 owner를 업데이트하고 이후 `DELETE FROM broker_credentials WHERE daemon_id = %s` 호출. autocommit 모드이므로 두 연산이 별도. 인증정보 삭제 실패는 로그 경고 후 무시(데몬 업데이트는 이미 완료).
|
||||
|
||||
5. **DELETE 405 (REQ-053 AC-8)**: `@router.delete("/v1/admin/daemons/{daemon_id}")` 핸들러를 명시 등록해 HTTP 405를 반환. FastAPI 기본은 미등록 DELETE → 405 (Method Not Allowed)이나 명시 등록이 더 안전.
|
||||
|
||||
6. **slug 중복 → 409 (REQ-053 AC-2)**: postgres.py create_daemon에서 UniqueViolation → ValueError 변환. 라우터에서 ValueError 메시지 패턴 매칭으로 409 반환.
|
||||
|
||||
7. **DELETE credential 204 (REQ-052 AC-5)**: 인증정보가 없어도 204 반환(멱등). 있으면 삭제 후 204.
|
||||
|
||||
8. **브로커 경로 단순화**: PUT body에 `broker: str = "kis"`. GET/DELETE는 기본 broker(현재 단일 broker 가정).
|
||||
|
||||
## Changed Areas
|
||||
|
||||
- app/api/v1_admin_daemons.py — 신규
|
||||
- app/api/v1_daemon_credential.py — 신규
|
||||
- app/schemas/v1.py — CredentialSetRequest, CredentialMetaResponse, DaemonCreateRequest, DaemonUpdateRequest, DaemonAdminResponse 추가
|
||||
- app/repositories/postgres.py — get_broker_credential_by_daemon, delete_credentials_by_daemon_id, update_daemon_owner 추가
|
||||
- app/repositories/portfolio_repository.py — Protocol 동기화
|
||||
- app/main.py — 라우터 include
|
||||
- tests/test_bat022_req052_credential_api.py — 신규
|
||||
- tests/test_bat022_req053_admin_daemon_crud.py — 신규
|
||||
- tests/test_bat022_req054_security.py — 신규
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# BAT-022 Implementation
|
||||
|
||||
- Status: completed
|
||||
- Commit: 6a0e14d
|
||||
|
||||
## 변경 파일 목록
|
||||
|
||||
### 신규 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
| --- | --- |
|
||||
| `app/api/v1_admin_daemons.py` | REQ-053 admin daemon CRUD 라우터 (`/v1/admin/daemons`) |
|
||||
| `app/api/v1_daemon_credential.py` | REQ-052 소유 스코프 credential 관리 라우터 (`/v1/daemons/{id}/credential`) |
|
||||
| `tests/test_bat022_req052_credential_api.py` | REQ-052 AC-1~10 테스트 (17 케이스) |
|
||||
| `tests/test_bat022_req053_admin_daemon_crud.py` | REQ-053 AC-1~12 테스트 (15 케이스) |
|
||||
| `tests/test_bat022_req054_security.py` | REQ-054 AC-1~7 + REQ-051 회귀 (12 케이스) |
|
||||
|
||||
### 변경 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
| --- | --- |
|
||||
| `app/schemas/v1.py` | `CredentialSetRequest`, `CredentialMetaResponse`, `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 추가 |
|
||||
| `app/repositories/postgres.py` | `get_broker_credential_by_daemon`, `delete_credentials_by_daemon_id`, `update_daemon_owner` 추가 |
|
||||
| `app/repositories/portfolio_repository.py` | Protocol에 신규 3개 메서드 추가 |
|
||||
| `app/main.py` | `v1_admin_daemons_router`, `v1_daemon_credential_router` include |
|
||||
|
||||
## REQ별 구현 요약
|
||||
|
||||
### REQ-052 (소유 스코프 인증정보 관리 API)
|
||||
|
||||
- `GET /v1/daemons/{daemon_id}/credential` — 소유 스코프 검증 후 BrokerCredential 메타데이터 반환 (has_credential, version, last_rotated_at). secret 없음.
|
||||
- `PUT /v1/daemons/{daemon_id}/credential` — `app_key`/`app_secret` Fernet 암호화 후 `upsert_broker_credential(daemon_id=...)` 호출. 응답 메타데이터만.
|
||||
- `DELETE /v1/daemons/{daemon_id}/credential` — `delete_broker_credential(daemon_id=...)` 호출, 204 반환(멱등).
|
||||
- 소유 스코프 게이트: `daemon.owner_user_id != auth.user_id` → 403. admin role도 동일 검사(AC-9).
|
||||
- service-token 거부: `token_source == "db" and token_type == "service"` → 403(AC-10).
|
||||
- 기존 `upsert_broker_credential`, `delete_broker_credential`(dcy-010 자산) 재사용. CLI와 동일 repo 메서드.
|
||||
|
||||
### REQ-053 (admin 데몬 CRUD API)
|
||||
|
||||
- `POST /v1/admin/daemons` → 기존 `create_daemon()` 재사용, 201.
|
||||
- `PATCH /v1/admin/daemons/{id}` → owner 변경 시 `update_daemon_owner()`(새 메서드), display_name만 변경 시 기존 `update_daemon()`.
|
||||
- `POST .../deactivate` → `update_daemon(is_active=False)` + `revoke_tokens_by_daemon_id()`(REQ-022 재사용).
|
||||
- `POST .../activate` → `update_daemon(is_active=True)`.
|
||||
- `DELETE .../` → 명시적 405 핸들러 등록.
|
||||
- `require_admin` dependency 사용 (기존 `app/core/auth.py`).
|
||||
|
||||
### REQ-054 (보안 격리)
|
||||
|
||||
- 응답 스키마: `CredentialMetaResponse`/`DaemonAdminResponse` 모두 secret 필드 부재.
|
||||
- 로그: `PUT credential` 처리 시 broker/daemon_id만 기록 (app_key/app_secret 값 미포함).
|
||||
- Fernet at-rest: `encrypt_payload()` 호출 후 `upsert_broker_credential()` — 평문 미저장(AC-5).
|
||||
- REQ-051 decrypt 경로 불변: `/v1/user_info` 코드 무변경, 회귀 4개 테스트 PASS(AC-7).
|
||||
|
||||
## 설계 결정 트레이스 (design.md 참조)
|
||||
|
||||
- OQ-1 해소: `/v1/daemons/{daemon_id}/credential` — daemon-scoped, REST 정합.
|
||||
- OQ-2 해소: `/v1/admin/daemons` — admin 전용 prefix, 기존 GET /v1/daemons(caller-scoped)와 분리.
|
||||
- service-token 403: `token_source == "db" and token_type == "service"` 조건. env-token은 token_type=None이므로 해당 없음.
|
||||
- admin + 소유 스코프: admin role도 ownership 검사 통과 → 403(REQ-052 AC-9).
|
||||
@@ -0,0 +1,47 @@
|
||||
# BAT-022 — daemon CRUD admin API + self-service 인증정보 관리 API
|
||||
|
||||
- Batch ID: BAT-022
|
||||
- Profile: standard
|
||||
- Status: draft-batch-verification
|
||||
- Discovery: dcy-011
|
||||
- Included REQ: REQ-052, REQ-053, REQ-054
|
||||
- Folder: docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/
|
||||
- Created: 2026-06-28
|
||||
|
||||
## Summary
|
||||
|
||||
dcy-011(daemon admin CRUD + 사용자 self-service 브로커 인증정보 관리 API)을 portfolio-hub에 구현한다.
|
||||
|
||||
- REQ-052: user-token + 소유 스코프 인증정보 set/rotate/delete + 메타데이터 GET (`/v1/daemons/{daemon_id}/credential`)
|
||||
- REQ-053: admin 데몬 생성·owner 할당·변경·(비)활성화 (`/v1/admin/daemons`)
|
||||
- REQ-054: 모든 관리 API 응답/로그에 app_key/app_secret 평문 미포함(보안 격리) + REQ-051 회귀 PASS
|
||||
|
||||
dcy-010 자산(BrokerCredential 모델, Fernet 암호화, upsert/delete 메서드) 재사용. CLI와 동일 repo 메서드 공유.
|
||||
|
||||
## Included REQ
|
||||
|
||||
| REQ | 제목 | Type | 의존 |
|
||||
| --- | --- | --- | --- |
|
||||
| REQ-052 | 사용자 소유 스코프 인증정보 관리 HTTP API | Interface | dcy-010 자산(BrokerCredential, Fernet) |
|
||||
| REQ-053 | admin 데몬 CRUD HTTP API | Interface | Daemon 모델, REQ-022(service token revoke) |
|
||||
| REQ-054 | 인증정보 관리 보안 격리 | Non-Functional | REQ-052, REQ-053, REQ-051 |
|
||||
|
||||
## DoR (진입 조건)
|
||||
|
||||
- [x] REQ-052/053/054 전건 Approved 상태 확인
|
||||
- [x] dcy-011 confirmed Discovery 확인(OQ-1/OQ-2 Deferred → design 단계 해소)
|
||||
- [x] 구현 대상 파일 존재 확인: app/repositories/postgres.py, app/domain/models.py, app/core/auth.py, app/core/encryption.py
|
||||
- [x] dcy-010 자산 존재 확인: BrokerCredential PK (daemon_id, broker), upsert_broker_credential, list_broker_credentials, delete_broker_credential
|
||||
|
||||
## DoD (완료 조건)
|
||||
|
||||
- [x] REQ-052 AC-1~10 테스트 통과 (17 케이스)
|
||||
- [x] REQ-053 AC-1~12 테스트 통과 (15 케이스)
|
||||
- [x] REQ-054 AC-1~7 테스트 통과 (12 케이스, caplog 비노출, 응답 secret 부재, REQ-051 회귀)
|
||||
- [x] 기존 521 테스트 회귀 없음 (565 passed, 0 failures)
|
||||
- [x] draft-batch-verification 문서에 AC별 evidence 기록
|
||||
|
||||
## 비가역 결재 게이트
|
||||
|
||||
- DB 마이그레이션: 신규 DDL 없음. 기존 ensure_schema() + postgres.py 메서드 확장으로 처리 (비가역 없음).
|
||||
- HTTP API 계약 신설: trex-web dcy-009가 소비 예정. 이 batch는 계약 신설이므로 기존 소비자에 영향 없음(호환 깨짐 없음).
|
||||
@@ -0,0 +1,85 @@
|
||||
# BAT-022 Planning
|
||||
|
||||
## 구현 의존 순서
|
||||
|
||||
1. **REQ-052/053 스키마** — `app/schemas/v1.py`에 `CredentialSetRequest`, `CredentialMetaResponse`, `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 추가
|
||||
2. **REQ-053 repository 확장** — `postgres.py`에 `update_daemon_owner()` (owner 변경 + 인증정보 자동 삭제) 신규. `update_daemon()` 재사용 가능한 부분은 그대로.
|
||||
3. **REQ-052 repository 확장** — `postgres.py`에 `get_broker_credential_meta()` (단일 credential 조회, user_id 소유 검증 포함) 추가. 기존 `upsert_broker_credential`, `delete_broker_credential` 재사용.
|
||||
4. **REQ-053 라우터** — `app/api/v1_admin_daemons.py` 신규. `POST /v1/admin/daemons`, `PATCH /v1/admin/daemons/{daemon_id}`, `POST /v1/admin/daemons/{daemon_id}/activate`, `POST /v1/admin/daemons/{daemon_id}/deactivate`. `require_admin` 사용.
|
||||
5. **REQ-052 라우터** — `app/api/v1_daemon_credential.py` 신규. `GET /v1/daemons/{daemon_id}/credential`, `PUT /v1/daemons/{daemon_id}/credential`, `DELETE /v1/daemons/{daemon_id}/credential`. `require_read` + 소유 스코프 검사.
|
||||
6. **REQ-052/053/054 테스트** — `tests/test_bat022_*.py` 신규 (AC별 pytest 케이스)
|
||||
7. **REQ-054 보안 격리 테스트** — caplog, 응답 JSON 필드 열거, REQ-051 회귀
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### T-1: 스키마 추가 (app/schemas/v1.py)
|
||||
|
||||
**REQ-052 스키마:**
|
||||
- `CredentialSetRequest` — `app_key: str`, `app_secret: str`, `account_ref: str`, `broker: str = "kis"`
|
||||
- `CredentialMetaResponse` — `daemon_id: str`, `broker: str`, `account_ref: str`, `has_credential: bool`, `credential_version: int`, `last_rotated_at: datetime | None` (secret 필드 없음)
|
||||
|
||||
**REQ-053 스키마:**
|
||||
- `DaemonCreateRequest` — `daemon_slug: str`, `display_name: str | None`, `owner_user_id: str`
|
||||
- `DaemonUpdateRequest` — `display_name: str | None`, `owner_user_id: str | None`
|
||||
- `DaemonAdminResponse` — `id: str`, `daemon_slug: str`, `display_name: str | None`, `owner_user_id: str`, `is_active: bool` (credential 필드 없음)
|
||||
|
||||
### T-2: repository 메서드 추가 (app/repositories/postgres.py + protocol)
|
||||
|
||||
- `get_broker_credential_by_daemon` — `(daemon_id, broker)` 로 단일 BrokerCredential 조회(없으면 None). 소유 검증은 서비스/라우터 레이어에서.
|
||||
- `delete_credentials_by_daemon_id` — daemon_id에 속한 모든 BrokerCredential 삭제(owner 재할당 시 무효화용). REQ-053 AC-4 지원.
|
||||
- `update_daemon_owner` — owner_user_id 변경 + 해당 daemon의 모든 broker_credentials 삭제 (단일 메서드 atomic). owner 변경 없이 display_name만 변경하면 기존 `update_daemon()` 재사용.
|
||||
- Protocol(`portfolio_repository.py`) 동기화.
|
||||
|
||||
### T-3: admin daemon 라우터 (app/api/v1_admin_daemons.py)
|
||||
|
||||
경로: `/v1/admin/daemons`
|
||||
|
||||
- `POST /v1/admin/daemons` → `DaemonCreateRequest` → 201 + `DaemonAdminResponse`
|
||||
- 중복 slug → 409 Conflict
|
||||
- 비admin/service-token → 403, 비인증 → 401
|
||||
- `PATCH /v1/admin/daemons/{daemon_id}` → `DaemonUpdateRequest` → 200 + `DaemonAdminResponse`
|
||||
- owner 변경 시 `update_daemon_owner()` 호출(인증정보 자동 무효화)
|
||||
- owner 변경 없으면 `update_daemon(display_name=...)` 호출
|
||||
- 미존재 → 404
|
||||
- `POST /v1/admin/daemons/{daemon_id}/deactivate` → 200 + `DaemonAdminResponse`
|
||||
- `update_daemon(is_active=False)` + `revoke_tokens_by_daemon_id()` (REQ-022 재사용)
|
||||
- 미존재 → 404
|
||||
- `POST /v1/admin/daemons/{daemon_id}/activate` → 200 + `DaemonAdminResponse`
|
||||
- `update_daemon(is_active=True)` 호출
|
||||
- 미존재 → 404
|
||||
- `DELETE /v1/admin/daemons/{daemon_id}` → 405 Method Not Allowed (하드 삭제 미제공)
|
||||
|
||||
### T-4: credential 라우터 (app/api/v1_daemon_credential.py)
|
||||
|
||||
경로: `/v1/daemons/{daemon_id}/credential`
|
||||
|
||||
공통 소유 스코프 게이트:
|
||||
- 비인증(토큰 없음) → 401 (require_read가 처리)
|
||||
- service-token(`token_type == "service"`) → 403
|
||||
- auth.user_id가 daemon.owner_user_id와 불일치 → 403
|
||||
- 미존재 daemon_id → 404
|
||||
|
||||
엔드포인트:
|
||||
- `GET /v1/daemons/{daemon_id}/credential` → 200 + `CredentialMetaResponse`
|
||||
- 인증정보 없으면 `has_credential: false` + 나머지 null
|
||||
- `PUT /v1/daemons/{daemon_id}/credential` → 200 + `CredentialMetaResponse`
|
||||
- `app_key`/`app_secret` Fernet 암호화 → `upsert_broker_credential()` 호출
|
||||
- 응답에 app_key/app_secret 미포함
|
||||
- `DELETE /v1/daemons/{daemon_id}/credential` → 204 No Content (인증정보 없어도 204)
|
||||
|
||||
### T-5: main.py 라우터 등록
|
||||
|
||||
- `v1_admin_daemons_router`, `v1_daemon_credential_router` include
|
||||
|
||||
### T-6: 테스트 신규 (tests/test_bat022_*.py)
|
||||
|
||||
- `test_bat022_req052_credential_api.py` — REQ-052 AC-1~10 (소유 스코프 음성 포함)
|
||||
- `test_bat022_req053_admin_daemon_crud.py` — REQ-053 AC-1~12 (owner 재할당 + 인증정보 무효화, DELETE 405 포함)
|
||||
- `test_bat022_req054_security.py` — REQ-054 AC-1~7 (caplog, secret 필드 부재, REQ-051 회귀)
|
||||
|
||||
## Risks / Constraints
|
||||
|
||||
- service-token 403 (REQ-052 AC-10): `token_type == "service"` 체크는 `auth.token_source == "db"` 전제. env-token은 `token_type=None`이므로 별도 분기 필요.
|
||||
- admin role 검증 (REQ-053): `require_admin`은 기존 permission hierarchy(admin > trade-write > read)를 사용. env admin 토큰도 처리.
|
||||
- owner 변경 + 인증정보 무효화(REQ-053 AC-4): `update_daemon_owner()`는 단일 DB 트랜잭션이 아님(psycopg3 autocommit). partial failure(daemon 업데이트 성공 + 인증정보 삭제 실패)는 드문 케이스이나 로그 경고 추가.
|
||||
- DELETE 405 (REQ-053 AC-8): FastAPI에서 미등록 경로의 DELETE는 404를 반환. 명시적 405 반환하려면 라우터에 `DELETE` 핸들러를 405 고정으로 등록해야 함.
|
||||
@@ -0,0 +1,92 @@
|
||||
# BAT-022 Verification
|
||||
|
||||
- Status: draft
|
||||
- Batch: BAT-022
|
||||
- Verification Date: 2026-06-28
|
||||
|
||||
## 테스트 실행 결과
|
||||
|
||||
### 신규 테스트 (44 케이스)
|
||||
|
||||
```
|
||||
tests/test_bat022_req052_credential_api.py — 17 passed
|
||||
tests/test_bat022_req053_admin_daemon_crud.py — 15 passed
|
||||
tests/test_bat022_req054_security.py — 12 passed
|
||||
```
|
||||
|
||||
### 전체 테스트 스위트
|
||||
|
||||
```
|
||||
565 passed, 11 warnings in 32.53s
|
||||
```
|
||||
|
||||
기존 521 테스트 회귀: **0건** (521 → 565, +44 신규만 추가)
|
||||
|
||||
## REQ-052 AC별 검증 Evidence
|
||||
|
||||
| AC | 테스트 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| AC-1: PUT → 200 + 메타데이터, secret 없음 | `test_put_credential_returns_meta_without_secret` | PASS |
|
||||
| AC-2: 회전 시 credential_version 증가 | `test_put_credential_rotation_increments_version` | PASS |
|
||||
| AC-3: CLI·API·service-token 정합 (upsert daemon_id 전달) | `test_put_credential_upsert_called_with_correct_daemon_id` | PASS |
|
||||
| AC-4: GET → 200 + 메타데이터, secret 없음 | `test_get_credential_meta_returns_metadata_without_secret` | PASS |
|
||||
| AC-5: DELETE → 204 + 이후 has_credential=False | `test_delete_credential_returns_204`, `test_get_after_delete_returns_has_credential_false` | PASS |
|
||||
| AC-6 (음성): 타 사용자 토큰 → 403 | `test_put/get/delete_credential_other_user_returns_403` (3개) | PASS |
|
||||
| AC-7: 비인증 → 401 | `test_credential_no_token_returns_401` | PASS |
|
||||
| AC-8: 미존재 daemon → 404 | `test_credential_nonexistent_daemon_returns_404`, `test_put_nonexistent_daemon_returns_404` | PASS |
|
||||
| AC-9 (음성): admin으로 타인 daemon write → 403 | `test_admin_cannot_write/read_other_user_credential` (2개) | PASS |
|
||||
| AC-10 (음성): service-token → 403 | `test_service_token_credential_get/put/delete_returns_403` (3개) | PASS |
|
||||
|
||||
## REQ-053 AC별 검증 Evidence
|
||||
|
||||
| AC | 테스트 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| AC-1: POST 생성 → 201 + DaemonAdminResponse | `test_admin_create_daemon_returns_201` | PASS |
|
||||
| AC-2: 중복 slug → 409 | `test_admin_create_daemon_duplicate_slug_returns_409` | PASS |
|
||||
| AC-3: display_name 변경 → BrokerCredential 유지 | `test_admin_patch_display_name_credential_preserved` | PASS |
|
||||
| AC-4: owner 재할당 → BrokerCredential 자동 삭제 | `test_admin_patch_owner_invalidates_credentials` | PASS |
|
||||
| AC-5: 재할당 후 이전 owner 인증정보 무효화 | `test_admin_patch_owner_then_old_owner_loses_credential` | PASS |
|
||||
| AC-6: 비활성화 → is_active=False + service token revoke | `test_admin_deactivate_daemon_revokes_tokens` | PASS |
|
||||
| AC-7: 재활성화 → is_active=True | `test_admin_activate_daemon_returns_active` | PASS |
|
||||
| AC-8 (음성): DELETE → 405 | `test_admin_delete_daemon_returns_405` | PASS |
|
||||
| AC-9 (음성): user-token(비admin) → 403 | `test_non_admin_user_token_create/patch/deactivate` (3개) | PASS |
|
||||
| AC-10 (음성): service-token → 403 | `test_service_token_admin_crud_returns_403` | PASS |
|
||||
| AC-11: 비인증 → 401 | `test_admin_crud_no_token_returns_401` | PASS |
|
||||
| AC-12: 미존재 daemon → 404 | `test_admin_patch/deactivate_nonexistent_daemon_returns_404` (2개) | PASS |
|
||||
|
||||
## REQ-054 AC별 검증 Evidence
|
||||
|
||||
| AC | 테스트 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| AC-1: PUT 응답에 app_key/app_secret 없음 | `test_put_credential_response_has_no_secret_fields` | PASS |
|
||||
| AC-2: GET 응답에 app_key/app_secret 없음 | `test_get_credential_meta_response_has_no_secret_fields` | PASS |
|
||||
| AC-3: admin CRUD 응답에 credential 필드 없음 | `test_admin_create/patch_response_has_no_credential_fields` (2개) | PASS |
|
||||
| AC-4: caplog — PUT 처리 중 app_key/app_secret 값 미포함 | `test_put_credential_log_does_not_expose_secret` | PASS |
|
||||
| AC-5: Fernet 암호화 저장 확인 (gAAAA... 형태) | `test_put_credential_stores_encrypted_payload` | PASS |
|
||||
| AC-6: PUT 후 service-token user_info → 동일 account_ref | `test_put_then_service_token_user_info_returns_correct_account_ref` | PASS |
|
||||
| AC-7: REQ-051 회귀 (4개 테스트) | `test_req051_regression_*` (4개) | PASS |
|
||||
|
||||
## 음성 테스트 체크리스트
|
||||
|
||||
| 음성 시나리오 | 케이스 수 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| 소유 스코프 403 (AC-6: 타 사용자 토큰) | 3 | PASS |
|
||||
| admin 소유 스코프 403 (AC-9) | 2 | PASS |
|
||||
| service-token 403 (AC-10, REQ-052) | 3 | PASS |
|
||||
| service-token 403 (AC-10, REQ-053) | 1 | PASS |
|
||||
| DELETE 405 (AC-8, REQ-053) | 1 | PASS |
|
||||
| secret 필드 부재 (AC-1/2/3, REQ-054) | 4 | PASS |
|
||||
| caplog secret 미노출 (AC-4, REQ-054) | 1 | PASS |
|
||||
|
||||
## 회귀 테스트
|
||||
|
||||
- REQ-051 AC-1~3, AC-5: 신규 API 추가 후 GET /v1/user_info 보안 격리 불변 확인 (4개 테스트 PASS)
|
||||
- 기존 521 테스트 전건 회귀 없음
|
||||
|
||||
## 잔여 위험 / 미해결
|
||||
|
||||
1. **AC-3 통합 수준**: REQ-052 AC-3(CLI·API·service-token 3경로 정합)은 이 batch에서 unit-level로 검증(upsert daemon_id 전달 확인). 실 DB 통합 검증은 dev DB 환경 필요(비가역 아님, 선택적 추후 확인 가능).
|
||||
|
||||
2. **DELETE credential 기본 broker "kis"**: GET/DELETE 에서 broker 파라미터 없이 "kis" 고정. 다중 broker 지원이 필요해지면 경로 파라미터 추가 필요 — 현재 단일 broker 가정 설계 일치.
|
||||
|
||||
3. **update_daemon_owner autocommit 부분 실패**: postgres.py `update_daemon_owner()`는 daemon UPDATE + credential DELETE를 autocommit 2회 연산으로 처리. daemon 업데이트 성공 후 credential 삭제 실패 케이스는 테스트 범위 밖(네트워크 장애 등). 실 운영에서 발생 시 수동 정리 필요.
|
||||
@@ -29,3 +29,4 @@
|
||||
| BAT-019 | standard | confirmed | REQ-044, REQ-045 | dcy-009 | [bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) |
|
||||
| BAT-020 | standard | release-candidate | REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051 | dcy-010 | [bat-020](./bat-020_20260628_daemon-scoped-broker-credential/) |
|
||||
| BAT-021 | standard | in-progress | REQ-047 | dcy-010 | [bat-021](./bat-021_20260628_req047-step2-integration-and-runbook/) |
|
||||
| BAT-022 | standard | draft-batch-verification | REQ-052, REQ-053, REQ-054 | dcy-011 | [bat-022](./bat-022_20260628_daemon-crud-self-service-credential-api/) |
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# 0. 문서 상태
|
||||
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
|
||||
|
||||
- 상태: confirmed
|
||||
- 문서 ID: dcy-011_20260628_daemon-crud-and-self-service-credential-api
|
||||
- 이슈명: 데몬 admin CRUD API + 사용자 self-service 브로커 인증정보 관리 API — 소유 스코프 write/rotate/메타데이터(복호화 secret 미반환), 데몬 생성·할당·(비)활성화는 admin. trex-web dcy-009 백엔드 (교차 발원)
|
||||
- 작성 시각(KST): 2026-06-28
|
||||
- 마지막 갱신 시각(KST): 2026-06-28 (REQ 초안 생성)
|
||||
- 대체됨: 없음
|
||||
- 후속 Discovery 참조: 없음
|
||||
- 생성된 REQ 참조:
|
||||
- REQ-052: 사용자 소유 스코프 인증정보 관리 HTTP API ([docs/srs/Interface/req-052_user-owned-credential-management-api.md](../srs/Interface/req-052_user-owned-credential-management-api.md))
|
||||
- REQ-053: admin 데몬 CRUD HTTP API ([docs/srs/Interface/req-053_admin-daemon-crud-api.md](../srs/Interface/req-053_admin-daemon-crud-api.md))
|
||||
- REQ-054: 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출 ([docs/srs/Non-Functional/req-054_credential-management-security-write-only.md](../srs/Non-Functional/req-054_credential-management-security-write-only.md))
|
||||
|
||||
# 1. 계획 상태 요약
|
||||
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
||||
|
||||
- 연계 문서 현황:
|
||||
- 관련 점수 파일: 미생성
|
||||
- 관련 TODO/메모: GitHub org 보드 fourblocks2020/trex #7 — "portfolio-hub 데몬 레지스트리/관리 API", "서비스 토큰/데몬 발급·관리". **교차 발원 = trex-web `dcy-009`**(사용자 self-service 인증정보 관리 + admin 데몬 provisioning UI). 선행 = `dcy-010`(daemon-scoped 브로커 인증정보 모델/보안), `dcy-004`(토큰·권한 모델)
|
||||
- 해석:
|
||||
- trex-web dcy-009가 정의한 기능(사용자가 자기 데몬 KIS 인증정보를 self-service 관리, admin이 데몬 provisioning)을 받쳐줄 portfolio-hub 백엔드 API가 없다. 현재 데몬은 read-only(`/v1/daemons`), 인증정보는 CLI(`pfhub credential`) 전용이며 복호화 read는 service-token `/v1/user_info`뿐이다.
|
||||
- 핵심 판단: ① **사용자 소유 스코프** 인증정보 관리 API(write/rotate/delete + 메타데이터, **복호화 secret 절대 미반환**), ② **admin 데몬 CRUD** API(생성·할당·(비)활성화)를 dcy-004 권한 모델 위에 어떻게 설계하고, service-token decrypt 경로(REQ-051)와 어떻게 분리·불변 유지할지.
|
||||
|
||||
# 2. 요구사항 판정 결과
|
||||
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
|
||||
|
||||
- 판정: 신규
|
||||
- 근거
|
||||
- 기존 `/v1/daemons`는 read 전용이고, 인증정보 관리는 CLI 전용이다. **HTTP 기반 소유 스코프 인증정보 관리 API**와 **admin 데몬 CRUD API**는 정의된 바 없다.
|
||||
- dcy-010은 인증정보 *모델/저장*(CLI·service-token read)을 다뤘고, 본 건은 *사용자/admin HTTP 관리 표면*을 신설하므로 신규 주기로 추적한다.
|
||||
|
||||
# 3. 문제점의 요약
|
||||
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다.
|
||||
|
||||
- 현재 상태
|
||||
- `BrokerCredential`(dcy-010): PK `(daemon_id, broker)`, `user_id` 보유, Fernet 암호화 `{app_key, app_secret}`. 관리=CLI(`pfhub credential set/list/remove --daemon-id`). 복호화 read=**service-token 전용 `GET /v1/user_info`**(REQ-051, web/user-token 403).
|
||||
- `Daemon`(id, owner_user_id, daemon_slug, display_name, is_active): read API `GET /v1/daemons`만. 생성·변경·(비)활성화 HTTP API 없음.
|
||||
- 권한 모델(dcy-004): user-token(권한 read/write), admin role, service-token(daemon_id 바인딩). 소유 스코프 인가 선례 = dcy-009 trex-web 포트폴리오/REQ-049 `managed_by_daemon_id` 일치.
|
||||
- 암호화 유틸(`app/core/encryption.py` Fernet, `CREDENTIAL_ENCRYPTION_KEY`) 존재.
|
||||
- 기대 상태
|
||||
- **사용자(소유 스코프)**: HTTP로 자기 소유 데몬의 인증정보를 등록·회전·삭제하고 메타데이터(broker, account_ref, version, last_rotated, 보유 여부)를 조회. **자기 것만**.
|
||||
- **admin**: HTTP로 데몬을 생성·사용자 할당·변경·(비)활성화.
|
||||
- **복호화된 app_secret은 user-token/admin 어떤 응답에도 포함되지 않는다.** service-token decrypt 경로(REQ-051)는 불변.
|
||||
- 이해관계자
|
||||
- 사장님(보안·권한·결재), 팀장(거버넌스), portfolio-hub-dev(구현), 일반 사용자(self-service 소비, trex-web 경유), admin/운영자(데몬 provisioning), trex-web 개발(dcy-009 소비측)
|
||||
- 제약사항
|
||||
- **최민감 시크릿(KIS app_secret = 실계좌·실자금)** 복호화 평문이 user-token/admin 응답·로그에 노출 금지. service-token `/v1/user_info`(REQ-051)가 유일 decrypt-return 경로 — 불변.
|
||||
- **소유 스코프 인가 엄격**: 사용자는 자기 소유 데몬(owner_user_id==요청자)의 인증정보만. 타인 접근 403.
|
||||
- 데몬은 운영 런타임 식별자 → 생성·(비)활성화 영향 큼. 삭제는 dcy-009 결정대로 제외(비활성화).
|
||||
- CLI(`pfhub credential`)와 신규 API의 동작 정합(동일 repository·암호화 자산 재사용).
|
||||
|
||||
## 주요 사용자 시나리오
|
||||
- admin이 `POST /v1/daemons`(또는 동등)로 데몬 D를 생성하고 owner=사용자 U로 할당한다.
|
||||
- 사용자 U가 (trex-web 경유, user-token) 자기 데몬 D의 인증정보를 등록(app_key/app_secret) → 서버 Fernet 암호화 저장. 응답엔 secret 미포함, account_ref·version만.
|
||||
- 사용자 V가 사용자 U의 데몬 D 인증정보 조회/수정 요청 → 403(소유 스코프).
|
||||
- 데몬 D 인스턴스는 service-token으로 `/v1/user_info`에서 복호화 인증정보 fetch(기존 경로 불변).
|
||||
|
||||
# 4. 이번에 정의할 변경
|
||||
이번 Discovery에서 다루는 핵심 변경 항목.
|
||||
|
||||
- (C1) **사용자 소유 스코프 인증정보 관리 API** — 자기 소유 데몬 인증정보 set/rotate/delete + 메타데이터 GET. user-token + ownership 게이트. **복호화 secret 미반환**. 암호화·저장은 dcy-010 자산 재사용.
|
||||
- (C2) **admin 데몬 CRUD API** — 데몬 생성·owner 할당·변경·(비)활성화. admin role. (삭제 제외 — 비활성화.)
|
||||
- (C3) **repository 계층 확장** — credential ownership/메타데이터 조회·daemon CRUD 메서드. CLI와 동일 자산 공유.
|
||||
|
||||
## 영향 범위
|
||||
- 코드 영향:
|
||||
- `app/api/` — 신규 인증정보 관리 라우트(소유 스코프) + `v1_daemons.py` write(admin) 확장
|
||||
- `app/repositories/*` — credential 메타데이터/ownership 조회, daemon create/update/deactivate
|
||||
- `app/core/auth.py` — ownership 게이트(요청자==owner) + admin 게이트 재사용
|
||||
- `app/schemas/*` — 관리 요청/응답 스키마(**secret 미포함** 메타데이터 응답)
|
||||
- 암호화: `app/core/encryption.py`(dcy-010) 재사용
|
||||
- 문서 영향:
|
||||
- `docs/srs/`(신규 REQ), `docs/discovery/index.md`(본 문서), `docs/interface-contract.md`(신규 엔드포인트)
|
||||
- 교차: trex-web dcy-009(소비자 UI)
|
||||
- 운영 영향:
|
||||
- 사용자 self-service 등록으로 CLI 부담 감소(CLI는 fallback 유지). admin 데몬 provisioning HTTP 경로.
|
||||
|
||||
# 5. 요구사항 목록
|
||||
|
||||
## 기능 요구사항
|
||||
- FR-1: 사용자가 **자기 소유 데몬**의 인증정보를 등록·회전·삭제한다(user-token). app_key/app_secret 입력 → Fernet 암호화 저장(dcy-010). 회전 시 credential_version++/last_rotated_at 갱신.
|
||||
- FR-2: 사용자가 자기 인증정보 **메타데이터**(broker, account_ref, credential_version, last_rotated_at, 데몬별 보유 여부)를 조회한다. **복호화된 app_key/app_secret은 응답에 포함되지 않는다.**
|
||||
- FR-3: 인증정보 관리 경로는 **소유 스코프 인가**를 강제한다 — 요청자 user_id != 대상 데몬 owner_user_id → HTTP 403(타인 데몬·계좌 접근 차단).
|
||||
- FR-4: admin이 데몬을 생성·owner 할당·변경(display_name 등)·(비)활성화한다(admin role). **하드 삭제는 제공하지 않는다(비활성화로 대체).**
|
||||
|
||||
## 비기능 요구사항
|
||||
- NFR-1 (보안 — 최우선): 복호화된 app_secret은 **user-token/admin 어떤 응답·로그에도 포함되지 않는다**(쓰기 전용 관리 + 메타데이터). service-token `GET /v1/user_info`(REQ-051)가 유일 decrypt-return 경로 — 본 건으로 변경하지 않는다. at-rest Fernet 불변.
|
||||
- NFR-2 (권한): 인증정보 관리 = **소유 스코프**(user-token, 요청자==데몬 owner). 데몬 CRUD = **admin role**. dcy-004 토큰/권한 모델 재사용.
|
||||
- NFR-3 (일관성): 신규 API·CLI(`pfhub credential`)·service-token read가 동일 `BrokerCredential`/`Daemon` 모델·암호화 자산에서 정합 동작. daemon_id·owner_user_id 바인딩 정합(dcy-010).
|
||||
|
||||
## 범위 경계
|
||||
- In Scope
|
||||
- 소유 스코프 인증정보 관리 API(set/rotate/delete + 메타데이터, secret 미반환).
|
||||
- admin 데몬 CRUD API(생성·할당·변경·(비)활성화, 삭제 제외).
|
||||
- repository·스키마·권한 게이트.
|
||||
- Out of Scope
|
||||
- service-token 복호화 read 경로(`/v1/user_info`, dcy-010/REQ-051) 변경 — **불변**.
|
||||
- trex-web UI(= dcy-009).
|
||||
- 서비스 토큰 발급·폐기(별 Discovery, dcy-009 OQ-3).
|
||||
- 외부 시크릿 매니저 외부화.
|
||||
- 데몬 하드 삭제.
|
||||
|
||||
# 6. 리스크/가정 목록
|
||||
|
||||
## 리스크
|
||||
- R-1 (High): 복호화 app_secret이 user-token/admin 관리 응답·로그로 유출(실계좌 탈취).
|
||||
- 완화 방안: 관리 API는 set/rotate/delete + 메타데이터만, 복호화 payload 절대 미반환. 응답 스키마에 secret 필드 부재. 로그 secret 미포함(REQ-051 패턴). service-token 경로와 라우트·권한 분리. 음성 테스트.
|
||||
- R-2 (High): ownership 인가 결함 → 사용자가 타인 데몬 인증정보 조회·수정(횡적 권한 상승).
|
||||
- 완화 방안: 모든 관리 경로 **엄격 ownership 검사**(요청자==owner_user_id, 불일치 403). dcy-009 trex-web 포트폴리오/REQ-049 daemon-scope 인가 재사용. 음성 테스트(타인 자원→403) 필수.
|
||||
- R-3 (Medium): 데몬 (비)활성화·변경 blast radius — 운영 인스턴스가 daemon_id로 fetch 중.
|
||||
- 완화 방안: 삭제 미제공(비활성화), 참조 무결성·활성 데몬 보호, 인증정보 보유 데몬 비활성화 경고.
|
||||
- R-4 (Medium): CLI·신규 API 이중 관리 경로 불일치.
|
||||
- 완화 방안: 동일 repository 메서드·암호화 자산 재사용, PK `(daemon_id, broker)` 정합.
|
||||
|
||||
## 가정
|
||||
- A-1: dcy-010 `BrokerCredential`(PK `(daemon_id, broker)`, user_id, Fernet, `CREDENTIAL_ENCRYPTION_KEY`) 완료. (확인됨, Implemented)
|
||||
- A-2: `Daemon.owner_user_id` 존재 → 소유 스코프 인가가 데이터상 가능. (확인됨)
|
||||
- A-3: dcy-004 토큰/권한(user-token, admin role, service-token) 재사용.
|
||||
- A-4: 교차 발원 trex-web `dcy-009`(confirmed)가 소비자. UI는 dcy-009 범위.
|
||||
- A-5: service-token `/v1/user_info`(REQ-051) decrypt-return 경로 불변(유일 decrypt 경로).
|
||||
|
||||
## 오픈 질문
|
||||
- OQ-1: 인증정보 관리 엔드포인트 경로·형태 — daemon-scoped(`/v1/daemons/{id}/credential`) vs user-credential.
|
||||
- 상태: **Deferred (2026-06-28)** → draft-batch-design 단계 위임.
|
||||
- 처리 방안: design에서 REST 형태 확정. 팀장 권고 = daemon-scoped 경로(소유 스코프 자연). dcy-009 UI 흐름·v1 컨벤션 정합.
|
||||
- 종료 조건: draft-batch-design 전 경로·메서드 확정.
|
||||
- OQ-2: admin 데몬 CRUD 경로 — `/v1/daemons` 확장 vs `/v1/admin/daemons`.
|
||||
- 상태: **Deferred (2026-06-28)** → draft-batch-design 단계 위임.
|
||||
- 처리 방안: design에서 확정. dcy-006 admin 경로 컨벤션 참고.
|
||||
- 종료 조건: draft-batch-design 전 확정.
|
||||
- OQ-3: 데몬 owner 재할당 허용 여부 + 재할당 시 인증정보 처리.
|
||||
- 상태: **Answered (2026-06-28)**
|
||||
- 결정: **재할당 허용(admin), 단 owner 변경 시 해당 데몬의 기존 인증정보 자동 무효화**(이전 owner의 KIS 계좌 이관 금지 — 새 owner가 재등록). §5 FR-4/범위·R-2 반영.
|
||||
|
||||
# 7. 초기 성공 기준
|
||||
- S-1: 사용자가 자기 데몬 인증정보를 API로 등록하면, 그 데몬이 service-token `/v1/user_info`에서 동일 계좌를 fetch함을 검증할 수 있다.
|
||||
- S-2: 인증정보 관리/메타데이터·데몬 CRUD 어떤 user-token/admin 응답에도 복호화 app_secret/app_key가 포함되지 않음이 테스트로 확인된다.
|
||||
- S-3: 사용자 A가 사용자 B 소유 데몬의 인증정보 조회·수정 시 403(소유 스코프)이 테스트로 확인된다.
|
||||
- S-4: admin이 데몬 생성·owner 할당·(비)활성화를 수행하고, 비-admin은 거부(403)됨이 확인된다.
|
||||
|
||||
## 측정 방식 및 데이터 출처
|
||||
- S-1 측정 방식: API 등록 → service-token `/v1/user_info` fetch 일치 / 데이터 출처: 통합테스트, broker_credentials 행
|
||||
- S-2 측정 방식: 관리·CRUD 응답 스키마/실응답에서 secret 필드 부재 + 로그 미노출 / 데이터 출처: API·로그 테스트
|
||||
- S-3 측정 방식: 타 사용자 자원 접근 → 403 음성 테스트 / 데이터 출처: API 테스트
|
||||
- S-4 측정 방식: admin/비-admin 권한 경로 테스트 / 데이터 출처: API 테스트
|
||||
|
||||
# 8. REQ로 넘기기 전 확인 체크
|
||||
- [x] 범위(In Scope/Out of Scope) 확정
|
||||
- 기준: 데몬 owner 재할당 정책(OQ-3 재할당+인증정보 무효화) 결정 완료
|
||||
- [x] 성공 지표의 측정 방식/데이터 출처 확정
|
||||
- 기준: S-1~S-4 측정 가능성 검토 완료
|
||||
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
|
||||
- 기준: R-1(secret 노출 — 쓰기전용)·R-2(소유 스코프 인가 — 엄격 ownership) 완화 모델 확정. 구현 책임=portfolio-hub-dev
|
||||
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
||||
- 기준: OQ-3 Answered, OQ-1/OQ-2 Deferred(design 위임)
|
||||
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
||||
- 기준: 사장님+팀장 confirm-discovery 완료
|
||||
|
||||
# 9. 파일 처리 결과
|
||||
- 처리 결과: 생성
|
||||
- 생성 경로: docs/discovery/dcy-011_20260628_daemon-crud-and-self-service-credential-api.md
|
||||
- 참조 문서:
|
||||
- trex-web docs/discovery/dcy-009_20260628_daemon-and-broker-credential-admin-management.md (교차 발원 — 소비자 UI/요건)
|
||||
- docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md (선행 — 인증정보 모델/보안/REQ-051)
|
||||
- docs/discovery/dcy-004_20260520_token-issuance-and-lifecycle-overhaul.md (권한/토큰 모델)
|
||||
|
||||
# 10. 사용자 결정 필요 항목 요약
|
||||
|
||||
## DECIDE
|
||||
- ~~데몬 owner 재할당 (OQ-3)~~ → **RESOLVED 2026-06-28: 재할당 허용(admin), owner 변경 시 기존 인증정보 자동 무효화**
|
||||
|
||||
## CONFIRM
|
||||
- ~~쓰기 전용·secret 미반환·service-token 유일 decrypt 불변~~ → **RESOLVED 2026-06-28: 확정(NFR-1)**
|
||||
- ~~엄격 소유 스코프 인가 + 데몬 CRUD admin~~ → **RESOLVED 2026-06-28: 확정(NFR-2)**
|
||||
|
||||
## DATA
|
||||
- {{DATA_V1_CONVENTIONS:기존 v1 API 경로·권한 컨벤션(/v1/daemons, /v1/portfolios, dcy-006 admin) — 엔드포인트 형태(OQ-1/OQ-2 design) 설계 입력}}
|
||||
|
||||
# 11. Discovery Freeze
|
||||
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다.
|
||||
|
||||
## 섹션 참조
|
||||
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
|
||||
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
|
||||
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
|
||||
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
|
||||
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
|
||||
|
||||
## Freeze 플래그
|
||||
- Handoff Decision: REQ Drafting 진행 가능
|
||||
- Handoff Rationale: confirm-discovery 완료(2026-06-28). CONFIRM 2건(쓰기전용·secret 미반환·service-token 유일 decrypt / 엄격 소유 스코프+데몬 CRUD admin) + DECIDE 1건(OQ-3 owner 재할당 허용+인증정보 자동 무효화) 확정. OQ-1/OQ-2(엔드포인트 경로)는 draft-batch-design 위임 Deferred. 잔여 Open 0건 → REQ 인계 가능. trex-web dcy-009가 이 계약 소비.
|
||||
- Ready for REQ Drafting: true
|
||||
- Confirmed By: 팀장(Claude)
|
||||
- Confirmed At (KST): 2026-06-28
|
||||
@@ -16,3 +16,4 @@
|
||||
| dcy-008_20260625_current-holding-wac-cost-basis-read | 20260625 | 현재 보유 종목별 평단(WAC) 조회 API — trex-core 중장기 cold-start 슬롯 평단 seed 의존(교차 발원 trex-core dcy-034 FR-7). GET /v1/portfolios/{id}/holdings/cost-basis | [Discovery](./dcy-008_20260625_current-holding-wac-cost-basis-read.md) | confirmed |
|
||||
| dcy-009_20260627_user-read-asset-catalog-and-daemons | 20260627 | 사용자용 read 2종 — GET /v1/assets require_read 완화 + 사용자 가용 데몬 목록 신규(교차 발원 trex-web 포트폴리오 관리 dcy-007) | [Discovery](./dcy-009_20260627_user-read-asset-catalog-and-daemons.md) | confirmed |
|
||||
| dcy-010_20260627_daemon-scoped-broker-credential-provisioning | 20260627 | daemon-scoped 브로커(KIS) 인증정보 소유·전달 — 다중 인스턴스 trex-core가 daemon별 다른 계좌로 거래 (교차 발원 trex-core 중장기 멀티 인스턴스) | [Discovery](./dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md) | confirmed |
|
||||
| dcy-011_20260628_daemon-crud-and-self-service-credential-api | 20260628 | 데몬 admin CRUD API + 사용자 self-service 브로커 인증정보 관리 API — 소유 스코프 write/rotate/메타데이터(복호화 secret 미반환), 데몬 생성·할당·(비)활성화는 admin (교차 발원 trex-web dcy-009) | [Discovery](./dcy-011_20260628_daemon-crud-and-self-service-credential-api.md) | confirmed |
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# REQ-052: 사용자 소유 스코프 인증정보 관리 HTTP API
|
||||
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- 현재 BrokerCredential 관리는 CLI(`pfhub credential`)와 service-token read 경로(REQ-051/REQ-049)만 존재하며, HTTP user-token 기반 관리 표면이 없다.
|
||||
- trex-web dcy-009 사용자 self-service UI가 소비하는 백엔드 경로를 신설한다: 사용자가 자기 소유 데몬의 KIS 인증정보를 HTTP API로 등록·회전·삭제하고, 메타데이터(secret 미포함)를 조회할 수 있도록 한다.
|
||||
- dcy-010 자산(BrokerCredential 모델, Fernet 암호화, `CREDENTIAL_ENCRYPTION_KEY`, PK `(daemon_id, broker)`)을 재사용하여 CLI와 동일 repository·암호화 경로를 공유한다.
|
||||
|
||||
## Requirement
|
||||
- 시스템은 user-token으로 인증된 사용자가 **자기 소유 데몬**의 broker 인증정보를 등록·회전·삭제할 수 있는 write 엔드포인트를 제공해야 한다.
|
||||
- 등록/회전: `app_key` + `app_secret` + `account_ref` 입력을 Fernet 암호화하여 `BrokerCredential`에 upsert. 회전 시 `credential_version` 증가, `last_rotated_at` 갱신.
|
||||
- 삭제: 해당 `(daemon_id, broker)` 행 제거.
|
||||
- 시스템은 사용자가 자기 소유 데몬의 인증정보 **메타데이터**를 조회할 수 있는 read 엔드포인트를 제공해야 한다. 응답에는 `broker`, `account_ref`, `credential_version`, `last_rotated_at`, 보유 여부(`has_credential`)만 포함하며 **복호화된 `app_key`/`app_secret`은 절대 포함하지 않는다**.
|
||||
- **소유 스코프 인가**를 모든 관리 경로에 강제해야 한다: 요청자 `user_id`가 대상 데몬의 `owner_user_id`와 일치하지 않으면 HTTP 403 Forbidden을 반환한다. 인증 토큰이 없는 요청은 HTTP 401을 반환한다.
|
||||
- 인증정보 write 요청이 성공하면 응답에는 메타데이터(`broker`, `account_ref`, `credential_version`, `last_rotated_at`)만 반환하고 `app_key`/`app_secret`은 포함하지 않는다.
|
||||
- 엔드포인트 경로·HTTP 메서드의 구체 형태는 draft-batch-design에서 v1 컨벤션(`/v1/daemons/{daemon_id}/credential` 방향 권고, dcy-011 OQ-1 Deferred)에 맞춰 확정한다.
|
||||
- 기존 CLI(`pfhub credential`)와 동일한 `BrokerCredentialRepository` 메서드 및 `app/core/encryption.py` Fernet 자산을 재사용해야 한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given user-token(소유자 A)으로 자기 소유 daemon D에 `app_key`/`app_secret`/`account_ref` 를 등록, When write 엔드포인트를 호출, Then HTTP 200/201 + 메타데이터 응답(`broker`, `account_ref`, `credential_version`, `last_rotated_at`)이 반환되고 응답 JSON에 `app_key`/`app_secret` 필드가 존재하지 않는다.
|
||||
2. Given 같은 소유자 A가 동일 daemon D 인증정보를 재등록(회전), When write 엔드포인트를 호출, Then 응답의 `credential_version`이 이전보다 1 증가하고 `last_rotated_at`이 갱신된다.
|
||||
3. Given 소유자 A가 daemon D 인증정보를 등록한 뒤, When service-token `/v1/user_info`(REQ-049)로 해당 daemon D의 인증정보를 fetch하면, Then `broker.account_ref`가 방금 등록한 값과 일치한다(CLI·API·service-token 경로 정합).
|
||||
4. Given user-token(소유자 A)으로 자기 소유 daemon D 인증정보 메타데이터를 GET, When 조회 엔드포인트를 호출, Then HTTP 200 + `broker`, `account_ref`, `credential_version`, `last_rotated_at`, `has_credential` 반환되고 `app_key`/`app_secret` 필드는 응답에 없다.
|
||||
5. Given user-token(소유자 A)으로 자기 소유 daemon D 인증정보를 삭제, When delete 엔드포인트를 호출, Then HTTP 200/204 반환 후 해당 daemon D 메타데이터 조회 시 `has_credential: false`(또는 인증정보 없음 응답).
|
||||
6. Given user-token(사용자 B)으로 **사용자 A 소유** daemon D의 인증정보에 대한 write/read/delete 요청, When 엔드포인트를 호출, Then HTTP 403 Forbidden 반환(소유 스코프 위반).
|
||||
7. Given 인증 토큰 없는 요청으로 인증정보 관리 엔드포인트 호출, When 호출, Then HTTP 401 반환.
|
||||
8. Given 존재하지 않는 daemon_id를 대상으로 인증정보 관리 요청, When 호출, Then HTTP 404 반환.
|
||||
9. Given admin 토큰으로 자기 소유 데몬이 아닌 daemon D의 인증정보 write 엔드포인트 호출, When 호출, Then HTTP 403 반환(관리 API는 소유 스코프 전용 — admin role로 타인 인증정보 조작 불가).
|
||||
10. Given service-token으로 인증정보 관리 엔드포인트 호출, When 호출, Then HTTP 403 반환(service-token은 read 전용 `/v1/user_info` 경로만 허용).
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- `docs/interface-contract.md` — 인증정보 관리 엔드포인트 경로·권한·응답 스키마(secret 미포함 정책) 신규 섹션 추가.
|
||||
- Modules:
|
||||
- 신규 라우터 파일(경로·파일명은 design 확정) — 인증정보 관리 write/read/delete 엔드포인트.
|
||||
- `app/schemas/` — `CredentialSetRequest`(app_key/app_secret/account_ref), `CredentialMetaResponse`(메타데이터 only, secret 미포함) 스키마 신규 정의.
|
||||
- `app/services/` — 소유 스코프 인가 검사 + Fernet 암호화/upsert/삭제 서비스 메서드 신규 또는 확장.
|
||||
- `app/repositories/broker_credential_repository.py` — 메타데이터 조회·write 메서드 확장(CLI와 공유).
|
||||
- `app/core/encryption.py` — 재사용 (변경 없음).
|
||||
- `app/main.py` 또는 라우터 등록 — 신규 라우터 include.
|
||||
- Tests:
|
||||
- AC-1~10 커버 pytest 케이스. 특히 AC-6(소유 스코프 음성), AC-9(admin 소유 스코프 음성), AC-10(service-token 음성), AC-3(CLI·API·service-token 정합) 강제 검증.
|
||||
|
||||
## Notes
|
||||
- OQ-1(엔드포인트 경로): draft-batch-design 위임(dcy-011 Deferred). 팀장 권고 = daemon-scoped(`/v1/daemons/{daemon_id}/credential`). v1 컨벤션·dcy-009 UI 흐름 정합 설계 단계 확정.
|
||||
- **복호화 secret 미반환 불변**: 이 REQ의 어떤 응답도 `app_secret`/`app_key` 평문을 포함하지 않는다. 유일 decrypt-return 경로는 service-token `GET /v1/user_info`(REQ-051/REQ-049) — 본 REQ로 변경하지 않는다.
|
||||
- AC-9(admin 소유 스코프): admin은 데몬 CRUD(REQ-053)를 담당하며, admin role로 타인 인증정보 write/read를 허용하지 않는다. 인증정보는 **사용자 본인 소유 스코프만**. 이 경계가 REQ-052(user-token, 소유 스코프)와 REQ-053(admin, 데몬 CRUD)의 책임 분리이다.
|
||||
- NFR-1(보안 — secret 미반환·로그 미노출)은 REQ-054에서 독립 Non-Functional REQ로 추적한다. 이 REQ AC는 응답 schema 단의 secret 부재 확인을 포함하며, REQ-054가 전체 보안 격리를 측정한다.
|
||||
- NFR-3(일관성 — CLI·API 정합): AC-3이 흡수한다. 구현 시 동일 repository 메서드 재사용 강제.
|
||||
- Source: dcy-011 FR-1, FR-2, FR-3, NFR-2, NFR-3.
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260628-01
|
||||
- Date: 2026-06-28
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 신규 생성 (Proposed) — dcy-011 FR-1/FR-2/FR-3 산출물.
|
||||
|
||||
#### Intent
|
||||
- dcy-011 confirmed Discovery의 FR-1(인증정보 등록·회전·삭제), FR-2(메타데이터 조회, secret 미반환), FR-3(소유 스코프 인가 강제)를 단일 Interface REQ로 정규화한다. 세 FR이 동일 관리 표면(user-token + 소유 스코프 인증정보 관리)에 속하므로 하나의 REQ로 묶는다.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1 ~ AC-10 전체 (신규 초안)
|
||||
- Updated: 없음
|
||||
- Removed: 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- `docs/srs/Interface/req-052_user-owned-credential-management-api.md` 신규 생성
|
||||
- `docs/interface-contract.md` 갱신 예고
|
||||
- Modules:
|
||||
- 인증정보 관리 라우터(신규), 스키마(신규), 서비스·repository 확장
|
||||
- Tests:
|
||||
- 소유 스코프 음성(AC-6/9/10), 메타데이터 secret 부재(AC-1/4), CLI 정합(AC-3) 케이스 신규
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: 미정 (suggest-batch-reqs 대기)
|
||||
- Releases: 미정
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 suggest-batch-reqs. REQ-053/REQ-054와 동일 batch 묶기 권고. OQ-1(경로)은 design 단계 해소.
|
||||
|
||||
#### Validation Plan
|
||||
- pytest로 AC-1~10 각 케이스 확인. AC-6(타 사용자 소유 스코프 음성), AC-9(admin 소유 스코프 음성), AC-10(service-token 음성)은 음성 테스트로 강제 검증.
|
||||
|
||||
### CHG-20260628-02
|
||||
- Date: 2026-06-28
|
||||
- Author: 팀장(Claude)
|
||||
- confirm-req: `Proposed` → `Approved`. dcy-011 FR-1/2/3 추적. 소유 스코프 음성(AC-6/9/10)·메타데이터 secret 부재(AC-1/4)·CLI·API·service-token 정합(AC-3) 적절. **AC-9(admin도 타인 인증정보 write→403)** = 사장님 "self-service 전용·admin 감독 없음" 결정과 정합 확인(admin은 데몬 CRUD만, 인증정보는 본인 소유 스코프). 경로(OQ-1) design 위임. blocker 없음.
|
||||
@@ -0,0 +1,104 @@
|
||||
# REQ-053: admin 데몬 CRUD HTTP API
|
||||
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- 현재 `Daemon` 엔티티는 `GET /v1/daemons`(read 전용, REQ-045)만 HTTP로 노출되며, 생성·변경·(비)활성화 HTTP API가 없다. 관리자(admin)가 HTTP API로 데몬을 provisioning하고 owner를 할당·변경·(비)활성화할 수 있어야 한다.
|
||||
- trex-web dcy-009 admin UI가 소비하는 백엔드 경로를 신설한다.
|
||||
- 데몬은 운영 런타임 식별자(service-token daemon_id 바인딩, KIS 계좌 연결)로 하드 삭제는 제공하지 않으며 **비활성화로 대체**한다(dcy-011 FR-4 결정). owner 재할당은 허용하되 **owner 변경 시 해당 데몬의 기존 인증정보를 자동 무효화**한다(이전 owner의 KIS 계좌 이관 금지, dcy-011 OQ-3 결정).
|
||||
|
||||
## Requirement
|
||||
- 시스템은 admin role로 인증된 요청자가 데몬을 **생성**하고 `owner_user_id`를 할당할 수 있는 POST 엔드포인트를 제공해야 한다.
|
||||
- 입력: `daemon_slug`(고유 식별자), `display_name`(선택), `owner_user_id`.
|
||||
- 성공 시 생성된 Daemon의 `id`, `daemon_slug`, `display_name`, `owner_user_id`, `is_active` 반환.
|
||||
- 시스템은 admin이 기존 데몬의 `display_name` 또는 `owner_user_id`를 **변경**할 수 있는 PATCH 엔드포인트를 제공해야 한다.
|
||||
- **owner 변경 시 해당 데몬의 기존 `BrokerCredential` 전체를 자동 삭제(무효화)** 해야 한다 — 이전 owner의 KIS 계좌 정보가 새 owner에게 이관되는 것을 방지한다.
|
||||
- 시스템은 admin이 데몬을 **비활성화**(`is_active=false`) 및 **재활성화**(`is_active=true`)할 수 있는 엔드포인트를 제공해야 한다.
|
||||
- 비활성화 시 해당 데몬에 바인딩된 service token을 자동 revoke해야 한다(기존 REQ-022 정책 재사용).
|
||||
- **하드 삭제(DELETE) 엔드포인트는 제공하지 않는다.** 비활성화가 유일한 종료 경로이다.
|
||||
- admin role이 아닌 요청(user-token, service-token 포함)은 HTTP 403 Forbidden을 반환해야 한다.
|
||||
- 엔드포인트 경로·HTTP 메서드의 구체 형태는 draft-batch-design에서 v1 admin 컨벤션(dcy-011 OQ-2 Deferred, dcy-006 admin 경로 참고)에 맞춰 확정한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given admin 토큰으로 daemon_slug, display_name, owner_user_id를 포함한 생성 요청, When POST 엔드포인트를 호출, Then HTTP 201 + 생성된 Daemon(`id`, `daemon_slug`, `display_name`, `owner_user_id`, `is_active=true`) 반환.
|
||||
2. Given admin 토큰으로 중복 `daemon_slug`로 생성 요청, When POST 엔드포인트를 호출, Then HTTP 409(또는 422) Conflict 반환(중복 슬러그 거부).
|
||||
3. Given admin 토큰으로 존재하는 daemon D의 `display_name` 변경 요청(owner 변경 없음), When PATCH 엔드포인트를 호출, Then HTTP 200 + 갱신된 Daemon 반환. 기존 BrokerCredential은 유지된다.
|
||||
4. Given admin 토큰으로 daemon D의 `owner_user_id`를 사용자 B로 재할당 요청(원래 소유자 A), When PATCH 엔드포인트를 호출, Then HTTP 200 + 갱신된 Daemon 반환 + 해당 daemon D의 BrokerCredential이 모두 삭제(무효화)된다.
|
||||
5. Given daemon D의 owner가 변경된 직후, When 변경 전 소유자 A의 service-token으로 `/v1/user_info`를 호출, Then `broker: null` 반환(이전 owner 인증정보 이관 차단).
|
||||
6. Given admin 토큰으로 활성 daemon D 비활성화 요청, When 비활성화 엔드포인트를 호출, Then HTTP 200 + `is_active: false` 반환 + daemon D에 바인딩된 service token 자동 revoke(REQ-022 정책).
|
||||
7. Given admin 토큰으로 비활성 daemon D 재활성화 요청, When 재활성화 엔드포인트를 호출, Then HTTP 200 + `is_active: true` 반환.
|
||||
8. Given admin 토큰으로 daemon D에 DELETE 요청(하드 삭제), When 호출, Then HTTP 405 Method Not Allowed(또는 404) 반환 — 하드 삭제 엔드포인트 미제공.
|
||||
9. Given user-token(admin role 없음)으로 데몬 생성·변경·비활성화 엔드포인트 호출, When 호출, Then HTTP 403 Forbidden 반환.
|
||||
10. Given service-token으로 데몬 CRUD 엔드포인트 호출, When 호출, Then HTTP 403 Forbidden 반환.
|
||||
11. Given 인증 토큰 없는 요청으로 데몬 CRUD 엔드포인트 호출, When 호출, Then HTTP 401 반환.
|
||||
12. Given admin 토큰으로 존재하지 않는 daemon_id에 PATCH/비활성화 요청, When 호출, Then HTTP 404 반환.
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- `docs/interface-contract.md` — admin 데몬 CRUD 엔드포인트 경로·권한·owner 재할당 시 인증정보 무효화 정책 신규 섹션 추가.
|
||||
- Modules:
|
||||
- 신규 또는 확장 admin 데몬 라우터(경로·파일명은 design 확정).
|
||||
- `app/schemas/` — `DaemonCreateRequest`, `DaemonUpdateRequest`, `DaemonAdminResponse` 스키마 신규 정의.
|
||||
- `app/services/` — 데몬 생성·변경·비활성화 서비스 메서드 신규. owner 재할당 시 `BrokerCredential` 무효화 로직 포함.
|
||||
- `app/repositories/` — `DaemonRepository` create/update/deactivate/reactivate 메서드 신규.
|
||||
- `app/repositories/broker_credential_repository.py` — owner 재할당 시 credential 삭제 메서드(신규 또는 확장).
|
||||
- REQ-022(`daemon deactivation service token auto-revoke`) 서비스 로직 재사용 — 비활성화 경로에서 호출.
|
||||
- Tests:
|
||||
- AC-1~12 커버 pytest 케이스. AC-4/5(owner 재할당 + 인증정보 무효화), AC-8(DELETE 405 음성), AC-9/10/11(권한 음성) 강제 검증.
|
||||
|
||||
## Notes
|
||||
- OQ-2(엔드포인트 경로): draft-batch-design 위임(dcy-011 Deferred). `/v1/admin/daemons` vs `/v1/daemons`(admin 분기) — dcy-006 admin 경로 컨벤션 참고.
|
||||
- **owner 재할당 + 인증정보 자동 무효화(AC-4/5)**: dcy-011 OQ-3 Answered(2026-06-28 결정). 새 owner는 자기 KIS 인증정보를 REQ-052 API로 재등록해야 한다. 이 정책은 이전 owner의 KIS 계좌 정보가 데몬에 잔류하는 보안 위험을 제거한다.
|
||||
- **하드 삭제 미제공(AC-8)**: 데몬은 운영 런타임 식별자(service-token daemon_id 바인딩)이므로 참조 무결성 보호를 위해 비활성화가 유일 종료 경로. dcy-011 FR-4 결정.
|
||||
- **비활성화 → service token revoke(AC-6)**: 기존 REQ-022 정책(`daemon deactivation auto-revoke`)을 이 경로에서도 호출. REQ-022 구현 재사용.
|
||||
- NFR-2(권한 — admin role 강제): AC-9/10/11로 흡수한다.
|
||||
- daemon_slug 중복 처리(AC-2): HTTP 409 vs 422는 design 단계에서 v1 컨벤션에 맞춰 확정.
|
||||
- Source: dcy-011 FR-4, NFR-2, OQ-3 Answered.
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260628-01
|
||||
- Date: 2026-06-28
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 신규 생성 (Proposed) — dcy-011 FR-4 산출물.
|
||||
|
||||
#### Intent
|
||||
- dcy-011 confirmed Discovery의 FR-4(admin 데몬 생성·owner 할당·변경·(비)활성화, 삭제 제외) 및 OQ-3 결정(owner 재할당 허용 + 인증정보 자동 무효화)을 Interface REQ로 정규화한다.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1 ~ AC-12 전체 (신규 초안)
|
||||
- Updated: 없음
|
||||
- Removed: 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- `docs/srs/Interface/req-053_admin-daemon-crud-api.md` 신규 생성
|
||||
- `docs/interface-contract.md` 갱신 예고
|
||||
- Modules:
|
||||
- admin 데몬 라우터(신규), DaemonCreate/UpdateRequest/AdminResponse 스키마(신규), 서비스·repository 신규
|
||||
- Tests:
|
||||
- owner 재할당+인증정보 무효화(AC-4/5), DELETE 음성(AC-8), 권한 음성(AC-9~11) 케이스 신규
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: 미정 (suggest-batch-reqs 대기)
|
||||
- Releases: 미정
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 suggest-batch-reqs. REQ-052/REQ-054와 동일 batch 묶기 권고. OQ-2(경로)은 design 단계 해소.
|
||||
|
||||
#### Validation Plan
|
||||
- pytest로 AC-1~12 각 케이스 확인. AC-4/5(owner 재할당 + 인증정보 무효화) 통합 시나리오, AC-8(DELETE 음성), AC-9(admin role 강제 음성) 강제 검증.
|
||||
|
||||
### CHG-20260628-02
|
||||
- Date: 2026-06-28
|
||||
- Author: 팀장(Claude)
|
||||
- confirm-req: `Proposed` → `Approved`. dcy-011 FR-4/OQ-3 추적. 데몬 생성·할당·(비)활성화, **owner 재할당 시 인증정보 자동 무효화(AC-4/5)** 보안정합, **하드 삭제 미제공(AC-8)**·비활성화→service token revoke(REQ-022 재사용, AC-6)·admin role 강제(AC-9/10/11) 적절. 경로(OQ-2)·409/422·405/404 design 위임. blocker 없음.
|
||||
@@ -1,6 +1,6 @@
|
||||
# REQ-047: broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션
|
||||
|
||||
- Status: Approved
|
||||
- Status: Implemented
|
||||
- Type: Migration
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
@@ -123,3 +123,37 @@
|
||||
|
||||
#### Validation Plan
|
||||
- 사장님 결재 → Step1 실DB 적용 → REQ-048 백필 → Step2 실DB 적용 → AC-1~7 전건 real-DB evidence 확보 후 Implemented 전환.
|
||||
|
||||
### CHG-20260628-04
|
||||
- Date: 2026-06-28
|
||||
- Author: 팀장(Claude)
|
||||
- confirm-req-implemented: `Approved` → `Implemented`.
|
||||
|
||||
#### Change Summary
|
||||
- BAT-021 PR #18 머지(mergeCommit 7164f9e) — ensure_schema() broker_credentials 최종 daemon-scoped 스키마 갱신, _row_to_broker_credential 컬럼순서 정정, upsert ON CONFLICT (user_id,broker) → (daemon_id,broker) 수정, daemon 폴백 트랜잭션 abort 이슈(fallback 제거) 코드가 포함됨.
|
||||
- 실 dev PostgreSQL 통합검증: Step1 → backfill → Step2 → downgrade 순서 전건 실행, AC-1~7 PASS.
|
||||
- QC(opus) PASS-WITH-NOTES(blocker 0) 확인됨.
|
||||
|
||||
#### Intent
|
||||
- Implemented 전환. AC-3/4/5(Step2 PK 전환 적용 후 schema) real-DB evidence 확보 완료.
|
||||
- **주의**: Implemented는 코드 + dev DB 통합검증 완료 기준. **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트**(런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md`, 배포순서 Step1→backfill→Step2→코드배포 엄수, alembic stamp 사전확인). 운영 적용 시 AC-3/4/5 실운영 evidence 재확보.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added: 없음
|
||||
- Updated: 없음
|
||||
- Removed: 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- 없음 (상태 전환 기록)
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: BAT-021 (PR #18, mergeCommit 7164f9e) — 동반 코드 수정 + 통합검증 완료
|
||||
- Releases: 미정 (Step2 실 운영 DB 적용은 사장님 결재 후)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no (dev DB AC-1~7 전건 PASS, 운영 적용 시 재확보 별도 필요)
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: Implemented
|
||||
|
||||
#### Validation Plan
|
||||
- BAT-021 verification.md의 AC-1~7 전건 real-DB evidence 참조. 운영 적용 시 런북 절차 준수.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# REQ-054: 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출
|
||||
|
||||
- Status: Approved
|
||||
- Type: Non-Functional
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- dcy-011 NFR-1(보안 최우선): 복호화된 `app_secret`/`app_key`(KIS API 비밀키 — 실계좌·실자금 직결)는 user-token/admin 어떤 관리 API 응답·로그에도 포함되어서는 안 된다.
|
||||
- REQ-052(사용자 소유 스코프 인증정보 관리 API)와 REQ-053(admin 데몬 CRUD API)이 신설됨으로써 응답/로그 노출 표면이 확대된다. 이 표면에서 복호화 secret이 유출되지 않음을 독립적으로 측정·검증한다.
|
||||
- `GET /v1/user_info`(service-token 전용, REQ-051/REQ-049)가 유일 decrypt-return 경로라는 불변 정책을 신규 관리 API 추가 이후에도 유지한다.
|
||||
|
||||
## Requirement
|
||||
- 인증정보 관리 API(REQ-052: 소유 스코프 write/read/delete + 메타데이터, REQ-053: admin 데몬 CRUD) 응답 스키마에 `app_key` 및 `app_secret` 필드가 존재해서는 안 된다.
|
||||
- 인증정보 관리 API 처리 중 생성되는 모든 로그(INFO, WARNING, ERROR 수준 포함)에 `app_secret` 또는 `app_key` 평문이 포함되어서는 안 된다. 로그에는 `broker` 이름, `daemon_id`, 오류 유형만 기록한다.
|
||||
- 복호화된 `credential_payload`(`app_key`, `app_secret`)를 반환하는 유일 경로는 service-token 전용 `GET /v1/user_info`(REQ-051/REQ-049)이다. 이 REQ-052·REQ-053 신설로 인해 이 경로가 변경되어서는 안 된다.
|
||||
- 인증정보 쓰기(등록/회전) 후 DB에 저장되는 값은 반드시 Fernet 암호화된 형태여야 한다. 평문 `app_secret`/`app_key`가 `broker_credentials` 테이블에 저장되어서는 안 된다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given REQ-052 인증정보 write(등록/회전) 엔드포인트 응답 JSON, When 응답 필드를 열거, Then `app_key`, `app_secret` 키가 최상위 및 중첩 객체에 포함되지 않는다.
|
||||
2. Given REQ-052 인증정보 메타데이터 GET 엔드포인트 응답 JSON, When 응답 필드를 열거, Then `app_key`, `app_secret` 키가 포함되지 않는다.
|
||||
3. Given REQ-053 admin 데몬 생성·변경·(비)활성화 엔드포인트 응답 JSON, When 응답 필드를 열거, Then `app_key`, `app_secret`, `credential_payload` 키가 포함되지 않는다.
|
||||
4. Given 인증정보 write/rotate 처리 중 caplog 수집, When 로그 출력을 확인, Then 실제 `app_key`/`app_secret` 값이 로그 메시지에 포함되지 않는다.
|
||||
5. Given 인증정보 write 요청의 `app_key`/`app_secret` 값으로 DB row를 직접 조회, When `broker_credentials.credential_payload` 컬럼을 확인, Then 평문이 아닌 Fernet 암호화 토큰(`gAAAA...`) 형태로 저장되어 있다.
|
||||
6. Given REQ-052 인증정보 write 후 service-token `GET /v1/user_info`(REQ-049)로 조회, When 호출, Then `broker.credential_payload.app_secret`이 올바르게 복호화되어 반환된다 — 관리 API 추가가 유일 decrypt 경로(REQ-051)를 파괴하지 않음 확인.
|
||||
7. Given REQ-052·REQ-053 신규 엔드포인트가 추가된 후 기존 `GET /v1/user_info` 보안 격리 테스트(REQ-051 AC-1~8), When 회귀 실행, Then 전체 PASS — 신규 API 추가가 기존 service-token 전용 경로에 영향 없음 확인.
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- `docs/interface-contract.md` — 인증정보 관리 API 보안 정책 섹션: 복호화 secret 미반환·로그 미노출·Fernet at-rest 불변·service-token 유일 decrypt 경로 명시.
|
||||
- Modules:
|
||||
- `app/schemas/` — REQ-052 응답 스키마(`CredentialMetaResponse`)에 `app_key`/`app_secret` 필드 부재 강제(Pydantic 모델 설계 단계 확인).
|
||||
- `app/services/` — 인증정보 write 서비스에서 로그 호출 시 secret 평문 비포함 확인.
|
||||
- `app/core/encryption.py` — Fernet 암호화 후 저장 경로(REQ-052 write) 재사용.
|
||||
- Tests:
|
||||
- caplog 기반 로그 secret 비노출 테스트(AC-4).
|
||||
- 응답 스키마 secret 필드 부재 테스트(AC-1/2/3).
|
||||
- DB 저장 암호화 확인 테스트(AC-5).
|
||||
- REQ-051 회귀 테스트(AC-7): 신규 API 추가 후 기존 service-token gate 테스트 전부 PASS.
|
||||
|
||||
## Notes
|
||||
- dcy-011 NFR-1(보안 — 최우선): REQ-052/REQ-053의 각 AC에 secret 부재 조건이 포함되어 있으나, 이 REQ-054는 신규 관리 API 전체를 아우르는 **독립 측정·검증 목적** Non-Functional REQ로 분리한다. REQ-051(서비스 토큰 전용 격리)과 나란히 추적한다.
|
||||
- AC-7(REQ-051 회귀): REQ-052·REQ-053 구현 후 기존 service-token 보안 격리(REQ-051 AC-1~8)가 회귀 없이 통과함을 이 REQ의 검증 항목으로 포함한다.
|
||||
- at-rest Fernet 암호화(AC-5): `CREDENTIAL_ENCRYPTION_KEY` 관리 정책은 REQ-051 Notes에 따라 이 REQ 범위 밖(환경변수 기반 유지).
|
||||
- NFR-2(권한)·NFR-3(일관성)의 나머지는 REQ-052/REQ-053 AC에 흡수된다. NFR-1 보안만 별도 Non-Functional REQ로 승격한다.
|
||||
- Source: dcy-011 NFR-1, R-1(복호화 secret 유출 리스크), S-2(복호화 secret 미포함 성공 기준).
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260628-01
|
||||
- Date: 2026-06-28
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- 신규 생성 (Proposed) — dcy-011 NFR-1 산출물.
|
||||
|
||||
#### Intent
|
||||
- dcy-011 NFR-1(복호화 secret이 user-token/admin 응답·로그에 미포함, REQ-051 패턴)을 신규 관리 API(REQ-052·REQ-053) 표면 전체에 대한 독립 측정 가능 Non-Functional REQ로 정규화한다.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1 ~ AC-7 전체 (신규 초안)
|
||||
- Updated: 없음
|
||||
- Removed: 없음
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- `docs/srs/Non-Functional/req-054_credential-management-security-write-only.md` 신규 생성
|
||||
- `docs/interface-contract.md` 갱신 예고
|
||||
- Modules:
|
||||
- 응답 스키마(secret 필드 부재), 서비스 로그 확인
|
||||
- Tests:
|
||||
- caplog 로그 비노출(AC-4), 응답 schema secret 부재(AC-1~3), DB 암호화 확인(AC-5), REQ-051 회귀(AC-7) 신규
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: 미정 (suggest-batch-reqs 대기)
|
||||
- Releases: 미정
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 suggest-batch-reqs. REQ-052/REQ-053와 동일 batch 묶기 권고.
|
||||
|
||||
#### Validation Plan
|
||||
- pytest caplog fixture로 AC-4(로그 비노출), 응답 JSON 필드 열거로 AC-1~3, DB row 직접 확인으로 AC-5, REQ-051 회귀 suite로 AC-7 검증.
|
||||
|
||||
### CHG-20260628-02
|
||||
- Date: 2026-06-28
|
||||
- Author: 팀장(Claude)
|
||||
- confirm-req: `Proposed` → `Approved`. dcy-011 NFR-1 추적. 신규 관리 API(REQ-052/053) 응답·로그 secret 미포함(AC-1~4)·Fernet at-rest(AC-5)·service-token 유일 decrypt 불변(AC-6)·REQ-051 회귀(AC-7) testable. REQ-051과 나란히 독립 보안 REQ 승격 타당. blocker 없음.
|
||||
+8
-4
@@ -74,7 +74,7 @@
|
||||
|
||||
## Next Requirement ID
|
||||
|
||||
- Current: `REQ-052`
|
||||
- Current: `REQ-055`
|
||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||
|
||||
## Requirement Register
|
||||
@@ -127,16 +127,20 @@
|
||||
| REQ-044 | GET /v1/assets 권한 완화 — require_admin → require_read | Interface | Implemented | High | Justin Ko | [req-044](./Interface/req-044_get-v1-assets-require-read-relaxation.md) |
|
||||
| REQ-045 | 사용자 가용 데몬 목록 read 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-045](./Interface/req-045_user-daemon-list-endpoint.md) |
|
||||
| REQ-046 | BrokerCredential 도메인 모델 daemon_id 필드 추가 | Data | Implemented | High | Justin Ko | [req-046](./Data/req-046_broker-credential-daemon-id-field.md) |
|
||||
| REQ-047 | broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션 | Migration | Approved | High | Justin Ko | [req-047](./Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md) |
|
||||
| REQ-047 | broker_credentials 테이블 daemon-scoped PK 전환 마이그레이션 | Migration | Implemented | High | Justin Ko | [req-047](./Migration/req-047_broker-credentials-daemon-scoped-pk-migration.md) |
|
||||
| REQ-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | Implemented | High | Justin Ko | [req-048](./Migration/req-048_broker-credentials-daemon-id-backfill.md) |
|
||||
| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달로 확장 | Interface | Implemented | High | Justin Ko | [req-049](./Interface/req-049_user-info-daemon-scoped-broker-delivery.md) |
|
||||
| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | Implemented | High | Justin Ko | [req-050](./Interface/req-050_credential-cli-daemon-id-option.md) |
|
||||
| REQ-051 | 브로커 인증정보 보안 격리 — service-token 전용·web 토큰 차단 | Non-Functional | Implemented | High | Justin Ko | [req-051](./Non-Functional/req-051_broker-credential-security-isolation.md) |
|
||||
| REQ-052 | 사용자 소유 스코프 인증정보 관리 HTTP API | Interface | Approved | High | Justin Ko | [req-052](./Interface/req-052_user-owned-credential-management-api.md) |
|
||||
| REQ-053 | admin 데몬 CRUD HTTP API | Interface | Approved | High | Justin Ko | [req-053](./Interface/req-053_admin-daemon-crud-api.md) |
|
||||
| REQ-054 | 인증정보 관리 보안 격리 — write-only·secret 미반환·로그 미노출 | Non-Functional | Approved | High | Justin Ko | [req-054](./Non-Functional/req-054_credential-management-security-write-only.md) |
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 2026-06-28: REQ-052/053/054 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-011 추적·AC testable·blocker 0. 소유 스코프 음성(REQ-052 AC-6/9/10)·owner 재할당+인증정보 무효화(REQ-053 AC-4/5)·secret 미반환·로그 미노출·REQ-051 회귀(REQ-054) 확인. admin도 인증정보는 소유 스코프 전용(self-service 결정 정합). 엔드포인트 경로(OQ-1/2) design 위임. 다음=suggest-batch-reqs.
|
||||
- 2026-06-28: REQ-052/053/054 신규 생성 (Proposed) — dcy-011 confirmed Discovery 산출물. REQ-052(사용자 소유 스코프 인증정보 관리 HTTP API), REQ-053(admin 데몬 CRUD HTTP API), REQ-054(인증정보 관리 보안 격리 Non-Functional).
|
||||
- 2026-06-28: REQ-047 `Approved` → `Implemented` (confirm-req-implemented) — BAT-021 PR #18 머지(7164f9e), 실 dev PG 통합검증 AC-1~7 PASS(Step1→backfill→Step2→downgrade), QC PASS-WITH-NOTES(blocker 0). **Step2(PK 전환) 실 운영 DB 적용은 별도 release 결재 게이트**(런북 `docs/runbooks/bat-021-req047-step2-migration-runbook.md` 엄수, 배포순서 Step1→backfill→Step2→코드배포, alembic stamp 사전확인). 운영 적용 시 AC-3/4/5 실운영 evidence 재확보.
|
||||
- 2026-06-28: REQ-046/048/049/050/051 `Approved` → `Implemented` (confirm-req-implemented) — BAT-020 PR #17 머지(d4acfd4), QC PASS-WITH-NOTES(blocker 0), 테스트 통과. REQ-047은 실 운영 DB 적용·실 PostgreSQL 통합검증이 비가역 사장님 결재 게이트 미완 → Approved 유지. AC-3/4/5(Step2 적용 후 schema) real-DB evidence 대기. 결재·적용 시 TODO: ensure_schema()·upsert ON CONFLICT를 (daemon_id,broker)로 갱신.
|
||||
- 2026-06-28: REQ-046~051 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-010 daemon-scoped broker credential. 전건 dcy-010 FR/NFR 추적·AC testable·blocker 0 확인. 단계적 마이그레이션(REQ-047 Step1/2)·**REQ-047 Step2 PK 전환은 비가역 결재 게이트**·2데몬 격리(REQ-049 AC-4/5)·보안 격리(REQ-051) 반영. 비차단 design 위임 2건(REQ-048 구현방식, REQ-050 slug/UUID·과도기 정책).
|
||||
- 2026-06-27: REQ-025 name 검증 완화(CHG-20260627-03) — `_PORTFOLIO_NAME_RE` 한글(가-힣)+공백 허용(`^[a-zA-Z0-9가-힣 ._:\-]{1,100}$`). minor-change. 기존 AC 유지, 테스트 3개 추가.
|
||||
- 2026-06-27: REQ-044/045 `Approved` → `Implemented` — BAT-019 PR #16 main 머지(`7f15c6b`) QC PASS. 472 passed/0 failed. REQ-045 env토큰(user_id=None) 처리 Notes 정정: "HTTP 403" → "빈 목록 [] HTTP 200"(change-req CHG-20260627-02).
|
||||
- 2026-06-27: REQ-044/045 신규 (Proposed) — dcy-009 FR-1(GET /v1/assets require_read 완화)·FR-2(사용자 가용 데몬 목록 엔드포인트 신설) REQ 초안 작성. 엔드포인트 경로·스키마(OQ-2) 및 가용 데몬 의미(OQ-3)는 design Deferred.
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
"""BAT-022: REQ-052 — GET/PUT/DELETE /v1/daemons/{daemon_id}/credential (소유 스코프 인증정보 관리)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.auth import AuthContext
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.encryption import encrypt_payload
|
||||
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
|
||||
from app.main import create_app
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
OWNER_USER_ID = "user-owner"
|
||||
OTHER_USER_ID = "user-other"
|
||||
DAEMON_ID = "daemon-001"
|
||||
BROKER = "kis"
|
||||
ACCOUNT_REF = "acc-001"
|
||||
APP_KEY = "my-app-key-12345"
|
||||
APP_SECRET = "my-app-secret-xyz"
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
return Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(settings: Settings, mock_repo) -> TestClient:
|
||||
get_settings.cache_clear()
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _auth_header(token: str = "user-token") -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _make_daemon(
|
||||
daemon_id: str = DAEMON_ID,
|
||||
owner_user_id: str = OWNER_USER_ID,
|
||||
is_active: bool = True,
|
||||
) -> Daemon:
|
||||
return Daemon(
|
||||
id=daemon_id,
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug="trex-orb",
|
||||
display_name="ORB",
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
def _make_cred(
|
||||
daemon_id: str = DAEMON_ID,
|
||||
account_ref: str = ACCOUNT_REF,
|
||||
credential_version: int = 1,
|
||||
) -> BrokerCredential:
|
||||
payload_json = json.dumps({"app_key": APP_KEY, "app_secret": APP_SECRET})
|
||||
encrypted = encrypt_payload(_TEST_ENC_KEY, payload_json)
|
||||
return BrokerCredential(
|
||||
user_id=OWNER_USER_ID,
|
||||
broker=BROKER,
|
||||
account_ref=account_ref,
|
||||
encrypted_payload=encrypted,
|
||||
credential_version=credential_version,
|
||||
last_rotated_at=datetime(2026, 6, 28, 12, 0, 0, tzinfo=timezone.utc),
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
|
||||
def _make_user_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="tok-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="user",
|
||||
runtime_id=None,
|
||||
daemon_id=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_service_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="svc-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="service",
|
||||
runtime_id="rt-1",
|
||||
daemon_id=DAEMON_ID,
|
||||
)
|
||||
|
||||
|
||||
def _make_admin_principal(user_id: str = "admin-user") -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="adm-1",
|
||||
user_id=user_id,
|
||||
permission="admin",
|
||||
token_type="admin",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-1: PUT → 200 + 메타데이터 (secret 미포함)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_returns_meta_without_secret() -> None:
|
||||
"""REQ-052 AC-1: user-token(소유자) PUT → 200 + 메타데이터, app_key/app_secret 없음."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred(credential_version=1)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# Metadata fields present
|
||||
assert body["daemon_id"] == DAEMON_ID
|
||||
assert body["broker"] == BROKER
|
||||
assert body["account_ref"] == ACCOUNT_REF
|
||||
assert body["has_credential"] is True
|
||||
assert body["credential_version"] == 1
|
||||
# Secret fields MUST NOT be in response (REQ-052 AC-1, REQ-054 AC-1)
|
||||
assert "app_key" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "encrypted_payload" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-2: 회전 시 credential_version 증가
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_rotation_increments_version() -> None:
|
||||
"""REQ-052 AC-2: 동일 daemon 재PUT → credential_version 이전보다 1 증가."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
# Simulate rotation: upsert returns version=2
|
||||
cred_v2 = _make_cred(credential_version=2)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred_v2
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["credential_version"] == 2
|
||||
# upsert called with correct daemon_id
|
||||
mock_repo.upsert_broker_credential.assert_called_once()
|
||||
call_kwargs = mock_repo.upsert_broker_credential.call_args
|
||||
assert call_kwargs.kwargs["daemon_id"] == DAEMON_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-3: service-token /v1/user_info 정합 (CLI·API·service-token 경로 통일)
|
||||
# AC-3 is integration-level: verified by REQ-054 AC-6 (service-token GET /v1/user_info after PUT)
|
||||
# Here we verify upsert is called with the correct daemon_id + account_ref
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_upsert_called_with_correct_daemon_id() -> None:
|
||||
"""REQ-052 AC-3 partial: PUT 이후 upsert에 daemon_id가 정확히 전달됨."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred(account_ref="my-account")
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": "my-account", "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
mock_repo.upsert_broker_credential.assert_called_once()
|
||||
kwargs = mock_repo.upsert_broker_credential.call_args.kwargs
|
||||
assert kwargs["daemon_id"] == DAEMON_ID
|
||||
assert kwargs["account_ref"] == "my-account"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-4: GET → 200 + 메타데이터 (secret 미포함)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_credential_meta_returns_metadata_without_secret() -> None:
|
||||
"""REQ-052 AC-4: user-token(소유자) GET → 200 + 메타데이터, secret 없음."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.get_broker_credential_by_daemon.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["broker"] == BROKER
|
||||
assert body["account_ref"] == ACCOUNT_REF
|
||||
assert body["has_credential"] is True
|
||||
assert body["credential_version"] == 1
|
||||
assert "app_key" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "encrypted_payload" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-5: DELETE → 204, 이후 GET has_credential=False
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_delete_credential_returns_204() -> None:
|
||||
"""REQ-052 AC-5: DELETE → 204."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.delete_broker_credential.return_value = True
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_get_after_delete_returns_has_credential_false() -> None:
|
||||
"""REQ-052 AC-5: DELETE 이후 GET → has_credential=False."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.get_broker_credential_by_daemon.return_value = None # deleted
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_credential"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-6: 타 사용자 토큰 → 403 (소유 스코프 음성)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_other_user_returns_403() -> None:
|
||||
"""REQ-052 AC-6: 다른 user의 토큰으로 PUT → 403."""
|
||||
settings = _make_settings()
|
||||
# OTHER user's principal
|
||||
principal = _make_user_principal(user_id=OTHER_USER_ID)
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref", "broker": BROKER},
|
||||
headers=_auth_header("other-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_get_credential_other_user_returns_403() -> None:
|
||||
"""REQ-052 AC-6: 다른 user의 토큰으로 GET → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal(user_id=OTHER_USER_ID)
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("other-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_delete_credential_other_user_returns_403() -> None:
|
||||
"""REQ-052 AC-6: 다른 user의 토큰으로 DELETE → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal(user_id=OTHER_USER_ID)
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("other-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-7: 비인증 → 401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_no_token_returns_401() -> None:
|
||||
"""REQ-052 AC-7: 인증 토큰 없이 호출 → 401."""
|
||||
settings = _make_settings()
|
||||
client = _make_client(settings, MagicMock())
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp2 = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
|
||||
)
|
||||
assert resp2.status_code == 401
|
||||
|
||||
resp3 = client.delete(f"/v1/daemons/{DAEMON_ID}/credential")
|
||||
assert resp3.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-8: 미존재 daemon_id → 404
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_nonexistent_daemon_returns_404() -> None:
|
||||
"""REQ-052 AC-8: 존재하지 않는 daemon_id → 404."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = None # not found
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/nonexistent/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_put_nonexistent_daemon_returns_404() -> None:
|
||||
"""REQ-052 AC-8: PUT 미존재 daemon → 404."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
"/v1/daemons/nonexistent/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-9: admin 토큰으로 타인 daemon 인증정보 write → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_cannot_write_other_user_credential() -> None:
|
||||
"""REQ-052 AC-9: admin role이라도 타인 소유 daemon write → 403."""
|
||||
settings = _make_settings()
|
||||
admin_principal = _make_admin_principal(user_id="admin-user")
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER, not admin
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = admin_principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
|
||||
headers=_auth_header("admin-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_admin_cannot_read_other_user_credential() -> None:
|
||||
"""REQ-052 AC-9: admin role이라도 타인 소유 daemon GET → 403."""
|
||||
settings = _make_settings()
|
||||
admin_principal = _make_admin_principal(user_id="admin-user")
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = admin_principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("admin-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-10: service-token → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_service_token_credential_get_returns_403() -> None:
|
||||
"""REQ-052 AC-10: service-token으로 GET /v1/daemons/{id}/credential → 403."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("svc-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_service_token_credential_put_returns_403() -> None:
|
||||
"""REQ-052 AC-10: service-token으로 PUT → 403."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
|
||||
headers=_auth_header("svc-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_service_token_credential_delete_returns_403() -> None:
|
||||
"""REQ-052 AC-10: service-token으로 DELETE → 403."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("svc-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,653 @@
|
||||
"""BAT-022: REQ-053 — POST/PATCH /v1/admin/daemons (admin 데몬 CRUD)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
|
||||
from app.main import create_app
|
||||
from app.repositories.postgres import PostgresPortfolioRepository
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
ADMIN_USER_ID = "admin-user"
|
||||
OWNER_A_ID = "user-a"
|
||||
OWNER_B_ID = "user-b"
|
||||
DAEMON_ID = "daemon-001"
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
return Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(settings: Settings, mock_repo) -> TestClient:
|
||||
get_settings.cache_clear()
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _auth_header(token: str = "admin-token") -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _make_daemon(
|
||||
daemon_id: str = DAEMON_ID,
|
||||
owner_user_id: str = OWNER_A_ID,
|
||||
daemon_slug: str = "trex-orb",
|
||||
display_name: str = "ORB",
|
||||
is_active: bool = True,
|
||||
) -> Daemon:
|
||||
return Daemon(
|
||||
id=daemon_id,
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug=daemon_slug,
|
||||
display_name=display_name,
|
||||
is_active=is_active,
|
||||
created_at=datetime(2026, 6, 28, 10, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2026, 6, 28, 10, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _make_admin_principal(user_id: str = ADMIN_USER_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="adm-1",
|
||||
user_id=user_id,
|
||||
permission="admin",
|
||||
token_type="admin",
|
||||
)
|
||||
|
||||
|
||||
def _make_user_principal(user_id: str = OWNER_A_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="usr-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="user",
|
||||
)
|
||||
|
||||
|
||||
def _make_service_principal(user_id: str = OWNER_A_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="svc-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="service",
|
||||
daemon_id=DAEMON_ID,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-1: POST 생성 → 201 + DaemonAdminResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_create_daemon_returns_201() -> None:
|
||||
"""REQ-053 AC-1: admin POST → 201 + id/daemon_slug/display_name/owner_user_id/is_active."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon = _make_daemon()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.create_daemon.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "trex-orb", "display_name": "ORB", "owner_user_id": OWNER_A_ID},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["id"] == DAEMON_ID
|
||||
assert body["daemon_slug"] == "trex-orb"
|
||||
assert body["display_name"] == "ORB"
|
||||
assert body["owner_user_id"] == OWNER_A_ID
|
||||
assert body["is_active"] is True
|
||||
# Secret fields must not appear (REQ-054 AC-3)
|
||||
assert "app_key" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "credential_payload" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-2: 중복 slug → 409
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_create_daemon_duplicate_slug_returns_409() -> None:
|
||||
"""REQ-053 AC-2: 중복 daemon_slug → 409 Conflict."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.create_daemon.side_effect = ValueError("이미 존재하는 daemon slug입니다: trex-orb")
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "trex-orb", "owner_user_id": OWNER_A_ID},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-3: display_name 변경 — BrokerCredential 유지
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_patch_display_name_credential_preserved() -> None:
|
||||
"""REQ-053 AC-3: display_name 변경 → 200, BrokerCredential 삭제 없음."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_before = _make_daemon(display_name="OLD NAME")
|
||||
daemon_after = _make_daemon(display_name="NEW NAME")
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_before
|
||||
mock_repo.update_daemon.return_value = daemon_after
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"display_name": "NEW NAME"},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["display_name"] == "NEW NAME"
|
||||
# update_daemon_owner must NOT be called (no owner change)
|
||||
mock_repo.update_daemon_owner.assert_not_called()
|
||||
# delete_credentials_by_daemon_id must NOT be called
|
||||
mock_repo.delete_credentials_by_daemon_id.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-4: owner 재할당 → BrokerCredential 자동 삭제
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_patch_owner_invalidates_credentials() -> None:
|
||||
"""REQ-053 AC-4: owner 재할당 → 200, BrokerCredential 자동 삭제(무효화)."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_before = _make_daemon(owner_user_id=OWNER_A_ID)
|
||||
daemon_after = _make_daemon(owner_user_id=OWNER_B_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_before
|
||||
mock_repo.update_daemon_owner.return_value = daemon_after
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"owner_user_id": OWNER_B_ID},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["owner_user_id"] == OWNER_B_ID
|
||||
# update_daemon_owner must be called (includes credential deletion)
|
||||
mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-5: owner 변경 후 이전 owner의 /v1/user_info → broker=null
|
||||
# (verified via update_daemon_owner deleting credentials; AC-4 covers the mechanism)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_patch_owner_then_old_owner_loses_credential() -> None:
|
||||
"""REQ-053 AC-5: owner 재할당 이후 이전 owner 인증정보 제거 확인 (update_daemon_owner 호출로 검증)."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_before = _make_daemon(owner_user_id=OWNER_A_ID)
|
||||
daemon_after = _make_daemon(owner_user_id=OWNER_B_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_before
|
||||
mock_repo.update_daemon_owner.return_value = daemon_after
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"owner_user_id": OWNER_B_ID},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# update_daemon_owner deletes credentials as part of the operation (see postgres.py implementation)
|
||||
mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-6: 비활성화 → is_active=False + service token auto-revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_deactivate_daemon_revokes_tokens() -> None:
|
||||
"""REQ-053 AC-6: 비활성화 → 200 + is_active=False, service token auto-revoke."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_active = _make_daemon(is_active=True)
|
||||
daemon_inactive = _make_daemon(is_active=False)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_active
|
||||
mock_repo.update_daemon.return_value = daemon_inactive
|
||||
mock_repo.revoke_tokens_by_daemon_id.return_value = 1
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(f"/v1/admin/daemons/{DAEMON_ID}/deactivate", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_active"] is False
|
||||
mock_repo.update_daemon.assert_called_once_with(DAEMON_ID, is_active=False)
|
||||
mock_repo.revoke_tokens_by_daemon_id.assert_called_once_with(DAEMON_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-7: 재활성화 → is_active=True
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_activate_daemon_returns_active() -> None:
|
||||
"""REQ-053 AC-7: 재활성화 → 200 + is_active=True."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_inactive = _make_daemon(is_active=False)
|
||||
daemon_active = _make_daemon(is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_inactive
|
||||
mock_repo.update_daemon.return_value = daemon_active
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(f"/v1/admin/daemons/{DAEMON_ID}/activate", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_active"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-8: DELETE → 405 (하드 삭제 미제공)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_delete_daemon_returns_405() -> None:
|
||||
"""REQ-053 AC-8: DELETE /v1/admin/daemons/{id} → 405 Method Not Allowed."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.delete(f"/v1/admin/daemons/{DAEMON_ID}", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 405
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-9: user-token(비admin) → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_non_admin_user_token_create_daemon_returns_403() -> None:
|
||||
"""REQ-053 AC-9: user-token(non-admin) → POST /v1/admin/daemons → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal() # permission=read
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "test", "owner_user_id": OWNER_A_ID},
|
||||
headers=_auth_header("user-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_non_admin_patch_returns_403() -> None:
|
||||
"""REQ-053 AC-9: user-token → PATCH /v1/admin/daemons/{id} → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"display_name": "X"},
|
||||
headers=_auth_header("user-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_non_admin_deactivate_returns_403() -> None:
|
||||
"""REQ-053 AC-9: user-token → POST /v1/admin/daemons/{id}/deactivate → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}/deactivate",
|
||||
headers=_auth_header("user-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-10: service-token → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_service_token_admin_crud_returns_403() -> None:
|
||||
"""REQ-053 AC-10: service-token → POST /v1/admin/daemons → 403."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "test", "owner_user_id": OWNER_A_ID},
|
||||
headers=_auth_header("svc-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-11: 비인증 → 401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_crud_no_token_returns_401() -> None:
|
||||
"""REQ-053 AC-11: 비인증 → POST /v1/admin/daemons → 401."""
|
||||
settings = _make_settings()
|
||||
client = _make_client(settings, MagicMock())
|
||||
resp = client.post("/v1/admin/daemons", json={"daemon_slug": "test", "owner_user_id": "u1"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-12: 미존재 daemon_id → 404
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_patch_nonexistent_daemon_returns_404() -> None:
|
||||
"""REQ-053 AC-12: PATCH 미존재 daemon → 404."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
"/v1/admin/daemons/nonexistent",
|
||||
json={"display_name": "X"},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_admin_deactivate_nonexistent_daemon_returns_404() -> None:
|
||||
"""REQ-053 AC-12: deactivate 미존재 daemon → 404."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons/nonexistent/deactivate",
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BAT-022 QC Major: update_daemon_owner 원자성 — 부분 실패 시 credential leak 방지
|
||||
# (dcy-011 OQ-3: 이전 owner의 KIS 인증정보 신규 owner에게 상속 불가)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_conn_for_owner_update(*, fail_on_update: bool = False):
|
||||
"""
|
||||
psycopg Connection 모의 객체를 반환.
|
||||
conn.transaction() 컨텍스트 매니저를 지원하고, 내부 cursor 실행을 추적한다.
|
||||
fail_on_update=True 이면 UPDATE daemons 실행 시 예외를 발생시켜
|
||||
트랜잭션 롤백을 유발한다.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock
|
||||
import psycopg
|
||||
|
||||
executed_sqls: list[str] = []
|
||||
rollback_called = False
|
||||
commit_called = False
|
||||
|
||||
class TrackingCursor:
|
||||
"""execute 호출을 기록하고 SELECT/UPDATE/DELETE에 대한 결과를 제공."""
|
||||
def __init__(self) -> None:
|
||||
self._last_sql = ""
|
||||
self._rows: list = []
|
||||
|
||||
def execute(self, sql: str, params=None) -> None:
|
||||
sql_stripped = sql.strip()
|
||||
executed_sqls.append(sql_stripped)
|
||||
if sql_stripped.startswith("SELECT"):
|
||||
# daemon exists
|
||||
self._rows = [("daemon-001",)]
|
||||
elif sql_stripped.startswith("DELETE"):
|
||||
self._rows = []
|
||||
elif sql_stripped.startswith("UPDATE"):
|
||||
if fail_on_update:
|
||||
raise psycopg.DatabaseError("simulated UPDATE failure")
|
||||
self._rows = [(
|
||||
"daemon-001",
|
||||
"user-b",
|
||||
"trex-orb",
|
||||
"ORB",
|
||||
True,
|
||||
datetime(2026, 6, 28, tzinfo=timezone.utc),
|
||||
datetime(2026, 6, 28, tzinfo=timezone.utc),
|
||||
)]
|
||||
|
||||
def fetchone(self):
|
||||
if self._rows:
|
||||
return self._rows[0]
|
||||
return None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
class FakeTransaction:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
nonlocal rollback_called, commit_called
|
||||
if exc_type is not None:
|
||||
rollback_called = True
|
||||
return False # re-raise
|
||||
commit_called = True
|
||||
return False
|
||||
|
||||
class FakeConn:
|
||||
def transaction(self):
|
||||
return FakeTransaction()
|
||||
|
||||
def cursor(self):
|
||||
return TrackingCursor()
|
||||
|
||||
return FakeConn(), executed_sqls, lambda: rollback_called, lambda: commit_called
|
||||
|
||||
|
||||
def test_update_daemon_owner_executes_delete_before_update() -> None:
|
||||
"""BAT-022 QC Major / dcy-011 OQ-3: credential DELETE가 owner UPDATE보다 먼저 실행됨을 확인.
|
||||
|
||||
안전 순서 보장: DELETE가 먼저이므로 UPDATE 실패 시 owner 미변경+credential 없음(leak 없음).
|
||||
"""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
fake_conn, executed_sqls, is_rollback, is_commit = _make_mock_conn_for_owner_update()
|
||||
|
||||
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
|
||||
|
||||
with _patch.object(repo, "_connect", return_value=fake_conn), \
|
||||
_patch.object(repo, "ensure_schema"):
|
||||
result = repo.update_daemon_owner("daemon-001", owner_user_id="user-b")
|
||||
|
||||
# DELETE must come before UPDATE in execution order
|
||||
delete_idx = next(i for i, s in enumerate(executed_sqls) if s.startswith("DELETE"))
|
||||
update_idx = next(i for i, s in enumerate(executed_sqls) if s.startswith("UPDATE"))
|
||||
assert delete_idx < update_idx, (
|
||||
f"DELETE must execute before UPDATE to prevent credential leak. "
|
||||
f"Actual order: DELETE={delete_idx}, UPDATE={update_idx}"
|
||||
)
|
||||
# Owner change committed
|
||||
assert is_commit()
|
||||
assert result.owner_user_id == "user-b"
|
||||
|
||||
|
||||
def test_update_daemon_owner_rollback_on_update_failure_prevents_leak() -> None:
|
||||
"""BAT-022 QC Major / dcy-011 OQ-3: UPDATE daemons 실패 시 트랜잭션 롤백(rollback 경로 확인).
|
||||
|
||||
부분 실패 시 "새 owner + 이전 credential 잔류" 상태(credential leak)가 발생하지 않아야 함.
|
||||
트랜잭션 블록 안에서 UPDATE가 실패하면 예외가 전파되고 commit은 호출되지 않는다.
|
||||
"""
|
||||
import psycopg
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
fake_conn, executed_sqls, is_rollback, is_commit = _make_mock_conn_for_owner_update(fail_on_update=True)
|
||||
|
||||
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
|
||||
|
||||
with _patch.object(repo, "_connect", return_value=fake_conn), \
|
||||
_patch.object(repo, "ensure_schema"):
|
||||
with pytest.raises(psycopg.DatabaseError):
|
||||
repo.update_daemon_owner("daemon-001", owner_user_id="user-b")
|
||||
|
||||
# DELETE was attempted before UPDATE
|
||||
assert any(s.startswith("DELETE") for s in executed_sqls), \
|
||||
"DELETE must have been attempted before UPDATE"
|
||||
# UPDATE failure triggered rollback (not commit)
|
||||
assert is_rollback(), "Transaction must roll back when UPDATE fails"
|
||||
assert not is_commit(), "Transaction must NOT commit on UPDATE failure"
|
||||
|
||||
|
||||
def test_update_daemon_owner_both_ops_in_single_transaction() -> None:
|
||||
"""BAT-022 QC Major: DELETE + UPDATE가 단일 conn.transaction() 블록 안에서 실행됨을 확인.
|
||||
|
||||
두 연산이 별도 커넥션이 아닌 같은 트랜잭션 안에 묶여 있어야 원자성이 보장된다.
|
||||
"""
|
||||
from unittest.mock import patch as _patch, call as _call
|
||||
|
||||
transaction_enter_count = [0]
|
||||
executed_sqls: list[str] = []
|
||||
|
||||
class CountingTransaction:
|
||||
def __enter__(self):
|
||||
transaction_enter_count[0] += 1
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
class TrackingCursor:
|
||||
def execute(self, sql: str, params=None) -> None:
|
||||
executed_sqls.append(sql.strip())
|
||||
|
||||
def fetchone(self):
|
||||
sql = executed_sqls[-1] if executed_sqls else ""
|
||||
if sql.startswith("SELECT"):
|
||||
return ("daemon-001",)
|
||||
if sql.startswith("UPDATE"):
|
||||
return (
|
||||
"daemon-001", "user-b", "trex-orb", "ORB", True,
|
||||
datetime(2026, 6, 28, tzinfo=timezone.utc),
|
||||
datetime(2026, 6, 28, tzinfo=timezone.utc),
|
||||
)
|
||||
return None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
class FakeConn:
|
||||
def transaction(self):
|
||||
return CountingTransaction()
|
||||
|
||||
def cursor(self):
|
||||
return TrackingCursor()
|
||||
|
||||
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
|
||||
|
||||
with _patch.object(repo, "_connect", return_value=FakeConn()), \
|
||||
_patch.object(repo, "ensure_schema"):
|
||||
repo.update_daemon_owner("daemon-001", owner_user_id="user-b")
|
||||
|
||||
# Exactly one transaction block entered (not two separate autocommit calls)
|
||||
assert transaction_enter_count[0] == 1, (
|
||||
f"Expected 1 transaction block, got {transaction_enter_count[0]}. "
|
||||
"DELETE and UPDATE must be inside a single transaction."
|
||||
)
|
||||
@@ -0,0 +1,444 @@
|
||||
"""BAT-022: REQ-054 — 인증정보 관리 보안 격리 (write-only, secret 미반환, 로그 미노출) + REQ-051 회귀."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.encryption import decrypt_payload, encrypt_payload
|
||||
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
|
||||
from app.main import create_app
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
DAEMON_ID = "daemon-001"
|
||||
OWNER_USER_ID = "user-owner"
|
||||
BROKER = "kis"
|
||||
ACCOUNT_REF = "acc-001"
|
||||
APP_KEY = "real-app-key-secret"
|
||||
APP_SECRET = "real-app-secret-value"
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
return Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(settings: Settings, mock_repo) -> TestClient:
|
||||
get_settings.cache_clear()
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _auth_header(token: str = "user-token") -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _make_daemon(owner_user_id: str = OWNER_USER_ID) -> Daemon:
|
||||
return Daemon(
|
||||
id=DAEMON_ID,
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug="trex-orb",
|
||||
display_name="ORB",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_cred(
|
||||
account_ref: str = ACCOUNT_REF,
|
||||
credential_version: int = 1,
|
||||
app_key: str = APP_KEY,
|
||||
app_secret: str = APP_SECRET,
|
||||
) -> BrokerCredential:
|
||||
payload_json = json.dumps({"app_key": app_key, "app_secret": app_secret})
|
||||
encrypted = encrypt_payload(_TEST_ENC_KEY, payload_json)
|
||||
return BrokerCredential(
|
||||
user_id=OWNER_USER_ID,
|
||||
broker=BROKER,
|
||||
account_ref=account_ref,
|
||||
encrypted_payload=encrypted,
|
||||
credential_version=credential_version,
|
||||
last_rotated_at=datetime(2026, 6, 28, 12, 0, 0, tzinfo=timezone.utc),
|
||||
daemon_id=DAEMON_ID,
|
||||
)
|
||||
|
||||
|
||||
def _make_user_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="tok-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="user",
|
||||
)
|
||||
|
||||
|
||||
def _make_admin_principal(user_id: str = "admin-user") -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="adm-1",
|
||||
user_id=user_id,
|
||||
permission="admin",
|
||||
token_type="admin",
|
||||
)
|
||||
|
||||
|
||||
def _make_service_principal(user_id: str = OWNER_USER_ID, daemon_id: str = DAEMON_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="svc-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="service",
|
||||
runtime_id="rt-1",
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-1: PUT 응답에 app_key/app_secret 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_response_has_no_secret_fields() -> None:
|
||||
"""REQ-054 AC-1: PUT 응답 JSON에 app_key, app_secret 키 없음(최상위·중첩 포함)."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# Recursively check no secret fields anywhere in response
|
||||
body_str = json.dumps(body)
|
||||
assert "app_key" not in body_str
|
||||
assert "app_secret" not in body_str
|
||||
assert "encrypted_payload" not in body_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-2: GET 응답에 app_key/app_secret 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_credential_meta_response_has_no_secret_fields() -> None:
|
||||
"""REQ-054 AC-2: GET 응답 JSON에 app_key, app_secret 키 없음."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.get_broker_credential_by_daemon.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body_str = json.dumps(resp.json())
|
||||
assert "app_key" not in body_str
|
||||
assert "app_secret" not in body_str
|
||||
assert "encrypted_payload" not in body_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-3: admin CRUD 응답에 app_key/app_secret/credential_payload 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_create_response_has_no_credential_fields() -> None:
|
||||
"""REQ-054 AC-3: POST /v1/admin/daemons 응답에 app_key/app_secret/credential_payload 없음."""
|
||||
settings = _make_settings()
|
||||
from app.domain.models import Daemon
|
||||
principal = _make_admin_principal()
|
||||
daemon = Daemon(
|
||||
id=DAEMON_ID, owner_user_id=OWNER_USER_ID, daemon_slug="trex-orb",
|
||||
display_name="ORB", is_active=True,
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.create_daemon.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "trex-orb", "owner_user_id": OWNER_USER_ID},
|
||||
headers=_auth_header("admin-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body_str = json.dumps(resp.json())
|
||||
assert "app_key" not in body_str
|
||||
assert "app_secret" not in body_str
|
||||
assert "credential_payload" not in body_str
|
||||
|
||||
|
||||
def test_admin_patch_response_has_no_credential_fields() -> None:
|
||||
"""REQ-054 AC-3: PATCH /v1/admin/daemons/{id} 응답에 app_key/app_secret/credential_payload 없음."""
|
||||
settings = _make_settings()
|
||||
from app.domain.models import Daemon
|
||||
principal = _make_admin_principal()
|
||||
daemon = Daemon(
|
||||
id=DAEMON_ID, owner_user_id=OWNER_USER_ID, daemon_slug="trex-orb",
|
||||
display_name="NEW", is_active=True,
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.update_daemon.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"display_name": "NEW"},
|
||||
headers=_auth_header("admin-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body_str = json.dumps(resp.json())
|
||||
assert "app_key" not in body_str
|
||||
assert "app_secret" not in body_str
|
||||
assert "credential_payload" not in body_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-4: caplog — PUT 처리 중 로그에 app_key/app_secret 값 미포함
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_log_does_not_expose_secret(caplog) -> None:
|
||||
"""REQ-054 AC-4: PUT 처리 중 로그에 실제 app_key/app_secret 값 미포함."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred(app_key=APP_KEY, app_secret=APP_SECRET)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="app.api.v1_daemon_credential"), \
|
||||
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
log_text = " ".join(caplog.messages)
|
||||
# Actual values must not appear in logs
|
||||
assert APP_KEY not in log_text, f"app_key found in logs: {log_text}"
|
||||
assert APP_SECRET not in log_text, f"app_secret found in logs: {log_text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-5: DB に암호화 저장 확인 (encrypted_payload가 gAAAA... 형태인지 upsert 호출 인수로 검증)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_stores_encrypted_payload() -> None:
|
||||
"""REQ-054 AC-5: PUT 시 Fernet 암호화된 payload를 DB에 저장함 확인."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred()
|
||||
|
||||
captured_encrypted = {}
|
||||
|
||||
def mock_upsert(user_id, broker, *, account_ref, encrypted_payload, daemon_id=None):
|
||||
captured_encrypted["payload"] = encrypted_payload
|
||||
return cred
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.side_effect = mock_upsert
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# The stored payload must be a Fernet token (starts with gAAAA)
|
||||
stored = captured_encrypted["payload"]
|
||||
assert stored.startswith("gAAAA"), f"Not a Fernet token: {stored[:20]}"
|
||||
# Must not be plaintext
|
||||
assert APP_KEY not in stored
|
||||
assert APP_SECRET not in stored
|
||||
# Verify it actually decrypts correctly
|
||||
decrypted = json.loads(decrypt_payload(_TEST_ENC_KEY, stored))
|
||||
assert decrypted["app_key"] == APP_KEY
|
||||
assert decrypted["app_secret"] == APP_SECRET
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-6: PUT 이후 service-token GET /v1/user_info → correct account_ref returned
|
||||
# (유일 decrypt 경로 불변 확인)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_then_service_token_user_info_returns_correct_account_ref() -> None:
|
||||
"""REQ-054 AC-6: PUT 후 service-token /v1/user_info에서 동일 account_ref 반환 (decrypt 경로 불변)."""
|
||||
settings = _make_settings()
|
||||
|
||||
# 1. PUT credential
|
||||
user_principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred_stored = _make_cred(account_ref="my-account-789", app_key=APP_KEY, app_secret=APP_SECRET)
|
||||
|
||||
mock_repo_put = MagicMock()
|
||||
mock_repo_put.get_token_principal.return_value = user_principal
|
||||
mock_repo_put.get_daemon_by_id.return_value = daemon
|
||||
mock_repo_put.upsert_broker_credential.return_value = cred_stored
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_put), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo_put):
|
||||
client_put = _make_client(settings, mock_repo_put)
|
||||
resp_put = client_put.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": "my-account-789", "broker": BROKER},
|
||||
headers=_auth_header("user-token"),
|
||||
)
|
||||
assert resp_put.status_code == 200
|
||||
|
||||
# 2. GET /v1/user_info via service-token
|
||||
svc_principal = _make_service_principal()
|
||||
user = User(id=OWNER_USER_ID, username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo_svc = MagicMock()
|
||||
mock_repo_svc.get_token_principal.return_value = svc_principal
|
||||
mock_repo_svc.get_user.return_value = user
|
||||
mock_repo_svc.list_broker_credentials.return_value = [cred_stored]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_svc), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_svc):
|
||||
client_svc = _make_client(settings, mock_repo_svc)
|
||||
resp_svc = client_svc.get("/v1/user_info", headers={"Authorization": "Bearer svc-token"})
|
||||
|
||||
assert resp_svc.status_code == 200
|
||||
body = resp_svc.json()
|
||||
assert body["broker"] is not None
|
||||
assert body["broker"]["account_ref"] == "my-account-789"
|
||||
# credential_payload must be decrypted and returned on service-token path
|
||||
assert body["broker"]["credential_payload"] is not None
|
||||
assert body["broker"]["credential_payload"]["app_key"] == APP_KEY
|
||||
assert body["broker"]["credential_payload"]["app_secret"] == APP_SECRET
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-7: REQ-051 회귀 — 신규 API 추가 후 기존 service-token 보안 격리 PASS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_req051_regression_web_token_user_info_still_403() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-1 회귀: web token → /v1/user_info → 403 (불변)."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id=OWNER_USER_ID, permission="read",
|
||||
token_type="user", daemon_id=DAEMON_ID,
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers={"Authorization": "Bearer web-token"})
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_req051_regression_service_token_without_daemon_id_403() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-3 회귀: service-token without daemon_id → 403."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id=OWNER_USER_ID, permission="read",
|
||||
token_type="service", daemon_id=None,
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers={"Authorization": "Bearer svc-no-daemon"})
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_req051_regression_unauthenticated_401() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-2 회귀: 비인증 → 401."""
|
||||
settings = _make_settings()
|
||||
client = _make_client(settings, MagicMock())
|
||||
resp = client.get("/v1/user_info")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_req051_regression_service_token_with_daemon_returns_broker() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-1 회귀: service-token + daemon_id → 200 + broker (불변)."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
user = User(id=OWNER_USER_ID, username="alice", permission="read", is_active=True)
|
||||
cred = _make_cred()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [cred]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers={"Authorization": "Bearer svc-token"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["broker"] is not None
|
||||
assert body["broker"]["account_ref"] == ACCOUNT_REF
|
||||
assert body["broker"]["credential_payload"]["app_key"] == APP_KEY
|
||||
assert body["broker"]["credential_payload"]["app_secret"] == APP_SECRET
|
||||
|
||||
|
||||
def test_req051_regression_other_endpoints_no_credential_exposure() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-5 회귀: /health 등에 credential 필드 없음."""
|
||||
settings = _make_settings()
|
||||
client = TestClient(create_app(settings_factory=lambda: settings))
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "app_key" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "credential_payload" not in body
|
||||
assert "encrypted_payload" not in body
|
||||
Reference in New Issue
Block a user