Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions cli/src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
151 changes: 50 additions & 101 deletions supabase/functions/_backend/private/accept_invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -147,104 +148,68 @@ async function ensurePublicUserRowExists(
}

async function ensureOrgMembership(
supabaseAdmin: ReturnType<typeof useSupabaseAdmin>,
c: Parameters<typeof useSupabaseAdmin>[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) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions supabase/functions/_backend/private/invite_new_user_to_org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions supabase/functions/_backend/utils/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3198,6 +3198,7 @@ export type Database = {
future_uuid: string
id: number
invite_magic_string: string
invited_by_user_id: string | null
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
last_name: string
org_id: string
rbac_role_name: string
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading