Skip to content

Commit 17ed8f4

Browse files
committed
fix(workspaces): record an audit entry for auto-provisioned fallback workspaces
Normal workspace creation gets its own WORKSPACE_CREATED audit row; the auto-provisioned fallback for a stranded member didn't, making it only discoverable indirectly via the deletion's audit metadata. archiveWorkspace now accepts an optional actor (from the deleting admin's session) and records a WORKSPACE_CREATED audit entry per fallback workspace it provisions, attributing the action to the admin who triggered the deletion.
1 parent 96b1e68 commit 17ed8f4

4 files changed

Lines changed: 70 additions & 4 deletions

File tree

apps/sim/app/api/workspaces/[id]/route.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ describe('DELETE /api/workspaces/[id]', () => {
8383
expect(body).toEqual({ success: true })
8484
expect(mockArchiveWorkspace).toHaveBeenCalledWith('workspace-1', {
8585
requestId: 'workspace-workspace-1',
86+
actorId: 'user-admin',
87+
actorName: 'Admin',
88+
actorEmail: 'admin@example.com',
8689
})
8790
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
8891
expect.objectContaining({

apps/sim/app/api/workspaces/[id]/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@ export const DELETE = withRouteHandler(
250250

251251
const archiveResult = await archiveWorkspace(workspaceId, {
252252
requestId: `workspace-${workspaceId}`,
253+
actorId: session.user.id,
254+
actorName: session.user.name,
255+
actorEmail: session.user.email,
253256
})
254257

255258
if (!archiveResult.archived) {

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { permissionsMock, permissionsMockFns } from '@sim/testing'
4+
import { auditMock, auditMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
@@ -41,6 +41,8 @@ vi.mock('@/lib/workspaces/create', () => ({
4141
createWorkspaceRecord: mockCreateWorkspaceRecord,
4242
}))
4343

44+
vi.mock('@sim/audit', () => auditMock)
45+
4446
import { archiveWorkspace } from './lifecycle'
4547

4648
function createUpdateChain() {
@@ -86,7 +88,7 @@ describe('workspace lifecycle', () => {
8688
where: vi.fn().mockResolvedValue([]),
8789
}),
8890
})
89-
mockCreateWorkspaceRecord.mockResolvedValue({ id: 'fallback-workspace' })
91+
mockCreateWorkspaceRecord.mockResolvedValue({ id: 'fallback-workspace', name: 'My Workspace' })
9092
})
9193

9294
it('archives workspace and dependent resources under serializable isolation', async () => {
@@ -138,7 +140,12 @@ describe('workspace lifecycle', () => {
138140
callback(tx)
139141
)
140142

141-
const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' })
143+
const result = await archiveWorkspace('workspace-1', {
144+
requestId: 'req-1',
145+
actorId: 'admin-1',
146+
actorName: 'Admin',
147+
actorEmail: 'admin@example.com',
148+
})
142149

143150
expect(result).toEqual({
144151
archived: true,
@@ -155,11 +162,44 @@ describe('workspace lifecycle', () => {
155162
executor: tx,
156163
})
157164
)
165+
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
166+
expect.objectContaining({
167+
actorId: 'admin-1',
168+
resourceId: 'fallback-workspace',
169+
metadata: expect.objectContaining({
170+
deletedWorkspaceId: 'workspace-1',
171+
recipientUserId: 'user-victim',
172+
}),
173+
})
174+
)
158175
// Deletion is never blocked — the workspace is still archived alongside the fallback creation.
159176
expect(tx.update).toHaveBeenCalledTimes(8)
160177
expect(tx.delete).toHaveBeenCalledTimes(1)
161178
})
162179

180+
it('does not record an audit entry for the fallback workspace when no actor is provided', async () => {
181+
mockGetWorkspaceWithOwner.mockResolvedValue({
182+
id: 'workspace-1',
183+
name: 'Workspace 1',
184+
ownerId: 'user-1',
185+
archivedAt: null,
186+
})
187+
mockArchiveWorkflowsForWorkspace.mockResolvedValue(0)
188+
mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([
189+
accessibleWorkspaceRow('workspace-1'),
190+
])
191+
192+
const tx = createTx([{ userId: 'user-victim' }])
193+
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
194+
callback(tx)
195+
)
196+
197+
await archiveWorkspace('workspace-1', { requestId: 'req-1' })
198+
199+
expect(mockCreateWorkspaceRecord).toHaveBeenCalled()
200+
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
201+
})
202+
163203
it('only provisions a fallback for the one member who would actually be stranded', async () => {
164204
mockGetWorkspaceWithOwner.mockResolvedValue({
165205
id: 'workspace-1',

apps/sim/lib/workspaces/lifecycle.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
12
import { db } from '@sim/db'
23
import {
34
apiKey,
@@ -38,6 +39,10 @@ interface ArchiveWorkspaceOptions {
3839
* the banned user specifically should not be handed a fresh workspace as a side effect.
3940
*/
4041
force?: boolean
42+
/** Attributed as the actor on the audit log entry for any auto-provisioned replacement workspace. */
43+
actorId?: string
44+
actorName?: string | null
45+
actorEmail?: string | null
4146
}
4247

4348
interface ArchiveWorkspaceResult {
@@ -119,14 +124,29 @@ export async function archiveWorkspace(
119124
if (!options.force) {
120125
const strandedUserIds = await findMembersStrandedByArchival(tx, workspaceId)
121126
for (const userId of strandedUserIds) {
122-
await createWorkspaceRecord({
127+
const fallbackWorkspace = await createWorkspaceRecord({
123128
userId,
124129
name: FALLBACK_WORKSPACE_NAME,
125130
organizationId: null,
126131
workspaceMode: WORKSPACE_MODE.PERSONAL,
127132
billedAccountUserId: userId,
128133
executor: tx,
129134
})
135+
136+
if (options.actorId) {
137+
recordAudit({
138+
workspaceId: fallbackWorkspace.id,
139+
actorId: options.actorId,
140+
actorName: options.actorName,
141+
actorEmail: options.actorEmail,
142+
action: AuditAction.WORKSPACE_CREATED,
143+
resourceType: AuditResourceType.WORKSPACE,
144+
resourceId: fallbackWorkspace.id,
145+
resourceName: fallbackWorkspace.name,
146+
description: `Auto-created replacement workspace "${fallbackWorkspace.name}" for a member left with no workspace after deleting "${workspaceRecord.name}"`,
147+
metadata: { deletedWorkspaceId: workspaceId, recipientUserId: userId },
148+
})
149+
}
130150
}
131151
provisionedWorkspaceUserIds = strandedUserIds
132152
}

0 commit comments

Comments
 (0)