Skip to content

Commit 556b67e

Browse files
fix(workflows): require auth before deployment checks
1 parent 6f2e6eb commit 556b67e

File tree

2 files changed

+173
-38
lines changed

2 files changed

+173
-38
lines changed

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

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -268,10 +268,21 @@ describe('validateWorkflowAccess', () => {
268268
})
269269

270270
it('returns 404 for deployed access when workflow is missing', async () => {
271+
mockAuthenticateApiKeyFromHeader.mockResolvedValue({
272+
success: true,
273+
userId: 'user-1',
274+
keyId: 'key-1',
275+
keyType: 'workspace',
276+
workspaceId: WORKSPACE_ID,
277+
})
271278
mockGetActiveWorkflowRecord.mockResolvedValue(null)
272279
mockGetWorkflowById.mockResolvedValue(null)
273280

274-
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
281+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
282+
headers: { 'x-api-key': 'valid-key' },
283+
})
284+
285+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
275286
requireDeployment: true,
276287
})
277288

@@ -282,14 +293,70 @@ describe('validateWorkflowAccess', () => {
282293
},
283294
})
284295
expect(mockCheckHybridAuth).not.toHaveBeenCalled()
296+
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenNthCalledWith(1, 'valid-key', {
297+
keyTypes: ['workspace', 'personal'],
298+
})
299+
})
300+
301+
it('returns 401 before deployed workflow lookup when api key is missing', async () => {
302+
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
303+
requireDeployment: true,
304+
})
305+
306+
expect(result).toEqual({
307+
error: {
308+
message: 'Unauthorized: API key required',
309+
status: 401,
310+
},
311+
})
312+
expect(mockGetActiveWorkflowRecord).not.toHaveBeenCalled()
313+
expect(mockGetWorkflowById).not.toHaveBeenCalled()
285314
expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled()
286315
})
287316

288-
it('returns 403 for deployed access when workflow has no workspace', async () => {
317+
it('returns 401 before deployed workflow lookup when api key is invalid', async () => {
318+
mockAuthenticateApiKeyFromHeader.mockResolvedValue({
319+
success: false,
320+
error: 'Invalid API key',
321+
})
322+
323+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
324+
headers: { 'x-api-key': 'invalid-key' },
325+
})
326+
327+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
328+
requireDeployment: true,
329+
})
330+
331+
expect(result).toEqual({
332+
error: {
333+
message: 'Unauthorized: Invalid API key',
334+
status: 401,
335+
},
336+
})
337+
expect(mockGetActiveWorkflowRecord).not.toHaveBeenCalled()
338+
expect(mockGetWorkflowById).not.toHaveBeenCalled()
339+
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('invalid-key', {
340+
keyTypes: ['workspace', 'personal'],
341+
})
342+
})
343+
344+
it('returns 403 for deployed access when authenticated workflow has no workspace', async () => {
345+
mockAuthenticateApiKeyFromHeader.mockResolvedValue({
346+
success: true,
347+
userId: 'user-1',
348+
keyId: 'key-1',
349+
keyType: 'workspace',
350+
workspaceId: WORKSPACE_ID,
351+
})
289352
mockGetActiveWorkflowRecord.mockResolvedValue(null)
290353
mockGetWorkflowById.mockResolvedValue(createWorkflow({ workspaceId: null, isDeployed: true }))
291354

292-
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
355+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
356+
headers: { 'x-api-key': 'valid-key' },
357+
})
358+
359+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
293360
requireDeployment: true,
294361
})
295362

@@ -301,14 +368,27 @@ describe('validateWorkflowAccess', () => {
301368
},
302369
})
303370
expect(mockCheckHybridAuth).not.toHaveBeenCalled()
304-
expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled()
371+
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenNthCalledWith(1, 'valid-key', {
372+
keyTypes: ['workspace', 'personal'],
373+
})
305374
})
306375

307-
it('returns 404 for deployed access when workflow workspace is archived', async () => {
376+
it('returns 404 for deployed access when authenticated workflow workspace is archived', async () => {
377+
mockAuthenticateApiKeyFromHeader.mockResolvedValue({
378+
success: true,
379+
userId: 'user-1',
380+
keyId: 'key-1',
381+
keyType: 'workspace',
382+
workspaceId: WORKSPACE_ID,
383+
})
308384
mockGetActiveWorkflowRecord.mockResolvedValue(null)
309385
mockGetWorkflowById.mockResolvedValue(createWorkflow({ isDeployed: true }))
310386

311-
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
387+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
388+
headers: { 'x-api-key': 'valid-key' },
389+
})
390+
391+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
312392
requireDeployment: true,
313393
})
314394

@@ -320,6 +400,38 @@ describe('validateWorkflowAccess', () => {
320400
})
321401
expect(mockGetWorkflowById).toHaveBeenCalledWith(WORKFLOW_ID)
322402
expect(mockCheckHybridAuth).not.toHaveBeenCalled()
323-
expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled()
403+
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenNthCalledWith(1, 'valid-key', {
404+
keyTypes: ['workspace', 'personal'],
405+
})
406+
})
407+
408+
it('returns 403 for deployed access when authenticated workflow is not deployed', async () => {
409+
mockAuthenticateApiKeyFromHeader.mockResolvedValue({
410+
success: true,
411+
userId: 'user-1',
412+
keyId: 'key-1',
413+
keyType: 'workspace',
414+
workspaceId: WORKSPACE_ID,
415+
})
416+
mockGetActiveWorkflowRecord.mockResolvedValue(createWorkflow({ isDeployed: false }))
417+
418+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
419+
headers: { 'x-api-key': 'valid-key' },
420+
})
421+
422+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
423+
requireDeployment: true,
424+
})
425+
426+
expect(result).toEqual({
427+
error: {
428+
message: 'Workflow is not deployed',
429+
status: 403,
430+
},
431+
})
432+
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('valid-key', {
433+
keyTypes: ['workspace', 'personal'],
434+
})
435+
expect(mockUpdateApiKeyLastUsed).not.toHaveBeenCalled()
324436
})
325437
})

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

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -118,51 +118,66 @@ export async function validateWorkflowAccess(
118118
return { workflow, auth }
119119
}
120120

121-
const workflowResult = await getValidatedWorkflow(workflowId)
122-
if (workflowResult.error || !workflowResult.workflow) {
123-
return workflowResult
124-
}
125-
const workflow = workflowResult.workflow
126-
127121
if (requireDeployment) {
128-
if (!workflow.isDeployed) {
129-
return {
130-
error: {
131-
message: 'Workflow is not deployed',
132-
status: 403,
133-
},
122+
const internalSecret = request.headers.get('X-Internal-Secret')
123+
const hasValidInternalSecret =
124+
allowInternalSecret && env.INTERNAL_API_SECRET && internalSecret === env.INTERNAL_API_SECRET
125+
126+
let apiKeyHeader: string | null = null
127+
128+
if (!hasValidInternalSecret) {
129+
for (const [key, value] of request.headers.entries()) {
130+
if (key.toLowerCase() === 'x-api-key' && value) {
131+
apiKeyHeader = value
132+
break
133+
}
134134
}
135-
}
136135

137-
const internalSecret = request.headers.get('X-Internal-Secret')
138-
if (
139-
allowInternalSecret &&
140-
env.INTERNAL_API_SECRET &&
141-
internalSecret === env.INTERNAL_API_SECRET
142-
) {
143-
return { workflow }
144-
}
136+
if (!apiKeyHeader) {
137+
return {
138+
error: {
139+
message: 'Unauthorized: API key required',
140+
status: 401,
141+
},
142+
}
143+
}
145144

146-
let apiKeyHeader = null
147-
for (const [key, value] of request.headers.entries()) {
148-
if (key.toLowerCase() === 'x-api-key' && value) {
149-
apiKeyHeader = value
150-
break
145+
const preflightResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
146+
keyTypes: ['workspace', 'personal'],
147+
})
148+
149+
if (!preflightResult.success) {
150+
return {
151+
error: {
152+
message: 'Unauthorized: Invalid API key',
153+
status: 401,
154+
},
155+
}
151156
}
152157
}
153158

154-
if (!apiKeyHeader) {
159+
const workflowResult = await getValidatedWorkflow(workflowId)
160+
if (workflowResult.error || !workflowResult.workflow) {
161+
return workflowResult
162+
}
163+
const workflow = workflowResult.workflow
164+
165+
if (!workflow.isDeployed) {
155166
return {
156167
error: {
157-
message: 'Unauthorized: API key required',
158-
status: 401,
168+
message: 'Workflow is not deployed',
169+
status: 403,
159170
},
160171
}
161172
}
162173

174+
if (hasValidInternalSecret) {
175+
return { workflow }
176+
}
177+
163178
let validResult: ApiKeyAuthResult | null = null
164179

165-
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
180+
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader!, {
166181
workspaceId: workflow.workspaceId as string,
167182
keyTypes: ['workspace', 'personal'],
168183
})
@@ -183,8 +198,16 @@ export async function validateWorkflowAccess(
183198
if (validResult.keyId) {
184199
await updateApiKeyLastUsed(validResult.keyId)
185200
}
201+
202+
return { workflow }
203+
}
204+
205+
return {
206+
error: {
207+
message: 'Internal server error',
208+
status: 500,
209+
},
186210
}
187-
return { workflow }
188211
} catch (error) {
189212
logger.error('Validation error:', { error })
190213
return {

0 commit comments

Comments
 (0)