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:
+25
-5
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user