# 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 1. **New media-listing queries need NO visibility clause and NO plumbing.** If the statement goes through `ScopedDB` with a declared identity, the policy already filters it. 2. **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. 3. **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 websocket `initial_state` carries the connecting user. 4. **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 the `db-init` compose service provisions that role at startup). 5. **Schema changes go in `schema.sql` in 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 reads `media_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).