feat(secrets): pluggable secret backend with OpenBao read (Phase 4)

- secret_backends.py: SecretBackend protocol, FileSecretBackend (historical),
  OpenBaoBackend (KV-v2 read + file fallback), build_backend() env-selected
- _read_secret delegates to the active backend; default is file, so production
  is unchanged until CASTLE_SECRET_BACKEND=openbao
- OpenBao token bootstraps from the file backend; missing/unreachable falls back
- tests: file hit/miss, backend selection, unreachable + empty-token fallback

Read path verified live against castle-openbao. Write path, auto-unseal-on-boot,
and TLS hardening documented as remaining for full OpenBao production use.
This commit is contained in:
2026-07-07 05:08:16 -07:00
parent c67c06d8e6
commit f94517887e
4 changed files with 183 additions and 5 deletions

View File

@@ -1,7 +1,8 @@
# Fleet Mesh Plan — OpenBao + NATS
**Status:** in progress on branch `feat/fleet-mesh-nats-openbao`.
Phases 02 complete + single-node verified (live). Phases 34 pending (need 2nd node).
Phases 02 + Phase 4 (secret-read backend) complete + single-node verified (live).
Phase 3 (cross-node routing/breaker) and Phase 4 hardening pending the 2nd node.
## Context
@@ -218,6 +219,34 @@ second node is proven on NATS.
**Pending second node:** the follower-side reconcile (watch `castle-config` →
`castle apply`) is wired as an SSE hook but only meaningful with a peer.
### Phase 4 — secret-read backend DONE + verified + tested (2026-07-07)
- New `core/castle_core/secret_backends.py`: `SecretBackend` protocol,
`FileSecretBackend` (the historical behavior), `OpenBaoBackend` (KV-v2 read with
file fallback), and `build_backend()` selecting via `CASTLE_SECRET_BACKEND`
(default **file** — production is byte-for-byte unchanged until opted in).
- `_read_secret` (the single chokepoint, `config.py`) now delegates to the active
backend. `${secret:NAME}` syntax untouched.
- OpenBao token bootstraps from the file backend (it can't live in the vault it
unlocks); a missing key / auth failure / unreachable server all fall through to
file, so a partly-migrated vault keeps working.
- Verified live against the running `castle-openbao`: a secret stored in the vault
resolves through `${secret:...}` in openbao mode; file-only secrets resolve via
fallback; missing → placeholder; **default file mode unchanged**.
- **Tests:** `core/tests/test_secret_backends.py` (file hit/miss, backend
selection, unreachable-vault + empty-token fallback). Suites: **206 core + 93 api**.
**Remaining for full OpenBao production use (documented, not blocking — nothing
uses the backend until `CASTLE_SECRET_BACKEND=openbao`):**
1. **Write path** — `castle-api/secrets.py` CRUD still writes to files; in openbao
mode dashboard-set secrets land in file (resolve via fallback) rather than the
vault. Add `write`/`delete` to the backends + route the API writer through them.
2. **Auto-unseal on boot** (Phase 0 deferral) — OpenBao re-seals on container
restart. Wire an unseal step (manifest `exec_start_post` or a companion
oneshot reading `OPENBAO_UNSEAL_KEY`). Not urgent while the backend is unused.
3. **TLS hardening** — NATS + OpenBao are plaintext localhost. Required before
cross-network or moving real secrets: NATS mTLS + auth, OpenBao TLS listener.
## Decisions (resolved)
1. **Discovery — both.** Keep mDNS for zero-config LAN peer discovery *and*