From d24058d8630be660ac6b0f163d434119c9c0b951 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sun, 28 Jun 2026 14:13:03 +0900 Subject: [PATCH] feat(bat-022): daemon CRUD admin API + self-service credential management (REQ-052/053/054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST/PATCH/deactivate/activate /v1/admin/daemons — admin-only daemon CRUD (REQ-053) - GET/PUT/DELETE /v1/daemons/{daemon_id}/credential — user-owned scoped credential management (REQ-052) - Owner re-assignment auto-invalidates BrokerCredential via update_daemon_owner() (REQ-053 AC-4) - Deactivation auto-revokes service tokens via revoke_tokens_by_daemon_id() (REQ-022 reuse, REQ-053 AC-6) - DELETE /v1/admin/daemons/{id} → 405 (hard delete not provided, REQ-053 AC-8) - All responses exclude app_key/app_secret; Fernet encryption at rest (REQ-054) - Service-token → 403 on credential endpoints (REQ-052 AC-10) - Admin ownership scope enforced: admin cannot write other user's credential (REQ-052 AC-9) - REQ-051 regression: 4 service-token guard tests pass (REQ-054 AC-7) - 44 new tests, 565 total passed (521 baseline + 44 new), 0 failures Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- app/api/v1_admin_daemons.py | 191 +++++++ app/api/v1_daemon_credential.py | 191 +++++++ app/main.py | 4 + app/repositories/portfolio_repository.py | 15 + app/repositories/postgres.py | 75 +++ app/schemas/v1.py | 49 ++ .../design.md | 93 ++++ .../index.md | 47 ++ .../planning.md | 85 +++ tests/test_bat022_req052_credential_api.py | 516 ++++++++++++++++++ tests/test_bat022_req053_admin_daemon_crud.py | 459 ++++++++++++++++ tests/test_bat022_req054_security.py | 444 +++++++++++++++ 12 files changed, 2169 insertions(+) create mode 100644 app/api/v1_admin_daemons.py create mode 100644 app/api/v1_daemon_credential.py create mode 100644 docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/design.md create mode 100644 docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/index.md create mode 100644 docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/planning.md create mode 100644 tests/test_bat022_req052_credential_api.py create mode 100644 tests/test_bat022_req053_admin_daemon_crud.py create mode 100644 tests/test_bat022_req054_security.py diff --git a/app/api/v1_admin_daemons.py b/app/api/v1_admin_daemons.py new file mode 100644 index 0000000..e585405 --- /dev/null +++ b/app/api/v1_admin_daemons.py @@ -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.", + ) diff --git a/app/api/v1_daemon_credential.py b/app/api/v1_daemon_credential.py new file mode 100644 index 0000000..95fa1de --- /dev/null +++ b/app/api/v1_daemon_credential.py @@ -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() diff --git a/app/main.py b/app/main.py index f5429c8..66511d9 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 8bee6ab..053d599 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -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: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 668d519..7fcc490 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -2343,6 +2343,81 @@ 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 + credential invalidation are two separate statements (autocommit). + Credential deletion failure is logged by caller — daemon update is treated as + authoritative. + """ + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT id FROM daemons WHERE id = %s + """, + (daemon_id,), + ) + if cur.fetchone() is None: + raise ValueError("데몬을 찾을 수 없습니다") + 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() + daemon = self._row_to_daemon(row) + # Invalidate broker credentials after owner change + with self._connect().cursor() as cur: + cur.execute( + "DELETE FROM broker_credentials WHERE daemon_id = %s", + (daemon_id,), + ) + return daemon + def list_service_tokens_with_null_daemon(self) -> list[AuthToken]: self.ensure_schema() with self._connect().cursor() as cur: diff --git a/app/schemas/v1.py b/app/schemas/v1.py index 6aa948c..b4588b2 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -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 diff --git a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/design.md b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/design.md new file mode 100644 index 0000000..81779bb --- /dev/null +++ b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/design.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 — 신규 diff --git a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/index.md b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/index.md new file mode 100644 index 0000000..1cd868e --- /dev/null +++ b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/index.md @@ -0,0 +1,47 @@ +# BAT-022 — daemon CRUD admin API + self-service 인증정보 관리 API + +- Batch ID: BAT-022 +- Profile: standard +- Status: in-progress +- 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 (완료 조건) + +- [ ] REQ-052 AC-1~10 테스트 통과 +- [ ] REQ-053 AC-1~12 테스트 통과 +- [ ] REQ-054 AC-1~7 테스트 통과 (caplog 비노출, 응답 secret 부재, REQ-051 회귀) +- [ ] 기존 521 테스트 회귀 없음 +- [ ] draft-batch-verification 문서에 AC별 evidence 기록 + +## 비가역 결재 게이트 + +- DB 마이그레이션: 신규 DDL 없음. 기존 ensure_schema() + postgres.py 메서드 확장으로 처리 (비가역 없음). +- HTTP API 계약 신설: trex-web dcy-009가 소비 예정. 이 batch는 계약 신설이므로 기존 소비자에 영향 없음(호환 깨짐 없음). diff --git a/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/planning.md b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/planning.md new file mode 100644 index 0000000..599692c --- /dev/null +++ b/docs/batches/bat-022_20260628_daemon-crud-self-service-credential-api/planning.md @@ -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 고정으로 등록해야 함. diff --git a/tests/test_bat022_req052_credential_api.py b/tests/test_bat022_req052_credential_api.py new file mode 100644 index 0000000..f9fe769 --- /dev/null +++ b/tests/test_bat022_req052_credential_api.py @@ -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 diff --git a/tests/test_bat022_req053_admin_daemon_crud.py b/tests/test_bat022_req053_admin_daemon_crud.py new file mode 100644 index 0000000..d13e7e1 --- /dev/null +++ b/tests/test_bat022_req053_admin_daemon_crud.py @@ -0,0 +1,459 @@ +"""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 + +_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 diff --git a/tests/test_bat022_req054_security.py b/tests/test_bat022_req054_security.py new file mode 100644 index 0000000..1286d22 --- /dev/null +++ b/tests/test_bat022_req054_security.py @@ -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