Skip to content

Commit ca07155

Browse files
committed
fix(workspaces): exclude banned users and include org-admin candidates in stranding check
Two gaps found by Cursor review on the latest commit: - findMembersStrandedByArchival only considered users with an explicit permissions row on the workspace being deleted. An org admin who accesses that workspace purely through their organization role (no permission row at all) was never evaluated as a stranding candidate, so they could be left with zero workspaces without getting a fallback. Now unions explicit permission holders with the organization's admins/owners (via the member table + ORG_ADMIN_ROLES) before running the same accessibility check on every candidate. - A banned user who still holds a permissions row on someone else's workspace (they don't have to own it) would get a fresh fallback workspace if stranded by that workspace's deletion — even though banned users should never receive new resources. Now filters actively-banned userIds (via the existing getActivelyBannedUserIds helper, which also covers blocked emails/domains) out of the stranded list before provisioning. Added tests for both: an org admin with no explicit permission row gets provisioned when stranded, and an actively banned stranded member does not get provisioned (and gets no audit entry).
1 parent 34735df commit ca07155

2 files changed

Lines changed: 127 additions & 13 deletions

File tree

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

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ const {
1010
mockArchiveWorkflowsForWorkspace,
1111
mockListAccessibleWorkspaceRowsForUser,
1212
mockCreateWorkspaceRecord,
13+
mockGetActivelyBannedUserIds,
1314
} = vi.hoisted(() => ({
1415
mockSelect: vi.fn(),
1516
mockTransaction: vi.fn(),
1617
mockArchiveWorkflowsForWorkspace: vi.fn(),
1718
mockListAccessibleWorkspaceRowsForUser: vi.fn(),
1819
mockCreateWorkspaceRecord: vi.fn(),
20+
mockGetActivelyBannedUserIds: vi.fn(),
1921
}))
2022

2123
const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner
@@ -41,6 +43,10 @@ vi.mock('@/lib/workspaces/create', () => ({
4143
createWorkspaceRecord: mockCreateWorkspaceRecord,
4244
}))
4345

46+
vi.mock('@/lib/auth/ban', () => ({
47+
getActivelyBannedUserIds: mockGetActivelyBannedUserIds,
48+
}))
49+
4450
vi.mock('@sim/audit', () => auditMock)
4551

4652
import { archiveWorkspace } from './lifecycle'
@@ -65,9 +71,18 @@ function accessibleWorkspaceRow(workspaceId: string) {
6571
return { workspace: { id: workspaceId }, permissionType: 'admin' as const }
6672
}
6773

68-
function createTx(members: Array<{ userId: string }>) {
74+
function createTx(
75+
members: Array<{ userId: string }>,
76+
orgAdminMembers: Array<{ userId: string }> = []
77+
) {
78+
const selectDistinct = vi.fn()
79+
// First call is always the explicit-permissions query; the second (only reached when the
80+
// workspace has an organizationId) is the org-admin query.
81+
selectDistinct.mockReturnValueOnce(createMembersChain(members))
82+
selectDistinct.mockReturnValueOnce(createMembersChain(orgAdminMembers))
83+
6984
return {
70-
selectDistinct: vi.fn().mockReturnValue(createMembersChain(members)),
85+
selectDistinct,
7186
select: vi.fn().mockReturnValue({
7287
from: vi.fn().mockReturnValue({
7388
where: vi.fn().mockResolvedValue([]),
@@ -89,6 +104,7 @@ describe('workspace lifecycle', () => {
89104
}),
90105
})
91106
mockCreateWorkspaceRecord.mockResolvedValue({ id: 'fallback-workspace', name: 'My Workspace' })
107+
mockGetActivelyBannedUserIds.mockResolvedValue([])
92108
})
93109

94110
it('archives workspace and dependent resources under serializable isolation', async () => {
@@ -303,6 +319,74 @@ describe('workspace lifecycle', () => {
303319
expect(tx.update).toHaveBeenCalledTimes(8)
304320
})
305321

322+
it('provisions a fallback for an org admin who is stranded but has no explicit permission row', async () => {
323+
mockGetWorkspaceWithOwner.mockResolvedValue({
324+
id: 'workspace-1',
325+
name: 'Workspace 1',
326+
ownerId: 'user-1',
327+
organizationId: 'org-1',
328+
archivedAt: null,
329+
})
330+
mockArchiveWorkflowsForWorkspace.mockResolvedValue(0)
331+
// The org admin has no row in `permissions` for this workspace at all — they only appear as
332+
// an org-admin candidate. Their only accessible workspace is this one, so they're stranded.
333+
mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([
334+
accessibleWorkspaceRow('workspace-1'),
335+
])
336+
337+
const tx = createTx([], [{ userId: 'user-org-admin-no-row' }])
338+
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
339+
callback(tx)
340+
)
341+
342+
const result = await archiveWorkspace('workspace-1', {
343+
requestId: 'req-1',
344+
provisionFallbackForStrandedMembers: true,
345+
})
346+
347+
expect(result).toEqual({
348+
archived: true,
349+
workspaceName: 'Workspace 1',
350+
provisionedWorkspaceUserIds: ['user-org-admin-no-row'],
351+
})
352+
expect(tx.selectDistinct).toHaveBeenCalledTimes(2)
353+
expect(mockCreateWorkspaceRecord).toHaveBeenCalledWith(
354+
expect.objectContaining({ userId: 'user-org-admin-no-row' })
355+
)
356+
})
357+
358+
it('does not provision a fallback for an actively banned stranded member', async () => {
359+
mockGetWorkspaceWithOwner.mockResolvedValue({
360+
id: 'workspace-1',
361+
name: 'Workspace 1',
362+
ownerId: 'user-1',
363+
archivedAt: null,
364+
})
365+
mockArchiveWorkflowsForWorkspace.mockResolvedValue(0)
366+
mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([
367+
accessibleWorkspaceRow('workspace-1'),
368+
])
369+
mockGetActivelyBannedUserIds.mockResolvedValue(['user-banned'])
370+
371+
const tx = createTx([{ userId: 'user-banned' }])
372+
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
373+
callback(tx)
374+
)
375+
376+
const result = await archiveWorkspace('workspace-1', {
377+
requestId: 'req-1',
378+
provisionFallbackForStrandedMembers: true,
379+
actorId: 'admin-1',
380+
})
381+
382+
expect(result).toEqual({
383+
archived: true,
384+
workspaceName: 'Workspace 1',
385+
})
386+
expect(mockCreateWorkspaceRecord).not.toHaveBeenCalled()
387+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
388+
})
389+
306390
it('proceeds without provisioning when every member has another active workspace', async () => {
307391
mockGetWorkspaceWithOwner.mockResolvedValue({
308392
id: 'workspace-1',

apps/sim/lib/workspaces/lifecycle.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
knowledgeBase,
99
knowledgeConnector,
1010
mcpServers,
11+
member,
1112
permissions,
1213
userTableDefinitions,
1314
workflowMcpServer,
@@ -16,7 +17,9 @@ import {
1617
workspaceFiles,
1718
} from '@sim/db/schema'
1819
import { createLogger } from '@sim/logger'
20+
import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace'
1921
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
22+
import { getActivelyBannedUserIds } from '@/lib/auth/ban'
2023
import type { DbOrTx } from '@/lib/db/types'
2124
import { mcpPubSub } from '@/lib/mcp/pubsub'
2225
import { mcpService } from '@/lib/mcp/service'
@@ -55,39 +58,62 @@ interface ArchiveWorkspaceResult {
5558
}
5659

5760
/**
58-
* Returns the userIds of explicit workspace members for whom `workspaceId` is their only
59-
* accessible active (non-archived) workspace. "Accessible" includes workspaces granted through
60-
* an explicit permission row AND workspaces derived from organization owner/admin role — an org
61-
* admin can always fall back to the rest of the organization's workspaces even without an
62-
* explicit permission row on any of them, so they are never stranded by this deletion.
61+
* Returns the userIds who would be left with zero accessible active (non-archived) workspaces if
62+
* `workspaceId` were archived. Candidates are the union of explicit workspace members AND the
63+
* organization's admins/owners — an org admin can access a workspace purely through their org
64+
* role with no permission row at all, so they must be checked even though they never show up as
65+
* an explicit member. "Accessible" (via `listAccessibleWorkspaceRowsForUser`) already accounts for
66+
* that same org-admin-derived access when deciding whether a candidate has another workspace to
67+
* fall back to. Actively banned users are excluded from the result — they should never receive a
68+
* new resource as a side effect of someone else's action.
6369
*
6470
* Must be called against the same executor used to perform the archival, under
6571
* `serializable` isolation, so the check and the write are atomic with respect to a concurrent
6672
* deletion of another workspace shared by the same member.
6773
*/
6874
async function findMembersStrandedByArchival(
6975
executor: DbOrTx,
70-
workspaceId: string
76+
workspaceId: string,
77+
organizationId: string | null
7178
): Promise<string[]> {
72-
const members = await executor
79+
const explicitMembers = await executor
7380
.selectDistinct({ userId: permissions.userId })
7481
.from(permissions)
7582
.where(and(eq(permissions.entityId, workspaceId), eq(permissions.entityType, 'workspace')))
7683

77-
if (members.length === 0) {
84+
const candidateUserIds = new Set(explicitMembers.map((row) => row.userId))
85+
86+
if (organizationId) {
87+
const orgAdmins = await executor
88+
.selectDistinct({ userId: member.userId })
89+
.from(member)
90+
.where(
91+
and(eq(member.organizationId, organizationId), inArray(member.role, [...ORG_ADMIN_ROLES]))
92+
)
93+
for (const { userId } of orgAdmins) {
94+
candidateUserIds.add(userId)
95+
}
96+
}
97+
98+
if (candidateUserIds.size === 0) {
7899
return []
79100
}
80101

81102
const strandedUserIds: string[] = []
82-
for (const { userId } of members) {
103+
for (const userId of candidateUserIds) {
83104
const accessible = await listAccessibleWorkspaceRowsForUser(userId, 'active', executor)
84105
const hasOtherWorkspace = accessible.some((row) => row.workspace.id !== workspaceId)
85106
if (!hasOtherWorkspace) {
86107
strandedUserIds.push(userId)
87108
}
88109
}
89110

90-
return strandedUserIds
111+
if (strandedUserIds.length === 0) {
112+
return []
113+
}
114+
115+
const bannedUserIds = new Set(await getActivelyBannedUserIds(strandedUserIds))
116+
return strandedUserIds.filter((userId) => !bannedUserIds.has(userId))
91117
}
92118

93119
export async function archiveWorkspace(
@@ -123,7 +149,11 @@ export async function archiveWorkspace(
123149
const fallbacks: Array<{ userId: string; workspaceId: string; name: string }> = []
124150

125151
if (options.provisionFallbackForStrandedMembers) {
126-
const strandedUserIds = await findMembersStrandedByArchival(tx, workspaceId)
152+
const strandedUserIds = await findMembersStrandedByArchival(
153+
tx,
154+
workspaceId,
155+
workspaceRecord.organizationId
156+
)
127157
for (const userId of strandedUserIds) {
128158
// Intentionally bypasses getWorkspaceCreationPolicy: this is a system-provisioned safety
129159
// net (never blocked by "who can create a workspace" rules), not user self-service.

0 commit comments

Comments
 (0)