Skip to content

Commit c6be4cf

Browse files
committed
fix(workspaces): protect non-banned co-members from stranding in the ban flow; trim redundant comments
disableUserResources now opts into provisionFallbackForStrandedMembers. Actively banned users are already excluded from receiving a fallback (findMembersStrandedByArchival filters them out), so this only protects a non-banned co-member of a banned owner's workspace from the same stranding bug this PR fixes for interactive deletion — previously they had no safety net at all. Also trims several inline comments that only restated what the adjacent assertion or test title already made clear, splitting a couple of telemetry assertions into their own descriptively-named tests instead of narrating them inline.
1 parent 277e794 commit c6be4cf

6 files changed

Lines changed: 85 additions & 32 deletions

File tree

apps/sim/lib/workflows/lifecycle.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe('disableUserResources', () => {
147147
vi.clearAllMocks()
148148
})
149149

150-
it('archives every owned workspace without opting into fallback provisioning, so banning is never blocked by other members', async () => {
150+
it('archives every owned workspace with fallback provisioning opted in, so a non-banned co-member is not stranded', async () => {
151151
mockSelect.mockReturnValue(createSelectChain([{ id: 'workspace-1' }, { id: 'workspace-2' }]))
152152
mockDelete.mockReturnValue({ where: vi.fn().mockResolvedValue([]) })
153153
mockArchiveWorkspace.mockResolvedValue({ archived: true, workspaceName: 'Workspace' })
@@ -157,11 +157,11 @@ describe('disableUserResources', () => {
157157
expect(mockArchiveWorkspace).toHaveBeenCalledTimes(2)
158158
expect(mockArchiveWorkspace).toHaveBeenCalledWith(
159159
'workspace-1',
160-
expect.not.objectContaining({ provisionFallbackForStrandedMembers: true })
160+
expect.objectContaining({ provisionFallbackForStrandedMembers: true })
161161
)
162162
expect(mockArchiveWorkspace).toHaveBeenCalledWith(
163163
'workspace-2',
164-
expect.not.objectContaining({ provisionFallbackForStrandedMembers: true })
164+
expect.objectContaining({ provisionFallbackForStrandedMembers: true })
165165
)
166166
expect(mockDelete).toHaveBeenCalled()
167167
})

apps/sim/lib/workflows/lifecycle.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,11 @@ export async function archiveWorkflowsByIdsInWorkspace(
346346
/**
347347
* Disables all resources owned by a banned user by archiving every workspace
348348
* they own (cascading to workflows, chats, KBs, tables, files, etc.)
349-
* and deleting their personal API keys.
349+
* and deleting their personal API keys. Still opts into stranded-member
350+
* fallback provisioning: the banned owner is excluded from receiving a
351+
* fallback (`findMembersStrandedByArchival` filters actively banned users),
352+
* but a non-banned co-member of an owned workspace can still be stranded by
353+
* the ban and must get the same safety net as an interactive deletion.
350354
*/
351355
export async function disableUserResources(userId: string): Promise<void> {
352356
const requestId = generateRequestId()
@@ -360,7 +364,9 @@ export async function disableUserResources(userId: string): Promise<void> {
360364
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt)))
361365

362366
await Promise.all([
363-
...ownedWorkspaces.map((w) => archiveWorkspace(w.id, { requestId })),
367+
...ownedWorkspaces.map((w) =>
368+
archiveWorkspace(w.id, { requestId, provisionFallbackForStrandedMembers: true })
369+
),
364370
db.delete(apiKey).where(eq(apiKey.userId, userId)),
365371
])
366372

apps/sim/lib/workspaces/create.test.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,46 @@ describe('createWorkspaceRecord', () => {
6868
expect(record.workspaceMode).toBe('personal')
6969
expect(tx.insert).toHaveBeenCalledTimes(3)
7070
expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledTimes(1)
71-
// Safe to fire immediately: this call committed its own transaction before returning.
71+
})
72+
73+
it('runs directly against a provided executor instead of opening a nested transaction', async () => {
74+
const tx = createInsertOnlyTx()
75+
76+
await createWorkspaceRecord({
77+
userId: 'user-1',
78+
name: 'My Workspace',
79+
organizationId: null,
80+
workspaceMode: 'personal',
81+
billedAccountUserId: 'user-1',
82+
executor: tx as never,
83+
})
84+
85+
expect(mockTransaction).not.toHaveBeenCalled()
86+
expect(tx.insert).toHaveBeenCalledTimes(3)
87+
})
88+
89+
it('fires the workspaceCreated event once its own transaction commits', async () => {
90+
const tx = createInsertOnlyTx()
91+
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
92+
callback(tx)
93+
)
94+
95+
const record = await createWorkspaceRecord({
96+
userId: 'user-1',
97+
name: 'My Workspace',
98+
organizationId: null,
99+
workspaceMode: 'personal',
100+
billedAccountUserId: 'user-1',
101+
})
102+
72103
expect(mockWorkspaceCreatedEvent).toHaveBeenCalledWith({
73104
workspaceId: record.id,
74105
userId: 'user-1',
75106
name: 'My Workspace',
76107
})
77108
})
78109

79-
it('runs directly against a provided executor instead of opening a nested transaction', async () => {
110+
it('does not fire the workspaceCreated event when given a caller-owned executor', async () => {
80111
const tx = createInsertOnlyTx()
81112

82113
await createWorkspaceRecord({
@@ -88,11 +119,6 @@ describe('createWorkspaceRecord', () => {
88119
executor: tx as never,
89120
})
90121

91-
expect(mockTransaction).not.toHaveBeenCalled()
92-
expect(tx.insert).toHaveBeenCalledTimes(3)
93-
// Must NOT fire here: the caller's outer transaction hasn't committed yet and could still
94-
// roll back, which would make this a phantom event for a workspace that never existed. The
95-
// caller owns firing this once its own transaction commits.
96122
expect(mockWorkspaceCreatedEvent).not.toHaveBeenCalled()
97123
})
98124

apps/sim/lib/workspaces/create.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,6 @@ export async function createWorkspaceRecord({
147147
throw error
148148
}
149149

150-
// Only fire when this call committed its own transaction. When `executor` is a caller-supplied
151-
// `tx`, the insert isn't durable yet — the caller's transaction can still roll back after this
152-
// function returns, so firing here would risk a phantom event for a workspace that never
153-
// existed. Callers that pass `executor` must fire this themselves once their transaction commits.
154150
if (!executor) {
155151
try {
156152
PlatformEvents.workspaceCreated({ workspaceId, userId, name })

apps/sim/lib/workspaces/lifecycle.test.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,7 @@ function createTx(
8282
orgAdminMembers: Array<{ userId: string }> = []
8383
) {
8484
const selectDistinct = vi.fn()
85-
// First call is always the explicit-permissions query; the second (only reached when the
86-
// workspace has an organizationId) is the org-admin query.
85+
// Mocked in call order: explicit-permissions query, then org-admin query.
8786
selectDistinct.mockReturnValueOnce(createMembersChain(members))
8887
selectDistinct.mockReturnValueOnce(createMembersChain(orgAdminMembers))
8988

@@ -233,7 +232,7 @@ describe('workspace lifecycle', () => {
233232
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
234233
})
235234

236-
it('does not record an audit entry for a fallback workspace whose transaction subsequently fails', async () => {
235+
it('does not record an audit entry or fire the workspaceCreated event for a fallback workspace whose transaction subsequently fails', async () => {
237236
mockGetWorkspaceWithOwner.mockResolvedValue({
238237
id: 'workspace-1',
239238
name: 'Workspace 1',
@@ -258,10 +257,6 @@ describe('workspace lifecycle', () => {
258257
})
259258
).rejects.toThrow('serialization_failure')
260259

261-
// recordAudit and the workspaceCreated telemetry event must only ever fire after the
262-
// transaction has committed — otherwise a failed transaction (e.g. a serialization abort)
263-
// would leave a phantom audit entry / event pointing at a fallback workspace that was
264-
// rolled back.
265260
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
266261
expect(mockWorkspaceCreatedEvent).not.toHaveBeenCalled()
267262
})
@@ -341,8 +336,6 @@ describe('workspace lifecycle', () => {
341336
archivedAt: null,
342337
})
343338
mockArchiveWorkflowsForWorkspace.mockResolvedValue(0)
344-
// The org admin has no row in `permissions` for this workspace at all — they only appear as
345-
// an org-admin candidate. Their only accessible workspace is this one, so they're stranded.
346339
mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([
347340
accessibleWorkspaceRow('workspace-1'),
348341
])
@@ -400,6 +393,40 @@ describe('workspace lifecycle', () => {
400393
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
401394
})
402395

396+
it('provisions a fallback for a non-banned stranded co-member while excluding the banned owner in the same run', async () => {
397+
mockGetWorkspaceWithOwner.mockResolvedValue({
398+
id: 'workspace-1',
399+
name: 'Workspace 1',
400+
ownerId: 'user-banned',
401+
archivedAt: null,
402+
})
403+
mockArchiveWorkflowsForWorkspace.mockResolvedValue(0)
404+
mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([
405+
accessibleWorkspaceRow('workspace-1'),
406+
])
407+
mockGetActivelyBannedUserIds.mockResolvedValue(['user-banned'])
408+
409+
const tx = createTx([{ userId: 'user-banned' }, { userId: 'user-cohort' }])
410+
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
411+
callback(tx)
412+
)
413+
414+
const result = await archiveWorkspace('workspace-1', {
415+
requestId: 'req-1',
416+
provisionFallbackForStrandedMembers: true,
417+
})
418+
419+
expect(result).toEqual({
420+
archived: true,
421+
workspaceName: 'Workspace 1',
422+
provisionedWorkspaceUserIds: ['user-cohort'],
423+
})
424+
expect(mockCreateWorkspaceRecord).toHaveBeenCalledTimes(1)
425+
expect(mockCreateWorkspaceRecord).toHaveBeenCalledWith(
426+
expect.objectContaining({ userId: 'user-cohort' })
427+
)
428+
})
429+
403430
it('proceeds without provisioning when every member has another active workspace', async () => {
404431
mockGetWorkspaceWithOwner.mockResolvedValue({
405432
id: 'workspace-1',
@@ -431,7 +458,7 @@ describe('workspace lifecycle', () => {
431458
expect(tx.update).toHaveBeenCalledTimes(8)
432459
})
433460

434-
it('never checks or provisions when provisionFallbackForStrandedMembers is not set (ban flow default)', async () => {
461+
it('never checks or provisions when provisionFallbackForStrandedMembers is not set, but still performs the archival writes (ban flow default)', async () => {
435462
mockGetWorkspaceWithOwner.mockResolvedValue({
436463
id: 'workspace-1',
437464
name: 'Workspace 1',
@@ -455,9 +482,6 @@ describe('workspace lifecycle', () => {
455482
expect(mockListAccessibleWorkspaceRowsForUser).not.toHaveBeenCalled()
456483
expect(mockCreateWorkspaceRecord).not.toHaveBeenCalled()
457484
expect(mockTransaction).toHaveBeenCalledWith(expect.any(Function), undefined)
458-
// The archival writes must still run even when fallback provisioning is skipped entirely —
459-
// this is the exact regression a prior version of this fix introduced (an early return that
460-
// skipped all archival writes whenever the flag was off).
461485
expect(tx.update).toHaveBeenCalledTimes(8)
462486
expect(tx.delete).toHaveBeenCalledTimes(1)
463487
})

apps/sim/lib/workspaces/lifecycle.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ interface ArchiveWorkspaceOptions {
4040
/**
4141
* Opts into auto-provisioning a replacement workspace for any member who'd otherwise be left
4242
* with zero active workspaces. Off by default so archival stays a pure "delete this workspace"
43-
* primitive for callers that don't ask for it (e.g. the account-disable flow, where a banned
44-
* user's workspaces must be fully archived without handing the banned user a fresh one).
45-
* Only the interactive DELETE route should set this.
43+
* primitive for callers that don't need it. Safe to combine with a banned owner: actively banned
44+
* users are excluded from receiving a fallback regardless of this flag (see
45+
* `findMembersStrandedByArchival`), so the account-disable flow also sets this to protect any
46+
* non-banned co-member of the banned owner's workspace.
4647
*/
4748
provisionFallbackForStrandedMembers?: boolean
4849
actorId?: string

0 commit comments

Comments
 (0)