Skip to content

Commit e7bd278

Browse files
committed
fix(folders): close reorder lock TOCTOU race and archived-folder PUT gap
Two more real findings, both confirmed against actual code: - The reorder route checks lock state before calling performReorderFolders, but that check and the transactional write are separate round-trips -- an admin locking a source folder or target parent in that window wasn't re-checked before the write applied. Extended the shared lock engine (getFolderLockStatus/assertFolderMutable in @sim/platform-authz/resource-lock) with an optional dbClient parameter (mirrors assertFolderParentValid's existing DbOrTx pattern) so the reorder transaction can re-check every source folder and target parent using the same `tx` the writes run in, rather than reimplementing the lock-walk ad hoc. - PUT/DELETE /api/folders/[id] loaded the target folder by id only, and performUpdateFolder's workflow and knowledge_base/table branches updated rows without requiring deletedAt to be null -- an archived folder could still be renamed, reparented, or relocked. The file-folder and reorder paths already filtered this correctly; added the same isNull(deletedAt) filter to the route's existingFolder lookup and both performUpdateFolder branches' UPDATE clauses. Added regression tests for both.
1 parent 0fed07b commit e7bd278

6 files changed

Lines changed: 116 additions & 11 deletions

File tree

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,36 @@ describe('Individual Folder API Route', () => {
207207
})
208208
})
209209

210+
it('returns 404 for an archived (soft-deleted) folder rather than updating it', async () => {
211+
// Regression test: the existingFolder lookup previously had no deletedAt
212+
// filter, so a soft-deleted folder could still be renamed/reparented/
213+
// relocked via this route even though it no longer appears anywhere in
214+
// the UI. Simulates the lookup finding nothing (as it now would for an
215+
// archived row, since the route filters isNull(deletedAt)).
216+
mockAuthenticatedUser()
217+
mockDbRef.current = {
218+
select: vi.fn().mockReturnValue({
219+
from: vi.fn().mockReturnValue({
220+
where: vi.fn().mockReturnValue({
221+
then: vi.fn().mockImplementation((callback) => Promise.resolve(callback([]))),
222+
}),
223+
}),
224+
}),
225+
update: vi.fn(),
226+
delete: vi.fn(),
227+
}
228+
229+
const req = createMockRequest('PUT', { name: 'Should not apply' })
230+
const params = Promise.resolve({ id: 'folder-1' })
231+
232+
const response = await PUT(req, { params })
233+
234+
expect(response.status).toBe(404)
235+
const data = await response.json()
236+
expect(data.error).toBe('Folder not found')
237+
expect(mockPerformUpdateFolder).not.toHaveBeenCalled()
238+
})
239+
210240
it('should update parent folder successfully', async () => {
211241
mockAuthenticatedUser()
212242

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
22
import { folder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
5-
import { eq } from 'drizzle-orm'
5+
import { and, eq, isNull } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { updateFolderContract } from '@/lib/api/contracts'
88
import { parseRequest } from '@/lib/api/server'
@@ -43,7 +43,7 @@ export const PUT = withRouteHandler(
4343
const existingFolder = await db
4444
.select()
4545
.from(folder)
46-
.where(eq(folder.id, id))
46+
.where(and(eq(folder.id, id), isNull(folder.deletedAt)))
4747
.then((rows) => rows[0])
4848

4949
if (!existingFolder) {
@@ -130,7 +130,7 @@ export const DELETE = withRouteHandler(
130130
const existingFolder = await db
131131
.select()
132132
.from(folder)
133-
.where(eq(folder.id, id))
133+
.where(and(eq(folder.id, id), isNull(folder.deletedAt)))
134134
.then((rows) => rows[0])
135135

136136
if (!existingFolder) {

apps/sim/app/api/folders/reorder/route.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@
33
*
44
* @vitest-environment node
55
*/
6-
import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing'
6+
7+
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
8+
import {
9+
authMockFns,
10+
createMockRequest,
11+
permissionsMock,
12+
permissionsMockFns,
13+
resourceLockMockFns,
14+
} from '@sim/testing'
715
import { drizzleOrmMock } from '@sim/testing/mocks'
816
import { beforeEach, describe, expect, it, vi } from 'vitest'
917

@@ -43,6 +51,8 @@ describe('PUT /api/folders/reorder', () => {
4351

4452
beforeEach(() => {
4553
vi.clearAllMocks()
54+
resourceLockMockFns.mockAssertFolderMutable.mockReset()
55+
resourceLockMockFns.mockAssertFolderMutable.mockResolvedValue(undefined)
4656

4757
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
4858
mockGetUserEntityPermissions.mockResolvedValue('admin')
@@ -257,6 +267,39 @@ describe('PUT /api/folders/reorder', () => {
257267
expect(data.error).toBe('One or more folders were not found')
258268
})
259269

270+
it('returns a 423 when a folder is concurrently locked before the write', async () => {
271+
// Regression test: the route checks lock state before calling
272+
// performReorderFolders (both of the route's own checks -- for update.id and
273+
// update.parentId, since parentId is `null` not `undefined` -- succeed below,
274+
// simulating the folder being unlocked at that moment), but that's a separate
275+
// round-trip -- an admin could lock the folder in the window between that
276+
// check and the transaction. The third assertFolderMutable call is the new
277+
// in-transaction recheck this test targets.
278+
mockWhere
279+
.mockReturnValueOnce([
280+
{ id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' },
281+
])
282+
.mockReturnValueOnce([{ id: 'folder-1', parentId: null }])
283+
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
284+
285+
resourceLockMockFns.mockAssertFolderMutable
286+
.mockResolvedValueOnce(undefined)
287+
.mockResolvedValueOnce(undefined)
288+
.mockRejectedValueOnce(new ResourceLockedError('workflow', false, 'Folder is locked'))
289+
290+
const req = createMockRequest('PUT', {
291+
workspaceId: 'workspace-123',
292+
updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }],
293+
})
294+
295+
const response = await PUT(req)
296+
297+
expect(response.status).toBe(423)
298+
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledTimes(3)
299+
expect(mockDb.transaction).toHaveBeenCalledTimes(1)
300+
expect(mockTxUpdate).not.toHaveBeenCalled()
301+
})
302+
260303
it('rejects a batch that would form a cycle', async () => {
261304
mockWhere
262305
.mockReturnValueOnce([

apps/sim/lib/folders/orchestration.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,8 @@ export async function performUpdateFolder(
729729
and(
730730
eq(folderTable.id, params.folderId),
731731
eq(folderTable.workspaceId, params.workspaceId),
732-
eq(folderTable.resourceType, params.resourceType)
732+
eq(folderTable.resourceType, params.resourceType),
733+
isNull(folderTable.deletedAt)
733734
)
734735
)
735736
.returning()
@@ -838,6 +839,18 @@ export async function performReorderFolders(
838839
}
839840
}
840841

842+
// The route checks lock state before calling this function, but that's a
843+
// separate round-trip -- an admin could lock a source folder or a target
844+
// parent in the window between that check and this transaction. Re-check
845+
// both inside the transaction (joining `tx` so the read is part of the
846+
// same atomic unit as the writes below) before applying anything.
847+
for (const update of validUpdates) {
848+
await assertFolderMutable(update.id, resourceType, tx)
849+
}
850+
for (const parentId of targetParentIds) {
851+
await assertFolderMutable(parentId, resourceType, tx)
852+
}
853+
841854
for (const update of validUpdates) {
842855
const updateData: Record<string, unknown> = {
843856
sortOrder: update.sortOrder,
@@ -861,6 +874,10 @@ export async function performReorderFolders(
861874
}
862875
})
863876
} catch (error) {
877+
// Propagate uncaught so the route's ResourceLockedError -> 423 handling applies.
878+
if (error instanceof ResourceLockedError) {
879+
throw error
880+
}
864881
return { success: false, updated: 0, error: toError(error).message }
865882
}
866883

apps/sim/lib/workflows/orchestration/folder-lifecycle.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ export async function performUpdateFolder(
193193
and(
194194
eq(folder.id, params.folderId),
195195
eq(folder.workspaceId, params.workspaceId),
196-
isWorkflowFolder
196+
isWorkflowFolder,
197+
isNull(folder.deletedAt)
197198
)
198199
)
199200
.returning()

packages/platform-authz/src/resource-lock.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,23 @@ import {
77
workflow,
88
workspaceFiles,
99
} from '@sim/db'
10+
import type * as schema from '@sim/db/schema'
11+
import type { ExtractTablesWithRelations } from 'drizzle-orm'
1012
import { and, eq, isNull } from 'drizzle-orm'
11-
import type { PgColumn, PgTable } from 'drizzle-orm/pg-core'
13+
import type { PgColumn, PgTable, PgTransaction } from 'drizzle-orm/pg-core'
14+
import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js'
1215

1316
export type FolderResourceType = (typeof folderResourceTypeEnum.enumValues)[number]
1417

18+
/** Allows a caller to join its own transaction instead of the module-level `db`. */
19+
type DbOrTx =
20+
| typeof db
21+
| PgTransaction<
22+
PostgresJsQueryResultHKT,
23+
typeof schema,
24+
ExtractTablesWithRelations<typeof schema>
25+
>
26+
1527
export interface LockStatus {
1628
locked: boolean
1729
directLocked: boolean
@@ -94,7 +106,8 @@ const UNLOCKED_STATUS: LockStatus = {
94106
*/
95107
export async function getFolderLockStatus(
96108
folderId: string | null,
97-
resourceType: FolderResourceType
109+
resourceType: FolderResourceType,
110+
dbClient: DbOrTx = db
98111
): Promise<LockStatus> {
99112
if (!folderId) return UNLOCKED_STATUS
100113

@@ -104,7 +117,7 @@ export async function getFolderLockStatus(
104117

105118
while (currentFolderId && !visited.has(currentFolderId)) {
106119
visited.add(currentFolderId)
107-
const [folderRow] = await db
120+
const [folderRow] = await dbClient
108121
.select({
109122
id: folder.id,
110123
parentId: folder.parentId,
@@ -174,9 +187,10 @@ export async function getResourceLockStatus(
174187

175188
export async function assertFolderMutable(
176189
folderId: string | null,
177-
resourceType: FolderResourceType
190+
resourceType: FolderResourceType,
191+
dbClient: DbOrTx = db
178192
): Promise<void> {
179-
const status = await getFolderLockStatus(folderId, resourceType)
193+
const status = await getFolderLockStatus(folderId, resourceType, dbClient)
180194
if (status.locked) {
181195
throw new ResourceLockedError(
182196
resourceType,

0 commit comments

Comments
 (0)