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:
2026-06-28 14:18:37 +09:00
co-authored by Claude Sonnet 4.6
parent 80d0511d7f
commit d24058d863
12 changed files with 2169 additions and 0 deletions
+75
View File
@@ -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: