Skip to content

Commit e619b30

Browse files
committed
fix(organizations): recheck inherited access under invitation locks
1 parent 8e3cd2b commit e619b30

4 files changed

Lines changed: 262 additions & 48 deletions

File tree

‎apps/sim/lib/invitations/direct-grant.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,40 @@ describe('grantWorkspaceAccessDirectly', () => {
145145
expect(dbChainMockFns.for.mock.invocationCallOrder[0]).toBeLessThan(
146146
mockGetEffectiveWorkspacePermission.mock.invocationCallOrder[0]
147147
)
148-
expect(dbChainMockFns.for).toHaveBeenCalledTimes(3)
148+
expect(dbChainMockFns.for).toHaveBeenCalledTimes(4)
149149
expect(dbChainMockFns.for.mock.invocationCallOrder[1]).toBeLessThan(
150150
mockGetEffectiveWorkspacePermission.mock.invocationCallOrder[0]
151151
)
152152
expect(dbChainMockFns.from).toHaveBeenCalledWith(member)
153153
})
154154

155+
it.each(['admin', 'owner'] as const)(
156+
'preserves an invitee who became organization %s before the transaction without redundant effects',
157+
async (role) => {
158+
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role })
159+
160+
const result = await grantWorkspaceAccessDirectly({
161+
...baseInput,
162+
existingPermissionPolicy: 'ensure-at-least',
163+
})
164+
165+
expect(result).toEqual({ outcome: 'unchanged', permission: 'admin' })
166+
expect(mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0]).toBeLessThan(
167+
dbChainMockFns.for.mock.invocationCallOrder[1]
168+
)
169+
expect(dbChainMockFns.for.mock.invocationCallOrder[1]).toBeLessThan(
170+
mockGetUserOrganization.mock.invocationCallOrder[0]
171+
)
172+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
173+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
174+
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
175+
expect(mockSyncWorkspaceEnvCredentials).not.toHaveBeenCalled()
176+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
177+
expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled()
178+
expect(mockCaptureServerEvent).not.toHaveBeenCalled()
179+
}
180+
)
181+
155182
it('delivers the transactionally enqueued notification through the outbox', async () => {
156183
await directGrantOutboxHandlers[DIRECT_GRANT_EMAIL_EVENT_TYPE](
157184
{

‎apps/sim/lib/invitations/direct-grant.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
workspaceEnvironment,
1111
} from '@sim/db/schema'
1212
import { createLogger } from '@sim/logger'
13-
import { permissionSatisfies } from '@sim/platform-authz/workspace'
13+
import { isOrgAdminRole, permissionSatisfies } from '@sim/platform-authz/workspace'
1414
import { generateId } from '@sim/utils/id'
1515
import { isRecordLike } from '@sim/utils/object'
1616
import { normalizeEmail } from '@sim/utils/string'
@@ -179,6 +179,13 @@ export async function grantWorkspaceAccessDirectly(
179179
and(eq(member.userId, input.actorId), eq(member.organizationId, input.organizationId))
180180
)
181181
.for('update')
182+
await tx
183+
.select({ id: member.id })
184+
.from(member)
185+
.where(
186+
and(eq(member.userId, input.userId), eq(member.organizationId, input.organizationId))
187+
)
188+
.for('update')
182189

183190
const workspaceRow = await getWorkspaceWithOwner(input.workspaceId, {
184191
executor: tx,
@@ -228,7 +235,9 @@ export async function grantWorkspaceAccessDirectly(
228235
.limit(1)
229236

230237
let outcome: DirectGrantOutcome
231-
if (existing) {
238+
if (isOrgAdminRole(inviteeMembership.role)) {
239+
outcome = { outcome: 'unchanged', permission: 'admin' }
240+
} else if (existing) {
232241
const existingPermission = existing.permissionType as PermissionType
233242
if (
234243
input.existingPermissionPolicy === 'ensure-at-least' &&

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

Lines changed: 169 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,14 @@ describe('createWorkspaceInvitation', () => {
292292
it.each(['admin', 'owner'] as const)(
293293
'leaves inherited access unchanged when ensuring access for an organization %s',
294294
async (role) => {
295-
queueWhereResponses([[{ id: 'user-2', email: 'member@example.com' }], []])
296-
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role })
295+
queueTableRows(userTable, [{ id: 'user-2', email: 'member@example.com' }])
296+
queueTableRows(member, [{ role: 'owner' }])
297+
queueTableRows(member, [{ role }])
298+
mockGetUserOrganization.mockResolvedValueOnce({
299+
organizationId: 'org-1',
300+
memberId: 'member-2',
301+
role,
302+
})
297303

298304
const result = await createWorkspaceInvitation({
299305
context: makeContext(['ws-1', 'ws-2']),
@@ -318,41 +324,189 @@ describe('createWorkspaceInvitation', () => {
318324
}
319325
)
320326

321-
it('reports a promotion as updated without granting redundant workspace permissions', async () => {
327+
it.each(['member', 'admin'])(
328+
'reports a current member promotion correctly after initially observing %s',
329+
async (observedRole) => {
330+
queueTableRows(userTable, [{ id: 'user-2' }])
331+
queueTableRows(member, [{ role: 'owner' }])
332+
queueTableRows(member, [{ role: 'member' }])
333+
mockGetUserOrganization.mockResolvedValueOnce({
334+
organizationId: 'org-1',
335+
memberId: 'member-2',
336+
role: observedRole,
337+
})
338+
339+
const result = await createWorkspaceInvitation({
340+
context: makeContext(['ws-1', 'ws-2']),
341+
email: 'member@example.com',
342+
permission: 'write',
343+
membership: 'admin',
344+
existingAccessPolicy: 'ensure-at-least',
345+
request,
346+
})
347+
348+
expect(result).toMatchObject({
349+
workspaceIds: [],
350+
instantAdd: true,
351+
outcome: 'updated',
352+
membershipIntent: 'internal',
353+
})
354+
expect(dbChainMockFns.update).toHaveBeenCalledExactlyOnceWith(member)
355+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ role: 'admin' })
356+
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledExactlyOnceWith(
357+
expect.objectContaining({
358+
action: 'org_member.role_changed',
359+
metadata: expect.objectContaining({ previousRole: 'member', newRole: 'admin' }),
360+
})
361+
)
362+
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
363+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
364+
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
365+
}
366+
)
367+
368+
it('reconciles workspace access when an inherited admin was demoted before the locked check', async () => {
322369
queueTableRows(userTable, [{ id: 'user-2' }])
323-
queueTableRows(member, [{ role: 'owner' }])
370+
queueTableRows(member, [{ role: 'member' }])
324371
queueTableRows(member, [{ role: 'member' }])
325372
mockGetUserOrganization.mockResolvedValueOnce({
326373
organizationId: 'org-1',
327374
memberId: 'member-2',
328-
role: 'member',
375+
role: 'admin',
329376
})
330377

331378
const result = await createWorkspaceInvitation({
332-
context: makeContext(['ws-1', 'ws-2']),
379+
context: makeContext(),
333380
email: 'member@example.com',
381+
membership: 'member',
334382
permission: 'write',
335-
membership: 'admin',
336383
existingAccessPolicy: 'ensure-at-least',
337-
request,
338384
})
339385

340386
expect(result).toMatchObject({
341-
workspaceIds: [],
387+
outcome: 'added',
388+
workspaceIds: ['ws-1'],
342389
instantAdd: true,
343-
outcome: 'updated',
344-
membershipIntent: 'internal',
345390
})
346-
expect(dbChainMockFns.update).toHaveBeenCalledExactlyOnceWith(member)
347-
expect(dbChainMockFns.set).toHaveBeenCalledWith({ role: 'admin' })
348-
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledExactlyOnceWith(
349-
expect.objectContaining({ action: 'org_member.role_changed' })
391+
expect(mockAcquireInvitationMutationLocks).toHaveBeenCalledExactlyOnceWith(expect.anything(), {
392+
invitationIds: [],
393+
workspaceIds: ['ws-1'],
394+
})
395+
expect(mockAcquireInvitationMutationLocks.mock.invocationCallOrder[0]).toBeLessThan(
396+
mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0]
397+
)
398+
expect(mockAcquireOrganizationUserMutationLocks.mock.invocationCallOrder[0]).toBeLessThan(
399+
dbChainMockFns.for.mock.invocationCallOrder[0]
400+
)
401+
expect(mockGetEffectiveWorkspacePermission).toHaveBeenCalledExactlyOnceWith(
402+
'user-1',
403+
expect.objectContaining({ id: 'ws-1', organizationId: 'org-1' }),
404+
expect.anything()
405+
)
406+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledExactlyOnceWith(
407+
expect.objectContaining({ userId: 'user-2', permission: 'write' })
350408
)
409+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
410+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
411+
})
412+
413+
it('lets workspace admins preserve current inherited access without organization-admin authority', async () => {
414+
queueTableRows(userTable, [{ id: 'user-2' }])
415+
queueTableRows(member, [{ role: 'member' }])
416+
queueTableRows(member, [{ role: 'admin' }])
417+
mockGetUserOrganization.mockResolvedValueOnce({
418+
organizationId: 'org-1',
419+
memberId: 'member-2',
420+
role: 'admin',
421+
})
422+
423+
const result = await createWorkspaceInvitation({
424+
context: makeContext(),
425+
email: 'member@example.com',
426+
membership: 'member',
427+
existingAccessPolicy: 'ensure-at-least',
428+
})
429+
430+
expect(result).toMatchObject({ outcome: 'unchanged', workspaceIds: [] })
431+
expect(mockGetEffectiveWorkspacePermission).toHaveBeenCalled()
432+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
351433
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
434+
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
435+
})
436+
437+
it('excludes workspaces already covered when the direct grant observes a concurrent promotion', async () => {
438+
queueTableRows(userTable, [{ id: 'user-2' }])
439+
mockGetUserOrganization.mockResolvedValueOnce({
440+
organizationId: 'org-1',
441+
memberId: 'member-2',
442+
role: 'member',
443+
})
444+
mockGrantWorkspaceAccessDirectly.mockResolvedValueOnce({
445+
outcome: 'unchanged',
446+
permission: 'admin',
447+
})
448+
449+
const result = await createWorkspaceInvitation({
450+
context: makeContext(),
451+
email: 'member@example.com',
452+
existingAccessPolicy: 'ensure-at-least',
453+
})
454+
455+
expect(result).toMatchObject({ outcome: 'unchanged', workspaceIds: [], instantAdd: true })
456+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledOnce()
352457
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
353458
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
354459
})
355460

461+
it('rejects inherited access reconciliation when the workspace changed organizations', async () => {
462+
queueTableRows(userTable, [{ id: 'user-2' }])
463+
queueTableRows(member, [{ role: 'owner' }])
464+
queueTableRows(member, [{ role: 'admin' }])
465+
mockGetUserOrganization.mockResolvedValueOnce({
466+
organizationId: 'org-1',
467+
memberId: 'member-2',
468+
role: 'admin',
469+
})
470+
mockGetWorkspaceWithOwner.mockResolvedValueOnce(makeTarget('ws-1', 'org-2').workspaceDetails)
471+
472+
await expect(
473+
createWorkspaceInvitation({
474+
context: makeContext(),
475+
email: 'member@example.com',
476+
existingAccessPolicy: 'ensure-at-least',
477+
})
478+
).rejects.toMatchObject({ status: 409 })
479+
480+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
481+
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
482+
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
483+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
484+
})
485+
486+
it('rejects an inherited-access no-op when the inviter lost workspace admin access', async () => {
487+
queueTableRows(userTable, [{ id: 'user-2' }])
488+
queueTableRows(member, [{ role: 'member' }])
489+
queueTableRows(member, [{ role: 'admin' }])
490+
mockGetUserOrganization.mockResolvedValueOnce({
491+
organizationId: 'org-1',
492+
memberId: 'member-2',
493+
role: 'admin',
494+
})
495+
mockGetEffectiveWorkspacePermission.mockResolvedValueOnce('read')
496+
497+
await expect(
498+
createWorkspaceInvitation({
499+
context: makeContext(),
500+
email: 'member@example.com',
501+
existingAccessPolicy: 'ensure-at-least',
502+
})
503+
).rejects.toMatchObject({ status: 409 })
504+
505+
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
506+
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
507+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
508+
})
509+
356510
it.each(['admin', 'owner'] as const)(
357511
'does not inherit workspace access from a different organization %s role',
358512
async (role) => {

0 commit comments

Comments
 (0)