diff --git a/supabase/README.md b/supabase/README.md index 00e4f597a..d35c3a832 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -97,6 +97,7 @@ Do not use wildcard origins to hide a failed browser test. Before protected stag ## Migrations and database tests Migration filenames define execution order. Never edit an applied migration to change production history; add a new forward migration. +When creating a table in `public`, grant only the Data API roles and operations it needs in the same migration, alongside RLS and its policies. Fresh projects and database resets cannot rely on automatic table or sequence grants. Local gate: diff --git a/supabase/migrations/20260923191320_explicit_legacy_table_grants.sql b/supabase/migrations/20260923191320_explicit_legacy_table_grants.sql new file mode 100644 index 000000000..a8ef35aa3 --- /dev/null +++ b/supabase/migrations/20260923191320_explicit_legacy_table_grants.sql @@ -0,0 +1,31 @@ +-- Older table-creation migrations relied on Supabase's automatic public grants. +-- Make their final Data API permissions explicit for new projects and db reset. + +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + public.user_settings, + public.cases, public.attacks, public.brainstorms, public.factoids, + public.chats, public.messages, public.sources, public.source_files, + public.ai_requests, public.api_keys, + public.user_roles, public.rate_limits, public.global_prompts, + public.flowcharts, + public.subscriptions, public.promo_codes, public.user_promos, + public.admin_grants, public.shared_items, public.source_folders, + public.daily_usage_counters +TO service_role; + +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + public.cases, public.attacks, public.brainstorms, public.factoids, + public.chats, public.messages, public.sources, public.source_files, + public.api_keys, public.flowcharts, public.source_folders +TO authenticated; + +GRANT SELECT ON TABLE + public.user_roles, public.rate_limits, public.subscriptions, + public.user_promos, public.admin_grants, public.ai_requests +TO authenticated; + +-- Settings keep their existing SELECT and column-limited UPDATE grants; +-- shared items and global prompts remain server-owned. + +-- The existing anonymous keepalive insert uses this sequence's nextval(). +GRANT USAGE ON SEQUENCE public.keepalive_events_id_seq TO anon, service_role; diff --git a/supabase/tests/legacy_table_grants_test.sql b/supabase/tests/legacy_table_grants_test.sql new file mode 100644 index 000000000..ce0f81fa2 --- /dev/null +++ b/supabase/tests/legacy_table_grants_test.sql @@ -0,0 +1,54 @@ +BEGIN; +SELECT plan(6); + +SELECT ok( + has_table_privilege('authenticated', 'public.flowcharts', 'SELECT') + AND has_table_privilege('authenticated', 'public.flowcharts', 'INSERT') + AND has_table_privilege('authenticated', 'public.flowcharts', 'UPDATE') + AND has_table_privilege('authenticated', 'public.flowcharts', 'DELETE') + AND has_table_privilege('authenticated', 'public.source_folders', 'SELECT') + AND has_table_privilege('authenticated', 'public.source_folders', 'INSERT'), + 'client-owned legacy tables remain writable after a fresh migration replay' +); +SELECT ok( + has_table_privilege('service_role', 'public.promo_codes', 'SELECT') + AND has_table_privilege('service_role', 'public.promo_codes', 'INSERT') + AND has_table_privilege('service_role', 'public.promo_codes', 'UPDATE') + AND has_table_privilege('service_role', 'public.daily_usage_counters', 'INSERT') + AND has_table_privilege('service_role', 'public.daily_usage_counters', 'UPDATE'), + 'server-owned legacy tables remain reachable to the service role' +); +SELECT ok( + has_table_privilege('authenticated', 'public.user_settings', 'SELECT') + AND has_column_privilege('authenticated', 'public.user_settings', 'language', 'UPDATE') + AND NOT has_table_privilege('authenticated', 'public.user_settings', 'INSERT,UPDATE,DELETE'), + 'settings keep their column-limited client permissions' +); +SELECT ok( + has_table_privilege('authenticated', 'public.subscriptions', 'SELECT') + AND has_table_privilege('authenticated', 'public.user_promos', 'SELECT') + AND has_table_privilege('authenticated', 'public.ai_requests', 'SELECT'), + 'subscription status, redeemed promos, and usage remain client-readable' +); +SELECT ok( + has_sequence_privilege('anon', 'public.keepalive_events_id_seq', 'USAGE'), + 'anonymous keepalive inserts can generate an event id' +); + +INSERT INTO auth.users (id) VALUES + ('f1111111-1111-4111-8111-111111111111'), + ('f2222222-2222-4222-8222-222222222222'); +INSERT INTO public.ai_requests (user_id, feature, provider, model) VALUES + ('f1111111-1111-4111-8111-111111111111', 'ask', 'test', 'test'), + ('f2222222-2222-4222-8222-222222222222', 'ask', 'test', 'test'); +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', 'f1111111-1111-4111-8111-111111111111', true); +SELECT is( + (SELECT count(*) FROM public.ai_requests), + 1::bigint, + 'signed-in usage reads see only their own requests' +); +RESET ROLE; + +SELECT * FROM finish(); +ROLLBACK;