From efbe312f1641249540ee3d2453890220a22c9b77 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 12:01:37 -0400 Subject: [PATCH] =?UTF-8?q?docs(agents):=20family-lens=20enforcement=20mov?= =?UTF-8?q?es=20to=20RLS=20=E2=80=94=20one=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approves and lands the doctrine rewrite from the verified rollout: RLS on media_items IS the enforcement (policy joins library_visibility on app.user_id, declared per transaction by ScopedDB); new media-listing queries and endpoints need no visibility plumbing. Documents the three identity bridge sites, background-job identities, the FORCE/owner role and db-init provisioning, per-boot schema idempotency, and the two auditors (TestRlsInvariant + tests/ui). Item-scoped gates may answer 404 as well as 403 (contract_pins 2026-10-29). History records the convention-era leak the dashboard section queries carried. --- AGENTS.md | 62 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f3e4e2f6..34f2760d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,30 +106,50 @@ inside quoted attribute values (gotcha #1 below), so dynamic ## Library visibility (family-lens rule) — ALWAYS apply Per-user library visibility (`library_visibility` table, default-ALLOW: -every user sees every library unless an admin hides one) must be -enforced on **every endpoint that returns media metadata or content**. -It is NOT automatic — enforcement is per-handler. +every user sees every library unless an admin hides one) is enforced by +**Postgres RLS** — one source of truth: the labeled policy block in +`database/schema/schema.sql` + the runtime role +(`database/schema/z_rls_role.sh`). -- **New endpoints that list/filter media or series** MUST filter by the - requesting user's visible-library set, and MUST reject an explicit - `library_id` outside that set with 403 (never silently pass). -- Helpers (do not hand-roll): - - `handlers`: `(*MediaHandler).visibleLibrarySet` / - `(*SeriesHandler).visibleLibrarySet` — membership map + id slice. - - `router`: `visibleLibraryIDsFor` — same for SSR routes. - - `resolveLibrary` (helpers.go) only honors requested library ids - inside the visible set. -- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR - library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see - `ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`. -- Content endpoints (file bytes, page images) additionally gate per item - like `ReaderHandler.ensureItemVisible` / the download endpoint. -- Tests must pin visibility: hidden library → 403 on the endpoint, and - absent from unscoped aggregates. +- **The engine is the enforcement.** Every `media_items` read is + filtered by the policy, which joins `library_visibility` on the + per-transaction GUC `app.user_id`. New media-listing queries need + NO visibility clause; new endpoints need NO visibility plumbing. +- **The app declares identity; it never computes visibility.** All + sqlc statements run through `ScopedDB` + (`internal/database/scoped.go`), which declares `app.user_id` from + the stdlib request context. Three bridge sites put it there: + `router.go` JWT, `router.go` cookie/device, and + `middleware/device_auth.go` (KOReader/Kobo sync devices). A media + read on a detached `context.Background()` RAISES — thread the + request context or the acting identity (see conflicts.go and + analytics.go for the pattern). +- **Background jobs declare the acting identity** before detached + work: scan jobs carry the library owner / first-admin fallback + (the job Context carries it — `ScanFolders` self-declares the + configured admin), the hash backfill runs as first admin. The sync + queue processor needs nothing (`sync_queue` is not RLS-governed). +- **NEVER**: session-scoped `app.user_id` (pooled-connection identity + bleed); a bypass flag; connecting the app as the bootstrap + superuser (superusers bypass RLS unconditionally — `DATABASE_USER` + stays `bookhoard_srv`; the `db-init` compose service provisions the + role on every startup). +- **schema.sql re-runs on EVERY boot** (executeSchema) — everything + added to it must be idempotent (`DROP .. IF EXISTS` + `CREATE`). + The migration tx declares a synthetic identity + (`schemaMigrationIdentity`) because the schema's own dedup block + reads `media_items`. +- **Auditors**: `TestRlsInvariant` (API black-box — add new + media-returning surfaces to it; loosening requires a + docs/contract_pins.md entry) and the Playwright sweep (`tests/ui`, + new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped + gates may answer 404 as well as 403 (row invisible vs Go-level + check — contract_pins 2026-10-29). History: 2026-10-13 (`431fec0`) the series/media listing surfaces -leaked hidden libraries to any authenticated account — series endpoints -were name-only across ALL libraries. Fixed; do not regress. +leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the +dashboard section queries had silently never carried the clauses, +and the invariant test went red → green and stays as the auditor. ## Tests