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

+6
View File
@@ -6,6 +6,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/middleware"
"bookhoard/internal/rls"
"bookhoard/internal/setupstatus"
"context"
"errors"
@@ -1145,6 +1146,11 @@ func (h *AuthHandler) generateJWT(userID string) (string, error) {
}
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
// Registration runs BEFORE the JWT exists — the request context
// carries no identity, and the collections RLS insert policy
// (2026-10-31) would reject the five system-collection seeds.
// Declare the new user as the actor: owner == actor, policy passes.
ctx = rls.WithUser(ctx, userID)
defaultCollections := []struct {
Name string
Description string
+6
View File
@@ -545,6 +545,12 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
treeJSON,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Collections RLS: another user's collection is INVISIBLE —
// the update touched no rows. Answer 404, not a 500 that
// reads like a bug (2026-10-31 collections RLS).
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}