Skip to content

Commit 03b5671

Browse files
waleedlatif1claude
andcommitted
fix(workflows): harden lock enforcement and clean up dead code
- Fix infinite loop in pure isFolderEffectivelyLocked on circular parentId chains - Add lock checks to create/duplicate routes (block writes into locked folders) - Use isWorkflowEffectivelyLockedDb instead of inline checks in state/workflow routes - Remove use-effective-lock.ts re-export file; import from @/lib/workflows/lock directly - Remove dead import and re-export from lock-db.ts - Use LockableFolder type in reorder routes - Add test for all-unlocked circular chain edge case Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9f891b5 commit 03b5671

File tree

18 files changed

+418
-1094
lines changed

18 files changed

+418
-1094
lines changed

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
88
import { getSession } from '@/lib/auth'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { generateId } from '@/lib/core/utils/uuid'
11+
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
1112
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
1213
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1314

@@ -66,11 +67,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
6667
}
6768

6869
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
70+
const targetParentId = parentId ?? sourceFolder.parentId
71+
72+
if (targetParentId) {
73+
const parentLocked = await isFolderEffectivelyLockedDb(targetParentId)
74+
if (parentLocked) {
75+
return NextResponse.json(
76+
{ error: 'Cannot duplicate a folder into a locked folder' },
77+
{ status: 403 }
78+
)
79+
}
80+
}
6981

7082
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
7183
const newFolderId = clientNewId || generateId()
7284
const now = new Date()
73-
const targetParentId = parentId ?? sourceFolder.parentId
7485

7586
const folderParentCondition = targetParentId
7687
? eq(workflowFolder.parentId, targetParentId)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
88
import { generateRequestId } from '@/lib/core/utils/request'
9-
import { isFolderEffectivelyLocked } from '@/lib/workflows/lock'
9+
import { isFolderEffectivelyLocked, type LockableFolder } from '@/lib/workflows/lock'
1010
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1111

1212
const logger = createLogger('FolderReorderAPI')
@@ -74,7 +74,7 @@ export async function PUT(req: NextRequest) {
7474
.from(workflowFolder)
7575
.where(eq(workflowFolder.workspaceId, workspaceId))
7676

77-
const folderMap: Record<string, { id: string; parentId: string | null; isLocked: boolean }> = {}
77+
const folderMap: Record<string, LockableFolder> = {}
7878
for (const f of allFolders) {
7979
folderMap[f.id] = f
8080
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
88
import { getSession } from '@/lib/auth'
99
import { generateId } from '@/lib/core/utils/uuid'
1010
import { captureServerEvent } from '@/lib/posthog/server'
11+
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
1112
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1213

1314
const logger = createLogger('FoldersAPI')
@@ -97,6 +98,16 @@ export async function POST(request: NextRequest) {
9798
)
9899
}
99100

101+
if (parentId) {
102+
const parentLocked = await isFolderEffectivelyLockedDb(parentId)
103+
if (parentLocked) {
104+
return NextResponse.json(
105+
{ error: 'Cannot create a folder inside a locked folder' },
106+
{ status: 403 }
107+
)
108+
}
109+
}
110+
100111
const id = clientId || generateId()
101112

102113
const newFolder = await db.transaction(async (tx) => {

apps/sim/app/api/workflows/[id]/duplicate/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { PlatformEvents } from '@/lib/core/telemetry'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import { captureServerEvent } from '@/lib/posthog/server'
9+
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
910
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
1011

1112
const logger = createLogger('WorkflowDuplicateAPI')
@@ -37,6 +38,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
3738
const { name, description, color, workspaceId, folderId, newId } =
3839
DuplicateRequestSchema.parse(body)
3940

41+
if (folderId) {
42+
const folderLocked = await isFolderEffectivelyLockedDb(folderId)
43+
if (folderLocked) {
44+
return NextResponse.json(
45+
{ error: 'Cannot duplicate a workflow into a locked folder' },
46+
{ status: 403 }
47+
)
48+
}
49+
}
50+
4051
logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`)
4152

4253
const result = await duplicateWorkflow({

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

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { z } from 'zod'
77
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
88
import { generateRequestId } from '@/lib/core/utils/request'
99
import { captureServerEvent } from '@/lib/posthog/server'
10-
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
10+
import { isWorkflowEffectivelyLockedDb } from '@/lib/workflows/lock-db'
1111
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
1212
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
1313
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
@@ -184,10 +184,7 @@ export async function DELETE(
184184
)
185185
}
186186

187-
const isLocked =
188-
workflowData.isLocked ||
189-
(workflowData.folderId ? await isFolderEffectivelyLockedDb(workflowData.folderId) : false)
190-
if (isLocked) {
187+
if (await isWorkflowEffectivelyLockedDb(workflowId)) {
191188
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
192189
}
193190

@@ -313,9 +310,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
313310
}
314311

315312
// If workflow is effectively locked, only allow isLocked toggle (by admins)
316-
const effectivelyLocked =
317-
workflowData.isLocked ||
318-
(workflowData.folderId ? await isFolderEffectivelyLockedDb(workflowData.folderId) : false)
313+
const effectivelyLocked = await isWorkflowEffectivelyLockedDb(workflowId)
319314
if (effectivelyLocked) {
320315
const hasNonLockUpdates =
321316
updates.name !== undefined ||

apps/sim/app/api/workflows/[id]/state/route.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import { z } from 'zod'
77
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
88
import { env } from '@/lib/core/config/env'
99
import { generateRequestId } from '@/lib/core/utils/request'
10+
import { isWorkflowEffectivelyLockedDb } from '@/lib/workflows/lock-db'
1011
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
1112
import {
1213
loadWorkflowFromNormalizedTables,
1314
saveWorkflowToNormalizedTables,
1415
} from '@/lib/workflows/persistence/utils'
15-
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
1616
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
1717
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
1818
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
@@ -200,11 +200,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
200200
)
201201
}
202202

203-
// Check if workflow is effectively locked (directly or via folder cascade)
204-
const isLocked =
205-
workflowData.isLocked ||
206-
(workflowData.folderId ? await isFolderEffectivelyLockedDb(workflowData.folderId) : false)
207-
if (isLocked) {
203+
if (await isWorkflowEffectivelyLockedDb(workflowId)) {
208204
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
209205
}
210206

apps/sim/app/api/workflows/[id]/variables/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod'
77
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
88
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
99
import { generateRequestId } from '@/lib/core/utils/request'
10+
import { isWorkflowEffectivelyLockedDb } from '@/lib/workflows/lock-db'
1011
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
1112
import type { Variable } from '@/stores/variables/types'
1213

@@ -65,6 +66,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
6566
)
6667
}
6768

69+
if (await isWorkflowEffectivelyLockedDb(workflowId)) {
70+
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
71+
}
72+
6873
const body = await req.json()
6974

7075
try {

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
88
import { generateRequestId } from '@/lib/core/utils/request'
9-
import { isFolderEffectivelyLocked, isWorkflowEffectivelyLocked } from '@/lib/workflows/lock'
9+
import {
10+
isFolderEffectivelyLocked,
11+
isWorkflowEffectivelyLocked,
12+
type LockableFolder,
13+
} from '@/lib/workflows/lock'
1014
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1115

1216
const logger = createLogger('WorkflowReorderAPI')
@@ -74,7 +78,7 @@ export async function PUT(req: NextRequest) {
7478
.from(workflowFolder)
7579
.where(eq(workflowFolder.workspaceId, workspaceId))
7680

77-
const folderMap: Record<string, { id: string; parentId: string | null; isLocked: boolean }> = {}
81+
const folderMap: Record<string, LockableFolder> = {}
7882
for (const f of allFolders) {
7983
folderMap[f.id] = f
8084
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { generateId } from '@/lib/core/utils/uuid'
1111
import { captureServerEvent } from '@/lib/posthog/server'
1212
import { getNextWorkflowColor } from '@/lib/workflows/colors'
1313
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
14+
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
1415
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
1516
import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
1617
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
@@ -163,6 +164,16 @@ export async function POST(req: NextRequest) {
163164
)
164165
}
165166

167+
if (folderId) {
168+
const folderLocked = await isFolderEffectivelyLockedDb(folderId)
169+
if (folderLocked) {
170+
return NextResponse.json(
171+
{ error: 'Cannot create a workflow inside a locked folder' },
172+
{ status: 403 }
173+
)
174+
}
175+
}
176+
166177
const workflowId = clientId || generateId()
167178
const now = new Date()
168179

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { generateId } from '@/lib/core/utils/uuid'
2323
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
2424
import type { OAuthProvider } from '@/lib/oauth'
2525
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
26+
import { isWorkflowEffectivelyLocked } from '@/lib/workflows/lock'
2627
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
2728
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
2829
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -79,7 +80,6 @@ import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-setti
7980
import { useWorkflowMap } from '@/hooks/queries/workflows'
8081
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
8182
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
82-
import { isWorkflowEffectivelyLocked } from '@/hooks/use-effective-lock'
8383
import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return'
8484
import { useCanvasModeStore } from '@/stores/canvas-mode'
8585
import { useChatStore } from '@/stores/chat/store'

0 commit comments

Comments
 (0)