The page still taught the pre-2026-10-29 app-side pattern —
visibleLibrarySet helpers, hand-rolled sqlc.narg('visible_library_ids')
clauses, per-endpoint 403 checks — which the RLS move made both wrong
and counterproductive: media_items, podcast_feeds, and collections are
now governed by row-level security filtered on the app.user_id GUC
that ScopedDB declares per statement. New media queries need NO
visibility plumbing; detached contexts RAISE unless the identity is
threaded (rls.WithUser); the NEVER list (session-scoped GUC, bypass
flags, superuser connections) and the auditors (TestRlsInvariant,
Playwright sweep, contract pins) are documented with the policy
mechanics they enforce. The series-per-library rule survives — it is
query semantics, not visibility filtering, and the policy makes
'visible' automatic under it.
4.8 KiB
Library Visibility — how per-user data isolation actually works
Every user sees every library unless an admin hides it from them
(library_visibility table, default ALLOW — the family-lens case:
hide the adult library from a child's account, and no trace of it —
titles, covers, series, page images — may reach that account).
The one thing to understand before writing any code that touches media:
Enforcement lives in the DATABASE, as Postgres row-level security. The application declares who is asking; it never computes what they may see.
The engine
database/schema/schema.sql carries a labeled RLS block (re-run
idempotently on every boot). Governed tables — media_items,
podcast_feeds, collections, collection_items — have policies that
filter every row against the library_visibility mapping using the
app.user_id GUC: a per-transaction SET LOCAL declared by
ScopedDB (internal/database/scoped.go), which wraps the pool so
every sqlc statement runs inside a transaction that declares the
acting user.
- A statement whose context carries an identity sees only that user's visible rows.
- A statement with no identity raises on a governed table — the engine fails loud instead of leaking. Ungoverned tables (auth lookups, settings, sessions) are unaffected and keep working.
- Collections use predicate functions (
app_can_read_collection,app_can_write_collection) that are PURE over their arguments — reads are owner-or-admin, writes are owner (admin escape for SYSTEM collections only). Future sharing = one EXISTS branch inside the predicate, no policy migration.
The rules for new code
- New media-listing queries need NO visibility clause and NO
plumbing. If the statement goes through
ScopedDBwith a declared identity, the policy already filters it. - The app declares identity; it never computes visibility. Three bridges put the identity on the request context: the JWT middleware, the cookie/device middleware, and the KOReader/Kobo device auth middleware. Everything downstream just carries the context.
- A media read on a detached
context.Background()RAISES. If you need detached work (background jobs, websocket fan-in), thread the acting identity explicitly —rls.WithUser(ctx, userID)— the way scan jobs carry the library owner (first-admin fallback) and the websocketinitial_statecarries the connecting user. - NEVER: session-scoped GUCs (pooled-connection identity bleed), a
bypass flag, or connecting the app as a superuser (superusers bypass
RLS unconditionally — the app runs as
bookhoard_srv, and thedb-initcompose service provisions that role at startup). - Schema changes go in
schema.sqlin place, idempotent (DROP .. IF EXISTS+CREATE) — it re-runs on every boot. The migration transaction declares a synthetic identity because the schema's own dedup pass readsmedia_items.
How this gets audited
TestRlsInvariant— API black-box: two users, one hidden library, every media-returning surface checked. New media-returning surfaces must be added to it.- The Playwright sweep (
tests/ui/) — user-visible pages checked against the coverage list. docs/contract_pins.md— contract-level pins (a loosening requires a changelog entry there).
Why the rule is written in blood
The pre-RLS app-side approach shipped a real leak (2026-10-13,
431fec0): series endpoints were name-only across ALL libraries and the
PDF thumbnail endpoint served page images of hidden books — every
hand-rolled filter had to be remembered at every call site, and one
forgotten clause leaked. On 2026-10-29 enforcement moved into the
database, and the invariant test went red → green to prove the old
helpers were no longer needed. If you find yourself writing a
visible_library_ids filter by hand, you are re-creating the bug.
Series are per-library
Series group by (library_id, series) — the same series name in two
libraries is two entries (manga adaptations, LN splits). Do not re-merge
them cross-library. /api/series/books requires a visible library_id;
/api/series without one aggregates only the caller's visible libraries
(the policy makes "visible" automatic).