feat(bat-022): daemon CRUD admin API + self-service credential management (REQ-052/053/054)
- 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
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""POST/PATCH /v1/admin/daemons — admin daemon CRUD (REQ-053)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, require_admin
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import DaemonAdminResponse, DaemonCreateRequest, DaemonUpdateRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/admin", tags=["v1-admin"])
|
||||
|
||||
|
||||
def _get_repo(settings=None):
|
||||
from app.core.config import get_settings as _gs
|
||||
return get_portfolio_repository_from_settings(settings or _gs())
|
||||
|
||||
|
||||
def _to_response(daemon) -> DaemonAdminResponse:
|
||||
return DaemonAdminResponse(
|
||||
id=daemon.id,
|
||||
daemon_slug=daemon.daemon_slug,
|
||||
display_name=daemon.display_name,
|
||||
owner_user_id=daemon.owner_user_id,
|
||||
is_active=daemon.is_active,
|
||||
)
|
||||
|
||||
|
||||
def _daemon_error_to_http(exc: ValueError) -> HTTPException:
|
||||
detail = str(exc)
|
||||
if "찾을 수 없습니다" in detail:
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
||||
if "이미 존재하는 daemon slug" in detail:
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
if "사용자를 찾을 수 없습니다" in detail:
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /v1/admin/daemons — 데몬 생성 (REQ-053 AC-1/2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/daemons", response_model=DaemonAdminResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_daemon(
|
||||
body: DaemonCreateRequest,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> DaemonAdminResponse:
|
||||
"""Create a new daemon and assign owner (admin only). REQ-053 AC-1/2."""
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
daemon = repo.create_daemon(
|
||||
body.owner_user_id,
|
||||
daemon_slug=body.daemon_slug,
|
||||
display_name=body.display_name,
|
||||
)
|
||||
return _to_response(daemon)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /v1/admin/daemons/{daemon_id} — display_name/owner 변경 (REQ-053 AC-3/4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.patch("/daemons/{daemon_id}", response_model=DaemonAdminResponse)
|
||||
def update_daemon(
|
||||
daemon_id: str,
|
||||
body: DaemonUpdateRequest,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> DaemonAdminResponse:
|
||||
"""Update daemon display_name and/or owner.
|
||||
|
||||
Owner change auto-invalidates all broker credentials for this daemon (REQ-053 AC-4).
|
||||
"""
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
existing = repo.get_daemon_by_id(daemon_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
|
||||
|
||||
if body.owner_user_id is not None and body.owner_user_id != existing.owner_user_id:
|
||||
# Owner change: update owner + invalidate credentials
|
||||
try:
|
||||
daemon = repo.update_daemon_owner(daemon_id, owner_user_id=body.owner_user_id)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
# Apply display_name change if also requested
|
||||
if body.display_name is not None:
|
||||
try:
|
||||
daemon = repo.update_daemon(daemon_id, display_name=body.display_name)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
else:
|
||||
# No owner change — update display_name only if provided
|
||||
if body.display_name is not None:
|
||||
try:
|
||||
daemon = repo.update_daemon(daemon_id, display_name=body.display_name)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
else:
|
||||
daemon = existing
|
||||
|
||||
return _to_response(daemon)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /v1/admin/daemons/{daemon_id}/deactivate (REQ-053 AC-6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/daemons/{daemon_id}/deactivate", response_model=DaemonAdminResponse)
|
||||
def deactivate_daemon(
|
||||
daemon_id: str,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> DaemonAdminResponse:
|
||||
"""Deactivate daemon and auto-revoke bound service tokens (REQ-053 AC-6, REQ-022)."""
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
existing = repo.get_daemon_by_id(daemon_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
|
||||
|
||||
daemon = repo.update_daemon(daemon_id, is_active=False)
|
||||
# REQ-022: auto-revoke service tokens bound to this daemon
|
||||
revoked = repo.revoke_tokens_by_daemon_id(daemon_id)
|
||||
logger.info(
|
||||
"daemon %s deactivated; revoked %d service token(s)",
|
||||
daemon_id,
|
||||
revoked,
|
||||
)
|
||||
return _to_response(daemon)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /v1/admin/daemons/{daemon_id}/activate (REQ-053 AC-7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/daemons/{daemon_id}/activate", response_model=DaemonAdminResponse)
|
||||
def activate_daemon(
|
||||
daemon_id: str,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> DaemonAdminResponse:
|
||||
"""Re-activate a deactivated daemon. REQ-053 AC-7."""
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
existing = repo.get_daemon_by_id(daemon_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
|
||||
|
||||
daemon = repo.update_daemon(daemon_id, is_active=True)
|
||||
return _to_response(daemon)
|
||||
except ValueError as exc:
|
||||
raise _daemon_error_to_http(exc) from exc
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /v1/admin/daemons/{daemon_id} — 하드 삭제 미제공 → 405 (REQ-053 AC-8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/daemons/{daemon_id}", status_code=status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
def delete_daemon_not_allowed(
|
||||
daemon_id: str,
|
||||
_auth: AuthContext = Depends(require_admin),
|
||||
) -> Response:
|
||||
"""Hard delete is not supported — deactivate instead. REQ-053 AC-8."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
detail="Hard delete not supported. Use POST /v1/admin/daemons/{daemon_id}/deactivate instead.",
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""GET/PUT/DELETE /v1/daemons/{daemon_id}/credential — user-owned credential management (REQ-052)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.encryption import encrypt_payload
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import CredentialMetaResponse, CredentialSetRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/daemons", tags=["v1"])
|
||||
|
||||
|
||||
def _get_repo(settings=None):
|
||||
from app.core.config import get_settings as _gs
|
||||
return get_portfolio_repository_from_settings(settings or _gs())
|
||||
|
||||
|
||||
def _require_user_token(auth: AuthContext) -> None:
|
||||
"""Reject service-token access to credential management endpoints.
|
||||
|
||||
REQ-052 AC-10: service-token → 403 (service-token is read-only /v1/user_info only).
|
||||
"""
|
||||
if auth.token_source == "db" and auth.token_type == "service":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Service token cannot access credential management endpoints",
|
||||
)
|
||||
|
||||
|
||||
def _require_daemon_ownership(auth: AuthContext, daemon, daemon_id: str) -> None:
|
||||
"""Check caller owns the daemon.
|
||||
|
||||
REQ-052 AC-6/AC-9: user_id != owner_user_id → 403.
|
||||
admin role also subject to ownership check (AC-9).
|
||||
"""
|
||||
if auth.user_id is None or daemon.owner_user_id != auth.user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Ownership scope violation: you do not own this daemon",
|
||||
)
|
||||
|
||||
|
||||
def _get_daemon_or_404(repo, daemon_id: str):
|
||||
daemon = repo.get_daemon_by_id(daemon_id)
|
||||
if daemon is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="데몬을 찾을 수 없습니다")
|
||||
return daemon
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/daemons/{daemon_id}/credential — 메타데이터 조회 (REQ-052 AC-4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{daemon_id}/credential", response_model=CredentialMetaResponse)
|
||||
def get_credential_meta(
|
||||
daemon_id: str,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> CredentialMetaResponse:
|
||||
"""Return credential metadata for caller-owned daemon. Secret never returned (REQ-052, REQ-054).
|
||||
|
||||
REQ-052 AC-4: 200 + metadata (broker, account_ref, credential_version, last_rotated_at, has_credential).
|
||||
REQ-052 AC-7: 401 if no token.
|
||||
REQ-052 AC-10: 403 if service-token.
|
||||
REQ-052 AC-6/9: 403 if caller does not own daemon.
|
||||
REQ-052 AC-8: 404 if daemon does not exist.
|
||||
"""
|
||||
_require_user_token(auth)
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
daemon = _get_daemon_or_404(repo, daemon_id)
|
||||
_require_daemon_ownership(auth, daemon, daemon_id)
|
||||
|
||||
# Default broker is "kis" (single-broker assumption per design)
|
||||
broker = "kis"
|
||||
cred = repo.get_broker_credential_by_daemon(daemon_id, broker)
|
||||
if cred is None:
|
||||
return CredentialMetaResponse(
|
||||
daemon_id=daemon_id,
|
||||
broker=broker,
|
||||
account_ref="",
|
||||
has_credential=False,
|
||||
credential_version=0,
|
||||
last_rotated_at=None,
|
||||
)
|
||||
return CredentialMetaResponse(
|
||||
daemon_id=daemon_id,
|
||||
broker=cred.broker,
|
||||
account_ref=cred.account_ref,
|
||||
has_credential=True,
|
||||
credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at,
|
||||
)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /v1/daemons/{daemon_id}/credential — 등록/회전 (REQ-052 AC-1/2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.put("/{daemon_id}/credential", response_model=CredentialMetaResponse)
|
||||
def set_credential(
|
||||
daemon_id: str,
|
||||
body: CredentialSetRequest,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> CredentialMetaResponse:
|
||||
"""Set or rotate broker credentials for caller-owned daemon.
|
||||
|
||||
Fernet-encrypts app_key/app_secret before storing.
|
||||
Response contains metadata only — secret never returned (REQ-052 AC-1, REQ-054 AC-1).
|
||||
REQ-052 AC-2: rotation increments credential_version.
|
||||
"""
|
||||
_require_user_token(auth)
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
daemon = _get_daemon_or_404(repo, daemon_id)
|
||||
_require_daemon_ownership(auth, daemon, daemon_id)
|
||||
|
||||
# Fernet encrypt payload — REQ-054 AC-5: only encrypted stored in DB
|
||||
enc_key = settings.require_credential_encryption_key()
|
||||
# REQ-054 AC-4: log must not contain app_key/app_secret values
|
||||
logger.info(
|
||||
"credential set/rotate for daemon %s broker %s",
|
||||
daemon_id,
|
||||
body.broker,
|
||||
)
|
||||
payload_json = json.dumps({"app_key": body.app_key, "app_secret": body.app_secret})
|
||||
encrypted = encrypt_payload(enc_key, payload_json)
|
||||
|
||||
cred = repo.upsert_broker_credential(
|
||||
auth.user_id, # type: ignore[arg-type]
|
||||
body.broker,
|
||||
account_ref=body.account_ref,
|
||||
encrypted_payload=encrypted,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
return CredentialMetaResponse(
|
||||
daemon_id=daemon_id,
|
||||
broker=cred.broker,
|
||||
account_ref=cred.account_ref,
|
||||
has_credential=True,
|
||||
credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at,
|
||||
)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /v1/daemons/{daemon_id}/credential — 삭제 (REQ-052 AC-5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/{daemon_id}/credential", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_credential(
|
||||
daemon_id: str,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> Response:
|
||||
"""Delete broker credential for caller-owned daemon.
|
||||
|
||||
REQ-052 AC-5: 204 after deletion; 204 also if credential did not exist (idempotent).
|
||||
"""
|
||||
_require_user_token(auth)
|
||||
repo = _get_repo(settings)
|
||||
try:
|
||||
daemon = _get_daemon_or_404(repo, daemon_id)
|
||||
_require_daemon_ownership(auth, daemon, daemon_id)
|
||||
|
||||
# Default broker is "kis"
|
||||
broker = "kis"
|
||||
repo.delete_broker_credential(
|
||||
auth.user_id, # type: ignore[arg-type]
|
||||
broker,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
@@ -11,7 +11,9 @@ from app.api.auth_routes import router as auth_router
|
||||
from app.api.portfolios import router as portfolio_router
|
||||
from app.api.strategy_sets import router as strategy_sets_router
|
||||
from app.api.users import router as users_router
|
||||
from app.api.v1_admin_daemons import router as v1_admin_daemons_router
|
||||
from app.api.v1_assets import router as v1_assets_router
|
||||
from app.api.v1_daemon_credential import router as v1_daemon_credential_router
|
||||
from app.api.v1_daemons import router as v1_daemons_router
|
||||
from app.api.v1_portfolios import router as v1_portfolios_router
|
||||
from app.api.v1_tags import router as v1_tags_router
|
||||
@@ -81,7 +83,9 @@ def create_app(
|
||||
app.include_router(portfolio_router)
|
||||
app.include_router(strategy_sets_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(v1_admin_daemons_router)
|
||||
app.include_router(v1_assets_router)
|
||||
app.include_router(v1_daemon_credential_router)
|
||||
app.include_router(v1_daemons_router)
|
||||
app.include_router(v1_portfolios_router)
|
||||
app.include_router(v1_tags_router)
|
||||
|
||||
@@ -114,6 +114,21 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def list_service_tokens_with_null_daemon(self) -> list[AuthToken]: ...
|
||||
|
||||
def get_broker_credential_by_daemon(
|
||||
self,
|
||||
daemon_id: str,
|
||||
broker: str,
|
||||
) -> BrokerCredential | None: ...
|
||||
|
||||
def delete_credentials_by_daemon_id(self, daemon_id: str) -> int: ...
|
||||
|
||||
def update_daemon_owner(
|
||||
self,
|
||||
daemon_id: str,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
) -> Daemon: ...
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio: ...
|
||||
|
||||
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: ...
|
||||
|
||||
@@ -2343,6 +2343,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user