Skip to content

Commit 26efd46

Browse files
PlaneInABottleGitHub Copilot
andcommitted
fix: harden workflow API auth follow-ups
Co-authored-by: GitHub Copilot <github-copilot[bot]@users.noreply.github.com>
1 parent e77c8fb commit 26efd46

File tree

7 files changed

+71
-134
lines changed

7 files changed

+71
-134
lines changed

apps/sim/app/api/workflows/[id]/deploy/route.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
77

88
const {
99
mockCleanupWebhooksForWorkflow,
10+
mockRecordAudit,
1011
mockDbLimit,
1112
mockDbOrderBy,
1213
mockDbFrom,
@@ -23,8 +24,10 @@ const {
2324
mockUndeployWorkflow,
2425
mockValidatePublicApiAllowed,
2526
mockValidateWorkflowAccess,
27+
mockValidateWorkflowPermissions,
2628
} = vi.hoisted(() => ({
2729
mockCleanupWebhooksForWorkflow: vi.fn(),
30+
mockRecordAudit: vi.fn(),
2831
mockDbLimit: vi.fn(),
2932
mockDbOrderBy: vi.fn(),
3033
mockDbFrom: vi.fn(),
@@ -41,14 +44,15 @@ const {
4144
mockUndeployWorkflow: vi.fn(),
4245
mockValidatePublicApiAllowed: vi.fn(),
4346
mockValidateWorkflowAccess: vi.fn(),
47+
mockValidateWorkflowPermissions: vi.fn(),
4448
}))
4549

4650
vi.mock('@sim/logger', () => ({
4751
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
4852
}))
4953

5054
vi.mock('@/lib/workflows/utils', () => ({
51-
validateWorkflowPermissions: vi.fn(),
55+
validateWorkflowPermissions: (...args: unknown[]) => mockValidateWorkflowPermissions(...args),
5256
}))
5357

5458
vi.mock('@/app/api/workflows/middleware', () => ({
@@ -105,7 +109,7 @@ vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
105109
vi.mock('@/lib/audit/log', () => ({
106110
AuditAction: {},
107111
AuditResourceType: {},
108-
recordAudit: vi.fn(),
112+
recordAudit: (...args: unknown[]) => mockRecordAudit(...args),
109113
}))
110114

111115
vi.mock('@/ee/access-control/utils/permission-check', () => ({
@@ -142,7 +146,13 @@ describe('Workflow deploy route', () => {
142146
it('allows API-key auth for deploy using hybrid auth userId', async () => {
143147
mockValidateWorkflowAccess.mockResolvedValue({
144148
workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' },
145-
auth: { success: true, userId: 'api-user', authType: 'api_key' },
149+
auth: {
150+
success: true,
151+
userId: 'api-user',
152+
userName: 'API Key User',
153+
userEmail: 'api@example.com',
154+
authType: 'api_key',
155+
},
146156
})
147157
mockDeployWorkflow.mockResolvedValue({
148158
success: true,
@@ -164,12 +174,26 @@ describe('Workflow deploy route', () => {
164174
deployedBy: 'api-user',
165175
workflowName: 'Test Workflow',
166176
})
177+
expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled()
178+
expect(mockRecordAudit).toHaveBeenCalledWith(
179+
expect.objectContaining({
180+
actorId: 'api-user',
181+
actorName: 'API Key User',
182+
actorEmail: 'api@example.com',
183+
})
184+
)
167185
})
168186

169187
it('allows API-key auth for undeploy using hybrid auth userId', async () => {
170188
mockValidateWorkflowAccess.mockResolvedValue({
171189
workflow: { id: 'wf-1', name: 'Test Workflow', workspaceId: 'ws-1' },
172-
auth: { success: true, userId: 'api-user', authType: 'api_key' },
190+
auth: {
191+
success: true,
192+
userId: 'api-user',
193+
userName: 'API Key User',
194+
userEmail: 'api@example.com',
195+
authType: 'api_key',
196+
},
173197
})
174198
mockUndeployWorkflow.mockResolvedValue({ success: true })
175199

@@ -183,6 +207,14 @@ describe('Workflow deploy route', () => {
183207
const data = await response.json()
184208
expect(data.isDeployed).toBe(false)
185209
expect(mockUndeployWorkflow).toHaveBeenCalledWith({ workflowId: 'wf-1' })
210+
expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled()
211+
expect(mockRecordAudit).toHaveBeenCalledWith(
212+
expect.objectContaining({
213+
actorId: 'api-user',
214+
actorName: 'API Key User',
215+
actorEmail: 'api@example.com',
216+
})
217+
)
186218
})
187219

188220
it('checks public API restrictions against hybrid auth userId', async () => {

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

Lines changed: 12 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
createSchedulesForDeploy,
2222
validateWorkflowSchedules,
2323
} from '@/lib/workflows/schedules'
24-
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
2524
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
2625
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
2726
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -34,21 +33,12 @@ export const runtime = 'nodejs'
3433
type LifecycleAdminAccessResult = {
3534
error: { message: string; status: number } | null | undefined
3635
auth: AuthResult | null | undefined
37-
session:
38-
| Awaited<ReturnType<typeof validateWorkflowPermissions>>['session']
39-
| null
40-
| undefined
41-
workflow:
42-
| Awaited<ReturnType<typeof validateWorkflowPermissions>>['workflow']
43-
| Awaited<ReturnType<typeof validateWorkflowAccess>>['workflow']
44-
| null
45-
| undefined
36+
workflow: Awaited<ReturnType<typeof validateWorkflowAccess>>['workflow'] | null | undefined
4637
}
4738

4839
async function validateLifecycleAdminAccess(
4940
request: NextRequest,
50-
workflowId: string,
51-
requestId: string
41+
workflowId: string
5242
): Promise<LifecycleAdminAccessResult> {
5343
const hybridAccess = await validateWorkflowAccess(request, workflowId, {
5444
requireDeployment: false,
@@ -59,35 +49,13 @@ async function validateLifecycleAdminAccess(
5949
return {
6050
error: hybridAccess.error,
6151
auth: hybridAccess.auth,
62-
session: null,
6352
workflow: hybridAccess.workflow,
6453
}
6554
}
6655

67-
if (hybridAccess.auth?.authType === 'session') {
68-
const sessionAccess = await validateWorkflowPermissions(workflowId, requestId, 'admin')
69-
const auth: AuthResult | null = sessionAccess.session?.user?.id
70-
? {
71-
success: true,
72-
userId: sessionAccess.session.user.id,
73-
userName: sessionAccess.session.user.name,
74-
userEmail: sessionAccess.session.user.email,
75-
authType: 'session',
76-
}
77-
: null
78-
79-
return {
80-
error: sessionAccess.error,
81-
auth,
82-
session: sessionAccess.session,
83-
workflow: sessionAccess.workflow,
84-
}
85-
}
86-
8756
return {
8857
error: null,
8958
auth: hybridAccess.auth,
90-
session: null,
9159
workflow: hybridAccess.workflow,
9260
}
9361
}
@@ -178,17 +146,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
178146
const { id } = await params
179147

180148
try {
181-
const {
182-
auth,
183-
error,
184-
session,
185-
workflow: workflowData,
186-
} = await validateLifecycleAdminAccess(request, id, requestId)
149+
const { auth, error, workflow: workflowData } = await validateLifecycleAdminAccess(request, id)
187150
if (error) {
188151
return createErrorResponse(error.message, error.status)
189152
}
190153

191-
const actorUserId: string | null = session?.user?.id ?? auth?.userId ?? null
154+
const actorUserId: string | null = auth?.userId ?? null
192155
if (!actorUserId) {
193156
logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`)
194157
return createErrorResponse('Unable to determine deploying user', 400)
@@ -329,8 +292,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
329292
recordAudit({
330293
workspaceId: workflowData?.workspaceId || null,
331294
actorId: actorUserId,
332-
actorName: session?.user?.name,
333-
actorEmail: session?.user?.email,
295+
actorName: auth?.userName,
296+
actorEmail: auth?.userEmail,
334297
action: AuditAction.WORKFLOW_DEPLOYED,
335298
resourceType: AuditResourceType.WORKFLOW,
336299
resourceId: id,
@@ -374,7 +337,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
374337
const { id } = await params
375338

376339
try {
377-
const { auth, error, session } = await validateLifecycleAdminAccess(request, id, requestId)
340+
const { auth, error } = await validateLifecycleAdminAccess(request, id)
378341
if (error) {
379342
return createErrorResponse(error.message, error.status)
380343
}
@@ -390,7 +353,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
390353
const { validatePublicApiAllowed, PublicApiNotAllowedError } = await import(
391354
'@/ee/access-control/utils/permission-check'
392355
)
393-
const actorUserId = session?.user?.id ?? auth?.userId
356+
const actorUserId = auth?.userId
394357
try {
395358
await validatePublicApiAllowed(actorUserId)
396359
} catch (err) {
@@ -421,17 +384,12 @@ export async function DELETE(
421384
const { id } = await params
422385

423386
try {
424-
const {
425-
auth,
426-
error,
427-
session,
428-
workflow: workflowData,
429-
} = await validateLifecycleAdminAccess(request, id, requestId)
387+
const { auth, error, workflow: workflowData } = await validateLifecycleAdminAccess(request, id)
430388
if (error) {
431389
return createErrorResponse(error.message, error.status)
432390
}
433391

434-
const actorUserId = session?.user?.id ?? auth?.userId ?? null
392+
const actorUserId = auth?.userId ?? null
435393
if (!actorUserId) {
436394
return createErrorResponse('Unable to determine undeploying user', 400)
437395
}
@@ -458,8 +416,8 @@ export async function DELETE(
458416
recordAudit({
459417
workspaceId: workflowData?.workspaceId || null,
460418
actorId: actorUserId,
461-
actorName: session?.user?.name,
462-
actorEmail: session?.user?.email,
419+
actorName: auth?.userName,
420+
actorEmail: auth?.userEmail,
463421
action: AuditAction.WORKFLOW_UNDEPLOYED,
464422
resourceType: AuditResourceType.WORKFLOW,
465423
resourceId: id,

apps/sim/app/api/workflows/[id]/deployed/route.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ describe('Workflow deployed-state route', () => {
5656
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', {
5757
requireDeployment: false,
5858
action: 'read',
59-
allowInternalSecret: false,
6059
})
6160
})
6261
})

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
3434
const validation = await validateWorkflowAccess(request, id, {
3535
requireDeployment: false,
3636
action: 'read',
37-
allowInternalSecret: false,
3837
})
3938
if (validation.error) {
4039
const response = createErrorResponse(validation.error.message, validation.error.status)

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

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ describe('Workflow By ID API Route', () => {
407407
const response = await DELETE(req, { params })
408408

409409
expect(response.status).toBe(200)
410+
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
410411
})
411412

412413
it('should prevent deletion of the last workflow in workspace', async () => {
@@ -447,22 +448,11 @@ describe('Workflow By ID API Route', () => {
447448
})
448449

449450
it.concurrent('should deny deletion for non-admin users', async () => {
450-
const mockWorkflow = {
451-
id: 'workflow-123',
452-
userId: 'other-user',
453-
name: 'Test Workflow',
454-
workspaceId: 'workspace-456',
455-
}
456-
457-
mockGetSession({ user: { id: 'user-123' } })
458-
459-
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
460-
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
461-
allowed: false,
462-
status: 403,
463-
message: 'Unauthorized: Access denied to admin this workflow',
464-
workflow: mockWorkflow,
465-
workspacePermission: null,
451+
mockValidateWorkflowAccess.mockResolvedValue({
452+
error: {
453+
message: 'Unauthorized: Access denied to admin this workflow',
454+
status: 403,
455+
},
466456
})
467457

468458
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
@@ -531,6 +521,7 @@ describe('Workflow By ID API Route', () => {
531521
expect(response.status).toBe(200)
532522
const data = await response.json()
533523
expect(data.workflow.name).toBe('Updated Workflow')
524+
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
534525
})
535526

536527
it('should allow users with write permission to update workflow', async () => {
@@ -578,24 +569,13 @@ describe('Workflow By ID API Route', () => {
578569
})
579570

580571
it('should deny update for users with only read permission', async () => {
581-
const mockWorkflow = {
582-
id: 'workflow-123',
583-
userId: 'other-user',
584-
name: 'Test Workflow',
585-
workspaceId: 'workspace-456',
586-
}
587-
588572
const updateData = { name: 'Updated Workflow' }
589573

590-
mockGetSession({ user: { id: 'user-123' } })
591-
592-
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
593-
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
594-
allowed: false,
595-
status: 403,
596-
message: 'Unauthorized: Access denied to write this workflow',
597-
workflow: mockWorkflow,
598-
workspacePermission: 'read',
574+
mockValidateWorkflowAccess.mockResolvedValue({
575+
error: {
576+
message: 'Unauthorized: Access denied to write this workflow',
577+
status: 403,
578+
},
599579
})
600580

601581
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
@@ -764,7 +744,10 @@ describe('Workflow By ID API Route', () => {
764744

765745
const updatedWorkflow = { ...mockWorkflow, folderId: 'folder-2', updatedAt: new Date() }
766746

767-
mockGetSession({ user: { id: 'user-123' } })
747+
mockValidateWorkflowAccess.mockResolvedValue({
748+
workflow: mockWorkflow,
749+
auth: { success: true, userId: 'user-123', authType: 'session' },
750+
})
768751
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
769752
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
770753
allowed: true,
@@ -806,7 +789,10 @@ describe('Workflow By ID API Route', () => {
806789
workspaceId: 'workspace-456',
807790
}
808791

809-
mockGetSession({ user: { id: 'user-123' } })
792+
mockValidateWorkflowAccess.mockResolvedValue({
793+
workflow: mockWorkflow,
794+
auth: { success: true, userId: 'user-123', authType: 'session' },
795+
})
810796
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
811797
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
812798
allowed: true,

0 commit comments

Comments
 (0)