From c098cf8482f99ea2d94685b6775af19cd9ee0fec Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 24 Feb 2026 20:03:15 +0000 Subject: [PATCH 1/2] fix(security): restrict org member rpc access --- ...260224000000_fix_org_member_rpc_access.sql | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 supabase/migrations/20260224000000_fix_org_member_rpc_access.sql diff --git a/supabase/migrations/20260224000000_fix_org_member_rpc_access.sql b/supabase/migrations/20260224000000_fix_org_member_rpc_access.sql new file mode 100644 index 0000000000..84b27f55bd --- /dev/null +++ b/supabase/migrations/20260224000000_fix_org_member_rpc_access.sql @@ -0,0 +1,173 @@ +-- ============================================================================ +-- Fix auth checks and execution privileges for org RPCs +-- ============================================================================ + +CREATE OR REPLACE FUNCTION "public"."get_org_members" ("guild_id" "uuid") RETURNS TABLE ( + "aid" bigint, + "uid" "uuid", + "email" "varchar", + "image_url" "varchar", + "role" "public"."user_min_right", + "is_tmp" boolean +) LANGUAGE plpgsql SECURITY DEFINER +SET +search_path = '' AS $$ +DECLARE + v_user_id uuid; + v_is_service_role boolean; +BEGIN + v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); + v_is_service_role := ( + ((SELECT auth.jwt() ->> 'role') = 'service_role') + OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres') + ); + + IF NOT v_is_service_role THEN + IF v_user_id IS NULL OR NOT public.check_min_rights( + 'read'::public.user_min_right, + v_user_id, + get_org_members.guild_id, + NULL::character varying, + NULL::bigint + ) THEN + PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('guild_id', get_org_members.guild_id, 'uid', v_user_id)); + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + END IF; + + RETURN QUERY SELECT * FROM public.get_org_members(v_user_id, get_org_members.guild_id); +END; +$$; + +CREATE OR REPLACE FUNCTION "public"."get_org_members" ( + "user_id" uuid, + "guild_id" uuid +) RETURNS TABLE ( + aid bigint, + uid uuid, + email varchar, + image_url varchar, + role public.user_min_right, + is_tmp boolean +) LANGUAGE plpgsql SECURITY DEFINER +SET +search_path = '' AS $$ +DECLARE + v_user_id uuid; + v_is_service_role boolean; +BEGIN + v_is_service_role := ( + ((SELECT auth.jwt() ->> 'role') = 'service_role') + OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres') + ); + + IF NOT v_is_service_role THEN + v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); + IF v_user_id IS NULL OR v_user_id IS DISTINCT FROM get_org_members.user_id THEN + PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('guild_id', get_org_members.guild_id, 'uid', v_user_id, 'requested_uid', get_org_members.user_id)); + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + + IF NOT public.check_min_rights( + 'read'::public.user_min_right, + v_user_id, + get_org_members.guild_id, + NULL::character varying, + NULL::bigint + ) THEN + PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('guild_id', get_org_members.guild_id, 'uid', v_user_id)); + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + END IF; + + RETURN QUERY + -- Get existing org members + SELECT o.id AS aid, users.id AS uid, users.email, users.image_url, o.user_right AS role, false AS is_tmp + FROM public.org_users o + JOIN public.users ON users.id = o.user_id + WHERE o.org_id = get_org_members.guild_id + AND public.is_member_of_org(users.id, o.org_id) + UNION + -- Get pending invitations from tmp_users + SELECT + ((SELECT COALESCE(MAX(id), 0) FROM public.org_users) + tmp.id)::bigint AS aid, + tmp.future_uuid AS uid, + tmp.email::varchar, + ''::varchar AS image_url, + public.transform_role_to_invite(tmp.role) AS role, + true AS is_tmp + FROM public.tmp_users tmp + WHERE tmp.org_id = get_org_members.guild_id + AND tmp.cancelled_at IS NULL + AND GREATEST(tmp.updated_at, tmp.created_at) > (CURRENT_TIMESTAMP - INTERVAL '7 days'); +END; +$$; + +ALTER FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) OWNER TO "postgres"; +ALTER FUNCTION "public"."get_org_members" ("guild_id" "uuid") OWNER TO "postgres"; + +GRANT EXECUTE ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "authenticated"; +GRANT EXECUTE ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "service_role"; +GRANT EXECUTE ON FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) TO "authenticated"; +GRANT EXECUTE ON FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) TO "service_role"; +REVOKE ALL ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") FROM PUBLIC; +REVOKE ALL ON FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") + RETURNS TABLE ( + "user_id" "uuid", + "email" text, + "first_name" text, + "last_name" text, + "password_policy_compliant" boolean + ) + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_user_id uuid; + v_is_service_role boolean; +BEGIN + v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); + v_is_service_role := ( + ((SELECT auth.jwt() ->> 'role') = 'service_role') + OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres') + ); + + IF NOT v_is_service_role THEN + IF v_user_id IS NULL OR NOT ( + public.check_min_rights( + 'super_admin'::public.user_min_right, + (SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], check_org_members_password_policy.org_id)), + check_org_members_password_policy.org_id, + NULL::character varying, + NULL::bigint + ) + ) THEN + RAISE EXCEPTION 'NO_RIGHTS'; + END IF; + END IF; + + -- Check if org exists + IF NOT EXISTS (SELECT 1 FROM public.orgs WHERE public.orgs.id = check_org_members_password_policy.org_id) THEN + RAISE EXCEPTION 'Organization does not exist'; + END IF; + + RETURN QUERY + SELECT + ou.user_id, + au.email::text, + u.first_name::text, + u.last_name::text, + public.user_meets_password_policy(ou.user_id, check_org_members_password_policy.org_id) AS "password_policy_compliant" + FROM public.org_users ou + JOIN auth.users au ON au.id = ou.user_id + LEFT JOIN public.users u ON u.id = ou.user_id + WHERE ou.org_id = check_org_members_password_policy.org_id; +END; +$$; + +ALTER FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") OWNER TO "postgres"; +GRANT EXECUTE ON FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") TO "authenticated"; +GRANT EXECUTE ON FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") TO "service_role"; +REVOKE ALL ON FUNCTION "public"."check_org_members_password_policy"("org_id" "uuid") FROM PUBLIC; From c8676527658f2608132f63b10806f20ee8f19c1c Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 24 Feb 2026 23:29:43 +0000 Subject: [PATCH 2/2] fix(security): enforce rpc role checks and tighten org RPC grants --- ...260224000000_fix_org_member_rpc_access.sql | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/supabase/migrations/20260224000000_fix_org_member_rpc_access.sql b/supabase/migrations/20260224000000_fix_org_member_rpc_access.sql index 84b27f55bd..504f2c76ef 100644 --- a/supabase/migrations/20260224000000_fix_org_member_rpc_access.sql +++ b/supabase/migrations/20260224000000_fix_org_member_rpc_access.sql @@ -19,7 +19,7 @@ BEGIN v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); v_is_service_role := ( ((SELECT auth.jwt() ->> 'role') = 'service_role') - OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres') + OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') ); IF NOT v_is_service_role THEN @@ -58,7 +58,7 @@ DECLARE BEGIN v_is_service_role := ( ((SELECT auth.jwt() ->> 'role') = 'service_role') - OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres') + OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') ); IF NOT v_is_service_role THEN @@ -86,11 +86,10 @@ BEGIN FROM public.org_users o JOIN public.users ON users.id = o.user_id WHERE o.org_id = get_org_members.guild_id - AND public.is_member_of_org(users.id, o.org_id) UNION -- Get pending invitations from tmp_users SELECT - ((SELECT COALESCE(MAX(id), 0) FROM public.org_users) + tmp.id)::bigint AS aid, + (-tmp.id)::bigint AS aid, tmp.future_uuid AS uid, tmp.email::varchar, ''::varchar AS image_url, @@ -108,7 +107,6 @@ ALTER FUNCTION "public"."get_org_members" ("guild_id" "uuid") OWNER TO "postgres GRANT EXECUTE ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "authenticated"; GRANT EXECUTE ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") TO "service_role"; -GRANT EXECUTE ON FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) TO "authenticated"; GRANT EXECUTE ON FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) TO "service_role"; REVOKE ALL ON FUNCTION "public"."get_org_members" ("guild_id" "uuid") FROM PUBLIC; REVOKE ALL ON FUNCTION "public"."get_org_members" ("user_id" uuid, "guild_id" uuid) FROM PUBLIC; @@ -128,11 +126,11 @@ DECLARE v_user_id uuid; v_is_service_role boolean; BEGIN - v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); - v_is_service_role := ( - ((SELECT auth.jwt() ->> 'role') = 'service_role') - OR ((SELECT current_user) IS NOT DISTINCT FROM 'postgres') - ); + v_user_id := public.get_identity('{read,upload,write,all}'::public.key_mode[]); + v_is_service_role := ( + ((SELECT auth.jwt() ->> 'role') = 'service_role') + OR ((SELECT session_user) IS NOT DISTINCT FROM 'postgres') + ); IF NOT v_is_service_role THEN IF v_user_id IS NULL OR NOT ( @@ -144,6 +142,7 @@ BEGIN NULL::bigint ) ) THEN + PERFORM public.pg_log('deny: NO_RIGHTS', jsonb_build_object('org_id', check_org_members_password_policy.org_id, 'uid', v_user_id)); RAISE EXCEPTION 'NO_RIGHTS'; END IF; END IF;