Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export function SandboxCanvasProvider({
workspaceId: SANDBOX_WORKSPACE_ID,
sortOrder: 0,
isSandbox: true,
isLocked: false,
}

useWorkflowStore.getState().replaceWorkflowState(workflowState)
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/app/api/folders/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateId } from '@/lib/core/utils/uuid'
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

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

const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
const targetParentId = parentId ?? sourceFolder.parentId

if (targetParentId) {
const parentLocked = await isFolderEffectivelyLockedDb(targetParentId)
if (parentLocked) {
return NextResponse.json(
{ error: 'Cannot duplicate a folder into a locked folder' },
{ status: 403 }
)
}
}

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

const folderParentCondition = targetParentId
? eq(workflowFolder.parentId, targetParentId)
Expand Down
31 changes: 30 additions & 1 deletion apps/sim/app/api/folders/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { captureServerEvent } from '@/lib/posthog/server'
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
import { performDeleteFolder } from '@/lib/workflows/orchestration'
import { checkForCircularReference } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
Expand All @@ -18,6 +19,7 @@ const updateFolderSchema = z.object({
isExpanded: z.boolean().optional(),
parentId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
isLocked: z.boolean().optional(),
})

// PUT - Update a folder
Expand All @@ -42,7 +44,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
}

const { name, color, isExpanded, parentId, sortOrder } = validationResult.data
const { name, color, isExpanded, parentId, sortOrder, isLocked } = validationResult.data

// Verify the folder exists
const existingFolder = await db
Expand All @@ -69,6 +71,27 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}

// If toggling isLocked, require admin permission
if (isLocked !== undefined && workspacePermission !== 'admin') {
return NextResponse.json(
{ error: 'Admin access required to lock/unlock folders' },
{ status: 403 }
)
}

// If folder is effectively locked, only allow isLocked toggle (admin) and isExpanded toggle
const effectivelyLocked = await isFolderEffectivelyLockedDb(id)
if (effectivelyLocked) {
const hasNonAllowedUpdates =
name !== undefined ||
color !== undefined ||
parentId !== undefined ||
sortOrder !== undefined
if (hasNonAllowedUpdates) {
return NextResponse.json({ error: 'Folder is locked' }, { status: 403 })
}
}

// Prevent setting a folder as its own parent or creating circular references
if (parentId && parentId === id) {
return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 })
Expand All @@ -91,6 +114,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (isExpanded !== undefined) updates.isExpanded = isExpanded
if (parentId !== undefined) updates.parentId = parentId || null
if (sortOrder !== undefined) updates.sortOrder = sortOrder
if (isLocked !== undefined) updates.isLocked = isLocked

const [updatedFolder] = await db
.update(workflowFolder)
Expand Down Expand Up @@ -144,6 +168,11 @@ export async function DELETE(
)
}

const effectivelyLocked = await isFolderEffectivelyLockedDb(id)
if (effectivelyLocked) {
return NextResponse.json({ error: 'Folder is locked' }, { status: 403 })
}

const result = await performDeleteFolder({
folderId: id,
workspaceId: existingFolder.workspaceId,
Expand Down
39 changes: 38 additions & 1 deletion apps/sim/app/api/folders/reorder/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { isFolderEffectivelyLocked, type LockableFolder } from '@/lib/workflows/lock'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('FolderReorderAPI')
Expand Down Expand Up @@ -44,7 +45,12 @@ export async function PUT(req: NextRequest) {

const folderIds = updates.map((u) => u.id)
const existingFolders = await db
.select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId })
.select({
id: workflowFolder.id,
workspaceId: workflowFolder.workspaceId,
parentId: workflowFolder.parentId,
isLocked: workflowFolder.isLocked,
})
.from(workflowFolder)
.where(inArray(workflowFolder.id, folderIds))

Expand All @@ -58,6 +64,37 @@ export async function PUT(req: NextRequest) {
return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
}

// Build folder map for cascade lock checks
const allFolders = await db
.select({
id: workflowFolder.id,
parentId: workflowFolder.parentId,
isLocked: workflowFolder.isLocked,
})
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))

const folderMap: Record<string, LockableFolder> = {}
for (const f of allFolders) {
folderMap[f.id] = f
}

// Block if any source folder or destination parent is effectively locked
for (const update of validUpdates) {
if (isFolderEffectivelyLocked(update.id, folderMap)) {
return NextResponse.json(
{ error: 'Cannot move or reorder a locked folder' },
{ status: 403 }
)
}
if (update.parentId && isFolderEffectivelyLocked(update.parentId, folderMap)) {
return NextResponse.json(
{ error: 'Cannot move folders into a locked folder' },
{ status: 403 }
)
}
}

await db.transaction(async (tx) => {
for (const update of validUpdates) {
const updateData: Record<string, unknown> = {
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/app/api/folders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateId } from '@/lib/core/utils/uuid'
import { captureServerEvent } from '@/lib/posthog/server'
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('FoldersAPI')
Expand Down Expand Up @@ -97,6 +98,16 @@ export async function POST(request: NextRequest) {
)
}

if (parentId) {
const parentLocked = await isFolderEffectivelyLockedDb(parentId)
if (parentLocked) {
return NextResponse.json(
{ error: 'Cannot create a folder inside a locked folder' },
{ status: 403 }
)
}
}

const id = clientId || generateId()

const newFolder = await db.transaction(async (tx) => {
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/app/api/workflows/[id]/duplicate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { isFolderEffectivelyLockedDb } from '@/lib/workflows/lock-db'
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'

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

if (folderId) {
const folderLocked = await isFolderEffectivelyLockedDb(folderId)
if (folderLocked) {
return NextResponse.json(
{ error: 'Cannot duplicate a workflow into a locked folder' },
{ status: 403 }
)
}
}

logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`)

const result = await duplicateWorkflow({
Expand Down
36 changes: 36 additions & 0 deletions apps/sim/app/api/workflows/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { z } from 'zod'
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { isWorkflowEffectivelyLockedDb } from '@/lib/workflows/lock-db'
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
Expand All @@ -19,6 +20,7 @@ const UpdateWorkflowSchema = z.object({
color: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
isLocked: z.boolean().optional(),
})

/**
Expand Down Expand Up @@ -182,6 +184,10 @@ export async function DELETE(
)
}

if (await isWorkflowEffectivelyLockedDb(workflowId)) {
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
}

const { searchParams } = new URL(request.url)
const checkTemplates = searchParams.get('check-templates') === 'true'
const deleteTemplatesParam = searchParams.get('deleteTemplates')
Expand Down Expand Up @@ -288,12 +294,42 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}

// If toggling isLocked, require admin permission
if (updates.isLocked !== undefined) {
const adminAuth = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'admin',
})
if (!adminAuth.allowed) {
return NextResponse.json(
{ error: 'Admin access required to lock/unlock workflows' },
{ status: 403 }
)
}
}

// If workflow is effectively locked, only allow isLocked toggle (by admins)
const effectivelyLocked = await isWorkflowEffectivelyLockedDb(workflowId)
if (effectivelyLocked) {
const hasNonLockUpdates =
updates.name !== undefined ||
updates.description !== undefined ||
updates.color !== undefined ||
updates.folderId !== undefined ||
updates.sortOrder !== undefined
if (hasNonLockUpdates) {
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
}
}

const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (updates.name !== undefined) updateData.name = updates.name
if (updates.description !== undefined) updateData.description = updates.description
if (updates.color !== undefined) updateData.color = updates.color
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
if (updates.isLocked !== undefined) updateData.isLocked = updates.isLocked

if (updates.name !== undefined || updates.folderId !== undefined) {
const targetName = updates.name ?? workflowData.name
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/api/workflows/[id]/state/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { isWorkflowEffectivelyLockedDb } from '@/lib/workflows/lock-db'
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
import {
loadWorkflowFromNormalizedTables,
Expand Down Expand Up @@ -199,6 +200,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}

if (await isWorkflowEffectivelyLockedDb(workflowId)) {
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
}

// Sanitize custom tools in agent blocks before saving
const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(
state.blocks as Record<string, BlockState>
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/api/workflows/[id]/variables/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { isWorkflowEffectivelyLockedDb } from '@/lib/workflows/lock-db'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import type { Variable } from '@/stores/variables/types'

Expand Down Expand Up @@ -65,6 +66,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
}

if (await isWorkflowEffectivelyLockedDb(workflowId)) {
return NextResponse.json({ error: 'Workflow is locked' }, { status: 403 })
}

const body = await req.json()

try {
Expand Down
Loading
Loading