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:
1 parent
aaf57a66a2
commit
ab98015190
13 files changed
+366
-25
No files matched your search
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
)
|
||||
|
||||
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
|
||||
@@ -67,13 +69,14 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
||||
require.NotEmpty(t, foundPath)
|
||||
|
||||
// (b) The playlist collection exists with provenance, dashboard pin,
|
||||
// and the item at reading position 7.
|
||||
// and the item at reading position 7. Owner-scoped read: collections
|
||||
// RLS (2026-10-31) hides the row from the pool session's SeedIdentity.
|
||||
adminUUIDStr := adminID.String()
|
||||
var collID uuid.UUID
|
||||
var collName, collDesc string
|
||||
var showOnDashboard bool
|
||||
var viewSettingsRaw []byte
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||
`SELECT id, name, description, show_on_dashboard, view_settings
|
||||
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
|
||||
@@ -85,7 +88,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
||||
|
||||
var position *int32
|
||||
var itemCount int
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
// Owner-scoped reads: collections RLS (2026-10-31) — items ride the
|
||||
// parent's readability, so the pool session's SeedIdentity sees none.
|
||||
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
|
||||
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
|
||||
require.Equal(t, 1, itemCount)
|
||||
@@ -96,11 +101,11 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
||||
// (c) Rescan: no duplicate collection, no duplicate membership.
|
||||
scanTestFolder(t, setup, adminID, folder)
|
||||
var collectionCount, membershipCount int
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||
adminID).Scan(&collectionCount))
|
||||
require.Equal(t, 1, collectionCount)
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
|
||||
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
|
||||
require.Equal(t, 1, membershipCount)
|
||||
|
||||
@@ -131,7 +136,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
||||
// (e) hide_finished + Up Next + completion marker.
|
||||
// Flip the collection's flag on, mark the single item finished for the
|
||||
// admin, and the dashboard section becomes Complete with no items.
|
||||
_, err = setup.DBPool.Exec(context.Background(),
|
||||
// Owner-or-admin UPDATE policy: run as ADMIN (collections RLS,
|
||||
// 2026-10-31).
|
||||
adminScopeExec(t, setup, adminCtxValue(t, setup),
|
||||
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(context.Background(),
|
||||
|
||||
Reference in new issue
Block a user