-- RLS policy tests (pgTAP), per organized-ideas.md §1: "automated tests -- specifically for the RLS policies... the actual security boundary once -- roles matter." Run with: supabase test db -- -- device_categories is used as the representative test for the shared -- catalog pattern (manufacturers/port_types/cable_types/device_templates -- all use the identical is_public/owner_id policy shape) rather than -- repeating the same assertions five times. -- -- Approach: fixture users/rows are set up as the postgres superuser (which -- bypasses RLS entirely), then we switch to the `authenticated` role and -- impersonate each fixture user in turn by setting the JWT `sub` claim that -- auth.uid() reads — the same mechanism Supabase's own runtime uses. -- -- Note on UPDATE/DELETE vs. INSERT RLS failures: an INSERT whose new row -- fails WITH CHECK always raises 42501. An UPDATE/DELETE whose target row -- doesn't satisfy USING is simply excluded from the statement — 0 rows -- affected, no error. Only an UPDATE where USING passes (the row is yours -- to touch) but the *new* values fail WITH CHECK actually throws. Tests -- below use throws_ok only for genuine WITH CHECK failures, and a plain -- update-then-assert-unchanged for the "you can't even touch this row" -- case. begin; create extension if not exists pgtap with schema extensions; select plan(23); -- ---------------------------------------------------------------------- -- Fixtures (as postgres — RLS does not apply) -- ---------------------------------------------------------------------- insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'alice@example.com', '{"username":"alice"}'), ('22222222-2222-2222-2222-222222222222', 'bob@example.com', '{"username":"bob"}'), ('33333333-3333-3333-3333-333333333333', 'carol@example.com', '{"username":"carol_admin"}'), ('44444444-4444-4444-4444-444444444444', 'dave@example.com', '{"username":"dave_superadmin"}'); update public.profiles set role = 'admin' where id = '33333333-3333-3333-3333-333333333333'; update public.profiles set role = 'super_admin' where id = '44444444-4444-4444-4444-444444444444'; -- Everything from here on runs as the `authenticated` role, with auth.uid() -- controlled by the JWT sub claim we set before each block. set local role authenticated; -- ---------------------------------------------------------------------- -- Catalog pattern (device_categories as the representative case) -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select lives_ok( $$ insert into public.device_categories (id, name, owner_id, is_public) values ('a0000000-0000-0000-0000-000000000001', 'Alice Test Category', '11111111-1111-1111-1111-111111111111', false) $$, 'alice can insert her own private category' ); select throws_ok( $$ insert into public.device_categories (name, owner_id, is_public) values ('Sneaky Public Category', '11111111-1111-1111-1111-111111111111', true) $$, '42501'::char(5), null, 'alice cannot insert a public category directly' ); select is( (select count(*)::int from public.device_categories where id = 'a0000000-0000-0000-0000-000000000001'), 1, 'alice can see her own private category' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.device_categories where id = 'a0000000-0000-0000-0000-000000000001'), 0, 'bob cannot see alice''s private category' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select throws_ok( $$ update public.device_categories set is_public = true where id = 'a0000000-0000-0000-0000-000000000001' $$, '42501'::char(5), null, 'alice cannot self-promote her category to public' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select lives_ok( $$ update public.device_categories set is_public = true where id = 'a0000000-0000-0000-0000-000000000001' $$, 'carol (admin) can promote alice''s category to public in place' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.device_categories where id = 'a0000000-0000-0000-0000-000000000001'), 1, 'bob can see the category now that it is public' ); -- Now-public row: alice's USING clause ("mine AND still private") no -- longer matches at all, so this update is a silent no-op, not an error. select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); update public.device_categories set name = 'Renamed' where id = 'a0000000-0000-0000-0000-000000000001'; select is( (select name from public.device_categories where id = 'a0000000-0000-0000-0000-000000000001'), 'Alice Test Category', 'alice (original owner) can no longer edit it now that it is public (update is a no-op)' ); -- ---------------------------------------------------------------------- -- Diagrams + collaborators -- ---------------------------------------------------------------------- select lives_ok( $$ insert into public.diagrams (id, name, owner_id, data) values ('b0000000-0000-0000-0000-000000000001', 'Alice''s Rig', '11111111-1111-1111-1111-111111111111', '{}'::jsonb) $$, 'alice can insert her own diagram' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 0, 'bob cannot see alice''s diagram before being added as a collaborator' ); select throws_ok( $$ insert into public.diagram_collaborators (diagram_id, user_id, permission) values ('b0000000-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'edit') $$, '42501'::char(5), null, 'bob cannot add himself as a collaborator on alice''s diagram' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select lives_ok( $$ insert into public.diagram_collaborators (diagram_id, user_id, permission) values ('b0000000-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'view') $$, 'alice (owner) can add bob as a view-only collaborator' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select is( (select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 1, 'bob can now see alice''s diagram as a view collaborator' ); -- Bob's collaborator permission is 'view', so diagrams_update's USING -- clause doesn't match at all for him — silent no-op, not an error. update public.diagrams set name = 'Bob was here' where id = 'b0000000-0000-0000-0000-000000000001'; select is( (select name from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 'Alice''s Rig', 'bob (view-only) cannot update alice''s diagram (update is a no-op)' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select lives_ok( $$ update public.diagram_collaborators set permission = 'edit' where diagram_id = 'b0000000-0000-0000-0000-000000000001' and user_id = '22222222-2222-2222-2222-222222222222' $$, 'alice (owner) can upgrade bob to edit access' ); select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select lives_ok( $$ update public.diagrams set name = 'Bob was here' where id = 'b0000000-0000-0000-0000-000000000001' $$, 'bob (edit collaborator) can now update alice''s diagram' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select is( (select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 0, 'carol (admin, not super admin) has no special visibility into alice''s diagram' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select is( (select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'), 1, 'dave (super admin) can see any diagram' ); -- ---------------------------------------------------------------------- -- Catalog submissions -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true); select lives_ok( $$ insert into public.catalog_submissions (entity_type, proposed_data, submitter_id) values ('device_category', '{"name":"Bob''s New Category"}'::jsonb, '22222222-2222-2222-2222-222222222222') $$, 'bob can submit a new catalog entry for review' ); select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select is( (select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222'), 0, 'alice cannot see bob''s submission' ); select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true); select is( (select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222'), 1, 'carol (admin) can see bob''s submission' ); -- ---------------------------------------------------------------------- -- Profiles / role escalation -- ---------------------------------------------------------------------- select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true); select throws_ok( $$ update public.profiles set role = 'admin' where id = '11111111-1111-1111-1111-111111111111' $$, '42501'::char(5), null, 'alice cannot promote her own role' ); select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true); select lives_ok( $$ update public.profiles set role = 'admin' where id = '11111111-1111-1111-1111-111111111111' $$, 'dave (super admin) can change another user''s role' ); select * from finish(); rollback;