Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
507c84f
fix(workflows): tighten shared workflow access validation
PlaneInABottle Mar 11, 2026
2b05c74
fix(workflows): allow app auth on workflow lifecycle actions
PlaneInABottle Mar 11, 2026
46b46fb
fix(workflows): support app auth on deployment routes
PlaneInABottle Mar 12, 2026
0616aa6
fix: harden workflow API auth routes
PlaneInABottle Mar 13, 2026
7b31f55
fix: harden workflow API auth follow-ups
PlaneInABottle Mar 13, 2026
5499177
fix: align workflow audit actors for API keys
PlaneInABottle Mar 13, 2026
39c014a
fix: align workflow audit metadata helpers
PlaneInABottle Mar 13, 2026
2c267c0
refactor: inline workflow access wrappers
PlaneInABottle Mar 13, 2026
2d4271d
test(workflows): fix auth route lint issues
Mar 14, 2026
5493e78
fix(workflows): sync MCP tools on revert
Mar 14, 2026
965e9ac
test(workflows): stabilize async route queue mocks
Mar 14, 2026
abbf8c9
fix(workflows): enforce deployment patch auth ordering
Mar 14, 2026
8bb57d4
test(workflows): reset async route mocks between tests
Mar 14, 2026
818e477
test(workflows): deflake workflow route auth tests
Mar 14, 2026
cd24443
fix(workflows): preserve auth ordering and harden async tests
Mar 14, 2026
ecee883
test(workflows): fix AuthType mock shape in async route test
Mar 14, 2026
d3d15e6
Merge upstream/staging into fix/workflow-api-auth-hardening
Mar 14, 2026
866dd93
fix(workflows): harden deployment auth and audit actor metadata
Mar 14, 2026
a2aaddb
fix(workflows): remove dead deployment actor guard
Mar 14, 2026
3103042
fix(workflows): scope workspace API keys to workflow access
Mar 14, 2026
ce2ed79
fix(workflows): use AuthType enum in middleware auth checks
Mar 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 239 additions & 0 deletions apps/sim/app/api/workflows/[id]/deploy/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/**
* @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<typeof import('drizzle-orm')>()
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',
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: undefined,
actorEmail: undefined,
})
)
})

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',
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: undefined,
actorEmail: undefined,
})
)
})

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')
})
})
85 changes: 53 additions & 32 deletions apps/sim/app/api/workflows/[id]/deploy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, 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'
Expand All @@ -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 { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

Expand All @@ -35,15 +36,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({
Expand Down Expand Up @@ -115,16 +117,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)
Expand Down Expand Up @@ -274,11 +278,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })

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,
Expand Down Expand Up @@ -322,11 +328,16 @@ 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

const body = await request.json()
const { isPublicApi } = body

Expand All @@ -338,8 +349,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)
Expand Down Expand Up @@ -368,13 +380,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 })
Expand All @@ -395,11 +414,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,
Expand Down
Loading
Loading