diff --git a/supabase/migrations/20260427105817_restrict_is_paying_and_good_plan_org_action_access.sql b/supabase/migrations/20260427105817_restrict_is_paying_and_good_plan_org_action_access.sql new file mode 100644 index 0000000000..ec25fe10d4 --- /dev/null +++ b/supabase/migrations/20260427105817_restrict_is_paying_and_good_plan_org_action_access.sql @@ -0,0 +1,84 @@ +-- Restrict org billing/usage status RPCs +-- so anonymous callers cannot infer org plan state. +CREATE OR REPLACE FUNCTION public.is_paying_and_good_plan_org_action( + "orgid" uuid, + "actions" public.action_type [] +) RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' AS $$ +DECLARE + caller_role text; + org_customer_id text; + result boolean; + has_credits boolean; +BEGIN + SELECT current_setting('role', true) INTO caller_role; + + IF COALESCE(caller_role, '') NOT IN ('service_role', 'postgres', 'supabase_admin') THEN + IF NOT (public.check_min_rights( + 'read'::public.user_min_right, + (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], is_paying_and_good_plan_org_action.orgid)), + is_paying_and_good_plan_org_action.orgid, + NULL::character varying, + NULL::bigint + )) THEN + RETURN false; + END IF; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.usage_credit_balances ucb + WHERE ucb.org_id = orgid + AND COALESCE(ucb.available_credits, 0) > 0 + ) INTO has_credits; + + IF has_credits THEN + RETURN true; + END IF; + + SELECT o.customer_id INTO org_customer_id + FROM public.orgs o + WHERE o.id = orgid; + + SELECT (si.trial_at > now()) OR (si.status = 'succeeded' AND NOT ( + (si.mau_exceeded AND 'mau' = ANY(actions)) + OR (si.storage_exceeded AND 'storage' = ANY(actions)) + OR (si.bandwidth_exceeded AND 'bandwidth' = ANY(actions)) + OR (si.build_time_exceeded AND 'build_time' = ANY(actions)) + )) + INTO result + FROM public.stripe_info si + WHERE si.customer_id = org_customer_id + LIMIT 1; + + RETURN COALESCE(result, false); +END; +$$; + +ALTER FUNCTION public.is_paying_and_good_plan_org_action( + "orgid" uuid, + "actions" public.action_type [] +) OWNER TO "postgres"; + +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( + "orgid" uuid, + "actions" public.action_type [] +) FROM public; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( + "orgid" uuid, + "actions" public.action_type [] +) FROM anon; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( + "orgid" uuid, + "actions" public.action_type [] +) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org_action( + "orgid" uuid, + "actions" public.action_type [] +) TO authenticated; +GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org_action( + "orgid" uuid, + "actions" public.action_type [] +) TO service_role; diff --git a/supabase/tests/11_test_plan.sql b/supabase/tests/11_test_plan.sql index c622208f7c..2e6e54bb1c 100644 --- a/supabase/tests/11_test_plan.sql +++ b/supabase/tests/11_test_plan.sql @@ -167,6 +167,8 @@ BEGIN END; $$ LANGUAGE plpgsql; +SELECT tests.authenticate_as_service_role(); + SELECT my_tests(); SELECT * diff --git a/supabase/tests/25_test_secret_functions.sql b/supabase/tests/25_test_secret_functions.sql index dd6f6c00d2..46508cce82 100644 --- a/supabase/tests/25_test_secret_functions.sql +++ b/supabase/tests/25_test_secret_functions.sql @@ -3,6 +3,8 @@ BEGIN; SELECT plan(3); +SELECT tests.authenticate_as_service_role(); + -- Test is_org_yearly SELECT is( diff --git a/supabase/tests/46_test_org_status_rpcs.sql b/supabase/tests/46_test_org_status_rpcs.sql index 86b74363e2..318dc4551f 100644 --- a/supabase/tests/46_test_org_status_rpcs.sql +++ b/supabase/tests/46_test_org_status_rpcs.sql @@ -1,6 +1,6 @@ BEGIN; -SELECT plan(8); +SELECT plan(12); -- Member of admin org can read billing/trial RPCs SELECT tests.authenticate_as('test_admin'); @@ -19,6 +19,16 @@ SELECT 'is_trial_org - org admin can read trial days' ); +SELECT + is( + is_paying_and_good_plan_org_action( + '22dbad8a-b885-4309-9b3b-a09f8460fb6d', + ARRAY['mau']::public.action_type [] + ), + true, + 'is_paying_and_good_plan_org_action - org admin can read plan status' + ); + -- Non-member should be denied by org authorization checks SELECT tests.authenticate_as('test_user'); @@ -36,6 +46,16 @@ SELECT 'is_trial_org - non-member org user gets 0' ); +SELECT + is( + is_paying_and_good_plan_org_action( + '22dbad8a-b885-4309-9b3b-a09f8460fb6d', + ARRAY['mau']::public.action_type [] + ), + false, + 'is_paying_and_good_plan_org_action - non-member org user gets false' + ); + -- Anonymous user should not have execute permission SELECT tests.clear_authentication(); @@ -55,6 +75,16 @@ SELECT 'is_trial_org - anonymous call is blocked' ); +SELECT + throws_ok( + 'SELECT is_paying_and_good_plan_org_action(' + || '''22dbad8a-b885-4309-9b3b-a09f8460fb6d'', ' + || 'ARRAY[''mau'']::public.action_type[])', + '42501', + 'permission denied for function is_paying_and_good_plan_org_action', + 'is_paying_and_good_plan_org_action - anonymous call is blocked' + ); + -- service role keeps backend-style access SELECT tests.authenticate_as_service_role(); @@ -73,7 +103,16 @@ SELECT ); SELECT - * + is( + is_paying_and_good_plan_org_action( + '22dbad8a-b885-4309-9b3b-a09f8460fb6d', + ARRAY['mau']::public.action_type [] + ), + true, + 'is_paying_and_good_plan_org_action - service role can read plan status' + ); + +SELECT * -- noqa: AM04 FROM finish();