From eca35c444f7dc0e2e332f1eea34c214ba88598d5 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 27 Apr 2026 14:43:35 +0200 Subject: [PATCH 1/6] fix(db): harden security definer execute grants --- ...harden_security_definer_execute_grants.sql | 565 ++++++++++++++++++ ...security-definer-execute-hardening.test.ts | 182 ++++++ 2 files changed, 747 insertions(+) create mode 100644 supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql create mode 100644 tests/security-definer-execute-hardening.test.ts diff --git a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql new file mode 100644 index 0000000000..872db46005 --- /dev/null +++ b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql @@ -0,0 +1,565 @@ +-- Pure helpers do not need elevated privileges. +ALTER FUNCTION public.get_apikey_header() SECURITY INVOKER; +ALTER FUNCTION public.is_apikey_expired( + timestamp with time zone +) SECURITY INVOKER; +ALTER FUNCTION public.strip_html(text) SECURITY INVOKER; +ALTER FUNCTION public.transform_role_to_invite( + public.user_min_right +) SECURITY INVOKER; +ALTER FUNCTION public.transform_role_to_non_invite( + public.user_min_right +) SECURITY INVOKER; +ALTER FUNCTION public.verify_api_key_hash(text, text) SECURITY INVOKER; + +-- Trigger-only internals should never be exposed as RPC entrypoints. +REVOKE ALL ON FUNCTION public.apikeys_force_server_key() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.apikeys_strip_plain_key_for_hashed() FROM PUBLIC; + +REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_encrypted_bundle_on_insert() FROM ANON; +REVOKE ALL +ON FUNCTION public.check_encrypted_bundle_on_insert() +FROM AUTHENTICATED; + +REVOKE ALL +ON FUNCTION public.cleanup_onboarding_app_data_on_complete() +FROM PUBLIC; + +DO $$ +BEGIN + IF to_regprocedure('public.generate_org_user_on_org_create()') IS NOT NULL THEN + EXECUTE 'REVOKE ALL ON FUNCTION public.generate_org_user_on_org_create() FROM PUBLIC'; + EXECUTE 'REVOKE ALL ON FUNCTION public.generate_org_user_on_org_create() FROM ANON'; + EXECUTE 'REVOKE ALL ON FUNCTION public.generate_org_user_on_org_create() FROM AUTHENTICATED'; + END IF; +END; +$$; + +REVOKE ALL +ON FUNCTION public.generate_org_user_stripe_info_on_org_create() +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.generate_org_user_stripe_info_on_org_create() +FROM ANON; +REVOKE ALL +ON FUNCTION public.generate_org_user_stripe_info_on_org_create() +FROM AUTHENTICATED; + +REVOKE ALL ON FUNCTION public.noupdate() FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.prevent_last_super_admin_binding_delete() +FROM PUBLIC; + +REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM ANON; +REVOKE ALL ON FUNCTION public.sanitize_apps_text_fields() FROM AUTHENTICATED; + +REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM ANON; +REVOKE ALL ON FUNCTION public.sanitize_orgs_text_fields() FROM AUTHENTICATED; + +REVOKE ALL ON FUNCTION public.sanitize_tmp_users_text_fields() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sanitize_tmp_users_text_fields() FROM ANON; +REVOKE ALL +ON FUNCTION public.sanitize_tmp_users_text_fields() +FROM AUTHENTICATED; + +REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM ANON; +REVOKE ALL ON FUNCTION public.sanitize_users_text_fields() FROM AUTHENTICATED; + +REVOKE ALL +ON FUNCTION public.sync_org_has_usage_credits_from_grants() +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.sync_org_user_role_binding_on_delete() +FROM PUBLIC; + +REVOKE ALL +ON FUNCTION public.sync_org_user_role_binding_on_update() +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_update() FROM ANON; +REVOKE ALL +ON FUNCTION public.sync_org_user_role_binding_on_update() +FROM AUTHENTICATED; + +REVOKE ALL ON FUNCTION public.sync_org_user_to_role_binding() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.sync_org_user_to_role_binding() FROM ANON; +REVOKE ALL +ON FUNCTION public.sync_org_user_to_role_binding() +FROM AUTHENTICATED; + +-- Internal helpers and maintenance functions should stay service-role only. +REVOKE ALL +ON FUNCTION public.check_org_hashed_key_enforcement(uuid, public.apikeys) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.check_org_hashed_key_enforcement(uuid, public.apikeys) +FROM ANON; +REVOKE ALL +ON FUNCTION public.check_org_hashed_key_enforcement(uuid, public.apikeys) +FROM AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.check_org_hashed_key_enforcement(uuid, public.apikeys) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.delete_old_deleted_versions() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_old_deleted_versions() FROM ANON; +REVOKE ALL +ON FUNCTION public.delete_old_deleted_versions() +FROM AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.delete_old_deleted_versions() TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_apikey() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_apikey() FROM ANON; +REVOKE ALL ON FUNCTION public.get_apikey() FROM AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.get_apikey() TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.get_user_main_org_id_by_app_id(text) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.get_user_main_org_id_by_app_id(text) +FROM ANON; +REVOKE ALL +ON FUNCTION public.get_user_main_org_id_by_app_id(text) +FROM AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.get_user_main_org_id_by_app_id(text) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) +FROM ANON; +REVOKE ALL +ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) +FROM AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa_for_org( + uuid +) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) FROM ANON; +REVOKE ALL +ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) +FROM AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.resync_org_user_role_bindings(uuid, uuid) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.resync_org_user_role_bindings(uuid, uuid) +FROM ANON; +REVOKE ALL +ON FUNCTION public.resync_org_user_role_bindings(uuid, uuid) +FROM AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.resync_org_user_role_bindings(uuid, uuid) +TO SERVICE_ROLE; + +-- These RPCs are intended for signed-in users only. +REVOKE ALL ON FUNCTION public.accept_invitation_to_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.accept_invitation_to_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.accept_invitation_to_org( + uuid +) TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.accept_invitation_to_org(uuid) TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.check_org_members_2fa_enabled(uuid) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.check_org_members_2fa_enabled(uuid) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.check_org_members_2fa_enabled(uuid) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.check_org_members_2fa_enabled(uuid) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.check_org_members_password_policy(uuid) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.check_org_members_password_policy(uuid) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.check_org_members_password_policy(uuid) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.check_org_members_password_policy(uuid) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.count_non_compliant_bundles(uuid, text) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.count_non_compliant_bundles(uuid, text) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.count_non_compliant_bundles(uuid, text) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.count_non_compliant_bundles(uuid, text) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.delete_group_with_bindings(uuid) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.delete_group_with_bindings(uuid) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.delete_group_with_bindings(uuid) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.delete_group_with_bindings(uuid) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.delete_non_compliant_bundles(uuid, text) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.delete_non_compliant_bundles(uuid, text) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.delete_non_compliant_bundles(uuid, text) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.delete_non_compliant_bundles(uuid, text) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.delete_org_member_role(uuid, uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_org_member_role(uuid, uuid) FROM ANON; +GRANT EXECUTE +ON FUNCTION public.delete_org_member_role(uuid, uuid) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.delete_org_member_role(uuid, uuid) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.delete_user() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.delete_user() FROM ANON; +GRANT EXECUTE ON FUNCTION public.delete_user() TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.delete_user() TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_account_removal_date() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_account_removal_date() FROM ANON; +GRANT EXECUTE +ON FUNCTION public.get_account_removal_date() +TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.get_account_removal_date() TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_app_access_rbac(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_app_access_rbac(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.get_app_access_rbac(uuid) TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.get_app_access_rbac(uuid) TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, date, date) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, date, date) FROM ANON; +GRANT EXECUTE +ON FUNCTION public.get_app_metrics(uuid, date, date) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.get_app_metrics(uuid, date, date) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_app_metrics(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_app_metrics(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid) TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid) TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_org_members(uuid, uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_members(uuid, uuid) FROM ANON; +GRANT EXECUTE +ON FUNCTION public.get_org_members(uuid, uuid) +TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.get_org_members(uuid, uuid) TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_org_members(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_members(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.get_org_members(uuid) TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.get_org_members(uuid) TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_org_members_rbac(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_org_members_rbac(uuid) FROM ANON; +GRANT EXECUTE +ON FUNCTION public.get_org_members_rbac(uuid) +TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.get_org_members_rbac(uuid) TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.get_org_user_access_rbac(uuid, uuid) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.get_org_user_access_rbac(uuid, uuid) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.get_org_user_access_rbac(uuid, uuid) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.get_org_user_access_rbac(uuid, uuid) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_total_storage_size_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_total_storage_size_org(uuid) FROM ANON; +GRANT EXECUTE +ON FUNCTION public.get_total_storage_size_org(uuid) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.get_total_storage_size_org(uuid) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.get_user_org_ids() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_user_org_ids() FROM ANON; +GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.has_2fa_enabled() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.has_2fa_enabled() FROM ANON; +GRANT EXECUTE ON FUNCTION public.has_2fa_enabled() TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.has_2fa_enabled() TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.invite_user_to_org( + character varying, uuid, public.user_min_right +) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.invite_user_to_org( + character varying, uuid, public.user_min_right +) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.invite_user_to_org( + character varying, uuid, public.user_min_right +) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.invite_user_to_org( + character varying, uuid, public.user_min_right +) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.is_allowed_action_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_allowed_action_org(uuid) FROM ANON; +GRANT EXECUTE +ON FUNCTION public.is_allowed_action_org(uuid) +TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.is_allowed_action_org(uuid) TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.is_canceled_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_canceled_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_canceled_org(uuid) TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.is_canceled_org(uuid) TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.is_good_plan_v5_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_good_plan_v5_org(uuid) FROM ANON; +GRANT EXECUTE +ON FUNCTION public.is_good_plan_v5_org(uuid) +TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.is_good_plan_v5_org(uuid) TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.is_onboarded_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_onboarded_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_onboarded_org(uuid) TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.is_onboarded_org(uuid) TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.is_onboarding_needed_org(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_onboarding_needed_org(uuid) FROM ANON; +GRANT EXECUTE +ON FUNCTION public.is_onboarding_needed_org(uuid) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.is_onboarding_needed_org(uuid) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.is_org_yearly(uuid) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_org_yearly(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_org_yearly(uuid) TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.is_org_yearly(uuid) TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.is_paying_and_good_plan_org(uuid) +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org(uuid) FROM ANON; +GRANT EXECUTE +ON FUNCTION public.is_paying_and_good_plan_org(uuid) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.is_paying_and_good_plan_org(uuid) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.is_paying_and_good_plan_org_action( + uuid, public.action_type [] +) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.is_paying_and_good_plan_org_action( + uuid, public.action_type [] +) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.is_paying_and_good_plan_org_action( + uuid, public.action_type [] +) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.is_paying_and_good_plan_org_action( + uuid, public.action_type [] +) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.modify_permissions_tmp(text, uuid, public.user_min_right) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.modify_permissions_tmp(text, uuid, public.user_min_right) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.modify_permissions_tmp(text, uuid, public.user_min_right) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.modify_permissions_tmp(text, uuid, public.user_min_right) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.rbac_check_permission(text, uuid, character varying, bigint) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.rbac_check_permission_no_password_policy( + text, uuid, character varying, bigint +) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.rbac_check_permission_no_password_policy( + text, uuid, character varying, bigint +) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.rbac_check_permission_no_password_policy( + text, uuid, character varying, bigint +) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.rbac_check_permission_no_password_policy( + text, uuid, character varying, bigint +) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.update_org_invite_role_rbac(uuid, uuid, text) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.update_org_invite_role_rbac(uuid, uuid, text) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.update_org_invite_role_rbac(uuid, uuid, text) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.update_org_invite_role_rbac(uuid, uuid, text) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.update_org_member_role(uuid, uuid, text) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.update_org_member_role(uuid, uuid, text) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.update_org_member_role(uuid, uuid, text) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.update_org_member_role(uuid, uuid, text) +TO SERVICE_ROLE; + +REVOKE ALL +ON FUNCTION public.update_tmp_invite_role_rbac(uuid, text, text) +FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.update_tmp_invite_role_rbac(uuid, text, text) +FROM ANON; +GRANT EXECUTE +ON FUNCTION public.update_tmp_invite_role_rbac(uuid, text, text) +TO AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.update_tmp_invite_role_rbac(uuid, text, text) +TO SERVICE_ROLE; + +REVOKE ALL ON FUNCTION public.verify_mfa() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.verify_mfa() FROM ANON; +GRANT EXECUTE ON FUNCTION public.verify_mfa() TO AUTHENTICATED; +GRANT EXECUTE ON FUNCTION public.verify_mfa() TO SERVICE_ROLE; diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts new file mode 100644 index 0000000000..e5c35d151a --- /dev/null +++ b/tests/security-definer-execute-hardening.test.ts @@ -0,0 +1,182 @@ +import type { Pool } from 'pg' +import { Pool as PgPool } from 'pg' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { POSTGRES_URL } from './test-utils.ts' + +interface ProcState { + anon_exec: boolean + auth_exec: boolean + proc: string + prosecdef: boolean +} + +const INVOKER_PROCS = [ + 'public.get_apikey_header()', + 'public.is_apikey_expired(timestamp with time zone)', + 'public.strip_html(text)', + 'public.transform_role_to_invite(public.user_min_right)', + 'public.transform_role_to_non_invite(public.user_min_right)', + 'public.verify_api_key_hash(text, text)', +] as const + +const SERVICE_ONLY_PROCS = [ + 'public.apikeys_force_server_key()', + 'public.apikeys_strip_plain_key_for_hashed()', + 'public.check_encrypted_bundle_on_insert()', + 'public.check_org_hashed_key_enforcement(uuid, public.apikeys)', + 'public.cleanup_onboarding_app_data_on_complete()', + 'public.delete_old_deleted_versions()', + 'public.generate_org_user_stripe_info_on_org_create()', + 'public.get_apikey()', + 'public.get_user_main_org_id_by_app_id(text)', + 'public.noupdate()', + 'public.prevent_last_super_admin_binding_delete()', + 'public.reject_access_due_to_2fa_for_app(character varying)', + 'public.reject_access_due_to_2fa_for_org(uuid)', + 'public.resync_org_user_role_bindings(uuid, uuid)', + 'public.sanitize_apps_text_fields()', + 'public.sanitize_orgs_text_fields()', + 'public.sanitize_tmp_users_text_fields()', + 'public.sanitize_users_text_fields()', + 'public.sync_org_has_usage_credits_from_grants()', + 'public.sync_org_user_role_binding_on_update()', + 'public.sync_org_user_to_role_binding()', +] as const + +const AUTHENTICATED_ONLY_PROCS = [ + 'public.accept_invitation_to_org(uuid)', + 'public.check_org_members_2fa_enabled(uuid)', + 'public.check_org_members_password_policy(uuid)', + 'public.count_non_compliant_bundles(uuid, text)', + 'public.delete_group_with_bindings(uuid)', + 'public.delete_non_compliant_bundles(uuid, text)', + 'public.delete_org_member_role(uuid, uuid)', + 'public.delete_user()', + 'public.get_account_removal_date()', + 'public.get_app_access_rbac(uuid)', + 'public.get_app_metrics(uuid, character varying, date, date)', + 'public.get_app_metrics(uuid, date, date)', + 'public.get_app_metrics(uuid)', + 'public.get_org_members(uuid, uuid)', + 'public.get_org_members(uuid)', + 'public.get_org_members_rbac(uuid)', + 'public.get_org_user_access_rbac(uuid, uuid)', + 'public.get_total_app_storage_size_orgs(uuid, character varying)', + 'public.get_total_storage_size_org(uuid)', + 'public.get_user_org_ids()', + 'public.has_2fa_enabled()', + 'public.invite_user_to_org(character varying, uuid, public.user_min_right)', + 'public.invite_user_to_org_rbac(character varying, uuid, text)', + 'public.is_allowed_action_org(uuid)', + 'public.is_allowed_action_org_action(uuid, public.action_type[])', + 'public.is_canceled_org(uuid)', + 'public.is_good_plan_v5_org(uuid)', + 'public.is_onboarded_org(uuid)', + 'public.is_onboarding_needed_org(uuid)', + 'public.is_org_yearly(uuid)', + 'public.is_paying_and_good_plan_org(uuid)', + 'public.is_paying_and_good_plan_org_action(uuid, public.action_type[])', + 'public.modify_permissions_tmp(text, uuid, public.user_min_right)', + 'public.rbac_check_permission(text, uuid, character varying, bigint)', + 'public.rbac_check_permission_no_password_policy(text, uuid, character varying, bigint)', + 'public.update_org_invite_role_rbac(uuid, uuid, text)', + 'public.update_org_member_role(uuid, uuid, text)', + 'public.update_tmp_invite_role_rbac(uuid, text, text)', + 'public.verify_mfa()', +] as const + +describe('security definer execute hardening', () => { + let pool: Pool + + beforeAll(() => { + pool = new PgPool({ connectionString: POSTGRES_URL }) + }) + + afterAll(async () => { + await pool.end() + }) + + async function getProcStates(procs: readonly string[]): Promise> { + const result = await pool.query(` + WITH requested AS ( + SELECT + proc, + to_regprocedure(proc) AS proc_oid + FROM unnest($1::text[]) AS proc + ) + SELECT + requested.proc, + p.prosecdef, + has_function_privilege('anon', p.oid, 'EXECUTE') AS anon_exec, + has_function_privilege('authenticated', p.oid, 'EXECUTE') AS auth_exec + FROM requested + LEFT JOIN pg_proc AS p + ON p.oid = requested.proc_oid + ORDER BY 1 + `, [procs]) + + return new Map(result.rows.map(row => [row.proc, row])) + } + + it.concurrent('runs pure helpers as security invoker', async () => { + const states = await getProcStates(INVOKER_PROCS) + + expect(states.size).toBe(INVOKER_PROCS.length) + + for (const proc of INVOKER_PROCS) { + expect(states.get(proc)?.prosecdef, proc).toBe(false) + } + }) + + it.concurrent('keeps helper behavior intact', async () => { + const result = await pool.query<{ + expired_null: boolean + invite_role: string + non_invite_role: string + stripped: string + verified: boolean + }>(` + SELECT + public.is_apikey_expired(NULL) AS expired_null, + public.strip_html('capgo') AS stripped, + public.transform_role_to_invite('write'::public.user_min_right)::text AS invite_role, + public.transform_role_to_non_invite('invite_admin'::public.user_min_right)::text AS non_invite_role, + public.verify_api_key_hash( + 'capgo', + encode(extensions.digest('capgo', 'sha256'), 'hex') + ) AS verified + `) + + expect(result.rows[0]).toEqual({ + expired_null: false, + invite_role: 'invite_write', + non_invite_role: 'admin', + stripped: 'capgo', + verified: true, + }) + }) + + it.concurrent('blocks direct execution of service-only helpers', async () => { + const states = await getProcStates(SERVICE_ONLY_PROCS) + + expect(states.size).toBe(SERVICE_ONLY_PROCS.length) + + for (const proc of SERVICE_ONLY_PROCS) { + const state = states.get(proc) + expect(state?.anon_exec, proc).toBe(false) + expect(state?.auth_exec, proc).toBe(false) + } + }) + + it.concurrent('keeps signed-in RPCs inaccessible to anonymous callers', async () => { + const states = await getProcStates(AUTHENTICATED_ONLY_PROCS) + + expect(states.size).toBe(AUTHENTICATED_ONLY_PROCS.length) + + for (const proc of AUTHENTICATED_ONLY_PROCS) { + const state = states.get(proc) + expect(state?.anon_exec, proc).toBe(false) + expect(state?.auth_exec, proc).toBe(true) + } + }) +}) From 1b9a9ab7b72be2d7ddb8aeec68a0736dde274394 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 27 Apr 2026 15:06:46 +0200 Subject: [PATCH 2/6] fix(db): restore anon-safe rpc grants --- ...harden_security_definer_execute_grants.sql | 56 +++++++++---------- ...security-definer-execute-hardening.test.ts | 49 ++++++++++------ 2 files changed, 59 insertions(+), 46 deletions(-) diff --git a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql index 872db46005..8a2ac4a5af 100644 --- a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql +++ b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql @@ -119,12 +119,10 @@ GRANT EXECUTE ON FUNCTION public.get_apikey() TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.get_user_main_org_id_by_app_id(text) FROM PUBLIC; -REVOKE ALL -ON FUNCTION public.get_user_main_org_id_by_app_id(text) -FROM ANON; -REVOKE ALL +GRANT EXECUTE ON FUNCTION public.get_user_main_org_id_by_app_id(text) TO ANON; +GRANT EXECUTE ON FUNCTION public.get_user_main_org_id_by_app_id(text) -FROM AUTHENTICATED; +TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.get_user_main_org_id_by_app_id(text) TO SERVICE_ROLE; @@ -132,12 +130,12 @@ TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) FROM PUBLIC; -REVOKE ALL +GRANT EXECUTE ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) -FROM ANON; -REVOKE ALL +TO ANON; +GRANT EXECUTE ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) -FROM AUTHENTICATED; +TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.reject_access_due_to_2fa_for_app(character varying) TO SERVICE_ROLE; @@ -145,10 +143,10 @@ TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa_for_org( uuid ) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) FROM ANON; -REVOKE ALL +GRANT EXECUTE ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) TO ANON; +GRANT EXECUTE ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) -FROM AUTHENTICATED; +TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.reject_access_due_to_2fa_for_org(uuid) TO SERVICE_ROLE; @@ -293,7 +291,7 @@ GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid) TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.get_org_members(uuid, uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_org_members(uuid, uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.get_org_members(uuid, uuid) TO ANON; GRANT EXECUTE ON FUNCTION public.get_org_members(uuid, uuid) TO AUTHENTICATED; @@ -327,9 +325,9 @@ TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) FROM PUBLIC; -REVOKE ALL +GRANT EXECUTE ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) -FROM ANON; +TO ANON; GRANT EXECUTE ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) TO AUTHENTICATED; @@ -338,7 +336,7 @@ ON FUNCTION public.get_total_app_storage_size_orgs(uuid, character varying) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.get_total_storage_size_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_total_storage_size_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.get_total_storage_size_org(uuid) TO ANON; GRANT EXECUTE ON FUNCTION public.get_total_storage_size_org(uuid) TO AUTHENTICATED; @@ -352,7 +350,7 @@ GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.has_2fa_enabled() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.has_2fa_enabled() FROM ANON; +GRANT EXECUTE ON FUNCTION public.has_2fa_enabled() TO ANON; GRANT EXECUTE ON FUNCTION public.has_2fa_enabled() TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.has_2fa_enabled() TO SERVICE_ROLE; @@ -391,7 +389,7 @@ ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.is_allowed_action_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.is_allowed_action_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_allowed_action_org(uuid) TO ANON; GRANT EXECUTE ON FUNCTION public.is_allowed_action_org(uuid) TO AUTHENTICATED; @@ -400,9 +398,9 @@ GRANT EXECUTE ON FUNCTION public.is_allowed_action_org(uuid) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) FROM PUBLIC; -REVOKE ALL +GRANT EXECUTE ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) -FROM ANON; +TO ANON; GRANT EXECUTE ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) TO AUTHENTICATED; @@ -411,24 +409,24 @@ ON FUNCTION public.is_allowed_action_org_action(uuid, public.action_type []) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.is_canceled_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.is_canceled_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_canceled_org(uuid) TO ANON; GRANT EXECUTE ON FUNCTION public.is_canceled_org(uuid) TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.is_canceled_org(uuid) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.is_good_plan_v5_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.is_good_plan_v5_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_good_plan_v5_org(uuid) TO ANON; GRANT EXECUTE ON FUNCTION public.is_good_plan_v5_org(uuid) TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.is_good_plan_v5_org(uuid) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.is_onboarded_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.is_onboarded_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_onboarded_org(uuid) TO ANON; GRANT EXECUTE ON FUNCTION public.is_onboarded_org(uuid) TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.is_onboarded_org(uuid) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.is_onboarding_needed_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.is_onboarding_needed_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_onboarding_needed_org(uuid) TO ANON; GRANT EXECUTE ON FUNCTION public.is_onboarding_needed_org(uuid) TO AUTHENTICATED; @@ -437,14 +435,14 @@ ON FUNCTION public.is_onboarding_needed_org(uuid) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.is_org_yearly(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.is_org_yearly(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_org_yearly(uuid) TO ANON; GRANT EXECUTE ON FUNCTION public.is_org_yearly(uuid) TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.is_org_yearly(uuid) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org(uuid) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org(uuid) FROM ANON; +GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org(uuid) TO ANON; GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org(uuid) TO AUTHENTICATED; @@ -457,11 +455,11 @@ ON FUNCTION public.is_paying_and_good_plan_org_action( uuid, public.action_type [] ) FROM PUBLIC; -REVOKE ALL +GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org_action( uuid, public.action_type [] ) -FROM ANON; +TO ANON; GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org_action( uuid, public.action_type [] @@ -560,6 +558,6 @@ ON FUNCTION public.update_tmp_invite_role_rbac(uuid, text, text) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.verify_mfa() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.verify_mfa() FROM ANON; +GRANT EXECUTE ON FUNCTION public.verify_mfa() TO ANON; GRANT EXECUTE ON FUNCTION public.verify_mfa() TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.verify_mfa() TO SERVICE_ROLE; diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index e5c35d151a..951d3f9f3f 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -28,11 +28,8 @@ const SERVICE_ONLY_PROCS = [ 'public.delete_old_deleted_versions()', 'public.generate_org_user_stripe_info_on_org_create()', 'public.get_apikey()', - 'public.get_user_main_org_id_by_app_id(text)', 'public.noupdate()', 'public.prevent_last_super_admin_binding_delete()', - 'public.reject_access_due_to_2fa_for_app(character varying)', - 'public.reject_access_due_to_2fa_for_org(uuid)', 'public.resync_org_user_role_bindings(uuid, uuid)', 'public.sanitize_apps_text_fields()', 'public.sanitize_orgs_text_fields()', @@ -43,6 +40,26 @@ const SERVICE_ONLY_PROCS = [ 'public.sync_org_user_to_role_binding()', ] as const +const ANON_ALLOWED_PROCS = [ + 'public.get_org_members(uuid, uuid)', + 'public.get_total_app_storage_size_orgs(uuid, character varying)', + 'public.get_total_storage_size_org(uuid)', + 'public.get_user_main_org_id_by_app_id(text)', + 'public.has_2fa_enabled()', + 'public.is_allowed_action_org(uuid)', + 'public.is_allowed_action_org_action(uuid, public.action_type[])', + 'public.is_canceled_org(uuid)', + 'public.is_good_plan_v5_org(uuid)', + 'public.is_onboarded_org(uuid)', + 'public.is_onboarding_needed_org(uuid)', + 'public.is_org_yearly(uuid)', + 'public.is_paying_and_good_plan_org(uuid)', + 'public.is_paying_and_good_plan_org_action(uuid, public.action_type[])', + 'public.reject_access_due_to_2fa_for_app(character varying)', + 'public.reject_access_due_to_2fa_for_org(uuid)', + 'public.verify_mfa()', +] as const + const AUTHENTICATED_ONLY_PROCS = [ 'public.accept_invitation_to_org(uuid)', 'public.check_org_members_2fa_enabled(uuid)', @@ -57,32 +74,18 @@ const AUTHENTICATED_ONLY_PROCS = [ 'public.get_app_metrics(uuid, character varying, date, date)', 'public.get_app_metrics(uuid, date, date)', 'public.get_app_metrics(uuid)', - 'public.get_org_members(uuid, uuid)', 'public.get_org_members(uuid)', 'public.get_org_members_rbac(uuid)', 'public.get_org_user_access_rbac(uuid, uuid)', - 'public.get_total_app_storage_size_orgs(uuid, character varying)', - 'public.get_total_storage_size_org(uuid)', 'public.get_user_org_ids()', - 'public.has_2fa_enabled()', 'public.invite_user_to_org(character varying, uuid, public.user_min_right)', 'public.invite_user_to_org_rbac(character varying, uuid, text)', - 'public.is_allowed_action_org(uuid)', - 'public.is_allowed_action_org_action(uuid, public.action_type[])', - 'public.is_canceled_org(uuid)', - 'public.is_good_plan_v5_org(uuid)', - 'public.is_onboarded_org(uuid)', - 'public.is_onboarding_needed_org(uuid)', - 'public.is_org_yearly(uuid)', - 'public.is_paying_and_good_plan_org(uuid)', - 'public.is_paying_and_good_plan_org_action(uuid, public.action_type[])', 'public.modify_permissions_tmp(text, uuid, public.user_min_right)', 'public.rbac_check_permission(text, uuid, character varying, bigint)', 'public.rbac_check_permission_no_password_policy(text, uuid, character varying, bigint)', 'public.update_org_invite_role_rbac(uuid, uuid, text)', 'public.update_org_member_role(uuid, uuid, text)', 'public.update_tmp_invite_role_rbac(uuid, text, text)', - 'public.verify_mfa()', ] as const describe('security definer execute hardening', () => { @@ -168,6 +171,18 @@ describe('security definer execute hardening', () => { } }) + it.concurrent('keeps anon-safe helpers callable for anonymous callers', async () => { + const states = await getProcStates(ANON_ALLOWED_PROCS) + + expect(states.size).toBe(ANON_ALLOWED_PROCS.length) + + for (const proc of ANON_ALLOWED_PROCS) { + const state = states.get(proc) + expect(state?.anon_exec, proc).toBe(true) + expect(state?.auth_exec, proc).toBe(true) + } + }) + it.concurrent('keeps signed-in RPCs inaccessible to anonymous callers', async () => { const states = await getProcStates(AUTHENTICATED_ONLY_PROCS) From 0a958819591d1691165c465f1e00b1cd79be6b26 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 27 Apr 2026 15:18:24 +0200 Subject: [PATCH 3/6] fix(db): restore api key rpc grants --- ...27105151_harden_security_definer_execute_grants.sql | 10 +++++----- tests/security-definer-execute-hardening.test.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql index 8a2ac4a5af..d1d0b6c1ed 100644 --- a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql +++ b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql @@ -345,7 +345,7 @@ ON FUNCTION public.get_total_storage_size_org(uuid) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.get_user_org_ids() FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_user_org_ids() FROM ANON; +GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO ANON; GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.get_user_org_ids() TO SERVICE_ROLE; @@ -359,11 +359,11 @@ ON FUNCTION public.invite_user_to_org( character varying, uuid, public.user_min_right ) FROM PUBLIC; -REVOKE ALL +GRANT EXECUTE ON FUNCTION public.invite_user_to_org( character varying, uuid, public.user_min_right ) -FROM ANON; +TO ANON; GRANT EXECUTE ON FUNCTION public.invite_user_to_org( character varying, uuid, public.user_min_right @@ -378,9 +378,9 @@ TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) FROM PUBLIC; -REVOKE ALL +GRANT EXECUTE ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) -FROM ANON; +TO ANON; GRANT EXECUTE ON FUNCTION public.invite_user_to_org_rbac(character varying, uuid, text) TO AUTHENTICATED; diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 951d3f9f3f..0a8301d81d 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -45,7 +45,10 @@ const ANON_ALLOWED_PROCS = [ 'public.get_total_app_storage_size_orgs(uuid, character varying)', 'public.get_total_storage_size_org(uuid)', 'public.get_user_main_org_id_by_app_id(text)', + 'public.get_user_org_ids()', 'public.has_2fa_enabled()', + 'public.invite_user_to_org(character varying, uuid, public.user_min_right)', + 'public.invite_user_to_org_rbac(character varying, uuid, text)', 'public.is_allowed_action_org(uuid)', 'public.is_allowed_action_org_action(uuid, public.action_type[])', 'public.is_canceled_org(uuid)', @@ -77,9 +80,6 @@ const AUTHENTICATED_ONLY_PROCS = [ 'public.get_org_members(uuid)', 'public.get_org_members_rbac(uuid)', 'public.get_org_user_access_rbac(uuid, uuid)', - 'public.get_user_org_ids()', - 'public.invite_user_to_org(character varying, uuid, public.user_min_right)', - 'public.invite_user_to_org_rbac(character varying, uuid, text)', 'public.modify_permissions_tmp(text, uuid, public.user_min_right)', 'public.rbac_check_permission(text, uuid, character varying, bigint)', 'public.rbac_check_permission_no_password_policy(text, uuid, character varying, bigint)', From a48f6566e84c03bffa0f6a49d838a98f63c4dde9 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 27 Apr 2026 15:35:58 +0200 Subject: [PATCH 4/6] fix(db): restore stats rpc grants --- ...5151_harden_security_definer_execute_grants.sql | 14 +++++++------- tests/security-definer-execute-hardening.test.ts | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql index d1d0b6c1ed..9c8fd1b6d2 100644 --- a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql +++ b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql @@ -266,9 +266,9 @@ GRANT EXECUTE ON FUNCTION public.get_app_access_rbac(uuid) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) FROM PUBLIC; -REVOKE ALL +GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) -FROM ANON; +TO ANON; GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) TO AUTHENTICATED; @@ -277,7 +277,7 @@ ON FUNCTION public.get_app_metrics(uuid, character varying, date, date) TO SERVICE_ROLE; REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, date, date) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_app_metrics(uuid, date, date) FROM ANON; +GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid, date, date) TO ANON; GRANT EXECUTE ON FUNCTION public.get_app_metrics(uuid, date, date) TO AUTHENTICATED; @@ -455,16 +455,16 @@ ON FUNCTION public.is_paying_and_good_plan_org_action( uuid, public.action_type [] ) FROM PUBLIC; -GRANT EXECUTE +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( uuid, public.action_type [] ) -TO ANON; -GRANT EXECUTE +FROM ANON; +REVOKE ALL ON FUNCTION public.is_paying_and_good_plan_org_action( uuid, public.action_type [] ) -TO AUTHENTICATED; +FROM AUTHENTICATED; GRANT EXECUTE ON FUNCTION public.is_paying_and_good_plan_org_action( uuid, public.action_type [] diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 0a8301d81d..181517d452 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -28,6 +28,7 @@ const SERVICE_ONLY_PROCS = [ 'public.delete_old_deleted_versions()', 'public.generate_org_user_stripe_info_on_org_create()', 'public.get_apikey()', + 'public.is_paying_and_good_plan_org_action(uuid, public.action_type[])', 'public.noupdate()', 'public.prevent_last_super_admin_binding_delete()', 'public.resync_org_user_role_bindings(uuid, uuid)', @@ -41,6 +42,8 @@ const SERVICE_ONLY_PROCS = [ ] as const const ANON_ALLOWED_PROCS = [ + 'public.get_app_metrics(uuid, character varying, date, date)', + 'public.get_app_metrics(uuid, date, date)', 'public.get_org_members(uuid, uuid)', 'public.get_total_app_storage_size_orgs(uuid, character varying)', 'public.get_total_storage_size_org(uuid)', @@ -57,7 +60,6 @@ const ANON_ALLOWED_PROCS = [ 'public.is_onboarding_needed_org(uuid)', 'public.is_org_yearly(uuid)', 'public.is_paying_and_good_plan_org(uuid)', - 'public.is_paying_and_good_plan_org_action(uuid, public.action_type[])', 'public.reject_access_due_to_2fa_for_app(character varying)', 'public.reject_access_due_to_2fa_for_org(uuid)', 'public.verify_mfa()', @@ -74,8 +76,6 @@ const AUTHENTICATED_ONLY_PROCS = [ 'public.delete_user()', 'public.get_account_removal_date()', 'public.get_app_access_rbac(uuid)', - 'public.get_app_metrics(uuid, character varying, date, date)', - 'public.get_app_metrics(uuid, date, date)', 'public.get_app_metrics(uuid)', 'public.get_org_members(uuid)', 'public.get_org_members_rbac(uuid)', From a3f37b576fac8c949f781d7c597dae941387003a Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 27 Apr 2026 15:42:59 +0200 Subject: [PATCH 5/6] test(db): narrow helper grant assertion --- tests/security-definer-execute-hardening.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 181517d452..5214a366db 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -28,7 +28,6 @@ const SERVICE_ONLY_PROCS = [ 'public.delete_old_deleted_versions()', 'public.generate_org_user_stripe_info_on_org_create()', 'public.get_apikey()', - 'public.is_paying_and_good_plan_org_action(uuid, public.action_type[])', 'public.noupdate()', 'public.prevent_last_super_admin_binding_delete()', 'public.resync_org_user_role_bindings(uuid, uuid)', From f311135aa56c0f0772da8c544ee506ca8ec32f70 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 27 Apr 2026 15:55:33 +0200 Subject: [PATCH 6/6] test(db): tighten helper grant coverage --- ...0427105151_harden_security_definer_execute_grants.sql | 6 ++++++ tests/security-definer-execute-hardening.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql index 9c8fd1b6d2..6ae4a98e9a 100644 --- a/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql +++ b/supabase/migrations/20260427105151_harden_security_definer_execute_grants.sql @@ -75,6 +75,12 @@ FROM PUBLIC; REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_delete() FROM PUBLIC; +REVOKE ALL +ON FUNCTION public.sync_org_user_role_binding_on_delete() +FROM ANON; +REVOKE ALL +ON FUNCTION public.sync_org_user_role_binding_on_delete() +FROM AUTHENTICATED; REVOKE ALL ON FUNCTION public.sync_org_user_role_binding_on_update() diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 5214a366db..80499cf338 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -36,6 +36,7 @@ const SERVICE_ONLY_PROCS = [ 'public.sanitize_tmp_users_text_fields()', 'public.sanitize_users_text_fields()', 'public.sync_org_has_usage_credits_from_grants()', + 'public.sync_org_user_role_binding_on_delete()', 'public.sync_org_user_role_binding_on_update()', 'public.sync_org_user_to_role_binding()', ] as const @@ -120,12 +121,17 @@ describe('security definer execute hardening', () => { return new Map(result.rows.map(row => [row.proc, row])) } + function assertProcExists(states: Map, proc: string) { + expect(states.get(proc)?.prosecdef, `${proc} does not exist or signature mismatch`).not.toBeNull() + } + it.concurrent('runs pure helpers as security invoker', async () => { const states = await getProcStates(INVOKER_PROCS) expect(states.size).toBe(INVOKER_PROCS.length) for (const proc of INVOKER_PROCS) { + assertProcExists(states, proc) expect(states.get(proc)?.prosecdef, proc).toBe(false) } }) @@ -164,6 +170,7 @@ describe('security definer execute hardening', () => { expect(states.size).toBe(SERVICE_ONLY_PROCS.length) for (const proc of SERVICE_ONLY_PROCS) { + assertProcExists(states, proc) const state = states.get(proc) expect(state?.anon_exec, proc).toBe(false) expect(state?.auth_exec, proc).toBe(false) @@ -176,6 +183,7 @@ describe('security definer execute hardening', () => { expect(states.size).toBe(ANON_ALLOWED_PROCS.length) for (const proc of ANON_ALLOWED_PROCS) { + assertProcExists(states, proc) const state = states.get(proc) expect(state?.anon_exec, proc).toBe(true) expect(state?.auth_exec, proc).toBe(true) @@ -188,6 +196,7 @@ describe('security definer execute hardening', () => { expect(states.size).toBe(AUTHENTICATED_ONLY_PROCS.length) for (const proc of AUTHENTICATED_ONLY_PROCS) { + assertProcExists(states, proc) const state = states.get(proc) expect(state?.anon_exec, proc).toBe(false) expect(state?.auth_exec, proc).toBe(true)