feat(collections): RLS ownership enforcement at the DB layer

collections + collection_items join the RLS-governed tables, same
doctrine as the media_items family lens (2026-10-29). Policies route
through two PURE predicate functions (app_can_read_collection /
app_can_write_collection) that never query collections — no policy
recursion under FORCE RLS — so a future sharing model is one EXISTS
branch inside the functions, not a schema re-touch. Reads: owner or
admin; writes: owner, admin escape for SYSTEM collections only.

Why: the write path had no app-level ownership gate on Update/Delete/
AddBooks — any authenticated user could mutate another user's
collection by id. The DB is now the enforcement of record; existing
app-level gates (match-mode, reorder, view-settings) stay as
defense-in-depth.

Behavior changes (contract_pins 2026-10-31):
- cross-user GET detail: 200 -> 404 (row invisible)
- cross-user PUT update: silent success -> 404 (ErrNoRows mapping;
  service wrap fixed %v -> %w so errors.Is sees pgx.ErrNoRows)
- cross-user POST books: silent success -> 404
- cross-user DELETE: 204, now a true DB no-op
- anonymous sees nothing; admin persona unchanged

Bootstrap: registration seeds system collections BEFORE the JWT
exists — CreateDefaultCollectionsForUser declares the new user's
identity (rls.WithUser), the same fix mirrored in the test harness
(createDefaultCollectionsForUser owner-scoped; collection sweeps run
under the owner's identity).

Tests: new TestCollectionsRLS pins the cross-user matrix end-to-end
(404 detail/update/add/view-settings, 204 no-op delete, admin escape,
owner positive control, registration seeding under RLS). Harness
fixture writes to non-system collections now run as the row's OWNER
(scopeExecAs/ownerSeedCtx) — the admin write-escape covers system
rows only, and bare-ctx pool reads of collections see nothing.
Filter/playlist/audiobook/scan-state fixtures converted. Full
container suite green (159s).
This commit is contained in:
John O'Keefe committed 2026-10-31 18:30:02 -04:00
1 parent aaf57a66a2
commit ab98015190
13 files changed
+366 -25

No files matched your search

+31
View File
@@ -514,3 +514,34 @@ Test-side deletions follow the asymmetry rule: subtests exercising the
deleted endpoints are gone; their surviving SEMANTICS are pinned in
their tree form (TestSmartCollections rewritten on trees,
TestEpisodeCollectionsFilterTree, TestPodcastSmartCollections).
## 2026-10-31 — collections RLS: cross-user detail/writes tighten to 404/no-op
`collections` and `collection_items` joined the RLS-governed tables
(policies `p_collections_*` / `p_collection_items_*`, predicates
`app_can_read_collection` / `app_can_write_collection` — the same
doctrine as the media_items family lens, 2026-10-29). Contract
changes:
- `GET /api/collections/:id` for another user's collection: was 200
(by-id readability), now 404 (row invisible).
- `PUT /api/collections/:id` cross-user: was ungated success (no
app-level ownership check), now 404 — the RLS update touches no
rows, and the handler maps ErrNoRows to 404.
- `POST /api/collections/:id/books` cross-user: was ungated, now 404
(rejectSmartMutation's GetCollectionData is RLS-invisible).
- `DELETE /api/collections/:id` cross-user: still 204, but now a true
DB-level no-op (zero rows deleted) instead of an app-level free hit.
- `PUT /api/collections/:id/view-settings` cross-user: 404 (the owner
gate stays as defense-in-depth; the DB is enforcement of record).
- `PUT .../match-mode` and `.../items/order` keep their app-level 403
gates; the DB now backs them.
- Reads: owner or admin (policy escape); non-owner non-admin users see
nothing, anonymous included. Future sharing rides the predicate
functions — one EXISTS branch in app_can_read/write_collection, no
policy migration.
Test-side: TestCollectionsRLS pins the cross-user matrix end-to-end;
fixture writes to non-system collections run as the row's OWNER
(scopeExecAs/ownerSeedCtx) because the admin write-escape covers
system collections only.