Add local Supabase backend foundation: schema, RLS, and RLS tests

- supabase/config.toml: local dev stack config, pinned to the app's
  fixed dev server port, email confirmation required (hard
  verification gate per organized-ideas.md).
- Initial schema migration: profiles/roles, the public/private
  catalog tables (manufacturers, device categories, port types, cable
  types, device templates + ports) with the shared is_public/owner_id
  RLS pattern, a generalized catalog_submissions review-queue table,
  and diagrams as JSONB documents (+ collaborators, snapshots) rather
  than fully normalized -- see the migration's header comment for why.
- pgTAP RLS test suite (23 assertions) covering catalog visibility and
  promotion-in-place, diagram owner/collaborator/admin/super-admin
  visibility and edit permissions, submission visibility, and role
  escalation. Caught and fixed a real infinite-recursion bug between
  the diagrams and diagram_collaborators policies before this ever
  touched real data.
- vite.config.ts: pinned dev server port so Supabase Auth's redirect
  allow-list doesn't silently break if Vite floats to another port.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-04 23:11:52 -05:00
co-authored by Claude Sonnet 5
parent e424e40f1b
commit 8cea3f8b92
5 changed files with 1194 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
-- 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;