From 23850d3d1c451ebc31607f8e7b159cc67d877fa0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 14:27:53 +0000 Subject: [PATCH 01/16] fix(rbac): block invite role escalation via tmp_users RPC and acceptance Add rank guards to update_tmp_invite_role_rbac and update_org_invite_role_rbac, track tmp_users.invited_by_user_id, and revalidate inviter rank when invitations are accepted through SQL RPCs and /private/accept_invitation. Co-authored-by: Martin DONADIEU --- .../_backend/private/accept_invitation.ts | 63 ++- .../private/invite_new_user_to_org.ts | 6 + ...2416_fix_invite_role_escalation_guards.sql | 361 ++++++++++++++++++ supabase/schemas/prod.sql | 122 +++++- tests/invite-role-escalation-guards.test.ts | 265 +++++++++++++ 5 files changed, 809 insertions(+), 8 deletions(-) create mode 100644 supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql create mode 100644 tests/invite-role-escalation-guards.test.ts diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 62a86a3042..05c2351cb9 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -5,6 +5,7 @@ import { safeParseSchema } from '../utils/schema_validation.ts' import { parseBody, quickError, simpleError, useCors } from '../utils/hono.ts' import { cloudlog } from '../utils/logging.ts' import { getEffectivePasswordMinLength, getPasswordPolicyValidationErrors } from '../utils/password_policy.ts' +import { closeClient, getPgClient } from '../utils/pg.ts' import { emptySupabase, supabaseAdmin as useSupabaseAdmin } from '../utils/supabase.ts' import { syncUserPreferenceTags } from '../utils/user_preferences.ts' import { getEnv } from '../utils/utils.ts' @@ -146,7 +147,59 @@ async function ensurePublicUserRowExists( } } +async function assertInvitationRoleGrantable( + c: Parameters[0], + invitation: { + org_id: string + rbac_role_name?: string | null + invited_by_user_id?: string | null + }, + rbacRoleName: string, +) { + const pgClient = getPgClient(c) + try { + const inviterResult = invitation.invited_by_user_id + ? { rows: [{ inviter_id: invitation.invited_by_user_id }] } + : await pgClient.query<{ inviter_id: string | null }>( + ` + SELECT COALESCE(tmp_users.invited_by_user_id, orgs.created_by) AS inviter_id + FROM public.tmp_users + JOIN public.orgs ON orgs.id = tmp_users.org_id + WHERE tmp_users.org_id = $1::uuid + AND tmp_users.rbac_role_name = $2 + LIMIT 1 + `, + [invitation.org_id, rbacRoleName], + ) + + const inviterId = inviterResult.rows[0]?.inviter_id + if (!inviterId) { + return quickError(403, 'failed_to_accept_invitation', 'Invitation inviter could not be validated', { + error: 'Missing invitation inviter', + }) + } + + await pgClient.query( + `SELECT public.assert_principal_can_grant_org_role($1::uuid, $2::uuid, $3, 'accept_new_user_invitation')`, + [invitation.org_id, inviterId, rbacRoleName], + ) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('Admins cannot elevate privileges!')) { + return quickError(403, 'failed_to_accept_invitation', 'Invitation role exceeds inviter privileges', { + error: message, + }) + } + return quickError(500, 'failed_to_accept_invitation', 'Failed to validate invitation role', { error: message }) + } + finally { + await closeClient(pgClient) + } +} + async function ensureOrgMembership( + c: Parameters[0], supabaseAdmin: ReturnType, userId: string, invitation: any, @@ -159,6 +212,10 @@ async function ensureOrgMembership( return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: 'Missing RBAC role name' }) } + const grantableError = await assertInvitationRoleGrantable(c, invitation, rbacRoleName) + if (grantableError) + return grantableError + let rbacRoleId: string | null = null const { data: role, error: roleError } = await supabaseAdmin @@ -318,7 +375,7 @@ app.post('/', async (c) => { } const userId = session.user?.id ?? existingUser.id - const membershipError = await ensureOrgMembership(supabaseAdmin, userId, invitation) + const membershipError = await ensureOrgMembership(c, supabaseAdmin, userId, invitation) if (membershipError) return membershipError @@ -387,7 +444,7 @@ app.post('/', async (c) => { if (publicUserError) return publicUserError - const membershipError = await ensureOrgMembership(supabaseAdmin, session.user.id, invitation) + const membershipError = await ensureOrgMembership(c, supabaseAdmin, session.user.id, invitation) if (membershipError) return membershipError @@ -472,7 +529,7 @@ app.post('/', async (c) => { return quickError(400, 'sign_in_failed', 'Sign in failed, please retry', { error: sessionError.message }) } - const membershipError = await ensureOrgMembership(supabaseAdmin, user.user.id, invitation) + const membershipError = await ensureOrgMembership(c, supabaseAdmin, user.user.id, invitation) if (membershipError) { didRollback = true await rollbackCreatedUser(c, user.user.id) diff --git a/supabase/functions/_backend/private/invite_new_user_to_org.ts b/supabase/functions/_backend/private/invite_new_user_to_org.ts index 76c4a188c5..f23c3cbd27 100644 --- a/supabase/functions/_backend/private/invite_new_user_to_org.ts +++ b/supabase/functions/_backend/private/invite_new_user_to_org.ts @@ -152,6 +152,10 @@ app.post('/', middlewareAuth, async (c) => { const rbacRoleName = res.rbacRoleName const inviteCreatorUser = res.inviteCreatorUser const org = res.org + const authContext = c.get('auth') + if (!authContext?.userId) { + throw simpleError('failed_to_invite_user', 'Failed to invite user', {}, 'Not authorized') + } // Use admin client for tmp_users operations since RLS blocks all access on that table const supabaseAdminClient = supabaseAdmin(c) @@ -195,6 +199,7 @@ app.post('/', middlewareAuth, async (c) => { first_name: body.first_name, last_name: body.last_name, rbac_role_name: rbacRoleName, + invited_by_user_id: authContext.userId, invite_magic_string: generateInviteMagicString(), }) .eq('email', body.email) @@ -215,6 +220,7 @@ app.post('/', middlewareAuth, async (c) => { rbac_role_name: rbacRoleName, first_name: body.first_name, last_name: body.last_name, + invited_by_user_id: authContext.userId, }).select('*').single() if (createUserError) { diff --git a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql new file mode 100644 index 0000000000..3a0aacd62d --- /dev/null +++ b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql @@ -0,0 +1,361 @@ +-- Block invite-role escalation via tmp_users RPCs and revalidate inviter rank at acceptance. + +CREATE OR REPLACE FUNCTION public.assert_principal_can_grant_org_role( + p_org_id uuid, + p_principal_id uuid, + p_role_name text, + p_mutation text +) RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_target_priority integer; + v_caller_priority integer; +BEGIN + IF p_org_id IS NULL OR p_principal_id IS NULL OR p_role_name IS NULL THEN + PERFORM public.pg_log( + 'deny: RBAC_INVITE_GRANT_UNKNOWN_TARGET', + pg_catalog.jsonb_build_object( + 'org_id', p_org_id, + 'principal_id', p_principal_id, + 'mutation', p_mutation + ) + ); + RAISE EXCEPTION 'Admins cannot elevate privileges!'; + END IF; + + SELECT roles.priority_rank + INTO v_target_priority + FROM public.roles + WHERE roles.name = p_role_name + AND roles.scope_type = public.rbac_scope_org() + AND roles.is_assignable IS TRUE + LIMIT 1; + + IF v_target_priority IS NULL THEN + PERFORM public.pg_log( + 'deny: RBAC_INVITE_GRANT_UNKNOWN_ROLE', + pg_catalog.jsonb_build_object( + 'org_id', p_org_id, + 'principal_id', p_principal_id, + 'role_name', p_role_name, + 'mutation', p_mutation + ) + ); + RAISE EXCEPTION 'Admins cannot elevate privileges!'; + END IF; + + v_caller_priority := public.principal_max_role_priority( + p_org_id, + public.rbac_principal_user(), + p_principal_id + ); + + IF v_caller_priority IS NULL OR v_caller_priority < v_target_priority THEN + PERFORM public.pg_log( + 'deny: RBAC_INVITE_GRANT_PRIORITY_ESCALATION', + pg_catalog.jsonb_build_object( + 'org_id', p_org_id, + 'principal_id', p_principal_id, + 'mutation', p_mutation, + 'caller_max_priority', v_caller_priority, + 'target_role_priority', v_target_priority + ) + ); + RAISE EXCEPTION 'Admins cannot elevate privileges!'; + END IF; +END; +$$; + +ALTER FUNCTION public.assert_principal_can_grant_org_role(uuid, uuid, text, text) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.assert_principal_can_grant_org_role(uuid, uuid, text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.assert_principal_can_grant_org_role(uuid, uuid, text, text) TO service_role; + +ALTER TABLE public.tmp_users + ADD COLUMN IF NOT EXISTS invited_by_user_id uuid; + +UPDATE public.tmp_users tu +SET invited_by_user_id = orgs.created_by +FROM public.orgs +WHERE tu.org_id = orgs.id + AND tu.invited_by_user_id IS NULL; + +CREATE OR REPLACE FUNCTION public.update_tmp_invite_role_rbac( + p_org_id uuid, + p_email text, + p_new_role_name text +) RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + role_id uuid; + role_priority integer; +BEGIN + SELECT r.id, r.priority_rank + INTO role_id, role_priority + FROM public.roles r + WHERE r.name = p_new_role_name + AND r.scope_type = public.rbac_scope_org() + AND r.is_assignable = true + LIMIT 1; + + IF role_id IS NULL THEN + RAISE EXCEPTION 'ROLE_NOT_FOUND'; + END IF; + + IF p_new_role_name = public.rbac_role_org_super_admin() THEN + IF NOT public.rbac_check_permission_request( + public.rbac_perm_org_update_user_roles(), + p_org_id, + NULL::character varying, + NULL::bigint + ) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + ELSE + IF NOT public.rbac_check_permission_request( + public.rbac_perm_org_invite_user(), + p_org_id, + NULL::character varying, + NULL::bigint + ) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + END IF; + + PERFORM public.assert_request_principal_rank( + p_org_id, + role_priority, + 'tmp_invite_role_update' + ); + + UPDATE public.tmp_users + SET rbac_role_name = p_new_role_name, + updated_at = now() + WHERE org_id = p_org_id + AND email = p_email + AND cancelled_at IS NULL; + + IF NOT FOUND THEN + RAISE EXCEPTION 'NO_INVITATION'; + END IF; + + RETURN 'OK'; +END; +$$; + +CREATE OR REPLACE FUNCTION public.update_org_invite_role_rbac( + p_org_id uuid, + p_user_id uuid, + p_new_role_name text +) RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + role_id uuid; + role_priority integer; +BEGIN + SELECT r.id, r.priority_rank + INTO role_id, role_priority + FROM public.roles r + WHERE r.name = p_new_role_name + AND r.scope_type = public.rbac_scope_org() + AND r.is_assignable = true + LIMIT 1; + + IF role_id IS NULL THEN + RAISE EXCEPTION 'ROLE_NOT_FOUND'; + END IF; + + IF p_new_role_name = public.rbac_role_org_super_admin() THEN + IF NOT public.rbac_check_permission_request( + public.rbac_perm_org_update_user_roles(), + p_org_id, + NULL::character varying, + NULL::bigint + ) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + ELSE + IF NOT public.rbac_check_permission_request( + public.rbac_perm_org_invite_user(), + p_org_id, + NULL::character varying, + NULL::bigint + ) THEN + RAISE EXCEPTION 'NO_PERMISSION_TO_UPDATE_ROLES'; + END IF; + END IF; + + PERFORM public.assert_request_principal_rank( + p_org_id, + role_priority, + 'org_invite_role_update' + ); + + UPDATE public.org_users + SET rbac_role_name = p_new_role_name, + updated_at = now() + WHERE org_id = p_org_id + AND user_id = p_user_id + AND is_invite IS TRUE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'NO_INVITATION'; + END IF; + + RETURN 'OK'; +END; +$$; + +CREATE OR REPLACE FUNCTION public.accept_invitation_to_org(org_id uuid) RETURNS character varying +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +SET row_security = off +AS $$ +DECLARE + invite public.org_users%ROWTYPE; + invite_user_id uuid; + invite_org_id uuid; + role_name text; + role_id uuid; + v_inviter_id uuid; +BEGIN + SELECT public.org_users.* + INTO invite + FROM public.org_users + WHERE public.org_users.org_id = accept_invitation_to_org.org_id + AND public.org_users.user_id = auth.uid() + AND public.org_users.is_invite IS TRUE + ORDER BY public.org_users.created_at DESC NULLS LAST, + public.org_users.id DESC + LIMIT 1; + + IF invite.id IS NOT NULL THEN + IF invite.rbac_role_name IS NULL THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + invite_user_id := invite.user_id; + invite_org_id := invite.org_id; + role_name := invite.rbac_role_name; + + SELECT rb.granted_by + INTO v_inviter_id + FROM public.role_bindings rb + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = invite_user_id + AND rb.org_id = invite_org_id + AND rb.scope_type = public.rbac_scope_org() + AND rb.reason IN ('Pending invitation', 'Invited via invite_user_to_org_rbac') + ORDER BY rb.granted_at DESC NULLS LAST + LIMIT 1; + ELSE + SELECT rb.principal_id, rb.org_id, r.name, rb.granted_by + INTO invite_user_id, invite_org_id, role_name, v_inviter_id + FROM public.role_bindings rb + JOIN public.roles r + ON r.id = rb.role_id + AND r.scope_type = rb.scope_type + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = auth.uid() + AND rb.org_id = accept_invitation_to_org.org_id + AND rb.scope_type = public.rbac_scope_org() + AND rb.reason IN ('Pending invitation', 'Invited via invite_user_to_org_rbac') + ORDER BY rb.granted_at DESC NULLS LAST + LIMIT 1; + + IF invite_user_id IS NULL THEN + RETURN 'NO_INVITE'; + END IF; + END IF; + + IF role_name IS NULL THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + SELECT public.roles.id + INTO role_id + FROM public.roles + WHERE public.roles.name = role_name + AND public.roles.scope_type = public.rbac_scope_org() + AND public.roles.is_assignable = true + LIMIT 1; + + IF role_id IS NULL THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + IF v_inviter_id IS NULL THEN + SELECT orgs.created_by + INTO v_inviter_id + FROM public.orgs + WHERE orgs.id = invite_org_id; + END IF; + + PERFORM public.assert_principal_can_grant_org_role( + invite_org_id, + v_inviter_id, + role_name, + 'accept_invitation_to_org' + ); + + -- Keep is_invite true until after the accepted binding is inserted so the + -- privilege guards can verify this is a real invite acceptance. + IF invite.id IS NULL THEN + INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES (invite_user_id, invite_org_id, role_name, true); + END IF; + + DELETE FROM public.role_bindings + WHERE public.role_bindings.principal_type = public.rbac_principal_user() + AND public.role_bindings.principal_id = invite_user_id + AND public.role_bindings.scope_type = public.rbac_scope_org() + AND public.role_bindings.org_id = invite_org_id; + + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + channel_id, + granted_by, + granted_at, + reason, + is_direct + ) VALUES ( + public.rbac_principal_user(), + invite_user_id, + role_id, + public.rbac_scope_org(), + invite_org_id, + NULL, + NULL, + auth.uid(), + now(), + 'Accepted invitation', + true + ) ON CONFLICT DO NOTHING; + + UPDATE public.org_users + SET is_invite = false, + rbac_role_name = role_name, + updated_at = CURRENT_TIMESTAMP + WHERE public.org_users.user_id = invite_user_id + AND public.org_users.org_id = invite_org_id + AND public.org_users.is_invite IS TRUE; + + RETURN 'OK'; +END; +$$; + +COMMENT ON FUNCTION public.assert_principal_can_grant_org_role(uuid, uuid, text, text) + IS 'Ensures a principal can grant an org-scoped role at or below their max rank. Used for invite acceptance validation.'; diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index d99fdf433d..34c02fe6a1 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -431,6 +431,7 @@ DECLARE invite_org_id uuid; role_name text; role_id uuid; + v_inviter_id uuid; BEGIN SELECT public.org_users.* INTO invite @@ -449,9 +450,20 @@ BEGIN invite_user_id := invite.user_id; invite_org_id := invite.org_id; role_name := invite.rbac_role_name; + + SELECT rb.granted_by + INTO v_inviter_id + FROM public.role_bindings rb + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = invite_user_id + AND rb.org_id = invite_org_id + AND rb.scope_type = public.rbac_scope_org() + AND rb.reason IN ('Pending invitation', 'Invited via invite_user_to_org_rbac') + ORDER BY rb.granted_at DESC NULLS LAST + LIMIT 1; ELSE - SELECT rb.principal_id, rb.org_id, r.name - INTO invite_user_id, invite_org_id, role_name + SELECT rb.principal_id, rb.org_id, r.name, rb.granted_by + INTO invite_user_id, invite_org_id, role_name, v_inviter_id FROM public.role_bindings rb JOIN public.roles r ON r.id = rb.role_id @@ -484,6 +496,20 @@ BEGIN RETURN 'ROLE_NOT_FOUND'; END IF; + IF v_inviter_id IS NULL THEN + SELECT orgs.created_by + INTO v_inviter_id + FROM public.orgs + WHERE orgs.id = invite_org_id; + END IF; + + PERFORM public.assert_principal_can_grant_org_role( + invite_org_id, + v_inviter_id, + role_name, + 'accept_invitation_to_org' + ); + -- Keep is_invite true until after the accepted binding is inserted so the -- privilege guards can verify this is a real invite acceptance. IF invite.id IS NULL THEN @@ -1674,6 +1700,73 @@ $$; ALTER FUNCTION "public"."assert_request_principal_rank"("p_org_id" "uuid", "p_target_priority" integer, "p_mutation" "text") OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "public"."assert_principal_can_grant_org_role"("p_org_id" "uuid", "p_principal_id" "uuid", "p_role_name" "text", "p_mutation" "text") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_target_priority integer; + v_caller_priority integer; +BEGIN + IF p_org_id IS NULL OR p_principal_id IS NULL OR p_role_name IS NULL THEN + PERFORM public.pg_log( + 'deny: RBAC_INVITE_GRANT_UNKNOWN_TARGET', + pg_catalog.jsonb_build_object( + 'org_id', p_org_id, + 'principal_id', p_principal_id, + 'mutation', p_mutation + ) + ); + RAISE EXCEPTION 'Admins cannot elevate privileges!'; + END IF; + + SELECT roles.priority_rank + INTO v_target_priority + FROM public.roles + WHERE roles.name = p_role_name + AND roles.scope_type = public.rbac_scope_org() + AND roles.is_assignable IS TRUE + LIMIT 1; + + IF v_target_priority IS NULL THEN + PERFORM public.pg_log( + 'deny: RBAC_INVITE_GRANT_UNKNOWN_ROLE', + pg_catalog.jsonb_build_object( + 'org_id', p_org_id, + 'principal_id', p_principal_id, + 'role_name', p_role_name, + 'mutation', p_mutation + ) + ); + RAISE EXCEPTION 'Admins cannot elevate privileges!'; + END IF; + + v_caller_priority := public.principal_max_role_priority( + p_org_id, + public.rbac_principal_user(), + p_principal_id + ); + + IF v_caller_priority IS NULL OR v_caller_priority < v_target_priority THEN + PERFORM public.pg_log( + 'deny: RBAC_INVITE_GRANT_PRIORITY_ESCALATION', + pg_catalog.jsonb_build_object( + 'org_id', p_org_id, + 'principal_id', p_principal_id, + 'mutation', p_mutation, + 'caller_max_priority', v_caller_priority, + 'target_role_priority', v_target_priority + ) + ); + RAISE EXCEPTION 'Admins cannot elevate privileges!'; + END IF; +END; +$$; + + +ALTER FUNCTION "public"."assert_principal_can_grant_org_role"("p_org_id" "uuid", "p_principal_id" "uuid", "p_role_name" "text", "p_mutation" "text") OWNER TO "postgres"; + + CREATE OR REPLACE FUNCTION "public"."audit_log_trigger"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO '' @@ -19436,8 +19529,9 @@ CREATE OR REPLACE FUNCTION "public"."update_org_invite_role_rbac"("p_org_id" "uu AS $$ DECLARE role_id uuid; + role_priority integer; BEGIN - SELECT id INTO role_id + SELECT r.id, r.priority_rank INTO role_id, role_priority FROM public.roles r WHERE r.name = p_new_role_name AND r.scope_type = public.rbac_scope_org() @@ -19458,6 +19552,12 @@ BEGIN END IF; END IF; + PERFORM public.assert_request_principal_rank( + p_org_id, + role_priority, + 'org_invite_role_update' + ); + UPDATE public.org_users SET rbac_role_name = p_new_role_name, updated_at = now() @@ -19617,8 +19717,9 @@ CREATE OR REPLACE FUNCTION "public"."update_tmp_invite_role_rbac"("p_org_id" "uu AS $$ DECLARE role_id uuid; + role_priority integer; BEGIN - SELECT id INTO role_id + SELECT r.id, r.priority_rank INTO role_id, role_priority FROM public.roles r WHERE r.name = p_new_role_name AND r.scope_type = public.rbac_scope_org() @@ -19639,6 +19740,12 @@ BEGIN END IF; END IF; + PERFORM public.assert_request_principal_rank( + p_org_id, + role_priority, + 'tmp_invite_role_update' + ); + UPDATE public.tmp_users SET rbac_role_name = p_new_role_name, updated_at = now() @@ -22327,7 +22434,8 @@ CREATE TABLE IF NOT EXISTS "public"."tmp_users" ( "cancelled_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, - "rbac_role_name" "text" DEFAULT 'org_member'::"text" NOT NULL + "rbac_role_name" "text" DEFAULT 'org_member'::"text" NOT NULL, + "invited_by_user_id" "uuid" ); @@ -26532,6 +26640,10 @@ REVOKE ALL ON FUNCTION "public"."assert_request_principal_rank"("p_org_id" "uuid GRANT ALL ON FUNCTION "public"."assert_request_principal_rank"("p_org_id" "uuid", "p_target_priority" integer, "p_mutation" "text") TO "service_role"; +REVOKE ALL ON FUNCTION "public"."assert_principal_can_grant_org_role"("p_org_id" "uuid", "p_principal_id" "uuid", "p_role_name" "text", "p_mutation" "text") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."assert_principal_can_grant_org_role"("p_org_id" "uuid", "p_principal_id" "uuid", "p_role_name" "text", "p_mutation" "text") TO "service_role"; + + REVOKE ALL ON FUNCTION "public"."audit_log_trigger"() FROM PUBLIC; GRANT ALL ON FUNCTION "public"."audit_log_trigger"() TO "service_role"; diff --git a/tests/invite-role-escalation-guards.test.ts b/tests/invite-role-escalation-guards.test.ts new file mode 100644 index 0000000000..3afa816eec --- /dev/null +++ b/tests/invite-role-escalation-guards.test.ts @@ -0,0 +1,265 @@ +import type { PoolClient } from 'pg' +import { randomUUID } from 'node:crypto' +import { Pool } from 'pg' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { + createOrgOwnedByUser, + insertPendingOrgInvitation, + POSTGRES_URL, + setAuthenticatedClaim, + setServiceRoleClaim, + USER_ID, + USER_ID_2, + USER_ID_NONMEMBER, +} from './test-utils.ts' + +describe('invite role escalation guards', () => { + let pool: Pool + let client: PoolClient + + const query = (text: string, params?: Array) => client.query(text, params) + + beforeAll(() => { + pool = new Pool({ + connectionString: POSTGRES_URL, + max: 1, + }) + }) + + beforeEach(async () => { + client = await pool.connect() + await client.query('BEGIN') + }) + + afterEach(async () => { + if (!client) + return + try { + await query('ROLLBACK') + } + finally { + client.release() + } + }) + + afterAll(async () => { + await pool.end() + }) + + async function bindOrgRole(orgId: string, userId: string, roleName: string, grantedBy = USER_ID) { + await query( + ` + INSERT INTO public.org_users (org_id, user_id, rbac_role_name, is_invite) + VALUES ($1::uuid, $2::uuid, $3, false) + ON CONFLICT DO NOTHING + `, + [orgId, userId, roleName], + ) + + await query( + ` + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, reason, is_direct + ) + SELECT + public.rbac_principal_user(), + $1::uuid, + roles.id, + public.rbac_scope_org(), + $2::uuid, + $3::uuid, + 'Invite escalation guard test', + true + FROM public.roles + WHERE roles.name = $4 + AND roles.scope_type = public.rbac_scope_org() + ON CONFLICT DO NOTHING + `, + [userId, orgId, grantedBy, roleName], + ) + } + + async function insertTmpInvite(options: { + orgId: string + email: string + roleName: string + invitedBy: string + }) { + await setServiceRoleClaim(query) + await query( + ` + INSERT INTO public.tmp_users ( + email, org_id, rbac_role_name, first_name, last_name, invited_by_user_id + ) VALUES ($1, $2::uuid, $3, 'Escalation', 'Target', $4::uuid) + `, + [options.email, options.orgId, options.roleName, options.invitedBy], + ) + } + + it('blocks org_admin from escalating a tmp_users invite to org_super_admin', async () => { + const orgId = await createOrgOwnedByUser(query, USER_ID, 'Tmp invite escalation org') + await bindOrgRole(orgId, USER_ID_2, 'org_admin') + const email = `tmp-escalation-${randomUUID()}@capgo.app` + await insertTmpInvite({ + orgId, + email, + roleName: 'org_member', + invitedBy: USER_ID_2, + }) + + await setAuthenticatedClaim(query, USER_ID_2) + + let thrown: unknown + try { + await query( + `SELECT public.update_tmp_invite_role_rbac($1::uuid, $2, $3)`, + [orgId, email, 'org_super_admin'], + ) + } + catch (error) { + thrown = error + } + + expect(thrown).toBeTruthy() + expect((thrown as Error).message).toContain('Admins cannot elevate privileges!') + + const invite = await query( + `SELECT rbac_role_name FROM public.tmp_users WHERE org_id = $1::uuid AND email = $2`, + [orgId, email], + ) + expect(invite.rows[0]?.rbac_role_name).toBe('org_member') + }) + + it('allows org_super_admin to set a tmp_users invite role to org_super_admin', async () => { + const orgId = await createOrgOwnedByUser(query, USER_ID, 'Tmp invite super admin org') + const email = `tmp-super-${randomUUID()}@capgo.app` + await insertTmpInvite({ + orgId, + email, + roleName: 'org_member', + invitedBy: USER_ID, + }) + + await setAuthenticatedClaim(query, USER_ID) + const result = await query( + `SELECT public.update_tmp_invite_role_rbac($1::uuid, $2, $3) AS status`, + [orgId, email, 'org_super_admin'], + ) + expect(result.rows[0]?.status).toBe('OK') + + const invite = await query( + `SELECT rbac_role_name FROM public.tmp_users WHERE org_id = $1::uuid AND email = $2`, + [orgId, email], + ) + expect(invite.rows[0]?.rbac_role_name).toBe('org_super_admin') + }) + + it('blocks org_admin from escalating a pending org_users invite to org_super_admin', async () => { + const orgId = await createOrgOwnedByUser(query, USER_ID, 'Org invite escalation org') + await bindOrgRole(orgId, USER_ID_2, 'org_admin') + await setServiceRoleClaim(query) + await insertPendingOrgInvitation(query, { + orgId, + inviteeId: USER_ID_NONMEMBER, + roleName: 'org_member', + grantedBy: USER_ID_2, + }) + + await setAuthenticatedClaim(query, USER_ID_2) + + let thrown: unknown + try { + await query( + `SELECT public.update_org_invite_role_rbac($1::uuid, $2::uuid, $3)`, + [orgId, USER_ID_NONMEMBER, 'org_super_admin'], + ) + } + catch (error) { + thrown = error + } + + expect(thrown).toBeTruthy() + expect((thrown as Error).message).toContain('Admins cannot elevate privileges!') + }) + + it('rejects accept_invitation_to_org when the invite role exceeds inviter rank', async () => { + const orgId = await createOrgOwnedByUser(query, USER_ID, 'Accept invite escalation org') + await bindOrgRole(orgId, USER_ID_2, 'org_admin') + await setServiceRoleClaim(query) + await insertPendingOrgInvitation(query, { + orgId, + inviteeId: USER_ID_NONMEMBER, + roleName: 'org_member', + grantedBy: USER_ID_2, + }) + + await query( + ` + UPDATE public.org_users + SET rbac_role_name = public.rbac_role_org_super_admin() + WHERE org_id = $1::uuid + AND user_id = $2::uuid + `, + [orgId, USER_ID_NONMEMBER], + ) + + await query( + ` + UPDATE public.role_bindings rb + SET role_id = roles.id + FROM public.roles + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = $1::uuid + AND rb.org_id = $2::uuid + AND rb.scope_type = public.rbac_scope_org() + AND roles.name = public.rbac_role_org_super_admin() + AND roles.scope_type = public.rbac_scope_org() + `, + [USER_ID_NONMEMBER, orgId], + ) + + await setAuthenticatedClaim(query, USER_ID_NONMEMBER) + + let thrown: unknown + try { + await query( + `SELECT public.accept_invitation_to_org($1::uuid) AS status`, + [orgId], + ) + } + catch (error) { + thrown = error + } + + expect(thrown).toBeTruthy() + expect((thrown as Error).message).toContain('Admins cannot elevate privileges!') + }) + + it('rejects assert_principal_can_grant_org_role for escalated tmp invite roles', async () => { + const orgId = await createOrgOwnedByUser(query, USER_ID, 'Tmp invite assert org') + await bindOrgRole(orgId, USER_ID_2, 'org_admin') + + await setServiceRoleClaim(query) + let thrown: unknown + try { + await query( + ` + SELECT public.assert_principal_can_grant_org_role( + $1::uuid, + $2::uuid, + public.rbac_role_org_super_admin(), + 'test' + ) + `, + [orgId, USER_ID_2], + ) + } + catch (error) { + thrown = error + } + + expect(thrown).toBeTruthy() + expect((thrown as Error).message).toContain('Admins cannot elevate privileges!') + }) +}) From 4c10a4e9b75c564a6b9b6291fc2f1fb210b96b33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 14:37:20 +0000 Subject: [PATCH 02/16] fix(types): add tmp_users.invited_by_user_id and closeClient args Co-authored-by: Martin DONADIEU --- src/types/supabase.types.ts | 3 +++ supabase/functions/_backend/private/accept_invitation.ts | 2 +- supabase/functions/_backend/utils/supabase.types.ts | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index a4eb9f5d03..0832a71e33 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -3198,6 +3198,7 @@ export type Database = { future_uuid: string id: number invite_magic_string: string + invited_by_user_id: string | null last_name: string org_id: string rbac_role_name: string @@ -3211,6 +3212,7 @@ export type Database = { future_uuid?: string id?: number invite_magic_string?: string + invited_by_user_id?: string | null last_name: string org_id: string rbac_role_name?: string @@ -3224,6 +3226,7 @@ export type Database = { future_uuid?: string id?: number invite_magic_string?: string + invited_by_user_id?: string | null last_name?: string org_id?: string rbac_role_name?: string diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 05c2351cb9..8f9d20ad2b 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -194,7 +194,7 @@ async function assertInvitationRoleGrantable( return quickError(500, 'failed_to_accept_invitation', 'Failed to validate invitation role', { error: message }) } finally { - await closeClient(pgClient) + await closeClient(c, pgClient) } } diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index a4eb9f5d03..0832a71e33 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -3198,6 +3198,7 @@ export type Database = { future_uuid: string id: number invite_magic_string: string + invited_by_user_id: string | null last_name: string org_id: string rbac_role_name: string @@ -3211,6 +3212,7 @@ export type Database = { future_uuid?: string id?: number invite_magic_string?: string + invited_by_user_id?: string | null last_name: string org_id: string rbac_role_name?: string @@ -3224,6 +3226,7 @@ export type Database = { future_uuid?: string id?: number invite_magic_string?: string + invited_by_user_id?: string | null last_name?: string org_id?: string rbac_role_name?: string From 2a67dad640de5a2b8449ea3ffc0faafcfbf751c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 15:02:02 +0000 Subject: [PATCH 03/16] test(rbac): fix tmp_users invite guard tests for RLS and aborted tx Co-authored-by: Martin DONADIEU --- tests/invite-role-escalation-guards.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/invite-role-escalation-guards.test.ts b/tests/invite-role-escalation-guards.test.ts index 3afa816eec..c575410ceb 100644 --- a/tests/invite-role-escalation-guards.test.ts +++ b/tests/invite-role-escalation-guards.test.ts @@ -111,6 +111,7 @@ describe('invite role escalation guards', () => { await setAuthenticatedClaim(query, USER_ID_2) let thrown: unknown + await query('SAVEPOINT invite_escalation_block') try { await query( `SELECT public.update_tmp_invite_role_rbac($1::uuid, $2, $3)`, @@ -119,11 +120,13 @@ describe('invite role escalation guards', () => { } catch (error) { thrown = error + await query('ROLLBACK TO SAVEPOINT invite_escalation_block') } expect(thrown).toBeTruthy() expect((thrown as Error).message).toContain('Admins cannot elevate privileges!') + await setServiceRoleClaim(query) const invite = await query( `SELECT rbac_role_name FROM public.tmp_users WHERE org_id = $1::uuid AND email = $2`, [orgId, email], @@ -148,6 +151,7 @@ describe('invite role escalation guards', () => { ) expect(result.rows[0]?.status).toBe('OK') + await setServiceRoleClaim(query) const invite = await query( `SELECT rbac_role_name FROM public.tmp_users WHERE org_id = $1::uuid AND email = $2`, [orgId, email], From 9eb6d71b2c2f8eee90b05e850fcd873e9a169fe5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 15:20:12 +0000 Subject: [PATCH 04/16] chore: retrigger CI after flaky Cloudflare shard Co-authored-by: Martin DONADIEU From f7fea62779ed94c6a74e72ef8dda914dd8056469 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 16:09:32 +0000 Subject: [PATCH 05/16] fix(rbac): address review feedback on invite acceptance guards - Drop unsafe tmp_users inviter backfill; require proven inviter at accept - Lock RBAC org scope before inviter rank validation in accept_invitation_to_org - Scope accept_invitation inviter lookup to the selected invitation row - Sync invited_by_user_id across committed Supabase type copies Co-authored-by: Martin DONADIEU --- bun.lock | 2 +- cli/src/types/supabase.types.ts | 3 +++ .../_backend/plugin_runtime/utils/supabase.types.ts | 3 +++ .../functions/_backend/private/accept_invitation.ts | 8 ++++---- ...0908142416_fix_invite_role_escalation_guards.sql | 13 +++---------- supabase/schemas/prod.sql | 7 +++---- 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/bun.lock b/bun.lock index 1c8667060d..24a14b221f 100644 --- a/bun.lock +++ b/bun.lock @@ -211,7 +211,7 @@ }, "cli": { "name": "@capgo/cli", - "version": "8.47.1", + "version": "8.48.2", "bin": { "capgo": "dist/index.js", }, diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index 3dad914f2f..50ddb1eab5 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -2785,6 +2785,7 @@ export type Database = { future_uuid: string id: number invite_magic_string: string + invited_by_user_id: string | null last_name: string org_id: string rbac_role_name: string @@ -2798,6 +2799,7 @@ export type Database = { future_uuid?: string id?: number invite_magic_string?: string + invited_by_user_id?: string | null last_name: string org_id: string rbac_role_name?: string @@ -2811,6 +2813,7 @@ export type Database = { future_uuid?: string id?: number invite_magic_string?: string + invited_by_user_id?: string | null last_name?: string org_id?: string rbac_role_name?: string diff --git a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts index b06b835891..f91e4d9a31 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts @@ -2983,6 +2983,7 @@ export type Database = { future_uuid: string id: number invite_magic_string: string + invited_by_user_id: string | null last_name: string org_id: string rbac_role_name: string @@ -2996,6 +2997,7 @@ export type Database = { future_uuid?: string id?: number invite_magic_string?: string + invited_by_user_id?: string | null last_name: string org_id: string rbac_role_name?: string @@ -3009,6 +3011,7 @@ export type Database = { future_uuid?: string id?: number invite_magic_string?: string + invited_by_user_id?: string | null last_name?: string org_id?: string rbac_role_name?: string diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 8f9d20ad2b..2ad35dc21a 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -162,14 +162,14 @@ async function assertInvitationRoleGrantable( ? { rows: [{ inviter_id: invitation.invited_by_user_id }] } : await pgClient.query<{ inviter_id: string | null }>( ` - SELECT COALESCE(tmp_users.invited_by_user_id, orgs.created_by) AS inviter_id + SELECT tmp_users.invited_by_user_id AS inviter_id FROM public.tmp_users - JOIN public.orgs ON orgs.id = tmp_users.org_id WHERE tmp_users.org_id = $1::uuid - AND tmp_users.rbac_role_name = $2 + AND tmp_users.email = $2 + AND tmp_users.rbac_role_name = $3 LIMIT 1 `, - [invitation.org_id, rbacRoleName], + [invitation.org_id, invitation.email, rbacRoleName], ) const inviterId = inviterResult.rows[0]?.inviter_id diff --git a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql index 3a0aacd62d..42bde35505 100644 --- a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql +++ b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql @@ -76,12 +76,6 @@ GRANT EXECUTE ON FUNCTION public.assert_principal_can_grant_org_role(uuid, uuid, ALTER TABLE public.tmp_users ADD COLUMN IF NOT EXISTS invited_by_user_id uuid; -UPDATE public.tmp_users tu -SET invited_by_user_id = orgs.created_by -FROM public.orgs -WHERE tu.org_id = orgs.id - AND tu.invited_by_user_id IS NULL; - CREATE OR REPLACE FUNCTION public.update_tmp_invite_role_rbac( p_org_id uuid, p_email text, @@ -293,12 +287,11 @@ BEGIN END IF; IF v_inviter_id IS NULL THEN - SELECT orgs.created_by - INTO v_inviter_id - FROM public.orgs - WHERE orgs.id = invite_org_id; + RETURN 'INVITER_NOT_FOUND'; END IF; + PERFORM public.lock_rbac_orgs(invite_org_id); + PERFORM public.assert_principal_can_grant_org_role( invite_org_id, v_inviter_id, diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index 34c02fe6a1..ff3e7e57b9 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -497,12 +497,11 @@ BEGIN END IF; IF v_inviter_id IS NULL THEN - SELECT orgs.created_by - INTO v_inviter_id - FROM public.orgs - WHERE orgs.id = invite_org_id; + RETURN 'INVITER_NOT_FOUND'; END IF; + PERFORM public.lock_rbac_orgs(invite_org_id); + PERFORM public.assert_principal_can_grant_org_role( invite_org_id, v_inviter_id, From 85d892105209d84d593ce56702ac9ebba84b1296 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 16:10:06 +0000 Subject: [PATCH 06/16] fix(types): include invitation email in grantable assertion input Co-authored-by: Martin DONADIEU --- supabase/functions/_backend/private/accept_invitation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 2ad35dc21a..1e5f4731a1 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -151,6 +151,7 @@ async function assertInvitationRoleGrantable( c: Parameters[0], invitation: { org_id: string + email: string rbac_role_name?: string | null invited_by_user_id?: string | null }, From fe9b5ce5833c42c68f88cf6e53a4e9d0ab595065 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 16:39:49 +0000 Subject: [PATCH 07/16] chore: retrigger CI after review fixes Co-authored-by: Martin DONADIEU From f9cf26323362994171069c099287bbabe9d5f15c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 16:50:30 +0000 Subject: [PATCH 08/16] fix(rbac): atomic tmp invite acceptance and lock-before-read Add accept_tmp_user_invitation RPC that holds lock_rbac_orgs through inviter rank validation and membership writes. Reject legacy invites with null invited_by_user_id. Reorder accept_invitation_to_org to lock before reading invitation state. Co-authored-by: Martin DONADIEU --- cli/src/types/supabase.types.ts | 4 + src/types/supabase.types.ts | 4 + .../plugin_runtime/utils/supabase.types.ts | 4 + .../_backend/private/accept_invitation.ts | 183 +++--------------- .../_backend/utils/supabase.types.ts | 4 + ...2416_fix_invite_role_escalation_guards.sql | 136 ++++++++++++- supabase/schemas/prod.sql | 137 ++++++++++++- tests/invite-role-escalation-guards.test.ts | 30 ++- ...security-definer-execute-hardening.test.ts | 2 + 9 files changed, 341 insertions(+), 163 deletions(-) diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index 50ddb1eab5..20ff6e213b 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -3402,6 +3402,10 @@ export type Database = { } Functions: { accept_invitation_to_org: { Args: { org_id: string }; Returns: string } + accept_tmp_user_invitation: { + Args: { p_invite_magic_string: string; p_user_id: string } + Returns: string + } acknowledge_compatibility_event: { Args: { event_id: number; note: string } Returns: undefined diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 0832a71e33..fe2c7f0606 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -3886,6 +3886,10 @@ export type Database = { } Functions: { accept_invitation_to_org: { Args: { org_id: string }; Returns: string } + accept_tmp_user_invitation: { + Args: { p_invite_magic_string: string; p_user_id: string } + Returns: string + } acknowledge_compatibility_event: { Args: { event_id: number; note: string } Returns: undefined diff --git a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts index f91e4d9a31..cf968d14ea 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts @@ -3651,6 +3651,10 @@ export type Database = { } Functions: { accept_invitation_to_org: { Args: { org_id: string }; Returns: string } + accept_tmp_user_invitation: { + Args: { p_invite_magic_string: string; p_user_id: string } + Returns: string + } acknowledge_compatibility_event: { Args: { event_id: number; note: string } Returns: undefined diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 1e5f4731a1..a860e5738c 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -147,43 +147,38 @@ async function ensurePublicUserRowExists( } } -async function assertInvitationRoleGrantable( +async function ensureOrgMembership( c: Parameters[0], - invitation: { - org_id: string - email: string - rbac_role_name?: string | null - invited_by_user_id?: string | null - }, - rbacRoleName: string, + userId: string, + magicInviteString: string, ) { const pgClient = getPgClient(c) try { - const inviterResult = invitation.invited_by_user_id - ? { rows: [{ inviter_id: invitation.invited_by_user_id }] } - : await pgClient.query<{ inviter_id: string | null }>( - ` - SELECT tmp_users.invited_by_user_id AS inviter_id - FROM public.tmp_users - WHERE tmp_users.org_id = $1::uuid - AND tmp_users.email = $2 - AND tmp_users.rbac_role_name = $3 - LIMIT 1 - `, - [invitation.org_id, invitation.email, rbacRoleName], - ) - - const inviterId = inviterResult.rows[0]?.inviter_id - if (!inviterId) { - return quickError(403, 'failed_to_accept_invitation', 'Invitation inviter could not be validated', { + const result = await pgClient.query<{ accept_tmp_user_invitation: string }>( + `SELECT public.accept_tmp_user_invitation($1, $2::uuid) AS accept_tmp_user_invitation`, + [magicInviteString, userId], + ) + + const status = result.rows[0]?.accept_tmp_user_invitation + if (status === 'OK') + return + + if (status === 'NO_INVITE') { + return quickError(404, 'failed_to_accept_invitation', 'Invitation not found', { error: 'Invitation not found' }) + } + + // Legacy invites without invited_by_user_id must be reissued by an org admin. + if (status === 'INVITER_NOT_FOUND') { + return quickError(403, 'failed_to_accept_invitation', 'Invitation must be reissued before acceptance', { error: 'Missing invitation inviter', }) } - await pgClient.query( - `SELECT public.assert_principal_can_grant_org_role($1::uuid, $2::uuid, $3, 'accept_new_user_invitation')`, - [invitation.org_id, inviterId, rbacRoleName], - ) + if (status === 'ROLE_NOT_FOUND') { + return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: 'Role not found' }) + } + + return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation', { error: status ?? 'Unknown status' }) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -192,119 +187,13 @@ async function assertInvitationRoleGrantable( error: message, }) } - return quickError(500, 'failed_to_accept_invitation', 'Failed to validate invitation role', { error: message }) + return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation', { error: message }) } finally { await closeClient(c, pgClient) } } -async function ensureOrgMembership( - c: Parameters[0], - supabaseAdmin: ReturnType, - userId: string, - invitation: any, -) { - const rbacRoleName = typeof invitation.rbac_role_name === 'string' - ? invitation.rbac_role_name.trim() - : '' - - if (!rbacRoleName) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: 'Missing RBAC role name' }) - } - - const grantableError = await assertInvitationRoleGrantable(c, invitation, rbacRoleName) - if (grantableError) - return grantableError - - let rbacRoleId: string | null = null - - const { data: role, error: roleError } = await supabaseAdmin - .from('roles') - .select('id') - .eq('name', rbacRoleName) - .eq('scope_type', 'org') - .single() - - if (roleError || !role) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: roleError?.message ?? 'Role not found' }) - } - - rbacRoleId = role.id - - // Avoid creating duplicates: org_users does not have a unique constraint on (org_id, user_id). - const { data: existingMembershipRows, error: existingMembershipError } = await supabaseAdmin - .from('org_users') - .select('id') - .eq('user_id', userId) - .eq('org_id', invitation.org_id) - .is('app_id', null) - .is('channel_id', null) - - if (existingMembershipError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to check existing org membership', { error: existingMembershipError.message }) - } - - if (existingMembershipRows && existingMembershipRows.length > 0) { - const { error: updateMembershipError } = await supabaseAdmin - .from('org_users') - .update({ - rbac_role_name: rbacRoleName, - is_invite: false, - }) - .eq('user_id', userId) - .eq('org_id', invitation.org_id) - .is('app_id', null) - .is('channel_id', null) - - if (updateMembershipError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to update org membership', { error: updateMembershipError.message }) - } - } - else { - const { error: insertIntoMainTableError } = await supabaseAdmin.from('org_users').insert({ - user_id: userId, - org_id: invitation.org_id, - rbac_role_name: rbacRoleName, - is_invite: false, - }) - - if (insertIntoMainTableError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation insert into org_users', { error: insertIntoMainTableError.message }) - } - } - - const { error: deleteBindingError } = await supabaseAdmin - .from('role_bindings') - .delete() - .eq('principal_type', 'user') - .eq('principal_id', userId) - .eq('scope_type', 'org') - .eq('org_id', invitation.org_id) - - if (deleteBindingError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to clear existing RBAC role bindings', { error: deleteBindingError.message }) - } - - const { error: insertBindingError } = await supabaseAdmin - .from('role_bindings') - .insert({ - principal_type: 'user', - principal_id: userId, - role_id: rbacRoleId as string, - scope_type: 'org', - org_id: invitation.org_id, - granted_by: userId, - granted_at: new Date().toISOString(), - reason: 'Accepted invitation', - is_direct: true, - }) - - if (insertBindingError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to create RBAC role binding', { error: insertBindingError.message }) - } -} - app.post('/', async (c) => { const rawBody = await parseBody(c) @@ -376,16 +265,10 @@ app.post('/', async (c) => { } const userId = session.user?.id ?? existingUser.id - const membershipError = await ensureOrgMembership(c, supabaseAdmin, userId, invitation) + const membershipError = await ensureOrgMembership(c, userId, baseBody.magic_invite_string) if (membershipError) return membershipError - // Remove the invite only after the org membership is created successfully. - const { error: tmpUserDeleteError } = await supabaseAdmin.from('tmp_users').delete().eq('invite_magic_string', baseBody.magic_invite_string) - if (tmpUserDeleteError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation delete tmp_users', { error: tmpUserDeleteError.message }) - } - return c.json({ access_token: session.session?.access_token, refresh_token: session.session?.refresh_token, @@ -445,15 +328,10 @@ app.post('/', async (c) => { if (publicUserError) return publicUserError - const membershipError = await ensureOrgMembership(c, supabaseAdmin, session.user.id, invitation) + const membershipError = await ensureOrgMembership(c, session.user.id, body.magic_invite_string) if (membershipError) return membershipError - const { error: tmpUserDeleteError } = await supabaseAdmin.from('tmp_users').delete().eq('invite_magic_string', body.magic_invite_string) - if (tmpUserDeleteError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation delete tmp_users', { error: tmpUserDeleteError.message }) - } - return c.json({ access_token: session.session?.access_token, refresh_token: session.session?.refresh_token, @@ -530,17 +408,12 @@ app.post('/', async (c) => { return quickError(400, 'sign_in_failed', 'Sign in failed, please retry', { error: sessionError.message }) } - const membershipError = await ensureOrgMembership(c, supabaseAdmin, user.user.id, invitation) + const membershipError = await ensureOrgMembership(c, user.user.id, body.magic_invite_string) if (membershipError) { didRollback = true await rollbackCreatedUser(c, user.user.id) return membershipError } - // Remove the invite only after the account + org membership are created successfully. - const { error: tmpUserDeleteError } = await supabaseAdmin.from('tmp_users').delete().eq('invite_magic_string', body.magic_invite_string) - if (tmpUserDeleteError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation delete tmp_users', { error: tmpUserDeleteError.message }) - } return c.json({ access_token: session.session?.access_token, diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 0832a71e33..fe2c7f0606 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -3886,6 +3886,10 @@ export type Database = { } Functions: { accept_invitation_to_org: { Args: { org_id: string }; Returns: string } + accept_tmp_user_invitation: { + Args: { p_invite_magic_string: string; p_user_id: string } + Returns: string + } acknowledge_compatibility_event: { Args: { event_id: number; note: string } Returns: undefined diff --git a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql index 42bde35505..55bc7cf510 100644 --- a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql +++ b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql @@ -208,6 +208,135 @@ BEGIN END; $$; +CREATE OR REPLACE FUNCTION public.accept_tmp_user_invitation( + p_invite_magic_string text, + p_user_id uuid +) RETURNS text +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +SET row_security = off +AS $$ +DECLARE + v_org_id uuid; + v_invite public.tmp_users%ROWTYPE; + v_role_id uuid; + v_rbac_role_name text; +BEGIN + SELECT tmp_users.org_id + INTO v_org_id + FROM public.tmp_users + WHERE tmp_users.invite_magic_string = p_invite_magic_string + AND tmp_users.cancelled_at IS NULL + LIMIT 1; + + IF v_org_id IS NULL THEN + RETURN 'NO_INVITE'; + END IF; + + PERFORM public.lock_rbac_orgs(v_org_id); + + SELECT tmp_users.* + INTO v_invite + FROM public.tmp_users + WHERE tmp_users.invite_magic_string = p_invite_magic_string + AND tmp_users.cancelled_at IS NULL + FOR UPDATE + LIMIT 1; + + IF v_invite.id IS NULL THEN + RETURN 'NO_INVITE'; + END IF; + + v_rbac_role_name := pg_catalog.btrim(v_invite.rbac_role_name); + IF v_rbac_role_name IS NULL OR v_rbac_role_name = '' THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + -- Invites created before invited_by_user_id was recorded lack inviter attribution. + IF v_invite.invited_by_user_id IS NULL THEN + RETURN 'INVITER_NOT_FOUND'; + END IF; + + PERFORM public.assert_principal_can_grant_org_role( + v_invite.org_id, + v_invite.invited_by_user_id, + v_rbac_role_name, + 'accept_tmp_user_invitation' + ); + + SELECT roles.id + INTO v_role_id + FROM public.roles + WHERE roles.name = v_rbac_role_name + AND roles.scope_type = public.rbac_scope_org() + AND roles.is_assignable = true + LIMIT 1; + + IF v_role_id IS NULL THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM public.org_users + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL + ) THEN + UPDATE public.org_users + SET rbac_role_name = v_rbac_role_name, + is_invite = false, + updated_at = now() + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL; + ELSE + INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES (p_user_id, v_invite.org_id, v_rbac_role_name, false); + END IF; + + DELETE FROM public.role_bindings + WHERE role_bindings.principal_type = public.rbac_principal_user() + AND role_bindings.principal_id = p_user_id + AND role_bindings.scope_type = public.rbac_scope_org() + AND role_bindings.org_id = v_invite.org_id; + + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + granted_by, + granted_at, + reason, + is_direct + ) VALUES ( + public.rbac_principal_user(), + p_user_id, + v_role_id, + public.rbac_scope_org(), + v_invite.org_id, + p_user_id, + now(), + 'Accepted invitation', + true + ); + + DELETE FROM public.tmp_users + WHERE tmp_users.id = v_invite.id; + + RETURN 'OK'; +END; +$$; + +ALTER FUNCTION public.accept_tmp_user_invitation(text, uuid) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.accept_tmp_user_invitation(text, uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.accept_tmp_user_invitation(text, uuid) TO service_role; + CREATE OR REPLACE FUNCTION public.accept_invitation_to_org(org_id uuid) RETURNS character varying LANGUAGE plpgsql SECURITY DEFINER @@ -222,6 +351,8 @@ DECLARE role_id uuid; v_inviter_id uuid; BEGIN + PERFORM public.lock_rbac_orgs(accept_invitation_to_org.org_id); + SELECT public.org_users.* INTO invite FROM public.org_users @@ -290,8 +421,6 @@ BEGIN RETURN 'INVITER_NOT_FOUND'; END IF; - PERFORM public.lock_rbac_orgs(invite_org_id); - PERFORM public.assert_principal_can_grant_org_role( invite_org_id, v_inviter_id, @@ -352,3 +481,6 @@ $$; COMMENT ON FUNCTION public.assert_principal_can_grant_org_role(uuid, uuid, text, text) IS 'Ensures a principal can grant an org-scoped role at or below their max rank. Used for invite acceptance validation.'; + +COMMENT ON FUNCTION public.accept_tmp_user_invitation(text, uuid) + IS 'Atomically validates inviter rank and finalizes a tmp_users invitation (org membership + role binding + invite delete).'; diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index ff3e7e57b9..970f976744 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -433,6 +433,8 @@ DECLARE role_id uuid; v_inviter_id uuid; BEGIN + PERFORM public.lock_rbac_orgs(accept_invitation_to_org.org_id); + SELECT public.org_users.* INTO invite FROM public.org_users @@ -500,8 +502,6 @@ BEGIN RETURN 'INVITER_NOT_FOUND'; END IF; - PERFORM public.lock_rbac_orgs(invite_org_id); - PERFORM public.assert_principal_can_grant_org_role( invite_org_id, v_inviter_id, @@ -1766,6 +1766,134 @@ $$; ALTER FUNCTION "public"."assert_principal_can_grant_org_role"("p_org_id" "uuid", "p_principal_id" "uuid", "p_role_name" "text", "p_mutation" "text") OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "public"."accept_tmp_user_invitation"("p_invite_magic_string" "text", "p_user_id" "uuid") RETURNS "text" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + SET "row_security" TO 'off' + AS $$ +DECLARE + v_org_id uuid; + v_invite public.tmp_users%ROWTYPE; + v_role_id uuid; + v_rbac_role_name text; +BEGIN + SELECT tmp_users.org_id + INTO v_org_id + FROM public.tmp_users + WHERE tmp_users.invite_magic_string = p_invite_magic_string + AND tmp_users.cancelled_at IS NULL + LIMIT 1; + + IF v_org_id IS NULL THEN + RETURN 'NO_INVITE'; + END IF; + + PERFORM public.lock_rbac_orgs(v_org_id); + + SELECT tmp_users.* + INTO v_invite + FROM public.tmp_users + WHERE tmp_users.invite_magic_string = p_invite_magic_string + AND tmp_users.cancelled_at IS NULL + FOR UPDATE + LIMIT 1; + + IF v_invite.id IS NULL THEN + RETURN 'NO_INVITE'; + END IF; + + v_rbac_role_name := pg_catalog.btrim(v_invite.rbac_role_name); + IF v_rbac_role_name IS NULL OR v_rbac_role_name = '' THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + -- Invites created before invited_by_user_id was recorded lack inviter attribution. + IF v_invite.invited_by_user_id IS NULL THEN + RETURN 'INVITER_NOT_FOUND'; + END IF; + + PERFORM public.assert_principal_can_grant_org_role( + v_invite.org_id, + v_invite.invited_by_user_id, + v_rbac_role_name, + 'accept_tmp_user_invitation' + ); + + SELECT roles.id + INTO v_role_id + FROM public.roles + WHERE roles.name = v_rbac_role_name + AND roles.scope_type = public.rbac_scope_org() + AND roles.is_assignable = true + LIMIT 1; + + IF v_role_id IS NULL THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM public.org_users + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL + ) THEN + UPDATE public.org_users + SET rbac_role_name = v_rbac_role_name, + is_invite = false, + updated_at = now() + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL; + ELSE + INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES (p_user_id, v_invite.org_id, v_rbac_role_name, false); + END IF; + + DELETE FROM public.role_bindings + WHERE role_bindings.principal_type = public.rbac_principal_user() + AND role_bindings.principal_id = p_user_id + AND role_bindings.scope_type = public.rbac_scope_org() + AND role_bindings.org_id = v_invite.org_id; + + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + granted_by, + granted_at, + reason, + is_direct + ) VALUES ( + public.rbac_principal_user(), + p_user_id, + v_role_id, + public.rbac_scope_org(), + v_invite.org_id, + p_user_id, + now(), + 'Accepted invitation', + true + ); + + DELETE FROM public.tmp_users + WHERE tmp_users.id = v_invite.id; + + RETURN 'OK'; +END; +$$; + + +ALTER FUNCTION "public"."accept_tmp_user_invitation"("p_invite_magic_string" "text", "p_user_id" "uuid") OWNER TO "postgres"; + + +COMMENT ON FUNCTION "public"."accept_tmp_user_invitation"("p_invite_magic_string" "text", "p_user_id" "uuid") IS 'Atomically validates inviter rank and finalizes a tmp_users invitation (org membership + role binding + invite delete).'; + + CREATE OR REPLACE FUNCTION "public"."audit_log_trigger"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO '' @@ -26546,6 +26674,11 @@ GRANT ALL ON FUNCTION "public"."accept_invitation_to_org"("org_id" "uuid") TO "s +REVOKE ALL ON FUNCTION "public"."accept_tmp_user_invitation"("p_invite_magic_string" "text", "p_user_id" "uuid") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."accept_tmp_user_invitation"("p_invite_magic_string" "text", "p_user_id" "uuid") TO "service_role"; + + + REVOKE ALL ON FUNCTION "public"."acknowledge_compatibility_event"("event_id" bigint, "note" "text") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."acknowledge_compatibility_event"("event_id" bigint, "note" "text") TO "service_role"; GRANT ALL ON FUNCTION "public"."acknowledge_compatibility_event"("event_id" bigint, "note" "text") TO "authenticated"; diff --git a/tests/invite-role-escalation-guards.test.ts b/tests/invite-role-escalation-guards.test.ts index c575410ceb..f4dbff87c6 100644 --- a/tests/invite-role-escalation-guards.test.ts +++ b/tests/invite-role-escalation-guards.test.ts @@ -84,17 +84,21 @@ describe('invite role escalation guards', () => { orgId: string email: string roleName: string - invitedBy: string + invitedBy: string | null + magicString?: string }) { + const magicString = options.magicString ?? `magic-${randomUUID()}` await setServiceRoleClaim(query) await query( ` INSERT INTO public.tmp_users ( - email, org_id, rbac_role_name, first_name, last_name, invited_by_user_id - ) VALUES ($1, $2::uuid, $3, 'Escalation', 'Target', $4::uuid) + email, org_id, rbac_role_name, first_name, last_name, + invited_by_user_id, invite_magic_string + ) VALUES ($1, $2::uuid, $3, 'Escalation', 'Target', $4::uuid, $5) `, - [options.email, options.orgId, options.roleName, options.invitedBy], + [options.email, options.orgId, options.roleName, options.invitedBy, magicString], ) + return magicString } it('blocks org_admin from escalating a tmp_users invite to org_super_admin', async () => { @@ -240,6 +244,24 @@ describe('invite role escalation guards', () => { expect((thrown as Error).message).toContain('Admins cannot elevate privileges!') }) + it('rejects accept_tmp_user_invitation when invited_by_user_id is null', async () => { + const orgId = await createOrgOwnedByUser(query, USER_ID, 'Legacy tmp invite org') + const email = `legacy-invite-${randomUUID()}@capgo.app` + const magicString = await insertTmpInvite({ + orgId, + email, + roleName: 'org_member', + invitedBy: null, + }) + + await setServiceRoleClaim(query) + const result = await query( + `SELECT public.accept_tmp_user_invitation($1, $2::uuid) AS status`, + [magicString, USER_ID_NONMEMBER], + ) + expect(result.rows[0]?.status).toBe('INVITER_NOT_FOUND') + }) + it('rejects assert_principal_can_grant_org_role for escalated tmp invite roles', async () => { const orgId = await createOrgOwnedByUser(query, USER_ID, 'Tmp invite assert org') await bindOrgRole(orgId, USER_ID_2, 'org_admin') diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 9391ddd858..cc3546c48c 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -23,6 +23,8 @@ const SERVICE_ONLY_PROCS = [ 'public.apikeys_force_server_key()', 'public.apikeys_strip_plain_key_for_hashed()', 'public.assert_effective_super_admin_binding_removal(uuid, text)', + 'public.assert_principal_can_grant_org_role(uuid, uuid, text, text)', + 'public.accept_tmp_user_invitation(text, uuid)', 'public.prevent_role_binding_priority_escalation()', 'public.check_encrypted_bundle_on_insert()', 'public.check_org_hashed_key_enforcement(uuid, public.apikeys)', From 4b81fd11139825f17ed628a6d27a3f220213df85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 17:15:59 +0000 Subject: [PATCH 09/16] fix(rbac): harden tmp invite acceptance edge cases Move RPC status mapping outside try/catch so quickError keeps correct HTTP codes. Set role_bindings.granted_by to the inviter, keep is_invite true through binding insert, and reject ALREADY_MEMBER instead of replacing active membership. Co-authored-by: Martin DONADIEU --- .../_backend/private/accept_invitation.ts | 49 +++++++++------- ...2416_fix_invite_role_escalation_guards.sql | 35 ++++++++++-- supabase/schemas/prod.sql | 35 ++++++++++-- tests/invite-role-escalation-guards.test.ts | 57 +++++++++++++++++++ 4 files changed, 143 insertions(+), 33 deletions(-) diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index a860e5738c..546e613866 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -153,32 +153,13 @@ async function ensureOrgMembership( magicInviteString: string, ) { const pgClient = getPgClient(c) + let status: string | undefined try { const result = await pgClient.query<{ accept_tmp_user_invitation: string }>( `SELECT public.accept_tmp_user_invitation($1, $2::uuid) AS accept_tmp_user_invitation`, [magicInviteString, userId], ) - - const status = result.rows[0]?.accept_tmp_user_invitation - if (status === 'OK') - return - - if (status === 'NO_INVITE') { - return quickError(404, 'failed_to_accept_invitation', 'Invitation not found', { error: 'Invitation not found' }) - } - - // Legacy invites without invited_by_user_id must be reissued by an org admin. - if (status === 'INVITER_NOT_FOUND') { - return quickError(403, 'failed_to_accept_invitation', 'Invitation must be reissued before acceptance', { - error: 'Missing invitation inviter', - }) - } - - if (status === 'ROLE_NOT_FOUND') { - return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: 'Role not found' }) - } - - return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation', { error: status ?? 'Unknown status' }) + status = result.rows[0]?.accept_tmp_user_invitation } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -192,6 +173,32 @@ async function ensureOrgMembership( finally { await closeClient(c, pgClient) } + + if (status === 'OK') + return + + if (status === 'NO_INVITE') { + return quickError(404, 'failed_to_accept_invitation', 'Invitation not found', { error: 'Invitation not found' }) + } + + // Legacy invites without invited_by_user_id must be reissued by an org admin. + if (status === 'INVITER_NOT_FOUND') { + return quickError(403, 'failed_to_accept_invitation', 'Invitation must be reissued before acceptance', { + error: 'Missing invitation inviter', + }) + } + + if (status === 'ALREADY_MEMBER') { + return quickError(409, 'already_org_member', 'User is already a member of this organization', { + error: 'User already has active org membership', + }) + } + + if (status === 'ROLE_NOT_FOUND') { + return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: 'Role not found' }) + } + + return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation', { error: status ?? 'Unknown status' }) } app.post('/', async (c) => { diff --git a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql index 55bc7cf510..7ebe8b3530 100644 --- a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql +++ b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql @@ -284,18 +284,31 @@ BEGIN AND org_users.org_id = v_invite.org_id AND org_users.app_id IS NULL AND org_users.channel_id IS NULL + AND org_users.is_invite IS FALSE ) THEN + RETURN 'ALREADY_MEMBER'; + END IF; + + -- Keep is_invite true until after the accepted binding is inserted. + IF NOT EXISTS ( + SELECT 1 + FROM public.org_users + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL + ) THEN + INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES (p_user_id, v_invite.org_id, v_rbac_role_name, true); + ELSE UPDATE public.org_users SET rbac_role_name = v_rbac_role_name, - is_invite = false, updated_at = now() WHERE org_users.user_id = p_user_id AND org_users.org_id = v_invite.org_id AND org_users.app_id IS NULL - AND org_users.channel_id IS NULL; - ELSE - INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) - VALUES (p_user_id, v_invite.org_id, v_rbac_role_name, false); + AND org_users.channel_id IS NULL + AND org_users.is_invite IS TRUE; END IF; DELETE FROM public.role_bindings @@ -320,12 +333,22 @@ BEGIN v_role_id, public.rbac_scope_org(), v_invite.org_id, - p_user_id, + v_invite.invited_by_user_id, now(), 'Accepted invitation', true ); + UPDATE public.org_users + SET is_invite = false, + rbac_role_name = v_rbac_role_name, + updated_at = now() + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL + AND org_users.is_invite IS TRUE; + DELETE FROM public.tmp_users WHERE tmp_users.id = v_invite.id; diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index 970f976744..188eee7c2f 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -1838,18 +1838,31 @@ BEGIN AND org_users.org_id = v_invite.org_id AND org_users.app_id IS NULL AND org_users.channel_id IS NULL + AND org_users.is_invite IS FALSE ) THEN + RETURN 'ALREADY_MEMBER'; + END IF; + + -- Keep is_invite true until after the accepted binding is inserted. + IF NOT EXISTS ( + SELECT 1 + FROM public.org_users + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL + ) THEN + INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES (p_user_id, v_invite.org_id, v_rbac_role_name, true); + ELSE UPDATE public.org_users SET rbac_role_name = v_rbac_role_name, - is_invite = false, updated_at = now() WHERE org_users.user_id = p_user_id AND org_users.org_id = v_invite.org_id AND org_users.app_id IS NULL - AND org_users.channel_id IS NULL; - ELSE - INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) - VALUES (p_user_id, v_invite.org_id, v_rbac_role_name, false); + AND org_users.channel_id IS NULL + AND org_users.is_invite IS TRUE; END IF; DELETE FROM public.role_bindings @@ -1874,12 +1887,22 @@ BEGIN v_role_id, public.rbac_scope_org(), v_invite.org_id, - p_user_id, + v_invite.invited_by_user_id, now(), 'Accepted invitation', true ); + UPDATE public.org_users + SET is_invite = false, + rbac_role_name = v_rbac_role_name, + updated_at = now() + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL + AND org_users.is_invite IS TRUE; + DELETE FROM public.tmp_users WHERE tmp_users.id = v_invite.id; diff --git a/tests/invite-role-escalation-guards.test.ts b/tests/invite-role-escalation-guards.test.ts index f4dbff87c6..45e311cf0f 100644 --- a/tests/invite-role-escalation-guards.test.ts +++ b/tests/invite-role-escalation-guards.test.ts @@ -262,6 +262,63 @@ describe('invite role escalation guards', () => { expect(result.rows[0]?.status).toBe('INVITER_NOT_FOUND') }) + it('rejects accept_tmp_user_invitation when the invitee is already an active org member', async () => { + const orgId = await createOrgOwnedByUser(query, USER_ID, 'Tmp invite already member org') + await bindOrgRole(orgId, USER_ID_NONMEMBER, 'org_member') + const email = `already-member-${randomUUID()}@capgo.app` + const magicString = await insertTmpInvite({ + orgId, + email, + roleName: 'org_admin', + invitedBy: USER_ID, + }) + + await setServiceRoleClaim(query) + const result = await query( + `SELECT public.accept_tmp_user_invitation($1, $2::uuid) AS status`, + [magicString, USER_ID_NONMEMBER], + ) + expect(result.rows[0]?.status).toBe('ALREADY_MEMBER') + + const invite = await query( + `SELECT id FROM public.tmp_users WHERE invite_magic_string = $1`, + [magicString], + ) + expect(invite.rows.length).toBe(1) + }) + + it('records the inviter as granted_by when accepting a tmp_users invitation', async () => { + const orgId = await createOrgOwnedByUser(query, USER_ID, 'Tmp invite granted_by org') + const email = `granted-by-${randomUUID()}@capgo.app` + const magicString = await insertTmpInvite({ + orgId, + email, + roleName: 'org_member', + invitedBy: USER_ID, + }) + + await setServiceRoleClaim(query) + const acceptResult = await query( + `SELECT public.accept_tmp_user_invitation($1, $2::uuid) AS status`, + [magicString, USER_ID_NONMEMBER], + ) + expect(acceptResult.rows[0]?.status).toBe('OK') + + const binding = await query( + ` + SELECT granted_by + FROM public.role_bindings + WHERE principal_type = public.rbac_principal_user() + AND principal_id = $1::uuid + AND org_id = $2::uuid + AND scope_type = public.rbac_scope_org() + AND reason = 'Accepted invitation' + `, + [USER_ID_NONMEMBER, orgId], + ) + expect(binding.rows[0]?.granted_by).toBe(USER_ID) + }) + it('rejects assert_principal_can_grant_org_role for escalated tmp invite roles', async () => { const orgId = await createOrgOwnedByUser(query, USER_ID, 'Tmp invite assert org') await bindOrgRole(orgId, USER_ID_2, 'org_admin') From 963c8d45f6e0819078b14ddddf39036f9f41c996 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 17:35:18 +0000 Subject: [PATCH 10/16] chore: retrigger CI after flaky shard failures Co-authored-by: Martin DONADIEU From 3dce03a4b336ae5e7f88d5aad95ce243a1af7002 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 08:08:40 +0000 Subject: [PATCH 11/16] fix(rbac): serialize invite role updates and finalize guards Acquire lock_rbac_orgs in update_org/tmp invite role RPCs so acceptance cannot race role downgrades. Reject accept_tmp_user_invitation when pending membership cannot be finalized. Add e2e escalation test for tmp acceptance. Co-authored-by: Martin DONADIEU --- .../_backend/private/accept_invitation.ts | 6 +++ ...2416_fix_invite_role_escalation_guards.sql | 22 +++++++++++ supabase/schemas/prod.sql | 22 +++++++++++ tests/invite-role-escalation-guards.test.ts | 38 +++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 546e613866..7a751c9610 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -194,6 +194,12 @@ async function ensureOrgMembership( }) } + if (status === 'MEMBERSHIP_NOT_FINALIZED') { + return quickError(409, 'failed_to_accept_invitation', 'Invitation membership could not be finalized', { + error: 'Pending org membership row missing or invalid', + }) + } + if (status === 'ROLE_NOT_FOUND') { return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: 'Role not found' }) } diff --git a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql index 7ebe8b3530..3252ad3b49 100644 --- a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql +++ b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql @@ -127,6 +127,8 @@ BEGIN 'tmp_invite_role_update' ); + PERFORM public.lock_rbac_orgs(p_org_id); + UPDATE public.tmp_users SET rbac_role_name = p_new_role_name, updated_at = now() @@ -193,6 +195,8 @@ BEGIN 'org_invite_role_update' ); + PERFORM public.lock_rbac_orgs(p_org_id); + UPDATE public.org_users SET rbac_role_name = p_new_role_name, updated_at = now() @@ -222,6 +226,7 @@ DECLARE v_invite public.tmp_users%ROWTYPE; v_role_id uuid; v_rbac_role_name text; + v_finalize_rows integer; BEGIN SELECT tmp_users.org_id INTO v_org_id @@ -311,6 +316,18 @@ BEGIN AND org_users.is_invite IS TRUE; END IF; + IF NOT EXISTS ( + SELECT 1 + FROM public.org_users + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL + AND org_users.is_invite IS TRUE + ) THEN + RETURN 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + DELETE FROM public.role_bindings WHERE role_bindings.principal_type = public.rbac_principal_user() AND role_bindings.principal_id = p_user_id @@ -349,6 +366,11 @@ BEGIN AND org_users.channel_id IS NULL AND org_users.is_invite IS TRUE; + GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; + IF v_finalize_rows = 0 THEN + RETURN 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + DELETE FROM public.tmp_users WHERE tmp_users.id = v_invite.id; diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index 188eee7c2f..dec9a43dee 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -1776,6 +1776,7 @@ DECLARE v_invite public.tmp_users%ROWTYPE; v_role_id uuid; v_rbac_role_name text; + v_finalize_rows integer; BEGIN SELECT tmp_users.org_id INTO v_org_id @@ -1865,6 +1866,18 @@ BEGIN AND org_users.is_invite IS TRUE; END IF; + IF NOT EXISTS ( + SELECT 1 + FROM public.org_users + WHERE org_users.user_id = p_user_id + AND org_users.org_id = v_invite.org_id + AND org_users.app_id IS NULL + AND org_users.channel_id IS NULL + AND org_users.is_invite IS TRUE + ) THEN + RETURN 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + DELETE FROM public.role_bindings WHERE role_bindings.principal_type = public.rbac_principal_user() AND role_bindings.principal_id = p_user_id @@ -1903,6 +1916,11 @@ BEGIN AND org_users.channel_id IS NULL AND org_users.is_invite IS TRUE; + GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; + IF v_finalize_rows = 0 THEN + RETURN 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + DELETE FROM public.tmp_users WHERE tmp_users.id = v_invite.id; @@ -19708,6 +19726,8 @@ BEGIN 'org_invite_role_update' ); + PERFORM public.lock_rbac_orgs(p_org_id); + UPDATE public.org_users SET rbac_role_name = p_new_role_name, updated_at = now() @@ -19896,6 +19916,8 @@ BEGIN 'tmp_invite_role_update' ); + PERFORM public.lock_rbac_orgs(p_org_id); + UPDATE public.tmp_users SET rbac_role_name = p_new_role_name, updated_at = now() diff --git a/tests/invite-role-escalation-guards.test.ts b/tests/invite-role-escalation-guards.test.ts index 45e311cf0f..27678f52a5 100644 --- a/tests/invite-role-escalation-guards.test.ts +++ b/tests/invite-role-escalation-guards.test.ts @@ -244,6 +244,44 @@ describe('invite role escalation guards', () => { expect((thrown as Error).message).toContain('Admins cannot elevate privileges!') }) + it('rejects accept_tmp_user_invitation when the invite role exceeds inviter rank', async () => { + const orgId = await createOrgOwnedByUser(query, USER_ID, 'Tmp invite accept escalation org') + await bindOrgRole(orgId, USER_ID_2, 'org_admin') + const email = `tmp-accept-escalation-${randomUUID()}@capgo.app` + const magicString = await insertTmpInvite({ + orgId, + email, + roleName: 'org_member', + invitedBy: USER_ID_2, + }) + + await setServiceRoleClaim(query) + await query( + `UPDATE public.tmp_users SET rbac_role_name = public.rbac_role_org_super_admin() WHERE invite_magic_string = $1`, + [magicString], + ) + + let thrown: unknown + try { + await query( + `SELECT public.accept_tmp_user_invitation($1, $2::uuid) AS status`, + [magicString, USER_ID_NONMEMBER], + ) + } + catch (error) { + thrown = error + } + + expect(thrown).toBeTruthy() + expect((thrown as Error).message).toContain('Admins cannot elevate privileges!') + + const invite = await query( + `SELECT id FROM public.tmp_users WHERE invite_magic_string = $1`, + [magicString], + ) + expect(invite.rows.length).toBe(1) + }) + it('rejects accept_tmp_user_invitation when invited_by_user_id is null', async () => { const orgId = await createOrgOwnedByUser(query, USER_ID, 'Legacy tmp invite org') const email = `legacy-invite-${randomUUID()}@capgo.app` From 4b4062f156bd38ca11808675415007dff2a1ebb5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 08:22:01 +0000 Subject: [PATCH 12/16] test: use savepoint for tmp invite escalation rejection Co-authored-by: Martin DONADIEU --- tests/invite-role-escalation-guards.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/invite-role-escalation-guards.test.ts b/tests/invite-role-escalation-guards.test.ts index 27678f52a5..7a9cb1b55e 100644 --- a/tests/invite-role-escalation-guards.test.ts +++ b/tests/invite-role-escalation-guards.test.ts @@ -262,6 +262,7 @@ describe('invite role escalation guards', () => { ) let thrown: unknown + await query('SAVEPOINT tmp_invite_accept_escalation') try { await query( `SELECT public.accept_tmp_user_invitation($1, $2::uuid) AS status`, @@ -270,6 +271,7 @@ describe('invite role escalation guards', () => { } catch (error) { thrown = error + await query('ROLLBACK TO SAVEPOINT tmp_invite_accept_escalation') } expect(thrown).toBeTruthy() From 9b79ba85ee6b72c9f04844d078ef23266b9b5a5d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 08:31:50 +0000 Subject: [PATCH 13/16] fix(rbac): lock before invite-role auth and guard org accept finalize Move lock_rbac_orgs to the start of update_tmp_invite_role_rbac and update_org_invite_role_rbac so rank/permission checks serialize with acceptance. Return MEMBERSHIP_NOT_FINALIZED when accept_invitation_to_org finalization UPDATE affects zero rows. Co-authored-by: Martin DONADIEU --- ...908142416_fix_invite_role_escalation_guards.sql | 14 ++++++++++---- supabase/schemas/prod.sql | 14 ++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql index 3252ad3b49..343f19a767 100644 --- a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql +++ b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql @@ -89,6 +89,8 @@ DECLARE role_id uuid; role_priority integer; BEGIN + PERFORM public.lock_rbac_orgs(p_org_id); + SELECT r.id, r.priority_rank INTO role_id, role_priority FROM public.roles r @@ -127,8 +129,6 @@ BEGIN 'tmp_invite_role_update' ); - PERFORM public.lock_rbac_orgs(p_org_id); - UPDATE public.tmp_users SET rbac_role_name = p_new_role_name, updated_at = now() @@ -157,6 +157,8 @@ DECLARE role_id uuid; role_priority integer; BEGIN + PERFORM public.lock_rbac_orgs(p_org_id); + SELECT r.id, r.priority_rank INTO role_id, role_priority FROM public.roles r @@ -195,8 +197,6 @@ BEGIN 'org_invite_role_update' ); - PERFORM public.lock_rbac_orgs(p_org_id); - UPDATE public.org_users SET rbac_role_name = p_new_role_name, updated_at = now() @@ -395,6 +395,7 @@ DECLARE role_name text; role_id uuid; v_inviter_id uuid; + v_finalize_rows integer; BEGIN PERFORM public.lock_rbac_orgs(accept_invitation_to_org.org_id); @@ -520,6 +521,11 @@ BEGIN AND public.org_users.org_id = invite_org_id AND public.org_users.is_invite IS TRUE; + GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; + IF v_finalize_rows = 0 THEN + RETURN 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + RETURN 'OK'; END; $$; diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index dec9a43dee..870f3f3329 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -432,6 +432,7 @@ DECLARE role_name text; role_id uuid; v_inviter_id uuid; + v_finalize_rows integer; BEGIN PERFORM public.lock_rbac_orgs(accept_invitation_to_org.org_id); @@ -556,6 +557,11 @@ BEGIN AND public.org_users.org_id = invite_org_id AND public.org_users.is_invite IS TRUE; + GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; + IF v_finalize_rows = 0 THEN + RETURN 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + RETURN 'OK'; END; $$; @@ -19699,6 +19705,8 @@ DECLARE role_id uuid; role_priority integer; BEGIN + PERFORM public.lock_rbac_orgs(p_org_id); + SELECT r.id, r.priority_rank INTO role_id, role_priority FROM public.roles r WHERE r.name = p_new_role_name @@ -19726,8 +19734,6 @@ BEGIN 'org_invite_role_update' ); - PERFORM public.lock_rbac_orgs(p_org_id); - UPDATE public.org_users SET rbac_role_name = p_new_role_name, updated_at = now() @@ -19889,6 +19895,8 @@ DECLARE role_id uuid; role_priority integer; BEGIN + PERFORM public.lock_rbac_orgs(p_org_id); + SELECT r.id, r.priority_rank INTO role_id, role_priority FROM public.roles r WHERE r.name = p_new_role_name @@ -19916,8 +19924,6 @@ BEGIN 'tmp_invite_role_update' ); - PERFORM public.lock_rbac_orgs(p_org_id); - UPDATE public.tmp_users SET rbac_role_name = p_new_role_name, updated_at = now() From 030b6c359e10a27ffc02623b4ec390577d71af92 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 08:47:59 +0000 Subject: [PATCH 14/16] chore: retrigger CI after flaky Cloudflare shard failure Co-authored-by: Martin DONADIEU From 1ba2532d8495d6a05ac91bfa46305a839e7ef0c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 08:52:58 +0000 Subject: [PATCH 15/16] fix(rbac): roll back invite acceptance when finalize update is a no-op Raise MEMBERSHIP_NOT_FINALIZED after post-binding finalization checks so partial role-binding writes are not left committed. Map the exception to HTTP 409 in accept_invitation. Co-authored-by: Martin DONADIEU --- supabase/functions/_backend/private/accept_invitation.ts | 5 +++++ .../20260908142416_fix_invite_role_escalation_guards.sql | 4 ++-- supabase/schemas/prod.sql | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 7a751c9610..ed855ab9ec 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -168,6 +168,11 @@ async function ensureOrgMembership( error: message, }) } + if (message.includes('MEMBERSHIP_NOT_FINALIZED')) { + return quickError(409, 'failed_to_accept_invitation', 'Invitation membership could not be finalized', { + error: 'Pending org membership row missing or invalid', + }) + } return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation', { error: message }) } finally { diff --git a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql index 343f19a767..cc70815953 100644 --- a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql +++ b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql @@ -368,7 +368,7 @@ BEGIN GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; IF v_finalize_rows = 0 THEN - RETURN 'MEMBERSHIP_NOT_FINALIZED'; + RAISE EXCEPTION 'MEMBERSHIP_NOT_FINALIZED'; END IF; DELETE FROM public.tmp_users @@ -523,7 +523,7 @@ BEGIN GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; IF v_finalize_rows = 0 THEN - RETURN 'MEMBERSHIP_NOT_FINALIZED'; + RAISE EXCEPTION 'MEMBERSHIP_NOT_FINALIZED'; END IF; RETURN 'OK'; diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index 870f3f3329..b1b13ab402 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -559,7 +559,7 @@ BEGIN GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; IF v_finalize_rows = 0 THEN - RETURN 'MEMBERSHIP_NOT_FINALIZED'; + RAISE EXCEPTION 'MEMBERSHIP_NOT_FINALIZED'; END IF; RETURN 'OK'; @@ -1924,7 +1924,7 @@ BEGIN GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; IF v_finalize_rows = 0 THEN - RETURN 'MEMBERSHIP_NOT_FINALIZED'; + RAISE EXCEPTION 'MEMBERSHIP_NOT_FINALIZED'; END IF; DELETE FROM public.tmp_users From f447b988bd24be2060bc5c23975775c7b184b90b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 09:09:01 +0000 Subject: [PATCH 16/16] fix(rbac): place FOR UPDATE after LIMIT in tmp invite select PostgreSQL requires the locking clause after LIMIT in accept_tmp_user_invitation. Co-authored-by: Martin DONADIEU --- .../20260908142416_fix_invite_role_escalation_guards.sql | 4 ++-- supabase/schemas/prod.sql | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql index cc70815953..11043e2fa3 100644 --- a/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql +++ b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql @@ -246,8 +246,8 @@ BEGIN FROM public.tmp_users WHERE tmp_users.invite_magic_string = p_invite_magic_string AND tmp_users.cancelled_at IS NULL - FOR UPDATE - LIMIT 1; + LIMIT 1 + FOR UPDATE; IF v_invite.id IS NULL THEN RETURN 'NO_INVITE'; diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index b1b13ab402..d1d17fcec7 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -1802,8 +1802,8 @@ BEGIN FROM public.tmp_users WHERE tmp_users.invite_magic_string = p_invite_magic_string AND tmp_users.cancelled_at IS NULL - FOR UPDATE - LIMIT 1; + LIMIT 1 + FOR UPDATE; IF v_invite.id IS NULL THEN RETURN 'NO_INVITE';