From 0cc441d6ce6ba8c9c208679a3d002a9fc64dd6b5 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 22:15:13 +0200 Subject: [PATCH] docs(data-model): isolation enforcement is live (RLS), not dormant The "Isolation invariant" section said enforcement was dormant at Stage 0. ADR-012 turned it on: Postgres RLS ENABLE+FORCE on every user-owned table (migration 003_rls.up.sql), keyed off the tapir.current_user_id GUC set by the store's withUser helper, deny-by- default on an unset GUC. The Stage-2 two-user isolation test (internal/adapters/store/rls_test.go) is pulled forward and passing. Kept the ADR-011 single-user history honest. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/data-model.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index 954fd64..c956cb8 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -127,12 +127,32 @@ erDiagram lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently; a failed brain delivery doesn't fail the store delivery. -## Isolation invariant (Stage 1+) +## Isolation invariant (Stage 1+) — LIVE -Every user-owned table carries `user_id`. At Stage 1, this is enforced at the DB layer via a -per-tenant Postgres role + row grants (architecture review SC7), not only in application code. -At Stage 0 (single user) the column exists but the enforcement is dormant. The isolation test -in VISION Stage 2 asserts user A cannot read user B's rows. +Every user-owned table carries `user_id`, and isolation is **enforced at the DB layer**, not +only in application code. ADR-011 shipped this surface single-user (one allowlisted subject, +enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the same slice.** + +Enforcement is **Postgres Row-Level Security** (migration `003_rls.up.sql`): + +- RLS is `ENABLE`d **and** `FORCE`d on every user-owned table — `users`, `videos`, + `transcripts`, `summaries`, `summary_actions`, `video_connections`. `FORCE` is load-bearing: + the app connects as the table **owner** (`tapir` role), and owners bypass RLS unless forced. +- Each policy keys off the per-request GUC `tapir.current_user_id`, set transaction-locally by + the store's `withUser` helper via `set_config('tapir.current_user_id', $1, true)` — it + auto-resets on commit/rollback, so it never leaks across a pooled connection. +- `current_setting('tapir.current_user_id', true)` uses `missing_ok = true`: an **unset** GUC + yields `NULL`, the predicate matches no rows, and access **denies by default**. +- `sink_deliveries` has no `user_id`; its policy derives ownership from the parent summary via + `EXISTS (SELECT 1 FROM summaries …)`. +- `user_identities` (the Dex-subject → user_id map) is **deliberately not RLS-enabled** — it is + auth plumbing read *before* a user_id is known; putting RLS there would deadlock. It holds no + user data. + +The Stage-2 isolation bar is **pulled forward, not deferred**: `internal/adapters/store/rls_test.go` +runs two users against a non-superuser, non-`BYPASSRLS` role and asserts user A reads/writes zero +of user B's rows across every table. It ships green with the multi-user features (ADR-012); no +multi-user feature merges ahead of it passing. ## Job / processing state