Skip to content

Commit af9c8c6

Browse files
committed
feat(folders): generalize resource locking across workflows, files, knowledge bases, and tables
- New generic locking engine (packages/platform-authz/src/resource-lock.ts): one config-driven RESOURCE_LOCK_LOOKUP + ResourceLockedError, mirroring the PINNED_RESOURCE_LOOKUP/FolderCascadeConfig pattern already established for folders/pinning. packages/platform-authz/src/workflow.ts's lock exports become thin resourceType='workflow' wrappers over the generic engine -- WorkflowLockedError/FolderLockedError are now ResourceLockedError subclasses, so all ~30 existing call sites keep working unchanged - New locked column on workspaceFiles/knowledgeBase/userTableDefinitions (mirrors workflow.locked), migration 0259_resource_level_locking.sql - FOLDER_RESOURCE_POLICIES now enables real lock enforcement (not a no-op) for all four resourceTypes -- folder-level lock cascade, previously workflow-only, now applies uniformly - assertResourceMutable/assertFolderMutable wired into the structural mutation entry points for each type: file rename/delete/move/restore (including bulk folder-scoped operations), knowledge base update/delete, table rename/delete -- locked blocks all of these but never blocks reads (download/search/query), matching the workflow precedent - Lock/unlock UI: row-level toggle for files/knowledge bases/tables plus folder-level lock in their context menus, reusing the existing generic Lock/Unlock context-menu item - Route-level 423 test coverage added for every new lock-enforcement call site (direct lock and folder-inherited lock) Deep content-mutation lock enforcement (table row/column edits, knowledge base document/chunk/connector edits) is intentionally out of scope here -- a much larger surface (~35 additional routes) that assertResourceMutable is positioned to extend into as a follow-up, not silently dropped.
1 parent 7eb619b commit af9c8c6

52 files changed

Lines changed: 18839 additions & 202 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { folder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { FolderLockedError } from '@sim/platform-authz/workflow'
4+
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
55
import { eq } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { updateFolderContract } from '@/lib/api/contracts'
@@ -110,7 +110,7 @@ export const PUT = withRouteHandler(
110110

111111
return NextResponse.json({ folder: result.folder })
112112
} catch (error) {
113-
if (error instanceof FolderLockedError) {
113+
if (error instanceof ResourceLockedError) {
114114
return NextResponse.json({ error: error.message }, { status: error.status })
115115
}
116116

@@ -183,7 +183,7 @@ export const DELETE = withRouteHandler(
183183
deletedItems: result.deletedItems,
184184
})
185185
} catch (error) {
186-
if (error instanceof FolderLockedError) {
186+
if (error instanceof ResourceLockedError) {
187187
return NextResponse.json({ error: error.message }, { status: error.status })
188188
}
189189

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { folder } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { FolderLockedError } from '@sim/platform-authz/workflow'
4+
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
55
import { eq, inArray } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { reorderFoldersContract } from '@/lib/api/contracts'
@@ -135,7 +135,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
135135

136136
return NextResponse.json({ success: true, updated: result.updated })
137137
} catch (error) {
138-
if (error instanceof FolderLockedError) {
138+
if (error instanceof ResourceLockedError) {
139139
return NextResponse.json({ error: error.message }, { status: error.status })
140140
}
141141

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
createMockRequest,
1010
permissionsMock,
1111
permissionsMockFns,
12-
workflowAuthzMockFns,
12+
resourceLockMockFns,
1313
} from '@sim/testing'
1414
import { drizzleOrmMock } from '@sim/testing/mocks'
1515
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -392,9 +392,9 @@ describe('Folders API Route', () => {
392392
it('should reject creating a subfolder inside a locked parent folder', async () => {
393393
mockAuthenticatedUser()
394394

395-
const { FolderLockedError } = await import('@sim/platform-authz/workflow')
396-
workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce(
397-
new FolderLockedError('Folder is locked')
395+
const { ResourceLockedError } = await import('@sim/platform-authz/resource-lock')
396+
resourceLockMockFns.mockAssertFolderMutable.mockRejectedValueOnce(
397+
new ResourceLockedError('workflow', false, 'Folder is locked')
398398
)
399399

400400
const req = createMockRequest('POST', {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createLogger } from '@sim/logger'
2-
import { FolderLockedError } from '@sim/platform-authz/workflow'
2+
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { createFolderContract, listFoldersContract } from '@/lib/api/contracts'
55
import { parseRequest } from '@/lib/api/server'
@@ -116,7 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
116116

117117
return NextResponse.json({ folder: newFolder })
118118
} catch (error) {
119-
if (error instanceof FolderLockedError) {
119+
if (error instanceof ResourceLockedError) {
120120
return NextResponse.json({ error: error.message }, { status: error.status })
121121
}
122122
logger.error('Error creating folder:', { error })

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { createLogger } from '@sim/logger'
3+
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { updateKnowledgeBaseContract } from '@/lib/api/contracts/knowledge'
56
import { parseRequest } from '@/lib/api/server'
@@ -15,6 +16,7 @@ import {
1516
KnowledgeBaseValidationError,
1617
updateKnowledgeBase,
1718
} from '@/lib/knowledge/service'
19+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1820
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
1921

2022
const logger = createLogger('KnowledgeBaseByIdAPI')
@@ -95,6 +97,19 @@ export const PUT = withRouteHandler(
9597

9698
const validatedData = parsed.data.body
9799

100+
if (validatedData.locked !== undefined) {
101+
const workspaceId = accessCheck.knowledgeBase.workspaceId
102+
const workspacePermission = workspaceId
103+
? await getUserEntityPermissions(userId, 'workspace', workspaceId)
104+
: null
105+
if (workspacePermission !== 'admin') {
106+
return NextResponse.json(
107+
{ error: 'Admin access required to lock knowledge bases' },
108+
{ status: 403 }
109+
)
110+
}
111+
}
112+
98113
const updatedKnowledgeBase = await updateKnowledgeBase(
99114
id,
100115
{
@@ -103,6 +118,7 @@ export const PUT = withRouteHandler(
103118
workspaceId: validatedData.workspaceId,
104119
folderId: validatedData.folderId,
105120
chunkingConfig: validatedData.chunkingConfig,
121+
locked: validatedData.locked,
106122
},
107123
requestId,
108124
{ actorUserId: userId }
@@ -142,6 +158,9 @@ export const PUT = withRouteHandler(
142158
data: updatedKnowledgeBase,
143159
})
144160
} catch (error) {
161+
if (error instanceof ResourceLockedError) {
162+
return NextResponse.json({ error: error.message }, { status: error.status })
163+
}
145164
if (error instanceof KnowledgeBaseConflictError) {
146165
return NextResponse.json({ error: error.message }, { status: 409 })
147166
}
@@ -218,6 +237,9 @@ export const DELETE = withRouteHandler(
218237
data: { message: 'Knowledge base deleted successfully' },
219238
})
220239
} catch (error) {
240+
if (error instanceof ResourceLockedError) {
241+
return NextResponse.json({ error: error.message }, { status: error.status })
242+
}
221243
logger.error(`[${requestId}] Error deleting knowledge base`, error)
222244
return NextResponse.json({ error: 'Failed to delete knowledge base' }, { status: 500 })
223245
}

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
23
import { getErrorMessage } from '@sim/utils/errors'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { getTableQuerySchema, renameTableContract } from '@/lib/api/contracts/tables'
@@ -9,6 +10,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
910
import { captureServerEvent } from '@/lib/posthog/server'
1011
import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table'
1112
import { getWorkspaceTableLimits } from '@/lib/table/billing'
13+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1214
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
1315

1416
const logger = createLogger('TableDetailAPI')
@@ -65,6 +67,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
6567
metadata: table.metadata ?? null,
6668
rowCount: table.rowCount,
6769
maxRows: maxRowsPerTable,
70+
locked: table.locked,
6871
createdAt:
6972
table.createdAt instanceof Date
7073
? table.createdAt.toISOString()
@@ -125,19 +128,42 @@ export const PATCH = withRouteHandler(
125128
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
126129
}
127130

131+
if (validated.locked !== undefined && validated.locked !== table.locked) {
132+
const workspacePermission = await getUserEntityPermissions(
133+
authResult.userId,
134+
'workspace',
135+
table.workspaceId
136+
)
137+
if (workspacePermission !== 'admin') {
138+
return NextResponse.json(
139+
{ error: 'Admin access required to lock tables' },
140+
{ status: 403 }
141+
)
142+
}
143+
}
144+
145+
const isLockOnlyUpdate =
146+
validated.name === table.name &&
147+
(validated.folderId === undefined || validated.folderId === (table.folderId ?? null))
148+
128149
const updated = await renameTable(
129150
tableId,
130151
validated.name,
131152
requestId,
132153
authResult.userId,
133-
validated.folderId
154+
validated.folderId,
155+
validated.locked,
156+
isLockOnlyUpdate
134157
)
135158

136159
return NextResponse.json({
137160
success: true,
138161
data: { table: updated },
139162
})
140163
} catch (error) {
164+
if (error instanceof ResourceLockedError) {
165+
return NextResponse.json({ error: error.message }, { status: error.status })
166+
}
141167
if (error instanceof TableConflictError) {
142168
return NextResponse.json({ error: error.message }, { status: 409 })
143169
}
@@ -200,6 +226,9 @@ export const DELETE = withRouteHandler(
200226
if (isZodError(error)) {
201227
return validationErrorResponse(error)
202228
}
229+
if (error instanceof ResourceLockedError) {
230+
return NextResponse.json({ error: error.message }, { status: error.status })
231+
}
203232

204233
logger.error(`[${requestId}] Error deleting table:`, error)
205234
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })

apps/sim/app/api/table/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
210210
maxRows: t.maxRows,
211211
workspaceId: t.workspaceId,
212212
folderId: t.folderId ?? null,
213+
locked: t.locked,
213214
createdBy: t.createdBy,
214215
createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
215216
updatedAt: t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { getValidationErrorMessage } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9-
import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration'
9+
import {
10+
performRestoreWorkspaceFile,
11+
workspaceFilesOrchestrationStatus,
12+
} from '@/lib/workspace-files/orchestration'
1013
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1114

1215
const logger = createLogger('RestoreWorkspaceFileAPI')
@@ -46,7 +49,7 @@ export const POST = withRouteHandler(
4649
if (!result.success) {
4750
return NextResponse.json(
4851
{ error: result.error },
49-
{ status: result.errorCode === 'conflict' ? 409 : 500 }
52+
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
5053
)
5154
}
5255

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import { getSession } from '@/lib/auth'
1010
import { generateRequestId } from '@/lib/core/utils/request'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { captureServerEvent } from '@/lib/posthog/server'
13+
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
1314
import {
1415
performDeleteWorkspaceFileItems,
1516
performRenameWorkspaceFile,
17+
workspaceFilesOrchestrationStatus,
1618
} from '@/lib/workspace-files/orchestration'
1719
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1820

@@ -37,7 +39,7 @@ export const PATCH = withRouteHandler(
3739
const parsed = await parseRequest(renameWorkspaceFileContract, request, context)
3840
if (!parsed.success) return parsed.response
3941
const { id: workspaceId, fileId } = parsed.data.params
40-
const { name } = parsed.data.body
42+
const { name, locked } = parsed.data.body
4143

4244
const userPermission = await getUserEntityPermissions(
4345
session.user.id,
@@ -51,16 +53,32 @@ export const PATCH = withRouteHandler(
5153
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
5254
}
5355

56+
const existingFile = await getWorkspaceFile(workspaceId, fileId)
57+
if (!existingFile) {
58+
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
59+
}
60+
61+
if (locked !== undefined && locked !== existingFile.locked && userPermission !== 'admin') {
62+
return NextResponse.json(
63+
{ success: false, error: 'Admin access required to lock files' },
64+
{ status: 403 }
65+
)
66+
}
67+
68+
const isLockOnlyUpdate = name === existingFile.name
69+
5470
const result = await performRenameWorkspaceFile({
5571
workspaceId,
5672
fileId,
5773
name,
5874
userId: session.user.id,
75+
locked,
76+
isLockOnlyUpdate,
5977
})
6078
if (!result.success || !result.file) {
6179
return NextResponse.json(
6280
{ success: false, error: result.error },
63-
{ status: result.errorCode === 'conflict' ? 409 : 500 }
81+
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
6482
)
6583
}
6684

@@ -132,14 +150,7 @@ export const DELETE = withRouteHandler(
132150
if (!result.success) {
133151
return NextResponse.json(
134152
{ success: false, error: result.error },
135-
{
136-
status:
137-
result.errorCode === 'validation'
138-
? 400
139-
: result.errorCode === 'not_found'
140-
? 404
141-
: 500,
142-
}
153+
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
143154
)
144155
}
145156

0 commit comments

Comments
 (0)