diff --git a/DECISIONS.md b/DECISIONS.md index 6b609a2..63fa612 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -388,6 +388,62 @@ the rate gate (the batch path moves into k3s per the deferred CronJob item). --- +## ADR-015 — Per-user credentials: envelope-encrypted in PG18, not vault-stored + +**Status:** Accepted (2026-06-03) + +**Context.** The Stage-0/1 SecretStore (`internal/adapters/secrets/file.go`) holds per-user +YouTube OAuth refresh tokens as a flat key-value JSON map on a PVC — explicitly a stand-in for +"op/ESO later" (ADR-002, ADR-006). infra#86 proposed migrating it to an ESO-backed store. The +decision spike (infra#88) found that framing subtly wrong: **ESO syncs vault→cluster at +deploy/refresh time; it is not a runtime write API.** Per-user tokens are written *at runtime, +per end-user* (every YouTube connect; on token rotation) — they are application state, not +configuration. The homelab 1Password SA is also read-only, so a vault-write path would require +a new write-capable SA, widening Tapir's blast radius to shared estate infra to store what is +fundamentally Tapir's own row-data. Reading the actual SecretStore confirmed the shape: a +3-method port (`Get`/`Put`/`Delete`) over opaque refs, written interactively per user. + +**Decision.** Per-user credentials are stored **envelope-encrypted in PG18**, not in the vault: + +1. Tokens are encrypted with a **single app-level envelope key** and stored as ciphertext in + PG18, under the Row-Level Security already enforced and tested (ADR-012). Reads/writes go + through the existing `withUser` RLS-scoped seam. +2. The **envelope key** is the only secret in 1Password — fetched via the **existing read-only + SA** (confirmed working). No new write-capable SA; no per-user vault items. +3. The `ports.SecretStore` port is unchanged (`Get`/`Put`/`Delete`). The implementation swaps + `FileStore` (PVC JSON) for a `PGStore` (encrypted rows). Every consumer — connect, + disconnect, delete-account — is untouched (the port abstraction holds, ADR-003 spirit). +4. **Infra/operator credentials** (Dex client secret, MCP-auth tokens, service tokens) stay an + **ESO/1Password** concern. This ADR governs *per-user runtime* credentials only. The two + classes use two mechanisms deliberately — because they are two different things (runtime + app-state vs deploy-time config), not as a compromise. The "one mechanism" question + (maintainer's initial preference) was answered in #88 by correctly *classifying* the + secrets rather than unifying their storage. + +**Consequences.** +- Runtime credential writes are normal RLS'd DB writes — no ESO sync latency, no indirection, + no write-SA blast radius. The interactive connect→store→use flow works without a vault + round-trip. +- Keeps PG18 and keeps ADR-002 intact (Supabase was considered and rejected again in #88 — + adding a datastore to hold a few encrypted strings PG18 already holds). +- Adds an encrypt/decrypt seam and an **envelope-key rotation** responsibility (re-encrypt the + per-user rows under a new key). infra#89 (build) must implement and test rotation, not assume + it — this is the real engineering cost of the choice. +- The vault's involvement shrinks to one static key via the SA already trusted for reads. +- **Supersedes** the "PVC stand-in for op/ESO" intent recorded in `secrets/file.go` and + `docs/homelab-integration.md` for the *per-user* secret path (the ESO/1Password reference in + ADR-006 stands for the *infra-cred* path). + +**Reversibility / falsification (from infra#88).** Revisit if: per-user tokens need +high-frequency rotation writes (weak — PG18 handles it); an estate compliance policy requires +all credentials in 1P for a single audit surface (maintainer-knowable, not currently believed +to hold — would favour the vault-write path on policy grounds); or envelope-key rotation proves +operationally worse than per-secret vault rotation (the real cost #89 must prove). If none hold, +this stands. Full reasoning + rejected candidates (write-capable SA; Supabase): the infra#88 +decision doc (`infra/docs/superpowers/handoffs/`). Build + reboot-validation: infra#89. + +--- + ## Rejected alternatives Approaches considered during the 2026-06-02 planning + grill session and **deliberately not @@ -406,6 +462,8 @@ maps to the ADR that settles it. | Audio-download + Whisper STT in the core path | ToS-grey, breakage-prone (yt-dlp), contends for koala GPU with the JEPA PoC; captions alone test the core hypothesis | ADR-007 | | Building multi-tenant SaaS / Google OAuth verification now | "Real users soon" was lowered to Future B; SaaS machinery before the Stage 0 self-use gate is the primary documented anti-goal | ADR-008, VISION | | Delegating the S5 reuse spike to an agent swarm | A 1-hour sequential read-and-judge with a single coupled conclusion; orchestration overhead exceeds the work, and it's Diamond-1 judgment the maintainer wanted to own | (process note) | +| Vault-write SA for per-user OAuth tokens (ESO as runtime write path) | ESO syncs vault→cluster at deploy time, not a runtime write API; a write-SA widens blast radius to shared infra to store app row-data | ADR-015, infra#88 | +| Supabase for per-user credential storage | Adds a second datastore for a few encrypted strings PG18 already holds; reopens ADR-002 | ADR-015, infra#88 | If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one — not a silent reversal.