Skip to content

Commit b3ca91c

Browse files
feat(workflows): expose authenticated run subjects
1 parent a25d993 commit b3ca91c

90 files changed

Lines changed: 3537 additions & 228 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2-
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
2+
import {
3+
resolvePrincipalSubject,
4+
type WorkflowExecutionDelegatedPrincipal,
5+
} from '@sim/auth/principal'
36
import { createLogger } from '@sim/logger'
47
import { getErrorMessage } from '@sim/utils/errors'
58
import { type NextRequest, NextResponse } from 'next/server'
@@ -206,16 +209,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
206209
request,
207210
})
208211

209-
captureServerEvent(
210-
managedOAuthPrincipal.subjectUserId,
211-
'credential_used',
212-
{
213-
credential_type: 'managed_oauth',
214-
provider_id: toolMetadata.oauth.provider,
215-
workspace_id: managedOAuthPrincipal.workspaceId,
216-
},
217-
{ groups: { workspace: managedOAuthPrincipal.workspaceId } }
218-
)
212+
const managedOAuthSubject = resolvePrincipalSubject(managedOAuthPrincipal)
213+
if (managedOAuthSubject?.kind === 'sim_user') {
214+
captureServerEvent(
215+
managedOAuthSubject.userId,
216+
'credential_used',
217+
{
218+
credential_type: 'managed_oauth',
219+
provider_id: toolMetadata.oauth.provider,
220+
workspace_id: managedOAuthPrincipal.workspaceId,
221+
},
222+
{ groups: { workspace: managedOAuthPrincipal.workspaceId } }
223+
)
224+
}
219225

220226
return NextResponse.json(
221227
{

apps/sim/app/api/chat/[identifier]/otp/route.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,13 @@ describe('Chat OTP API Route', () => {
483483

484484
expect(mockRedisGet).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
485485
expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
486+
expect(mockSetChatAuthCookie).toHaveBeenCalledWith(
487+
expect.anything(),
488+
mockChatId,
489+
'email',
490+
undefined,
491+
mockEmail
492+
)
486493
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
487494
})
488495
})

apps/sim/app/api/chat/[identifier]/otp/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ export const PUT = withRouteHandler(
222222
includeThinking: deployment.includeThinking ?? false,
223223
includeToolCalls: deployment.includeToolCalls ?? false,
224224
})
225-
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
225+
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password, email)
226226

227227
return response
228228
} catch (error) {

apps/sim/app/api/chat/[identifier]/route.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,39 @@ describe('Chat Identifier API Route', () => {
413413
)
414414
}, 10000)
415415

416+
it('executes with the email proven by the chat authentication gate', async () => {
417+
mockValidateChatAuth.mockResolvedValueOnce({
418+
authorized: true,
419+
authenticatedEmail: 'person@example.com',
420+
})
421+
const req = createMockNextRequest('POST', { input: 'Hello world' })
422+
423+
const response = await POST(req, {
424+
params: Promise.resolve({ identifier: 'test-chat' }),
425+
})
426+
expect(response.status).toBe(200)
427+
428+
const streamOptions = vi.mocked(createStreamingResponse).mock.calls[0][0]
429+
await streamOptions.executeFn({
430+
onStream: vi.fn(),
431+
onBlockComplete: vi.fn(),
432+
abortSignal: new AbortController().signal,
433+
})
434+
435+
expect(vi.mocked(executeWorkflow).mock.calls[0][4]).toMatchObject({
436+
principal: {
437+
kind: 'system',
438+
serviceId: 'chat',
439+
workspaceId: 'test-workspace-id',
440+
workflowId: 'workflow-id',
441+
subject: {
442+
kind: 'authenticated_email',
443+
email: 'person@example.com',
444+
},
445+
},
446+
})
447+
}, 10000)
448+
416449
/**
417450
* A row predating the column has no tool policy, so it has not opted in.
418451
* Thinking must not drag tool frames along with it.

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,20 @@ export const POST = withRouteHandler(
310310
resolvedActorUserId,
311311
{
312312
enabled: true,
313+
principal: {
314+
kind: 'system',
315+
serviceId: 'chat',
316+
workspaceId,
317+
workflowId: deployment.workflowId,
318+
...(authResult.authenticatedEmail
319+
? {
320+
subject: {
321+
kind: 'authenticated_email' as const,
322+
email: authResult.authenticatedEmail,
323+
},
324+
}
325+
: {}),
326+
},
313327
selectedOutputs,
314328
isSecureMode: true,
315329
workflowTriggerType: 'chat',

apps/sim/app/api/chat/utils.test.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1818
const {
1919
mockMergeSubblockStateWithValues,
2020
mockMergeSubBlockValues,
21-
mockValidateAuthToken,
21+
mockReadDeploymentAuthToken,
2222
mockSetDeploymentAuthCookie,
2323
mockIsEmailAllowed,
2424
mockCheckRateLimitDirect,
2525
} = vi.hoisted(() => ({
2626
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
2727
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
28-
mockValidateAuthToken: vi.fn().mockReturnValue(false),
28+
mockReadDeploymentAuthToken: vi.fn().mockReturnValue(null),
2929
mockSetDeploymentAuthCookie: vi.fn(),
3030
mockIsEmailAllowed: vi.fn(),
3131
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
@@ -57,7 +57,7 @@ vi.mock('@sim/workflow-persistence/subblocks', () => ({
5757
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
5858

5959
vi.mock('@/lib/core/security/deployment', () => ({
60-
validateAuthToken: mockValidateAuthToken,
60+
readDeploymentAuthToken: mockReadDeploymentAuthToken,
6161
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
6262
isEmailAllowed: mockIsEmailAllowed,
6363
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
@@ -84,7 +84,7 @@ describe('Chat API Utils', () => {
8484

8585
describe('Auth token utils', () => {
8686
it('should accept valid auth cookie via validateChatAuth', async () => {
87-
mockValidateAuthToken.mockReturnValue(true)
87+
mockReadDeploymentAuthToken.mockReturnValue({})
8888

8989
const deployment = {
9090
id: 'chat-id',
@@ -100,7 +100,7 @@ describe('Chat API Utils', () => {
100100
} as any
101101

102102
const result = await validateChatAuth('request-id', deployment, mockRequest)
103-
expect(mockValidateAuthToken).toHaveBeenCalledWith(
103+
expect(mockReadDeploymentAuthToken).toHaveBeenCalledWith(
104104
'valid-token',
105105
'chat-id',
106106
'password',
@@ -110,7 +110,7 @@ describe('Chat API Utils', () => {
110110
})
111111

112112
it('should reject invalid auth cookie via validateChatAuth', async () => {
113-
mockValidateAuthToken.mockReturnValue(false)
113+
mockReadDeploymentAuthToken.mockReturnValue(null)
114114

115115
const deployment = {
116116
id: 'chat-id',
@@ -128,6 +128,26 @@ describe('Chat API Utils', () => {
128128
const result = await validateChatAuth('request-id', deployment, mockRequest)
129129
expect(result.authorized).toBe(false)
130130
})
131+
132+
it('returns the authenticated email carried by a valid email-auth cookie', async () => {
133+
mockReadDeploymentAuthToken.mockReturnValue({ authenticatedEmail: 'person@example.com' })
134+
135+
const deployment = {
136+
id: 'chat-id',
137+
authType: 'email',
138+
}
139+
const mockRequest = {
140+
method: 'POST',
141+
cookies: {
142+
get: vi.fn().mockReturnValue({ value: 'valid-token' }),
143+
},
144+
} as any
145+
146+
await expect(validateChatAuth('request-id', deployment, mockRequest)).resolves.toEqual({
147+
authorized: true,
148+
authenticatedEmail: 'person@example.com',
149+
})
150+
})
131151
})
132152

133153
describe('Cookie handling', () => {
@@ -143,9 +163,27 @@ describe('Chat API Utils', () => {
143163
'chat',
144164
'test-chat-id',
145165
'password',
166+
undefined,
146167
undefined
147168
)
148169
})
170+
171+
it('forwards an authenticated email into the signed deployment cookie', () => {
172+
const mockResponse = {
173+
cookies: { set: vi.fn() },
174+
} as unknown as NextResponse
175+
176+
setChatAuthCookie(mockResponse, 'test-chat-id', 'email', undefined, 'person@example.com')
177+
178+
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
179+
mockResponse,
180+
'chat',
181+
'test-chat-id',
182+
'email',
183+
undefined,
184+
'person@example.com'
185+
)
186+
})
149187
})
150188

151189
describe('Chat auth validation', () => {
@@ -427,14 +465,17 @@ describe('Chat API Utils', () => {
427465
})
428466

429467
it('authorizes execution when session email is allowlisted', async () => {
430-
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
468+
mockGetSession.mockResolvedValue({ user: { email: 'User@Example.com' } })
431469
mockIsEmailAllowed.mockReturnValue(true)
432470

433471
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
434472
input: 'hello',
435473
})
436474

437-
expect(result.authorized).toBe(true)
475+
expect(result).toEqual({
476+
authorized: true,
477+
authenticatedEmail: 'user@example.com',
478+
})
438479
})
439480

440481
it('rejects execution when session email is not allowlisted', async () => {

apps/sim/app/api/chat/utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ export function setChatAuthCookie(
1313
response: NextResponse,
1414
chatId: string,
1515
type: string,
16-
encryptedPassword?: string | null
16+
encryptedPassword?: string | null,
17+
authenticatedEmail?: string
1718
): void {
18-
setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword)
19+
setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword, authenticatedEmail)
1920
}
2021

2122
/**

apps/sim/app/api/files/public/[token]/otp/route.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ describe('PUT /api/files/public/[token]/otp', () => {
237237
'file',
238238
'sh_1',
239239
'email',
240-
null
240+
null,
241+
'user@acme.com'
241242
)
242243
})
243244

apps/sim/app/api/files/public/[token]/otp/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ export const PUT = withRouteHandler(
207207
'file',
208208
resolved.share.id,
209209
resolved.share.authType,
210-
resolved.share.password
210+
resolved.share.password,
211+
email
211212
)
212213
logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`)
213214
return response

apps/sim/app/api/files/uploads/purposes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom
251251
}
252252
case 'delegated':
253253
throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads')
254+
case 'system':
255+
throw new UploadSessionError('forbidden', 'System principals cannot create uploads')
254256
case 'credential_group_enrollment':
255257
throw new UploadSessionError(
256258
'forbidden',

0 commit comments

Comments
 (0)