- 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.6 KiB
Python
192 lines
7.6 KiB
Python
"""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.",
|
|
)
|