\set ON_ERROR_STOP on -- Regression: team management RPCs must reject callers that hold no membership -- row in the target team (see migrations/20260929010000_team_rpc_null_role_guard.sql). -- -- Before that migration each RPC read the caller role with -- SELECT role INTO caller_role FROM team_members WHERE ... user_id = auth.uid() -- and compared it with `<>` / `NOT IN`. For a non-member caller_role is NULL, -- every guard evaluated to NULL, plpgsql skipped the RAISE, and any signed-in -- user who knew a team id could mint an admin invite for a second account, -- change member roles, remove members and cancel invites. -- -- Local only: psql against the local Supabase stack. Runs in a transaction and -- rolls back. BEGIN; CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) RETURNS void LANGUAGE plpgsql AS $$ BEGIN IF condition IS NOT TRUE THEN RAISE EXCEPTION 'assertion_failed: %', message; END IF; END; $$; CREATE OR REPLACE FUNCTION pg_temp.act_as(user_id uuid) RETURNS void LANGUAGE sql AS $$ SELECT set_config( 'request.jwt.claims', pg_catalog.json_build_object('sub', user_id, 'role', 'authenticated')::text, true ); $$; -- Runs `statement` and requires it to fail with SQLSTATE 42501 and the given -- error message. Anything else (success, another error) fails the test. CREATE OR REPLACE FUNCTION pg_temp.expect_forbidden( statement text, expected_message text, label text ) RETURNS void LANGUAGE plpgsql AS $$ BEGIN BEGIN EXECUTE statement; EXCEPTION WHEN insufficient_privilege THEN IF SQLERRM <> expected_message THEN RAISE EXCEPTION 'assertion_failed: % raised % instead of %', label, SQLERRM, expected_message; END IF; RETURN; END; RAISE EXCEPTION 'assertion_failed: % was allowed', label; END; $$; -- --------------------------------------------------------------------------- -- 1. Pure role policy: a missing role (NULL) never satisfies any requirement. -- --------------------------------------------------------------------------- SELECT pg_temp.assert_true( public.team_role_at_least_v1(NULL, 'member') IS FALSE, 'no membership does not satisfy member' ); SELECT pg_temp.assert_true( public.team_role_at_least_v1(NULL, 'owner') IS FALSE, 'no membership does not satisfy owner' ); SELECT pg_temp.assert_true( public.team_role_at_least_v1('owner', NULL) IS FALSE, 'an unknown requirement is never satisfied' ); SELECT pg_temp.assert_true( public.team_role_at_least_v1('superuser', 'member') IS FALSE, 'an unknown role does not satisfy member' ); SELECT pg_temp.assert_true( public.team_role_at_least_v1('owner', 'admin') AND public.team_role_at_least_v1('owner', 'owner') AND public.team_role_at_least_v1('admin', 'admin') AND public.team_role_at_least_v1('admin', 'member') AND public.team_role_at_least_v1('member', 'member'), 'role hierarchy owner > admin > member is honoured' ); SELECT pg_temp.assert_true( NOT public.team_role_at_least_v1('admin', 'owner') AND NOT public.team_role_at_least_v1('member', 'admin'), 'lower roles do not satisfy higher requirements' ); SELECT pg_temp.assert_true( NOT has_function_privilege('authenticated', 'public.require_team_role_v1(uuid, text)', 'EXECUTE') AND NOT has_function_privilege('anon', 'public.require_team_role_v1(uuid, text)', 'EXECUTE'), 'role guard helper is internal to the team RPCs' ); -- --------------------------------------------------------------------------- -- 2. Fixtures: team T with owner O, admin A, member M; outsider X with a second -- account X2; a pending invite for I. -- --------------------------------------------------------------------------- INSERT INTO auth.users ( id, aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data, created_at, updated_at ) SELECT fixture.id, 'authenticated', 'authenticated', fixture.email, crypt('fixture-password', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() FROM (VALUES ('43100000-0000-4000-8000-000000000001'::uuid, 'null-guard-owner@example.invalid'), ('43100000-0000-4000-8000-000000000002'::uuid, 'null-guard-admin@example.invalid'), ('43100000-0000-4000-8000-000000000003'::uuid, 'null-guard-member@example.invalid'), ('43100000-0000-4000-8000-000000000004'::uuid, 'null-guard-outsider@example.invalid'), ('43100000-0000-4000-8000-000000000005'::uuid, 'null-guard-outsider-2@example.invalid'), ('43100000-0000-4000-8000-000000000006'::uuid, 'null-guard-invitee@example.invalid') ) AS fixture(id, email); SET LOCAL ROLE authenticated; SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000001'); SELECT (public.create_team('Null Role Guard Team')->>'id')::uuid AS team_id \gset SELECT set_config('test.team_id', :'team_id', true); SELECT public.create_team_invite(:'team_id', 'null-guard-admin@example.invalid', 'admin') ->>'token' AS admin_token \gset SELECT public.create_team_invite(:'team_id', 'null-guard-member@example.invalid', 'member') ->>'token' AS member_token \gset SELECT (public.create_team_invite(:'team_id', 'null-guard-invitee@example.invalid', 'member') ->>'id')::uuid AS pending_invite_id \gset SELECT set_config('test.pending_invite_id', :'pending_invite_id', true); SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000002'); SELECT public.accept_team_invite(:'admin_token'); SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000003'); SELECT public.accept_team_invite(:'member_token'); -- --------------------------------------------------------------------------- -- 3. Non-member X: every management RPC is refused. -- --------------------------------------------------------------------------- SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000004'); SELECT pg_temp.expect_forbidden( format( 'SELECT public.create_team_invite(%L::uuid, %L, %L)', :'team_id', 'null-guard-outsider-2@example.invalid', 'admin' ), 'team_admin_required', 'non-member minting an admin invite' ); SELECT pg_temp.expect_forbidden( format( 'SELECT public.create_team_invite(%L::uuid, %L, %L)', :'team_id', 'null-guard-outsider-2@example.invalid', 'member' ), 'team_admin_required', 'non-member minting a member invite' ); SELECT pg_temp.expect_forbidden( format( 'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)', :'team_id', '43100000-0000-4000-8000-000000000003', 'admin' ), 'team_owner_required', 'non-member promoting a member' ); SELECT pg_temp.expect_forbidden( format( 'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)', :'team_id', '43100000-0000-4000-8000-000000000002', 'member' ), 'team_owner_required', 'non-member demoting an admin' ); SELECT pg_temp.expect_forbidden( format( 'SELECT public.remove_team_member(%L::uuid, %L::uuid)', :'team_id', '43100000-0000-4000-8000-000000000003' ), 'team_admin_required', 'non-member removing a member' ); SELECT pg_temp.expect_forbidden( format( 'SELECT public.remove_team_member(%L::uuid, %L::uuid)', :'team_id', '43100000-0000-4000-8000-000000000002' ), 'team_admin_required', 'non-member removing an admin' ); SELECT pg_temp.expect_forbidden( format( 'SELECT public.remove_team_member(%L::uuid, %L::uuid)', :'team_id', '43100000-0000-4000-8000-000000000099' ), 'team_admin_required', 'non-member probing membership of an unknown user' ); SELECT pg_temp.expect_forbidden( format('SELECT public.cancel_team_invite(%L::uuid)', :'pending_invite_id'), 'team_admin_required', 'non-member cancelling a pending invite' ); -- The second attacker account has nothing to accept. SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000005'); SELECT pg_temp.assert_true( (SELECT count(*) FROM public.list_team_members(:'team_id')) = 0, 'second outsider account sees no team members' ); -- --------------------------------------------------------------------------- -- 4. Existing role rules still hold for members. -- --------------------------------------------------------------------------- SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000003'); SELECT pg_temp.expect_forbidden( format( 'SELECT public.create_team_invite(%L::uuid, %L, %L)', :'team_id', 'null-guard-outsider-2@example.invalid', 'member' ), 'team_admin_required', 'regular member inviting' ); SELECT pg_temp.expect_forbidden( format( 'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)', :'team_id', '43100000-0000-4000-8000-000000000002', 'member' ), 'team_owner_required', 'regular member demoting an admin' ); SELECT pg_temp.expect_forbidden( format( 'SELECT public.remove_team_member(%L::uuid, %L::uuid)', :'team_id', '43100000-0000-4000-8000-000000000002' ), 'team_admin_required', 'regular member removing an admin' ); SELECT pg_temp.expect_forbidden( format('SELECT public.cancel_team_invite(%L::uuid)', :'pending_invite_id'), 'team_admin_required', 'regular member cancelling another user''s invite' ); SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000002'); SELECT pg_temp.expect_forbidden( format( 'SELECT public.create_team_invite(%L::uuid, %L, %L)', :'team_id', 'null-guard-outsider-2@example.invalid', 'admin' ), 'owner_required_for_admin_invite', 'admin minting another admin invite' ); SELECT pg_temp.expect_forbidden( format( 'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)', :'team_id', '43100000-0000-4000-8000-000000000003', 'admin' ), 'team_owner_required', 'admin changing roles' ); SELECT pg_temp.assert_true( (public.create_team_invite(:'team_id', 'null-guard-outsider-2@example.invalid', 'member') ->>'role') = 'member', 'admin can still invite a member' ); SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000001'); SELECT pg_temp.assert_true( public.update_team_member_role( :'team_id', '43100000-0000-4000-8000-000000000003', 'admin' )->>'role' = 'admin' AND public.update_team_member_role( :'team_id', '43100000-0000-4000-8000-000000000003', 'member' )->>'role' = 'member', 'owner can still change roles' ); SELECT pg_temp.assert_true( (public.cancel_team_invite(:'pending_invite_id')->>'cancelled')::boolean, 'owner can still cancel an invite' ); -- A member can still leave on their own; leaving twice reports not found. SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000003'); SELECT pg_temp.assert_true( (public.remove_team_member(:'team_id', '43100000-0000-4000-8000-000000000003') ->>'removed')::boolean, 'member can leave the team' ); DO $$ BEGIN PERFORM public.remove_team_member( current_setting('test.team_id')::uuid, '43100000-0000-4000-8000-000000000003' ); RAISE EXCEPTION 'assertion_failed: leaving twice succeeded'; EXCEPTION WHEN no_data_found THEN NULL; END; $$; RESET ROLE; -- --------------------------------------------------------------------------- -- 5. State: the outsider changed nothing. -- --------------------------------------------------------------------------- SELECT pg_temp.assert_true( NOT EXISTS ( SELECT 1 FROM public.team_invites WHERE invited_by = '43100000-0000-4000-8000-000000000004' ), 'outsider created no invite' ); SELECT pg_temp.assert_true( (SELECT role FROM public.team_members WHERE team_id = :'team_id' AND user_id = '43100000-0000-4000-8000-000000000002') = 'admin', 'admin role is unchanged' ); SELECT pg_temp.assert_true( NOT EXISTS ( SELECT 1 FROM public.team_members WHERE team_id = :'team_id' AND user_id IN ( '43100000-0000-4000-8000-000000000004', '43100000-0000-4000-8000-000000000005' ) ), 'neither outsider account joined the team' ); ROLLBACK;