diff --git a/apps/realtime/src/handlers/operations.ts b/apps/realtime/src/handlers/operations.ts index 5b57aa6bb57..1884c907812 100644 --- a/apps/realtime/src/handlers/operations.ts +++ b/apps/realtime/src/handlers/operations.ts @@ -10,6 +10,7 @@ import { } from '@sim/realtime-protocol/constants' import { WorkflowOperationSchema } from '@sim/realtime-protocol/schemas' import { generateId } from '@sim/utils/id' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { ZodError } from 'zod' import { persistWorkflowOperation } from '@/database/operations' import type { AuthenticatedSocket } from '@/middleware/auth' @@ -139,6 +140,24 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager } } + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + emitOperationError( + { + type: 'WORKFLOW_LOCKED', + message: error.message, + operation, + target, + }, + { error: error.message, retryable: false } + ) + return + } + throw error + } + // Broadcast first for position updates to minimize latency, then persist // For other operations, persist first for consistency if (isPositionUpdate) { diff --git a/apps/realtime/src/handlers/subblocks.ts b/apps/realtime/src/handlers/subblocks.ts index e71792ca685..b3be99e7457 100644 --- a/apps/realtime/src/handlers/subblocks.ts +++ b/apps/realtime/src/handlers/subblocks.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { and, eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkRolePermission } from '@/middleware/permissions' @@ -151,6 +152,28 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: return } + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + socket.emit('operation-forbidden', { + type: 'WORKFLOW_LOCKED', + message: error.message, + operation: SUBBLOCK_OPERATIONS.UPDATE, + target: 'subblock', + }) + if (operationId) { + socket.emit('operation-failed', { + operationId, + error: error.message, + retryable: false, + }) + } + return + } + throw error + } + // Update user activity await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() }) @@ -231,6 +254,22 @@ async function flushSubblockUpdate( return } + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + pending.opToSocket.forEach((socketId, opId) => { + io.to(socketId).emit('operation-failed', { + operationId: opId, + error: error.message, + retryable: false, + }) + }) + return + } + throw error + } + let updateSuccessful = false let blockLocked = false await db.transaction(async (tx) => { diff --git a/apps/realtime/src/handlers/variables.ts b/apps/realtime/src/handlers/variables.ts index b67570674aa..5bde4c326d9 100644 --- a/apps/realtime/src/handlers/variables.ts +++ b/apps/realtime/src/handlers/variables.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { VARIABLE_OPERATIONS } from '@sim/realtime-protocol/constants' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkRolePermission } from '@/middleware/permissions' @@ -140,6 +141,28 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: return } + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + socket.emit('operation-forbidden', { + type: 'WORKFLOW_LOCKED', + message: error.message, + operation: VARIABLE_OPERATIONS.UPDATE, + target: 'variable', + }) + if (operationId) { + socket.emit('operation-failed', { + operationId, + error: error.message, + retryable: false, + }) + } + return + } + throw error + } + // Update user activity await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() }) @@ -218,6 +241,22 @@ async function flushVariableUpdate( return } + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + pending.opToSocket.forEach((socketId, opId) => { + io.to(socketId).emit('operation-failed', { + operationId: opId, + error: error.message, + retryable: false, + }) + }) + return + } + throw error + } + let updateSuccessful = false await db.transaction(async (tx) => { const [workflowRecord] = await tx diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 820409049dd..c7f339fe3ba 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -3,6 +3,7 @@ import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { FolderLockedError } from '@sim/workflow-authz' import { and, eq, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { duplicateFolderContract } from '@/lib/api/contracts' @@ -10,6 +11,7 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { DbOrTx } from '@/lib/db/types' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -38,7 +40,7 @@ export const POST = withRouteHandler( const sourceFolder = await db .select() .from(workflowFolder) - .where(eq(workflowFolder.id, sourceFolderId)) + .where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt))) .then((rows) => rows[0]) if (!sourceFolder) { @@ -56,11 +58,15 @@ export const POST = withRouteHandler( } const targetWorkspaceId = workspaceId || sourceFolder.workspaceId + if (targetWorkspaceId !== sourceFolder.workspaceId) { + throw new Error('Cross-workspace folder duplication is not supported') + } - const { newFolderId, folderMapping } = await db.transaction(async (tx) => { + const { newFolderId, folderMapping, workflowStats } = await db.transaction(async (tx) => { const newFolderId = clientNewId || generateId() const now = new Date() const targetParentId = parentId ?? sourceFolder.parentId + await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId) const folderParentCondition = targetParentId ? eq(workflowFolder.parentId, targetParentId) @@ -88,16 +94,23 @@ export const POST = withRouteHandler( return Math.min(currentMin, candidate) }, null) const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 + const deduplicatedName = await deduplicateFolderName( + tx, + targetWorkspaceId, + targetParentId, + name + ) await tx.insert(workflowFolder).values({ id: newFolderId, userId: session.user.id, workspaceId: targetWorkspaceId, - name, + name: deduplicatedName, color: color || sourceFolder.color, parentId: targetParentId, sortOrder, isExpanded: false, + locked: false, createdAt: now, updatedAt: now, }) @@ -114,16 +127,17 @@ export const POST = withRouteHandler( folderMapping ) - return { newFolderId, folderMapping } - }) + const workflowStats = await duplicateWorkflowsInFolderTree( + tx, + sourceFolder.workspaceId, + targetWorkspaceId, + folderMapping, + session.user.id, + requestId + ) - const workflowStats = await duplicateWorkflowsInFolderTree( - sourceFolder.workspaceId, - targetWorkspaceId, - folderMapping, - session.user.id, - requestId - ) + return { newFolderId, folderMapping, workflowStats } + }) const elapsed = Date.now() - startTime logger.info( @@ -132,7 +146,6 @@ export const POST = withRouteHandler( foldersCount: folderMapping.size, workflowsCount: workflowStats.total, workflowsSucceeded: workflowStats.succeeded, - workflowsFailed: workflowStats.failed, } ) @@ -162,6 +175,10 @@ export const POST = withRouteHandler( return NextResponse.json({ folder: duplicatedFolder }, { status: 201 }) } catch (error) { if (error instanceof Error) { + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + if (error.message === 'Source folder not found') { logger.warn(`[${requestId}] Source folder ${sourceFolderId} not found`) return NextResponse.json({ error: 'Source folder not found' }, { status: 404 }) @@ -173,6 +190,20 @@ export const POST = withRouteHandler( ) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + + if (error.message === 'Cross-workspace folder duplication is not supported') { + logger.warn( + `[${requestId}] User ${session.user.id} attempted cross-workspace folder duplication for ${sourceFolderId}` + ) + return NextResponse.json({ error: error.message }, { status: 400 }) + } + + if ( + error.message === 'Target parent folder not found' || + error.message === 'Cannot duplicate folder into itself or one of its descendants' + ) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } } const elapsed = Date.now() - startTime @@ -185,8 +216,76 @@ export const POST = withRouteHandler( } ) +async function assertTargetParentFolderMutable( + tx: DbOrTx, + parentId: string | null, + targetWorkspaceId: string, + sourceFolderId: string +): Promise { + let currentFolderId = parentId + const visited = new Set() + + while (currentFolderId && !visited.has(currentFolderId)) { + visited.add(currentFolderId) + const [folder] = await tx + .select({ + id: workflowFolder.id, + parentId: workflowFolder.parentId, + workspaceId: workflowFolder.workspaceId, + locked: workflowFolder.locked, + archivedAt: workflowFolder.archivedAt, + }) + .from(workflowFolder) + .where(eq(workflowFolder.id, currentFolderId)) + .limit(1) + + if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) { + throw new Error('Target parent folder not found') + } + if (folder.id === sourceFolderId) { + throw new Error('Cannot duplicate folder into itself or one of its descendants') + } + if (folder.locked) { + throw new FolderLockedError() + } + + currentFolderId = folder.parentId + } +} + +async function deduplicateFolderName( + tx: DbOrTx, + workspaceId: string, + parentId: string | null, + requestedName: string +): Promise { + const parentCondition = parentId + ? eq(workflowFolder.parentId, parentId) + : isNull(workflowFolder.parentId) + const siblingRows = await tx + .select({ name: workflowFolder.name }) + .from(workflowFolder) + .where( + and( + eq(workflowFolder.workspaceId, workspaceId), + parentCondition, + isNull(workflowFolder.archivedAt) + ) + ) + const siblingNames = new Set(siblingRows.map((row) => row.name)) + if (!siblingNames.has(requestedName)) return requestedName + + let suffix = 1 + let candidate = `${requestedName} (${suffix})` + while (siblingNames.has(candidate)) { + suffix += 1 + candidate = `${requestedName} (${suffix})` + } + return candidate +} + async function duplicateFolderStructure( - tx: any, + tx: DbOrTx, sourceFolderId: string, newParentFolderId: string, sourceWorkspaceId: string, @@ -201,7 +300,8 @@ async function duplicateFolderStructure( .where( and( eq(workflowFolder.parentId, sourceFolderId), - eq(workflowFolder.workspaceId, sourceWorkspaceId) + eq(workflowFolder.workspaceId, sourceWorkspaceId), + isNull(workflowFolder.archivedAt) ) ) @@ -218,6 +318,7 @@ async function duplicateFolderStructure( parentId: newParentFolderId, sortOrder: childFolder.sortOrder, isExpanded: false, + locked: false, createdAt: timestamp, updatedAt: timestamp, }) @@ -236,40 +337,53 @@ async function duplicateFolderStructure( } async function duplicateWorkflowsInFolderTree( + tx: DbOrTx, sourceWorkspaceId: string, targetWorkspaceId: string, folderMapping: Map, userId: string, requestId: string -): Promise<{ total: number; succeeded: number; failed: number }> { - const stats = { total: 0, succeeded: 0, failed: 0 } +): Promise<{ total: number; succeeded: number }> { + const stats = { total: 0, succeeded: 0 } + const workflowsByNewFolder = new Map>() + const workflowIdMap = new Map() for (const [oldFolderId, newFolderId] of folderMapping.entries()) { - const workflowsInFolder = await db + const workflowsInFolder = await tx .select() .from(workflow) - .where(and(eq(workflow.folderId, oldFolderId), eq(workflow.workspaceId, sourceWorkspaceId))) + .where( + and( + eq(workflow.folderId, oldFolderId), + eq(workflow.workspaceId, sourceWorkspaceId), + isNull(workflow.archivedAt) + ) + ) stats.total += workflowsInFolder.length + workflowsByNewFolder.set(newFolderId, workflowsInFolder) + for (const sourceWorkflow of workflowsInFolder) { + workflowIdMap.set(sourceWorkflow.id, generateId()) + } + } + for (const [newFolderId, workflowsInFolder] of workflowsByNewFolder.entries()) { for (const sourceWorkflow of workflowsInFolder) { - try { - await duplicateWorkflow({ - sourceWorkflowId: sourceWorkflow.id, - userId, - name: sourceWorkflow.name, - description: sourceWorkflow.description || undefined, - color: sourceWorkflow.color, - workspaceId: targetWorkspaceId, - folderId: newFolderId, - requestId, - }) + await duplicateWorkflow({ + sourceWorkflowId: sourceWorkflow.id, + userId, + name: sourceWorkflow.name, + description: sourceWorkflow.description || undefined, + color: sourceWorkflow.color, + workspaceId: targetWorkspaceId, + folderId: newFolderId, + requestId, + tx, + newWorkflowId: workflowIdMap.get(sourceWorkflow.id), + workflowIdMap, + }) - stats.succeeded++ - } catch (error) { - stats.failed++ - logger.error(`[${requestId}] Error duplicating workflow ${sourceWorkflow.id}:`, error) - } + stats.succeeded++ } } diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 88b733f3cf2..be2b71e1028 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateFolderContract } from '@/lib/api/contracts' @@ -38,7 +39,7 @@ export const PUT = withRouteHandler( if (!parsed.success) return parsed.response const { id } = parsed.data.params - const { name, color, isExpanded, parentId, sortOrder } = parsed.data.body + const { name, color, isExpanded, locked, parentId, sortOrder } = parsed.data.body // Verify the folder exists const existingFolder = await db @@ -65,6 +66,21 @@ export const PUT = withRouteHandler( ) } + if (locked !== undefined && workspacePermission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required to lock folders' }, + { status: 403 } + ) + } + + const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked') + if (hasNonLockUpdate) { + await assertFolderMutable(id) + } + if (parentId !== undefined) { + await assertFolderMutable(parentId) + } + // 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 }) @@ -85,6 +101,7 @@ export const PUT = withRouteHandler( if (name !== undefined) updates.name = name.trim() if (color !== undefined) updates.color = color if (isExpanded !== undefined) updates.isExpanded = isExpanded + if (locked !== undefined) updates.locked = locked if (parentId !== undefined) updates.parentId = parentId || null if (sortOrder !== undefined) updates.sortOrder = sortOrder @@ -98,6 +115,10 @@ export const PUT = withRouteHandler( return NextResponse.json({ folder: updatedFolder }) } catch (error) { + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Error updating folder:', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -139,6 +160,8 @@ export const DELETE = withRouteHandler( ) } + await assertFolderMutable(id) + const result = await performDeleteFolder({ folderId: id, workspaceId: existingFolder.workspaceId, @@ -164,6 +187,10 @@ export const DELETE = withRouteHandler( deletedItems: result.deletedItems, }) } catch (error) { + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error('Error deleting folder:', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index ded32e93db0..87222b75b8d 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError } from '@sim/workflow-authz' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { reorderFoldersContract } from '@/lib/api/contracts' @@ -50,6 +51,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) } + for (const update of validUpdates) { + await assertFolderMutable(update.id) + if (update.parentId !== undefined) { + await assertFolderMutable(update.parentId) + } + } + await db.transaction(async (tx) => { for (const update of validUpdates) { const updateData: Record = { @@ -69,6 +77,10 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, updated: validUpdates.length }) } catch (error) { + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error reordering folders`, error) return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 }) } diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 88da49a34ea..bc80d9cec56 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateScheduleContract } from '@/lib/api/contracts/schedules' @@ -117,6 +121,9 @@ export const PUT = withRouteHandler( const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write') if (result instanceof NextResponse) return result const { schedule, workspaceId } = result + if (schedule.workflowId) { + await assertWorkflowMutable(schedule.workflowId) + } const { action } = validatedBody @@ -264,6 +271,10 @@ export const PUT = withRouteHandler( return NextResponse.json({ message: 'Schedule activated successfully', nextRunAt }) } catch (error) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error updating schedule`, error) return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 }) } diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 4a62ce137b8..ab65529dfaa 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -131,6 +135,7 @@ export const PATCH = withRouteHandler( logger.warn(`[${requestId}] User ${userId} denied permission to modify webhook: ${id}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + await assertWorkflowMutable(webhookData.workflow.id) const updatedWebhook = await db .update(webhook) @@ -145,6 +150,10 @@ export const PATCH = withRouteHandler( logger.info(`[${requestId}] Successfully updated webhook: ${id}`) return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 }) } catch (error) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error updating webhook`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } @@ -201,6 +210,7 @@ export const DELETE = withRouteHandler( logger.warn(`[${requestId}] User ${userId} denied permission to delete webhook: ${id}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + await assertWorkflowMutable(webhookData.workflow.id) const foundWebhook = webhookData.webhook const credentialSetId = foundWebhook.credentialSetId as string | undefined @@ -301,6 +311,10 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error deleting webhook`, { error: error.message, stack: error.stack, diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 6b582a3c0a8..b53255d9e11 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -3,7 +3,11 @@ import { db } from '@sim/db' import { permissions, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { listWebhooksContract, upsertWebhookContract } from '@/lib/api/contracts/webhooks' @@ -293,6 +297,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + await assertWorkflowMutable(workflowId) // Determine existing webhook to update (prefer by workflow+block for credential-based providers) let targetWebhookId: string | null = null @@ -720,6 +725,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const status = targetWebhookId ? 200 : 201 return NextResponse.json({ webhook: savedWebhook }, { status }) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error creating/updating webhook`, { message: error.message, stack: error.stack, diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 6ddc1d081f1..a46714cd4c6 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -1,5 +1,9 @@ import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { workflowAutoLayoutContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -72,6 +76,8 @@ export const POST = withRouteHandler( ) } + await assertWorkflowMutable(workflowId) + let currentWorkflowData: NormalizedWorkflowData | null if (layoutOptions.blocks && layoutOptions.edges) { @@ -141,6 +147,10 @@ export const POST = withRouteHandler( }, }) } catch (error) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const elapsed = Date.now() - startTime logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index cfa8404948b..5a188ba9443 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,5 +1,6 @@ import { db, workflow } from '@sim/db' import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { updatePublicApiContract } from '@/lib/api/contracts/deployments' @@ -92,6 +93,7 @@ export const POST = withRouteHandler( logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`) return createErrorResponse('Unable to determine deploying user', 400) } + await assertWorkflowMutable(id) const result = await performFullDeploy({ workflowId: id, @@ -130,6 +132,9 @@ export const POST = withRouteHandler( warnings: result.warnings, }) } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return createErrorResponse(error.message, error.status) + } const message = error instanceof Error ? error.message : 'Failed to deploy workflow' logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error }) return createErrorResponse(message, 500) @@ -159,6 +164,7 @@ export const PATCH = withRouteHandler( if (error) { return createErrorResponse(error.message, error.status) } + await assertWorkflowMutable(id) if (isPublicApi) { try { @@ -185,6 +191,9 @@ export const PATCH = withRouteHandler( return createSuccessResponse({ isPublicApi }) } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return createErrorResponse(error.message, error.status) + } const message = error instanceof Error ? error.message : 'Failed to update deployment settings' logger.error(`[${requestId}] Error updating deployment settings`, { error }) @@ -207,6 +216,7 @@ export const DELETE = withRouteHandler( if (error) { return createErrorResponse(error.message, error.status) } + await assertWorkflowMutable(id) const result = await performFullUndeploy({ workflowId: id, @@ -232,6 +242,9 @@ export const DELETE = withRouteHandler( apiKey: null, }) } catch (error: unknown) { + if (error instanceof WorkflowLockedError) { + return createErrorResponse(error.message, error.status) + } const message = error instanceof Error ? error.message : 'Failed to undeploy workflow' logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error }) return createErrorResponse(message, 500) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index 30e7a4451b2..e7a95618bcd 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' import type { NextRequest } from 'next/server' import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows' import { generateRequestId } from '@/lib/core/utils/request' @@ -29,6 +30,7 @@ export const POST = withRouteHandler( if (error) { return createErrorResponse(error.message, error.status) } + await assertWorkflowMutable(id) const versionValidation = workflowDeploymentVersionParamSchema.safeParse(version) if (!versionValidation.success) { @@ -57,6 +59,10 @@ export const POST = withRouteHandler( lastSaved: result.lastSaved, }) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return createErrorResponse(error.message, error.status) + } + logger.error('Error reverting to deployment version', error) return createErrorResponse(error.message || 'Failed to revert', 500) } diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index c97b9f54bd0..044dcb86970 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { FolderLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { duplicateWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -94,6 +95,10 @@ export const POST = withRouteHandler( return NextResponse.json(result, { status: 201 }) } catch (error) { if (error instanceof Error) { + if (error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + if (error.message === 'Source workflow not found') { logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`) return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 }) @@ -105,6 +110,21 @@ export const POST = withRouteHandler( ) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } + + if (error.message === 'Cross-workspace workflow duplication is not supported') { + logger.warn( + `[${requestId}] User ${userId} attempted cross-workspace workflow duplication for ${sourceWorkflowId}` + ) + return NextResponse.json({ error: error.message }, { status: 400 }) + } + + if (error.message === 'Folder is locked') { + return NextResponse.json({ error: error.message }, { status: 423 }) + } + + if (error.message === 'Target folder not found') { + return NextResponse.json({ error: error.message }, { status: 400 }) + } } const elapsed = Date.now() - startTime diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index e7609924f0d..8c6b7a928ea 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { assertFolderMutable, FolderLockedError, WorkflowLockedError } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { restoreWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -44,6 +45,11 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + if (workflowData.locked) { + throw new WorkflowLockedError('Workflow is locked') + } + await assertFolderMutable(workflowData.folderId) + const result = await restoreWorkflow(workflowId, { requestId }) if (!result.restored) { @@ -78,6 +84,10 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 47c3b9ea04e..6feac3d767d 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,7 +1,13 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertFolderMutable, + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + FolderLockedError, + WorkflowLockedError, +} from '@sim/workflow-authz' import { and, eq, isNull, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkflowContract } from '@/lib/api/contracts/workflows' @@ -191,6 +197,8 @@ export const DELETE = withRouteHandler( ) } + await assertWorkflowMutable(workflowId) + const { searchParams } = new URL(request.url) const checkTemplates = searchParams.get('check-templates') === 'true' const deleteTemplatesParam = searchParams.get('deleteTemplates') @@ -245,6 +253,10 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const elapsed = Date.now() - startTime logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) @@ -300,12 +312,31 @@ export const PUT = withRouteHandler( ) } + if (updates.locked !== undefined && authorization.workspacePermission !== 'admin') { + logger.warn( + `[${requestId}] User ${userId} denied permission to lock workflow ${workflowId}` + ) + return NextResponse.json( + { error: 'Admin access required to lock workflows' }, + { status: 403 } + ) + } + + const hasNonLockUpdate = Object.keys(updates).some((key) => key !== 'locked') + if (hasNonLockUpdate) { + await assertWorkflowMutable(workflowId) + } + if (updates.folderId !== undefined) { + await assertFolderMutable(updates.folderId) + } + const updateData: Record = { 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.locked !== undefined) updateData.locked = updates.locked if (updates.name !== undefined || updates.folderId !== undefined) { const targetName = updates.name ?? workflowData.name @@ -359,6 +390,7 @@ export const PUT = withRouteHandler( workspaceId: workflow.workspaceId, folderId: workflow.folderId, sortOrder: workflow.sortOrder, + locked: workflow.locked, createdAt: workflow.createdAt, updatedAt: workflow.updatedAt, archivedAt: workflow.archivedAt, @@ -371,6 +403,10 @@ export const PUT = withRouteHandler( return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) } catch (error: any) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const elapsed = Date.now() - startTime logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 7ce6e22660f..a28da778e3d 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -2,7 +2,11 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { putWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' @@ -129,6 +133,8 @@ export const PUT = withRouteHandler( ) } + await assertWorkflowMutable(workflowId) + // Note: prior versions cross-checked that each variable's `workflowId` // equalled the path param. The write contract does not carry `workflowId` // per variable (the path param is the source of truth), so the check @@ -272,6 +278,10 @@ export const PUT = withRouteHandler( { status: 200 } ) } catch (error: any) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + const elapsed = Date.now() - startTime logger.error( `[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`, diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 291aac8d7b7..934f366f110 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' +import { + assertWorkflowMutable, + authorizeWorkflowByWorkspacePermission, + WorkflowLockedError, +} from '@sim/workflow-authz' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { workflowVariablesContract } from '@/lib/api/contracts/workflows' @@ -50,6 +54,8 @@ export const POST = withRouteHandler( ) } + await assertWorkflowMutable(workflowId) + const parsed = await parseRequest(workflowVariablesContract, req, context) if (!parsed.success) return parsed.response const { variables } = parsed.data.body @@ -88,6 +94,10 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { + if (error instanceof WorkflowLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error updating workflow variables`, error) return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 }) } diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts index ac8706f87cb..fdd049f4402 100644 --- a/apps/sim/app/api/workflows/reorder/route.ts +++ b/apps/sim/app/api/workflows/reorder/route.ts @@ -1,6 +1,12 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/workflow-authz' import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { reorderWorkflowsContract } from '@/lib/api/contracts/workflows' @@ -50,6 +56,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 }) } + for (const update of validUpdates) { + await assertWorkflowMutable(update.id) + if (update.folderId !== undefined) { + await assertFolderMutable(update.folderId) + } + } + await db.transaction(async (tx) => { for (const update of validUpdates) { const updateData: Record = { @@ -69,6 +82,10 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, updated: validUpdates.length }) } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + logger.error(`[${requestId}] Error reordering workflows`, error) return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 }) } diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 020f7abaecc..17024877312 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -87,6 +87,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { createdAt: workflow.createdAt, updatedAt: workflow.updatedAt, archivedAt: workflow.archivedAt, + locked: workflow.locked, } as const const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] diff --git a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts deleted file mode 100644 index 11f0c91c428..00000000000 --- a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { duplicateWorkspaceContract } from '@/lib/api/contracts/workspaces' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { duplicateWorkspace } from '@/lib/workspaces/duplicate' - -const logger = createLogger('WorkspaceDuplicateAPI') - -// POST /api/workspaces/[id]/duplicate - Duplicate a workspace with all its workflows -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const { id: sourceWorkspaceId } = await context.params - const requestId = generateRequestId() - const startTime = Date.now() - - const session = await getSession() - if (!session?.user?.id) { - logger.warn( - `[${requestId}] Unauthorized workspace duplication attempt for ${sourceWorkspaceId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest(duplicateWorkspaceContract, req, context) - if (!parsed.success) return parsed.response - const { name } = parsed.data.body - - logger.info( - `[${requestId}] Duplicating workspace ${sourceWorkspaceId} for user ${session.user.id}` - ) - - const result = await duplicateWorkspace({ - sourceWorkspaceId, - userId: session.user.id, - name, - requestId, - }) - - const elapsed = Date.now() - startTime - logger.info( - `[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms` - ) - - recordAudit({ - workspaceId: sourceWorkspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.WORKSPACE_DUPLICATED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: result.id, - resourceName: name, - description: `Duplicated workspace to "${name}"`, - metadata: { - sourceWorkspaceId, - affected: { workflows: result.workflowsCount, folders: result.foldersCount }, - }, - request: req, - }) - - return NextResponse.json(result, { status: 201 }) - } catch (error) { - if (error instanceof Error) { - if (error.message === 'Source workspace not found') { - logger.warn(`[${requestId}] Source workspace ${sourceWorkspaceId} not found`) - return NextResponse.json({ error: 'Source workspace not found' }, { status: 404 }) - } - - if (error.message === 'Source workspace not found or access denied') { - logger.warn( - `[${requestId}] User ${session.user.id} denied access to source workspace ${sourceWorkspaceId}` - ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - } - - const elapsed = Date.now() - startTime - logger.error( - `[${requestId}] Error duplicating workspace ${sourceWorkspaceId} after ${elapsed}ms:`, - error - ) - return NextResponse.json({ error: 'Failed to duplicate workspace' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx index 27bb15a58b6..bbf31a891fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx @@ -517,7 +517,7 @@ export function IntegrationsManager() { } } - const [isShareingWithWorkspace, setIsSharingWithWorkspace] = useState(false) + const [isSharingWithWorkspace, setIsSharingWithWorkspace] = useState(false) const handleShareWithWorkspace = async () => { if (!selectedCredential || !isSelectedAdmin) return @@ -1416,14 +1416,14 @@ export function IntegrationsManager() { }`} )} - {(workspaceUserOptions.length > 0 || isShareingWithWorkspace) && ( + {(workspaceUserOptions.length > 0 || isSharingWithWorkspace) && ( )}