Per organized-ideas.md §6: role assignment, account ban/unban/delete, and direct Admin/Super-Admin CRUD of public catalog entries outside the submission workflow. Backend: - list_users_for_admin(): Super-Admin-gated SECURITY DEFINER function joining profiles + auth.users (username, email, role, banned_until) — auth.users isn't exposed through PostgREST, so this is the only way to list accounts at all. - New Edge Function admin-user-action (ban/unban/delete), using @supabase/server's `auth: 'user'` mode to verify the caller's JWT, then Supabase Auth's Admin API for the actual mutation. This is deliberately an Edge Function rather than a Postgres function like everything else in this codebase: touching auth.users needs the Admin API, the stable documented interface, not a direct write to a schema Supabase manages internally. Self-action guard; verify_jwt = true at the gateway on top of the function's own JWT verification. - 5 new pgTAP tests (43/43 total) for list_users_for_admin (Super-Admin-only, even regular Admins get 42501). - CatalogRepository gains admin* methods (direct edit of a public port/cable/ device entry, plus adminUnpublish which flips is_public rather than deleting) — the update methods were already ownership-agnostic (RLS's is_admin() clause is what actually permits it), so these are thin aliases, not duplicated logic. Frontend: - authStore/AdminUserRepository: minimal role plumbing, shared UserRole type. - adminUserStore + AdminUsersModal: list/role-dropdown/ban/unban/delete, gated to Super Admin only via a new "Manage Users" TopBar button. - PortTypeManager/CableTypeManager/DevicePalette: built-in entries now show direct "Edit"/"Unpublish" for Admins (regular Admin included, per §6's capability table — not Super-Admin-exclusive) instead of "Suggest edit"; unpublish reuses the review-queue's impact-check RPC before confirming. - DeviceTemplateEditor gains an `adminMode` save path alongside its existing submissionMode/resubmitId ones. Verified: tsc -b and oxlint clean; supabase db reset + 43/43 pgTAP tests pass; confirmed both new privileged endpoints (the SQL function and the Edge Function) actually work through the real REST API via live curl calls — signup, email confirm, role promotion, ban/unban/delete round trips, self-action guard, non-super-admin rejection, and verify_jwt=true compatibility all exercised directly, not just asserted.
433 lines
17 KiB
PL/PgSQL
433 lines
17 KiB
PL/PgSQL
-- 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(43);
|
|
|
|
-- ----------------------------------------------------------------------
|
|
-- 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'
|
|
);
|
|
|
|
-- Fill the rest of bob's pending-submission cap (organized-ideas.md §3's
|
|
-- soft cap, set to 10) and confirm the 11th is rejected.
|
|
insert into public.catalog_submissions (entity_type, proposed_data, submitter_id)
|
|
select 'device_category', jsonb_build_object('name', 'Bob Cap Filler ' || g), '22222222-2222-2222-2222-222222222222'
|
|
from generate_series(1, 9) g;
|
|
|
|
select is(
|
|
(select count(*)::int from public.catalog_submissions
|
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'),
|
|
10,
|
|
'bob has filled his pending-submission cap (10)'
|
|
);
|
|
|
|
select throws_ok(
|
|
$$ insert into public.catalog_submissions (entity_type, proposed_data, submitter_id)
|
|
values ('device_category', '{"name":"One Too Many"}'::jsonb, '22222222-2222-2222-2222-222222222222') $$,
|
|
'42501'::char(5), null,
|
|
'bob cannot exceed the pending-submission cap'
|
|
);
|
|
|
|
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);
|
|
|
|
-- 10, not 1: includes the 9 cap-filler submissions inserted above.
|
|
select is(
|
|
(select count(*)::int from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222'),
|
|
10,
|
|
'carol (admin) can see all of bob''s submissions'
|
|
);
|
|
|
|
-- ----------------------------------------------------------------------
|
|
-- Withdrawing a submission — pending or rejected. A rejected submission
|
|
-- previously had no way out (only 'pending' was deletable); it should be
|
|
-- dismissable the same as a pending one, not stuck forever.
|
|
-- ----------------------------------------------------------------------
|
|
|
|
select lives_ok(
|
|
$$ update public.catalog_submissions
|
|
set status = 'rejected', reviewer_id = '33333333-3333-3333-3333-333333333333', review_reason = 'Needs more detail'
|
|
where id = (
|
|
select id from public.catalog_submissions
|
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'
|
|
order by created_at limit 1
|
|
) $$,
|
|
'carol (admin) can reject one of bob''s pending submissions'
|
|
);
|
|
|
|
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
|
|
|
select lives_ok(
|
|
$$ delete from public.catalog_submissions
|
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'rejected' $$,
|
|
'bob can withdraw (delete) his rejected submission'
|
|
);
|
|
|
|
select is(
|
|
(select count(*)::int from public.catalog_submissions
|
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'rejected'),
|
|
0,
|
|
'the rejected submission is gone after withdrawal'
|
|
);
|
|
|
|
select lives_ok(
|
|
$$ delete from public.catalog_submissions
|
|
where id = (
|
|
select id from public.catalog_submissions
|
|
where submitter_id = '22222222-2222-2222-2222-222222222222' and status = 'pending'
|
|
limit 1
|
|
) $$,
|
|
'bob can still withdraw a pending submission (unchanged behavior)'
|
|
);
|
|
|
|
-- ----------------------------------------------------------------------
|
|
-- 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'
|
|
);
|
|
|
|
-- ----------------------------------------------------------------------
|
|
-- Usage-impact function (organized-ideas.md §3's impact-check-before-editing:
|
|
-- an Admin can see the blast radius of a catalog edit without being able to
|
|
-- see the diagrams themselves).
|
|
-- ----------------------------------------------------------------------
|
|
|
|
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
|
|
|
|
select lives_ok(
|
|
$$ insert into public.diagrams (id, name, owner_id, data)
|
|
values ('b0000000-0000-0000-0000-000000000002', 'Alice''s Second Rig', '11111111-1111-1111-1111-111111111111',
|
|
'{"devices":[{"id":"d1","templateId":"pt-impact-test-device","category":"other","ports":[{"id":"p1","portTypeId":"pt-impact-test-port"}]}],"connections":[{"id":"c1","cableTypeId":"ct-impact-test-cable"}]}'::jsonb) $$,
|
|
'alice can insert a diagram referencing test catalog ids for the impact-check test'
|
|
);
|
|
|
|
-- bob, not alice, for this check: alice was promoted to admin by the role-
|
|
-- escalation test above, so she'd no longer be a useful "regular user" case.
|
|
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
|
|
|
select throws_ok(
|
|
$$ select * from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device') $$,
|
|
'42501'::char(5), null,
|
|
'bob (regular user) cannot call the usage-impact function'
|
|
);
|
|
|
|
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
|
|
|
select is(
|
|
(select diagram_count from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device')),
|
|
1,
|
|
'carol (admin) sees the correct impact count for a device template, despite having no direct visibility into that diagram'
|
|
);
|
|
|
|
select is(
|
|
(select sample -> 0 ->> 'ownerUsername' from public.catalog_entity_usage_impact('device_template', 'pt-impact-test-device')),
|
|
'alice',
|
|
'the impact sample identifies the diagram by name/owner username, not raw diagram content'
|
|
);
|
|
|
|
select is(
|
|
(select diagram_count from public.catalog_entity_usage_impact('port_type', 'pt-impact-test-port')),
|
|
1,
|
|
'carol (admin) sees the correct impact count for a port type'
|
|
);
|
|
|
|
select is(
|
|
(select diagram_count from public.catalog_entity_usage_impact('cable_type', 'ct-impact-test-cable')),
|
|
1,
|
|
'carol (admin) sees the correct impact count for a cable type'
|
|
);
|
|
|
|
select is(
|
|
(select diagram_count from public.catalog_entity_usage_impact('device_template', 'no-such-id')),
|
|
0,
|
|
'the impact count is zero for an entity id referenced by nothing'
|
|
);
|
|
|
|
-- ----------------------------------------------------------------------
|
|
-- Batched submitter-username lookup (gated the same way as the impact
|
|
-- function above — an Admin reviewing a submission can see who submitted
|
|
-- it, without a general ability to browse other users' profiles).
|
|
-- ----------------------------------------------------------------------
|
|
|
|
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
|
|
|
select throws_ok(
|
|
$$ select * from public.catalog_submission_submitters(
|
|
array(select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' limit 1)
|
|
) $$,
|
|
'42501'::char(5), null,
|
|
'bob (regular user) cannot look up submitter usernames'
|
|
);
|
|
|
|
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
|
|
|
select is(
|
|
(select username from public.catalog_submission_submitters(
|
|
array(select id from public.catalog_submissions where submitter_id = '22222222-2222-2222-2222-222222222222' limit 1)
|
|
)),
|
|
'bob',
|
|
'carol (admin) can look up the submitter''s username for a submission she can review'
|
|
);
|
|
|
|
-- ----------------------------------------------------------------------
|
|
-- Admin user listing (organized-ideas.md §6: "CRUD user accounts" is
|
|
-- Super-Admin-only — even a regular Admin gets 42501 here, unlike the
|
|
-- Admin-gated functions above).
|
|
-- ----------------------------------------------------------------------
|
|
|
|
select set_config('request.jwt.claim.sub', '22222222-2222-2222-2222-222222222222', true);
|
|
|
|
select throws_ok(
|
|
$$ select * from public.list_users_for_admin() $$,
|
|
'42501'::char(5), null,
|
|
'bob (regular user) cannot list users'
|
|
);
|
|
|
|
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
|
|
|
select throws_ok(
|
|
$$ select * from public.list_users_for_admin() $$,
|
|
'42501'::char(5), null,
|
|
'carol (admin, not super admin) cannot list users'
|
|
);
|
|
|
|
select set_config('request.jwt.claim.sub', '44444444-4444-4444-4444-444444444444', true);
|
|
|
|
select lives_ok(
|
|
$$ select * from public.list_users_for_admin() $$,
|
|
'dave (super admin) can list users'
|
|
);
|
|
|
|
select is(
|
|
(select role from public.list_users_for_admin() where username = 'alice'),
|
|
'admin',
|
|
'the listing reflects alice''s current role (promoted earlier in this test run)'
|
|
);
|
|
|
|
select is(
|
|
(select email from public.list_users_for_admin() where username = 'alice'),
|
|
'alice@example.com',
|
|
'the listing includes email, only readable via this Super-Admin-gated function (not directly through PostgREST)'
|
|
);
|
|
|
|
select * from finish();
|
|
|
|
rollback;
|