Moves the entire /api/admin/* namespace (audit-log ×2, db/rotate-key ×1,
backup ×10, scheduler ×6, reload-config ×1) out of api.py and into the
existing api_routers/admin.py, which already owned /api/admin/validate-provider.
- api.py: 4294 → 3547 LOC (Δ -747). Removed the orphaned # --- separator
left behind by the cut, the orphaned 'return {ok,loaded,errors}'
tail of reload-config, and the now-unused cyclone.clearhouse.InboundFile
top-level import.
- api_routers/admin.py: 60 → 851 LOC. Added the imports the moved
routes need (json, logging, threading, time, AuditEvent, verify_chain,
InboundFile) and stripped the redundant top-level imports of
symbols that the route bodies reach via the inline _db_crypto /
_secrets / _backup_svc_mod / _backup_sched_mod / _scheduler_mod
/ _audit aliases (those aliases stay — tests rely on being able to
monkeypatch cyclone.api_routers.admin._X.method). Hoisted the inline
'import time' from inside pull_inbound to module scope. Dropped
the redundant 'import threading as _threading' alias — top-level
'import threading' already covers it.
- tests/test_api_rotate_key.py: 4 monkeypatch targets migrated from
cyclone.api._db_crypto / cyclone.api._secrets to
cyclone.api_routers.admin._db_crypto / cyclone.api_routers.admin._secrets
to match the new home of the rotate-key endpoint. Lock acquire/release
also migrated.
The non-admin /api/config/* and /api/payers/{id}/summary routes that
bracketed the moved admin blocks stay in api.py — they're extracted
in Tasks 5 & 6.
Behaviour-preserving: pytest = 20 failed / 1253 passed / 10 skipped
= baseline match. Admin-only subset (audit-log + backup + scheduler +
rotate-key + reload-config) = 34 passed.
Adds in-place key rotation for the encrypted DB at rest (HIPAA
sec.164.308(a)(4) - periodic key rotation).
- db_crypto.rotate_db_key(): opens with old key, issues PRAGMA rekey,
reopens with new key, verifies schema survived (table-count sanity).
- db_crypto.generate_db_key(): fresh 256-bit CSPRNG hex key.
- db_crypto.fingerprint(): SHA-256[:8] of a key, for the operator to
compare across rotations.
- db.dispose_engine() + db.reinit_engine(): SP15 plumbing. The
rotation endpoint disposes the pooled connections (SQLCipher
refuses to rekey while another connection holds the file), runs
the rekey, then rebuilds the engine with the new key from the
Keychain.
- API: POST /api/admin/db/rotate-key with module-level threading.Lock
to serialize rotations. 400 when encryption not enabled, 409 when
a rotation is already in flight, 503 on rekey or Keychain failure
with a reason that tells the operator what to do next.
- Engine uses NullPool when SQLCipher is enabled: the default
QueuePool returns connections to a shared queue that any thread
can pull from, which breaks SQLCipher's thread affinity. NullPool
trades connection reuse for thread safety, the only correct
behavior under FastAPI's per-request threadpool.
- Audit event db.key_rotated with old/new fingerprints and
table_count, written after the engine is rebuilt so the new key
proves it can take new writes.
- previous key is stashed to a second Keychain account so the
operator can roll back if the new key turns out to be broken.
Tests:
- test_db_crypto.py: 12 new tests for generate/fingerprint/rekey
mechanics (5 require SQLCipher at runtime; skipped otherwise).
- test_api_rotate_key.py: 6 new tests for endpoint wiring
(encryption-required, Keychain update, audit event, rekey-failure
rollback, Keychain-write-failure 503, concurrent-rotation 409).