Skip to content

Commit 1d19581

Browse files
icecrasher321claude
andcommitted
fix(invitations): require org admin to grant org Admin, and two disclosure gaps
Privilege escalation. `createWorkspaceInvitation` stamped organization role `admin` whenever the caller passed `membership: 'admin'`, but authorization only checked workspace admin access — and unifying the invite modals exposed the Admin option to any workspace admin, where it had previously been reachable only from organization settings. A workspace-scoped administrator could therefore invite someone who joins as an organization Admin, gaining admin on every workspace the organization owns plus member and billing management. The inviter must now already hold organization owner/admin, checked server-side because the batch endpoint is reachable without the modal, and the modal no longer offers Admin to anyone else. The preview promised external access without mirroring acceptance's `external-requires-paid-plan` gate, so a free invitee — one who cancelled Pro, or left the organization that forced the external invite — was told they had workspace access and then refused. It now mirrors that gate, including its exemptions (billing on, organization-owned workspace, externality not imposed), and reports `blocked`. The modal's Enterprise seat check counted every non-External email as a seat. The server does not: an existing organization member is granted access directly, and an invitee already in another organization is forced external. The hard block refused batches the API would have accepted, so it is advisory now — per-email failures already come back with reasons. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 002b73d commit 1d19581

4 files changed

Lines changed: 100 additions & 11 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,19 @@ export function InviteModal({
137137
* is actually hosted by.
138138
*/
139139
const hostContext = useWorkspaceHostContext()
140+
/**
141+
* Organization Admin is an organization-level grant — it carries admin on every
142+
* workspace the org owns plus member and billing management — so it is only
143+
* offered to someone who already holds it. The batch endpoint enforces the same
144+
* rule; this only keeps the UI from presenting an option that would be refused.
145+
*/
146+
const canGrantOrganizationAdmin =
147+
isOrganizationInvite &&
148+
hostContext.hostOrganizationId === organizationId &&
149+
hostContext.viewer.isHostOrganizationAdmin
150+
const membershipOptions = canGrantOrganizationAdmin
151+
? MEMBERSHIP_OPTIONS
152+
: MEMBERSHIP_OPTIONS.filter((option) => option.value !== 'admin')
140153
const canViewOrganizationBilling =
141154
isOrganizationInvite &&
142155
hostContext.hostOrganizationId === organizationId &&
@@ -155,10 +168,17 @@ export function InviteModal({
155168
*/
156169
const isEnterpriseOrg = isEnterprise(organizationBillingData?.data?.subscriptionPlan)
157170
const hasSeatData = canViewOrganizationBilling && isEnterpriseOrg && totalSeats > 0
158-
const exceedsSeatCapacity =
171+
/**
172+
* Advisory only. The server decides per email and does not charge a seat for
173+
* everyone: an existing organization member is granted access directly, and an
174+
* invitee who already belongs to another organization is forced external. A
175+
* hard block here refused batches the API would have accepted, so this warns
176+
* and lets the send proceed — per-email failures come back with reasons.
177+
*/
178+
const mayExceedSeatCapacity =
159179
hasSeatData && membership !== 'external' && emails.length > availableSeats
160-
const seatLimitReason = exceedsSeatCapacity
161-
? `Only ${availableSeats} seat${availableSeats === 1 ? '' : 's'} available. External collaborators do not use seats.`
180+
const seatLimitReason = mayExceedSeatCapacity
181+
? `Only ${availableSeats} seat${availableSeats === 1 ? '' : 's'} available — invites beyond that may fail. External collaborators and existing members do not use seats.`
162182
: null
163183

164184
const validateEmail = useCallback(
@@ -242,8 +262,7 @@ export function InviteModal({
242262
Boolean(inviteDisabledReason) ||
243263
isSubmitting ||
244264
emails.length === 0 ||
245-
selectedWorkspaceIds.length === 0 ||
246-
exceedsSeatCapacity
265+
selectedWorkspaceIds.length === 0
247266

248267
return (
249268
<ChipModal
@@ -296,7 +315,7 @@ export function InviteModal({
296315
<ChipModalField
297316
type='dropdown'
298317
title='Membership'
299-
options={MEMBERSHIP_OPTIONS}
318+
options={membershipOptions}
300319
value={membership}
301320
placeholder='Select membership'
302321
align='start'

apps/sim/lib/invitations/core.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,6 @@ export async function getInvitationJoinPreview(
266266
workspacesToMove: [],
267267
workspaceIdsToMove: [],
268268
})
269-
if (inv.membershipIntent === 'external') return withOutcome('external')
270269

271270
let workspaceOrganizationId = inv.organizationId
272271
let billedAccountUserId: string | null = null
@@ -292,15 +291,36 @@ export async function getInvitationJoinPreview(
292291
* one (acceptance downgrades to external or rejects).
293292
*/
294293
const existingMembership = await getUserOrganization(inviteeUserId)
294+
const inDifferentOrganization =
295+
!!existingMembership &&
296+
(workspaceOrganizationId ? existingMembership.organizationId !== workspaceOrganizationId : true)
297+
298+
if (inv.membershipIntent === 'external') {
299+
/**
300+
* Mirrors acceptance's `external-requires-paid-plan` gate, including its
301+
* exemptions: it only applies with billing on, to an organization-owned
302+
* workspace, and not when externality was imposed because the invitee already
303+
* belongs to another organization. Without this the screen promised external
304+
* access that acceptance would refuse.
305+
*/
306+
if (
307+
isBillingEnabled &&
308+
!inDifferentOrganization &&
309+
workspaceOrganizationId &&
310+
(await getInvitePlanCategoryForUser(inviteeUserId)) === 'free'
311+
) {
312+
return withOutcome('blocked')
313+
}
314+
return withOutcome('external')
315+
}
316+
295317
if (existingMembership) {
296318
/**
297319
* Already in the organization acceptance lands in: nothing about their
298320
* standing changes. A membership in a DIFFERENT organization is the
299321
* external case — acceptance downgrades — so it keeps the plain shape.
300322
*/
301-
const inTargetOrganization =
302-
!!workspaceOrganizationId && existingMembership.organizationId === workspaceOrganizationId
303-
if (inTargetOrganization) return withOutcome('already-member')
323+
if (!inDifferentOrganization) return withOutcome('already-member')
304324
/**
305325
* In a DIFFERENT organization. Acceptance only downgrades a workspace-kind
306326
* invite with live grants to external; an organization-kind invite (or one

apps/sim/lib/invitations/workspace-invitations.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
mockRevertPendingInvitationGrants,
2222
mockFindPendingGrantWorkspaceIds,
2323
mockGetInvitePlanCategoryForUser,
24+
mockIsOrganizationOwnerOrAdmin,
2425
mockWorkspaceMemberInvited,
2526
mockCaptureServerEvent,
2627
} = vi.hoisted(() => ({
@@ -33,6 +34,7 @@ const {
3334
mockRevertPendingInvitationGrants: vi.fn(),
3435
mockFindPendingGrantWorkspaceIds: vi.fn(),
3536
mockGetInvitePlanCategoryForUser: vi.fn(),
37+
mockIsOrganizationOwnerOrAdmin: vi.fn(),
3638
mockWorkspaceMemberInvited: vi.fn(),
3739
mockCaptureServerEvent: vi.fn(),
3840
}))
@@ -71,6 +73,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
7173
getWorkspaceWithOwner: vi.fn(),
7274
}))
7375

76+
vi.mock('@/lib/billing/core/organization', () => ({
77+
isOrganizationOwnerOrAdmin: mockIsOrganizationOwnerOrAdmin,
78+
}))
79+
7480
vi.mock('@/lib/workspaces/policy', () => ({
7581
getWorkspaceInvitePolicy: vi.fn(),
7682
getInvitePlanCategoryForUser: mockGetInvitePlanCategoryForUser,
@@ -140,6 +146,7 @@ describe('createWorkspaceInvitation', () => {
140146
resetDbChainMock()
141147
/** Production default; the billing-disabled case opts out explicitly. */
142148
setEnvFlags({ isBillingEnabled: true })
149+
mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false)
143150
mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' })
144151
mockCreatePendingInvitation.mockResolvedValue({
145152
invitationId: 'inv-1',
@@ -254,7 +261,8 @@ describe('createWorkspaceInvitation', () => {
254261
)
255262
})
256263

257-
it('stamps an admin organization role when the inviter picks Admin membership', async () => {
264+
it('stamps an admin organization role when an org admin picks Admin membership', async () => {
265+
mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true)
258266
queueWhereResponses([[]])
259267

260268
await createWorkspaceInvitation({
@@ -295,6 +303,29 @@ describe('createWorkspaceInvitation', () => {
295303
expect(mockGetInvitePlanCategoryForUser).not.toHaveBeenCalled()
296304
})
297305

306+
it('refuses to grant organization Admin to a workspace-only administrator', async () => {
307+
/**
308+
* Organization Admin carries admin on every workspace the org owns plus
309+
* member and billing management, so workspace-scoped authority must not
310+
* escalate into it. Enforced server-side because the batch endpoint is
311+
* reachable without the modal.
312+
*/
313+
mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false)
314+
queueWhereResponses([[]])
315+
316+
await expect(
317+
createWorkspaceInvitation({
318+
context: makeContext(),
319+
email: 'new@example.com',
320+
permission: 'write',
321+
membership: 'admin',
322+
request,
323+
})
324+
).rejects.toThrow('Only an organization owner or admin')
325+
326+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
327+
})
328+
298329
it('rejects an explicit external invite for an invitee with no paid plan', async () => {
299330
queueWhereResponses([[{ id: 'user-5', email: 'free@example.com' }], []])
300331
mockGetUserOrganization.mockResolvedValueOnce(null)

apps/sim/lib/invitations/workspace-invitations.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type InvitationMembershipIntent, permissions, user } from '@sim/db/sche
44
import { normalizeEmail } from '@sim/utils/string'
55
import { and, eq, inArray, sql } from 'drizzle-orm'
66
import type { NextRequest } from 'next/server'
7+
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
78
import { getUserOrganization } from '@/lib/billing/organizations/membership'
89
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
910
import { isBillingEnabled } from '@/lib/core/config/env-flags'
@@ -337,6 +338,24 @@ export async function createWorkspaceInvitation({
337338
membershipIntent = 'external'
338339
}
339340

341+
/**
342+
* Granting organization Admin is an organization-level act: an org admin holds
343+
* admin on every workspace the org owns and can manage members, roles, and
344+
* billing. Workspace admin authority must not escalate into it, so the inviter
345+
* has to already hold it. Checked here rather than in the modal because the
346+
* modal is only the UI — the batch endpoint is reachable directly.
347+
*/
348+
if (membershipIntent === 'internal' && membership === 'admin') {
349+
if (!organizationId || !(await isOrganizationOwnerOrAdmin(context.inviterId, organizationId))) {
350+
throw new WorkspaceInvitationError({
351+
message:
352+
'Only an organization owner or admin can invite someone as an organization admin. Invite them as a Member instead.',
353+
status: 403,
354+
email: normalizedEmail,
355+
})
356+
}
357+
}
358+
340359
const role: 'admin' | 'member' =
341360
membershipIntent === 'internal' && membership === 'admin' ? 'admin' : 'member'
342361

0 commit comments

Comments
 (0)