Skip to content

Commit a96c72d

Browse files
fix(workflows): keep api-key auth nullable
1 parent 070f8d0 commit a96c72d

File tree

3 files changed

+154
-2
lines changed

3 files changed

+154
-2
lines changed

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ vi.mock('@/lib/auth/hybrid', () => ({
4444
}))
4545

4646
vi.mock('@/lib/core/config/env', () => ({
47-
env: {},
47+
env: { INTERNAL_API_SECRET: 'internal-secret' },
4848
getEnv: vi.fn(),
4949
}))
5050

@@ -432,4 +432,42 @@ describe('validateWorkflowAccess', () => {
432432
expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledTimes(1)
433433
expect(mockUpdateApiKeyLastUsed).not.toHaveBeenCalled()
434434
})
435+
436+
it('allows internal secret without requiring api key when workflow is deployed', async () => {
437+
mockGetActiveWorkflowRecord.mockResolvedValue(createWorkflow({ isDeployed: true }))
438+
439+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
440+
headers: { 'x-internal-secret': 'internal-secret' },
441+
})
442+
443+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
444+
requireDeployment: true,
445+
allowInternalSecret: true,
446+
})
447+
448+
expect(result).toEqual({ workflow: createWorkflow({ isDeployed: true }) })
449+
expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled()
450+
expect(mockUpdateApiKeyLastUsed).not.toHaveBeenCalled()
451+
})
452+
453+
it('still returns undeployed error before internal secret success when workflow is not deployed', async () => {
454+
mockGetActiveWorkflowRecord.mockResolvedValue(createWorkflow({ isDeployed: false }))
455+
456+
const request = new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`, {
457+
headers: { 'x-internal-secret': 'internal-secret' },
458+
})
459+
460+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
461+
requireDeployment: true,
462+
allowInternalSecret: true,
463+
})
464+
465+
expect(result).toEqual({
466+
error: {
467+
message: 'Workflow is not deployed',
468+
status: 403,
469+
},
470+
})
471+
expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled()
472+
})
435473
})
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockAuthenticateApiKey, mockGetWorkspaceBillingSettings, mockGetUserEntityPermissions } =
8+
vi.hoisted(() => ({
9+
mockAuthenticateApiKey: vi.fn(),
10+
mockGetWorkspaceBillingSettings: vi.fn(),
11+
mockGetUserEntityPermissions: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/api-key/auth', () => ({
15+
authenticateApiKey: (...args: unknown[]) => mockAuthenticateApiKey(...args),
16+
}))
17+
18+
vi.mock('@/lib/workspaces/utils', () => ({
19+
getWorkspaceBillingSettings: (...args: unknown[]) => mockGetWorkspaceBillingSettings(...args),
20+
}))
21+
22+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
23+
getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args),
24+
}))
25+
26+
import { databaseMock } from '@sim/testing'
27+
import { authenticateApiKeyFromHeader } from '@/lib/api-key/service'
28+
29+
const mockDb = databaseMock.db
30+
31+
describe('authenticateApiKeyFromHeader', () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
mockGetWorkspaceBillingSettings.mockResolvedValue({
35+
billedAccountUserId: 'billing-user',
36+
allowPersonalApiKeys: true,
37+
})
38+
mockGetUserEntityPermissions.mockResolvedValue({ permissionType: 'admin' })
39+
})
40+
41+
function createAwaitableQuery<T>(rows: T[]) {
42+
return {
43+
where: vi.fn().mockResolvedValue(rows),
44+
then: (onFulfilled: (value: T[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
45+
Promise.resolve(rows).then(onFulfilled, onRejected),
46+
}
47+
}
48+
49+
it('authenticates a valid key when the joined user row is missing', async () => {
50+
const rows = [
51+
{
52+
id: 'key-1',
53+
userId: 'user-1',
54+
userName: null,
55+
userEmail: null,
56+
workspaceId: 'ws-1',
57+
type: 'workspace',
58+
key: 'stored-key',
59+
expiresAt: null,
60+
},
61+
]
62+
63+
vi.mocked(mockDb.select).mockReturnValue({
64+
from: vi.fn().mockReturnValue({
65+
leftJoin: vi.fn().mockReturnValue(createAwaitableQuery(rows)),
66+
}),
67+
} as any)
68+
mockAuthenticateApiKey.mockResolvedValue(true)
69+
70+
const result = await authenticateApiKeyFromHeader('raw-key', {
71+
workspaceId: 'ws-1',
72+
keyTypes: ['workspace', 'personal'],
73+
})
74+
75+
expect(result).toEqual({
76+
success: true,
77+
userId: 'user-1',
78+
userName: null,
79+
userEmail: null,
80+
keyId: 'key-1',
81+
keyType: 'workspace',
82+
workspaceId: 'ws-1',
83+
})
84+
})
85+
86+
it('still scopes the authentication result by workspace', async () => {
87+
const rows = [
88+
{
89+
id: 'key-1',
90+
userId: 'user-1',
91+
userName: null,
92+
userEmail: null,
93+
workspaceId: 'ws-2',
94+
type: 'workspace',
95+
key: 'stored-key',
96+
expiresAt: null,
97+
},
98+
]
99+
100+
vi.mocked(mockDb.select).mockReturnValue({
101+
from: vi.fn().mockReturnValue({
102+
leftJoin: vi.fn().mockReturnValue(createAwaitableQuery(rows)),
103+
}),
104+
} as any)
105+
mockAuthenticateApiKey.mockResolvedValue(true)
106+
107+
const result = await authenticateApiKeyFromHeader('raw-key', {
108+
workspaceId: 'ws-1',
109+
keyTypes: ['workspace'],
110+
})
111+
112+
expect(result).toEqual({ success: false, error: 'Invalid API key' })
113+
})
114+
})

apps/sim/lib/api-key/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export async function authenticateApiKeyFromHeader(
7878
expiresAt: apiKeyTable.expiresAt,
7979
})
8080
.from(apiKeyTable)
81-
.innerJoin(userTable, eq(apiKeyTable.userId, userTable.id))
81+
.leftJoin(userTable, eq(apiKeyTable.userId, userTable.id))
8282

8383
// Apply filters
8484
const conditions = []

0 commit comments

Comments
 (0)