Skip to content

Commit 8ac1ab6

Browse files
committed
fix(folders): fix source-lock bypass in unlock skip, add in-tx recheck to updates
Two more findings this round: 1. Greptile P1 security: the "combined unlock + other field change" fix from an earlier round skipped the mutable pre-check entirely whenever the request set locked: false, but that check also covers a lock INHERITED from an ancestor folder -- clearing this object's own locked flag has no effect on that. An admin could unlock a folder while also moving it out from under a still-locked ancestor, since the ancestor-chain check was skipped along with the direct one. Replaced the "skip if unlocking" pattern with two new shared primitives, assertFolderMutableUnlessUnlocking / assertResourceMutableUnlessUnlocking (packages/platform-authz/src/resource-lock.ts): they still run the full mutable check, and only swallow a DIRECT (non-inherited) lock error when the request is unlocking in the same write. An inherited lock still blocks. Applied at all 5 call sites that had the old broad skip: the generic folders PUT route, renameTable, updateKnowledgeBase, performRenameWorkspaceFile, and the new in-transaction recheck added below. 2. Cursor: generic folder updates for knowledge_base/table (performUpdateFolder's plain branch) and file-folder updates (updateWorkspaceFileFolder) applied their UPDATE from a single statement after only the route's pre-transaction assertMutable call -- unlike reorder/restore, there was no in-transaction lock recheck, so a folder or ancestor locked in the window between that pre-check and the write could still be mutated. Wrapped the kb/table plain update path in a transaction with an in-tx recheck (joining `tx` so it's part of the same atomic unit as the write); added the equivalent recheck to updateWorkspaceFileFolder's existing transaction, plus a target-parent lock check that was previously entirely missing there (existence was checked, lock state was not). Updated the 4 existing "combined unlock" tests to reflect the corrected behavior (the check still runs; a direct lock is satisfied by unlocking, an inherited lock is not) and added a companion "still rejects when inherited" test at each of those 4 sites. Updated the shared @sim/testing resource-lock mock and knowledge/service.test.ts's local mock to implement the new wrappers' real catch logic rather than a bare passthrough, so tests that configure a rejection see the same behavior as production. Full local verification passed (typecheck, full vitest suite 11436 tests, check:api-validation, lint all clean).
1 parent 411570a commit 8ac1ab6

12 files changed

Lines changed: 335 additions & 61 deletions

File tree

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*
44
* @vitest-environment node
55
*/
6+
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
67
import {
78
auditMock,
89
authMockFns,
@@ -238,29 +239,55 @@ describe('Individual Folder API Route', () => {
238239
expect(mockPerformUpdateFolder).not.toHaveBeenCalled()
239240
})
240241

241-
it('skips the mutable check when unlocking a locked folder combined with a move in the same request', async () => {
242+
it('allows unlocking a directly-locked folder combined with a move in the same request', async () => {
242243
// Regression test: hasNonLockUpdate is true whenever parentId also changes, so
243244
// a combined "unlock + move" request previously still ran
244245
// policy.assertMutable(id) against the folder's current (still-locked) state
245246
// and was incorrectly rejected, even though the request unlocks it as part of
246-
// this same atomic write.
247+
// this same atomic write. The fixed behavior still runs the check (so an
248+
// inherited lock is caught below), but treats a DIRECT lock as satisfied
249+
// since this request clears it.
247250
mockAuthenticatedUser()
248251
resourceLockMockFns.mockAssertFolderMutable.mockReset()
249252
resourceLockMockFns.mockAssertFolderMutable.mockResolvedValue(undefined)
253+
resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce(
254+
new ResourceLockedError('workflow', false, 'Folder is locked')
255+
)
250256

251257
const req = createMockRequest('PUT', { locked: false, parentId: 'folder-2' })
252258
const params = Promise.resolve({ id: 'folder-1' })
253259

254260
const response = await PUT(req, { params })
255261

256262
expect(response.status).toBe(200)
257-
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledTimes(1)
263+
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledTimes(2)
264+
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith(
265+
'folder-1',
266+
'workflow'
267+
)
258268
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith(
259269
'folder-2',
260270
'workflow'
261271
)
262272
})
263273

274+
it('still rejects unlocking a folder combined with a move when the lock is inherited from an ancestor', async () => {
275+
// Clearing the folder's own `locked` flag doesn't affect a lock inherited from
276+
// an ancestor -- that must still block the combined request.
277+
mockAuthenticatedUser()
278+
resourceLockMockFns.mockAssertFolderMutable.mockReset()
279+
resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce(
280+
new ResourceLockedError('workflow', true, 'Folder is locked by an ancestor folder')
281+
)
282+
283+
const req = createMockRequest('PUT', { locked: false, parentId: 'folder-2' })
284+
const params = Promise.resolve({ id: 'folder-1' })
285+
286+
const response = await PUT(req, { params })
287+
288+
expect(response.status).toBe(423)
289+
})
290+
264291
it('should update parent folder successfully', async () => {
265292
mockAuthenticatedUser()
266293

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { db } from '@sim/db'
22
import { folder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
4+
import {
5+
assertFolderMutableUnlessUnlocking,
6+
ResourceLockedError,
7+
} from '@sim/platform-authz/resource-lock'
58
import { and, eq, isNull } from 'drizzle-orm'
69
import { type NextRequest, NextResponse } from 'next/server'
710
import { updateFolderContract } from '@/lib/api/contracts'
@@ -81,11 +84,12 @@ export const PUT = withRouteHandler(
8184

8285
// An admin combining `locked: false` with other field changes (e.g. a move) in
8386
// 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.
87+
// mutable-check must not treat that request's own current (about-to-be-cleared)
88+
// lock as blocking. It must still enforce a lock INHERITED from an ancestor,
89+
// since clearing this folder's own `locked` flag doesn't affect that.
8690
const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked')
87-
if (hasNonLockUpdate && locked !== false) {
88-
await policy.assertMutable(id)
91+
if (hasNonLockUpdate) {
92+
await assertFolderMutableUnlessUnlocking(id, existingFolder.resourceType, locked === false)
8993
}
9094
if (parentId !== undefined) {
9195
await policy.assertMutable(parentId)

apps/sim/lib/folders/orchestration.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { folder as folderTable, knowledgeBase, userTableDefinitions } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5-
import { assertFolderMutable, ResourceLockedError } from '@sim/platform-authz/resource-lock'
5+
import {
6+
assertFolderMutable,
7+
assertFolderMutableUnlessUnlocking,
8+
ResourceLockedError,
9+
} from '@sim/platform-authz/resource-lock'
610
import { getPostgresErrorCode, toError } from '@sim/utils/errors'
711
import { generateId } from '@sim/utils/id'
812
import { and, eq, inArray, isNull } from 'drizzle-orm'
@@ -753,18 +757,47 @@ export async function performUpdateFolder(
753757
if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder
754758
if (params.locked !== undefined) updates.locked = params.locked
755759

756-
const [updated] = await db
757-
.update(folderTable)
758-
.set(updates)
759-
.where(
760-
and(
761-
eq(folderTable.id, params.folderId),
762-
eq(folderTable.workspaceId, params.workspaceId),
763-
eq(folderTable.resourceType, params.resourceType),
764-
isNull(folderTable.deletedAt)
760+
const isLockOnlyUpdate =
761+
params.name === undefined && params.parentId === undefined && params.sortOrder === undefined
762+
763+
const updated = await db.transaction(async (tx) => {
764+
// The route checks lock state before calling this function, but that's a
765+
// separate round-trip -- an admin could lock this folder (or its target
766+
// parent) in the window between that check and this transaction. Re-check
767+
// inside the transaction (joining `tx` so the read is part of the same
768+
// atomic unit as the write below) before applying anything.
769+
//
770+
// `assertFolderMutableUnlessUnlocking` only treats this folder's own
771+
// (about-to-be-cleared) lock as satisfied when this same request unlocks it --
772+
// a lock inherited from an ancestor still blocks. Skipped entirely for a
773+
// lock-only update, matching the file/kb/table lock-toggle precedent.
774+
if (!isLockOnlyUpdate) {
775+
await assertFolderMutableUnlessUnlocking(
776+
params.folderId,
777+
params.resourceType,
778+
params.locked === false,
779+
tx
765780
)
766-
)
767-
.returning()
781+
}
782+
if (params.parentId) {
783+
// A folder in an unlocked location could otherwise be moved into a locked one.
784+
await assertFolderMutable(params.parentId, params.resourceType, tx)
785+
}
786+
787+
const [row] = await tx
788+
.update(folderTable)
789+
.set(updates)
790+
.where(
791+
and(
792+
eq(folderTable.id, params.folderId),
793+
eq(folderTable.workspaceId, params.workspaceId),
794+
eq(folderTable.resourceType, params.resourceType),
795+
isNull(folderTable.deletedAt)
796+
)
797+
)
798+
.returning()
799+
return row
800+
})
768801

769802
if (!updated) return { success: false, error: 'Folder not found', errorCode: 'not_found' }
770803
return { success: true, folder: updated }

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

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,52 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1212

1313
/** Minimal stand-in for `@sim/platform-authz/resource-lock`'s `ResourceLockedError`
1414
* (423, carries `resourceType`/`inherited`) — avoids `vi.importActual`. */
15-
const { mockAssertResourceMutable, mockAssertFolderMutable, MockResourceLockedError } = vi.hoisted(
16-
() => {
17-
class ResourceLockedErrorStub extends Error {
18-
readonly status = 423
19-
readonly resourceType: string
20-
readonly inherited: boolean
21-
constructor(resourceType: string, inherited: boolean, message?: string) {
22-
super(message ?? `${resourceType} is locked`)
23-
this.name = 'ResourceLockedError'
24-
this.resourceType = resourceType
25-
this.inherited = inherited
26-
}
15+
const {
16+
mockAssertResourceMutable,
17+
mockAssertFolderMutable,
18+
mockAssertResourceMutableUnlessUnlocking,
19+
MockResourceLockedError,
20+
} = vi.hoisted(() => {
21+
class ResourceLockedErrorStub extends Error {
22+
readonly status = 423
23+
readonly resourceType: string
24+
readonly inherited: boolean
25+
constructor(resourceType: string, inherited: boolean, message?: string) {
26+
super(message ?? `${resourceType} is locked`)
27+
this.name = 'ResourceLockedError'
28+
this.resourceType = resourceType
29+
this.inherited = inherited
2730
}
28-
return {
29-
mockAssertResourceMutable: vi.fn(),
30-
mockAssertFolderMutable: vi.fn(),
31-
MockResourceLockedError: ResourceLockedErrorStub,
31+
}
32+
const assertResourceMutable = vi.fn()
33+
// Real wrapper logic (not a bare passthrough) so tests that configure
34+
// assertResourceMutable to reject with a direct vs. inherited error see the
35+
// same "unless unlocking" behavior the production wrapper implements.
36+
const assertResourceMutableUnlessUnlocking = vi.fn(
37+
async (resourceType: string, resourceId: string, unlocking: boolean, tx?: unknown) => {
38+
try {
39+
const args = [resourceType, resourceId, tx].filter((a) => a !== undefined)
40+
await assertResourceMutable(...args)
41+
} catch (error) {
42+
if (unlocking && error instanceof ResourceLockedErrorStub && !error.inherited) return
43+
throw error
44+
}
3245
}
46+
)
47+
return {
48+
mockAssertResourceMutable: assertResourceMutable,
49+
mockAssertFolderMutable: vi.fn(),
50+
mockAssertResourceMutableUnlessUnlocking: assertResourceMutableUnlessUnlocking,
51+
MockResourceLockedError: ResourceLockedErrorStub,
3352
}
34-
)
53+
})
3554

3655
vi.mock('@sim/db', () => dbChainMock)
3756
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
3857
vi.mock('@sim/platform-authz/resource-lock', () => ({
3958
assertResourceMutable: mockAssertResourceMutable,
4059
assertFolderMutable: mockAssertFolderMutable,
60+
assertResourceMutableUnlessUnlocking: mockAssertResourceMutableUnlessUnlocking,
4161
ResourceLockedError: MockResourceLockedError,
4262
}))
4363

@@ -305,29 +325,50 @@ describe('updateKnowledgeBase — resource-lock enforcement', () => {
305325
expect(mockAssertResourceMutable).not.toHaveBeenCalled()
306326
})
307327

308-
it('skips the lock check when unlocking a locked knowledge base combined with a move in the same request', async () => {
328+
it('allows unlocking a directly-locked knowledge base combined with a move in the same request', async () => {
309329
// Regression test: hasNonLockUpdate is true whenever folderId also changes, so a
310330
// combined "unlock + move" request previously still ran assertResourceMutable
311331
// 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.
332+
// even though the request unlocks it as part of this same atomic write. The
333+
// fixed behavior still runs the check (so an inherited lock is caught below),
334+
// but treats a DIRECT lock as satisfied since this request clears it.
313335
dbChainMockFns.limit
314336
.mockResolvedValueOnce([{ workspaceId: 'ws-current', userId: 'u-1' }]) // currentKb (FOR UPDATE)
315337
.mockResolvedValueOnce([
316338
{ workspaceId: 'ws-current', resourceType: 'knowledge_base', deletedAt: null },
317339
]) // assertFolderParentValid's parent lookup
340+
mockAssertResourceMutable.mockRejectedValueOnce(
341+
new MockResourceLockedError('knowledge_base', false, 'Knowledge base is locked')
342+
)
318343

319344
await updateKnowledgeBase('kb-1', { folderId: 'folder-1', locked: false }, 'req-1').catch(
320345
() => undefined
321346
)
322347

323-
expect(mockAssertResourceMutable).not.toHaveBeenCalled()
348+
expect(mockAssertResourceMutable).toHaveBeenCalledWith('knowledge_base', 'kb-1')
324349
expect(mockAssertFolderMutable).toHaveBeenCalledWith(
325350
'folder-1',
326351
'knowledge_base',
327352
expect.anything()
328353
)
329354
})
330355

356+
it('still rejects unlocking a knowledge base combined with a move when the lock is inherited from its folder', async () => {
357+
// Clearing the KB's own `locked` flag doesn't affect a lock inherited from its
358+
// containing folder -- that must still block the combined request.
359+
mockAssertResourceMutable.mockRejectedValueOnce(
360+
new MockResourceLockedError(
361+
'knowledge_base',
362+
true,
363+
'Knowledge base is locked by its containing folder'
364+
)
365+
)
366+
367+
await expect(
368+
updateKnowledgeBase('kb-1', { folderId: 'folder-1', locked: false }, 'req-1')
369+
).rejects.toMatchObject({ status: 423, inherited: true })
370+
})
371+
331372
it('rejects moving the knowledge base into a locked destination folder with a 423', async () => {
332373
// Regression test: assertResourceMutable only checks the KB's *current* folder
333374
// chain -- without a separate assertFolderMutable(updates.folderId, ...) check,

apps/sim/lib/knowledge/service.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
workspaceFiles,
99
} from '@sim/db/schema'
1010
import { createLogger } from '@sim/logger'
11-
import { assertFolderMutable, assertResourceMutable } from '@sim/platform-authz/resource-lock'
11+
import {
12+
assertFolderMutable,
13+
assertResourceMutable,
14+
assertResourceMutableUnlessUnlocking,
15+
} from '@sim/platform-authz/resource-lock'
1216
import { getPostgresErrorCode } from '@sim/utils/errors'
1317
import { generateId } from '@sim/utils/id'
1418
import { and, count, eq, exists, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm'
@@ -315,10 +319,16 @@ export async function updateKnowledgeBase(
315319
updates.chunkingConfig !== undefined
316320
// An admin combining `locked: false` with other field changes in one request is
317321
// 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) {
321-
await assertResourceMutable('knowledge_base', knowledgeBaseId)
322+
// mutable-check must not treat that request's own current (about-to-be-cleared)
323+
// lock as blocking. It must still enforce a lock inherited from the KB's
324+
// containing folder, since clearing the KB's own `locked` flag doesn't affect
325+
// that.
326+
if (hasNonLockUpdate) {
327+
await assertResourceMutableUnlessUnlocking(
328+
'knowledge_base',
329+
knowledgeBaseId,
330+
updates.locked === false
331+
)
322332
}
323333

324334
if (updates.workspaceId !== undefined && !options?.actorUserId) {

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,13 @@ 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 () => {
96+
it('allows unlocking a directly-locked table combined with a move in the same request', async () => {
9797
// Regression test: isLockOnlyUpdate is false whenever folderId also changes, so a
9898
// combined "unlock + move" request previously still ran assertResourceMutable
9999
// 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.
100+
// even though the request unlocks it as part of this same atomic write. The
101+
// fixed behavior still runs the check (so an inherited lock is caught below),
102+
// but treats a DIRECT lock as satisfied since this request clears it.
101103
dbChainMockFns.limit
102104
.mockResolvedValueOnce([{ workspaceId: 'ws-1' }]) // tableRow lookup
103105
.mockResolvedValueOnce([{ workspaceId: 'ws-1', resourceType: 'table', deletedAt: null }]) // assertFolderParentValid's parent lookup
@@ -110,14 +112,31 @@ describe('table service — resource-lock enforcement', () => {
110112
locked: false,
111113
},
112114
])
115+
resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce(
116+
new ResourceLockedError('table', false, 'Table is locked')
117+
)
113118

114119
await renameTable('tbl-1', 'new_name', 'req-1', undefined, 'folder-1', false, false)
115120

116-
expect(resourceLockMockFns.mockAssertResourceMutable).not.toHaveBeenCalled()
121+
expect(resourceLockMockFns.mockAssertResourceMutable).toHaveBeenCalledWith('table', 'tbl-1')
117122
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-1', 'table')
118123
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
119124
})
120125

126+
it('still rejects unlocking a table combined with a move when the lock is inherited from its folder', async () => {
127+
// Clearing the table's own `locked` flag doesn't affect a lock inherited from
128+
// its containing folder -- that must still block the combined request.
129+
resourceLockMockFns.mockAssertResourceMutable.mockRejectedValueOnce(
130+
new ResourceLockedError('table', true, 'Table is locked by its containing folder')
131+
)
132+
133+
await expect(
134+
renameTable('tbl-1', 'new_name', 'req-1', undefined, 'folder-1', false, false)
135+
).rejects.toMatchObject({ status: 423, inherited: true })
136+
137+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
138+
})
139+
121140
it('rejects moving the table into a locked destination folder with a 423', async () => {
122141
// Regression test: assertResourceMutable only checks the table's *current*
123142
// folder chain -- without a separate assertFolderMutable(folderId, ...) check,

0 commit comments

Comments
 (0)