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..20ff6e213b 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 @@ -3399,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 a4eb9f5d03..fe2c7f0606 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 @@ -3883,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 b06b835891..cf968d14ea 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 @@ -3648,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 62a86a3042..ed855ab9ec 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' @@ -147,104 +148,68 @@ async function ensurePublicUserRowExists( } async function ensureOrgMembership( - supabaseAdmin: ReturnType, + c: Parameters[0], userId: string, - invitation: any, + magicInviteString: string, ) { - 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 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], + ) + status = result.rows[0]?.accept_tmp_user_invitation } - - 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' }) + 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, + }) + } + 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 }) } - - 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 }) + finally { + await closeClient(c, pgClient) } - 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 (status === 'OK') + return - if (updateMembershipError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to update org membership', { error: updateMembershipError.message }) - } + if (status === 'NO_INVITE') { + return quickError(404, 'failed_to_accept_invitation', 'Invitation not found', { error: 'Invitation not found' }) } - 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 }) - } + // 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', + }) } - 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 }) + 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', + }) } - 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 (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 (insertBindingError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to create RBAC role binding', { error: insertBindingError.message }) + 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) => { @@ -318,16 +283,10 @@ app.post('/', async (c) => { } const userId = session.user?.id ?? existingUser.id - const membershipError = await ensureOrgMembership(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, @@ -387,15 +346,10 @@ app.post('/', async (c) => { if (publicUserError) return publicUserError - const membershipError = await ensureOrgMembership(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, @@ -472,17 +426,12 @@ 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, 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/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/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index a4eb9f5d03..fe2c7f0606 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 @@ -3883,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 new file mode 100644 index 0000000000..11043e2fa3 --- /dev/null +++ b/supabase/migrations/20260908142416_fix_invite_role_escalation_guards.sql @@ -0,0 +1,537 @@ +-- 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; + +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 + 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 + 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 + 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 + 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_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; + v_finalize_rows integer; +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 + LIMIT 1 + FOR UPDATE; + + 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 + 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, + 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; + 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 + 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, + 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; + + GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; + IF v_finalize_rows = 0 THEN + RAISE EXCEPTION 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + + 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 +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; + v_finalize_rows integer; +BEGIN + PERFORM public.lock_rbac_orgs(accept_invitation_to_org.org_id); + + 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 + RETURN 'INVITER_NOT_FOUND'; + 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; + + GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; + IF v_finalize_rows = 0 THEN + RAISE EXCEPTION 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + + 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.'; + +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 d99fdf433d..d1d17fcec7 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -431,7 +431,11 @@ DECLARE invite_org_id uuid; 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); + SELECT public.org_users.* INTO invite FROM public.org_users @@ -449,9 +453,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 +499,17 @@ BEGIN RETURN 'ROLE_NOT_FOUND'; END IF; + IF v_inviter_id IS NULL THEN + RETURN 'INVITER_NOT_FOUND'; + 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 @@ -531,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 + RAISE EXCEPTION 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + RETURN 'OK'; END; $$; @@ -1674,6 +1705,242 @@ $$; 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"."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; + v_finalize_rows integer; +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 + LIMIT 1 + FOR UPDATE; + + 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 + 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, + 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; + 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 + 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, + 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; + + GET DIAGNOSTICS v_finalize_rows = ROW_COUNT; + IF v_finalize_rows = 0 THEN + RAISE EXCEPTION 'MEMBERSHIP_NOT_FINALIZED'; + END IF; + + 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 '' @@ -19436,8 +19703,11 @@ 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 + 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 AND r.scope_type = public.rbac_scope_org() @@ -19458,6 +19728,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 +19893,11 @@ 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 + 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 AND r.scope_type = public.rbac_scope_org() @@ -19639,6 +19918,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 +22612,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" ); @@ -26439,6 +26725,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"; @@ -26532,6 +26823,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..7a9cb1b55e --- /dev/null +++ b/tests/invite-role-escalation-guards.test.ts @@ -0,0 +1,388 @@ +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 | 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, invite_magic_string + ) VALUES ($1, $2::uuid, $3, 'Escalation', 'Target', $4::uuid, $5) + `, + [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 () => { + 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 + await query('SAVEPOINT invite_escalation_block') + try { + await query( + `SELECT public.update_tmp_invite_role_rbac($1::uuid, $2, $3)`, + [orgId, email, 'org_super_admin'], + ) + } + 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], + ) + 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') + + 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], + ) + 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 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 + await query('SAVEPOINT tmp_invite_accept_escalation') + try { + await query( + `SELECT public.accept_tmp_user_invitation($1, $2::uuid) AS status`, + [magicString, USER_ID_NONMEMBER], + ) + } + catch (error) { + thrown = error + await query('ROLLBACK TO SAVEPOINT tmp_invite_accept_escalation') + } + + 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` + 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 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') + + 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!') + }) +}) 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)',