Skip to content

Commit 9380c39

Browse files
feat(workflows): expose authenticated run subjects
1 parent 2795922 commit 9380c39

26 files changed

Lines changed: 632 additions & 96 deletions

File tree

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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,14 @@ export const POST = withRouteHandler(
315315
serviceId: 'chat',
316316
workspaceId,
317317
workflowId: deployment.workflowId,
318+
...(authResult.authenticatedEmail
319+
? {
320+
subject: {
321+
kind: 'authenticated_email' as const,
322+
email: authResult.authenticatedEmail,
323+
},
324+
}
325+
: {}),
318326
},
319327
selectedOutputs,
320328
isSecureMode: true,

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/blocks/blocks/start_trigger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const StartTriggerBlock: BlockConfig = {
3232
mode: 'advanced',
3333
defaultValue: false,
3434
description:
35-
'Expose trusted, server-injected run metadata under <start.metadata>: userEmail, workspaceId, workflowId, executionId, executionType, executionMode, startTime. Fields describe the invoking run — inside a custom block they identify the calling user and workflow.',
35+
'Expose trusted, server-injected run metadata under <start.metadata>: subject, userEmail, workspaceId, workflowId, executionId, executionType, executionMode, startTime. The subject identifies the authenticated Sim user, chat email, or external provider user without exposing credentials.',
3636
},
3737
],
3838
tools: {

apps/sim/executor/handlers/workflow/workflow-handler.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,7 @@ describe('WorkflowBlockHandler', () => {
671671
const ctx = {
672672
...mockContext,
673673
userId: 'consumer-1',
674+
principal: { kind: 'session', userId: 'consumer-1', sessionId: 'session-consumer' },
674675
workspaceId: 'workspace-consumer',
675676
executionId: 'exec-1',
676677
} as ExecutionContext
@@ -743,6 +744,11 @@ describe('WorkflowBlockHandler', () => {
743744
expect(executorOptions).toHaveLength(1)
744745
const startRunMetadata = executorOptions[0].contextExtensions.startRunMetadata
745746
expect(startRunMetadata).toMatchObject({
747+
subject: {
748+
kind: 'sim_user',
749+
userId: 'consumer-1',
750+
email: 'a@corp.com',
751+
},
746752
userEmail: 'a@corp.com',
747753
workspaceId: 'workspace-consumer',
748754
workflowId: 'parent-workflow-id',
@@ -761,6 +767,11 @@ describe('WorkflowBlockHandler', () => {
761767
metadata: { id: 'custom_block_abc', name: 'Published Block' },
762768
}
763769
const inheritedMetadata = {
770+
subject: {
771+
kind: 'sim_user' as const,
772+
userId: 'original-user',
773+
email: 'original@corp.com',
774+
},
764775
userEmail: 'original@corp.com',
765776
workspaceId: 'workspace-original',
766777
workflowId: 'workflow-original',
@@ -841,6 +852,11 @@ describe('WorkflowBlockHandler', () => {
841852

842853
expect(executorOptions).toHaveLength(1)
843854
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
855+
subject: {
856+
kind: 'sim_user',
857+
userId: 'original-user',
858+
email: 'original@corp.com',
859+
},
844860
userEmail: 'original@corp.com',
845861
workspaceId: 'workspace-original',
846862
workflowId: 'workflow-original',
@@ -849,12 +865,13 @@ describe('WorkflowBlockHandler', () => {
849865
expect(mockGetUserEmailById).not.toHaveBeenCalled()
850866
})
851867

852-
it('preserves a fail-soft null inherited email instead of re-resolving it', async () => {
868+
it('preserves an actorless inherited subject instead of inventing an identity', async () => {
853869
const ctx = {
854870
...mockContext,
855871
userId: 'publisher-1',
856872
workspaceId: 'workspace-parent',
857873
startRunMetadata: {
874+
subject: null,
858875
userEmail: null,
859876
workspaceId: 'workspace-original',
860877
workflowId: 'workflow-original',
@@ -895,12 +912,17 @@ describe('WorkflowBlockHandler', () => {
895912
await handler.execute(ctx, mockBlock, inputs)
896913

897914
expect(executorOptions).toHaveLength(1)
915+
expect(executorOptions[0].contextExtensions.startRunMetadata.subject).toBeNull()
898916
expect(executorOptions[0].contextExtensions.startRunMetadata.userEmail).toBeNull()
899917
expect(mockGetUserEmailById).not.toHaveBeenCalled()
900918
})
901919

902920
it('recovers inherited metadata from the seeded start-block state after resume', async () => {
903921
const seededMetadata = {
922+
subject: {
923+
kind: 'authenticated_email' as const,
924+
email: 'original@corp.com',
925+
},
904926
userEmail: 'original@corp.com',
905927
workspaceId: 'workspace-original',
906928
workflowId: 'workflow-original',
@@ -963,6 +985,10 @@ describe('WorkflowBlockHandler', () => {
963985

964986
expect(executorOptions).toHaveLength(1)
965987
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
988+
subject: {
989+
kind: 'authenticated_email',
990+
email: 'original@corp.com',
991+
},
966992
userEmail: 'original@corp.com',
967993
workspaceId: 'workspace-original',
968994
workflowId: 'workflow-original',
@@ -972,6 +998,10 @@ describe('WorkflowBlockHandler', () => {
972998

973999
it('passes inherited metadata through a toggle-off child so deeper children keep it', async () => {
9741000
const inheritedMetadata = {
1001+
subject: {
1002+
kind: 'authenticated_email' as const,
1003+
email: 'original@corp.com',
1004+
},
9751005
userEmail: 'original@corp.com',
9761006
workspaceId: 'workspace-original',
9771007
workflowId: 'workflow-original',

0 commit comments

Comments
 (0)