docs: add ADR-015 — per-user credentials envelope-encrypted in PG18
Records the infra#88 spike decision: per-user OAuth tokens are runtime app-state, not config, so they live envelope-encrypted in PG18 under RLS (key from 1P via the existing read-only SA) rather than in the vault. Infra creds stay ESO/1Password — two mechanisms because they're two different things. Includes the falsification conditions (frequent rotation; estate audit policy; key-rotation cost) so the choice is earned not assumed. Adds the two rejected candidates (vault-write SA; Supabase) to the rejected-alternatives table. Full reasoning in the #88 decision doc; build + reboot-validation in #89.
This commit is contained in:
@@ -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
|
## Rejected alternatives
|
||||||
|
|
||||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
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 |
|
| 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 |
|
| 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) |
|
| 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 —
|
If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one —
|
||||||
not a silent reversal.
|
not a silent reversal.
|
||||||
|
|||||||
Reference in New Issue
Block a user