- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
192 lines
7.0 KiB
Python
192 lines
7.0 KiB
Python
"""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()
|