diff --git a/supabase/tests/admin_tables_rls_test.sql b/supabase/tests/admin_tables_rls_test.sql new file mode 100644 index 000000000..1c1f9588e --- /dev/null +++ b/supabase/tests/admin_tables_rls_test.sql @@ -0,0 +1,160 @@ +BEGIN; +SELECT plan(19); + +-- Returns how many rows a statement changed; a privilege or RLS rejection changes none. +CREATE FUNCTION pg_temp.rows_changed(statement text) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE changed bigint; +BEGIN + EXECUTE statement; + GET DIAGNOSTICS changed = ROW_COUNT; + RETURN changed; +EXCEPTION WHEN insufficient_privilege THEN + RETURN 0; +END; +$$; + +INSERT INTO auth.users (id) VALUES + ('b1111111-1111-4111-8111-111111111111'), + ('b2222222-2222-4222-8222-222222222222'), + ('b3333333-3333-4333-8333-333333333333'); +INSERT INTO public.user_roles (user_id, role) VALUES + ('b1111111-1111-4111-8111-111111111111', 'user'), + ('b2222222-2222-4222-8222-222222222222', 'admin'); +INSERT INTO public.admin_grants (admin_user_id, target_user_id, grant_type, starts_at, ends_at, notes) VALUES + ('b2222222-2222-4222-8222-222222222222', 'b1111111-1111-4111-8111-111111111111', 'pro', now(), now() + interval '30 days', 'pgtap grant'); +INSERT INTO public.rate_limits (user_id, limit_type, limit_value) VALUES + (NULL, 'pgtap_daily', 100), + ('b1111111-1111-4111-8111-111111111111', 'pgtap_daily', 50), + ('b3333333-3333-4333-8333-333333333333', 'pgtap_daily', 500); +INSERT INTO public.global_prompts (feature, prompt_text) +VALUES ('ask', 'pgtap prompt') +ON CONFLICT (feature) DO UPDATE SET prompt_text = EXCLUDED.prompt_text; + +-- A regular user who is the target of an admin grant. +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'b1111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT is_empty( + $$SELECT 1 FROM public.admin_grants$$, + 'a regular user cannot read the admin grant log, even grants made to them' +); +SELECT throws_ok( + $$INSERT INTO public.admin_grants (target_user_id, grant_type, starts_at, ends_at) + VALUES ('b1111111-1111-4111-8111-111111111111', 'pro', now(), now() + interval '1 year')$$, + '42501', + NULL, + 'a regular user cannot record a grant for themselves' +); +SELECT results_eq( + $$SELECT user_id, limit_value FROM public.rate_limits + WHERE limit_type = 'pgtap_daily' ORDER BY user_id NULLS FIRST$$, + $$VALUES (NULL::uuid, 100), ('b1111111-1111-4111-8111-111111111111'::uuid, 50)$$, + 'a user reads the global limit and their own, not other users'' limits' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.rate_limits SET limit_value = 1000000 + WHERE limit_type = 'pgtap_daily'$$), + 0::bigint, + 'a user cannot raise their own or the global rate limit' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.rate_limits WHERE limit_type = 'pgtap_daily'$$), + 0::bigint, + 'a user cannot delete rate limits' +); +SELECT throws_ok( + $$INSERT INTO public.rate_limits (user_id, limit_type, limit_value) + VALUES ('b1111111-1111-4111-8111-111111111111', 'pgtap_other', 1000000)$$, + '42501', + NULL, + 'a user cannot create a rate limit override' +); +SELECT throws_ok( + $$SELECT * FROM public.get_active_rate_limits()$$, + 'P0001', + 'administrator access required', + 'a regular user cannot list every user''s rate limits' +); +SELECT throws_ok( + $$INSERT INTO public.global_prompts (feature, prompt_text) VALUES ('attack', 'injected')$$, + '42501', + NULL, + 'a regular user cannot add a global prompt' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.global_prompts SET prompt_text = 'injected' WHERE feature = 'ask'$$), + 0::bigint, + 'a regular user cannot rewrite a global prompt' +); + +-- Administrators read the grant log, but every admin write goes through the +-- admin Edge Function with the service role. +SELECT set_config('request.jwt.claim.sub', 'b2222222-2222-4222-8222-222222222222', true); +SELECT results_eq( + $$SELECT target_user_id, notes FROM public.admin_grants$$, + $$VALUES ('b1111111-1111-4111-8111-111111111111'::uuid, 'pgtap grant'::text)$$, + 'an administrator reads the grant log' +); +SELECT throws_ok( + $$INSERT INTO public.admin_grants (target_user_id, grant_type, starts_at, ends_at) + VALUES ('b2222222-2222-4222-8222-222222222222', 'pro', now(), now() + interval '1 year')$$, + '42501', + NULL, + 'an administrator cannot write the grant log directly' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.admin_grants$$), + 0::bigint, + 'an administrator cannot erase the grant log directly' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.global_prompts SET prompt_text = 'direct' WHERE feature = 'ask'$$), + 0::bigint, + 'an administrator cannot rewrite a global prompt directly' +); +RESET ROLE; + +SET LOCAL ROLE anon; +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT set_config('request.jwt.claim.role', 'anon', true); +SELECT is_empty( + $$SELECT 1 FROM public.admin_grants$$, + 'anonymous callers cannot read the grant log' +); +SELECT is_empty( + $$SELECT 1 FROM public.rate_limits WHERE user_id IS NOT NULL$$, + 'anonymous callers cannot read per-user rate limits' +); +SELECT throws_ok( + $$SELECT * FROM public.get_active_rate_limits()$$, + 'P0001', + 'administrator access required', + 'anonymous callers cannot list rate limits through the admin RPC' +); +SELECT throws_ok( + $$INSERT INTO public.rate_limits (user_id, limit_type, limit_value) + VALUES (NULL, 'pgtap_other', 1000000)$$, + '42501', + NULL, + 'anonymous callers cannot create rate limits' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.global_prompts$$), + 0::bigint, + 'anonymous callers cannot delete global prompts' +); +RESET ROLE; + +SELECT results_eq( + $$SELECT + (SELECT count(*) FROM public.admin_grants WHERE notes = 'pgtap grant'), + (SELECT string_agg(limit_value::text, ',' ORDER BY limit_value) + FROM public.rate_limits WHERE limit_type = 'pgtap_daily'), + (SELECT prompt_text FROM public.global_prompts WHERE feature = 'ask')$$, + $$VALUES (1::bigint, '50,100,500'::text, 'pgtap prompt'::text)$$, + 'no client attempt changed grants, limits, or prompts' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/owner_scoped_tables_rls_test.sql b/supabase/tests/owner_scoped_tables_rls_test.sql new file mode 100644 index 000000000..a58747479 --- /dev/null +++ b/supabase/tests/owner_scoped_tables_rls_test.sql @@ -0,0 +1,160 @@ +BEGIN; +SELECT plan(19); + +-- Returns how many rows a statement changed; a privilege or RLS rejection changes none. +CREATE FUNCTION pg_temp.rows_changed(statement text) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE changed bigint; +BEGIN + EXECUTE statement; + GET DIAGNOSTICS changed = ROW_COUNT; + RETURN changed; +EXCEPTION WHEN insufficient_privilege THEN + RETURN 0; +END; +$$; + +INSERT INTO auth.users (id) VALUES + ('e1111111-1111-4111-8111-111111111111'), + ('e2222222-2222-4222-8222-222222222222'); +INSERT INTO public.api_keys (id, user_id, provider, key_ciphertext, key_last4) VALUES + ('e1a00000-0000-4000-8000-000000000001', 'e1111111-1111-4111-8111-111111111111', 'openai', 'ciphertext-one', '1111'), + ('e2a00000-0000-4000-8000-000000000002', 'e2222222-2222-4222-8222-222222222222', 'openai', 'ciphertext-two', '2222'); +INSERT INTO public.debates (id, user_id, motion) VALUES + ('e1d00000-0000-4000-8000-000000000001', 'e1111111-1111-4111-8111-111111111111', 'This House would ban homework'), + ('e2d00000-0000-4000-8000-000000000002', 'e2222222-2222-4222-8222-222222222222', 'This House would abolish zoos'); +INSERT INTO public.sources (id, user_id, debate_id, title, content, type) VALUES + ('e1500000-0000-4000-8000-000000000001', 'e1111111-1111-4111-8111-111111111111', 'e1d00000-0000-4000-8000-000000000001', 'One', 'Body', 'text'), + ('e2500000-0000-4000-8000-000000000002', 'e2222222-2222-4222-8222-222222222222', 'e2d00000-0000-4000-8000-000000000002', 'Two', 'Body', 'text'); +INSERT INTO public.source_files (id, user_id, source_id, filename, storage_key) VALUES + ('e1f00000-0000-4000-8000-000000000001', 'e1111111-1111-4111-8111-111111111111', 'e1500000-0000-4000-8000-000000000001', 'one.pdf', 'one/key'), + ('e2f00000-0000-4000-8000-000000000002', 'e2222222-2222-4222-8222-222222222222', 'e2500000-0000-4000-8000-000000000002', 'two.pdf', 'two/key'); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'e1111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); + +-- api_keys: each user manages only their own encrypted provider keys. +SELECT results_eq( + $$SELECT id FROM public.api_keys$$, + $$VALUES ('e1a00000-0000-4000-8000-000000000001'::uuid)$$, + 'a user reads only their own API keys' +); +SELECT lives_ok( + $$INSERT INTO public.api_keys (user_id, provider, key_ciphertext) + VALUES ('e1111111-1111-4111-8111-111111111111', 'anthropic', 'ciphertext-three')$$, + 'a user can store their own API key' +); +SELECT throws_ok( + $$INSERT INTO public.api_keys (user_id, provider, key_ciphertext) + VALUES ('e2222222-2222-4222-8222-222222222222', 'anthropic', 'planted')$$, + '42501', + NULL, + 'a user cannot store an API key on another account' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.api_keys SET key_ciphertext = 'replaced' + WHERE id = 'e2a00000-0000-4000-8000-000000000002'$$), + 0::bigint, + 'a user cannot replace another user''s API key' +); +SELECT throws_ok( + $$UPDATE public.api_keys SET user_id = 'e2222222-2222-4222-8222-222222222222' + WHERE id = 'e1a00000-0000-4000-8000-000000000001'$$, + '42501', + NULL, + 'a user cannot move their API key onto another account' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.api_keys WHERE id = 'e2a00000-0000-4000-8000-000000000002'$$), + 0::bigint, + 'a user cannot delete another user''s API key' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.api_keys WHERE id = 'e1a00000-0000-4000-8000-000000000001'$$), + 1::bigint, + 'a user can delete their own API key' +); + +-- source_files: each user manages only their own uploaded file records. +SELECT results_eq( + $$SELECT id FROM public.source_files$$, + $$VALUES ('e1f00000-0000-4000-8000-000000000001'::uuid)$$, + 'a user reads only their own source files' +); +SELECT lives_ok( + $$INSERT INTO public.source_files (user_id, source_id, filename, storage_key) + VALUES ('e1111111-1111-4111-8111-111111111111', 'e1500000-0000-4000-8000-000000000001', 'three.pdf', 'three/key')$$, + 'a user can record their own source file' +); +SELECT throws_ok( + $$INSERT INTO public.source_files (user_id, source_id, filename, storage_key) + VALUES ('e2222222-2222-4222-8222-222222222222', 'e2500000-0000-4000-8000-000000000002', 'planted.pdf', 'planted/key')$$, + '42501', + NULL, + 'a user cannot record a source file on another account' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.source_files SET storage_key = 'redirected/key' + WHERE id = 'e2f00000-0000-4000-8000-000000000002'$$), + 0::bigint, + 'a user cannot redirect another user''s stored file' +); +SELECT throws_ok( + $$UPDATE public.source_files SET user_id = 'e2222222-2222-4222-8222-222222222222' + WHERE id = 'e1f00000-0000-4000-8000-000000000001'$$, + '42501', + NULL, + 'a user cannot move their source file onto another account' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.source_files WHERE id = 'e2f00000-0000-4000-8000-000000000002'$$), + 0::bigint, + 'a user cannot delete another user''s source file' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.source_files SET filename = 'renamed.pdf' + WHERE id = 'e1f00000-0000-4000-8000-000000000001'$$), + 1::bigint, + 'a user can update their own source file' +); +RESET ROLE; + +SET LOCAL ROLE anon; +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT set_config('request.jwt.claim.role', 'anon', true); +SELECT is_empty( + $$SELECT 1 FROM public.api_keys$$, + 'anonymous callers see no API keys' +); +SELECT is_empty( + $$SELECT 1 FROM public.source_files$$, + 'anonymous callers see no source files' +); +SELECT throws_ok( + $$INSERT INTO public.api_keys (user_id, provider, key_ciphertext) + VALUES ('e2222222-2222-4222-8222-222222222222', 'anthropic', 'planted')$$, + '42501', + NULL, + 'anonymous callers cannot store API keys' +); +SELECT throws_ok( + $$INSERT INTO public.source_files (user_id, source_id, filename, storage_key) + VALUES ('e2222222-2222-4222-8222-222222222222', 'e2500000-0000-4000-8000-000000000002', 'planted.pdf', 'planted/key')$$, + '42501', + NULL, + 'anonymous callers cannot record source files' +); +RESET ROLE; + +SELECT results_eq( + $$SELECT a.key_ciphertext, f.storage_key + FROM public.api_keys a, public.source_files f + WHERE a.user_id = 'e2222222-2222-4222-8222-222222222222' + AND f.user_id = 'e2222222-2222-4222-8222-222222222222'$$, + $$VALUES ('ciphertext-two'::text, 'two/key'::text)$$, + 'the other user''s key and file are untouched' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/server_only_tables_rls_test.sql b/supabase/tests/server_only_tables_rls_test.sql new file mode 100644 index 000000000..39749dc26 --- /dev/null +++ b/supabase/tests/server_only_tables_rls_test.sql @@ -0,0 +1,121 @@ +BEGIN; +SELECT plan(12); + +-- Returns how many rows a statement changed; a privilege or RLS rejection changes none. +CREATE FUNCTION pg_temp.rows_changed(statement text) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE changed bigint; +BEGIN + EXECUTE statement; + GET DIAGNOSTICS changed = ROW_COUNT; + RETURN changed; +EXCEPTION WHEN insufficient_privilege THEN + RETURN 0; +END; +$$; + +-- Returns how many rows a query exposes; a privilege rejection exposes none. +CREATE FUNCTION pg_temp.visible_rows(query text) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE visible bigint; +BEGIN + EXECUTE format('SELECT count(*) FROM (%s) AS rows', query) INTO visible; + RETURN visible; +EXCEPTION WHEN insufficient_privilege THEN + RETURN 0; +END; +$$; + +-- Stripe webhook idempotency records and clear-data markers belong to backend +-- code only: the service role and SECURITY DEFINER functions. +SELECT ok( + NOT has_table_privilege('anon', 'public.webhook_events', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') + AND NOT has_table_privilege('authenticated', 'public.webhook_events', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') + AND NOT has_table_privilege('anon', 'public.user_data_clear_markers', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') + AND NOT has_table_privilege('authenticated', 'public.user_data_clear_markers', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE'), + 'clients hold no table privileges on server-only tables' +); +-- A privilege list passed to has_table_privilege means "any of", so check each one. +SELECT ok( + (SELECT bool_and(has_table_privilege('service_role', relation, privilege)) + FROM unnest(ARRAY['public.webhook_events', 'public.user_data_clear_markers']) AS relation, + unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE']) AS privilege), + 'the service role keeps every privilege on server-only tables' +); + +INSERT INTO auth.users (id) VALUES ('a1111111-1111-4111-8111-111111111111'); +INSERT INTO public.webhook_events (id, event_type) VALUES ('evt_pgtap', 'checkout.session.completed'); +INSERT INTO public.user_data_clear_markers (user_id, cleared_at) +VALUES ('a1111111-1111-4111-8111-111111111111', now()); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'a1111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT is( + pg_temp.visible_rows($$SELECT 1 FROM public.webhook_events$$), + 0::bigint, + 'signed-in callers see no webhook events' +); +SELECT is( + pg_temp.visible_rows($$SELECT 1 FROM public.user_data_clear_markers$$), + 0::bigint, + 'signed-in callers see no clear markers, including their own' +); +SELECT throws_ok( + $$INSERT INTO public.webhook_events (id, event_type) VALUES ('evt_pgtap_replay', 'checkout.session.completed')$$, + '42501', + NULL, + 'signed-in callers cannot pre-claim a webhook event' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.webhook_events WHERE id = 'evt_pgtap'$$), + 0::bigint, + 'signed-in callers cannot erase a processed webhook event' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.user_data_clear_markers SET cleared_at = now() - interval '1 year'$$), + 0::bigint, + 'signed-in callers cannot backdate their clear marker' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.user_data_clear_markers$$), + 0::bigint, + 'signed-in callers cannot delete their clear marker' +); +RESET ROLE; + +SET LOCAL ROLE anon; +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT set_config('request.jwt.claim.role', 'anon', true); +SELECT is( + pg_temp.visible_rows($$SELECT 1 FROM public.webhook_events$$) + + pg_temp.visible_rows($$SELECT 1 FROM public.user_data_clear_markers$$), + 0::bigint, + 'anonymous callers see no server-only rows' +); +SELECT throws_ok( + $$INSERT INTO public.webhook_events (id, event_type) VALUES ('evt_pgtap_anon', 'checkout.session.completed')$$, + '42501', + NULL, + 'anonymous callers cannot write webhook events' +); +SELECT throws_ok( + $$INSERT INTO public.user_data_clear_markers (user_id, cleared_at) + VALUES ('a1111111-1111-4111-8111-111111111111', now())$$, + '42501', + NULL, + 'anonymous callers cannot write clear markers' +); +RESET ROLE; + +SELECT results_eq( + $$SELECT + (SELECT count(*) FROM public.webhook_events WHERE id LIKE 'evt_pgtap%'), + (SELECT count(*) FROM public.user_data_clear_markers + WHERE user_id = 'a1111111-1111-4111-8111-111111111111' AND cleared_at > now() - interval '1 day')$$, + $$VALUES (1::bigint, 1::bigint)$$, + 'server-only rows are unchanged after every client attempt' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/shared_items_rls_test.sql b/supabase/tests/shared_items_rls_test.sql new file mode 100644 index 000000000..499b8a8aa --- /dev/null +++ b/supabase/tests/shared_items_rls_test.sql @@ -0,0 +1,123 @@ +BEGIN; +SELECT plan(14); + +-- Returns how many rows a statement changed; a privilege or RLS rejection changes none. +CREATE FUNCTION pg_temp.rows_changed(statement text) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE changed bigint; +BEGIN + EXECUTE statement; + GET DIAGNOSTICS changed = ROW_COUNT; + RETURN changed; +EXCEPTION WHEN insufficient_privilege THEN + RETURN 0; +END; +$$; + +-- Shares are capabilities: the share Edge Function writes them with the service +-- role, and clients read one item at a time through get_shared_item(code). +SELECT ok( + NOT has_table_privilege('anon', 'public.shared_items', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') + AND NOT has_table_privilege('authenticated', 'public.shared_items', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE'), + 'clients have no direct table access to shares' +); +-- A privilege list passed to has_table_privilege means "any of", so check each one. +SELECT ok( + (SELECT bool_and(has_table_privilege('service_role', 'public.shared_items', privilege)) + FROM unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE']) AS privilege), + 'the share function can read and write shares with the service role' +); +SELECT ok( + NOT EXISTS ( + SELECT 1 FROM pg_proc + WHERE oid = 'public.get_shared_item(text)'::regprocedure + AND 'created_by' = ANY (proargnames) + ), + 'the share lookup does not reveal who created a share' +); + +INSERT INTO auth.users (id) VALUES + ('d1111111-1111-4111-8111-111111111111'), + ('d2222222-2222-4222-8222-222222222222'); +INSERT INTO public.shared_items (share_code, item_type, item_data, title, created_by, expires_at) VALUES + ('pgtap-share-live', 'case', '{"body": "live"}', 'Live share', 'd1111111-1111-4111-8111-111111111111', NULL), + ('pgtap-share-expired', 'case', '{"body": "expired"}', 'Expired share', 'd1111111-1111-4111-8111-111111111111', now() - interval '1 hour'), + ('pgtap-share-other', 'case', '{"body": "other"}', 'Other share', 'd2222222-2222-4222-8222-222222222222', now() + interval '1 day'); + +SET LOCAL ROLE anon; +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT set_config('request.jwt.claim.role', 'anon', true); +SELECT throws_ok( + $$SELECT * FROM public.shared_items$$, + '42501', + NULL, + 'anonymous callers cannot list shares' +); +SELECT results_eq( + $$SELECT share_code, title, item_data, view_count FROM public.get_shared_item('pgtap-share-live')$$, + $$VALUES ('pgtap-share-live'::text, 'Live share'::text, '{"body": "live"}'::jsonb, 1)$$, + 'anonymous callers read exactly the share whose code they hold' +); +SELECT is_empty( + $$SELECT * FROM public.get_shared_item('pgtap-share-expired')$$, + 'expired shares are not returned' +); +SELECT is_empty( + $$SELECT * FROM public.get_shared_item('pgtap-share-%')$$, + 'share codes are matched exactly, not as patterns' +); +SELECT throws_ok( + $$INSERT INTO public.shared_items (share_code, item_type, item_data, title) + VALUES ('pgtap-share-anon', 'case', '{}', 'Anonymous share')$$, + '42501', + NULL, + 'anonymous callers cannot create shares directly' +); +RESET ROLE; + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'd1111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT * FROM public.shared_items WHERE created_by = 'd1111111-1111-4111-8111-111111111111'$$, + '42501', + NULL, + 'owners cannot list their shares directly' +); +SELECT results_eq( + $$SELECT share_code, view_count FROM public.get_shared_item('pgtap-share-other')$$, + $$VALUES ('pgtap-share-other'::text, 1)$$, + 'signed-in callers read another user''s share only by its code' +); +SELECT throws_ok( + $$INSERT INTO public.shared_items (share_code, item_type, item_data, title, created_by) + VALUES ('pgtap-share-direct', 'case', '{}', 'Direct share', 'd1111111-1111-4111-8111-111111111111')$$, + '42501', + NULL, + 'owners cannot create shares directly' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.shared_items SET expires_at = NULL, view_count = 0 + WHERE share_code = 'pgtap-share-expired'$$), + 0::bigint, + 'owners cannot revive or reset their shares directly' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.shared_items WHERE share_code = 'pgtap-share-other'$$), + 0::bigint, + 'signed-in callers cannot delete shares directly' +); +RESET ROLE; + +SELECT results_eq( + $$SELECT share_code, view_count, expires_at IS NULL FROM public.shared_items + WHERE share_code LIKE 'pgtap-share-%' ORDER BY share_code$$, + $$VALUES + ('pgtap-share-expired'::text, 0, false), + ('pgtap-share-live'::text, 1, true), + ('pgtap-share-other'::text, 1, false)$$, + 'only code lookups changed shares, and only their view counts' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/user_roles_rls_test.sql b/supabase/tests/user_roles_rls_test.sql new file mode 100644 index 000000000..9c8bb26b4 --- /dev/null +++ b/supabase/tests/user_roles_rls_test.sql @@ -0,0 +1,140 @@ +BEGIN; +SELECT plan(14); + +-- Returns how many rows a statement changed; a privilege or RLS rejection changes none. +CREATE FUNCTION pg_temp.rows_changed(statement text) +RETURNS bigint LANGUAGE plpgsql AS $$ +DECLARE changed bigint; +BEGIN + EXECUTE statement; + GET DIAGNOSTICS changed = ROW_COUNT; + RETURN changed; +EXCEPTION WHEN insufficient_privilege THEN + RETURN 0; +END; +$$; + +INSERT INTO auth.users (id) VALUES + ('c1111111-1111-4111-8111-111111111111'), + ('c2222222-2222-4222-8222-222222222222'), + ('c3333333-3333-4333-8333-333333333333'), + ('c4444444-4444-4444-8444-444444444444'); +INSERT INTO public.user_roles (user_id, role) VALUES + ('c1111111-1111-4111-8111-111111111111', 'user'), + ('c2222222-2222-4222-8222-222222222222', 'admin'), + ('c4444444-4444-4444-8444-444444444444', 'banned'); + +-- A regular user. +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'c1111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT user_id, role FROM public.user_roles$$, + $$VALUES ('c1111111-1111-4111-8111-111111111111'::uuid, 'user'::text)$$, + 'a user reads only their own role' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.user_roles SET role = 'admin' + WHERE user_id = 'c1111111-1111-4111-8111-111111111111'$$), + 0::bigint, + 'a user cannot promote their own role to admin' +); +SELECT throws_ok( + $$INSERT INTO public.user_roles (user_id, role) + VALUES ('c1111111-1111-4111-8111-111111111111', 'admin') + ON CONFLICT (user_id) DO UPDATE SET role = 'admin'$$, + '42501', + NULL, + 'a user cannot upsert themselves into admin' +); +SELECT throws_ok( + $$INSERT INTO public.user_roles (user_id, role) + VALUES ('c3333333-3333-4333-8333-333333333333', 'admin')$$, + '42501', + NULL, + 'a user cannot grant admin to another account' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.user_roles SET role = 'user' + WHERE user_id = 'c2222222-2222-4222-8222-222222222222'$$), + 0::bigint, + 'a user cannot demote an administrator' +); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.user_roles + WHERE user_id = 'c2222222-2222-4222-8222-222222222222'$$), + 0::bigint, + 'a user cannot delete an administrator role' +); +SELECT throws_ok( + $$SELECT * FROM public.get_active_rate_limits()$$, + 'P0001', + 'administrator access required', + 'admin-only RPCs still reject the user after the escalation attempts' +); + +-- A user with no role row yet. +SELECT set_config('request.jwt.claim.sub', 'c3333333-3333-4333-8333-333333333333', true); +SELECT throws_ok( + $$INSERT INTO public.user_roles (user_id, role) + VALUES ('c3333333-3333-4333-8333-333333333333', 'admin')$$, + '42501', + NULL, + 'a user without a role row cannot create an admin role for themselves' +); + +-- A banned user. +SELECT set_config('request.jwt.claim.sub', 'c4444444-4444-4444-8444-444444444444', true); +SELECT is( + pg_temp.rows_changed($$DELETE FROM public.user_roles + WHERE user_id = 'c4444444-4444-4444-8444-444444444444'$$), + 0::bigint, + 'a banned user cannot delete their ban' +); +SELECT is( + pg_temp.rows_changed($$UPDATE public.user_roles SET role = 'user' + WHERE user_id = 'c4444444-4444-4444-8444-444444444444'$$), + 0::bigint, + 'a banned user cannot lift their ban' +); + +-- An administrator is still read-limited to their own row through the Data API. +SELECT set_config('request.jwt.claim.sub', 'c2222222-2222-4222-8222-222222222222', true); +SELECT results_eq( + $$SELECT user_id FROM public.user_roles$$, + $$VALUES ('c2222222-2222-4222-8222-222222222222'::uuid)$$, + 'an administrator reads other roles only through the admin function' +); +RESET ROLE; + +SET LOCAL ROLE anon; +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT set_config('request.jwt.claim.role', 'anon', true); +SELECT is_empty( + $$SELECT 1 FROM public.user_roles$$, + 'anonymous callers see no roles' +); +SELECT throws_ok( + $$INSERT INTO public.user_roles (user_id, role) + VALUES ('c3333333-3333-4333-8333-333333333333', 'admin')$$, + '42501', + NULL, + 'anonymous callers cannot create roles' +); +RESET ROLE; + +SELECT results_eq( + $$SELECT user_id, role FROM public.user_roles + WHERE user_id IN ( + 'c1111111-1111-4111-8111-111111111111', 'c2222222-2222-4222-8222-222222222222', + 'c3333333-3333-4333-8333-333333333333', 'c4444444-4444-4444-8444-444444444444' + ) ORDER BY user_id$$, + $$VALUES + ('c1111111-1111-4111-8111-111111111111'::uuid, 'user'::text), + ('c2222222-2222-4222-8222-222222222222'::uuid, 'admin'::text), + ('c4444444-4444-4444-8444-444444444444'::uuid, 'banned'::text)$$, + 'no client attempt changed any role' +); + +SELECT * FROM finish(); +ROLLBACK;