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
@@ -329,6 +329,22 @@ func seedCtx() context.Context {
|
||||
return rls.WithUser(context.Background(), rls.SeedIdentity)
|
||||
}
|
||||
|
||||
// ownerSeedCtx returns a context carrying a fixture user's identity,
|
||||
// for seeding rows OWNED by that user. Collections RLS (2026-10-31):
|
||||
// the write predicates want actor == owner for non-system rows (the
|
||||
// admin write-escape covers SYSTEM collections only), and reads under
|
||||
// the pool session's SeedIdentity see nothing.
|
||||
func ownerSeedCtx(userID pgtype.UUID) context.Context {
|
||||
return rls.WithUser(context.Background(), userID)
|
||||
}
|
||||
|
||||
// scopeExecAs is adminScopeExec's generic form: raw SQL under ANY
|
||||
// declared identity. Use it (with the row's OWNER) for fixture writes
|
||||
// to non-system collections — see ownerSeedCtx.
|
||||
func scopeExecAs(t *testing.T, setup *TestServerSetup, userID pgtype.UUID, sql string, args ...any) {
|
||||
adminScopeExec(t, setup, userID, sql, args...)
|
||||
}
|
||||
|
||||
// seedExec runs a raw statement against the pool under the seed
|
||||
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
|
||||
// the GUC), so governed tables need the identity declared manually
|
||||
@@ -356,7 +372,12 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||||
|
||||
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
||||
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
||||
ctx := context.Background()
|
||||
// Owner-scoped seeding: the collections RLS policies (2026-10-31)
|
||||
// need an acting identity, and the harness seeds before any JWT —
|
||||
// the same bootstrap fix as CreateDefaultCollectionsForUser in
|
||||
// internal/handlers/auth.go. Owner == actor, so SELECT sees the
|
||||
// rows, the INSERT passes, and the heal-UPDATE passes.
|
||||
ctx := rls.WithUser(context.Background(), userID)
|
||||
// Canonical Title-case names — the same list the registration seed
|
||||
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
|
||||
// A stale kebab-case copy lived here once and duplicated every
|
||||
@@ -772,11 +793,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
// run's creates die on UNIQUE(user_id, name) — the "collections
|
||||
// 500-panic" rot. System collections are skipped (permanent rows).
|
||||
// Same convention as the library pass: names containing "test".
|
||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
// Swept under the OWNER's identity: collections RLS (2026-10-31)
|
||||
// makes unscoped reads return nothing, which would silently turn
|
||||
// this cleanup into a no-op.
|
||||
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
if cerr == nil {
|
||||
for _, coll := range adminColls {
|
||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||
queries.DeleteCollection(ctx, coll.ID)
|
||||
queries.DeleteCollection(adminScope, coll.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,11 +875,13 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
|
||||
// Same pass for test-created COLLECTIONS (the "collections
|
||||
// 500-panic" rot: they lingered forever and poisoned later runs).
|
||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
// Owner-scoped for the same RLS reason as the setup sweep.
|
||||
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
if cerr == nil {
|
||||
for _, coll := range adminColls {
|
||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||
queries.DeleteCollection(ctx, coll.ID)
|
||||
queries.DeleteCollection(adminScope, coll.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user