Skip to content

Commit 50a2e8c

Browse files
feat(workflows): expose authenticated run subjects
1 parent 65a58a8 commit 50a2e8c

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
@@ -159,7 +159,7 @@ export const POST = withRouteHandler(
159159
const response = createSuccessResponse(toChatConfigResponse(deployment))
160160

161161
if (deployment.authType === 'password') {
162-
setChatAuthCookie(response, deployment)
162+
await setChatAuthCookie(response, deployment)
163163
}
164164

165165
return response
@@ -328,6 +328,14 @@ export const POST = withRouteHandler(
328328
serviceId: 'chat',
329329
workspaceId,
330330
workflowId: deployment.workflowId,
331+
...(authResult.authenticatedEmail
332+
? {
333+
subject: {
334+
kind: 'authenticated_email' as const,
335+
email: authResult.authenticatedEmail,
336+
},
337+
}
338+
: {}),
331339
},
332340
selectedOutputs,
333341
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
@@ -801,6 +801,7 @@ describe('WorkflowBlockHandler', () => {
801801
const ctx = {
802802
...mockContext,
803803
userId: 'consumer-1',
804+
principal: { kind: 'session', userId: 'consumer-1', sessionId: 'session-consumer' },
804805
workspaceId: 'workspace-consumer',
805806
executionId: 'exec-1',
806807
} as ExecutionContext
@@ -873,6 +874,11 @@ describe('WorkflowBlockHandler', () => {
873874
expect(executorOptions).toHaveLength(1)
874875
const startRunMetadata = executorOptions[0].contextExtensions.startRunMetadata
875876
expect(startRunMetadata).toMatchObject({
877+
subject: {
878+
kind: 'sim_user',
879+
userId: 'consumer-1',
880+
email: 'a@corp.com',
881+
},
876882
userEmail: 'a@corp.com',
877883
workspaceId: 'workspace-consumer',
878884
workflowId: 'parent-workflow-id',
@@ -891,6 +897,11 @@ describe('WorkflowBlockHandler', () => {
891897
metadata: { id: 'custom_block_abc', name: 'Published Block' },
892898
}
893899
const inheritedMetadata = {
900+
subject: {
901+
kind: 'sim_user' as const,
902+
userId: 'original-user',
903+
email: 'original@corp.com',
904+
},
894905
userEmail: 'original@corp.com',
895906
workspaceId: 'workspace-original',
896907
workflowId: 'workflow-original',
@@ -971,6 +982,11 @@ describe('WorkflowBlockHandler', () => {
971982

972983
expect(executorOptions).toHaveLength(1)
973984
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
985+
subject: {
986+
kind: 'sim_user',
987+
userId: 'original-user',
988+
email: 'original@corp.com',
989+
},
974990
userEmail: 'original@corp.com',
975991
workspaceId: 'workspace-original',
976992
workflowId: 'workflow-original',
@@ -979,12 +995,13 @@ describe('WorkflowBlockHandler', () => {
979995
expect(mockGetUserEmailById).not.toHaveBeenCalled()
980996
})
981997

982-
it('preserves a fail-soft null inherited email instead of re-resolving it', async () => {
998+
it('preserves an actorless inherited subject instead of inventing an identity', async () => {
983999
const ctx = {
9841000
...mockContext,
9851001
userId: 'publisher-1',
9861002
workspaceId: 'workspace-parent',
9871003
startRunMetadata: {
1004+
subject: null,
9881005
userEmail: null,
9891006
workspaceId: 'workspace-original',
9901007
workflowId: 'workflow-original',
@@ -1025,12 +1042,17 @@ describe('WorkflowBlockHandler', () => {
10251042
await handler.execute(ctx, mockBlock, inputs)
10261043

10271044
expect(executorOptions).toHaveLength(1)
1045+
expect(executorOptions[0].contextExtensions.startRunMetadata.subject).toBeNull()
10281046
expect(executorOptions[0].contextExtensions.startRunMetadata.userEmail).toBeNull()
10291047
expect(mockGetUserEmailById).not.toHaveBeenCalled()
10301048
})
10311049

10321050
it('recovers inherited metadata from the seeded start-block state after resume', async () => {
10331051
const seededMetadata = {
1052+
subject: {
1053+
kind: 'authenticated_email' as const,
1054+
email: 'original@corp.com',
1055+
},
10341056
userEmail: 'original@corp.com',
10351057
workspaceId: 'workspace-original',
10361058
workflowId: 'workflow-original',
@@ -1093,6 +1115,10 @@ describe('WorkflowBlockHandler', () => {
10931115

10941116
expect(executorOptions).toHaveLength(1)
10951117
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
1118+
subject: {
1119+
kind: 'authenticated_email',
1120+
email: 'original@corp.com',
1121+
},
10961122
userEmail: 'original@corp.com',
10971123
workspaceId: 'workspace-original',
10981124
workflowId: 'workflow-original',
@@ -1102,6 +1128,10 @@ describe('WorkflowBlockHandler', () => {
11021128

11031129
it('passes inherited metadata through a toggle-off child so deeper children keep it', async () => {
11041130
const inheritedMetadata = {
1131+
subject: {
1132+
kind: 'authenticated_email' as const,
1133+
email: 'original@corp.com',
1134+
},
11051135
userEmail: 'original@corp.com',
11061136
workspaceId: 'workspace-original',
11071137
workflowId: 'workflow-original',

0 commit comments

Comments
 (0)