Skip to content

Commit 3fbf489

Browse files
PlaneInABottletest
authored andcommitted
fix(workflows): support app auth on deployment routes
1 parent c2a5964 commit 3fbf489

File tree

6 files changed

+393
-19
lines changed

6 files changed

+393
-19
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mockValidateWorkflowAccess = vi.fn()
9+
const mockDbSelect = vi.fn()
10+
const mockDbFrom = vi.fn()
11+
const mockDbWhere = vi.fn()
12+
const mockDbLimit = vi.fn()
13+
const mockDbUpdate = vi.fn()
14+
const mockDbSet = vi.fn()
15+
const mockDbWhereUpdate = vi.fn()
16+
const mockSaveWorkflowToNormalizedTables = vi.fn()
17+
const mockSyncMcpToolsForWorkflow = vi.fn()
18+
const mockFetch = vi.fn()
19+
20+
vi.stubGlobal('fetch', mockFetch)
21+
22+
vi.mock('@sim/logger', () => ({
23+
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
24+
}))
25+
26+
vi.mock('@/app/api/workflows/middleware', () => ({
27+
validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args),
28+
}))
29+
30+
vi.mock('@/lib/core/utils/request', () => ({
31+
generateRequestId: () => 'req-123',
32+
}))
33+
34+
vi.mock('@/lib/core/config/env', () => ({
35+
env: { INTERNAL_API_SECRET: 'internal-secret', SOCKET_SERVER_URL: 'http://localhost:3002' },
36+
}))
37+
38+
vi.mock('@sim/db', () => ({
39+
db: {
40+
select: mockDbSelect,
41+
update: mockDbUpdate,
42+
},
43+
workflow: { id: 'id' },
44+
workflowDeploymentVersion: {
45+
state: 'state',
46+
workflowId: 'workflowId',
47+
isActive: 'isActive',
48+
version: 'version',
49+
},
50+
}))
51+
52+
vi.mock('drizzle-orm', () => ({
53+
and: vi.fn(),
54+
eq: vi.fn(),
55+
}))
56+
57+
vi.mock('@/lib/workflows/persistence/utils', () => ({
58+
saveWorkflowToNormalizedTables: (...args: unknown[]) => mockSaveWorkflowToNormalizedTables(...args),
59+
}))
60+
61+
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
62+
syncMcpToolsForWorkflow: (...args: unknown[]) => mockSyncMcpToolsForWorkflow(...args),
63+
}))
64+
65+
vi.mock('@/lib/audit/log', () => ({
66+
AuditAction: { WORKFLOW_DEPLOYMENT_REVERTED: 'WORKFLOW_DEPLOYMENT_REVERTED' },
67+
AuditResourceType: { WORKFLOW: 'WORKFLOW' },
68+
recordAudit: vi.fn(),
69+
}))
70+
71+
import { POST } from '@/app/api/workflows/[id]/deployments/[version]/revert/route'
72+
73+
describe('Workflow deployment version revert route', () => {
74+
beforeEach(() => {
75+
vi.clearAllMocks()
76+
mockDbSelect.mockReturnValue({ from: mockDbFrom })
77+
mockDbFrom.mockReturnValue({ where: mockDbWhere })
78+
mockDbWhere.mockReturnValue({ limit: mockDbLimit })
79+
mockDbLimit.mockResolvedValue([
80+
{
81+
state: {
82+
blocks: { 'block-1': { id: 'block-1', type: 'start_trigger', name: 'Start' } },
83+
edges: [],
84+
loops: {},
85+
parallels: {},
86+
},
87+
},
88+
])
89+
mockDbUpdate.mockReturnValue({ set: mockDbSet })
90+
mockDbSet.mockReturnValue({ where: mockDbWhereUpdate })
91+
mockDbWhereUpdate.mockResolvedValue(undefined)
92+
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
93+
mockFetch.mockResolvedValue({ ok: true })
94+
})
95+
96+
it('allows API-key auth for revert using hybrid auth userId', async () => {
97+
mockValidateWorkflowAccess.mockResolvedValue({
98+
workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' },
99+
auth: { success: true, userId: 'api-user', authType: 'api_key' },
100+
})
101+
102+
const req = new NextRequest(
103+
'http://localhost:3000/api/workflows/wf-1/deployments/3/revert',
104+
{
105+
method: 'POST',
106+
headers: { 'x-api-key': 'test-key' },
107+
}
108+
)
109+
const response = await POST(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) })
110+
111+
expect(response.status).toBe(200)
112+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', {
113+
requireDeployment: false,
114+
action: 'admin',
115+
})
116+
expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalled()
117+
expect(mockSyncMcpToolsForWorkflow).toHaveBeenCalled()
118+
})
119+
})

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,28 @@ import { env } from '@/lib/core/config/env'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
99
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
10-
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
10+
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
1111
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1212

1313
const logger = createLogger('RevertToDeploymentVersionAPI')
1414

15+
async function validateDeploymentVersionAdminAccess(request: NextRequest, workflowId: string) {
16+
const access = await validateWorkflowAccess(request, workflowId, {
17+
requireDeployment: false,
18+
action: 'admin',
19+
})
20+
21+
if (access.error) {
22+
return access
23+
}
24+
25+
return {
26+
error: null,
27+
auth: access.auth,
28+
workflow: access.workflow,
29+
}
30+
}
31+
1532
export const dynamic = 'force-dynamic'
1633
export const runtime = 'nodejs'
1734

@@ -24,14 +41,20 @@ export async function POST(
2441

2542
try {
2643
const {
44+
auth,
2745
error,
28-
session,
2946
workflow: workflowRecord,
30-
} = await validateWorkflowPermissions(id, requestId, 'admin')
47+
} = await validateDeploymentVersionAdminAccess(request, id)
3148
if (error) {
3249
return createErrorResponse(error.message, error.status)
3350
}
3451

52+
const actorUserId = auth?.userId
53+
if (!actorUserId) {
54+
logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment revert: ${id}`)
55+
return createErrorResponse('Unable to determine reverting user', 400)
56+
}
57+
3558
const versionSelector = version === 'active' ? null : Number(version)
3659
if (version !== 'active' && !Number.isFinite(versionSelector)) {
3760
return createErrorResponse('Invalid version', 400)
@@ -114,12 +137,12 @@ export async function POST(
114137

115138
recordAudit({
116139
workspaceId: workflowRecord?.workspaceId ?? null,
117-
actorId: session!.user.id,
140+
actorId: actorUserId,
118141
action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED,
119142
resourceType: AuditResourceType.WORKFLOW,
120143
resourceId: id,
121-
actorName: session!.user.name ?? undefined,
122-
actorEmail: session!.user.email ?? undefined,
144+
actorName: auth?.userName ?? undefined,
145+
actorEmail: auth?.userEmail ?? undefined,
123146
resourceName: workflowRecord?.name ?? undefined,
124147
description: `Reverted workflow to deployment version ${version}`,
125148
request,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mockValidateWorkflowAccess = vi.fn()
9+
const mockDbSelect = vi.fn()
10+
const mockDbFrom = vi.fn()
11+
const mockDbWhere = vi.fn()
12+
const mockDbLimit = vi.fn()
13+
const mockSaveTriggerWebhooksForDeploy = vi.fn()
14+
const mockCreateSchedulesForDeploy = vi.fn()
15+
const mockActivateWorkflowVersion = vi.fn()
16+
const mockSyncMcpToolsForWorkflow = vi.fn()
17+
18+
vi.mock('@sim/logger', () => ({
19+
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
20+
}))
21+
22+
vi.mock('@/app/api/workflows/middleware', () => ({
23+
validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args),
24+
}))
25+
26+
vi.mock('@/lib/core/utils/request', () => ({
27+
generateRequestId: () => 'req-123',
28+
}))
29+
30+
vi.mock('@sim/db', () => ({
31+
db: {
32+
select: mockDbSelect,
33+
update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn() })) })) }),
34+
},
35+
workflowDeploymentVersion: {
36+
id: 'id',
37+
state: 'state',
38+
workflowId: 'workflowId',
39+
version: 'version',
40+
isActive: 'isActive',
41+
name: 'name',
42+
description: 'description',
43+
},
44+
}))
45+
46+
vi.mock('drizzle-orm', () => ({
47+
and: vi.fn(),
48+
eq: vi.fn(),
49+
}))
50+
51+
vi.mock('@/lib/webhooks/deploy', () => ({
52+
restorePreviousVersionWebhooks: vi.fn(),
53+
saveTriggerWebhooksForDeploy: (...args: unknown[]) => mockSaveTriggerWebhooksForDeploy(...args),
54+
}))
55+
56+
vi.mock('@/lib/workflows/persistence/utils', () => ({
57+
activateWorkflowVersion: (...args: unknown[]) => mockActivateWorkflowVersion(...args),
58+
}))
59+
60+
vi.mock('@/lib/workflows/schedules', () => ({
61+
cleanupDeploymentVersion: vi.fn(),
62+
createSchedulesForDeploy: (...args: unknown[]) => mockCreateSchedulesForDeploy(...args),
63+
validateWorkflowSchedules: vi.fn(() => ({ isValid: true })),
64+
}))
65+
66+
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
67+
syncMcpToolsForWorkflow: (...args: unknown[]) => mockSyncMcpToolsForWorkflow(...args),
68+
}))
69+
70+
vi.mock('@/lib/audit/log', () => ({
71+
AuditAction: { WORKFLOW_DEPLOYMENT_ACTIVATED: 'WORKFLOW_DEPLOYMENT_ACTIVATED' },
72+
AuditResourceType: { WORKFLOW: 'WORKFLOW' },
73+
recordAudit: vi.fn(),
74+
}))
75+
76+
import { PATCH } from '@/app/api/workflows/[id]/deployments/[version]/route'
77+
78+
describe('Workflow deployment version route', () => {
79+
beforeEach(() => {
80+
vi.clearAllMocks()
81+
mockDbSelect.mockReturnValue({ from: mockDbFrom })
82+
mockDbFrom.mockReturnValue({ where: mockDbWhere })
83+
mockDbWhere.mockReturnValue({ limit: mockDbLimit })
84+
mockDbLimit
85+
.mockResolvedValueOnce([
86+
{
87+
id: 'dep-3',
88+
state: {
89+
blocks: { 'block-1': { id: 'block-1', type: 'start_trigger', name: 'Start' } },
90+
},
91+
},
92+
])
93+
.mockResolvedValueOnce([{ id: 'dep-2' }])
94+
mockSaveTriggerWebhooksForDeploy.mockResolvedValue({ success: true, warnings: [] })
95+
mockCreateSchedulesForDeploy.mockResolvedValue({ success: true })
96+
mockActivateWorkflowVersion.mockResolvedValue({
97+
success: true,
98+
deployedAt: '2024-01-17T12:00:00.000Z',
99+
})
100+
})
101+
102+
it('allows API-key auth for activation using hybrid auth userId', async () => {
103+
mockValidateWorkflowAccess.mockResolvedValue({
104+
workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' },
105+
auth: { success: true, userId: 'api-user', authType: 'api_key' },
106+
})
107+
108+
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployments/3', {
109+
method: 'PATCH',
110+
headers: { 'content-type': 'application/json', 'x-api-key': 'test-key' },
111+
body: JSON.stringify({ isActive: true }),
112+
})
113+
const response = await PATCH(req, { params: Promise.resolve({ id: 'wf-1', version: '3' }) })
114+
115+
expect(response.status).toBe(200)
116+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', {
117+
requireDeployment: false,
118+
action: 'admin',
119+
})
120+
expect(mockSaveTriggerWebhooksForDeploy).toHaveBeenCalledWith(
121+
expect.objectContaining({ userId: 'api-user' })
122+
)
123+
})
124+
})

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

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,33 @@ import {
1313
createSchedulesForDeploy,
1414
validateWorkflowSchedules,
1515
} from '@/lib/workflows/schedules'
16-
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
16+
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
1717
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1818
import type { BlockState } from '@/stores/workflows/workflow/types'
1919

2020
const logger = createLogger('WorkflowDeploymentVersionAPI')
2121

22+
async function validateDeploymentVersionLifecycleAccess(
23+
request: NextRequest,
24+
workflowId: string,
25+
action: 'read' | 'write' | 'admin'
26+
) {
27+
const access = await validateWorkflowAccess(request, workflowId, {
28+
requireDeployment: false,
29+
action,
30+
})
31+
32+
if (access.error) {
33+
return access
34+
}
35+
36+
return {
37+
error: null,
38+
auth: access.auth,
39+
workflow: access.workflow,
40+
}
41+
}
42+
2243
const patchBodySchema = z
2344
.object({
2445
name: z
@@ -53,9 +74,9 @@ export async function GET(
5374
const { id, version } = await params
5475

5576
try {
56-
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
57-
if (error) {
58-
return createErrorResponse(error.message, error.status)
77+
const access = await validateDeploymentVersionLifecycleAccess(request, id, 'read')
78+
if (access.error) {
79+
return createErrorResponse(access.error.message, access.error.status)
5980
}
6081

6182
const versionNum = Number(version)
@@ -108,10 +129,10 @@ export async function PATCH(
108129
// Activation requires admin permission, other updates require write
109130
const requiredPermission = isActive ? 'admin' : 'write'
110131
const {
132+
auth,
111133
error,
112-
session,
113134
workflow: workflowData,
114-
} = await validateWorkflowPermissions(id, requestId, requiredPermission)
135+
} = await validateDeploymentVersionLifecycleAccess(request, id, requiredPermission)
115136
if (error) {
116137
return createErrorResponse(error.message, error.status)
117138
}
@@ -123,7 +144,7 @@ export async function PATCH(
123144

124145
// Handle activation
125146
if (isActive) {
126-
const actorUserId = session?.user?.id
147+
const actorUserId = auth?.userId
127148
if (!actorUserId) {
128149
logger.warn(`[${requestId}] Unable to resolve actor user for deployment activation: ${id}`)
129150
return createErrorResponse('Unable to determine activating user', 400)
@@ -301,8 +322,8 @@ export async function PATCH(
301322
recordAudit({
302323
workspaceId: workflowData?.workspaceId,
303324
actorId: actorUserId,
304-
actorName: session?.user?.name,
305-
actorEmail: session?.user?.email,
325+
actorName: auth?.userName,
326+
actorEmail: auth?.userEmail,
306327
action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED,
307328
resourceType: AuditResourceType.WORKFLOW,
308329
resourceId: id,

0 commit comments

Comments
 (0)