"""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()