Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
-- Harden invite_user_to_org RPC against anonymous enumeration and disclosure.

CREATE OR REPLACE FUNCTION public.invite_user_to_org(
"email" character varying,
"org_id" uuid,
"invite_type" public.user_min_right
) RETURNS character varying
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
org record;
invited_user record;
current_record record;
current_tmp_user record;
calling_user_id uuid;
v_is_super_admin boolean := false;
v_use_rbac boolean := false;
BEGIN
-- Get the calling user's ID.
SELECT public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], invite_user_to_org.org_id)
INTO calling_user_id;

-- Treat missing orgs as unauthorized to avoid org existence enumeration.
SELECT * INTO org FROM public.orgs WHERE public.orgs.id=invite_user_to_org.org_id;
IF org IS NULL OR calling_user_id IS NULL THEN
RETURN 'NO_RIGHTS';
END IF;

-- Check if user has at least public.rbac_right_admin() rights.
IF NOT public.check_min_rights(public.rbac_right_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN
PERFORM public.pg_log('deny: NO_RIGHTS_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type));
RETURN 'NO_RIGHTS';
END IF;

-- If inviting as super_admin, caller must be super_admin.
IF (invite_type = public.rbac_right_super_admin()::public.user_min_right OR invite_type = public.rbac_right_invite_super_admin()::public.user_min_right) THEN
v_use_rbac := public.rbac_is_enabled_for_org(invite_user_to_org.org_id);

IF v_use_rbac THEN
SELECT EXISTS (
SELECT 1
FROM public.role_bindings rb
JOIN public.roles r ON r.id = rb.role_id
WHERE rb.principal_type = public.rbac_principal_user()
AND rb.principal_id = calling_user_id
AND (
(rb.scope_type = public.rbac_scope_org()
AND rb.org_id = invite_user_to_org.org_id
AND r.name = public.rbac_role_org_super_admin())
OR
(rb.scope_type = public.rbac_scope_platform()
AND r.name = public.rbac_role_platform_super_admin())
)
) INTO v_is_super_admin;

IF NOT v_is_super_admin THEN
PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type));
RETURN 'NO_RIGHTS';
END IF;

IF org.enforcing_2fa AND NOT public.has_2fa_enabled(auth.uid()) THEN
PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type, 'uid', auth.uid()));
Comment on lines +62 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n /dev/null && find . -name "*fix_invite_user_to_org_security.sql" -type f

Repository: Cap-go/capgo

Length of output: 128


🏁 Script executed:

find . -path "*/supabase/migrations/*" -name "*fix_invite_user_to_org_security.sql" -type f

Repository: Cap-go/capgo

Length of output: 128


🏁 Script executed:

git ls-files | grep -E "supabase/migrations/.*fix_invite_user_to_org_security"

Repository: Cap-go/capgo

Length of output: 126


🏁 Script executed:

git ls-files | grep -E "supabase/migrations/.*2026" | head -20

Repository: Cap-go/capgo

Length of output: 1409


🏁 Script executed:

ls -la supabase/migrations/ | grep "2026" | head -20

Repository: Cap-go/capgo

Length of output: 2029


🏁 Script executed:

fd "fix_invite_user_to_org_security" supabase/migrations/

Repository: Cap-go/capgo

Length of output: 126


🏁 Script executed:

ls -lh supabase/migrations/ | tail -30

Repository: Cap-go/capgo

Length of output: 3016


🏁 Script executed:

rg -l "fix_invite_user_to_org_security" supabase/migrations/

Repository: Cap-go/capgo

Length of output: 38


🏁 Script executed:

git ls-files supabase/migrations/ | tail -10

Repository: Cap-go/capgo

Length of output: 745


🏁 Script executed:

rg -l "SUPER_ADMIN_2FA_REQUIRED" supabase/

Repository: Cap-go/capgo

Length of output: 221


🏁 Script executed:

rg -B5 -A5 "SUPER_ADMIN_2FA_REQUIRED" supabase/

Repository: Cap-go/capgo

Length of output: 6352


🏁 Script executed:

cat -n supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql | head -100

Repository: Cap-go/capgo

Length of output: 5007


🏁 Script executed:

cat -n supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql

Repository: Cap-go/capgo

Length of output: 6138


🏁 Script executed:

rg -A10 "CREATE.*FUNCTION.*get_identity_org_allowed" supabase/

Repository: Cap-go/capgo

Length of output: 3596


🏁 Script executed:

rg -B2 -A2 "has_2fa_enabled" supabase/ | head -60

Repository: Cap-go/capgo

Length of output: 4242


🏁 Script executed:

rg -A15 "CREATE.*FUNCTION.*has_2fa_enabled" supabase/migrations/ | head -80

Repository: Cap-go/capgo

Length of output: 2714


🏁 Script executed:

rg -A8 "CREATE OR REPLACE FUNCTION public.has_2fa_enabled" supabase/schemas/prod.sql

Repository: Cap-go/capgo

Length of output: 38


🏁 Script executed:

cat -n supabase/migrations/20260102140000_fix_get_identity_hashed_apikeys.sql | head -100

Repository: Cap-go/capgo

Length of output: 4239


🏁 Script executed:

sed -n '100,160p' supabase/migrations/20260102140000_fix_get_identity_hashed_apikeys.sql

Repository: Cap-go/capgo

Length of output: 1957


🏁 Script executed:

rg -B5 "calling_user_id" supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql | head -40

Repository: Cap-go/capgo

Length of output: 1620


Use the resolved caller identity for 2FA checks and audit logging.

Authorization is checked against calling_user_id (resolved via get_identity_org_allowed() to handle both JWT and API key auth), but the 2FA check at lines 62-63 switches to auth.uid(). In service-role or API-key-mediated flows, auth.uid() is NULL while calling_user_id contains the actual authenticated user, causing the 2FA check to be bypassed or invalid and audit logging to record NULL instead of the actual caller.

🔧 Proposed fix
-      IF org.enforcing_2fa AND NOT public.has_2fa_enabled(auth.uid()) THEN
-        PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type, 'uid', auth.uid()));
+      IF org.enforcing_2fa AND NOT public.has_2fa_enabled(calling_user_id) THEN
+        PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type, 'uid', calling_user_id));
         RETURN 'NO_RIGHTS';
       END IF;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
IF org.enforcing_2fa AND NOT public.has_2fa_enabled(auth.uid()) THEN
PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type, 'uid', auth.uid()));
IF org.enforcing_2fa AND NOT public.has_2fa_enabled(calling_user_id) THEN
PERFORM public.pg_log('deny: SUPER_ADMIN_2FA_REQUIRED', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type, 'uid', calling_user_id));
RETURN 'NO_RIGHTS';
END IF;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql`
around lines 62 - 63, The 2FA check and audit log are using auth.uid() but
authorization earlier uses calling_user_id from get_identity_org_allowed(), so
update the IF and pg_log call in invite_user_to_org to use calling_user_id
instead of auth.uid(): call public.has_2fa_enabled(calling_user_id) in the IF
condition when org.enforcing_2fa is true, and include 'uid': calling_user_id in
the jsonb_build_object passed to public.pg_log so the correct resolved caller is
checked and recorded; keep all other logic and names (invite_user_to_org,
org.enforcing_2fa, public.has_2fa_enabled, public.pg_log) unchanged.

RETURN 'NO_RIGHTS';
END IF;
ELSE
IF NOT public.check_min_rights(public.rbac_right_super_admin()::public.user_min_right, calling_user_id, invite_user_to_org.org_id, NULL::varchar, NULL::bigint) THEN
PERFORM public.pg_log('deny: NO_RIGHTS_SUPER_ADMIN', jsonb_build_object('org_id', invite_user_to_org.org_id, 'invite_type', invite_user_to_org.invite_type));
RETURN 'NO_RIGHTS';
END IF;
END IF;
END IF;

-- Check if user already exists.
SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email=invite_user_to_org.email;

IF invited_user IS NOT NULL THEN
-- User exists, check if already in org.
SELECT public.org_users.id INTO current_record
FROM public.org_users
WHERE public.org_users.user_id=invited_user.id
AND public.org_users.org_id=invite_user_to_org.org_id;

IF current_record IS NOT NULL THEN
RETURN 'ALREADY_INVITED';
ELSE
-- Add user to org.
INSERT INTO public.org_users (user_id, org_id, user_right)
VALUES (invited_user.id, invite_user_to_org.org_id, invite_type);
RETURN 'OK';
END IF;
ELSE
-- User doesn't exist, check tmp_users for pending invitations.
SELECT * INTO current_tmp_user
FROM public.tmp_users
WHERE public.tmp_users.email=invite_user_to_org.email
AND public.tmp_users.org_id=invite_user_to_org.org_id;

IF current_tmp_user IS NOT NULL THEN
-- Invitation already exists.
IF current_tmp_user.cancelled_at IS NOT NULL THEN
-- Invitation was cancelled, check if recent.
IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN
RETURN 'TOO_RECENT_INVITATION_CANCELATION';
ELSE
RETURN 'NO_EMAIL';
END IF;
ELSE
RETURN 'ALREADY_INVITED';
END IF;
ELSE
-- No invitation exists, need to create one (handled elsewhere).
RETURN 'NO_EMAIL';
END IF;
END IF;
END;
$$;

REVOKE EXECUTE ON FUNCTION public.invite_user_to_org(
character varying,
uuid,
public.user_min_right
) FROM "anon";
Comment on lines +119 to +123

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reinstate anon execute on invite_user_to_org

/organization/members still calls invite_user_to_org for non-RBAC orgs (supabase/functions/_backend/public/organization/members/post.ts, legacy branch) through supabaseApikey, which authenticates with the anon key plus capgkey header (supabase/functions/_backend/utils/supabase.ts). Revoking anon execute here causes those valid API-key requests to fail with function-permission errors, so member invites break for legacy orgs; keep anon execute and rely on the function’s internal get_identity_org_allowed/check_min_rights checks to reject unauthenticated callers.

Useful? React with 👍 / 👎.


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";
Comment on lines +119 to +135
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for the migration file
fd -type f -name "*fix_invite_user_to_org_security.sql"

Repository: Cap-go/capgo

Length of output: 226


🏁 Script executed:

# Also check for any existing invite_user_to_org function definitions
rg -n "CREATE.*FUNCTION.*invite_user_to_org|DROP FUNCTION.*invite_user_to_org" supabase/migrations/

Repository: Cap-go/capgo

Length of output: 1140


🏁 Script executed:

# Search for patterns of PUBLIC revokes in other migrations
rg -n "REVOKE EXECUTE.*FROM PUBLIC|REVOKE.*FROM \"PUBLIC\"" supabase/migrations/ -A 2 -B 2

Repository: Cap-go/capgo

Length of output: 694


🏁 Script executed:

# Read the target migration file to understand full context
cat -n supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql

Repository: Cap-go/capgo

Length of output: 6138


🏁 Script executed:

# Check the previous migration that modified invite_user_to_org
cat -n supabase/migrations/20260203201308_rbac_org_member_no_app_access.sql | head -250

Repository: Cap-go/capgo

Length of output: 10909


🏁 Script executed:

# Search for all ACL modifications to invite_user_to_org function
rg -n "GRANT|REVOKE.*invite_user_to_org" supabase/migrations/ -B 2 -A 2

Repository: Cap-go/capgo

Length of output: 50369


🏁 Script executed:

# Check if there are any ALTER DEFAULT PRIVILEGES statements affecting functions
rg -n "ALTER DEFAULT PRIVILEGES" supabase/migrations/ -A 2 -B 2

Repository: Cap-go/capgo

Length of output: 3833


🌐 Web query:

PostgreSQL CREATE OR REPLACE FUNCTION preserves ACL permissions public role

💡 Result:

In PostgreSQL, CREATE OR REPLACE FUNCTION preserves the existing function’s ownership and permissions (ACL) when it replaces an existing function with the same name + same input argument types—so whatever you previously GRANTed/REVOKEd (including for the PUBLIC pseudo-role) remains in effect. [1]

What can make it look like “PUBLIC got EXECUTE again”:

  • If you didn’t actually replace the same function (e.g., you changed the argument types / effectively created a new overloaded function), then you created a new object, and newly created functions default to granting EXECUTE to PUBLIC unless you revoke it (or change default privileges). [1][2]
  • If you DROP + CREATE instead of OR REPLACE, it’s a new object, so old grants are not retained. [1]

So: Yes, OR REPLACE preserves ACLs for PUBLIC (and everyone else), as long as it truly replaces the same function signature. [1]

Sources: PostgreSQL CREATE FUNCTION docs on OR REPLACE preserving ownership/permissions [1], PostgreSQL privileges docs on default EXECUTE to PUBLIC for newly created functions [2].

[1] (postgresql.org)
[2] (postgresql.org)

Citations:


Add explicit REVOKE EXECUTE FROM PUBLIC before the anon revoke to fully close inherited access.

The function inherits EXECUTE on PUBLIC from the base migration defaults. Revoking from "anon" alone leaves the PUBLIC role with execute rights. Since CREATE OR REPLACE FUNCTION preserves existing ACLs, the function retains its PUBLIC grant unless explicitly revoked first. Add the PUBLIC revoke to match the pattern used elsewhere for security-critical functions:

Proposed fix
+REVOKE EXECUTE ON FUNCTION public.invite_user_to_org(
+  character varying,
+  uuid,
+  public.user_min_right
+) FROM PUBLIC;
+
 REVOKE EXECUTE ON FUNCTION public.invite_user_to_org(
   character varying,
   uuid,
   public.user_min_right
 ) FROM "anon";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
REVOKE EXECUTE 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 EXECUTE ON FUNCTION public.invite_user_to_org(
character varying,
uuid,
public.user_min_right
) FROM PUBLIC;
REVOKE EXECUTE 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";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql`
around lines 119 - 135, The function public.invite_user_to_org currently revokes
EXECUTE only from "anon" but inherits EXECUTE from PUBLIC; before the existing
REVOKE on "anon" add an explicit REVOKE EXECUTE ON FUNCTION
public.invite_user_to_org(character varying, uuid, public.user_min_right) FROM
PUBLIC so the PUBLIC grant is removed first, then keep the existing REVOKE FROM
"anon" and the subsequent GRANTs to "authenticated" and "service_role"; adjust
the ACL order around the invite_user_to_org function to ensure PUBLIC cannot
execute it after CREATE OR REPLACE.