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) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:15:13 +02:00
co-authored by Claude Opus 4.8
parent 8415a97d15
commit 0cc441d6ce
+25 -5
View File
@@ -127,12 +127,32 @@ erDiagram
lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently; lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently;
a failed brain delivery doesn't fail the store delivery. 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 Every user-owned table carries `user_id`, and isolation is **enforced at the DB layer**, not
per-tenant Postgres role + row grants (architecture review SC7), not only in application code. only in application code. ADR-011 shipped this surface single-user (one allowlisted subject,
At Stage 0 (single user) the column exists but the enforcement is dormant. The isolation test enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the same slice.**
in VISION Stage 2 asserts user A cannot read user B's rows.
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 ## Job / processing state