fix(invites): block re-arming an admin invite without role change (M1)

updateInvite ran the canAssignInviteRole guard only when the payload included
'role', so a board member could re-enable/extend an exhausted admin-granting
invite (is_enabled/max_uses/expires_at) and distribute admin access they cannot
mint. Load the existing invite and require canAssignInviteRole against its
effective role whenever any privilege-affecting field is (re-)armed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Moheeb Zara
2026-08-01 21:31:51 -07:00
co-authored by Claude Opus 4.8
parent 4b98c21e2b
commit 7fe3ffc632
2 changed files with 193 additions and 2 deletions
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
/**
* Regression: a board member must not be able to re-arm an admin-granting
* invite by updating privilege-affecting fields (is_enabled / max_uses /
* expires_at) WITHOUT touching `role`. Because updateInviteSchema makes `role`
* optional, the old role gate ran only when `role` was in the payload, so
* `updateInvite(adminInviteId, { is_enabled: true, max_uses: 5 })` sailed
* through and re-distributed admin access the board caller could never mint.
*
* These drive the real action through a recording mock Supabase client that
* scripts the requireMember lookup and the existing-invite load, then assert
* whether the tenant-scoped UPDATE reached the client.
*/
type QueueItem = { data?: unknown; error?: unknown }
function makeClient(opts: { user?: { id: string; email?: string } | null; queue?: QueueItem[] }) {
const calls: Array<{ method: string; args: unknown[] }> = []
const queue = [...(opts.queue ?? [])]
const next = (): QueueItem => (queue.length ? queue.shift()! : { data: null, error: null })
const builder: Record<string, unknown> = new Proxy(
{},
{
get(_t, prop: string) {
if (prop === 'then') {
return (resolve: (v: QueueItem) => void) => resolve(next())
}
if (prop === 'single' || prop === 'maybeSingle') {
return (...args: unknown[]) => {
calls.push({ method: prop, args })
return Promise.resolve(next())
}
}
return (...args: unknown[]) => {
calls.push({ method: prop, args })
return builder
}
},
},
)
const client = {
auth: {
getUser: vi.fn(() => Promise.resolve({ data: { user: opts.user ?? null } })),
},
from: (...args: unknown[]) => {
calls.push({ method: 'from', args })
return builder
},
_calls: calls,
}
return client
}
let currentClient: ReturnType<typeof makeClient>
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(currentClient),
}))
vi.mock('@/lib/supabase/admin', () => ({
createAdminClient: () => makeClient({ user: null, queue: [] }),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
import { updateInvite } from '@/lib/actions/invites'
const INVITE_ID = '22222222-2222-2222-2222-222222222222'
const user = { id: 'user-A', email: '[email protected]' }
const memberRow = (role: string, space_id = 'space-A') => ({
id: 'member-A',
space_id,
user_id: user.id,
role,
status: 'current',
display_name: 'Caller',
handle: 'caller',
})
/** An exhausted + disabled admin invite in space-A. */
const adminInvite = {
role: 'admin',
is_enabled: false,
max_uses: 1,
expires_at: null,
}
const memberInvite = {
role: 'member',
is_enabled: false,
max_uses: 1,
expires_at: null,
}
const eqCalls = (c: ReturnType<typeof makeClient>) =>
c._calls.filter(x => x.method === 'eq').map(x => x.args)
const updateReached = (c: ReturnType<typeof makeClient>) =>
c._calls.some(x => x.method === 'update')
beforeEach(() => {
vi.clearAllMocks()
})
describe('updateInvite privilege re-arm guard', () => {
it('blocks a BOARD member from re-enabling/extending an admin invite with no role field', async () => {
currentClient = makeClient({
user,
queue: [
{ data: memberRow('board') }, // requireMemberWithRole
{ data: adminInvite }, // existing-invite load (maybeSingle)
],
})
const res = await updateInvite(INVITE_ID, { is_enabled: true, max_uses: 5 })
expect(res).toHaveProperty('error')
expect((res as { error: string }).error).toMatch(/admin/i)
// The re-arming UPDATE must never reach the database.
expect(updateReached(currentClient)).toBe(false)
})
it('allows an ADMIN member to re-enable/extend an admin invite', async () => {
currentClient = makeClient({
user,
queue: [
{ data: memberRow('admin') }, // requireMemberWithRole
{ data: adminInvite }, // existing-invite load
{ error: null }, // update
],
})
const res = await updateInvite(INVITE_ID, { is_enabled: true, max_uses: 5 })
expect(res).not.toHaveProperty('error')
expect(updateReached(currentClient)).toBe(true)
// Still tenant-scoped.
expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A'])
})
it('lets a BOARD member still edit a member-role invite', async () => {
currentClient = makeClient({
user,
queue: [
{ data: memberRow('board') }, // requireMemberWithRole
{ data: memberInvite }, // existing-invite load
{ error: null }, // update
],
})
const res = await updateInvite(INVITE_ID, { is_enabled: true, max_uses: 5 })
expect(res).not.toHaveProperty('error')
expect(updateReached(currentClient)).toBe(true)
expect(eqCalls(currentClient)).toContainEqual(['space_id', 'space-A'])
})
})
+41 -2
View File
@@ -8,6 +8,22 @@ import { canAssignInviteRole } from '@/lib/invite-logic'
const INVITE_ADMIN_ROLES = ['admin', 'board'] as const const INVITE_ADMIN_ROLES = ['admin', 'board'] as const
// Whether `next` lets the invite be redeemed more times than `current`.
// null = unlimited, which is wider than any finite cap.
function widensUses(current: number | null, next: number | null): boolean {
if (next === null) return current !== null
if (current === null) return false
return next > current
}
// Whether `next` pushes the expiry later than `current`.
// null = never expires, which is later than any date.
function extendsExpiry(current: string | null, next: string | null): boolean {
if (next === null) return current !== null
if (current === null) return false
return new Date(next).getTime() > new Date(current).getTime()
}
function generateInviteCode(length = 8): string { function generateInviteCode(length = 8): string {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let out = '' let out = ''
@@ -86,8 +102,31 @@ export async function updateInvite(inviteId: string, updates: {
if (!auth.ok) return { error: auth.error } if (!auth.ok) return { error: auth.error }
const { member } = auth const { member } = auth
if (v.data.role !== undefined && !canAssignInviteRole(member.role, v.data.role)) { // Load the current row (scoped to the caller's space) so the privilege guard
return { error: `You cannot set an invite to grant the "${v.data.role}" role.` } // applies even when the payload omits `role`. Setting the role, re-enabling,
// widening max_uses, or extending expiry all (re-)distribute whatever role the
// invite already grants — so a board member must not be able to re-arm an
// exhausted/disabled admin invite they could never have minted.
const { data: existing, error: loadError } = await supabase
.from('space_invites')
.select('role, is_enabled, max_uses, expires_at')
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
.maybeSingle()
if (loadError) return { error: loadError.message }
if (!existing) return { error: 'Invite not found' }
// The role the invite grants after this update: the explicit new role, or the
// current role when the payload leaves it untouched.
const effectiveRole = v.data.role ?? existing.role
const rearms =
v.data.role !== undefined ||
(v.data.is_enabled === true && existing.is_enabled !== true) ||
(v.data.max_uses !== undefined && widensUses(existing.max_uses, v.data.max_uses)) ||
(v.data.expires_at !== undefined && extendsExpiry(existing.expires_at, v.data.expires_at))
if (rearms && !canAssignInviteRole(member.role, effectiveRole)) {
return { error: `You cannot modify an invite that grants the "${effectiveRole}" role.` }
} }
const patch: Record<string, unknown> = {} const patch: Record<string, unknown> = {}