Skip to content

Commit 6e225ac

Browse files
fix(workflows): scope get-by-id auth
1 parent 9755e6a commit 6e225ac

File tree

2 files changed

+192
-94
lines changed

2 files changed

+192
-94
lines changed

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

Lines changed: 159 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ const mockDbUpdate = vi.fn()
2626
const mockDbSelect = vi.fn()
2727
const mockValidateWorkflowAccess = vi.fn()
2828

29+
const READ_VALIDATION = {
30+
requireDeployment: false,
31+
action: 'read',
32+
} as const
33+
2934
/**
3035
* Helper to set mock auth state consistently across getSession and hybrid auth.
3136
*/
@@ -127,12 +132,14 @@ describe('Workflow By ID API Route', () => {
127132
expect(response.status).toBe(401)
128133
const data = await response.json()
129134
expect(data.error).toBe('Unauthorized')
135+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
130136
})
131137

132138
it('should return 404 when workflow does not exist', async () => {
133139
mockGetSession({ user: { id: 'user-123' } })
134-
135-
mockGetWorkflowById.mockResolvedValue(null)
140+
mockValidateWorkflowAccess.mockResolvedValue({
141+
error: { message: 'Workflow not found', status: 404 },
142+
})
136143

137144
const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent')
138145
const params = Promise.resolve({ id: 'nonexistent' })
@@ -142,24 +149,20 @@ describe('Workflow By ID API Route', () => {
142149
expect(response.status).toBe(404)
143150
const data = await response.json()
144151
expect(data.error).toBe('Workflow not found')
152+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'nonexistent', READ_VALIDATION)
145153
})
146154

147155
it('should return 404 for workspace api key targeting a workflow in another workspace', async () => {
148-
const mockWorkflow = {
149-
id: 'workflow-123',
150-
userId: 'other-user',
151-
name: 'Foreign Workflow',
152-
workspaceId: 'workspace-b',
153-
}
154-
155156
mockCheckHybridAuth.mockResolvedValue({
156157
success: true,
157158
userId: 'api-user',
158159
authType: 'api_key',
159160
apiKeyType: 'workspace',
160161
workspaceId: 'workspace-a',
161162
})
162-
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
163+
mockValidateWorkflowAccess.mockResolvedValue({
164+
error: { message: 'Workflow not found', status: 404 },
165+
})
163166

164167
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
165168
headers: { 'x-api-key': 'test-key' },
@@ -171,32 +174,124 @@ describe('Workflow By ID API Route', () => {
171174
expect(response.status).toBe(404)
172175
const data = await response.json()
173176
expect(data.error).toBe('Workflow not found')
177+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
178+
expect(mockGetWorkflowById).not.toHaveBeenCalled()
174179
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
175180
})
176181

177-
it('should return 401 for verified internal jwt without userId', async () => {
182+
it('should allow verified internal jwt without userId through the narrow precheck', async () => {
183+
const mockWorkflow = {
184+
id: 'workflow-123',
185+
userId: 'other-user',
186+
name: 'Internal Workflow',
187+
workspaceId: 'workspace-456',
188+
isDeployed: false,
189+
deployedAt: null,
190+
variables: {},
191+
}
192+
const mockNormalizedData = {
193+
blocks: {},
194+
edges: [],
195+
loops: {},
196+
parallels: {},
197+
isFromNormalizedTables: true,
198+
}
199+
178200
mockCheckHybridAuth.mockResolvedValue({
179201
success: true,
180202
authType: 'internal_jwt',
181203
})
182-
mockGetWorkflowById.mockResolvedValue({
204+
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
205+
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
206+
207+
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
208+
headers: { authorization: 'Bearer internal-token' },
209+
})
210+
const params = Promise.resolve({ id: 'workflow-123' })
211+
212+
const response = await GET(req, { params })
213+
214+
expect(response.status).toBe(200)
215+
const data = await response.json()
216+
expect(data.data.id).toBe('workflow-123')
217+
expect(data.data.state.blocks).toEqual(mockNormalizedData.blocks)
218+
expect(data.data.variables).toEqual({})
219+
expect(mockValidateWorkflowAccess).not.toHaveBeenCalled()
220+
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
221+
})
222+
223+
it('should deny personal api key reads when middleware rejects workspace policy', async () => {
224+
mockCheckHybridAuth.mockResolvedValue({
225+
success: true,
226+
userId: 'api-user',
227+
authType: 'api_key',
228+
apiKeyType: 'personal',
229+
})
230+
mockValidateWorkflowAccess.mockResolvedValue({
231+
error: { message: 'Unauthorized: Invalid API key', status: 401 },
232+
})
233+
234+
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
235+
headers: { 'x-api-key': 'personal-key' },
236+
})
237+
const params = Promise.resolve({ id: 'workflow-123' })
238+
239+
const response = await GET(req, { params })
240+
241+
expect(response.status).toBe(401)
242+
expect(await response.json()).toEqual({ error: 'Unauthorized: Invalid API key' })
243+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
244+
})
245+
246+
it('should allow personal api key reads when middleware returns scoped success', async () => {
247+
const mockWorkflow = {
183248
id: 'workflow-123',
184249
userId: 'other-user',
185-
name: 'Internal Workflow',
250+
name: 'Scoped Personal Workflow',
186251
workspaceId: 'workspace-456',
252+
isDeployed: false,
253+
deployedAt: null,
254+
variables: { secret: 'ok' },
255+
}
256+
const mockNormalizedData = {
257+
blocks: {},
258+
edges: [],
259+
loops: {},
260+
parallels: {},
261+
isFromNormalizedTables: true,
262+
}
263+
264+
mockCheckHybridAuth.mockResolvedValue({
265+
success: true,
266+
userId: 'api-user',
267+
authType: 'api_key',
268+
apiKeyType: 'personal',
187269
})
270+
mockValidateWorkflowAccess.mockResolvedValue({
271+
workflow: mockWorkflow,
272+
auth: {
273+
success: true,
274+
userId: 'api-user',
275+
authType: 'api_key',
276+
apiKeyType: 'personal',
277+
workspaceId: 'workspace-456',
278+
},
279+
})
280+
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
188281

189282
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
190-
headers: { authorization: 'Bearer internal-token' },
283+
headers: { 'x-api-key': 'personal-key' },
191284
})
192285
const params = Promise.resolve({ id: 'workflow-123' })
193286

194287
const response = await GET(req, { params })
195288

196-
expect(response.status).toBe(401)
289+
expect(response.status).toBe(200)
197290
const data = await response.json()
198-
expect(data.error).toBe('Unauthorized')
199-
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
291+
expect(data.data.id).toBe('workflow-123')
292+
expect(data.data.variables).toEqual({ secret: 'ok' })
293+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
294+
expect(mockGetWorkflowById).not.toHaveBeenCalled()
200295
})
201296

202297
it('should allow access when user has admin workspace permission', async () => {
@@ -216,13 +311,9 @@ describe('Workflow By ID API Route', () => {
216311
}
217312

218313
mockGetSession({ user: { id: 'user-123' } })
219-
220-
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
221-
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
222-
allowed: true,
223-
status: 200,
314+
mockValidateWorkflowAccess.mockResolvedValue({
224315
workflow: mockWorkflow,
225-
workspacePermission: 'admin',
316+
auth: { success: true, userId: 'user-123', authType: 'session' },
226317
})
227318

228319
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
@@ -235,6 +326,7 @@ describe('Workflow By ID API Route', () => {
235326
expect(response.status).toBe(200)
236327
const data = await response.json()
237328
expect(data.data.id).toBe('workflow-123')
329+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
238330
})
239331

240332
it('should allow access when user has workspace permissions', async () => {
@@ -254,13 +346,9 @@ describe('Workflow By ID API Route', () => {
254346
}
255347

256348
mockGetSession({ user: { id: 'user-123' } })
257-
258-
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
259-
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
260-
allowed: true,
261-
status: 200,
349+
mockValidateWorkflowAccess.mockResolvedValue({
262350
workflow: mockWorkflow,
263-
workspacePermission: 'read',
351+
auth: { success: true, userId: 'user-123', authType: 'session' },
264352
})
265353

266354
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
@@ -273,6 +361,7 @@ describe('Workflow By ID API Route', () => {
273361
expect(response.status).toBe(200)
274362
const data = await response.json()
275363
expect(data.data.id).toBe('workflow-123')
364+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
276365
})
277366

278367
it('should keep session access semantics unchanged for readable workflows', async () => {
@@ -284,12 +373,9 @@ describe('Workflow By ID API Route', () => {
284373
}
285374

286375
mockGetSession({ user: { id: 'user-123' } })
287-
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
288-
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
289-
allowed: true,
290-
status: 200,
376+
mockValidateWorkflowAccess.mockResolvedValue({
291377
workflow: mockWorkflow,
292-
workspacePermission: 'read',
378+
auth: { success: true, userId: 'user-123', authType: 'session' },
293379
})
294380

295381
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
@@ -300,30 +386,49 @@ describe('Workflow By ID API Route', () => {
300386
expect(response.status).toBe(200)
301387
const data = await response.json()
302388
expect(data.data.id).toBe('workflow-123')
303-
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
304-
workflowId: 'workflow-123',
305-
userId: 'user-123',
306-
action: 'read',
307-
})
389+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
308390
})
309391

310-
it('should deny access when user has no workspace permissions', async () => {
392+
it('should not use the internal precheck for user-backed internal callers', async () => {
311393
const mockWorkflow = {
312394
id: 'workflow-123',
313395
userId: 'other-user',
314-
name: 'Test Workflow',
396+
name: 'Internal User Workflow',
315397
workspaceId: 'workspace-456',
398+
isDeployed: false,
399+
deployedAt: null,
400+
variables: {},
316401
}
317402

318-
mockGetSession({ user: { id: 'user-123' } })
319-
320-
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
321-
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
322-
allowed: false,
323-
status: 403,
324-
message: 'Unauthorized: Access denied to read this workflow',
403+
mockCheckHybridAuth.mockResolvedValue({
404+
success: true,
405+
userId: 'internal-user',
406+
authType: 'internal_jwt',
407+
})
408+
mockValidateWorkflowAccess.mockResolvedValue({
325409
workflow: mockWorkflow,
326-
workspacePermission: null,
410+
auth: { success: true, userId: 'internal-user', authType: 'internal_jwt' },
411+
})
412+
413+
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
414+
headers: { authorization: 'Bearer internal-token' },
415+
})
416+
const params = Promise.resolve({ id: 'workflow-123' })
417+
418+
const response = await GET(req, { params })
419+
420+
expect(response.status).toBe(200)
421+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
422+
expect(mockGetWorkflowById).not.toHaveBeenCalled()
423+
})
424+
425+
it('should deny access when user has no workspace permissions', async () => {
426+
mockGetSession({ user: { id: 'user-123' } })
427+
mockValidateWorkflowAccess.mockResolvedValue({
428+
error: {
429+
message: 'Unauthorized: Access denied to read this workflow',
430+
status: 403,
431+
},
327432
})
328433

329434
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
@@ -334,6 +439,7 @@ describe('Workflow By ID API Route', () => {
334439
expect(response.status).toBe(403)
335440
const data = await response.json()
336441
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
442+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
337443
})
338444

339445
it('should use normalized tables when available', async () => {
@@ -353,13 +459,9 @@ describe('Workflow By ID API Route', () => {
353459
}
354460

355461
mockGetSession({ user: { id: 'user-123' } })
356-
357-
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
358-
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
359-
allowed: true,
360-
status: 200,
462+
mockValidateWorkflowAccess.mockResolvedValue({
361463
workflow: mockWorkflow,
362-
workspacePermission: 'admin',
464+
auth: { success: true, userId: 'user-123', authType: 'session' },
363465
})
364466

365467
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
@@ -373,6 +475,7 @@ describe('Workflow By ID API Route', () => {
373475
const data = await response.json()
374476
expect(data.data.state.blocks).toEqual(mockNormalizedData.blocks)
375477
expect(data.data.state.edges).toEqual(mockNormalizedData.edges)
478+
expect(mockValidateWorkflowAccess).toHaveBeenCalledWith(req, 'workflow-123', READ_VALIDATION)
376479
})
377480
})
378481

@@ -966,8 +1069,7 @@ describe('Workflow By ID API Route', () => {
9661069
describe('Error handling', () => {
9671070
it('should handle database errors gracefully', async () => {
9681071
mockGetSession({ user: { id: 'user-123' } })
969-
970-
mockGetWorkflowById.mockRejectedValue(new Error('Database connection timeout'))
1072+
mockValidateWorkflowAccess.mockRejectedValue(new Error('Database connection timeout'))
9711073

9721074
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
9731075
const params = Promise.resolve({ id: 'workflow-123' })

0 commit comments

Comments
 (0)