From 38f296abf03cbc513673a83471718e7465df179b Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 08:53:22 +0000 Subject: [PATCH 1/2] test(db): cover RLS on the nine tables that had no pgTAP tests shared_items, user_roles, admin_grants, api_keys, source_files, rate_limits, global_prompts, webhook_events and user_data_clear_markers all had row-level security enabled, but no test asserted their policies. The new tests check that: - a user cannot grant themselves admin, demote an admin, or lift their own ban; - shares can be read only through get_shared_item(code), and clients cannot read or write the table directly; - users can manage their own API keys and source files but cannot read, plant, move or delete anyone else's; - only admins read the grant log, users see only their own and global rate limits, and no client role can write grants, limits or prompts; - clients see no webhook events or clear markers and cannot write them. Denied UPDATE and DELETE statements must change zero rows, whether the table lacks the privilege or RLS filters the rows out. Co-Authored-By: Claude Opus 5.5 (1M context) --- supabase/tests/admin_tables_rls_test.sql | 160 ++++++++++++++++++ .../tests/owner_scoped_tables_rls_test.sql | 160 ++++++++++++++++++ .../tests/server_only_tables_rls_test.sql | 119 +++++++++++++ supabase/tests/shared_items_rls_test.sql | 121 +++++++++++++ supabase/tests/user_roles_rls_test.sql | 140 +++++++++++++++ 5 files changed, 700 insertions(+) create mode 100644 supabase/tests/admin_tables_rls_test.sql create mode 100644 supabase/tests/owner_scoped_tables_rls_test.sql create mode 100644 supabase/tests/server_only_tables_rls_test.sql create mode 100644 supabase/tests/shared_items_rls_test.sql create mode 100644 supabase/tests/user_roles_rls_test.sql 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..4531cc244 --- /dev/null +++ b/supabase/tests/server_only_tables_rls_test.sql @@ -0,0 +1,119 @@ +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' +); +SELECT ok( + has_table_privilege('service_role', 'public.webhook_events', 'SELECT,INSERT,UPDATE,DELETE') + AND has_table_privilege('service_role', 'public.user_data_clear_markers', 'SELECT,INSERT,UPDATE,DELETE'), + 'the service role keeps access to 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..0f07f4a3f --- /dev/null +++ b/supabase/tests/shared_items_rls_test.sql @@ -0,0 +1,121 @@ +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' +); +SELECT ok( + has_table_privilege('service_role', 'public.shared_items', 'SELECT,INSERT,UPDATE,DELETE'), + 'the share function can 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; From 7ceafbd535da8b8e76cedd4c9408730488bcf97a Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 09:13:49 +0000 Subject: [PATCH 2/2] test(db): check each service-role privilege separately has_table_privilege returns true when the role holds any privilege in a comma-separated list. The service-role checks for shared_items, webhook_events and user_data_clear_markers still passed after INSERT, UPDATE and DELETE were revoked, as long as SELECT remained. They now require every privilege. Revoking INSERT from service_role makes both checks fail. Co-Authored-By: Claude Opus 5.5 (1M context) --- supabase/tests/server_only_tables_rls_test.sql | 8 +++++--- supabase/tests/shared_items_rls_test.sql | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/supabase/tests/server_only_tables_rls_test.sql b/supabase/tests/server_only_tables_rls_test.sql index 4531cc244..39749dc26 100644 --- a/supabase/tests/server_only_tables_rls_test.sql +++ b/supabase/tests/server_only_tables_rls_test.sql @@ -35,10 +35,12 @@ SELECT ok( 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( - has_table_privilege('service_role', 'public.webhook_events', 'SELECT,INSERT,UPDATE,DELETE') - AND has_table_privilege('service_role', 'public.user_data_clear_markers', 'SELECT,INSERT,UPDATE,DELETE'), - 'the service role keeps access to server-only tables' + (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'); diff --git a/supabase/tests/shared_items_rls_test.sql b/supabase/tests/shared_items_rls_test.sql index 0f07f4a3f..499b8a8aa 100644 --- a/supabase/tests/shared_items_rls_test.sql +++ b/supabase/tests/shared_items_rls_test.sql @@ -21,9 +21,11 @@ SELECT ok( 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( - has_table_privilege('service_role', 'public.shared_items', 'SELECT,INSERT,UPDATE,DELETE'), - 'the share function can write shares with the service role' + (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 (