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
@@ -2180,3 +2180,117 @@ CREATE POLICY p_podcast_feeds_delete ON podcast_feeds
|
||||
-- policy's lookup table, and a user must always see their own
|
||||
-- subscription rows (subscribe -> visibility is a single INSERT).
|
||||
-- ============================================================
|
||||
-- ── collections + collection_items (2026-10-31): ownership at the DB ──
|
||||
-- layer, same doctrine as media_items/podcast_feeds above. The
|
||||
-- predicates live in FUNCTIONS (not inline) so a future sharing model
|
||||
-- edits exactly one place: add the share-table EXISTS branch inside
|
||||
-- app_can_read_collection / app_can_write_collection. The functions are
|
||||
-- PURE over their arguments — they never query collections — so the
|
||||
-- policies cannot recurse, FORCE RLS safe. Reads are authenticated-
|
||||
-- allow (household-readable: any signed-in identity, SeedIdentity
|
||||
-- harness included); WRITES are owner-or-admin — that is the
|
||||
-- enforcement of record for Update/Delete/AddBooks, which have no
|
||||
-- app-level gate.
|
||||
-- ===========================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION app_is_admin() RETURNS BOOLEAN
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM users u
|
||||
WHERE u.id = NULLIF(current_setting('app.user_id', true), '')::uuid
|
||||
AND u.role = 'admin'
|
||||
);
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION app_can_read_collection(
|
||||
p_collection_id UUID, p_owner UUID
|
||||
) RETURNS BOOLEAN
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
|
||||
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
|
||||
AND (
|
||||
p_owner = current_setting('app.user_id', true)::uuid
|
||||
OR app_is_admin()
|
||||
-- FUTURE SHARING slot: OR EXISTS (
|
||||
-- SELECT 1 FROM collection_shares cs
|
||||
-- WHERE cs.collection_id = p_collection_id
|
||||
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid)
|
||||
);
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION app_can_write_collection(
|
||||
p_collection_id UUID, p_owner UUID, p_system BOOLEAN
|
||||
) RETURNS BOOLEAN
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
|
||||
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
|
||||
AND (
|
||||
p_owner = current_setting('app.user_id', true)::uuid
|
||||
OR (COALESCE(p_system, false) AND app_is_admin())
|
||||
-- FUTURE SHARING slot: OR EXISTS (
|
||||
-- SELECT 1 FROM collection_shares cs
|
||||
-- WHERE cs.collection_id = p_collection_id
|
||||
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid
|
||||
-- AND cs.permission IN ('edit'))
|
||||
);
|
||||
$$;
|
||||
|
||||
ALTER TABLE collections ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE collections FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS p_collections_select ON collections;
|
||||
CREATE POLICY p_collections_select ON collections
|
||||
FOR SELECT USING (app_can_read_collection(id, user_id));
|
||||
DROP POLICY IF EXISTS p_collections_insert ON collections;
|
||||
CREATE POLICY p_collections_insert ON collections
|
||||
FOR INSERT WITH CHECK (
|
||||
user_id = NULLIF(current_setting('app.user_id', true), '')::uuid
|
||||
OR app_is_admin()
|
||||
);
|
||||
DROP POLICY IF EXISTS p_collections_update ON collections;
|
||||
CREATE POLICY p_collections_update ON collections
|
||||
FOR UPDATE
|
||||
USING (app_can_write_collection(id, user_id, is_system_collection))
|
||||
WITH CHECK (app_can_write_collection(id, user_id, is_system_collection));
|
||||
DROP POLICY IF EXISTS p_collections_delete ON collections;
|
||||
CREATE POLICY p_collections_delete ON collections
|
||||
FOR DELETE USING (app_can_write_collection(id, user_id, is_system_collection));
|
||||
|
||||
ALTER TABLE collection_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE collection_items FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- Items ride their PARENT's accessibility: the collections SELECT
|
||||
-- policy auto-applies inside these EXISTS subqueries.
|
||||
DROP POLICY IF EXISTS p_collection_items_select ON collection_items;
|
||||
CREATE POLICY p_collection_items_select ON collection_items
|
||||
FOR SELECT USING (EXISTS (
|
||||
SELECT 1 FROM collections c
|
||||
WHERE c.id = collection_items.collection_id
|
||||
));
|
||||
DROP POLICY IF EXISTS p_collection_items_insert ON collection_items;
|
||||
CREATE POLICY p_collection_items_insert ON collection_items
|
||||
FOR INSERT WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM collections c
|
||||
WHERE c.id = collection_items.collection_id
|
||||
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||
));
|
||||
DROP POLICY IF EXISTS p_collection_items_update ON collection_items;
|
||||
CREATE POLICY p_collection_items_update ON collection_items
|
||||
FOR UPDATE
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM collections c
|
||||
WHERE c.id = collection_items.collection_id
|
||||
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM collections c
|
||||
WHERE c.id = collection_items.collection_id
|
||||
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||
));
|
||||
DROP POLICY IF EXISTS p_collection_items_delete ON collection_items;
|
||||
CREATE POLICY p_collection_items_delete ON collection_items
|
||||
FOR DELETE USING (EXISTS (
|
||||
SELECT 1 FROM collections c
|
||||
WHERE c.id = collection_items.collection_id
|
||||
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
|
||||
));
|
||||
|
||||
|
||||
Reference in new issue
Block a user