diff --git a/apps/docs/content/docs/en/execution/api.mdx b/apps/docs/content/docs/en/execution/api.mdx index 61166941fb4..7f214df205d 100644 --- a/apps/docs/content/docs/en/execution/api.mdx +++ b/apps/docs/content/docs/en/execution/api.mdx @@ -19,6 +19,8 @@ curl -H "x-api-key: YOUR_API_KEY" \ You can generate API keys from the Sim platform and navigate to **Settings**, then go to **Sim Keys** and click **Create**. +Workflow lifecycle and deployment endpoints can also be used programmatically with session authentication or API keys, and public workflows remain available where the specific endpoint already supports public access. + ## Logs API All API responses include information about your workflow execution limits and usage: diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.test.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.test.ts new file mode 100644 index 00000000000..303fd7c424d --- /dev/null +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockActivateWorkflowVersion, + mockCleanupDeploymentVersion, + mockCreateSchedulesForDeploy, + mockDbFrom, + mockDbLimit, + mockDbSelect, + mockDbWhere, + mockGetActiveWorkflowRecord, + mockRestorePreviousVersionWebhooks, + mockSaveTriggerWebhooksForDeploy, + mockSyncMcpToolsForWorkflow, + mockValidateWorkflowSchedules, +} = vi.hoisted(() => ({ + mockActivateWorkflowVersion: vi.fn(), + mockCleanupDeploymentVersion: vi.fn(), + mockCreateSchedulesForDeploy: vi.fn(), + mockDbFrom: vi.fn(), + mockDbLimit: vi.fn(), + mockDbSelect: vi.fn(), + mockDbWhere: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockRestorePreviousVersionWebhooks: vi.fn(), + mockSaveTriggerWebhooksForDeploy: vi.fn(), + mockSyncMcpToolsForWorkflow: vi.fn(), + mockValidateWorkflowSchedules: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), error: vi.fn() }), +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockDbSelect }, + workflowDeploymentVersion: { + id: 'id', + state: 'state', + workflowId: 'workflowId', + isActive: 'isActive', + version: 'version', + }, +})) + +vi.mock('drizzle-orm', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + and: vi.fn(), + eq: vi.fn(), + } +}) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'req-123', +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + syncMcpToolsForWorkflow: (...args: unknown[]) => mockSyncMcpToolsForWorkflow(...args), +})) + +vi.mock('@/lib/webhooks/deploy', () => ({ + restorePreviousVersionWebhooks: (...args: unknown[]) => + mockRestorePreviousVersionWebhooks(...args), + saveTriggerWebhooksForDeploy: (...args: unknown[]) => mockSaveTriggerWebhooksForDeploy(...args), +})) + +vi.mock('@/lib/workflows/active-context', () => ({ + getActiveWorkflowRecord: (...args: unknown[]) => mockGetActiveWorkflowRecord(...args), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + activateWorkflowVersion: (...args: unknown[]) => mockActivateWorkflowVersion(...args), +})) + +vi.mock('@/lib/workflows/schedules', () => ({ + cleanupDeploymentVersion: (...args: unknown[]) => mockCleanupDeploymentVersion(...args), + createSchedulesForDeploy: (...args: unknown[]) => mockCreateSchedulesForDeploy(...args), + validateWorkflowSchedules: (...args: unknown[]) => mockValidateWorkflowSchedules(...args), +})) + +vi.mock('@/app/api/v1/admin/middleware', () => ({ + withAdminAuthParams: ( + handler: (request: NextRequest, context: { params: Promise }) => Promise + ) => handler, +})) + +import { POST } from '@/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route' + +describe('Admin workflow activate version route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbSelect.mockReturnValue({ from: mockDbFrom }) + mockDbFrom.mockReturnValue({ where: mockDbWhere }) + mockDbWhere.mockReturnValue({ limit: mockDbLimit }) + mockGetActiveWorkflowRecord.mockResolvedValue({ + id: 'wf-1', + name: 'Test Workflow', + userId: 'user-1', + }) + mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) + mockSaveTriggerWebhooksForDeploy.mockResolvedValue({ success: true, warnings: ['warn-1'] }) + mockCreateSchedulesForDeploy.mockResolvedValue({ success: true }) + mockActivateWorkflowVersion.mockResolvedValue({ + success: true, + deployedAt: new Date('2024-01-01T00:00:00.000Z'), + }) + mockCleanupDeploymentVersion.mockResolvedValue(undefined) + mockRestorePreviousVersionWebhooks.mockResolvedValue(undefined) + mockSyncMcpToolsForWorkflow.mockResolvedValue(undefined) + }) + + it('returns 200 with warnings for a successful activation', async () => { + const versionState = { + blocks: { start: { id: 'start', type: 'start_trigger', name: 'Start' } }, + } + mockDbLimit + .mockResolvedValueOnce([{ id: 'dep-3', state: versionState }]) + .mockResolvedValueOnce([{ id: 'dep-2' }]) + + const req = new NextRequest( + 'http://localhost:3000/api/v1/admin/workflows/wf-1/versions/3/activate', + { method: 'POST' } + ) + const response = await POST(req, { + params: Promise.resolve({ id: 'wf-1', versionId: '3' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + success: true, + version: 3, + deployedAt: '2024-01-01T00:00:00.000Z', + warnings: ['warn-1'], + }, + }) + expect(mockActivateWorkflowVersion).toHaveBeenCalledWith({ workflowId: 'wf-1', version: 3 }) + expect(mockSyncMcpToolsForWorkflow).toHaveBeenCalledWith({ + workflowId: 'wf-1', + requestId: 'req-123', + state: versionState, + context: 'activate', + }) + }) + + it('returns success when MCP sync throws after activation succeeds', async () => { + const versionState = { + blocks: { start: { id: 'start', type: 'start_trigger', name: 'Start' } }, + } + mockDbLimit + .mockResolvedValueOnce([{ id: 'dep-3', state: versionState }]) + .mockResolvedValueOnce([{ id: 'dep-2' }]) + mockSyncMcpToolsForWorkflow.mockRejectedValue(new Error('MCP sync failed')) + + const req = new NextRequest( + 'http://localhost:3000/api/v1/admin/workflows/wf-1/versions/3/activate', + { method: 'POST' } + ) + const response = await POST(req, { + params: Promise.resolve({ id: 'wf-1', versionId: '3' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + success: true, + version: 3, + deployedAt: '2024-01-01T00:00:00.000Z', + warnings: ['warn-1'], + }, + }) + expect(mockActivateWorkflowVersion).toHaveBeenCalledWith({ workflowId: 'wf-1', version: 3 }) + expect(mockSyncMcpToolsForWorkflow).toHaveBeenCalledWith({ + workflowId: 'wf-1', + requestId: 'req-123', + state: versionState, + context: 'activate', + }) + expect(mockActivateWorkflowVersion.mock.invocationCallOrder[0]).toBeLessThan( + mockSyncMcpToolsForWorkflow.mock.invocationCallOrder[0] + ) + }) + + it('returns 400 for invalid version numbers before loading deployment rows', async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/admin/workflows/wf-1/versions/not-a-number/activate', + { method: 'POST' } + ) + const response = await POST(req, { + params: Promise.resolve({ id: 'wf-1', versionId: 'not-a-number' }), + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { + code: 'BAD_REQUEST', + message: 'Invalid version number', + }, + }) + expect(mockDbSelect).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts index 1824c6508f4..5b1e0435b7d 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -174,12 +174,19 @@ export const POST = withAdminAuthParams(async (request, context) => } } - await syncMcpToolsForWorkflow({ - workflowId, - requestId, - state: versionRow.state, - context: 'activate', - }) + try { + await syncMcpToolsForWorkflow({ + workflowId, + requestId, + state: versionRow.state, + context: 'activate', + }) + } catch (syncError) { + logger.error( + `[${requestId}] Admin API: Failed to sync MCP tools after activation for workflow ${workflowId}`, + syncError + ) + } logger.info( `[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}` diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/workflows/[id]/deploy/route.test.ts new file mode 100644 index 00000000000..47ada799d57 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/deploy/route.test.ts @@ -0,0 +1,339 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCleanupWebhooksForWorkflow, + mockRecordAudit, + mockDbLimit, + mockDbOrderBy, + mockDbFrom, + mockDbSelect, + mockDbSet, + mockDbUpdate, + mockDbWhere, + mockCreateSchedulesForDeploy, + mockDeployWorkflow, + mockLoadWorkflowFromNormalizedTables, + mockRemoveMcpToolsForWorkflow, + mockSaveTriggerWebhooksForDeploy, + mockSyncMcpToolsForWorkflow, + mockUndeployWorkflow, + mockValidatePublicApiAllowed, + mockValidateWorkflowAccess, + mockValidateWorkflowPermissions, +} = vi.hoisted(() => ({ + mockCleanupWebhooksForWorkflow: vi.fn(), + mockRecordAudit: vi.fn(), + mockDbLimit: vi.fn(), + mockDbOrderBy: vi.fn(), + mockDbFrom: vi.fn(), + mockDbSelect: vi.fn(), + mockDbSet: vi.fn(), + mockDbUpdate: vi.fn(), + mockDbWhere: vi.fn(), + mockCreateSchedulesForDeploy: vi.fn(), + mockDeployWorkflow: vi.fn(), + mockLoadWorkflowFromNormalizedTables: vi.fn(), + mockRemoveMcpToolsForWorkflow: vi.fn(), + mockSaveTriggerWebhooksForDeploy: vi.fn(), + mockSyncMcpToolsForWorkflow: vi.fn(), + mockUndeployWorkflow: vi.fn(), + mockValidatePublicApiAllowed: vi.fn(), + mockValidateWorkflowAccess: vi.fn(), + mockValidateWorkflowPermissions: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})) + +vi.mock('@/lib/workflows/utils', () => ({ + validateWorkflowPermissions: (...args: unknown[]) => mockValidateWorkflowPermissions(...args), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'req-123', +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockDbSelect, update: mockDbUpdate }, + workflow: { variables: 'variables', id: 'id' }, + workflowDeploymentVersion: { + state: 'state', + workflowId: 'workflowId', + isActive: 'isActive', + createdAt: 'createdAt', + id: 'id', + }, +})) + +vi.mock('drizzle-orm', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + and: vi.fn(), + desc: vi.fn(), + eq: vi.fn(), + } +}) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: (...args: unknown[]) => + mockLoadWorkflowFromNormalizedTables(...args), + deployWorkflow: (...args: unknown[]) => mockDeployWorkflow(...args), + undeployWorkflow: (...args: unknown[]) => mockUndeployWorkflow(...args), +})) + +vi.mock('@/lib/workflows/comparison', () => ({ + hasWorkflowChanged: vi.fn().mockReturnValue(false), +})) + +vi.mock('@/lib/workflows/schedules', () => ({ + cleanupDeploymentVersion: vi.fn(), + createSchedulesForDeploy: (...args: unknown[]) => mockCreateSchedulesForDeploy(...args), + validateWorkflowSchedules: vi.fn().mockReturnValue({ isValid: true }), +})) + +vi.mock('@/lib/webhooks/deploy', () => ({ + cleanupWebhooksForWorkflow: (...args: unknown[]) => mockCleanupWebhooksForWorkflow(...args), + restorePreviousVersionWebhooks: vi.fn(), + saveTriggerWebhooksForDeploy: (...args: unknown[]) => mockSaveTriggerWebhooksForDeploy(...args), +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + removeMcpToolsForWorkflow: (...args: unknown[]) => mockRemoveMcpToolsForWorkflow(...args), + syncMcpToolsForWorkflow: (...args: unknown[]) => mockSyncMcpToolsForWorkflow(...args), +})) + +vi.mock('@/lib/audit/log', () => ({ + AuditAction: {}, + AuditResourceType: {}, + recordAudit: (...args: unknown[]) => mockRecordAudit(...args), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + PublicApiNotAllowedError: class PublicApiNotAllowedError extends Error {}, + validatePublicApiAllowed: (...args: unknown[]) => mockValidatePublicApiAllowed(...args), +})) + +import { DELETE, PATCH, POST } from '@/app/api/workflows/[id]/deploy/route' + +describe('Workflow deploy route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbSelect.mockReturnValue({ from: mockDbFrom }) + mockDbFrom.mockReturnValue({ where: mockDbWhere }) + mockDbWhere.mockReturnValue({ limit: mockDbLimit, orderBy: mockDbOrderBy }) + mockDbOrderBy.mockReturnValue({ limit: mockDbLimit }) + mockDbLimit.mockResolvedValue([]) + mockDbUpdate.mockReturnValue({ set: mockDbSet }) + mockDbSet.mockReturnValue({ where: mockDbWhere }) + mockCleanupWebhooksForWorkflow.mockResolvedValue(undefined) + mockCreateSchedulesForDeploy.mockResolvedValue({ success: true }) + mockLoadWorkflowFromNormalizedTables.mockResolvedValue({ + blocks: { 'block-1': { id: 'block-1', type: 'start_trigger', name: 'Start' } }, + edges: [], + loops: {}, + parallels: {}, + }) + mockSaveTriggerWebhooksForDeploy.mockResolvedValue({ success: true, warnings: [] }) + mockRemoveMcpToolsForWorkflow.mockResolvedValue(undefined) + mockSyncMcpToolsForWorkflow.mockResolvedValue(undefined) + mockValidatePublicApiAllowed.mockResolvedValue(undefined) + }) + + it('allows API-key auth for deploy using hybrid auth userId', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + mockDeployWorkflow.mockResolvedValue({ + success: true, + deployedAt: '2024-01-01T00:00:00Z', + deploymentVersionId: 'dep-1', + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deploy', { + method: 'POST', + headers: { 'x-api-key': 'test-key' }, + }) + const response = await POST(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.isDeployed).toBe(true) + expect(mockDeployWorkflow).toHaveBeenCalledWith({ + workflowId: 'wf-1', + deployedBy: 'api-user', + workflowName: 'Test Workflow', + }) + expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled() + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'api-user', + actorName: 'API Key Actor', + actorEmail: 'api@example.com', + }) + ) + }) + + it('returns success when MCP sync throws after deploy succeeds', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + mockDeployWorkflow.mockResolvedValue({ + success: true, + deployedAt: '2024-01-01T00:00:00Z', + deploymentVersionId: 'dep-1', + }) + mockSyncMcpToolsForWorkflow.mockRejectedValue(new Error('MCP sync failed')) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deploy', { + method: 'POST', + headers: { 'x-api-key': 'test-key' }, + }) + const response = await POST(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.isDeployed).toBe(true) + expect(mockRecordAudit).toHaveBeenCalled() + }) + + it('allows API-key auth for undeploy using hybrid auth userId', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + mockUndeployWorkflow.mockResolvedValue({ success: true }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deploy', { + method: 'DELETE', + headers: { 'x-api-key': 'test-key' }, + }) + const response = await DELETE(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.isDeployed).toBe(false) + expect(mockUndeployWorkflow).toHaveBeenCalledWith({ workflowId: 'wf-1' }) + expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled() + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'api-user', + actorName: 'API Key Actor', + actorEmail: 'api@example.com', + }) + ) + }) + + it('returns success when webhook cleanup throws after undeploy succeeds', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + mockUndeployWorkflow.mockResolvedValue({ success: true }) + mockCleanupWebhooksForWorkflow.mockRejectedValue(new Error('cleanup failed')) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deploy', { + method: 'DELETE', + headers: { 'x-api-key': 'test-key' }, + }) + const response = await DELETE(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.isDeployed).toBe(false) + expect(mockRemoveMcpToolsForWorkflow).toHaveBeenCalledWith('wf-1', 'req-123') + expect(mockRecordAudit).toHaveBeenCalled() + }) + + it('checks public API restrictions against hybrid auth userId', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { success: true, userId: 'api-user', authType: 'api_key' }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deploy', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-api-key': 'test-key' }, + body: JSON.stringify({ isPublicApi: true }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + expect(mockValidatePublicApiAllowed).toHaveBeenCalledWith('api-user') + }) + + it('returns 400 for malformed JSON bodies without permission checks or updates', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { success: true, userId: 'api-user', authType: 'api_key' }, + }) + + const req = { + json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected end of JSON input')), + } as unknown as NextRequest + + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Invalid JSON body', + code: 'INVALID_JSON_BODY', + }) + expect(mockValidatePublicApiAllowed).not.toHaveBeenCalled() + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('returns 400 when isPublicApi is not a boolean', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { success: true, userId: 'api-user', authType: 'api_key' }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deploy', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-api-key': 'test-key' }, + body: JSON.stringify({ isPublicApi: 'yes' }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(400) + expect(mockValidatePublicApiAllowed).not.toHaveBeenCalled() + expect(mockDbUpdate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index fe84eda30c1..604f9d0d5f3 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -2,6 +2,7 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { getAuditActorMetadata } from '@/lib/audit/actor-metadata' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' @@ -21,7 +22,7 @@ import { createSchedulesForDeploy, validateWorkflowSchedules, } from '@/lib/workflows/schedules' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { checkNeedsRedeployment, createErrorResponse, @@ -38,15 +39,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { id } = await params try { - const { error, workflow: workflowData } = await validateWorkflowPermissions( - id, - requestId, - 'read' - ) - if (error) { - return createErrorResponse(error.message, error.status) + const access = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'read', + }) + if (access.error) { + return createErrorResponse(access.error.message, access.error.status) } + const workflowData = access.workflow + if (!workflowData.isDeployed) { logger.info(`[${requestId}] Workflow is not deployed: ${id}`) return createSuccessResponse({ @@ -82,16 +84,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { id } = await params try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) + const access = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'admin', + }) + if (access.error) { + return createErrorResponse(access.error.message, access.error.status) } - const actorUserId: string | null = session?.user?.id ?? null + const auth = access.auth + const workflowData = access.workflow + + const actorUserId: string | null = auth?.userId ?? null if (!actorUserId) { logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`) return createErrorResponse('Unable to determine deploying user', 400) @@ -238,14 +242,21 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) - // Sync MCP tools with the latest parameter schema - await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' }) + try { + await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' }) + } catch (syncError) { + logger.error(`[${requestId}] Failed to sync MCP tools after deploy for workflow ${id}`, { + error: syncError, + }) + } + + const { actorName, actorEmail } = getAuditActorMetadata(auth) recordAudit({ workspaceId: workflowData?.workspaceId || null, actorId: actorUserId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, + actorName, + actorEmail, action: AuditAction.WORKFLOW_DEPLOYED, resourceType: AuditResourceType.WORKFLOW, resourceId: id, @@ -289,12 +300,23 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const { id } = await params try { - const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) + const access = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'admin', + }) + if (access.error) { + return createErrorResponse(access.error.message, access.error.status) + } + + const auth = access.auth + + let body: { isPublicApi?: unknown } + try { + body = await request.json() + } catch { + return createErrorResponse('Invalid JSON body', 400) } - const body = await request.json() const { isPublicApi } = body if (typeof isPublicApi !== 'boolean') { @@ -305,8 +327,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const { validatePublicApiAllowed, PublicApiNotAllowedError } = await import( '@/ee/access-control/utils/permission-check' ) + const actorUserId = auth?.userId try { - await validatePublicApiAllowed(session?.user?.id) + await validatePublicApiAllowed(actorUserId) } catch (err) { if (err instanceof PublicApiNotAllowedError) { return createErrorResponse('Public API access is disabled', 403) @@ -335,13 +358,20 @@ export async function DELETE( const { id } = await params try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) + const access = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'admin', + }) + if (access.error) { + return createErrorResponse(access.error.message, access.error.status) + } + + const auth = access.auth + const workflowData = access.workflow + + const actorUserId = auth?.userId ?? null + if (!actorUserId) { + return createErrorResponse('Unable to determine undeploying user', 400) } const result = await undeployWorkflow({ workflowId: id }) @@ -349,9 +379,21 @@ export async function DELETE( return createErrorResponse(result.error || 'Failed to undeploy workflow', 500) } - await cleanupWebhooksForWorkflow(id, workflowData as Record, requestId) + try { + await cleanupWebhooksForWorkflow(id, workflowData as Record, requestId) + } catch (cleanupError) { + logger.error(`[${requestId}] Failed to cleanup webhooks after undeploy for workflow ${id}`, { + error: cleanupError, + }) + } - await removeMcpToolsForWorkflow(id, requestId) + try { + await removeMcpToolsForWorkflow(id, requestId) + } catch (cleanupError) { + logger.error(`[${requestId}] Failed to cleanup MCP tools after undeploy for workflow ${id}`, { + error: cleanupError, + }) + } logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`) @@ -362,11 +404,13 @@ export async function DELETE( // Silently fail } + const { actorName, actorEmail } = getAuditActorMetadata(auth) + recordAudit({ workspaceId: workflowData?.workspaceId || null, - actorId: session!.user.id, - actorName: session?.user?.name, - actorEmail: session?.user?.email, + actorId: actorUserId, + actorName, + actorEmail, action: AuditAction.WORKFLOW_UNDEPLOYED, resourceType: AuditResourceType.WORKFLOW, resourceId: id, diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts new file mode 100644 index 00000000000..c996360738f --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockValidateWorkflowAccess = vi.fn() +const mockVerifyInternalToken = vi.fn() +const mockLoadDeployedWorkflowState = vi.fn() + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ warn: vi.fn(), error: vi.fn() }), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args), +})) + +vi.mock('@/lib/auth/internal', () => ({ + verifyInternalToken: (...args: unknown[]) => mockVerifyInternalToken(...args), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'req-123', +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadDeployedWorkflowState: (...args: unknown[]) => mockLoadDeployedWorkflowState(...args), +})) + +import { GET } from '@/app/api/workflows/[id]/deployed/route' + +describe('Workflow deployed-state route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyInternalToken.mockResolvedValue({ valid: false }) + mockLoadDeployedWorkflowState.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: [], + }) + }) + + it('uses hybrid workflow access when request is not internal bearer auth', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ workflow: { id: 'wf-1' } }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployed', { + headers: { 'x-api-key': 'test-key' }, + }) + const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', { + requireDeployment: false, + action: 'read', + }) + }) + + it('returns null deployedState for absence-like loader failures', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ workflow: { id: 'wf-1' } }) + mockLoadDeployedWorkflowState.mockRejectedValue( + new Error('Workflow wf-1 has no active deployment') + ) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployed', { + headers: { 'x-api-key': 'test-key' }, + }) + const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ deployedState: null }) + expect(response.headers.get('Cache-Control')).toBe( + 'no-store, no-cache, must-revalidate, max-age=0' + ) + }) + + it('returns null deployedState when loader reports workflow has no workspace', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ workflow: { id: 'wf-1' } }) + mockLoadDeployedWorkflowState.mockRejectedValue(new Error('Workflow wf-1 has no workspace')) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployed', { + headers: { 'x-api-key': 'test-key' }, + }) + const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ deployedState: null }) + expect(response.headers.get('Cache-Control')).toBe( + 'no-store, no-cache, must-revalidate, max-age=0' + ) + }) + + it('returns 500 when a non-loader error has the same absence-like message', async () => { + mockValidateWorkflowAccess.mockRejectedValue(new Error('Workflow wf-1 has no workspace')) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployed', { + headers: { 'x-api-key': 'test-key' }, + }) + const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(500) + expect(response.headers.get('Cache-Control')).toBe( + 'no-store, no-cache, must-revalidate, max-age=0' + ) + }) + + it('returns 500 when a non-loader error says workflow has no active deployment', async () => { + mockValidateWorkflowAccess.mockRejectedValue( + new Error('Workflow wf-1 has no active deployment') + ) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployed', { + headers: { 'x-api-key': 'test-key' }, + }) + const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(500) + expect(response.headers.get('Cache-Control')).toBe( + 'no-store, no-cache, must-revalidate, max-age=0' + ) + }) + + it('returns 500 when deployed-state loading throws', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ workflow: { id: 'wf-1' } }) + mockLoadDeployedWorkflowState.mockRejectedValue(new Error('load failed')) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployed', { + headers: { 'x-api-key': 'test-key' }, + }) + const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(500) + expect(response.headers.get('Cache-Control')).toBe( + 'no-store, no-cache, must-revalidate, max-age=0' + ) + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index 347e77eacb9..690146c7f1e 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -3,7 +3,7 @@ import type { NextRequest, NextResponse } from 'next/server' import { verifyInternalToken } from '@/lib/auth/internal' import { generateRequestId } from '@/lib/core/utils/request' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeployedStateAPI') @@ -16,6 +16,17 @@ function addNoCacheHeaders(response: NextResponse): NextResponse { return response } +function isAbsentDeploymentStateError(error: unknown, workflowId: string): boolean { + if (!(error instanceof Error)) { + return false + } + + return ( + error.message === `Workflow ${workflowId} has no active deployment` || + error.message === `Workflow ${workflowId} has no workspace` + ) +} + export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() const { id } = await params @@ -31,26 +42,33 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } if (!isInternalCall) { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - const response = createErrorResponse(error.message, error.status) + const validation = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'read', + }) + if (validation.error) { + const response = createErrorResponse(validation.error.message, validation.error.status) return addNoCacheHeaders(response) } } - let deployedState = null + let data try { - const data = await loadDeployedWorkflowState(id) - deployedState = { - blocks: data.blocks, - edges: data.edges, - loops: data.loops, - parallels: data.parallels, - variables: data.variables, - } + data = await loadDeployedWorkflowState(id) } catch (error) { - logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error }) - deployedState = null + if (isAbsentDeploymentStateError(error, id)) { + const response = createSuccessResponse({ deployedState: null }) + return addNoCacheHeaders(response) + } + + throw error + } + const deployedState = { + blocks: data.blocks, + edges: data.edges, + loops: data.loops, + parallels: data.parallels, + variables: data.variables, } const response = createSuccessResponse({ deployedState }) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts new file mode 100644 index 00000000000..f7978e78792 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts @@ -0,0 +1,240 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDbFrom, + mockDbLimit, + mockDbSelect, + mockDbWhere, + mockRecordAudit, + mockRestoreWorkflowDraftState, + mockSyncMcpToolsForWorkflow, + mockValidateWorkflowAccess, +} = vi.hoisted(() => ({ + mockDbFrom: vi.fn(), + mockDbLimit: vi.fn(), + mockDbSelect: vi.fn(), + mockDbWhere: vi.fn(), + mockRecordAudit: vi.fn(), + mockRestoreWorkflowDraftState: vi.fn(), + mockSyncMcpToolsForWorkflow: vi.fn(), + mockValidateWorkflowAccess: vi.fn(), +})) +const mockFetch = vi.fn() + +vi.stubGlobal('fetch', mockFetch) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'req-123', +})) + +vi.mock('@/lib/core/config/env', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + env: { + ...actual.env, + INTERNAL_API_SECRET: 'internal-secret', + SOCKET_SERVER_URL: 'http://localhost:3002', + }, + } +}) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + }, + workflowDeploymentVersion: { + state: 'state', + workflowId: 'workflowId', + isActive: 'isActive', + version: 'version', + }, +})) + +vi.mock('drizzle-orm', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + and: vi.fn(), + eq: vi.fn(), + } +}) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + restoreWorkflowDraftState: (...args: unknown[]) => mockRestoreWorkflowDraftState(...args), +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + syncMcpToolsForWorkflow: (...args: unknown[]) => mockSyncMcpToolsForWorkflow(...args), +})) + +vi.mock('@/lib/audit/log', () => ({ + AuditAction: { WORKFLOW_DEPLOYMENT_REVERTED: 'WORKFLOW_DEPLOYMENT_REVERTED' }, + AuditResourceType: { WORKFLOW: 'WORKFLOW' }, + recordAudit: (...args: unknown[]) => mockRecordAudit(...args), +})) + +import { POST } from '@/app/api/workflows/[id]/deployments/[version]/revert/route' + +describe('Workflow deployment version revert route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbSelect.mockReturnValue({ from: mockDbFrom }) + mockDbFrom.mockReturnValue({ where: mockDbWhere }) + mockDbWhere.mockReturnValue({ limit: mockDbLimit }) + mockDbLimit.mockResolvedValue([ + { + state: { + blocks: { 'block-1': { id: 'block-1', type: 'start_trigger', name: 'Start' } }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + ]) + mockRestoreWorkflowDraftState.mockResolvedValue({ success: true }) + mockFetch.mockResolvedValue({ ok: true }) + }) + + it('allows API-key auth for revert using hybrid auth userId', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3/revert', { + method: 'POST', + headers: { 'x-api-key': 'test-key' }, + }) + const response = await POST(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(response.status).toBe(200) + expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', { + requireDeployment: false, + action: 'admin', + }) + expect(mockRestoreWorkflowDraftState).toHaveBeenCalled() + expect(mockSyncMcpToolsForWorkflow).toHaveBeenCalled() + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'api-user', + actorName: 'API Key Actor', + actorEmail: 'api@example.com', + }) + ) + }) + + it('restores variables from the deployment snapshot', async () => { + mockDbLimit.mockResolvedValue([ + { + state: { + blocks: { 'block-1': { id: 'block-1', type: 'start_trigger', name: 'Start' } }, + edges: [], + loops: {}, + parallels: {}, + variables: { + var1: { id: 'var1', name: 'API Token', type: 'string', value: 'secret' }, + }, + }, + }, + ]) + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3/revert', { + method: 'POST', + headers: { 'x-api-key': 'test-key' }, + }) + + await POST(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(mockRestoreWorkflowDraftState).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + variables: { + var1: { id: 'var1', name: 'API Token', type: 'string', value: 'secret' }, + }, + }) + ) + }) + + it('defaults variables safely when missing from the deployment snapshot', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3/revert', { + method: 'POST', + headers: { 'x-api-key': 'test-key' }, + }) + + await POST(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(mockRestoreWorkflowDraftState).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf-1', + variables: {}, + }) + ) + }) + + it('returns success when MCP sync throws after revert succeeds', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + mockSyncMcpToolsForWorkflow.mockRejectedValue(new Error('MCP sync failed')) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3/revert', { + method: 'POST', + headers: { 'x-api-key': 'test-key' }, + }) + const response = await POST(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.message).toBe('Reverted to deployment version') + expect(mockRecordAudit).toHaveBeenCalled() + }) +}) 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 d3762c9181f..37bc7d793ef 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,12 +1,14 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { getAuditActorMetadata } from '@/lib/audit/actor-metadata' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' +import { restoreWorkflowDraftState } from '@/lib/workflows/persistence/utils' +import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('RevertToDeploymentVersionAPI') @@ -22,13 +24,23 @@ export async function POST( const { id, version } = await params try { - const { - error, - session, - workflow: workflowRecord, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) + const access = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'admin', + }) + if (access.error) { + return createErrorResponse(access.error.message, access.error.status) + } + + const auth = access.auth + const workflowRecord = access.workflow + + const actorUserId = auth?.userId + if (!actorUserId) { + logger.warn( + `[${requestId}] Unable to resolve actor user for workflow deployment revert: ${id}` + ) + return createErrorResponse('Unable to determine reverting user', 400) } const versionSelector = version === 'active' ? null : Number(version) @@ -72,23 +84,35 @@ export async function POST( return createErrorResponse('Invalid deployed state structure', 500) } - const saveResult = await saveWorkflowToNormalizedTables(id, { - blocks: deployedState.blocks, - edges: deployedState.edges, - loops: deployedState.loops || {}, - parallels: deployedState.parallels || {}, - lastSaved: Date.now(), - deploymentStatuses: deployedState.deploymentStatuses || {}, + const restoredAt = new Date() + const saveResult = await restoreWorkflowDraftState({ + workflowId: id, + state: { + blocks: deployedState.blocks, + edges: deployedState.edges, + loops: deployedState.loops || {}, + parallels: deployedState.parallels || {}, + }, + variables: deployedState.variables || {}, + restoredAt, }) if (!saveResult.success) { return createErrorResponse(saveResult.error || 'Failed to save deployed state', 500) } - await db - .update(workflow) - .set({ lastSynced: new Date(), updatedAt: new Date() }) - .where(eq(workflow.id, id)) + try { + await syncMcpToolsForWorkflow({ + workflowId: id, + requestId, + state: deployedState, + context: 'revert', + }) + } catch (syncError) { + logger.error(`[${requestId}] Failed to sync MCP tools after revert for workflow ${id}`, { + error: syncError, + }) + } try { const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' @@ -104,14 +128,16 @@ export async function POST( logger.error('Error sending workflow reverted event to socket server', e) } + const { actorName, actorEmail } = getAuditActorMetadata(auth) + recordAudit({ workspaceId: workflowRecord?.workspaceId ?? null, - actorId: session!.user.id, + actorId: actorUserId, action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, resourceType: AuditResourceType.WORKFLOW, resourceId: id, - actorName: session!.user.name ?? undefined, - actorEmail: session!.user.email ?? undefined, + actorName, + actorEmail, resourceName: workflowRecord?.name ?? undefined, description: `Reverted workflow to deployment version ${version}`, request, @@ -119,7 +145,7 @@ export async function POST( return createSuccessResponse({ message: 'Reverted to deployment version', - lastSaved: Date.now(), + lastSaved: restoredAt.getTime(), }) } catch (error: any) { logger.error('Error reverting to deployment version', error) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts new file mode 100644 index 00000000000..93ee4d43cff --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts @@ -0,0 +1,361 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockActivateWorkflowVersion, + mockAuthorizeWorkflowByWorkspacePermission, + mockCreateSchedulesForDeploy, + mockDbFrom, + mockDbLimit, + mockDbReturning, + mockDbSelect, + mockDbSet, + mockDbUpdate, + mockDbWhere, + mockDbWhereUpdate, + mockRecordAudit, + mockSaveTriggerWebhooksForDeploy, + mockSyncMcpToolsForWorkflow, + mockValidateWorkflowAccess, +} = vi.hoisted(() => ({ + mockActivateWorkflowVersion: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + mockCreateSchedulesForDeploy: vi.fn(), + mockDbFrom: vi.fn(), + mockDbLimit: vi.fn(), + mockDbReturning: vi.fn(), + mockDbSelect: vi.fn(), + mockDbSet: vi.fn(), + mockDbUpdate: vi.fn(), + mockDbWhere: vi.fn(), + mockDbWhereUpdate: vi.fn(), + mockRecordAudit: vi.fn(), + mockSaveTriggerWebhooksForDeploy: vi.fn(), + mockSyncMcpToolsForWorkflow: vi.fn(), + mockValidateWorkflowAccess: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'req-123', +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + update: mockDbUpdate, + }, + workflowDeploymentVersion: { + id: 'id', + state: 'state', + workflowId: 'workflowId', + version: 'version', + isActive: 'isActive', + name: 'name', + description: 'description', + }, +})) + +vi.mock('drizzle-orm', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + and: vi.fn(), + eq: vi.fn(), + } +}) + +vi.mock('@/lib/webhooks/deploy', () => ({ + restorePreviousVersionWebhooks: vi.fn(), + saveTriggerWebhooksForDeploy: (...args: unknown[]) => mockSaveTriggerWebhooksForDeploy(...args), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + activateWorkflowVersion: (...args: unknown[]) => mockActivateWorkflowVersion(...args), +})) + +vi.mock('@/lib/workflows/schedules', () => ({ + cleanupDeploymentVersion: vi.fn(), + createSchedulesForDeploy: (...args: unknown[]) => mockCreateSchedulesForDeploy(...args), + validateWorkflowSchedules: vi.fn(() => ({ isValid: true })), +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + syncMcpToolsForWorkflow: (...args: unknown[]) => mockSyncMcpToolsForWorkflow(...args), +})) + +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: (...args: unknown[]) => + mockAuthorizeWorkflowByWorkspacePermission(...args), +})) + +vi.mock('@/lib/audit/log', () => ({ + AuditAction: { WORKFLOW_DEPLOYMENT_ACTIVATED: 'WORKFLOW_DEPLOYMENT_ACTIVATED' }, + AuditResourceType: { WORKFLOW: 'WORKFLOW' }, + recordAudit: (...args: unknown[]) => mockRecordAudit(...args), +})) + +import { PATCH } from '@/app/api/workflows/[id]/deployments/[version]/route' + +describe('Workflow deployment version route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbSelect.mockReturnValue({ from: mockDbFrom }) + mockDbFrom.mockReturnValue({ where: mockDbWhere }) + mockDbWhere.mockReturnValue({ limit: mockDbLimit }) + mockDbLimit + .mockResolvedValueOnce([ + { + id: 'dep-3', + state: { + blocks: { 'block-1': { id: 'block-1', type: 'start_trigger', name: 'Start' } }, + }, + }, + ]) + .mockResolvedValueOnce([{ id: 'dep-2' }]) + mockDbUpdate.mockReturnValue({ set: mockDbSet }) + mockDbSet.mockReturnValue({ where: mockDbWhereUpdate }) + mockDbWhereUpdate.mockReturnValue({ returning: mockDbReturning }) + mockDbReturning.mockResolvedValue([]) + mockSaveTriggerWebhooksForDeploy.mockResolvedValue({ success: true, warnings: [] }) + mockCreateSchedulesForDeploy.mockResolvedValue({ success: true }) + mockActivateWorkflowVersion.mockResolvedValue({ + success: true, + deployedAt: '2024-01-17T12:00:00.000Z', + }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, status: 200 }) + }) + + it('uses write permission for metadata-only patch updates', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { success: true, userId: 'user-1', authType: 'session' }, + }) + mockDbReturning.mockResolvedValue([{ id: 'dep-3', name: 'Renamed', description: 'Updated' }]) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Renamed', description: 'Updated' }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(response.status).toBe(200) + expect(mockValidateWorkflowAccess).toHaveBeenCalledTimes(1) + expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', { + requireDeployment: false, + action: 'write', + }) + expect(mockDbUpdate).toHaveBeenCalledTimes(1) + expect(mockActivateWorkflowVersion).not.toHaveBeenCalled() + }) + + it('allows API-key auth for activation using hybrid auth userId', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-api-key': 'test-key' }, + body: JSON.stringify({ isActive: true }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(response.status).toBe(200) + expect(mockValidateWorkflowAccess).toHaveBeenNthCalledWith(1, req, 'wf-1', { + requireDeployment: false, + action: 'write', + }) + expect(mockValidateWorkflowAccess).toHaveBeenCalledTimes(1) + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: 'wf-1', + userId: 'api-user', + action: 'admin', + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + }) + expect(mockSaveTriggerWebhooksForDeploy).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'api-user' }) + ) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'api-user', + actorName: 'API Key Actor', + actorEmail: 'api@example.com', + }) + ) + }) + + it('returns success when MCP sync throws after activation succeeds', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: 'api_key', + }, + }) + mockSyncMcpToolsForWorkflow.mockRejectedValue(new Error('MCP sync failed')) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-api-key': 'test-key' }, + body: JSON.stringify({ isActive: true }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.success).toBe(true) + expect(mockRecordAudit).toHaveBeenCalled() + }) + + it('returns write auth failure before parsing or updating metadata', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + error: { message: 'Write permission required', status: 403 }, + }) + + const jsonSpy = vi.fn().mockResolvedValue({ name: 'Renamed' }) + const req = { + json: jsonSpy, + } as unknown as NextRequest + + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(response.status).toBe(403) + expect(jsonSpy).not.toHaveBeenCalled() + expect(mockDbUpdate).not.toHaveBeenCalled() + expect(mockActivateWorkflowVersion).not.toHaveBeenCalled() + }) + + it('returns admin auth failure before activation side effects', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { success: true, userId: 'user-1', authType: 'session' }, + }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Admin permission required', + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ isActive: true }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(response.status).toBe(403) + expect(mockValidateWorkflowAccess).toHaveBeenCalledTimes(1) + expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', { + requireDeployment: false, + action: 'write', + }) + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: 'wf-1', + userId: 'user-1', + action: 'admin', + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + }) + expect(mockDbSelect).not.toHaveBeenCalled() + expect(mockSaveTriggerWebhooksForDeploy).not.toHaveBeenCalled() + expect(mockCreateSchedulesForDeploy).not.toHaveBeenCalled() + expect(mockActivateWorkflowVersion).not.toHaveBeenCalled() + }) + + it('returns 400 when activation auth has no userId', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { success: true, userId: null, authType: 'session' }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ isActive: true }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(response.status).toBe(400) + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(mockSaveTriggerWebhooksForDeploy).not.toHaveBeenCalled() + expect(mockCreateSchedulesForDeploy).not.toHaveBeenCalled() + expect(mockActivateWorkflowVersion).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('returns 400 for malformed JSON bodies without side effects', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { success: true, userId: 'user-1', authType: 'session' }, + }) + + const req = { + json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected end of JSON input')), + } as unknown as NextRequest + + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Invalid JSON body', + code: 'INVALID_JSON_BODY', + }) + expect(mockDbSelect).not.toHaveBeenCalled() + expect(mockDbUpdate).not.toHaveBeenCalled() + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(mockSaveTriggerWebhooksForDeploy).not.toHaveBeenCalled() + expect(mockCreateSchedulesForDeploy).not.toHaveBeenCalled() + expect(mockActivateWorkflowVersion).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('returns invalid version before parsing the request body', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' }, + auth: { success: true, userId: 'user-1', authType: 'session' }, + }) + + const jsonSpy = vi.fn().mockResolvedValue({ isActive: true }) + const req = { + json: jsonSpy, + } as unknown as NextRequest + + const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1', version: 'NaN' }) }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Invalid version', + code: 'INVALID_VERSION', + }) + expect(jsonSpy).not.toHaveBeenCalled() + expect(mockDbSelect).not.toHaveBeenCalled() + expect(mockDbUpdate).not.toHaveBeenCalled() + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(mockSaveTriggerWebhooksForDeploy).not.toHaveBeenCalled() + expect(mockCreateSchedulesForDeploy).not.toHaveBeenCalled() + expect(mockActivateWorkflowVersion).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 56802840e95..c866f7d23c0 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' +import { getAuditActorMetadata } from '@/lib/audit/actor-metadata' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' @@ -13,7 +14,8 @@ import { createSchedulesForDeploy, validateWorkflowSchedules, } from '@/lib/workflows/schedules' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' +import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -53,9 +55,12 @@ export async function GET( const { id, version } = await params try { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - return createErrorResponse(error.message, error.status) + const access = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'read', + }) + if (access.error) { + return createErrorResponse(access.error.message, access.error.status) } const versionNum = Number(version) @@ -96,7 +101,26 @@ export async function PATCH( const { id, version } = await params try { - const body = await request.json() + const access = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'write', + }) + if (access.error) { + return createErrorResponse(access.error.message, access.error.status) + } + + const versionNum = Number(version) + if (!Number.isFinite(versionNum)) { + return createErrorResponse('Invalid version', 400) + } + + let body: unknown + try { + body = await request.json() + } catch { + return createErrorResponse('Invalid JSON body', 400) + } + const validation = patchBodySchema.safeParse(body) if (!validation.success) { @@ -104,31 +128,26 @@ export async function PATCH( } const { name, description, isActive } = validation.data - - // Activation requires admin permission, other updates require write - const requiredPermission = isActive ? 'admin' : 'write' - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, requiredPermission) - if (error) { - return createErrorResponse(error.message, error.status) - } - - const versionNum = Number(version) - if (!Number.isFinite(versionNum)) { - return createErrorResponse('Invalid version', 400) - } + const auth = access.auth + const workflowData = access.workflow // Handle activation if (isActive) { - const actorUserId = session?.user?.id + const actorUserId = auth?.userId if (!actorUserId) { - logger.warn(`[${requestId}] Unable to resolve actor user for deployment activation: ${id}`) return createErrorResponse('Unable to determine activating user', 400) } + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: id, + userId: actorUserId, + action: 'admin', + workflow: workflowData, + }) + if (!authorization.allowed) { + return createErrorResponse(authorization.message || 'Access denied', authorization.status) + } + const [versionRow] = await db .select({ id: workflowDeploymentVersion.id, @@ -255,12 +274,19 @@ export async function PATCH( } } - await syncMcpToolsForWorkflow({ - workflowId: id, - requestId, - state: versionRow.state, - context: 'activate', - }) + try { + await syncMcpToolsForWorkflow({ + workflowId: id, + requestId, + state: versionRow.state, + context: 'activate', + }) + } catch (syncError) { + logger.error( + `[${requestId}] Failed to sync MCP tools after activation for workflow ${id}`, + syncError + ) + } // Apply name/description updates if provided alongside activation let updatedName: string | null | undefined @@ -298,11 +324,13 @@ export async function PATCH( } } + const { actorName, actorEmail } = getAuditActorMetadata(auth) + recordAudit({ workspaceId: workflowData?.workspaceId, actorId: actorUserId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, + actorName, + actorEmail, action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED, resourceType: AuditResourceType.WORKFLOW, resourceId: id, diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.test.ts b/apps/sim/app/api/workflows/[id]/deployments/route.test.ts new file mode 100644 index 00000000000..2b760dc7eb5 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/deployments/route.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDbFrom, + mockDbLeftJoin, + mockDbOrderBy, + mockDbSelect, + mockDbWhere, + mockValidateWorkflowAccess, +} = vi.hoisted(() => ({ + mockDbFrom: vi.fn(), + mockDbLeftJoin: vi.fn(), + mockDbOrderBy: vi.fn(), + mockDbSelect: vi.fn(), + mockDbWhere: vi.fn(), + mockValidateWorkflowAccess: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'req-123', +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockDbSelect }, + user: { name: 'name', id: 'id' }, + workflowDeploymentVersion: { + id: 'id', + version: 'version', + name: 'name', + description: 'description', + isActive: 'isActive', + createdAt: 'createdAt', + createdBy: 'createdBy', + workflowId: 'workflowId', + }, +})) + +vi.mock('drizzle-orm', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + desc: vi.fn(), + eq: vi.fn(), + } +}) + +import { GET } from '@/app/api/workflows/[id]/deployments/route' + +describe('Workflow deployments list route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbSelect.mockReturnValue({ from: mockDbFrom }) + mockDbFrom.mockReturnValue({ leftJoin: mockDbLeftJoin }) + mockDbLeftJoin.mockReturnValue({ where: mockDbWhere }) + mockDbWhere.mockReturnValue({ orderBy: mockDbOrderBy }) + mockDbOrderBy.mockResolvedValue([ + { + id: 'dep-1', + version: 3, + name: 'Current active deployment', + description: 'Latest deployed state', + isActive: true, + createdAt: '2024-01-16T12:00:00.000Z', + createdBy: 'admin-api', + deployedBy: null, + }, + ]) + }) + + it('uses hybrid workflow access for read auth', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ workflow: { id: 'wf-1' } }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments', { + headers: { 'x-api-key': 'test-key' }, + }) + const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', { + requireDeployment: false, + action: 'read', + }) + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index ac2e7e1015f..f4980b07c7a 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeploymentsListAPI') @@ -16,9 +16,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { id } = await params try { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - return createErrorResponse(error.message, error.status) + const access = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'read', + }) + if (access.error) { + return createErrorResponse(access.error.message, access.error.status) } const rawVersions = await db diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 7d6c599dcfd..a33c7f282ff 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -6,24 +6,38 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { + mockAuthType, mockCheckHybridAuth, mockAuthorizeWorkflowByWorkspacePermission, mockPreprocessExecution, + mockGetJobQueue, + mockShouldExecuteInline, mockEnqueue, + mockLoggerInfo, + mockLoggerWarn, + mockLoggerError, + mockLoggerDebug, } = vi.hoisted(() => ({ + mockAuthType: { + SESSION: 'session', + API_KEY: 'api_key', + INTERNAL_JWT: 'internal_jwt', + }, mockCheckHybridAuth: vi.fn(), mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), mockPreprocessExecution: vi.fn(), + mockGetJobQueue: vi.fn(), + mockShouldExecuteInline: vi.fn(), mockEnqueue: vi.fn().mockResolvedValue('job-123'), + mockLoggerInfo: vi.fn(), + mockLoggerWarn: vi.fn(), + mockLoggerError: vi.fn(), + mockLoggerDebug: vi.fn(), })) vi.mock('@/lib/auth/hybrid', () => ({ + AuthType: mockAuthType, checkHybridAuth: mockCheckHybridAuth, - AuthType: { - SESSION: 'session', - API_KEY: 'api_key', - INTERNAL_JWT: 'internal_jwt', - }, })) vi.mock('@/lib/workflows/utils', () => ({ @@ -37,13 +51,8 @@ vi.mock('@/lib/execution/preprocessing', () => ({ })) vi.mock('@/lib/core/async-jobs', () => ({ - getJobQueue: vi.fn().mockResolvedValue({ - enqueue: mockEnqueue, - startJob: vi.fn(), - completeJob: vi.fn(), - markJobFailed: vi.fn(), - }), - shouldExecuteInline: vi.fn().mockReturnValue(false), + getJobQueue: mockGetJobQueue, + shouldExecuteInline: mockShouldExecuteInline, })) vi.mock('@/lib/core/utils/request', () => ({ @@ -71,10 +80,10 @@ vi.mock('@/background/workflow-execution', () => ({ vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), + info: mockLoggerInfo, + warn: mockLoggerWarn, + error: mockLoggerError, + debug: mockLoggerDebug, }), })) @@ -85,33 +94,93 @@ vi.mock('uuid', () => ({ import { POST } from './route' +async function readJsonBody(response: Response) { + const text = await response.text() + + return { + text, + json: text ? JSON.parse(text) : null, + } +} + +function expectCheckpointOrder(checkpoints: string[], expected: string[]) { + expect(checkpoints).toEqual(expected) +} + describe('workflow execute async route', () => { + const asyncCheckpoints: string[] = [] + beforeEach(() => { vi.clearAllMocks() - mockCheckHybridAuth.mockResolvedValue({ - success: true, - userId: 'session-user-1', - authType: 'session', + asyncCheckpoints.length = 0 + + mockCheckHybridAuth.mockReset() + mockAuthorizeWorkflowByWorkspacePermission.mockReset() + mockPreprocessExecution.mockReset() + mockGetJobQueue.mockReset() + mockShouldExecuteInline.mockReset() + mockEnqueue.mockReset() + + mockEnqueue.mockResolvedValue('job-123') + mockGetJobQueue.mockResolvedValue({ + enqueue: mockEnqueue, + startJob: vi.fn(), + completeJob: vi.fn(), + markJobFailed: vi.fn(), + }) + mockShouldExecuteInline.mockImplementation(() => { + asyncCheckpoints.push('shouldExecuteInline') + return false }) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - workflow: { - id: 'workflow-1', - userId: 'owner-1', - workspaceId: 'workspace-1', - }, + mockCheckHybridAuth.mockImplementation(async () => { + asyncCheckpoints.push('authorization') + return { + success: true, + userId: 'session-user-1', + authType: mockAuthType.SESSION, + } }) - mockPreprocessExecution.mockResolvedValue({ - success: true, - actorUserId: 'actor-1', - workflowRecord: { - id: 'workflow-1', - userId: 'owner-1', - workspaceId: 'workspace-1', - }, + mockAuthorizeWorkflowByWorkspacePermission.mockImplementation(async () => { + asyncCheckpoints.push('authorizeWorkflowByWorkspacePermission') + return { + allowed: true, + workflow: { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', + }, + } + }) + + mockPreprocessExecution.mockImplementation(async () => { + asyncCheckpoints.push('preprocessing') + return { + success: true, + actorUserId: 'actor-1', + workflowRecord: { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', + }, + } + }) + + mockGetJobQueue.mockImplementation(async () => { + asyncCheckpoints.push('getJobQueue') + return { + enqueue: mockEnqueue, + startJob: vi.fn(), + completeJob: vi.fn(), + markJobFailed: vi.fn(), + } + }) + + mockEnqueue.mockImplementation(async () => { + asyncCheckpoints.push('enqueue') + return 'job-123' }) }) @@ -127,11 +196,23 @@ describe('workflow execute async route', () => { const params = Promise.resolve({ id: 'workflow-1' }) const response = await POST(req as any, { params }) - const body = await response.json() + const { json: body, text: bodyText } = await readJsonBody(response) - expect(response.status).toBe(202) + expect( + response.status, + `Expected async execute route to return 202, got ${response.status} with body: ${bodyText}` + ).toBe(202) + expectCheckpointOrder(asyncCheckpoints, [ + 'authorization', + 'authorizeWorkflowByWorkspacePermission', + 'preprocessing', + 'getJobQueue', + 'enqueue', + 'shouldExecuteInline', + ]) expect(body.executionId).toBe('execution-123') expect(body.jobId).toBe('job-123') + expect(mockLoggerError).not.toHaveBeenCalled() expect(mockEnqueue).toHaveBeenCalledWith( 'workflow-execution', expect.objectContaining({ @@ -162,4 +243,42 @@ describe('workflow execute async route', () => { } ) }) + + it('returns queue failure payload with checkpoint trail and logs the queue error', async () => { + const queueError = new Error('queue unavailable') + mockEnqueue.mockImplementationOnce(async () => { + asyncCheckpoints.push('enqueue') + throw queueError + }) + + const req = createMockRequest( + 'POST', + { input: { hello: 'world' } }, + { + 'Content-Type': 'application/json', + 'X-Execution-Mode': 'async', + } + ) + + const response = await POST(req as any, { params: Promise.resolve({ id: 'workflow-1' }) }) + const { json: body, text: bodyText } = await readJsonBody(response) + + expectCheckpointOrder(asyncCheckpoints, [ + 'authorization', + 'authorizeWorkflowByWorkspacePermission', + 'preprocessing', + 'getJobQueue', + 'enqueue', + ]) + expect( + response.status, + `Expected async execute route to return 500, got ${response.status} with body: ${bodyText}` + ).toBe(500) + expect(body).toEqual({ error: 'Failed to queue async execution: queue unavailable' }) + expect(mockShouldExecuteInline).not.toHaveBeenCalled() + expect(mockLoggerError).toHaveBeenCalledWith( + '[req-12345678] Failed to queue async execution', + queueError + ) + }) }) diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 2000e5093ee..eb4a28ff3b0 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -24,6 +24,7 @@ const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() const mockArchiveWorkflow = vi.fn() const mockDbUpdate = vi.fn() const mockDbSelect = vi.fn() +const mockValidateWorkflowAccess = vi.fn() /** * Helper to set mock auth state consistently across getSession and hybrid auth. @@ -32,9 +33,17 @@ function mockGetSession(session: { user: { id: string } } | null) { if (session) { mockCheckHybridAuth.mockResolvedValue({ success: true, userId: session.user.id }) mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: session.user.id }) + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, + auth: { success: true, userId: session.user.id, authType: 'session' }, + }) } else { mockCheckHybridAuth.mockResolvedValue({ success: false }) mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + + mockValidateWorkflowAccess.mockResolvedValue({ + error: { message: 'Unauthorized', status: 401 }, + }) } } @@ -63,6 +72,10 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ mockLoadWorkflowFromNormalizedTables(workflowId), })) +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args), +})) + vi.mock('@/lib/workflows/utils', () => ({ getWorkflowById: (workflowId: string) => mockGetWorkflowById(workflowId), authorizeWorkflowByWorkspacePermission: (params: { @@ -99,6 +112,7 @@ describe('Workflow By ID API Route', () => { afterEach(() => { vi.clearAllMocks() + vi.unstubAllGlobals() }) describe('GET /api/workflows/[id]', () => { @@ -130,7 +144,37 @@ describe('Workflow By ID API Route', () => { expect(data.error).toBe('Workflow not found') }) - it.concurrent('should allow access when user has admin workspace permission', async () => { + it('should return 404 for workspace api key targeting a workflow in another workspace', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Foreign Workflow', + workspaceId: 'workspace-b', + } + + mockCheckHybridAuth.mockResolvedValue({ + success: true, + userId: 'api-user', + authType: 'api_key', + apiKeyType: 'workspace', + workspaceId: 'workspace-a', + }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + headers: { 'x-api-key': 'test-key' }, + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await GET(req, { params }) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Workflow not found') + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('should allow access when user has admin workspace permission', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -168,7 +212,7 @@ describe('Workflow By ID API Route', () => { expect(data.data.id).toBe('workflow-123') }) - it.concurrent('should allow access when user has workspace permissions', async () => { + it('should allow access when user has workspace permissions', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'other-user', @@ -206,6 +250,38 @@ describe('Workflow By ID API Route', () => { expect(data.data.id).toBe('workflow-123') }) + it('should keep session access semantics unchanged for readable workflows', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'read', + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await GET(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.data.id).toBe('workflow-123') + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: 'workflow-123', + userId: 'user-123', + action: 'read', + }) + }) + it('should deny access when user has no workspace permissions', async () => { const mockWorkflow = { id: 'workflow-123', @@ -235,7 +311,7 @@ describe('Workflow By ID API Route', () => { expect(data.error).toBe('Unauthorized: Access denied to read this workflow') }) - it.concurrent('should use normalized tables when available', async () => { + it('should use normalized tables when available', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -363,16 +439,24 @@ describe('Workflow By ID API Route', () => { expect(data.success).toBe(true) }) - it('should prevent deletion of the last workflow in workspace', async () => { + it('should allow API-key-backed deletion when workflow access is validated', async () => { const mockWorkflow = { id: 'workflow-123', - userId: 'user-123', + userId: 'other-user', name: 'Test Workflow', workspaceId: 'workspace-456', } - mockGetSession({ user: { id: 'user-123' } }) - + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: mockWorkflow, + auth: { + success: true, + userId: 'api-user-1', + authType: 'api_key', + userName: 'API Key Actor', + userEmail: null, + }, + }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, @@ -380,30 +464,36 @@ describe('Workflow By ID API Route', () => { workflow: mockWorkflow, workspacePermission: 'admin', }) - - // Mock db.select() to return only 1 workflow (the one being deleted) mockDbSelect.mockReturnValue({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), + where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]), }), }) + setupGlobalFetchMock({ ok: true }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'DELETE', + headers: { 'x-api-key': 'test-key' }, }) const params = Promise.resolve({ id: 'workflow-123' }) const response = await DELETE(req, { params }) - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toBe('Cannot delete the only workflow in the workspace') + expect(response.status).toBe(200) + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(auditMock.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'api-user-1', + actorName: 'API Key Actor', + actorEmail: undefined, + }) + ) }) - it.concurrent('should deny deletion for non-admin users', async () => { + it('should prevent deletion of the last workflow in workspace', async () => { const mockWorkflow = { id: 'workflow-123', - userId: 'other-user', + userId: 'user-123', name: 'Test Workflow', workspaceId: 'workspace-456', } @@ -412,11 +502,37 @@ describe('Workflow By ID API Route', () => { mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to admin this workflow', + allowed: true, + status: 200, workflow: mockWorkflow, - workspacePermission: null, + workspacePermission: 'admin', + }) + + // Mock db.select() to return only 1 workflow (the one being deleted) + mockDbSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), + }), + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'DELETE', + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const response = await DELETE(req, { params }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Cannot delete the only workflow in the workspace') + }) + + it('should deny deletion for non-admin users', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + error: { + message: 'Unauthorized: Access denied to admin this workflow', + status: 403, + }, }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { @@ -485,6 +601,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.workflow.name).toBe('Updated Workflow') + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() }) it('should allow users with write permission to update workflow', async () => { @@ -532,24 +649,13 @@ describe('Workflow By ID API Route', () => { }) it('should deny update for users with only read permission', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - const updateData = { name: 'Updated Workflow' } - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to write this workflow', - workflow: mockWorkflow, - workspacePermission: 'read', + mockValidateWorkflowAccess.mockResolvedValue({ + error: { + message: 'Unauthorized: Access denied to write this workflow', + status: 403, + }, }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { @@ -565,7 +671,7 @@ describe('Workflow By ID API Route', () => { expect(data.error).toBe('Unauthorized: Access denied to write this workflow') }) - it.concurrent('should validate request data', async () => { + it('should validate request data', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -718,7 +824,10 @@ describe('Workflow By ID API Route', () => { const updatedWorkflow = { ...mockWorkflow, folderId: 'folder-2', updatedAt: new Date() } - mockGetSession({ user: { id: 'user-123' } }) + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: mockWorkflow, + auth: { success: true, userId: 'user-123', authType: 'session' }, + }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, @@ -760,7 +869,10 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - mockGetSession({ user: { id: 'user-123' } }) + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: mockWorkflow, + auth: { success: true, userId: 'user-123', authType: 'session' }, + }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true, @@ -827,7 +939,7 @@ describe('Workflow By ID API Route', () => { }) describe('Error handling', () => { - it.concurrent('should handle database errors gracefully', async () => { + it('should handle database errors gracefully', async () => { mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockRejectedValue(new Error('Database connection timeout')) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 8b79fe2c287..5c484b4882c 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -4,12 +4,14 @@ import { createLogger } from '@sim/logger' import { and, eq, isNull, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { getAuditActorMetadata } from '@/lib/audit/actor-metadata' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { archiveWorkflow } from '@/lib/workflows/lifecycle' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils' +import { validateWorkflowAccess } from '@/app/api/workflows/middleware' const logger = createLogger('WorkflowByIdAPI') @@ -49,10 +51,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflowData.workspaceId) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } if (isInternalCall && !userId) { @@ -152,38 +151,35 @@ export async function DELETE( const { id: workflowId } = await params try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { + const validation = await validateWorkflowAccess(request, workflowId, { + requireDeployment: false, + action: 'admin', + }) + if (validation.error) { logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json( + { error: validation.error.message }, + { status: validation.error.status } + ) } - const userId = auth.userId + const auth = validation.auth + const userId = auth?.userId + const workflowData = validation.workflow - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'admin', - }) - const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) + if (!userId) { + logger.warn(`[${requestId}] Missing user identity for workflow deletion ${workflowId}`) + return NextResponse.json( + { error: 'Workflow deletion requires a user-backed session or API key identity' }, + { status: 400 } + ) + } if (!workflowData) { logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - const canDelete = authorization.allowed - - if (!canDelete) { - logger.warn( - `[${requestId}] User ${userId} denied permission to delete workflow ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) - } - // Check if this is the last workflow in the workspace if (workflowData.workspaceId) { const totalWorkflowsInWorkspace = await db @@ -255,11 +251,13 @@ export async function DELETE( const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`) + const { actorName, actorEmail } = getAuditActorMetadata(auth) + recordAudit({ workspaceId: workflowData.workspaceId || null, actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, + actorName, + actorEmail, action: AuditAction.WORKFLOW_DELETED, resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, @@ -290,42 +288,36 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const { id: workflowId } = await params try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { + const validation = await validateWorkflowAccess(request, workflowId, { + requireDeployment: false, + action: 'write', + }) + if (validation.error) { logger.warn(`[${requestId}] Unauthorized update attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json( + { error: validation.error.message }, + { status: validation.error.status } + ) } - const userId = auth.userId + const userId = validation.auth?.userId + const workflowData = validation.workflow + if (!userId) { + logger.warn(`[${requestId}] Missing user identity for workflow update ${workflowId}`) + return NextResponse.json( + { error: 'Workflow update requires a user-backed session or API key identity' }, + { status: 400 } + ) + } const body = await request.json() const updates = UpdateWorkflowSchema.parse(body) - // Fetch the workflow to check ownership/access - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) - if (!workflowData) { logger.warn(`[${requestId}] Workflow ${workflowId} not found for update`) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - const canUpdate = authorization.allowed - - if (!canUpdate) { - logger.warn( - `[${requestId}] User ${userId} denied permission to update workflow ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) - } - const updateData: Record = { updatedAt: new Date() } if (updates.name !== undefined) updateData.name = updates.name if (updates.description !== undefined) updateData.description = updates.description diff --git a/apps/sim/app/api/workflows/[id]/status/route.test.ts b/apps/sim/app/api/workflows/[id]/status/route.test.ts new file mode 100644 index 00000000000..a7fbda1e8d1 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/status/route.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDbFrom, + mockDbLimit, + mockDbOrderBy, + mockDbSelect, + mockDbWhere, + mockHasWorkflowChanged, + mockLoadWorkflowFromNormalizedTables, + mockValidateWorkflowAccess, +} = vi.hoisted(() => ({ + mockDbFrom: vi.fn(), + mockDbLimit: vi.fn(), + mockDbOrderBy: vi.fn(), + mockDbSelect: vi.fn(), + mockDbWhere: vi.fn(), + mockHasWorkflowChanged: vi.fn(), + mockLoadWorkflowFromNormalizedTables: vi.fn(), + mockValidateWorkflowAccess: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: (...args: unknown[]) => + mockLoadWorkflowFromNormalizedTables(...args), +})) + +vi.mock('@/lib/workflows/comparison', () => ({ + hasWorkflowChanged: (...args: unknown[]) => mockHasWorkflowChanged(...args), +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => 'req-123', +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + }, + workflow: { variables: 'variables', id: 'id' }, + workflowDeploymentVersion: { + state: 'state', + workflowId: 'workflowId', + isActive: 'isActive', + createdAt: 'createdAt', + }, +})) + +vi.mock('drizzle-orm', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + and: vi.fn(), + desc: vi.fn(), + eq: vi.fn(), + } +}) + +import { GET } from '@/app/api/workflows/[id]/status/route' + +describe('Workflow status route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbSelect.mockReturnValue({ from: mockDbFrom }) + mockDbFrom.mockReturnValue({ where: mockDbWhere }) + mockDbWhere.mockReturnValue({ limit: mockDbLimit, orderBy: mockDbOrderBy }) + mockDbOrderBy.mockReturnValue({ limit: mockDbLimit }) + }) + + it('uses hybrid workflow access for read auth', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + workflow: { id: 'wf-1', isDeployed: false, deployedAt: null, isPublished: false }, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/status', { + headers: { 'x-api-key': 'test-key' }, + }) + const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) }) + + expect(response.status).toBe(200) + expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', { + requireDeployment: false, + action: 'read', + }) + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/status/route.ts b/apps/sim/app/api/workflows/[id]/status/route.ts index ac428d414fb..d272e3d1b57 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -16,7 +16,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ try { const { id } = await params - const validation = await validateWorkflowAccess(request, id, false) + const validation = await validateWorkflowAccess(request, id, { + requireDeployment: false, + action: 'read', + }) if (validation.error) { logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`) return createErrorResponse(validation.error.message, validation.error.status) diff --git a/apps/sim/app/api/workflows/middleware.test.ts b/apps/sim/app/api/workflows/middleware.test.ts new file mode 100644 index 00000000000..621e978789b --- /dev/null +++ b/apps/sim/app/api/workflows/middleware.test.ts @@ -0,0 +1,494 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { validateWorkflowAccess } from './middleware' + +const { mockAuthenticateApiKeyFromHeader, mockUpdateApiKeyLastUsed, mockCheckHybridAuth } = + vi.hoisted(() => ({ + mockAuthenticateApiKeyFromHeader: vi.fn(), + mockUpdateApiKeyLastUsed: vi.fn(), + mockCheckHybridAuth: vi.fn(), + })) + +const { + mockAuthorizeWorkflowByWorkspacePermission, + mockGetActiveWorkflowRecord, + mockGetWorkflowById, + mockLoggerError, +} = vi.hoisted(() => ({ + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockGetWorkflowById: vi.fn(), + mockLoggerError: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: mockLoggerError }), +})) + +vi.mock('@/lib/api-key/service', () => ({ + authenticateApiKeyFromHeader: (...args: unknown[]) => mockAuthenticateApiKeyFromHeader(...args), + updateApiKeyLastUsed: (...args: unknown[]) => mockUpdateApiKeyLastUsed(...args), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + AuthType: { + SESSION: 'session', + API_KEY: 'api_key', + INTERNAL_JWT: 'internal_jwt', + }, + checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args), +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: { INTERNAL_API_SECRET: 'internal-secret' }, + getEnv: vi.fn(), +})) + +vi.mock('@/lib/workflows/active-context', () => ({ + getActiveWorkflowRecord: (...args: unknown[]) => mockGetActiveWorkflowRecord(...args), +})) + +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: (...args: unknown[]) => + mockAuthorizeWorkflowByWorkspacePermission(...args), + getWorkflowById: (...args: unknown[]) => mockGetWorkflowById(...args), +})) + +const WORKFLOW_ID = 'wf-1' +const WORKSPACE_ID = 'ws-1' + +function createRequest() { + return new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`) +} + +function createWorkflow(overrides: Record = {}) { + return { + id: WORKFLOW_ID, + workspaceId: WORKSPACE_ID, + isDeployed: false, + ...overrides, + } +} + +describe('validateWorkflowAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1', authType: 'session' }) + mockGetActiveWorkflowRecord.mockResolvedValue(createWorkflow()) + mockGetWorkflowById.mockResolvedValue(createWorkflow()) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: createWorkflow(), + workspacePermission: 'admin', + }) + }) + + it('returns 401 before workflow lookup when unauthenticated', async () => { + const request = createRequest() + + mockCheckHybridAuth.mockResolvedValue({ success: false, error: 'Unauthorized' }) + + const result = await validateWorkflowAccess(request, WORKFLOW_ID, { + requireDeployment: false, + action: 'read', + }) + + expect(result).toEqual({ + error: { + message: 'Unauthorized', + status: 401, + }, + }) + expect(mockCheckHybridAuth).toHaveBeenCalledWith(request, { requireWorkflowId: false }) + expect(mockGetWorkflowById).not.toHaveBeenCalled() + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('returns 404 for authenticated missing workflow', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + mockGetWorkflowById.mockResolvedValue(null) + + const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, { + requireDeployment: false, + action: 'read', + }) + + expect(result).toEqual({ + error: { + message: 'Workflow not found', + status: 404, + }, + }) + expect(mockGetWorkflowById).toHaveBeenCalledWith(WORKFLOW_ID) + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('returns 403 for authenticated workflow without workspace', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + mockGetWorkflowById.mockResolvedValue(createWorkflow({ workspaceId: null })) + + const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, { + requireDeployment: false, + action: 'read', + }) + + expect(result).toEqual({ + error: { + message: + 'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.', + status: 403, + }, + }) + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('returns 404 for authenticated workflow in an archived workspace', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(null) + mockGetWorkflowById.mockResolvedValue(createWorkflow()) + + const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, { + requireDeployment: false, + action: 'read', + }) + + expect(result).toEqual({ + error: { + message: 'Workflow not found', + status: 404, + }, + }) + expect(mockGetWorkflowById).toHaveBeenCalledWith(WORKFLOW_ID) + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('returns authorization denial status and message', async () => { + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Unauthorized: Access denied to admin this workflow', + workflow: createWorkflow(), + workspacePermission: 'write', + }) + + const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, { + requireDeployment: false, + action: 'admin', + }) + + expect(result).toEqual({ + error: { + message: 'Unauthorized: Access denied to admin this workflow', + status: 403, + }, + }) + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + action: 'admin', + workflow: createWorkflow(), + }) + }) + + it('returns 404 for workspace api keys scoped to a different workspace', async () => { + const auth = { + success: true, + userId: 'user-1', + workspaceId: 'ws-2', + authType: 'api_key' as const, + apiKeyType: 'workspace' as const, + } + + mockCheckHybridAuth.mockResolvedValue(auth) + + const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, { + requireDeployment: false, + action: 'read', + }) + + expect(result).toEqual({ + error: { + message: 'Workflow not found', + status: 404, + }, + }) + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('preserves session auth semantics for accessible workflows', async () => { + const workflow = createWorkflow({ name: 'Session Workflow' }) + const auth = { success: true, userId: 'user-1', authType: 'session' as const } + + mockCheckHybridAuth.mockResolvedValue(auth) + mockGetActiveWorkflowRecord.mockResolvedValue(workflow) + + const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, { + requireDeployment: false, + action: 'read', + }) + + expect(result).toEqual({ workflow, auth }) + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + action: 'read', + workflow, + }) + }) + + it('allows workspace api keys scoped to the same workspace', async () => { + const workflow = createWorkflow({ name: 'Scoped Workflow' }) + const auth = { + success: true, + userId: 'user-1', + workspaceId: WORKSPACE_ID, + authType: 'api_key' as const, + apiKeyType: 'workspace' as const, + } + + mockCheckHybridAuth.mockResolvedValue(auth) + mockGetActiveWorkflowRecord.mockResolvedValue(workflow) + + const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, { + requireDeployment: false, + action: 'read', + }) + + expect(result).toEqual({ workflow, auth }) + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + action: 'read', + workflow, + }) + }) + + it('returns workflow and auth on success', async () => { + const workflow = createWorkflow({ name: 'Test Workflow' }) + const auth = { success: true, userId: 'user-1', authType: 'session' as const } + + mockCheckHybridAuth.mockResolvedValue(auth) + mockGetActiveWorkflowRecord.mockResolvedValue(workflow) + + const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, { + requireDeployment: false, + action: 'read', + }) + + expect(result).toEqual({ workflow, auth }) + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + userId: 'user-1', + action: 'read', + workflow, + }) + }) + + it('returns 404 for deployed access when workflow is missing', async () => { + mockAuthenticateApiKeyFromHeader.mockResolvedValue({ + success: true, + userId: 'user-1', + keyId: 'key-1', + keyType: 'workspace', + workspaceId: WORKSPACE_ID, + }) + mockGetActiveWorkflowRecord.mockResolvedValue(null) + mockGetWorkflowById.mockResolvedValue(null) + + const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, { + headers: { 'x-api-key': 'valid-key' }, + }) + + const result = await validateWorkflowAccess(request, WORKFLOW_ID, { + requireDeployment: true, + }) + + expect(result).toEqual({ + error: { + message: 'Workflow not found', + status: 404, + }, + }) + expect(mockCheckHybridAuth).not.toHaveBeenCalled() + expect(mockGetActiveWorkflowRecord).toHaveBeenCalledWith(WORKFLOW_ID) + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + }) + + it('returns 401 before deployed workflow lookup when api key is missing', async () => { + const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, { + requireDeployment: true, + }) + + expect(result).toEqual({ + error: { + message: 'Unauthorized: API key required', + status: 401, + }, + }) + expect(mockGetActiveWorkflowRecord).not.toHaveBeenCalled() + expect(mockGetWorkflowById).not.toHaveBeenCalled() + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + }) + + it('returns 401 before deployed workflow lookup when api key is invalid', async () => { + mockAuthenticateApiKeyFromHeader.mockResolvedValue({ + success: false, + error: 'Invalid API key', + }) + + const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, { + headers: { 'x-api-key': 'invalid-key' }, + }) + + const result = await validateWorkflowAccess(request, WORKFLOW_ID, { + requireDeployment: true, + }) + + expect(result).toEqual({ + error: { + message: 'Unauthorized: Invalid API key', + status: 401, + }, + }) + expect(mockGetActiveWorkflowRecord).toHaveBeenCalledWith(WORKFLOW_ID) + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('invalid-key', { + workspaceId: WORKSPACE_ID, + keyTypes: ['workspace', 'personal'], + }) + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledTimes(1) + }) + + it('returns 403 for deployed access when authenticated workflow has no workspace', async () => { + mockAuthenticateApiKeyFromHeader.mockResolvedValue({ + success: true, + userId: 'user-1', + keyId: 'key-1', + keyType: 'workspace', + workspaceId: WORKSPACE_ID, + }) + mockGetActiveWorkflowRecord.mockResolvedValue(null) + mockGetWorkflowById.mockResolvedValue(createWorkflow({ workspaceId: null, isDeployed: true })) + + const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, { + headers: { 'x-api-key': 'valid-key' }, + }) + + const result = await validateWorkflowAccess(request, WORKFLOW_ID, { + requireDeployment: true, + }) + + expect(result).toEqual({ + error: { + message: + 'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.', + status: 403, + }, + }) + expect(mockCheckHybridAuth).not.toHaveBeenCalled() + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + }) + + it('returns 404 for deployed access when authenticated workflow workspace is archived', async () => { + mockAuthenticateApiKeyFromHeader.mockResolvedValue({ + success: true, + userId: 'user-1', + keyId: 'key-1', + keyType: 'workspace', + workspaceId: WORKSPACE_ID, + }) + mockGetActiveWorkflowRecord.mockResolvedValue(null) + mockGetWorkflowById.mockResolvedValue(createWorkflow({ isDeployed: true })) + + const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, { + headers: { 'x-api-key': 'valid-key' }, + }) + + const result = await validateWorkflowAccess(request, WORKFLOW_ID, { + requireDeployment: true, + }) + + expect(result).toEqual({ + error: { + message: 'Workflow not found', + status: 404, + }, + }) + expect(mockGetWorkflowById).toHaveBeenCalledWith(WORKFLOW_ID) + expect(mockCheckHybridAuth).not.toHaveBeenCalled() + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + }) + + it('returns 403 for deployed access when authenticated workflow is not deployed', async () => { + mockAuthenticateApiKeyFromHeader.mockResolvedValue({ + success: true, + userId: 'user-1', + keyId: 'key-1', + keyType: 'workspace', + workspaceId: WORKSPACE_ID, + }) + mockGetActiveWorkflowRecord.mockResolvedValue(createWorkflow({ isDeployed: false })) + + const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, { + headers: { 'x-api-key': 'valid-key' }, + }) + + const result = await validateWorkflowAccess(request, WORKFLOW_ID, { + requireDeployment: true, + }) + + expect(result).toEqual({ + error: { + message: 'Workflow is not deployed', + status: 403, + }, + }) + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('valid-key', { + workspaceId: WORKSPACE_ID, + keyTypes: ['workspace', 'personal'], + }) + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledTimes(1) + expect(mockUpdateApiKeyLastUsed).not.toHaveBeenCalled() + }) + + it('allows internal secret without requiring api key when workflow is deployed', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(createWorkflow({ isDeployed: true })) + + const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, { + headers: { 'x-internal-secret': 'internal-secret' }, + }) + + const result = await validateWorkflowAccess(request, WORKFLOW_ID, { + requireDeployment: true, + allowInternalSecret: true, + }) + + expect(result).toEqual({ workflow: createWorkflow({ isDeployed: true }) }) + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + expect(mockUpdateApiKeyLastUsed).not.toHaveBeenCalled() + }) + + it('still returns undeployed error before internal secret success when workflow is not deployed', async () => { + mockGetActiveWorkflowRecord.mockResolvedValue(createWorkflow({ isDeployed: false })) + + const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, { + headers: { 'x-internal-secret': 'internal-secret' }, + }) + + const result = await validateWorkflowAccess(request, WORKFLOW_ID, { + requireDeployment: true, + allowInternalSecret: true, + }) + + expect(result).toEqual({ + error: { + message: 'Workflow is not deployed', + status: 403, + }, + }) + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workflows/middleware.ts b/apps/sim/app/api/workflows/middleware.ts index d6260002fe3..e548971480e 100644 --- a/apps/sim/app/api/workflows/middleware.ts +++ b/apps/sim/app/api/workflows/middleware.ts @@ -5,8 +5,9 @@ import { authenticateApiKeyFromHeader, updateApiKeyLastUsed, } from '@/lib/api-key/service' -import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid' +import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' +import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils' const logger = createLogger('WorkflowMiddleware') @@ -17,31 +18,57 @@ export interface ValidationResult { auth?: AuthResult } +export interface WorkflowAccessOptions { + requireDeployment?: boolean + action?: 'read' | 'write' | 'admin' + allowInternalSecret?: boolean +} + +async function getValidatedWorkflow(workflowId: string): Promise { + const activeWorkflow = await getActiveWorkflowRecord(workflowId) + if (activeWorkflow) { + return { workflow: activeWorkflow } + } + + const workflow = await getWorkflowById(workflowId) + if (!workflow) { + return { + error: { + message: 'Workflow not found', + status: 404, + }, + } + } + + if (!workflow.workspaceId) { + return { + error: { + message: + 'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.', + status: 403, + }, + } + } + + return { + error: { + message: 'Workflow not found', + status: 404, + }, + } +} + export async function validateWorkflowAccess( request: NextRequest, workflowId: string, - requireDeployment = true + options: boolean | WorkflowAccessOptions = true ): Promise { try { - const workflow = await getWorkflowById(workflowId) - if (!workflow) { - return { - error: { - message: 'Workflow not found', - status: 404, - }, - } - } - - if (!workflow.workspaceId) { - return { - error: { - message: - 'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.', - status: 403, - }, - } - } + const normalizedOptions: WorkflowAccessOptions = + typeof options === 'boolean' ? { requireDeployment: options } : options + const requireDeployment = normalizedOptions.requireDeployment ?? true + const action = normalizedOptions.action ?? 'read' + const allowInternalSecret = normalizedOptions.allowInternalSecret ?? false if (!requireDeployment) { const auth = await checkHybridAuth(request, { requireWorkflowId: false }) @@ -54,10 +81,30 @@ export async function validateWorkflowAccess( } } + const workflowResult = await getValidatedWorkflow(workflowId) + if (workflowResult.error || !workflowResult.workflow) { + return workflowResult + } + const workflow = workflowResult.workflow + + if ( + auth.authType === AuthType.API_KEY && + auth.apiKeyType === 'workspace' && + auth.workspaceId !== workflow.workspaceId + ) { + return { + error: { + message: 'Workflow not found', + status: 404, + }, + } + } + const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, userId: auth.userId, - action: 'read', + action, + workflow, }) if (!authorization.allowed) { return { @@ -71,41 +118,53 @@ export async function validateWorkflowAccess( return { workflow, auth } } - if (requireDeployment) { - if (!workflow.isDeployed) { - return { - error: { - message: 'Workflow is not deployed', - status: 403, - }, - } - } - + { const internalSecret = request.headers.get('X-Internal-Secret') - if (env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET) { - return { workflow } - } + const hasValidInternalSecret = + allowInternalSecret && env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET + + let apiKeyHeader: string | null = null - let apiKeyHeader = null - for (const [key, value] of request.headers.entries()) { - if (key.toLowerCase() === 'x-api-key' && value) { - apiKeyHeader = value - break + if (!hasValidInternalSecret) { + for (const [key, value] of request.headers.entries()) { + if (key.toLowerCase() === 'x-api-key' && value) { + apiKeyHeader = value + break + } + } + + if (!apiKeyHeader) { + return { + error: { + message: 'Unauthorized: API key required', + status: 401, + }, + } } } - if (!apiKeyHeader) { - return { - error: { - message: 'Unauthorized: API key required', - status: 401, - }, + const workflowResult = await getValidatedWorkflow(workflowId) + if (workflowResult.error || !workflowResult.workflow) { + return workflowResult + } + const workflow = workflowResult.workflow + + if (hasValidInternalSecret) { + if (!workflow.isDeployed) { + return { + error: { + message: 'Workflow is not deployed', + status: 403, + }, + } } + + return { workflow } } let validResult: ApiKeyAuthResult | null = null - const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, { + const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader!, { workspaceId: workflow.workspaceId as string, keyTypes: ['workspace', 'personal'], }) @@ -123,11 +182,21 @@ export async function validateWorkflowAccess( } } + if (!workflow.isDeployed) { + return { + error: { + message: 'Workflow is not deployed', + status: 403, + }, + } + } + if (validResult.keyId) { await updateApiKeyLastUsed(validResult.keyId) } + + return { workflow } } - return { workflow } } catch (error) { logger.error('Validation error:', { error }) return { diff --git a/apps/sim/lib/api-key/service.test.ts b/apps/sim/lib/api-key/service.test.ts new file mode 100644 index 00000000000..27c8b97261b --- /dev/null +++ b/apps/sim/lib/api-key/service.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthenticateApiKey, mockGetWorkspaceBillingSettings, mockGetUserEntityPermissions } = + vi.hoisted(() => ({ + mockAuthenticateApiKey: vi.fn(), + mockGetWorkspaceBillingSettings: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + })) + +vi.mock('@/lib/api-key/auth', () => ({ + authenticateApiKey: (...args: unknown[]) => mockAuthenticateApiKey(...args), +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBillingSettings: (...args: unknown[]) => mockGetWorkspaceBillingSettings(...args), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args), +})) + +import { databaseMock } from '@sim/testing' +import { authenticateApiKeyFromHeader } from '@/lib/api-key/service' + +const mockDb = databaseMock.db + +describe('authenticateApiKeyFromHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkspaceBillingSettings.mockResolvedValue({ + billedAccountUserId: 'billing-user', + allowPersonalApiKeys: true, + }) + mockGetUserEntityPermissions.mockResolvedValue({ permissionType: 'admin' }) + }) + + function createAwaitableQuery(rows: T[]) { + return { + where: vi.fn().mockResolvedValue(rows), + then: (onFulfilled: (value: T[]) => unknown, onRejected?: (reason: unknown) => unknown) => + Promise.resolve(rows).then(onFulfilled, onRejected), + } + } + + it('authenticates a valid key when the joined user row is missing', async () => { + const rows = [ + { + id: 'key-1', + userId: 'user-1', + userName: null, + userEmail: null, + workspaceId: 'ws-1', + type: 'workspace', + key: 'stored-key', + expiresAt: null, + }, + ] + + vi.mocked(mockDb.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue(createAwaitableQuery(rows)), + }), + } as any) + mockAuthenticateApiKey.mockResolvedValue(true) + + const result = await authenticateApiKeyFromHeader('raw-key', { + workspaceId: 'ws-1', + keyTypes: ['workspace', 'personal'], + }) + + expect(result).toEqual({ + success: true, + userId: 'user-1', + userName: null, + userEmail: null, + keyId: 'key-1', + keyType: 'workspace', + workspaceId: 'ws-1', + }) + }) + + it('still scopes the authentication result by workspace', async () => { + const rows = [ + { + id: 'key-1', + userId: 'user-1', + userName: null, + userEmail: null, + workspaceId: 'ws-2', + type: 'workspace', + key: 'stored-key', + expiresAt: null, + }, + ] + + vi.mocked(mockDb.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + leftJoin: vi.fn().mockReturnValue(createAwaitableQuery(rows)), + }), + } as any) + mockAuthenticateApiKey.mockResolvedValue(true) + + const result = await authenticateApiKeyFromHeader('raw-key', { + workspaceId: 'ws-1', + keyTypes: ['workspace'], + }) + + expect(result).toEqual({ success: false, error: 'Invalid API key' }) + }) +}) diff --git a/apps/sim/lib/api-key/service.ts b/apps/sim/lib/api-key/service.ts index 84f19c34625..67f58c1f609 100644 --- a/apps/sim/lib/api-key/service.ts +++ b/apps/sim/lib/api-key/service.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { apiKey as apiKeyTable } from '@sim/db/schema' +import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { authenticateApiKey } from '@/lib/api-key/auth' @@ -33,6 +33,8 @@ export interface ApiKeyAuthOptions { export interface ApiKeyAuthResult { success: boolean userId?: string + userName?: string | null + userEmail?: string | null keyId?: string keyType?: 'personal' | 'workspace' workspaceId?: string @@ -68,12 +70,15 @@ export async function authenticateApiKeyFromHeader( .select({ id: apiKeyTable.id, userId: apiKeyTable.userId, + userName: userTable.name, + userEmail: userTable.email, workspaceId: apiKeyTable.workspaceId, type: apiKeyTable.type, key: apiKeyTable.key, expiresAt: apiKeyTable.expiresAt, }) .from(apiKeyTable) + .leftJoin(userTable, eq(apiKeyTable.userId, userTable.id)) // Apply filters const conditions = [] @@ -154,6 +159,8 @@ export async function authenticateApiKeyFromHeader( return { success: true, userId: storedKey.userId, + userName: storedKey.userName, + userEmail: storedKey.userEmail, keyId: storedKey.id, keyType: storedKey.type as 'personal' | 'workspace', workspaceId: storedKey.workspaceId || options.workspaceId || undefined, diff --git a/apps/sim/lib/audit/actor-metadata.test.ts b/apps/sim/lib/audit/actor-metadata.test.ts new file mode 100644 index 00000000000..68b3c4bebf2 --- /dev/null +++ b/apps/sim/lib/audit/actor-metadata.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { getAuditActorMetadata } from '@/lib/audit/actor-metadata' +import { AuthType } from '@/lib/auth/hybrid' + +describe('getAuditActorMetadata', () => { + it('preserves actor metadata for API key auth when present', () => { + expect( + getAuditActorMetadata({ + success: true, + userId: 'api-user', + userName: 'API Key Actor', + userEmail: 'api@example.com', + authType: AuthType.API_KEY, + }) + ).toEqual({ + actorName: 'API Key Actor', + actorEmail: 'api@example.com', + }) + }) + + it('returns undefined metadata when auth is missing', () => { + expect(getAuditActorMetadata(null)).toEqual({ + actorName: undefined, + actorEmail: undefined, + }) + }) +}) diff --git a/apps/sim/lib/audit/actor-metadata.ts b/apps/sim/lib/audit/actor-metadata.ts new file mode 100644 index 00000000000..e20298c45c5 --- /dev/null +++ b/apps/sim/lib/audit/actor-metadata.ts @@ -0,0 +1,18 @@ +import type { AuthResult } from '@/lib/auth/hybrid' + +export function getAuditActorMetadata(auth: AuthResult | null | undefined): { + actorName: string | undefined + actorEmail: string | undefined +} { + if (!auth) { + return { + actorName: undefined, + actorEmail: undefined, + } + } + + return { + actorName: auth.userName ?? undefined, + actorEmail: auth.userEmail ?? undefined, + } +} diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index af1e64da011..cb12ac83d9a 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -218,6 +218,8 @@ export async function checkHybridAuth( success: true, userId: result.userId!, workspaceId: result.workspaceId, + userName: result.userName, + userEmail: result.userEmail, authType: AuthType.API_KEY, apiKeyType: result.keyType, } diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index ef4c00fab84..d7831c1841b 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -1165,6 +1165,59 @@ describe('Database Helpers', () => { }) }) + describe('restoreWorkflowDraftState', () => { + it('rolls back normalized graph restore when workflow variable update fails', async () => { + const txDeleteWhere = vi.fn().mockResolvedValue(undefined) + const txInsertValues = vi.fn().mockResolvedValue(undefined) + const txUpdateWhere = vi.fn().mockRejectedValue(new Error('workflow update failed')) + const txUpdateSet = vi.fn().mockReturnValue({ where: txUpdateWhere }) + const tx = { + delete: vi.fn().mockReturnValue({ where: txDeleteWhere }), + insert: vi.fn().mockReturnValue({ values: txInsertValues }), + update: vi.fn().mockReturnValue({ set: txUpdateSet }), + } + + mockDb.transaction = vi.fn().mockImplementation(async (callback) => callback(tx)) + + const result = await dbHelpers.restoreWorkflowDraftState({ + workflowId: mockWorkflowId, + state: { + blocks: asAppState(mockWorkflowState).blocks, + edges: asAppState(mockWorkflowState).edges, + loops: asAppState(mockWorkflowState).loops, + parallels: asAppState(mockWorkflowState).parallels, + }, + variables: { + apiToken: { + id: 'apiToken', + name: 'API Token', + type: 'string', + value: 'secret', + }, + }, + restoredAt: new Date('2024-01-01T00:00:00.000Z'), + }) + + expect(result).toEqual({ success: false, error: 'workflow update failed' }) + expect(mockDb.transaction).toHaveBeenCalledTimes(1) + expect(tx.delete).toHaveBeenCalledTimes(3) + expect(tx.insert).toHaveBeenCalledTimes(3) + expect(tx.update).toHaveBeenCalledWith({}) + expect(txUpdateSet).toHaveBeenCalledWith({ + variables: { + apiToken: { + id: 'apiToken', + name: 'API Token', + type: 'string', + value: 'secret', + }, + }, + lastSynced: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + }) + }) + }) + describe('migrateAgentBlocksToMessagesFormat', () => { it('should migrate agent block with both systemPrompt and userPrompt', () => { const blocks = { diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 9044db5638b..56193f43f52 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -54,6 +54,13 @@ export interface DeployedWorkflowData extends NormalizedWorkflowData { variables?: Record } +interface RestoreWorkflowDraftStateParams { + workflowId: string + state: Pick + variables: Record + restoredAt: Date +} + export async function blockExistsInDeployment( workflowId: string, blockId: string @@ -534,88 +541,119 @@ export async function saveWorkflowToNormalizedTables( state: WorkflowState ): Promise<{ success: boolean; error?: string }> { try { - const blockRecords = state.blocks as Record - const canonicalLoops = generateLoopBlocks(blockRecords) - const canonicalParallels = generateParallelBlocks(blockRecords) - - // Start a transaction await db.transaction(async (tx) => { - await Promise.all([ - tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), - tx.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), - tx.delete(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), - ]) - - // Insert blocks - if (Object.keys(state.blocks).length > 0) { - const blockInserts = Object.values(state.blocks).map((block) => ({ - id: block.id, - workflowId: workflowId, - type: block.type, - name: block.name || '', - positionX: String(block.position?.x || 0), - positionY: String(block.position?.y || 0), - enabled: block.enabled ?? true, - horizontalHandles: block.horizontalHandles ?? true, - advancedMode: block.advancedMode ?? false, - triggerMode: block.triggerMode ?? false, - height: String(block.height || 0), - subBlocks: block.subBlocks || {}, - outputs: block.outputs || {}, - data: block.data || {}, - parentId: block.data?.parentId || null, - extent: block.data?.extent || null, - locked: block.locked ?? false, - })) - - await tx.insert(workflowBlocks).values(blockInserts) - } + await saveWorkflowToNormalizedTablesWithTx(tx, workflowId, state) + }) - // Insert edges - if (state.edges.length > 0) { - const edgeInserts = state.edges.map((edge) => ({ - id: edge.id, - workflowId: workflowId, - sourceBlockId: edge.source, - targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, - })) - - await tx.insert(workflowEdges).values(edgeInserts) - } + return { success: true } + } catch (error) { + logger.error(`Error saving workflow ${workflowId} to normalized tables:`, error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } +} - // Insert subflows (loops and parallels) - const subflowInserts: SubflowInsert[] = [] +async function saveWorkflowToNormalizedTablesWithTx( + tx: DbOrTx, + workflowId: string, + state: Pick +) { + const blockRecords = state.blocks as Record + const canonicalLoops = generateLoopBlocks(blockRecords) + const canonicalParallels = generateParallelBlocks(blockRecords) + + await Promise.all([ + tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), + tx.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), + tx.delete(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), + ]) + + if (Object.keys(state.blocks).length > 0) { + const blockInserts = Object.values(state.blocks).map((block) => ({ + id: block.id, + workflowId, + type: block.type, + name: block.name || '', + positionX: String(block.position?.x || 0), + positionY: String(block.position?.y || 0), + enabled: block.enabled ?? true, + horizontalHandles: block.horizontalHandles ?? true, + advancedMode: block.advancedMode ?? false, + triggerMode: block.triggerMode ?? false, + height: String(block.height || 0), + subBlocks: block.subBlocks || {}, + outputs: block.outputs || {}, + data: block.data || {}, + parentId: block.data?.parentId || null, + extent: block.data?.extent || null, + locked: block.locked ?? false, + })) - // Add loops - Object.values(canonicalLoops).forEach((loop) => { - subflowInserts.push({ - id: loop.id, - workflowId: workflowId, - type: SUBFLOW_TYPES.LOOP, - config: loop, - }) - }) + await tx.insert(workflowBlocks).values(blockInserts) + } - // Add parallels - Object.values(canonicalParallels).forEach((parallel) => { - subflowInserts.push({ - id: parallel.id, - workflowId: workflowId, - type: SUBFLOW_TYPES.PARALLEL, - config: parallel, - }) - }) + if (state.edges.length > 0) { + const edgeInserts = state.edges.map((edge) => ({ + id: edge.id, + workflowId, + sourceBlockId: edge.source, + targetBlockId: edge.target, + sourceHandle: edge.sourceHandle || null, + targetHandle: edge.targetHandle || null, + })) - if (subflowInserts.length > 0) { - await tx.insert(workflowSubflows).values(subflowInserts) - } + await tx.insert(workflowEdges).values(edgeInserts) + } + + const subflowInserts: SubflowInsert[] = [] + + Object.values(canonicalLoops).forEach((loop) => { + subflowInserts.push({ + id: loop.id, + workflowId, + type: SUBFLOW_TYPES.LOOP, + config: loop, + }) + }) + + Object.values(canonicalParallels).forEach((parallel) => { + subflowInserts.push({ + id: parallel.id, + workflowId, + type: SUBFLOW_TYPES.PARALLEL, + config: parallel, + }) + }) + + if (subflowInserts.length > 0) { + await tx.insert(workflowSubflows).values(subflowInserts) + } +} + +export async function restoreWorkflowDraftState({ + workflowId, + state, + variables, + restoredAt, +}: RestoreWorkflowDraftStateParams): Promise<{ success: boolean; error?: string }> { + try { + await db.transaction(async (tx) => { + await saveWorkflowToNormalizedTablesWithTx(tx, workflowId, state) + await tx + .update(workflow) + .set({ + variables, + lastSynced: restoredAt, + updatedAt: restoredAt, + }) + .where(eq(workflow.id, workflowId)) }) return { success: true } } catch (error) { - logger.error(`Error saving workflow ${workflowId} to normalized tables:`, error) + logger.error(`Error restoring draft workflow state ${workflowId}:`, error) return { success: false, error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/sim/lib/workflows/utils.test.ts b/apps/sim/lib/workflows/utils.test.ts index 0be09c73815..97b5989988c 100644 --- a/apps/sim/lib/workflows/utils.test.ts +++ b/apps/sim/lib/workflows/utils.test.ts @@ -29,7 +29,11 @@ vi.mock('@/lib/workflows/active-context', () => ({ getActiveWorkflowContext: mockGetActiveWorkflowContext, })) -import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { + authorizeWorkflowByWorkspacePermission, + setWorkflowVariables, + validateWorkflowPermissions, +} from '@/lib/workflows/utils' const mockDb = databaseMock.db @@ -309,3 +313,81 @@ describe('validateWorkflowPermissions', () => { }) }) }) + +describe('authorizeWorkflowByWorkspacePermission', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reuses a provided workflow without loading active workflow context', async () => { + const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'admin' }]) + const mockWhere = vi.fn(() => ({ limit: mockLimit })) + const mockFrom = vi.fn(() => ({ where: mockWhere })) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + + const result = await authorizeWorkflowByWorkspacePermission({ + workflowId: mockWorkflow.id, + userId: 'user-1', + action: 'admin', + workflow: mockWorkflow, + }) + + expect(result).toEqual({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'admin', + }) + expect(mockGetActiveWorkflowContext).not.toHaveBeenCalled() + }) + + it('preserves denial behavior when a provided workflow has no matching permission', async () => { + const mockLimit = vi.fn().mockResolvedValue([]) + const mockWhere = vi.fn(() => ({ limit: mockLimit })) + const mockFrom = vi.fn(() => ({ where: mockWhere })) + vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + + const result = await authorizeWorkflowByWorkspacePermission({ + workflowId: mockWorkflow.id, + userId: 'user-1', + action: 'write', + workflow: mockWorkflow, + }) + + expect(result).toEqual({ + allowed: false, + status: 403, + message: 'Unauthorized: Access denied to write this workflow', + workflow: mockWorkflow, + workspacePermission: null, + }) + expect(mockGetActiveWorkflowContext).not.toHaveBeenCalled() + }) +}) + +describe('setWorkflowVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('persists variables to the workflow row', async () => { + const snapshotVariables = { + var1: { id: 'var1', name: 'API Token', type: 'string', value: 'secret' }, + } + + const mockWhere = vi.fn().mockResolvedValue(undefined) + const mockSet = vi.fn(() => ({ where: mockWhere })) + vi.mocked(mockDb.update).mockReturnValue({ set: mockSet } as any) + + await setWorkflowVariables('wf-1', snapshotVariables) + + expect(mockDb.update).toHaveBeenCalled() + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ + variables: snapshotVariables, + updatedAt: expect.any(Date), + }) + ) + expect(mockWhere).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index d5c50b47ee6..b6b79aaafe6 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -337,11 +337,12 @@ export async function authorizeWorkflowByWorkspacePermission(params: { workflowId: string userId: string action?: 'read' | 'write' | 'admin' + workflow?: WorkflowRecord }): Promise { - const { workflowId, userId, action = 'read' } = params + const { workflowId, userId, action = 'read', workflow: preloadedWorkflow } = params - const activeContext = await getActiveWorkflowContext(workflowId) - if (!activeContext) { + const workflow = preloadedWorkflow ?? (await getActiveWorkflowContext(workflowId))?.workflow + if (!workflow) { return { allowed: false, status: 404, @@ -351,8 +352,6 @@ export async function authorizeWorkflowByWorkspacePermission(params: { } } - const workflow = activeContext.workflow - if (!workflow.workspaceId) { return { allowed: false, diff --git a/apps/sim/next-env.d.ts b/apps/sim/next-env.d.ts new file mode 100644 index 00000000000..c4e7c0ebef4 --- /dev/null +++ b/apps/sim/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import './.next/types/routes.d.ts' + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.