fix(db): lock increment_daily_usage and admin analytics RPCs to service_role

This commit is contained in:
Yun Chan 2026-09-28 02:16:21 +09:00
parent e69fe0335d
commit 63bc0ebe69
2 changed files with 483 additions and 0 deletions

View file

@ -0,0 +1,283 @@
\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;