Skip to content

Commit 017ca88

Browse files
fix(workflows): enforce scoped middleware api keys
1 parent 05c9f6f commit 017ca88

File tree

2 files changed

+174
-5
lines changed

2 files changed

+174
-5
lines changed

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

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ describe('validateWorkflowAccess', () => {
195195
})
196196

197197
it('returns 404 for workspace api keys scoped to a different workspace', async () => {
198+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
199+
headers: { 'x-api-key': 'workspace-key' },
200+
})
198201
const auth = {
199202
success: true,
200203
userId: 'user-1',
@@ -204,21 +207,118 @@ describe('validateWorkflowAccess', () => {
204207
}
205208

206209
mockCheckHybridAuth.mockResolvedValue(auth)
210+
mockAuthenticateApiKeyFromHeader.mockResolvedValue({
211+
success: false,
212+
error: 'Invalid API key',
213+
})
207214

208-
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
215+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
209216
requireDeployment: false,
210217
action: 'read',
211218
})
212219

213220
expect(result).toEqual({
214221
error: {
215-
message: 'Workflow not found',
216-
status: 404,
222+
message: 'Unauthorized: Invalid API key',
223+
status: 401,
217224
},
218225
})
226+
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('workspace-key', {
227+
workspaceId: WORKSPACE_ID,
228+
keyTypes: ['workspace', 'personal'],
229+
})
219230
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
220231
})
221232

233+
it('denies personal api keys when the workflow workspace disallows them', async () => {
234+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
235+
headers: { 'x-api-key': 'personal-key' },
236+
})
237+
const auth = {
238+
success: true,
239+
userId: 'user-1',
240+
authType: 'api_key' as const,
241+
apiKeyType: 'personal' as const,
242+
}
243+
244+
mockCheckHybridAuth.mockResolvedValue(auth)
245+
mockAuthenticateApiKeyFromHeader.mockResolvedValue({
246+
success: false,
247+
error: 'Invalid API key',
248+
})
249+
250+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
251+
requireDeployment: false,
252+
action: 'read',
253+
})
254+
255+
expect(result).toEqual({
256+
error: {
257+
message: 'Unauthorized: Invalid API key',
258+
status: 401,
259+
},
260+
})
261+
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('personal-key', {
262+
workspaceId: WORKSPACE_ID,
263+
keyTypes: ['workspace', 'personal'],
264+
})
265+
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
266+
})
267+
268+
it('allows personal api keys when the workflow workspace permits them', async () => {
269+
const workflow = createWorkflow({ name: 'Personal Key Workflow' })
270+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
271+
headers: { 'x-api-key': 'personal-key' },
272+
})
273+
const auth = {
274+
success: true,
275+
userId: 'user-1',
276+
authType: 'api_key' as const,
277+
apiKeyType: 'personal' as const,
278+
}
279+
280+
mockCheckHybridAuth.mockResolvedValue(auth)
281+
mockGetActiveWorkflowRecord.mockResolvedValue(workflow)
282+
mockAuthenticateApiKeyFromHeader.mockResolvedValue({
283+
success: true,
284+
userId: 'user-1',
285+
userName: 'Personal Key User',
286+
userEmail: 'personal@example.com',
287+
keyId: 'key-1',
288+
keyType: 'personal',
289+
workspaceId: WORKSPACE_ID,
290+
})
291+
292+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
293+
requireDeployment: false,
294+
action: 'read',
295+
})
296+
297+
expect(result).toEqual({
298+
workflow,
299+
auth: {
300+
success: true,
301+
userId: 'user-1',
302+
workspaceId: WORKSPACE_ID,
303+
userName: 'Personal Key User',
304+
userEmail: 'personal@example.com',
305+
authType: 'api_key',
306+
apiKeyType: 'personal',
307+
},
308+
})
309+
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('personal-key', {
310+
workspaceId: WORKSPACE_ID,
311+
keyTypes: ['workspace', 'personal'],
312+
})
313+
expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1')
314+
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
315+
workflowId: WORKFLOW_ID,
316+
userId: 'user-1',
317+
action: 'read',
318+
workflow,
319+
})
320+
})
321+
222322
it('preserves session auth semantics for accessible workflows', async () => {
223323
const workflow = createWorkflow({ name: 'Session Workflow' })
224324
const auth = { success: true, userId: 'user-1', authType: 'session' as const }
@@ -242,6 +342,9 @@ describe('validateWorkflowAccess', () => {
242342

243343
it('allows workspace api keys scoped to the same workspace', async () => {
244344
const workflow = createWorkflow({ name: 'Scoped Workflow' })
345+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
346+
headers: { 'x-api-key': 'workspace-key' },
347+
})
245348
const auth = {
246349
success: true,
247350
userId: 'user-1',
@@ -252,13 +355,34 @@ describe('validateWorkflowAccess', () => {
252355

253356
mockCheckHybridAuth.mockResolvedValue(auth)
254357
mockGetActiveWorkflowRecord.mockResolvedValue(workflow)
358+
mockAuthenticateApiKeyFromHeader.mockResolvedValue({
359+
success: true,
360+
userId: 'user-1',
361+
keyId: 'key-1',
362+
keyType: 'workspace',
363+
workspaceId: WORKSPACE_ID,
364+
})
255365

256-
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
366+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
257367
requireDeployment: false,
258368
action: 'read',
259369
})
260370

261-
expect(result).toEqual({ workflow, auth })
371+
expect(result).toEqual({
372+
workflow,
373+
auth: {
374+
success: true,
375+
userId: 'user-1',
376+
workspaceId: WORKSPACE_ID,
377+
authType: 'api_key',
378+
apiKeyType: 'workspace',
379+
},
380+
})
381+
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('workspace-key', {
382+
workspaceId: WORKSPACE_ID,
383+
keyTypes: ['workspace', 'personal'],
384+
})
385+
expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1')
262386
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
263387
workflowId: WORKFLOW_ID,
264388
userId: 'user-1',

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,51 @@ export async function validateWorkflowAccess(
8787
}
8888
const workflow = workflowResult.workflow
8989

90+
if (auth.authType === AuthType.API_KEY) {
91+
const apiKeyHeader = request.headers.get('x-api-key')
92+
if (!apiKeyHeader) {
93+
return {
94+
error: {
95+
message: 'Unauthorized: Invalid API key',
96+
status: 401,
97+
},
98+
}
99+
}
100+
101+
const scopedApiKeyResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
102+
workspaceId: workflow.workspaceId as string,
103+
keyTypes: ['workspace', 'personal'],
104+
})
105+
106+
if (!scopedApiKeyResult.success) {
107+
return {
108+
error: {
109+
message: 'Unauthorized: Invalid API key',
110+
status: 401,
111+
},
112+
}
113+
}
114+
115+
if (!scopedApiKeyResult.userId) {
116+
return {
117+
error: {
118+
message: 'Unauthorized: Invalid API key',
119+
status: 401,
120+
},
121+
}
122+
}
123+
124+
if (scopedApiKeyResult.keyId) {
125+
await updateApiKeyLastUsed(scopedApiKeyResult.keyId)
126+
}
127+
128+
auth.workspaceId = scopedApiKeyResult.workspaceId
129+
auth.userId = scopedApiKeyResult.userId
130+
auth.userName = scopedApiKeyResult.userName
131+
auth.userEmail = scopedApiKeyResult.userEmail
132+
auth.apiKeyType = scopedApiKeyResult.keyType
133+
}
134+
90135
if (
91136
auth.authType === AuthType.API_KEY &&
92137
auth.apiKeyType === 'workspace' &&

0 commit comments

Comments
 (0)