Skip to content

Commit 056e33c

Browse files
committed
fix(folders): close parent-lock write race, allow combined unlock+move
Two more findings this round, both real: 1. Greptile (3x, same root cause): assertFolderMutable's ancestor-chain walk (getFolderLockStatus in the shared platform-authz engine) and the resource's-own-row check (getResourceLockStatus) both read with plain SELECTs, not SELECT ... FOR UPDATE. A concurrent lock toggle on a parent folder (or a resource's own row) could still commit between that read and the caller's write, even though the caller had already re-checked inside its own transaction. Added FOR UPDATE to both shared queries -- this is the single engine every lock check in the codebase goes through (reorder/restore/move/create/update/delete, all four resource types), so one fix closes it everywhere, including the two restore-path and one reorder-path call sites Greptile flagged individually. Reordering multiple folders in one batch can now row-lock several folders in a single transaction, so two concurrent reorder requests touching an overlapping folder set could deadlock if they lock in different orders. Sorted the id list before acquiring locks in performReorderFolders so concurrent batches always acquire in the same order. 2. Cursor: the table PATCH route's isLockOnlyUpdate gate was false whenever folderId also changed, so an admin combining locked: false with a move in one request still ran assertResourceMutable against the table's current (still-locked) state and was incorrectly rejected -- even though the request unlocks it as part of the same atomic write. Found and fixed the same bug class at three sibling call sites: renameTable (table), updateKnowledgeBase (knowledge base, folderId), performRenameWorkspaceFile (file, name), and the generic folders PUT route (any field). All four now skip the mutable pre-check specifically when this request also sets locked: false, not just when the update is lock-only. Added regression tests for the combined-unlock-plus-other-field-change case at all four sites. Full local verification passed (typecheck, full vitest suite 11430 tests, check:api-validation, lint all clean).
1 parent f2c305f commit 056e33c

10 files changed

Lines changed: 131 additions & 9 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type MockUser,
1111
permissionsMock,
1212
permissionsMockFns,
13+
resourceLockMockFns,
1314
workflowsOrchestrationMockFns,
1415
} from '@sim/testing'
1516
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -237,6 +238,29 @@ describe('Individual Folder API Route', () => {
237238
expect(mockPerformUpdateFolder).not.toHaveBeenCalled()
238239
})
239240

241+
it('skips the mutable check when unlocking a locked folder combined with a move in the same request', async () => {
242+
// Regression test: hasNonLockUpdate is true whenever parentId also changes, so
243+
// a combined "unlock + move" request previously still ran
244+
// policy.assertMutable(id) against the folder's current (still-locked) state
245+
// and was incorrectly rejected, even though the request unlocks it as part of
246+
// this same atomic write.
247+
mockAuthenticatedUser()
248+
resourceLockMockFns.mockAssertFolderMutable.mockReset()
249+
resourceLockMockFns.mockAssertFolderMutable.mockResolvedValue(undefined)
250+
251+
const req = createMockRequest('PUT', { locked: false, parentId: 'folder-2' })
252+
const params = Promise.resolve({ id: 'folder-1' })
253+
254+
const response = await PUT(req, { params })
255+
256+
expect(response.status).toBe(200)
257+
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledTimes(1)
258+
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith(
259+
'folder-2',
260+
'workflow'
261+
)
262+
})
263+
240264
it('should update parent folder successfully', async () => {
241265
mockAuthenticatedUser()
242266

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ export const PUT = withRouteHandler(
7979
)
8080
}
8181

82+
// An admin combining `locked: false` with other field changes (e.g. a move) in
83+
// one request is unlocking the folder as part of this same atomic write -- the
84+
// mutable-check must not block that. Skip only when this request isn't also
85+
// unlocking.
8286
const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked')
83-
if (hasNonLockUpdate) {
87+
if (hasNonLockUpdate && locked !== false) {
8488
await policy.assertMutable(id)
8589
}
8690
if (parentId !== undefined) {

apps/sim/lib/folders/orchestration.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -874,11 +874,16 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
874874
// parent in the window between that check and this transaction. Re-check
875875
// both inside the transaction (joining `tx` so the read is part of the
876876
// same atomic unit as the writes below) before applying anything.
877-
for (const update of validUpdates) {
878-
await assertFolderMutable(update.id, resourceType, tx)
879-
}
880-
for (const parentId of targetParentIds) {
881-
await assertFolderMutable(parentId, resourceType, tx)
877+
//
878+
// assertFolderMutable now row-locks (FOR UPDATE) every folder it walks, so two
879+
// concurrent reorder batches touching an overlapping set of folders must acquire
880+
// those locks in the same order or they can deadlock. Sorting both id lists into
881+
// a single deterministic order before locking guarantees that.
882+
const lockOrder = Array.from(
883+
new Set([...validUpdates.map((u) => u.id), ...targetParentIds])
884+
).sort()
885+
for (const id of lockOrder) {
886+
await assertFolderMutable(id, resourceType, tx)
882887
}
883888

884889
for (const update of validUpdates) {

apps/sim/lib/knowledge/service.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,29 @@ describe('updateKnowledgeBase — resource-lock enforcement', () => {
305305
expect(mockAssertResourceMutable).not.toHaveBeenCalled()
306306
})
307307

308+
it('skips the lock check when unlocking a locked knowledge base combined with a move in the same request', async () => {
309+
// Regression test: hasNonLockUpdate is true whenever folderId also changes, so a
310+
// combined "unlock + move" request previously still ran assertResourceMutable
311+
// against the KB's current (still-locked) state and was incorrectly rejected,
312+
// even though the request unlocks it as part of this same atomic write.
313+
dbChainMockFns.limit
314+
.mockResolvedValueOnce([{ workspaceId: 'ws-current', userId: 'u-1' }]) // currentKb (FOR UPDATE)
315+
.mockResolvedValueOnce([
316+
{ workspaceId: 'ws-current', resourceType: 'knowledge_base', deletedAt: null },
317+
]) // assertFolderParentValid's parent lookup
318+
319+
await updateKnowledgeBase('kb-1', { folderId: 'folder-1', locked: false }, 'req-1').catch(
320+
() => undefined
321+
)
322+
323+
expect(mockAssertResourceMutable).not.toHaveBeenCalled()
324+
expect(mockAssertFolderMutable).toHaveBeenCalledWith(
325+
'folder-1',
326+
'knowledge_base',
327+
expect.anything()
328+
)
329+
})
330+
308331
it('rejects moving the knowledge base into a locked destination folder with a 423', async () => {
309332
// Regression test: assertResourceMutable only checks the KB's *current* folder
310333
// chain -- without a separate assertFolderMutable(updates.folderId, ...) check,

apps/sim/lib/knowledge/service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,11 @@ export async function updateKnowledgeBase(
313313
updates.workspaceId !== undefined ||
314314
updates.folderId !== undefined ||
315315
updates.chunkingConfig !== undefined
316-
if (hasNonLockUpdate) {
316+
// An admin combining `locked: false` with other field changes in one request is
317+
// unlocking the knowledge base as part of this same atomic write -- the
318+
// mutable-check must not block that. Skip only when this request isn't also
319+
// unlocking.
320+
if (hasNonLockUpdate && updates.locked !== false) {
317321
await assertResourceMutable('knowledge_base', knowledgeBaseId)
318322
}
319323

apps/sim/lib/table/__tests__/service.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,31 @@ describe('table service — resource-lock enforcement', () => {
9393
expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled()
9494
})
9595

96+
it('skips the lock check when unlocking a locked table combined with a move in the same request', async () => {
97+
// Regression test: isLockOnlyUpdate is false whenever folderId also changes, so a
98+
// combined "unlock + move" request previously still ran assertResourceMutable
99+
// against the table's current (still-locked) state and was incorrectly rejected,
100+
// even though the request unlocks it as part of this same atomic write.
101+
dbChainMockFns.limit
102+
.mockResolvedValueOnce([{ workspaceId: 'ws-1' }]) // tableRow lookup
103+
.mockResolvedValueOnce([{ workspaceId: 'ws-1', resourceType: 'table', deletedAt: null }]) // assertFolderParentValid's parent lookup
104+
dbChainMockFns.returning.mockResolvedValueOnce([
105+
{
106+
id: 'tbl-1',
107+
createdBy: 'user-1',
108+
workspaceId: 'ws-1',
109+
folderId: 'folder-1',
110+
locked: false,
111+
},
112+
])
113+
114+
await renameTable('tbl-1', 'new_name', 'req-1', undefined, 'folder-1', false, false)
115+
116+
expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled()
117+
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-1', 'table')
118+
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
119+
})
120+
96121
it('rejects moving the table into a locked destination folder with a 423', async () => {
97122
// Regression test: assertResourceMutable only checks the table's *current*
98123
// folder chain -- without a separate assertFolderMutable(folderId, ...) check,

apps/sim/lib/table/service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,11 @@ export async function renameTable(
575575
throw new Error(nameValidation.errors.join(', '))
576576
}
577577

578-
if (!isLockOnlyUpdate) {
578+
// `isLockOnlyUpdate` is false whenever name/folderId also change, but an admin
579+
// combining `locked: false` with those changes in one request is unlocking the
580+
// table as part of this same atomic write -- the mutable-check must not block
581+
// that. Skip only when this request isn't also unlocking.
582+
if (!isLockOnlyUpdate && locked !== false) {
579583
await assertResourceMutable('table', tableId)
580584
}
581585

apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,26 @@ describe('workspace-files orchestration — resource-lock enforcement', () => {
127127

128128
expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled()
129129
})
130+
131+
it('skips the lock check when unlocking a locked file combined with a rename in the same request', async () => {
132+
// Regression test: isLockOnlyUpdate is false whenever the name also changes, so
133+
// a combined "unlock + rename" request previously still ran
134+
// assertResourceMutable against the file's current (still-locked) state and was
135+
// incorrectly rejected, even though the request unlocks it as part of this same
136+
// atomic write.
137+
mockRenameWorkspaceFile.mockResolvedValueOnce({ id: 'file-1', name: 'renamed.txt' })
138+
139+
await performRenameWorkspaceFile({
140+
workspaceId: 'ws-1',
141+
fileId: 'file-1',
142+
name: 'renamed.txt',
143+
userId: 'user-1',
144+
locked: false,
145+
isLockOnlyUpdate: false,
146+
})
147+
148+
expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled()
149+
})
130150
})
131151

132152
describe('performDeleteWorkspaceFileItems', () => {

apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,10 @@ export async function performRenameWorkspaceFile(
313313
const { workspaceId, fileId, name, userId, locked, isLockOnlyUpdate } = params
314314

315315
try {
316-
if (!isLockOnlyUpdate) {
316+
// An admin combining `locked: false` with a rename in one request is unlocking
317+
// the file as part of this same atomic write -- the mutable-check must not
318+
// block that. Skip only when this request isn't also unlocking.
319+
if (!isLockOnlyUpdate && locked !== false) {
317320
await assertResourceMutable('file', fileId)
318321
}
319322

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ export async function getFolderLockStatus(
117117

118118
while (currentFolderId && !visited.has(currentFolderId)) {
119119
visited.add(currentFolderId)
120+
// `FOR UPDATE` row-locks each ancestor for the rest of the caller's transaction --
121+
// without it, a lock toggled on this folder after this read but before the
122+
// caller's write commits could still be silently bypassed. Callers that pass the
123+
// default `db` client (a pre-check outside any transaction) get a single-statement
124+
// implicit transaction, so the lock is released immediately and this is a no-op;
125+
// callers that pass `tx` hold it until their transaction resolves.
120126
const [folderRow] = await dbClient
121127
.select({
122128
id: folder.id,
@@ -131,6 +137,7 @@ export async function getFolderLockStatus(
131137
isNull(folder.deletedAt)
132138
)
133139
)
140+
.for('update')
134141
.limit(1)
135142

136143
if (!folderRow) break
@@ -162,13 +169,16 @@ export async function getResourceLockStatus(
162169
): Promise<LockStatus> {
163170
const config = RESOURCE_LOCK_LOOKUP[resourceType]
164171

172+
// See the `FOR UPDATE` comment in `getFolderLockStatus` above -- same reasoning
173+
// applies to the resource's own row.
165174
const [row] = await dbClient
166175
.select({
167176
locked: config.lockedColumn,
168177
folderId: config.folderIdColumn,
169178
})
170179
.from(config.table)
171180
.where(eq(config.idColumn, resourceId))
181+
.for('update')
172182
.limit(1)
173183

174184
if (!row) return UNLOCKED_STATUS

0 commit comments

Comments
 (0)