\set ON_ERROR_STOP on -- Function execution ACL policy (see migrations/20260929000001_function_acl_hardening.sql). -- -- * Every SECURITY DEFINER function in schema public pins search_path. -- * anon cannot execute any SECURITY DEFINER function in schema public. -- * SECURITY DEFINER trigger functions are not executable by authenticated. -- * authenticated can execute a SECURITY DEFINER function only if it is on -- the reviewed allowlist below. Adding an authenticated RPC means adding -- it here on purpose; a forgotten REVOKE fails this test. -- * increment_daily_usage and the admin analytics RPCs are service_role only, -- and daily_usage.count can never go negative. -- -- 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; $$; -- Reviewed allowlist: SECURITY DEFINER functions that signed-in users may call -- directly. Each one must derive the caller from auth.uid() (or enforce its own -- role check) inside the body. CREATE TEMP TABLE authenticated_definer_allowlist (proname text PRIMARY KEY) ON COMMIT DROP; INSERT INTO authenticated_definer_allowlist (proname) VALUES ('accept_team_invite'), ('admin_act_on_content_report_v1'), ('admin_list_content_reports_v1'), ('bootstrap_custom_instructions'), ('bootstrap_user_templates_v1'), ('cancel_team_invite'), ('create_team'), ('create_team_activity'), ('create_team_invite'), ('create_user_template_v1'), ('delete_user_template_v1'), ('export_account_portability'), ('list_team_invites'), ('list_team_members'), ('mobile_add_memo_tag_v1'), ('mobile_begin_meeting_processing'), ('mobile_begin_meeting_recording'), ('mobile_cancel_meeting_recording'), ('mobile_complete_meeting_processing'), ('mobile_create_meeting_workspace_v2'), ('mobile_fail_meeting_recording'), ('mobile_list_memo_tags_v1'), ('mobile_mark_meeting_processing_failure'), ('mobile_queue_meeting_recording'), ('mobile_remove_memo_tag_v1'), ('mobile_rename_memo_tag_v1'), ('mobile_search_memos_v1'), ('purge_revoked_device'), ('register_push_registration'), ('remove_team_member'), ('reorder_custom_instruction'), ('reserve_push_dispatch'), ('resolve_team_invite_recipient'), ('restore_account_portability'), ('revoke_device'), ('select_user_template_v1'), ('set_active_custom_instruction'), ('sync_delete_user_template_v1'), ('sync_set_builtin_instruction_prompt_v1'), ('sync_upsert_user_template_v1'), ('unregister_current_device'), ('unregister_push_registration'), ('update_team_member_role'), ('update_user_template_v1'), ('user_admin_team_ids'), ('user_team_ids'); -- Offender queries, shared by the detector self-check and the real checks. CREATE OR REPLACE FUNCTION pg_temp.definer_functions_executable_by(p_role text) RETURNS text LANGUAGE sql STABLE AS $$ SELECT string_agg(p.oid::regprocedure::text, ', ' ORDER BY p.oid::regprocedure::text) FROM pg_proc p WHERE p.pronamespace = 'public'::regnamespace AND p.prosecdef AND has_function_privilege(p_role, p.oid, 'EXECUTE'); $$; -- --------------------------------------------------------------------------- -- 0. Detector self-check: a new SECURITY DEFINER function without an explicit -- REVOKE is callable by anon under the platform defaults, and the offender -- query must report it. This proves a forgotten REVOKE is caught. -- --------------------------------------------------------------------------- CREATE FUNCTION public.zz_function_acl_probe_v1() RETURNS integer LANGUAGE sql SECURITY DEFINER SET search_path = '' AS 'SELECT 1'; SELECT pg_temp.assert_true( position('zz_function_acl_probe_v1()' IN coalesce(pg_temp.definer_functions_executable_by('anon'), '')) > 0, 'offender query must detect a SECURITY DEFINER function missing its REVOKE' ); DROP FUNCTION public.zz_function_acl_probe_v1(); -- --------------------------------------------------------------------------- -- 1. Catalog policy. -- --------------------------------------------------------------------------- DO $$ DECLARE offenders text; BEGIN SELECT string_agg(p.oid::regprocedure::text, ', ' ORDER BY p.oid::regprocedure::text) INTO offenders FROM pg_proc p WHERE p.pronamespace = 'public'::regnamespace AND p.prosecdef AND NOT EXISTS ( SELECT 1 FROM unnest(coalesce(p.proconfig, ARRAY[]::text[])) AS cfg(setting) WHERE cfg.setting LIKE 'search_path=%' ); PERFORM pg_temp.assert_true( offenders IS NULL, 'SECURITY DEFINER functions without a pinned search_path: ' || coalesce(offenders, '') ); END; $$; DO $$ DECLARE offenders text := pg_temp.definer_functions_executable_by('anon'); BEGIN PERFORM pg_temp.assert_true( offenders IS NULL, 'anon can execute SECURITY DEFINER functions: ' || coalesce(offenders, '') ); END; $$; DO $$ DECLARE offenders text; BEGIN SELECT string_agg(p.oid::regprocedure::text, ', ' ORDER BY p.oid::regprocedure::text) INTO offenders FROM pg_proc p WHERE p.pronamespace = 'public'::regnamespace AND p.prosecdef AND p.prorettype = 'trigger'::regtype AND has_function_privilege('authenticated', p.oid, 'EXECUTE'); PERFORM pg_temp.assert_true( offenders IS NULL, 'authenticated can execute SECURITY DEFINER trigger functions: ' || coalesce(offenders, '') ); END; $$; DO $$ DECLARE offenders text; BEGIN SELECT string_agg(p.oid::regprocedure::text, ', ' ORDER BY p.oid::regprocedure::text) INTO offenders FROM pg_proc p WHERE p.pronamespace = 'public'::regnamespace AND p.prosecdef AND has_function_privilege('authenticated', p.oid, 'EXECUTE') AND NOT EXISTS ( SELECT 1 FROM authenticated_definer_allowlist a WHERE a.proname = p.proname ); PERFORM pg_temp.assert_true( offenders IS NULL, 'authenticated can execute SECURITY DEFINER functions outside the reviewed allowlist: ' || coalesce(offenders, '') ); END; $$; -- --------------------------------------------------------------------------- -- 2. Service-role-only functions. -- --------------------------------------------------------------------------- DO $$ DECLARE fn text; BEGIN FOREACH fn IN ARRAY ARRAY[ 'public.increment_daily_usage(uuid,text,integer)', 'public.admin_usage_by_feature(date,date)', 'public.admin_top_users(date,date,integer)', 'public.admin_dau(date,date)' ] LOOP PERFORM pg_temp.assert_true( NOT has_function_privilege('anon', fn, 'EXECUTE'), 'anon must not execute ' || fn ); PERFORM pg_temp.assert_true( NOT has_function_privilege('authenticated', fn, 'EXECUTE'), 'authenticated must not execute ' || fn ); PERFORM pg_temp.assert_true( has_function_privilege('service_role', fn, 'EXECUTE'), 'service_role must execute ' || fn ); END LOOP; END; $$; -- --------------------------------------------------------------------------- -- 3. daily_usage counter integrity. -- --------------------------------------------------------------------------- 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 ) VALUES ( 'a1000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', 'function-acl-one@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now() ); SELECT pg_temp.assert_true( EXISTS ( SELECT 1 FROM pg_constraint WHERE conrelid = 'public.daily_usage'::regclass AND conname = 'daily_usage_count_nonnegative' AND convalidated ), 'daily_usage.count has a validated non-negative CHECK' ); SELECT pg_temp.assert_true( public.increment_daily_usage('a1000000-0000-4000-8000-000000000001', 'acl_probe', 5) = 5, 'service path increments the counter' ); SELECT pg_temp.assert_true( public.increment_daily_usage('a1000000-0000-4000-8000-000000000001', 'acl_probe', -2) = 3, 'service path refunds with a negative amount' ); SELECT pg_temp.assert_true( public.increment_daily_usage('a1000000-0000-4000-8000-000000000001', 'acl_probe', -1000000) = 0, 'a refund larger than the counter clamps at zero' ); SELECT pg_temp.assert_true( public.increment_daily_usage('a1000000-0000-4000-8000-000000000001', 'acl_probe_new', -7) = 0, 'a negative first write stores zero' ); DO $$ BEGIN PERFORM public.increment_daily_usage('a1000000-0000-4000-8000-000000000001', 'acl_probe', NULL); RAISE EXCEPTION 'assertion_failed: NULL amount was accepted'; EXCEPTION WHEN null_value_not_allowed THEN NULL; END; $$; DO $$ BEGIN UPDATE public.daily_usage SET count = -1 WHERE user_id = 'a1000000-0000-4000-8000-000000000001' AND feature = 'acl_probe'; RAISE EXCEPTION 'assertion_failed: negative daily_usage.count was stored'; EXCEPTION WHEN check_violation THEN NULL; END; $$; -- Call-time rejection for anon/authenticated is what PostgREST enforces via -- the EXECUTE privilege asserted in section 2. It is intentionally not probed -- with SET ROLE + a PL/pgSQL EXCEPTION handler here: on the local Supabase -- image that combination segfaults the backend and restarts the database. ROLLBACK;