docs(agents): family-lens enforcement moves to RLS — one source
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.
This commit is contained in:
1 parent
d49a9e6ca2
commit
efbe312f16
1 file changed
+41
-21
@@ -106,30 +106,50 @@ inside quoted attribute values (gotcha #1 below), so dynamic
|
|||||||
## Library visibility (family-lens rule) — ALWAYS apply
|
## Library visibility (family-lens rule) — ALWAYS apply
|
||||||
|
|
||||||
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
||||||
every user sees every library unless an admin hides one) must be
|
every user sees every library unless an admin hides one) is enforced by
|
||||||
enforced on **every endpoint that returns media metadata or content**.
|
**Postgres RLS** — one source of truth: the labeled policy block in
|
||||||
It is NOT automatic — enforcement is per-handler.
|
`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
|
- **The engine is the enforcement.** Every `media_items` read is
|
||||||
requesting user's visible-library set, and MUST reject an explicit
|
filtered by the policy, which joins `library_visibility` on the
|
||||||
`library_id` outside that set with 403 (never silently pass).
|
per-transaction GUC `app.user_id`. New media-listing queries need
|
||||||
- Helpers (do not hand-roll):
|
NO visibility clause; new endpoints need NO visibility plumbing.
|
||||||
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
|
- **The app declares identity; it never computes visibility.** All
|
||||||
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
|
sqlc statements run through `ScopedDB`
|
||||||
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
|
(`internal/database/scoped.go`), which declares `app.user_id` from
|
||||||
- `resolveLibrary` (helpers.go) only honors requested library ids
|
the stdlib request context. Three bridge sites put it there:
|
||||||
inside the visible set.
|
`router.go` JWT, `router.go` cookie/device, and
|
||||||
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
|
`middleware/device_auth.go` (KOReader/Kobo sync devices). A media
|
||||||
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
|
read on a detached `context.Background()` RAISES — thread the
|
||||||
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
|
request context or the acting identity (see conflicts.go and
|
||||||
- Content endpoints (file bytes, page images) additionally gate per item
|
analytics.go for the pattern).
|
||||||
like `ReaderHandler.ensureItemVisible` / the download endpoint.
|
- **Background jobs declare the acting identity** before detached
|
||||||
- Tests must pin visibility: hidden library → 403 on the endpoint, and
|
work: scan jobs carry the library owner / first-admin fallback
|
||||||
absent from unscoped aggregates.
|
(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
|
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
|
||||||
leaked hidden libraries to any authenticated account — series endpoints
|
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
|
||||||
were name-only across ALL libraries. Fixed; do not regress.
|
dashboard section queries had silently never carried the clauses,
|
||||||
|
and the invariant test went red → green and stays as the auditor.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user