Skip to content

Commit eccfa77

Browse files
feat(workflows): expose authenticated run subjects
1 parent 616d5e9 commit eccfa77

26 files changed

Lines changed: 660 additions & 140 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export const PUT = withRouteHandler(
228228
includeThinking: deployment.includeThinking ?? false,
229229
includeToolCalls: deployment.includeToolCalls ?? false,
230230
})
231-
setChatAuthCookie(response, deployment, email)
231+
await setChatAuthCookie(response, deployment, email)
232232

233233
return response
234234
} 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
@@ -416,6 +416,39 @@ describe('Chat Identifier API Route', () => {
416416
)
417417
}, 10000)
418418

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

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export const POST = withRouteHandler(
158158
const response = createSuccessResponse(toChatConfigResponse(deployment))
159159

160160
if (deployment.authType === 'password') {
161-
setChatAuthCookie(response, deployment)
161+
await setChatAuthCookie(response, deployment)
162162
}
163163

164164
return response
@@ -314,6 +314,14 @@ export const POST = withRouteHandler(
314314
serviceId: 'chat',
315315
workspaceId,
316316
workflowId: deployment.workflowId,
317+
...(authResult.authenticatedEmail
318+
? {
319+
subject: {
320+
kind: 'authenticated_email' as const,
321+
email: authResult.authenticatedEmail,
322+
},
323+
}
324+
: {}),
317325
},
318326
selectedOutputs,
319327
isSecureMode: true,

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

Lines changed: 55 additions & 10 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().mockResolvedValue(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.mockResolvedValue({})
8888

8989
const deployment = {
9090
id: 'chat-id',
@@ -100,15 +100,15 @@ 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
token: 'valid-token',
105105
resource: deployment,
106106
})
107107
expect(result.authorized).toBe(true)
108108
})
109109

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

113113
const deployment = {
114114
id: 'chat-id',
@@ -126,10 +126,32 @@ describe('Chat API Utils', () => {
126126
const result = await validateChatAuth('request-id', deployment, mockRequest)
127127
expect(result.authorized).toBe(false)
128128
})
129+
130+
it('returns the authenticated email carried by a valid email-auth cookie', async () => {
131+
mockReadDeploymentAuthToken.mockResolvedValue({
132+
authenticatedEmail: 'person@example.com',
133+
})
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+
})
129151
})
130152

131153
describe('Cookie handling', () => {
132-
it('should delegate to setDeploymentAuthCookie', () => {
154+
it('should delegate to setDeploymentAuthCookie', async () => {
133155
const mockResponse = {
134156
cookies: { set: vi.fn() },
135157
} as unknown as NextResponse
@@ -139,7 +161,7 @@ describe('Chat API Utils', () => {
139161
authType: 'password',
140162
password: 'encrypted-password',
141163
}
142-
setChatAuthCookie(mockResponse, deployment)
164+
await setChatAuthCookie(mockResponse, deployment)
143165

144166
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({
145167
response: mockResponse,
@@ -148,6 +170,26 @@ describe('Chat API Utils', () => {
148170
verifiedEmail: undefined,
149171
})
150172
})
173+
174+
it('forwards an authenticated email into the signed deployment cookie', async () => {
175+
const mockResponse = {
176+
cookies: { set: vi.fn() },
177+
} as unknown as NextResponse
178+
179+
const deployment = {
180+
id: 'test-chat-id',
181+
authType: 'email',
182+
allowedEmails: ['person@example.com'],
183+
}
184+
await setChatAuthCookie(mockResponse, deployment, 'person@example.com')
185+
186+
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({
187+
response: mockResponse,
188+
cookiePrefix: 'chat',
189+
resource: deployment,
190+
verifiedEmail: 'person@example.com',
191+
})
192+
})
151193
})
152194

153195
describe('Chat auth validation', () => {
@@ -429,14 +471,17 @@ describe('Chat API Utils', () => {
429471
})
430472

431473
it('authorizes execution when session email is allowlisted', async () => {
432-
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
474+
mockGetSession.mockResolvedValue({ user: { email: 'User@Example.com' } })
433475
mockIsEmailAllowed.mockReturnValue(true)
434476

435477
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
436478
input: 'hello',
437479
})
438480

439-
expect(result.authorized).toBe(true)
481+
expect(result).toEqual({
482+
authorized: true,
483+
authenticatedEmail: 'user@example.com',
484+
})
440485
})
441486

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

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import {
1313
validateDeploymentAuth,
1414
} from '@/lib/core/security/deployment-auth'
1515

16-
export function setChatAuthCookie(
16+
export async function setChatAuthCookie(
1717
response: NextResponse,
1818
deployment: DeploymentAuthResource,
1919
verifiedEmail?: string
20-
): void {
21-
setDeploymentAuthCookie({
20+
): Promise<void> {
21+
await setDeploymentAuthCookie({
2222
response,
2323
cookiePrefix: 'chat',
2424
resource: deployment,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ export const PUT = withRouteHandler(
200200
await deleteOTP('file', resolved.share.id, email)
201201

202202
const response = NextResponse.json({ authType: resolved.share.authType })
203-
setDeploymentAuthCookie({
203+
await setDeploymentAuthCookie({
204204
response,
205205
cookiePrefix: 'file',
206206
resource: resolved.share,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export const POST = withRouteHandler(
124124
}
125125

126126
const response = NextResponse.json({ authType: resolved.share.authType })
127-
setDeploymentAuthCookie({
127+
await setDeploymentAuthCookie({
128128
response,
129129
cookiePrefix: 'file',
130130
resource: resolved.share,

apps/sim/app/f/[token]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ async function renderAuthGate(token: string, share: GateShare) {
9191

9292
const cookieStore = await cookies()
9393
const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value
94-
if (validateAuthToken({ token: cookieValue ?? '', resource: share })) return null
94+
if (await validateAuthToken({ token: cookieValue ?? '', resource: share })) return null
9595

9696
return share.authType === 'email' ? (
9797
<PublicFileEmailAuth token={token} />

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
@@ -733,6 +733,7 @@ describe('WorkflowBlockHandler', () => {
733733
const ctx = {
734734
...mockContext,
735735
userId: 'consumer-1',
736+
principal: { kind: 'session', userId: 'consumer-1', sessionId: 'session-consumer' },
736737
workspaceId: 'workspace-consumer',
737738
executionId: 'exec-1',
738739
} as ExecutionContext
@@ -805,6 +806,11 @@ describe('WorkflowBlockHandler', () => {
805806
expect(executorOptions).toHaveLength(1)
806807
const startRunMetadata = executorOptions[0].contextExtensions.startRunMetadata
807808
expect(startRunMetadata).toMatchObject({
809+
subject: {
810+
kind: 'sim_user',
811+
userId: 'consumer-1',
812+
email: 'a@corp.com',
813+
},
808814
userEmail: 'a@corp.com',
809815
workspaceId: 'workspace-consumer',
810816
workflowId: 'parent-workflow-id',
@@ -823,6 +829,11 @@ describe('WorkflowBlockHandler', () => {
823829
metadata: { id: 'custom_block_abc', name: 'Published Block' },
824830
}
825831
const inheritedMetadata = {
832+
subject: {
833+
kind: 'sim_user' as const,
834+
userId: 'original-user',
835+
email: 'original@corp.com',
836+
},
826837
userEmail: 'original@corp.com',
827838
workspaceId: 'workspace-original',
828839
workflowId: 'workflow-original',
@@ -903,6 +914,11 @@ describe('WorkflowBlockHandler', () => {
903914

904915
expect(executorOptions).toHaveLength(1)
905916
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
917+
subject: {
918+
kind: 'sim_user',
919+
userId: 'original-user',
920+
email: 'original@corp.com',
921+
},
906922
userEmail: 'original@corp.com',
907923
workspaceId: 'workspace-original',
908924
workflowId: 'workflow-original',
@@ -911,12 +927,13 @@ describe('WorkflowBlockHandler', () => {
911927
expect(mockGetUserEmailById).not.toHaveBeenCalled()
912928
})
913929

914-
it('preserves a fail-soft null inherited email instead of re-resolving it', async () => {
930+
it('preserves an actorless inherited subject instead of inventing an identity', async () => {
915931
const ctx = {
916932
...mockContext,
917933
userId: 'publisher-1',
918934
workspaceId: 'workspace-parent',
919935
startRunMetadata: {
936+
subject: null,
920937
userEmail: null,
921938
workspaceId: 'workspace-original',
922939
workflowId: 'workflow-original',
@@ -957,12 +974,17 @@ describe('WorkflowBlockHandler', () => {
957974
await handler.execute(ctx, mockBlock, inputs)
958975

959976
expect(executorOptions).toHaveLength(1)
977+
expect(executorOptions[0].contextExtensions.startRunMetadata.subject).toBeNull()
960978
expect(executorOptions[0].contextExtensions.startRunMetadata.userEmail).toBeNull()
961979
expect(mockGetUserEmailById).not.toHaveBeenCalled()
962980
})
963981

964982
it('recovers inherited metadata from the seeded start-block state after resume', async () => {
965983
const seededMetadata = {
984+
subject: {
985+
kind: 'authenticated_email' as const,
986+
email: 'original@corp.com',
987+
},
966988
userEmail: 'original@corp.com',
967989
workspaceId: 'workspace-original',
968990
workflowId: 'workflow-original',
@@ -1025,6 +1047,10 @@ describe('WorkflowBlockHandler', () => {
10251047

10261048
expect(executorOptions).toHaveLength(1)
10271049
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
1050+
subject: {
1051+
kind: 'authenticated_email',
1052+
email: 'original@corp.com',
1053+
},
10281054
userEmail: 'original@corp.com',
10291055
workspaceId: 'workspace-original',
10301056
workflowId: 'workflow-original',
@@ -1034,6 +1060,10 @@ describe('WorkflowBlockHandler', () => {
10341060

10351061
it('passes inherited metadata through a toggle-off child so deeper children keep it', async () => {
10361062
const inheritedMetadata = {
1063+
subject: {
1064+
kind: 'authenticated_email' as const,
1065+
email: 'original@corp.com',
1066+
},
10371067
userEmail: 'original@corp.com',
10381068
workspaceId: 'workspace-original',
10391069
workflowId: 'workflow-original',

0 commit comments

Comments
 (0)