Skip to content

Commit 568c128

Browse files
PlaneInABottletest
authored andcommitted
fix(workflows): tighten shared workflow access validation
1 parent 4cb0f4a commit 568c128

File tree

5 files changed

+167
-8
lines changed

5 files changed

+167
-8
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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 mockVerifyInternalToken = vi.fn()
10+
const mockLoadDeployedWorkflowState = vi.fn()
11+
12+
vi.mock('@sim/logger', () => ({
13+
createLogger: () => ({ warn: vi.fn(), error: vi.fn() }),
14+
}))
15+
16+
vi.mock('@/app/api/workflows/middleware', () => ({
17+
validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args),
18+
}))
19+
20+
vi.mock('@/lib/auth/internal', () => ({
21+
verifyInternalToken: (...args: unknown[]) => mockVerifyInternalToken(...args),
22+
}))
23+
24+
vi.mock('@/lib/core/utils/request', () => ({
25+
generateRequestId: () => 'req-123',
26+
}))
27+
28+
vi.mock('@/lib/workflows/persistence/utils', () => ({
29+
loadDeployedWorkflowState: (...args: unknown[]) => mockLoadDeployedWorkflowState(...args),
30+
}))
31+
32+
import { GET } from '@/app/api/workflows/[id]/deployed/route'
33+
34+
describe('Workflow deployed-state route', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
mockVerifyInternalToken.mockResolvedValue({ valid: false })
38+
mockLoadDeployedWorkflowState.mockResolvedValue({
39+
blocks: {},
40+
edges: [],
41+
loops: {},
42+
parallels: {},
43+
variables: [],
44+
})
45+
})
46+
47+
it('uses hybrid workflow access when request is not internal bearer auth', async () => {
48+
mockValidateWorkflowAccess.mockResolvedValue({ workflow: { id: 'wf-1' } })
49+
50+
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/deployed', {
51+
headers: { 'x-api-key': 'test-key' },
52+
})
53+
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
54+
55+
expect(response.status).toBe(200)
56+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', {
57+
requireDeployment: false,
58+
action: 'read',
59+
allowInternalSecret: false,
60+
})
61+
})
62+
})

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { NextRequest, NextResponse } from 'next/server'
33
import { verifyInternalToken } from '@/lib/auth/internal'
44
import { generateRequestId } from '@/lib/core/utils/request'
55
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
6-
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
6+
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
77
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
88

99
const logger = createLogger('WorkflowDeployedStateAPI')
@@ -31,9 +31,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
3131
}
3232

3333
if (!isInternalCall) {
34-
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
35-
if (error) {
36-
const response = createErrorResponse(error.message, error.status)
34+
const validation = await validateWorkflowAccess(request, id, {
35+
requireDeployment: false,
36+
action: 'read',
37+
allowInternalSecret: false,
38+
})
39+
if (validation.error) {
40+
const response = createErrorResponse(validation.error.message, validation.error.status)
3741
return addNoCacheHeaders(response)
3842
}
3943
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 mockLoadWorkflowFromNormalizedTables = vi.fn()
10+
const mockHasWorkflowChanged = vi.fn()
11+
const mockDbSelect = vi.fn()
12+
const mockDbFrom = vi.fn()
13+
const mockDbWhere = vi.fn()
14+
const mockDbLimit = vi.fn()
15+
const mockDbOrderBy = vi.fn()
16+
17+
vi.mock('@sim/logger', () => ({
18+
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
19+
}))
20+
21+
vi.mock('@/app/api/workflows/middleware', () => ({
22+
validateWorkflowAccess: (...args: unknown[]) => mockValidateWorkflowAccess(...args),
23+
}))
24+
25+
vi.mock('@/lib/workflows/persistence/utils', () => ({
26+
loadWorkflowFromNormalizedTables: (...args: unknown[]) => mockLoadWorkflowFromNormalizedTables(...args),
27+
}))
28+
29+
vi.mock('@/lib/workflows/comparison', () => ({
30+
hasWorkflowChanged: (...args: unknown[]) => mockHasWorkflowChanged(...args),
31+
}))
32+
33+
vi.mock('@/lib/core/utils/request', () => ({
34+
generateRequestId: () => 'req-123',
35+
}))
36+
37+
vi.mock('@sim/db', () => ({
38+
db: {
39+
select: mockDbSelect,
40+
},
41+
workflow: { variables: 'variables', id: 'id' },
42+
workflowDeploymentVersion: { state: 'state', workflowId: 'workflowId', isActive: 'isActive', createdAt: 'createdAt' },
43+
}))
44+
45+
vi.mock('drizzle-orm', () => ({
46+
and: vi.fn(),
47+
desc: vi.fn(),
48+
eq: vi.fn(),
49+
}))
50+
51+
import { GET } from '@/app/api/workflows/[id]/status/route'
52+
53+
describe('Workflow status route', () => {
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
mockDbSelect.mockReturnValue({ from: mockDbFrom })
57+
mockDbFrom.mockReturnValue({ where: mockDbWhere })
58+
mockDbWhere.mockReturnValue({ limit: mockDbLimit, orderBy: mockDbOrderBy })
59+
mockDbOrderBy.mockReturnValue({ limit: mockDbLimit })
60+
})
61+
62+
it('uses hybrid workflow access for read auth', async () => {
63+
mockValidateWorkflowAccess.mockResolvedValue({
64+
workflow: { id: 'wf-1', isDeployed: false, deployedAt: null, isPublished: false },
65+
})
66+
67+
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/status', {
68+
headers: { 'x-api-key': 'test-key' },
69+
})
70+
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
71+
72+
expect(response.status).toBe(200)
73+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'wf-1', {
74+
requireDeployment: false,
75+
action: 'read',
76+
})
77+
})
78+
})

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
1717
try {
1818
const { id } = await params
1919

20-
const validation = await validateWorkflowAccess(request, id, false)
20+
const validation = await validateWorkflowAccess(request, id, {
21+
requireDeployment: false,
22+
action: 'read',
23+
})
2124
if (validation.error) {
2225
logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`)
2326
return createErrorResponse(validation.error.message, validation.error.status)

apps/sim/app/api/workflows/middleware.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,24 @@ export interface ValidationResult {
1717
auth?: AuthResult
1818
}
1919

20+
export interface WorkflowAccessOptions {
21+
requireDeployment?: boolean
22+
action?: 'read' | 'write' | 'admin'
23+
allowInternalSecret?: boolean
24+
}
25+
2026
export async function validateWorkflowAccess(
2127
request: NextRequest,
2228
workflowId: string,
23-
requireDeployment = true
29+
options: boolean | WorkflowAccessOptions = true
2430
): Promise<ValidationResult> {
2531
try {
32+
const normalizedOptions: WorkflowAccessOptions =
33+
typeof options === 'boolean' ? { requireDeployment: options } : options
34+
const requireDeployment = normalizedOptions.requireDeployment ?? true
35+
const action = normalizedOptions.action ?? 'read'
36+
const allowInternalSecret = normalizedOptions.allowInternalSecret ?? requireDeployment
37+
2638
const workflow = await getWorkflowById(workflowId)
2739
if (!workflow) {
2840
return {
@@ -57,7 +69,7 @@ export async function validateWorkflowAccess(
5769
const authorization = await authorizeWorkflowByWorkspacePermission({
5870
workflowId,
5971
userId: auth.userId,
60-
action: 'read',
72+
action,
6173
})
6274
if (!authorization.allowed) {
6375
return {
@@ -82,7 +94,7 @@ export async function validateWorkflowAccess(
8294
}
8395

8496
const internalSecret = request.headers.get('X-Internal-Secret')
85-
if (env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET) {
97+
if (allowInternalSecret && env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET) {
8698
return { workflow }
8799
}
88100

0 commit comments

Comments
 (0)