Skip to content

Restrict invite_user_to_org RPC to authenticated callers#1710

Open
riderx wants to merge 1 commit intomainfrom
riderx/fix-invite-oracle
Open

Restrict invite_user_to_org RPC to authenticated callers#1710
riderx wants to merge 1 commit intomainfrom
riderx/fix-invite-oracle

Conversation

@riderx
Copy link
Member

@riderx riderx commented Feb 27, 2026

Summary (AI generated)

  • Hardened public.invite_user_to_org to remove org existence enumeration by returning NO_RIGHTS for missing org_id and unauthenticated callers.
  • Replaced the function body in a migration and removed anon execute access while keeping authenticated and service-role grants.
  • Preserved existing permission checks and invitation behavior for authorized callers (OK, ALREADY_INVITED, NO_EMAIL, etc.).

Test plan (AI generated)

  • Ran bun lint:backend.

Screenshots (AI generated)

  • N/A (backend-only migration change).

Checklist (AI generated)

  • My code follows the code style of this project and passes bun run lint:backend.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • My change has adequate E2E test coverage.
  • I have tested my code manually, and I have provided steps how to reproduce my tests.

Summary by CodeRabbit

  • Bug Fixes
    • Strengthened security for organization user invitations with enhanced validation of user permissions and authentication requirements.
    • Improved handling of various invitation scenarios including existing users, pending invitations, and time-based restrictions with clearer status outcomes.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

A new SQL function invite_user_to_org is added with SECURITY DEFINER to manage user invitations to organizations. It authenticates callers, verifies organization-level and super-admin rights, enforces 2FA gating, handles existing invitations, and returns appropriate status codes based on invitation outcomes.

Changes

Cohort / File(s) Summary
Database Migration
supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql
New public SQL function with SECURITY DEFINER that handles user invitations to organizations, including caller authentication, RBAC verification, 2FA gating, and multiple invitation status outcomes (NO_RIGHTS, OK, ALREADY_INVITED, NO_EMAIL, TOO_RECENT_INVITATION_CANCELATION).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

💰 Rewarded

Poem

🐰 A function hops with care so tight,
Security checks left and right,
Two-factor gates and roles align,
Invitations flow by design,
Auth and RBAC, a rabbity delight!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: restricting the RPC function to authenticated callers only, which is the primary security hardening objective of the PR.
Description check ✅ Passed The description covers the key sections of the template including summary, test plan, and screenshots, with adequate detail about the security changes and limitations of testing performed.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch riderx/fix-invite-oracle

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 SQLFluff (4.0.4)
supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql

User Error: No dialect was specified. You must configure a dialect or specify one on the command line using --dialect after the command. Available dialects:
ansi, athena, bigquery, clickhouse, databricks, db2, doris, duckdb, exasol, flink, greenplum, hive, impala, mariadb, materialize, mysql, oracle, postgres, redshift, snowflake, soql, sparksql, sqlite, starrocks, teradata, trino, tsql, vertica


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c7b781740

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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

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 👍 / 👎.

@sonarqubecloud
Copy link

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql`:
- Around line 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.
- Around line 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.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e719357 and 6c7b781.

📒 Files selected for processing (1)
  • supabase/migrations/20260227150000_fix_invite_user_to_org_security.sql

Comment on lines +62 to +63
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()));
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.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant