From 6e572ef1ac2bdebf579b9bcadeb4e54978f9451d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 25 Aug 2026 17:12:24 -0700 Subject: [PATCH 1/4] feat(workflows): expose authenticated run subjects --- .../app/api/chat/[identifier]/otp/route.ts | 2 +- .../app/api/chat/[identifier]/route.test.ts | 33 ++++ apps/sim/app/api/chat/[identifier]/route.ts | 10 +- apps/sim/app/api/chat/utils.test.ts | 65 ++++++-- apps/sim/app/api/chat/utils.ts | 6 +- .../app/api/files/public/[token]/otp/route.ts | 2 +- .../sim/app/api/files/public/[token]/route.ts | 2 +- apps/sim/app/f/[token]/page.tsx | 2 +- apps/sim/blocks/blocks/start_trigger.ts | 2 +- .../workflow/workflow-handler.test.ts | 32 +++- .../handlers/workflow/workflow-handler.ts | 27 +++- apps/sim/executor/types.ts | 12 ++ apps/sim/executor/utils/start-block.test.ts | 5 + apps/sim/lib/auth/principal.test.ts | 59 ++++++- apps/sim/lib/core/security/deployment-auth.ts | 11 +- apps/sim/lib/core/security/deployment.test.ts | 151 +++++++++++------- apps/sim/lib/core/security/deployment.ts | 87 +++++++--- .../lib/credential-groups/credentials.test.ts | 10 ++ apps/sim/lib/credential-groups/credentials.ts | 1 + apps/sim/lib/users/queries.ts | 30 ++-- .../workflows/blocks/block-outputs.test.ts | 9 ++ .../sim/lib/workflows/blocks/block-outputs.ts | 24 ++- .../lib/workflows/executor/execution-core.ts | 5 +- .../executor/start-run-identity.test.ts | 109 +++++++++++++ .../workflows/executor/start-run-identity.ts | 30 ++++ packages/auth/src/principal.ts | 74 +++++++-- 26 files changed, 660 insertions(+), 140 deletions(-) create mode 100644 apps/sim/lib/workflows/executor/start-run-identity.test.ts create mode 100644 apps/sim/lib/workflows/executor/start-run-identity.ts diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index e954ff96f77..91c283e7e8a 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -228,7 +228,7 @@ export const PUT = withRouteHandler( includeThinking: deployment.includeThinking ?? false, includeToolCalls: deployment.includeToolCalls ?? false, }) - setChatAuthCookie(response, deployment, email) + await setChatAuthCookie(response, deployment, email) return response } catch (error) { diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index 5e7f427140a..11d8440abea 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -416,6 +416,39 @@ describe('Chat Identifier API Route', () => { ) }, 10000) + it('executes with the email proven by the chat authentication gate', async () => { + mockValidateChatAuth.mockResolvedValueOnce({ + authorized: true, + authenticatedEmail: 'person@example.com', + }) + const req = createMockNextRequest('POST', { input: 'Hello world' }) + + const response = await POST(req, { + params: Promise.resolve({ identifier: 'test-chat' }), + }) + expect(response.status).toBe(200) + + const streamOptions = vi.mocked(createStreamingResponse).mock.calls[0][0] + await streamOptions.executeFn({ + onStream: vi.fn(), + onBlockComplete: vi.fn(), + abortSignal: new AbortController().signal, + }) + + expect(vi.mocked(executeWorkflow).mock.calls[0][4]).toMatchObject({ + principal: { + kind: 'system', + serviceId: 'chat', + workspaceId: 'test-workspace-id', + workflowId: 'workflow-id', + subject: { + kind: 'authenticated_email', + email: 'person@example.com', + }, + }, + }) + }, 10000) + /** * A row predating the column has no tool policy, so it has not opted in. * Thinking must not drag tool frames along with it. diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index dedc480fc2f..5561de2a144 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -158,7 +158,7 @@ export const POST = withRouteHandler( const response = createSuccessResponse(toChatConfigResponse(deployment)) if (deployment.authType === 'password') { - setChatAuthCookie(response, deployment) + await setChatAuthCookie(response, deployment) } return response @@ -314,6 +314,14 @@ export const POST = withRouteHandler( serviceId: 'chat', workspaceId, workflowId: deployment.workflowId, + ...(authResult.authenticatedEmail + ? { + subject: { + kind: 'authenticated_email' as const, + email: authResult.authenticatedEmail, + }, + } + : {}), }, selectedOutputs, isSecureMode: true, diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 45993ea5aca..8d3d8d8382d 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -18,14 +18,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockMergeSubblockStateWithValues, mockMergeSubBlockValues, - mockValidateAuthToken, + mockReadDeploymentAuthToken, mockSetDeploymentAuthCookie, mockIsEmailAllowed, mockCheckRateLimitDirect, } = vi.hoisted(() => ({ mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), mockMergeSubBlockValues: vi.fn().mockReturnValue({}), - mockValidateAuthToken: vi.fn().mockReturnValue(false), + mockReadDeploymentAuthToken: vi.fn().mockResolvedValue(null), mockSetDeploymentAuthCookie: vi.fn(), mockIsEmailAllowed: vi.fn(), mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }), @@ -57,7 +57,7 @@ vi.mock('@sim/workflow-persistence/subblocks', () => ({ vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/core/security/deployment', () => ({ - validateAuthToken: mockValidateAuthToken, + readDeploymentAuthToken: mockReadDeploymentAuthToken, setDeploymentAuthCookie: mockSetDeploymentAuthCookie, isEmailAllowed: mockIsEmailAllowed, deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`, @@ -84,7 +84,7 @@ describe('Chat API Utils', () => { describe('Auth token utils', () => { it('should accept valid auth cookie via validateChatAuth', async () => { - mockValidateAuthToken.mockReturnValue(true) + mockReadDeploymentAuthToken.mockResolvedValue({}) const deployment = { id: 'chat-id', @@ -100,7 +100,7 @@ describe('Chat API Utils', () => { } as any const result = await validateChatAuth('request-id', deployment, mockRequest) - expect(mockValidateAuthToken).toHaveBeenCalledWith({ + expect(mockReadDeploymentAuthToken).toHaveBeenCalledWith({ token: 'valid-token', resource: deployment, }) @@ -108,7 +108,7 @@ describe('Chat API Utils', () => { }) it('should reject invalid auth cookie via validateChatAuth', async () => { - mockValidateAuthToken.mockReturnValue(false) + mockReadDeploymentAuthToken.mockResolvedValue(null) const deployment = { id: 'chat-id', @@ -126,10 +126,32 @@ describe('Chat API Utils', () => { const result = await validateChatAuth('request-id', deployment, mockRequest) expect(result.authorized).toBe(false) }) + + it('returns the authenticated email carried by a valid email-auth cookie', async () => { + mockReadDeploymentAuthToken.mockResolvedValue({ + authenticatedEmail: 'person@example.com', + }) + + const deployment = { + id: 'chat-id', + authType: 'email', + } + const mockRequest = { + method: 'POST', + cookies: { + get: vi.fn().mockReturnValue({ value: 'valid-token' }), + }, + } as any + + await expect(validateChatAuth('request-id', deployment, mockRequest)).resolves.toEqual({ + authorized: true, + authenticatedEmail: 'person@example.com', + }) + }) }) describe('Cookie handling', () => { - it('should delegate to setDeploymentAuthCookie', () => { + it('should delegate to setDeploymentAuthCookie', async () => { const mockResponse = { cookies: { set: vi.fn() }, } as unknown as NextResponse @@ -139,7 +161,7 @@ describe('Chat API Utils', () => { authType: 'password', password: 'encrypted-password', } - setChatAuthCookie(mockResponse, deployment) + await setChatAuthCookie(mockResponse, deployment) expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({ response: mockResponse, @@ -148,6 +170,26 @@ describe('Chat API Utils', () => { verifiedEmail: undefined, }) }) + + it('forwards an authenticated email into the signed deployment cookie', async () => { + const mockResponse = { + cookies: { set: vi.fn() }, + } as unknown as NextResponse + + const deployment = { + id: 'test-chat-id', + authType: 'email', + allowedEmails: ['person@example.com'], + } + await setChatAuthCookie(mockResponse, deployment, 'person@example.com') + + expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({ + response: mockResponse, + cookiePrefix: 'chat', + resource: deployment, + verifiedEmail: 'person@example.com', + }) + }) }) describe('Chat auth validation', () => { @@ -429,14 +471,17 @@ describe('Chat API Utils', () => { }) it('authorizes execution when session email is allowlisted', async () => { - mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } }) + mockGetSession.mockResolvedValue({ user: { email: 'User@Example.com' } }) mockIsEmailAllowed.mockReturnValue(true) const result = await validateChatAuth('request-id', ssoDeployment, postRequest, { input: 'hello', }) - expect(result.authorized).toBe(true) + expect(result).toEqual({ + authorized: true, + authenticatedEmail: 'user@example.com', + }) }) it('rejects execution when session email is not allowlisted', async () => { diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 55dd06e36a6..005aedfc91f 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -13,12 +13,12 @@ import { validateDeploymentAuth, } from '@/lib/core/security/deployment-auth' -export function setChatAuthCookie( +export async function setChatAuthCookie( response: NextResponse, deployment: DeploymentAuthResource, verifiedEmail?: string -): void { - setDeploymentAuthCookie({ +): Promise { + await setDeploymentAuthCookie({ response, cookiePrefix: 'chat', resource: deployment, diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index ef6408c90fe..26fa53f8f8b 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -200,7 +200,7 @@ export const PUT = withRouteHandler( await deleteOTP('file', resolved.share.id, email) const response = NextResponse.json({ authType: resolved.share.authType }) - setDeploymentAuthCookie({ + await setDeploymentAuthCookie({ response, cookiePrefix: 'file', resource: resolved.share, diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts index ef27b83063b..5874d977b9b 100644 --- a/apps/sim/app/api/files/public/[token]/route.ts +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -124,7 +124,7 @@ export const POST = withRouteHandler( } const response = NextResponse.json({ authType: resolved.share.authType }) - setDeploymentAuthCookie({ + await setDeploymentAuthCookie({ response, cookiePrefix: 'file', resource: resolved.share, diff --git a/apps/sim/app/f/[token]/page.tsx b/apps/sim/app/f/[token]/page.tsx index 1db478d98f1..2c59e81d057 100644 --- a/apps/sim/app/f/[token]/page.tsx +++ b/apps/sim/app/f/[token]/page.tsx @@ -91,7 +91,7 @@ async function renderAuthGate(token: string, share: GateShare) { const cookieStore = await cookies() const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value - if (validateAuthToken({ token: cookieValue ?? '', resource: share })) return null + if (await validateAuthToken({ token: cookieValue ?? '', resource: share })) return null return share.authType === 'email' ? ( diff --git a/apps/sim/blocks/blocks/start_trigger.ts b/apps/sim/blocks/blocks/start_trigger.ts index 377f320328d..55252f78262 100644 --- a/apps/sim/blocks/blocks/start_trigger.ts +++ b/apps/sim/blocks/blocks/start_trigger.ts @@ -32,7 +32,7 @@ export const StartTriggerBlock: BlockConfig = { mode: 'advanced', defaultValue: false, description: - 'Expose trusted, server-injected run metadata under : userEmail, workspaceId, workflowId, executionId, executionType, executionMode, startTime. Fields describe the invoking run — inside a custom block they identify the calling user and workflow.', + 'Expose trusted, server-injected run metadata under : subject, userEmail, workspaceId, workflowId, executionId, executionType, executionMode, startTime. The subject identifies the authenticated Sim user, chat email, or external provider user without exposing credentials.', }, ], tools: { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index b1dc669b4bf..bb26a975fbd 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -733,6 +733,7 @@ describe('WorkflowBlockHandler', () => { const ctx = { ...mockContext, userId: 'consumer-1', + principal: { kind: 'session', userId: 'consumer-1', sessionId: 'session-consumer' }, workspaceId: 'workspace-consumer', executionId: 'exec-1', } as ExecutionContext @@ -805,6 +806,11 @@ describe('WorkflowBlockHandler', () => { expect(executorOptions).toHaveLength(1) const startRunMetadata = executorOptions[0].contextExtensions.startRunMetadata expect(startRunMetadata).toMatchObject({ + subject: { + kind: 'sim_user', + userId: 'consumer-1', + email: 'a@corp.com', + }, userEmail: 'a@corp.com', workspaceId: 'workspace-consumer', workflowId: 'parent-workflow-id', @@ -823,6 +829,11 @@ describe('WorkflowBlockHandler', () => { metadata: { id: 'custom_block_abc', name: 'Published Block' }, } const inheritedMetadata = { + subject: { + kind: 'sim_user' as const, + userId: 'original-user', + email: 'original@corp.com', + }, userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', @@ -903,6 +914,11 @@ describe('WorkflowBlockHandler', () => { expect(executorOptions).toHaveLength(1) expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({ + subject: { + kind: 'sim_user', + userId: 'original-user', + email: 'original@corp.com', + }, userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', @@ -911,12 +927,13 @@ describe('WorkflowBlockHandler', () => { expect(mockGetUserEmailById).not.toHaveBeenCalled() }) - it('preserves a fail-soft null inherited email instead of re-resolving it', async () => { + it('preserves an actorless inherited subject instead of inventing an identity', async () => { const ctx = { ...mockContext, userId: 'publisher-1', workspaceId: 'workspace-parent', startRunMetadata: { + subject: null, userEmail: null, workspaceId: 'workspace-original', workflowId: 'workflow-original', @@ -957,12 +974,17 @@ describe('WorkflowBlockHandler', () => { await handler.execute(ctx, mockBlock, inputs) expect(executorOptions).toHaveLength(1) + expect(executorOptions[0].contextExtensions.startRunMetadata.subject).toBeNull() expect(executorOptions[0].contextExtensions.startRunMetadata.userEmail).toBeNull() expect(mockGetUserEmailById).not.toHaveBeenCalled() }) it('recovers inherited metadata from the seeded start-block state after resume', async () => { const seededMetadata = { + subject: { + kind: 'authenticated_email' as const, + email: 'original@corp.com', + }, userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', @@ -1025,6 +1047,10 @@ describe('WorkflowBlockHandler', () => { expect(executorOptions).toHaveLength(1) expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({ + subject: { + kind: 'authenticated_email', + email: 'original@corp.com', + }, userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', @@ -1034,6 +1060,10 @@ describe('WorkflowBlockHandler', () => { it('passes inherited metadata through a toggle-off child so deeper children keep it', async () => { const inheritedMetadata = { + subject: { + kind: 'authenticated_email' as const, + email: 'original@corp.com', + }, userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index c8a29c2fe8d..d922fbd917e 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -13,7 +13,6 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' -import { getUserEmailById } from '@/lib/users/queries' import { admitCustomBlockChildExecution, buildCustomBlockCorrelation, @@ -21,6 +20,10 @@ import { trackChildRun, } from '@/lib/workflows/custom-blocks/child-execution' import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations' +import { + resolveStartBlockRunIdentity, + type StartBlockRunIdentity, +} from '@/lib/workflows/executor/start-run-identity' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' import { type CustomBlockOutput, isCustomBlockType } from '@/blocks/custom/build-config' @@ -666,14 +669,22 @@ export class WorkflowBlockHandler implements BlockHandler { // When the parent run already carries trusted metadata, propagate ALL of // it so nested children see one consistent invoking identity (the // original consumer) instead of a mix of original and intermediate. - // Inherited email is taken verbatim — a fail-soft null must stay null, - // not be re-resolved to the intermediate (publisher) identity. + // New metadata carries the complete projected subject. Legacy snapshots + // without it are re-projected from the preserved execution principal. + let invokingIdentity: StartBlockRunIdentity + if (inherited && Object.hasOwn(inherited, 'subject')) { + invokingIdentity = { + subject: inherited.subject ?? null, + userEmail: inherited.userEmail ?? null, + } + } else { + if (!ctx.principal) { + throw new Error('Execution principal is required for Start block run metadata') + } + invokingIdentity = await resolveStartBlockRunIdentity(ctx.principal) + } childStartRunMetadata = { - userEmail: inherited - ? (inherited.userEmail ?? null) - : ctx.userId - ? await getUserEmailById(ctx.userId) - : null, + ...invokingIdentity, workspaceId: inherited?.workspaceId ?? ctx.workspaceId ?? null, workflowId: inherited?.workflowId ?? ctx.workflowId ?? null, executionId: ctx.executionId, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 8aa9e7cd6ce..46f21ecf579 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -241,6 +241,17 @@ export type ExecutionControlOutputFieldName = (typeof EXECUTION_CONTROL_OUTPUT_F /** Start block output key that carries trusted, server-injected run metadata. */ export const START_BLOCK_METADATA_FIELD = 'metadata' +/** Authenticated human or provider subject safe to expose to workflow authors. */ +export type StartBlockRunSubject = + | { kind: 'sim_user'; userId: string; email: string } + | { kind: 'authenticated_email'; email: string } + | { + kind: 'external_user' + provider: string + tenantId: string + subjectId: string + } + /** * Trusted run metadata surfaced under `` when the Start * block's "Add run metadata" toggle is enabled. Built server-side from the @@ -251,6 +262,7 @@ export const START_BLOCK_METADATA_FIELD = 'metadata' * authoring-time-known identity. */ export interface StartBlockRunMetadata { + subject?: StartBlockRunSubject | null userEmail?: string | null workspaceId?: string | null workflowId?: string | null diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 41e4488177b..27643e14171 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -848,6 +848,11 @@ describe('start-block utilities', () => { describe('run metadata injection', () => { const runMetadata = { + subject: { + kind: 'sim_user' as const, + userId: 'user-1', + email: 'real@sim.ai', + }, userEmail: 'real@sim.ai', workspaceId: 'ws-1', workflowId: 'wf-1', diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index 8e5e4c108c9..265ce224b41 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -277,6 +277,21 @@ describe('principal persistence', () => { expect(parsePrincipal(serializePrincipal(principal))).toEqual(principal) }) + it('round trips an authenticated chat email subject', () => { + const principal = { + kind: 'system' as const, + serviceId: 'chat' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + subject: { + kind: 'authenticated_email' as const, + email: 'person@example.com', + }, + } + + expect(parsePrincipal(serializePrincipal(principal))).toEqual(principal) + }) + it('rejects incomplete or cross-provider webhook identity', () => { expect(() => parsePrincipal({ @@ -309,10 +324,43 @@ describe('principal persistence', () => { }) ).toThrow('subject provider must match') }) + + it('rejects subjects on the wrong system surface', () => { + expect(() => + parsePrincipal({ + version: 1, + principal: { + kind: 'system', + serviceId: 'chat', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + subject: { + kind: 'external_user', + provider: 'slack', + tenantId: 'T123', + subjectId: 'U123', + }, + }, + }) + ).toThrow('Unsupported serialized principal subject kind external_user') + + expect(() => + parsePrincipal({ + version: 1, + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + subject: { kind: 'authenticated_email', email: 'person@example.com' }, + }, + }) + ).toThrow('cannot carry a subject') + }) }) describe('principal subjects', () => { - it('keeps Sim and external subjects distinct', () => { + it('keeps Sim, external, and authenticated-email subjects distinct', () => { expect( resolvePrincipalSubject({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) ).toEqual({ kind: 'sim_user', userId: 'user-1' }) @@ -337,6 +385,15 @@ describe('principal subjects', () => { tenantId: 'T123', subjectId: 'U123', }) + expect( + resolvePrincipalSubject({ + kind: 'system', + serviceId: 'chat', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + subject: { kind: 'authenticated_email', email: 'person@example.com' }, + }) + ).toEqual({ kind: 'authenticated_email', email: 'person@example.com' }) expect( resolvePrincipalSubject({ kind: 'system', diff --git a/apps/sim/lib/core/security/deployment-auth.ts b/apps/sim/lib/core/security/deployment-auth.ts index 29da0392bd7..23e94fdcdb1 100644 --- a/apps/sim/lib/core/security/deployment-auth.ts +++ b/apps/sim/lib/core/security/deployment-auth.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' +import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -8,7 +9,7 @@ import { type DeploymentAuthResource, deploymentAuthCookieName, isEmailAllowed, - validateAuthToken, + readDeploymentAuthToken, } from '@/lib/core/security/deployment' import { decryptSecret } from '@/lib/core/security/encryption' import { getClientIp } from '@/lib/core/utils/request' @@ -58,6 +59,7 @@ export interface DeploymentAuthBody { export interface DeploymentAuthResult { authorized: boolean + authenticatedEmail?: string error?: string status?: number retryAfterMs?: number @@ -85,8 +87,9 @@ export async function validateDeploymentAuth( if (authType === 'password' || authType === 'email') { const authCookie = request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id)) - if (authCookie && validateAuthToken({ token: authCookie.value, resource })) { - return { authorized: true } + if (authCookie) { + const claims = await readDeploymentAuthToken({ token: authCookie.value, resource }) + if (claims) return { authorized: true, ...claims } } } @@ -213,7 +216,7 @@ export async function validateDeploymentAuth( } if (isEmailAllowed(userEmail, resource.allowedEmails)) { - return { authorized: true } + return { authorized: true, authenticatedEmail: normalizeEmail(userEmail) } } return { authorized: false, error: 'Your email is not authorized to access this resource' } diff --git a/apps/sim/lib/core/security/deployment.test.ts b/apps/sim/lib/core/security/deployment.test.ts index 18b379fb521..d1f701f0c88 100644 --- a/apps/sim/lib/core/security/deployment.test.ts +++ b/apps/sim/lib/core/security/deployment.test.ts @@ -1,21 +1,34 @@ /** * @vitest-environment node */ +import { hmacSha256Hex } from '@sim/security/hmac' +import { resetEnvMock, setEnv } from '@sim/testing' import { NextResponse } from 'next/server' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { env } from '@/lib/core/config/env' import { type DeploymentAuthResource, deploymentAuthCookieName, isEmailAllowed, + readDeploymentAuthToken, setDeploymentAuthCookie, validateAuthToken, } from '@/lib/core/security/deployment' const DAY_MS = 24 * 60 * 60 * 1000 -function mintToken(resource: DeploymentAuthResource, verifiedEmail?: string): string { +beforeAll(() => { + setEnv({ ENCRYPTION_KEY: '0'.repeat(64) }) +}) + +afterAll(resetEnvMock) + +async function mintToken( + resource: DeploymentAuthResource, + verifiedEmail?: string +): Promise { const response = NextResponse.json({}) - setDeploymentAuthCookie({ + await setDeploymentAuthCookie({ response, cookiePrefix: 'file', resource, @@ -26,107 +39,135 @@ function mintToken(resource: DeploymentAuthResource, verifiedEmail?: string): st return token } +function withoutEncryptedEmailClaim(token: string): string { + const [encodedPayload] = token.split('.') + if (!encodedPayload) throw new Error('Expected encoded deployment auth payload') + const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) + payload.encryptedEmail = undefined + const encodedLegacyPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const signature = hmacSha256Hex(encodedLegacyPayload, env.BETTER_AUTH_SECRET) + return `${encodedLegacyPayload}.${signature}` +} + describe('deployment auth tokens', () => { afterEach(() => { vi.restoreAllMocks() }) - it('binds a password token to the resource, auth mode, and current password', () => { + it('binds a password token to the resource, auth mode, and current password', async () => { const resource = { id: 'share-1', authType: 'password', password: 'encrypted-password-1', } - const token = mintToken(resource) - - expect(validateAuthToken({ token, resource })).toBe(true) - expect(validateAuthToken({ token, resource: { ...resource, id: 'share-2' } })).toBe(false) - expect(validateAuthToken({ token, resource: { ...resource, authType: 'email' } })).toBe(false) - expect( + const token = await mintToken(resource) + + await expect(validateAuthToken({ token, resource })).resolves.toBe(true) + await expect( + validateAuthToken({ token, resource: { ...resource, id: 'share-2' } }) + ).resolves.toBe(false) + await expect( + validateAuthToken({ token, resource: { ...resource, authType: 'email' } }) + ).resolves.toBe(false) + await expect( validateAuthToken({ token, resource: { ...resource, password: 'encrypted-password-2' }, }) - ).toBe(false) + ).resolves.toBe(false) + await expect(readDeploymentAuthToken({ token, resource })).resolves.toEqual({}) + }) + + it('round-trips a normalized email without exposing it in the signed payload', async () => { + const resource = { + id: 'share-1', + authType: 'email', + password: null, + allowedEmails: ['person@example.com'], + } + const token = await mintToken(resource, ' Person@Example.com ') + const [encodedPayload] = token.split('.') + const decodedPayload = Buffer.from(encodedPayload, 'base64url').toString('utf8') + + expect(decodedPayload).not.toContain('person') + expect(decodedPayload).not.toContain('example.com') + await expect(readDeploymentAuthToken({ token, resource })).resolves.toEqual({ + authenticatedEmail: 'person@example.com', + }) }) - it('revokes an exact-address email token as soon as that address is removed', () => { + it('accepts a rollout token without inventing an email identity', async () => { + const resource = { + id: 'share-1', + authType: 'email', + password: null, + allowedEmails: ['viewer@example.test'], + } + const token = withoutEncryptedEmailClaim(await mintToken(resource, 'viewer@example.test')) + + await expect(readDeploymentAuthToken({ token, resource })).resolves.toEqual({}) + }) + + it('revokes an exact-address email token as soon as that address is removed', async () => { const resource = { id: 'share-1', authType: 'email', password: null, allowedEmails: ['viewer@example.test', 'other@example.test'], } - const token = mintToken(resource, 'Viewer@Example.Test') + const token = await mintToken(resource, 'Viewer@Example.Test') - expect(validateAuthToken({ token, resource })).toBe(true) - expect( + await expect(validateAuthToken({ token, resource })).resolves.toBe(true) + await expect( validateAuthToken({ token, resource: { ...resource, allowedEmails: ['other@example.test'] }, }) - ).toBe(false) + ).resolves.toBe(false) }) - it('keeps an email token valid while its exact or domain grant remains current', () => { + it('keeps an email token valid while its exact or domain grant remains current', async () => { const resource = { id: 'share-1', authType: 'email', password: null, allowedEmails: ['viewer@example.test'], } - const token = mintToken(resource, 'viewer@example.test') + const token = await mintToken(resource, 'viewer@example.test') - expect( + await expect( validateAuthToken({ token, resource: { ...resource, allowedEmails: ['new@example.test', 'viewer@example.test'] }, }) - ).toBe(true) - expect( + ).resolves.toBe(true) + await expect( validateAuthToken({ token, resource: { ...resource, allowedEmails: ['@example.test'] }, }) - ).toBe(true) + ).resolves.toBe(true) }) - it('revokes a domain-granted token when the domain is removed', () => { + it('revokes a domain-granted token when the domain is removed', async () => { const resource = { id: 'share-1', authType: 'email', password: null, allowedEmails: ['@example.test'], } - const token = mintToken(resource, 'viewer@example.test') + const token = await mintToken(resource, 'viewer@example.test') - expect(validateAuthToken({ token, resource })).toBe(true) - expect( + await expect(validateAuthToken({ token, resource })).resolves.toBe(true) + await expect( validateAuthToken({ token, resource: { ...resource, allowedEmails: ['@other.test'] }, }) - ).toBe(false) - }) - - it('does not expose the verified email address in the signed payload', () => { - const token = mintToken( - { - id: 'share-1', - authType: 'email', - password: null, - allowedEmails: ['viewer@example.test'], - }, - 'viewer@example.test' - ) - const [encodedPayload] = token.split('.') - const decodedPayload = Buffer.from(encodedPayload, 'base64url').toString('utf8') - - expect(decodedPayload).not.toContain('viewer') - expect(decodedPayload).not.toContain('example.test') + ).resolves.toBe(false) }) - it('rejects expired, future-dated, malformed, and legacy tokens', () => { + it('rejects expired, future-dated, malformed, and legacy tokens', async () => { const now = 1_700_000_000_000 const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now) const resource = { @@ -134,33 +175,33 @@ describe('deployment auth tokens', () => { authType: 'password', password: 'encrypted-password-1', } - const token = mintToken(resource) + const token = await mintToken(resource) nowSpy.mockReturnValue(now + DAY_MS + 1) - expect(validateAuthToken({ token, resource })).toBe(false) + await expect(validateAuthToken({ token, resource })).resolves.toBe(false) nowSpy.mockReturnValue(now - 60_001) - expect(validateAuthToken({ token, resource })).toBe(false) - expect(validateAuthToken({ token: `${token}tampered`, resource })).toBe(false) - expect(validateAuthToken({ token: 'legacy-token', resource })).toBe(false) + await expect(validateAuthToken({ token, resource })).resolves.toBe(false) + await expect(validateAuthToken({ token: `${token}tampered`, resource })).resolves.toBe(false) + await expect(validateAuthToken({ token: 'legacy-token', resource })).resolves.toBe(false) }) - it('requires the credential that corresponds to the selected auth mode', () => { + it('requires the credential that corresponds to the selected auth mode', async () => { const response = NextResponse.json({}) - expect(() => + await expect( setDeploymentAuthCookie({ response, cookiePrefix: 'chat', resource: { id: 'chat-1', authType: 'email', allowedEmails: ['viewer@example.test'] }, }) - ).toThrow('verified email') - expect(() => + ).rejects.toThrow('verified email') + await expect( setDeploymentAuthCookie({ response, cookiePrefix: 'chat', resource: { id: 'chat-1', authType: 'password', password: null }, }) - ).toThrow('configured password') + ).rejects.toThrow('configured password') }) }) diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index b9bac6a739a..55c75aaf841 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -5,6 +5,7 @@ import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import type { NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { isDev } from '@/lib/core/config/env-flags' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' const DEPLOYMENT_AUTH_TOKEN_VERSION = 1 const DEPLOYMENT_AUTH_TOKEN_TTL_MS = 24 * 60 * 60 * 1000 @@ -37,6 +38,7 @@ interface EmailAuthTokenPayload extends DeploymentAuthTokenBase { authType: 'email' emailSlot: string emailDomainSlot: string + encryptedEmail?: string } type DeploymentAuthTokenPayload = PasswordAuthTokenPayload | EmailAuthTokenPayload @@ -103,7 +105,10 @@ function emailGrants(allowedEmails: unknown): EmailGrant[] { return grants } -function generateAuthToken(resource: DeploymentAuthResource, verifiedEmail?: string): string { +async function generateAuthToken( + resource: DeploymentAuthResource, + verifiedEmail?: string +): Promise { const base = { version: DEPLOYMENT_AUTH_TOKEN_VERSION, resourceId: resource.id, @@ -124,10 +129,16 @@ function generateAuthToken(resource: DeploymentAuthResource, verifiedEmail?: str if (!verifiedEmail) { throw new Error('Cannot create email auth token without a verified email address') } + const normalizedEmail = normalizeEmail(verifiedEmail) + if (!isValidEmailSyntax(normalizedEmail)) { + throw new Error('Cannot create deployment auth token for an invalid email address') + } + const { encrypted: encryptedEmail } = await encryptSecret(normalizedEmail) payload = { ...base, authType: 'email', - ...emailIdentitySlots(verifiedEmail), + ...emailIdentitySlots(normalizedEmail), + encryptedEmail, } } else { throw new Error(`Cannot create auth token for unsupported auth type: ${resource.authType}`) @@ -158,7 +169,12 @@ function isDeploymentAuthTokenPayload(value: unknown): value is DeploymentAuthTo return isSha256Hex(payload.passwordSlot) } if (payload.authType === 'email') { - return isSha256Hex(payload.emailSlot) && isSha256Hex(payload.emailDomainSlot) + return ( + isSha256Hex(payload.emailSlot) && + isSha256Hex(payload.emailDomainSlot) && + (payload.encryptedEmail === undefined || + (typeof payload.encryptedEmail === 'string' && payload.encryptedEmail.length > 0)) + ) } return false } @@ -170,58 +186,89 @@ function isEmailTokenAllowed(payload: EmailAuthTokenPayload, allowedEmails: unkn }) } +export interface DeploymentAuthTokenClaims { + authenticatedEmail?: string +} + /** - * Validates a signed deployment cookie against the resource's current auth policy. - * Email tokens carry HMAC-derived identity slots so allow-list removals take effect - * immediately without exposing the verified address in the cookie. + * Validates a signed deployment cookie and recovers any confidential identity claim. + * Email identity remains encrypted in the cookie while its HMAC slots make current + * allow-list removals take effect immediately. Tokens minted before the encrypted + * claim was added remain valid but carry no workflow-visible identity. */ -export function validateAuthToken({ token, resource }: ValidateAuthTokenParams): boolean { +export async function readDeploymentAuthToken({ + token, + resource, +}: ValidateAuthTokenParams): Promise { try { const [encodedPayload, signature, extra] = token.split('.') - if (!encodedPayload || !signature || extra !== undefined) return false + if (!encodedPayload || !signature || extra !== undefined) return null const expectedSignature = signPayload(encodedPayload) - if (!safeCompare(signature, expectedSignature)) return false + if (!safeCompare(signature, expectedSignature)) return null const decoded: unknown = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) - if (!isDeploymentAuthTokenPayload(decoded)) return false - if (decoded.resourceId !== resource.id || decoded.authType !== resource.authType) return false + if (!isDeploymentAuthTokenPayload(decoded)) return null + if (decoded.resourceId !== resource.id || decoded.authType !== resource.authType) return null const now = Date.now() if ( decoded.issuedAt > now + DEPLOYMENT_AUTH_TOKEN_CLOCK_SKEW_MS || now - decoded.issuedAt > DEPLOYMENT_AUTH_TOKEN_TTL_MS ) { - return false + return null } if (decoded.authType === 'password') { - return Boolean( - resource.password && safeCompare(decoded.passwordSlot, passwordSlot(resource.password)) - ) + if ( + !resource.password || + !safeCompare(decoded.passwordSlot, passwordSlot(resource.password)) + ) { + return null + } + return {} } - return isEmailTokenAllowed(decoded, resource.allowedEmails) + if (!isEmailTokenAllowed(decoded, resource.allowedEmails)) return null + if (!decoded.encryptedEmail) return {} + + const { decrypted } = await decryptSecret(decoded.encryptedEmail) + const authenticatedEmail = normalizeEmail(decrypted) + if (!isValidEmailSyntax(authenticatedEmail)) return null + + const slots = emailIdentitySlots(authenticatedEmail) + if ( + !safeCompare(decoded.emailSlot, slots.emailSlot) || + !safeCompare(decoded.emailDomainSlot, slots.emailDomainSlot) + ) { + return null + } + return { authenticatedEmail } } catch { - return false + return null } } +/** Validates a signed deployment cookie against the resource's current auth policy. */ +export async function validateAuthToken(params: ValidateAuthTokenParams): Promise { + return (await readDeploymentAuthToken(params)) !== null +} + /** Canonical auth cookie name for a deployed resource (`{kind}_auth_{id}`). */ export function deploymentAuthCookieName(cookiePrefix: DeploymentAuthKind, id: string): string { return `${cookiePrefix}_auth_${id}` } /** Sets a signed, resource-bound authentication cookie for a deployment. */ -export function setDeploymentAuthCookie({ +export async function setDeploymentAuthCookie({ response, cookiePrefix, resource, verifiedEmail, -}: SetDeploymentAuthCookieParams): void { +}: SetDeploymentAuthCookieParams): Promise { response.cookies.set({ name: deploymentAuthCookieName(cookiePrefix, resource.id), - value: generateAuthToken(resource, verifiedEmail), + value: await generateAuthToken(resource, verifiedEmail), httpOnly: true, secure: !isDev, sameSite: 'lax', diff --git a/apps/sim/lib/credential-groups/credentials.test.ts b/apps/sim/lib/credential-groups/credentials.test.ts index 02e5195908a..98077cea353 100644 --- a/apps/sim/lib/credential-groups/credentials.test.ts +++ b/apps/sim/lib/credential-groups/credentials.test.ts @@ -90,6 +90,16 @@ describe('listCredentialGroupCredentialReferences', () => { ).resolves.toEqual({ enrollmentId: 'enrollment-1', email: 'person@example.com' }) }) + it('does not treat a chat-authenticated email as Credential Group enrollment access', async () => { + await expect( + loadCredentialGroupEnrollmentAccessForSubject('group-1', { + kind: 'authenticated_email', + email: 'person@example.com', + }) + ).resolves.toBeNull() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + it('fails fast when one external subject resolves to multiple enrollments', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { enrollmentId: 'enrollment-1', email: 'first@example.com' }, diff --git a/apps/sim/lib/credential-groups/credentials.ts b/apps/sim/lib/credential-groups/credentials.ts index 1ffe32740af..15d20929d5a 100644 --- a/apps/sim/lib/credential-groups/credentials.ts +++ b/apps/sim/lib/credential-groups/credentials.ts @@ -86,6 +86,7 @@ export async function loadCredentialGroupEnrollmentAccessForSubject( if (subject.kind === 'sim_user') { return loadCredentialGroupEnrollmentAccess(credentialGroupId, subject.userId) } + if (subject.kind !== 'external_user') return null if (!isCredentialGroupProvider(subject.provider)) return null const providerId = getCredentialGroupProviderId(subject.provider) const rows = await db diff --git a/apps/sim/lib/users/queries.ts b/apps/sim/lib/users/queries.ts index 7d3e783a571..867997cb966 100644 --- a/apps/sim/lib/users/queries.ts +++ b/apps/sim/lib/users/queries.ts @@ -1,12 +1,9 @@ import { db } from '@sim/db' import { settings, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { eq, inArray } from 'drizzle-orm' import type { UserSettingsApi } from '@/lib/api/contracts/user' import { normalizeStringArray } from '@/lib/core/utils/arrays' -const logger = createLogger('UserQueries') const MAX_USER_EMAIL_BATCH = 1000 /** @@ -86,23 +83,18 @@ export async function getUserSettings(userId: string | null): Promise { - try { - const [userRecord] = await db - .select({ email: user.email }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) - - return userRecord?.email ?? null - } catch (error) { - logger.warn('Failed to load user email', { userId, error: getErrorMessage(error) }) - return null - } +export async function getUserEmailById(userId: string): Promise { + const [userRecord] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + if (!userRecord?.email) throw new Error(`Authenticated user ${userId} has no email address`) + return userRecord.email } /** diff --git a/apps/sim/lib/workflows/blocks/block-outputs.test.ts b/apps/sim/lib/workflows/blocks/block-outputs.test.ts index 070a20a07d3..af6a85dd8b3 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.test.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.test.ts @@ -79,12 +79,21 @@ describe('block outputs parity', () => { const paths = getEffectiveBlockOutputPaths('start_trigger', subBlocks, options) expect(outputs).toHaveProperty('metadata') + expect(paths).toContain('metadata.subject.kind') + expect(paths).toContain('metadata.subject.userId') + expect(paths).toContain('metadata.subject.email') + expect(paths).toContain('metadata.subject.provider') + expect(paths).toContain('metadata.subject.tenantId') + expect(paths).toContain('metadata.subject.subjectId') expect(paths).toContain('metadata.userEmail') expect(paths).toContain('metadata.executionType') expect(paths).toContain('metadata.workflowId') expect( getEffectiveBlockOutputType('start_trigger', 'metadata.userEmail', subBlocks, options) ).toBe('string') + expect( + getEffectiveBlockOutputType('start_trigger', 'metadata.subject.kind', subBlocks, options) + ).toBe('string') const offOutputs = getEffectiveBlockOutputs('start_trigger', {}, options) const offPaths = getEffectiveBlockOutputPaths('start_trigger', {}, options) diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index b36f9ec3ac3..32775f7a418 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -148,9 +148,31 @@ const START_RUN_METADATA_OUTPUT = { type: 'json', description: 'Trusted run metadata (server-injected)', properties: { + subject: { + type: 'json', + description: + 'Authenticated caller subject, or null for actorless runs such as workspace API keys and schedules', + properties: { + kind: { + type: 'string', + description: 'Subject kind: sim_user, authenticated_email, or external_user', + }, + userId: { type: 'string', description: 'Sim user ID for a sim_user subject' }, + email: { + type: 'string', + description: 'Email for a Sim user or email-authenticated chat subject', + }, + provider: { type: 'string', description: 'Provider for an external_user subject' }, + tenantId: { + type: 'string', + description: 'Provider tenant ID for an external_user subject', + }, + subjectId: { type: 'string', description: 'Provider user ID for an external_user subject' }, + }, + }, userEmail: { type: 'string', - description: 'Email of the user who invoked the run (for custom blocks, the invoking user)', + description: 'Email of the authenticated subject, or null when the subject has no email', }, workspaceId: { type: 'string', diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index f5ec36774a7..9d712a2b728 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -27,9 +27,9 @@ import type { LoggingSession } from '@/lib/logs/execution/logging-session' import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values' import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' -import { getUserEmailById } from '@/lib/users/queries' import { waitForChildRuns } from '@/lib/workflows/custom-blocks/child-execution' import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { resolveStartBlockRunIdentity } from '@/lib/workflows/executor/start-run-identity' import { loadDeployedWorkflowState, loadWorkflowDeploymentVersionState, @@ -933,8 +933,9 @@ async function executeWorkflowCoreImpl( (block) => block.id === resolvedTriggerBlockId ) if (entryBlock && isRunMetadataEnabled(entryBlock)) { + const runIdentity = await resolveStartBlockRunIdentity(metadata.principal) startRunMetadata = { - userEmail: await getUserEmailById(userId), + ...runIdentity, workspaceId: providedWorkspaceId, workflowId, executionId, diff --git a/apps/sim/lib/workflows/executor/start-run-identity.test.ts b/apps/sim/lib/workflows/executor/start-run-identity.test.ts new file mode 100644 index 00000000000..7306845b757 --- /dev/null +++ b/apps/sim/lib/workflows/executor/start-run-identity.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserEmailById } = vi.hoisted(() => ({ + mockGetUserEmailById: vi.fn(), +})) + +vi.mock('@/lib/users/queries', () => ({ + getUserEmailById: mockGetUserEmailById, +})) + +import { resolveStartBlockRunIdentity } from '@/lib/workflows/executor/start-run-identity' + +describe('resolveStartBlockRunIdentity', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('identifies the owner of a personal API key', async () => { + mockGetUserEmailById.mockResolvedValue('owner@example.com') + + await expect( + resolveStartBlockRunIdentity({ + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + }) + ).resolves.toEqual({ + subject: { + kind: 'sim_user', + userId: 'user-1', + email: 'owner@example.com', + }, + userEmail: 'owner@example.com', + }) + expect(mockGetUserEmailById).toHaveBeenCalledWith('user-1') + }) + + it('exposes the email proven by a chat authentication gate', async () => { + await expect( + resolveStartBlockRunIdentity({ + kind: 'system', + serviceId: 'chat', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + subject: { kind: 'authenticated_email', email: 'person@example.com' }, + }) + ).resolves.toEqual({ + subject: { kind: 'authenticated_email', email: 'person@example.com' }, + userEmail: 'person@example.com', + }) + expect(mockGetUserEmailById).not.toHaveBeenCalled() + }) + + it('preserves an external webhook subject without treating it as a Sim user', async () => { + await expect( + resolveStartBlockRunIdentity({ + kind: 'system', + serviceId: 'webhook', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + webhookId: 'webhook-1', + provider: 'slack', + subject: { + kind: 'external_user', + provider: 'slack', + tenantId: 'team-1', + subjectId: 'slack-user-1', + }, + }) + ).resolves.toEqual({ + subject: { + kind: 'external_user', + provider: 'slack', + tenantId: 'team-1', + subjectId: 'slack-user-1', + }, + userEmail: null, + }) + expect(mockGetUserEmailById).not.toHaveBeenCalled() + }) + + it('does not invent a user for an actorless workspace API key', async () => { + await expect( + resolveStartBlockRunIdentity({ + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }) + ).resolves.toEqual({ subject: null, userEmail: null }) + expect(mockGetUserEmailById).not.toHaveBeenCalled() + }) + + it('fails fast when an authenticated Sim user has no resolvable email', async () => { + mockGetUserEmailById.mockRejectedValue( + new Error('Authenticated user user-1 has no email address') + ) + + await expect( + resolveStartBlockRunIdentity({ + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }) + ).rejects.toThrow('Authenticated user user-1 has no email address') + }) +}) diff --git a/apps/sim/lib/workflows/executor/start-run-identity.ts b/apps/sim/lib/workflows/executor/start-run-identity.ts new file mode 100644 index 00000000000..048ca5da81f --- /dev/null +++ b/apps/sim/lib/workflows/executor/start-run-identity.ts @@ -0,0 +1,30 @@ +import { resolvePrincipalSubject, type WorkflowExecutionPrincipal } from '@sim/auth/principal' +import { getUserEmailById } from '@/lib/users/queries' +import type { StartBlockRunSubject } from '@/executor/types' + +export interface StartBlockRunIdentity { + subject: StartBlockRunSubject | null + userEmail: string | null +} + +/** Projects the authenticated execution principal into workflow-visible identity metadata. */ +export async function resolveStartBlockRunIdentity( + principal: WorkflowExecutionPrincipal +): Promise { + const subject = resolvePrincipalSubject(principal) + if (!subject) return { subject: null, userEmail: null } + + switch (subject.kind) { + case 'sim_user': { + const email = await getUserEmailById(subject.userId) + return { + subject: { ...subject, email }, + userEmail: email, + } + } + case 'authenticated_email': + return { subject: { ...subject }, userEmail: subject.email } + case 'external_user': + return { subject: { ...subject }, userEmail: null } + } +} diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index e47e9dd9262..2477b8e55ef 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -31,11 +31,25 @@ export interface ExternalUserSubject { subjectId: string } +/** Email address proven by a deployment's OTP or SSO authentication gate. */ +export interface AuthenticatedEmailSubject { + kind: 'authenticated_email' + email: string +} + interface ActorlessSystemPrincipal { kind: 'system' - serviceId: 'public_api' | 'schedule' | 'internal' | 'table' | 'chat' + serviceId: 'public_api' | 'schedule' | 'internal' | 'table' + workspaceId: string + workflowId: string +} + +export interface ChatSystemPrincipal { + kind: 'system' + serviceId: 'chat' workspaceId: string workflowId: string + subject?: AuthenticatedEmailSubject } export interface WebhookSystemPrincipal { @@ -48,7 +62,10 @@ export interface WebhookSystemPrincipal { subject?: ExternalUserSubject } -export type SystemPrincipal = ActorlessSystemPrincipal | WebhookSystemPrincipal +export type SystemPrincipal = + | ActorlessSystemPrincipal + | ChatSystemPrincipal + | WebhookSystemPrincipal interface DelegatedPrincipalBase { kind: 'delegated' @@ -254,6 +271,18 @@ function parseExternalUserSubject(value: unknown): ExternalUserSubject { } } +function parseAuthenticatedEmailSubject(value: unknown): AuthenticatedEmailSubject { + const subject = requireRecord(value, 'Serialized principal subject') + if (subject.kind !== 'authenticated_email') { + throw new Error(`Unsupported serialized principal subject kind ${String(subject.kind)}`) + } + requireExactKeys(subject, ['kind', 'email']) + return { + kind: 'authenticated_email', + email: requireString(subject.email, 'subject.email'), + } +} + /** Encodes a workflow caller without persisting bearer credentials or invitation proofs. */ export function serializePrincipal(principal: WorkflowExecutionPrincipal): SerializedPrincipalV1 { switch (principal.kind) { @@ -326,12 +355,12 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { : requireString(principal.webhookId, 'webhookId') const provider = principal.provider === undefined ? undefined : requireString(principal.provider, 'provider') - const subject = - principal.subject === undefined ? undefined : parseExternalUserSubject(principal.subject) if (serviceId === 'webhook') { if (!webhookId || !provider) { throw new Error('Webhook system principals require webhookId and provider') } + const subject = + principal.subject === undefined ? undefined : parseExternalUserSubject(principal.subject) if (subject && subject.provider !== provider) { throw new Error('Webhook system principal subject provider must match its provider') } @@ -345,8 +374,26 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { ...(subject ? { subject } : {}), } } - if (webhookId || provider || subject) { - throw new Error(`System principal service ${serviceId} cannot carry webhook identity`) + if (serviceId === 'chat') { + if (webhookId || provider) { + throw new Error('Chat system principals cannot carry webhook identity') + } + const subject = + principal.subject === undefined + ? undefined + : parseAuthenticatedEmailSubject(principal.subject) + return { + kind, + serviceId, + workspaceId: requireString(principal.workspaceId, 'workspaceId'), + workflowId: requireString(principal.workflowId, 'workflowId'), + ...(subject ? { subject } : {}), + } + } + if (webhookId || provider || principal.subject !== undefined) { + throw new Error( + `System principal service ${serviceId} cannot carry a subject or webhook identity` + ) } return { kind, @@ -406,7 +453,7 @@ export type PrincipalActor = workflowId: string webhookId?: string provider?: string - subject?: ExternalUserSubject + subject?: ExternalUserSubject | AuthenticatedEmailSubject } | { kind: 'delegated' @@ -445,7 +492,10 @@ export interface PrincipalAttributionContext { workspaceBillingOwnerUserId?: string } -export type PrincipalSubject = { kind: 'sim_user'; userId: string } | ExternalUserSubject +export type PrincipalSubject = + | { kind: 'sim_user'; userId: string } + | ExternalUserSubject + | AuthenticatedEmailSubject /** Resolves a stable human or provider subject without inventing one for actorless callers. */ export function resolvePrincipalSubject(principal: Principal): PrincipalSubject | null { @@ -462,7 +512,9 @@ export function resolvePrincipalSubject(principal: Principal): PrincipalSubject } return principal.subjectUserId ? { kind: 'sim_user', userId: principal.subjectUserId } : null case 'system': - return principal.serviceId === 'webhook' ? (principal.subject ?? null) : null + return principal.serviceId === 'webhook' || principal.serviceId === 'chat' + ? (principal.subject ?? null) + : null case 'workspace_api_key': case 'credential_group_enrollment': return null @@ -493,7 +545,9 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { provider: principal.provider, ...(principal.subject ? { subject: principal.subject } : {}), } - : {}), + : principal.serviceId === 'chat' && principal.subject + ? { subject: principal.subject } + : {}), } case 'delegated': return { From e4e610fdb64b9423ae57faa4e4cbf7dd6d102cb3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 25 Aug 2026 17:33:52 -0700 Subject: [PATCH 2/4] fix(tests): use typed chat auth requests --- apps/sim/app/api/chat/utils.test.ts | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 8d3d8d8382d..0036d92367d 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -92,12 +92,9 @@ describe('Chat API Utils', () => { password: 'encrypted-password', } - const mockRequest = { - method: 'POST', - cookies: { - get: vi.fn().mockReturnValue({ value: 'valid-token' }), - }, - } as any + const mockRequest = createMockRequest('POST', undefined, { + cookie: 'chat_auth_chat-id=valid-token', + }) const result = await validateChatAuth('request-id', deployment, mockRequest) expect(mockReadDeploymentAuthToken).toHaveBeenCalledWith({ @@ -116,12 +113,9 @@ describe('Chat API Utils', () => { password: 'encrypted-password', } - const mockRequest = { - method: 'GET', - cookies: { - get: vi.fn().mockReturnValue({ value: 'invalid-token' }), - }, - } as any + const mockRequest = createMockRequest('GET', undefined, { + cookie: 'chat_auth_chat-id=invalid-token', + }) const result = await validateChatAuth('request-id', deployment, mockRequest) expect(result.authorized).toBe(false) @@ -136,12 +130,9 @@ describe('Chat API Utils', () => { id: 'chat-id', authType: 'email', } - const mockRequest = { - method: 'POST', - cookies: { - get: vi.fn().mockReturnValue({ value: 'valid-token' }), - }, - } as any + const mockRequest = createMockRequest('POST', undefined, { + cookie: 'chat_auth_chat-id=valid-token', + }) await expect(validateChatAuth('request-id', deployment, mockRequest)).resolves.toEqual({ authorized: true, From 17d266302bb97ff8044439b41ab5b75be9339bad Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 25 Aug 2026 17:43:17 -0700 Subject: [PATCH 3/4] fix(auth): harden subject delegation and cookies --- apps/sim/lib/auth/internal.test.ts | 75 ++++++++++++++++++++++++++++-- apps/sim/lib/auth/internal.ts | 6 +-- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/auth/internal.test.ts b/apps/sim/lib/auth/internal.test.ts index 6bf619889b0..d5a279c6b62 100644 --- a/apps/sim/lib/auth/internal.test.ts +++ b/apps/sim/lib/auth/internal.test.ts @@ -2,9 +2,11 @@ * @vitest-environment node */ +import { serializePrincipal } from '@sim/auth/principal' import { resetEnvMock } from '@sim/testing' -import { decodeJwt } from 'jose' +import { decodeJwt, SignJWT } from 'jose' import { afterAll, describe, expect, it, vi } from 'vitest' +import { env } from '@/lib/core/config/env' vi.unmock('@/lib/auth/internal') @@ -181,7 +183,32 @@ describe('internal executor delegation claims', () => { ).rejects.toBeInstanceOf(InvalidInternalDelegationTokenError) }) - it('rejects laundering actorless or external principals into a Sim user subject', async () => { + it('round-trips an authenticated chat subject without inventing a Sim user', async () => { + const token = await generateInternalDelegationToken({ + workflowId: 'workflow-1', + principal: { + kind: 'system', + serviceId: 'chat', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + subject: { kind: 'authenticated_email', email: 'person@example.com' }, + }, + }) + + await expect(verifyInternalDelegationToken(token)).resolves.toMatchObject({ + workflowId: 'workflow-1', + principal: { + kind: 'system', + serviceId: 'chat', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + subject: { kind: 'authenticated_email', email: 'person@example.com' }, + }, + }) + expect(decodeJwt(token).sub).toBeUndefined() + }) + + it('rejects laundering actorless or non-Sim principals into a Sim user subject', async () => { await expect( generateInternalDelegationToken({ subjectUserId: 'billing-owner', @@ -213,7 +240,49 @@ describe('internal executor delegation claims', () => { }, }, }) - ).rejects.toThrow('External workflow subjects cannot be represented as Sim users') + ).rejects.toThrow('Non-Sim workflow subjects cannot be represented as Sim users') + + await expect( + generateInternalDelegationToken({ + subjectUserId: 'unrelated-user', + workflowId: 'workflow-1', + principal: { + kind: 'system', + serviceId: 'chat', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + subject: { kind: 'authenticated_email', email: 'person@example.com' }, + }, + }) + ).rejects.toThrow('Non-Sim workflow subjects cannot be represented as Sim users') + }) + + it('rejects a signed delegation that pairs a non-Sim principal with a Sim user subject', async () => { + const issuedAt = Math.floor(Date.now() / 1000) + const token = await new SignJWT({ + type: 'internal_delegation', + serviceId: 'executor', + workflowId: 'workflow-1', + principal: serializePrincipal({ + kind: 'system', + serviceId: 'chat', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + subject: { kind: 'authenticated_email', email: 'person@example.com' }, + }), + }) + .setProtectedHeader({ alg: 'HS256' }) + .setJti('delegation-1') + .setSubject('unrelated-user') + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + 5 * 60) + .setIssuer('sim-internal') + .setAudience('sim-api') + .sign(new TextEncoder().encode(env.INTERNAL_API_SECRET)) + + await expect(verifyInternalDelegationToken(token)).rejects.toBeInstanceOf( + InvalidInternalDelegationTokenError + ) }) it('derives issued-at and expiry from one timestamp', async () => { diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts index fe410edf9a4..6930e5a377e 100644 --- a/apps/sim/lib/auth/internal.ts +++ b/apps/sim/lib/auth/internal.ts @@ -138,8 +138,8 @@ export async function generateInternalDelegationToken( ? requireNonEmptyDelegationClaim(input.subjectUserId, 'subjectUserId') : undefined const principalSubject = input.principal ? resolvePrincipalSubject(input.principal) : null - if (principalSubject?.kind === 'external_user' && suppliedSubjectUserId) { - throw new Error('External workflow subjects cannot be represented as Sim users') + if (principalSubject && principalSubject.kind !== 'sim_user' && suppliedSubjectUserId) { + throw new Error('Non-Sim workflow subjects cannot be represented as Sim users') } if (!principalSubject && input.principal && suppliedSubjectUserId) { throw new Error('Actorless workflow principals cannot be represented as Sim users') @@ -247,7 +247,7 @@ export async function verifyInternalDelegationToken( if ( (!principal && !subjectUserId) || (principalSubject?.kind === 'sim_user' && principalSubject.userId !== subjectUserId) || - (principalSubject?.kind === 'external_user' && subjectUserId) || + (principalSubject && principalSubject.kind !== 'sim_user' && subjectUserId) || (principal && !principalSubject && subjectUserId) ) { throw new InvalidInternalDelegationTokenError() From 0871361d40acde7a5774b60611a831e3b307b289 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 26 Aug 2026 19:41:17 -0700 Subject: [PATCH 4/4] fix(workflows): remove duplicate run email metadata --- apps/sim/blocks/blocks/start_trigger.ts | 2 +- .../handlers/workflow/workflow-handler.test.ts | 8 -------- .../executor/handlers/workflow/workflow-handler.ts | 1 - apps/sim/executor/types.ts | 1 - apps/sim/executor/utils/start-block.test.ts | 11 ++++++++--- apps/sim/lib/workflows/blocks/block-outputs.test.ts | 5 +---- apps/sim/lib/workflows/blocks/block-outputs.ts | 4 ---- .../workflows/executor/start-run-identity.test.ts | 5 +---- .../sim/lib/workflows/executor/start-run-identity.ts | 12 ++++-------- 9 files changed, 15 insertions(+), 34 deletions(-) diff --git a/apps/sim/blocks/blocks/start_trigger.ts b/apps/sim/blocks/blocks/start_trigger.ts index 55252f78262..37bd1f2ece2 100644 --- a/apps/sim/blocks/blocks/start_trigger.ts +++ b/apps/sim/blocks/blocks/start_trigger.ts @@ -32,7 +32,7 @@ export const StartTriggerBlock: BlockConfig = { mode: 'advanced', defaultValue: false, description: - 'Expose trusted, server-injected run metadata under : subject, userEmail, workspaceId, workflowId, executionId, executionType, executionMode, startTime. The subject identifies the authenticated Sim user, chat email, or external provider user without exposing credentials.', + 'Expose trusted, server-injected run metadata under : subject, workspaceId, workflowId, executionId, executionType, executionMode, startTime. The subject identifies the authenticated Sim user, chat email, or external provider user without exposing credentials.', }, ], tools: { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index bb26a975fbd..09ee4af5cd7 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -811,7 +811,6 @@ describe('WorkflowBlockHandler', () => { userId: 'consumer-1', email: 'a@corp.com', }, - userEmail: 'a@corp.com', workspaceId: 'workspace-consumer', workflowId: 'parent-workflow-id', executionId: 'exec-1', @@ -834,7 +833,6 @@ describe('WorkflowBlockHandler', () => { userId: 'original-user', email: 'original@corp.com', }, - userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', executionId: 'exec-1', @@ -919,7 +917,6 @@ describe('WorkflowBlockHandler', () => { userId: 'original-user', email: 'original@corp.com', }, - userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', executionMode: 'async', @@ -934,7 +931,6 @@ describe('WorkflowBlockHandler', () => { workspaceId: 'workspace-parent', startRunMetadata: { subject: null, - userEmail: null, workspaceId: 'workspace-original', workflowId: 'workflow-original', }, @@ -975,7 +971,6 @@ describe('WorkflowBlockHandler', () => { expect(executorOptions).toHaveLength(1) expect(executorOptions[0].contextExtensions.startRunMetadata.subject).toBeNull() - expect(executorOptions[0].contextExtensions.startRunMetadata.userEmail).toBeNull() expect(mockGetUserEmailById).not.toHaveBeenCalled() }) @@ -985,7 +980,6 @@ describe('WorkflowBlockHandler', () => { kind: 'authenticated_email' as const, email: 'original@corp.com', }, - userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', executionMode: 'sync', @@ -1051,7 +1045,6 @@ describe('WorkflowBlockHandler', () => { kind: 'authenticated_email', email: 'original@corp.com', }, - userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', }) @@ -1064,7 +1057,6 @@ describe('WorkflowBlockHandler', () => { kind: 'authenticated_email' as const, email: 'original@corp.com', }, - userEmail: 'original@corp.com', workspaceId: 'workspace-original', workflowId: 'workflow-original', } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index d922fbd917e..1ccc4005e59 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -675,7 +675,6 @@ export class WorkflowBlockHandler implements BlockHandler { if (inherited && Object.hasOwn(inherited, 'subject')) { invokingIdentity = { subject: inherited.subject ?? null, - userEmail: inherited.userEmail ?? null, } } else { if (!ctx.principal) { diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 46f21ecf579..6d55335a66d 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -263,7 +263,6 @@ export type StartBlockRunSubject = */ export interface StartBlockRunMetadata { subject?: StartBlockRunSubject | null - userEmail?: string | null workspaceId?: string | null workflowId?: string | null executionId?: string diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 27643e14171..fdb3bdcd347 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -853,7 +853,6 @@ describe('start-block utilities', () => { userId: 'user-1', email: 'real@sim.ai', }, - userEmail: 'real@sim.ai', workspaceId: 'ws-1', workflowId: 'wf-1', executionId: 'exec-1', @@ -877,7 +876,9 @@ describe('start-block utilities', () => { const output = buildStartBlockOutput({ resolution, workflowInput: { - metadata: { userEmail: 'attacker@x.com' }, + metadata: { + subject: { kind: 'authenticated_email', email: 'attacker@x.com' }, + }, simUserEmail: 'attacker@x.com', payload: 'value', }, @@ -894,7 +895,11 @@ describe('start-block utilities', () => { const output = buildStartBlockOutput({ resolution, - workflowInput: { metadata: { userEmail: 'attacker@x.com' } }, + workflowInput: { + metadata: { + subject: { kind: 'authenticated_email', email: 'attacker@x.com' }, + }, + }, }) expect(output).not.toHaveProperty('metadata') diff --git a/apps/sim/lib/workflows/blocks/block-outputs.test.ts b/apps/sim/lib/workflows/blocks/block-outputs.test.ts index af6a85dd8b3..a8ba7281112 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.test.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.test.ts @@ -85,12 +85,9 @@ describe('block outputs parity', () => { expect(paths).toContain('metadata.subject.provider') expect(paths).toContain('metadata.subject.tenantId') expect(paths).toContain('metadata.subject.subjectId') - expect(paths).toContain('metadata.userEmail') + expect(paths).not.toContain('metadata.userEmail') expect(paths).toContain('metadata.executionType') expect(paths).toContain('metadata.workflowId') - expect( - getEffectiveBlockOutputType('start_trigger', 'metadata.userEmail', subBlocks, options) - ).toBe('string') expect( getEffectiveBlockOutputType('start_trigger', 'metadata.subject.kind', subBlocks, options) ).toBe('string') diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index 32775f7a418..774a69e90b3 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -170,10 +170,6 @@ const START_RUN_METADATA_OUTPUT = { subjectId: { type: 'string', description: 'Provider user ID for an external_user subject' }, }, }, - userEmail: { - type: 'string', - description: 'Email of the authenticated subject, or null when the subject has no email', - }, workspaceId: { type: 'string', description: 'Workspace ID of the invoking run (for custom blocks, the invoking workspace)', diff --git a/apps/sim/lib/workflows/executor/start-run-identity.test.ts b/apps/sim/lib/workflows/executor/start-run-identity.test.ts index 7306845b757..9f1a319daff 100644 --- a/apps/sim/lib/workflows/executor/start-run-identity.test.ts +++ b/apps/sim/lib/workflows/executor/start-run-identity.test.ts @@ -33,7 +33,6 @@ describe('resolveStartBlockRunIdentity', () => { userId: 'user-1', email: 'owner@example.com', }, - userEmail: 'owner@example.com', }) expect(mockGetUserEmailById).toHaveBeenCalledWith('user-1') }) @@ -49,7 +48,6 @@ describe('resolveStartBlockRunIdentity', () => { }) ).resolves.toEqual({ subject: { kind: 'authenticated_email', email: 'person@example.com' }, - userEmail: 'person@example.com', }) expect(mockGetUserEmailById).not.toHaveBeenCalled() }) @@ -77,7 +75,6 @@ describe('resolveStartBlockRunIdentity', () => { tenantId: 'team-1', subjectId: 'slack-user-1', }, - userEmail: null, }) expect(mockGetUserEmailById).not.toHaveBeenCalled() }) @@ -89,7 +86,7 @@ describe('resolveStartBlockRunIdentity', () => { workspaceId: 'workspace-1', keyId: 'key-1', }) - ).resolves.toEqual({ subject: null, userEmail: null }) + ).resolves.toEqual({ subject: null }) expect(mockGetUserEmailById).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/workflows/executor/start-run-identity.ts b/apps/sim/lib/workflows/executor/start-run-identity.ts index 048ca5da81f..9bec8991505 100644 --- a/apps/sim/lib/workflows/executor/start-run-identity.ts +++ b/apps/sim/lib/workflows/executor/start-run-identity.ts @@ -4,7 +4,6 @@ import type { StartBlockRunSubject } from '@/executor/types' export interface StartBlockRunIdentity { subject: StartBlockRunSubject | null - userEmail: string | null } /** Projects the authenticated execution principal into workflow-visible identity metadata. */ @@ -12,19 +11,16 @@ export async function resolveStartBlockRunIdentity( principal: WorkflowExecutionPrincipal ): Promise { const subject = resolvePrincipalSubject(principal) - if (!subject) return { subject: null, userEmail: null } + if (!subject) return { subject: null } switch (subject.kind) { case 'sim_user': { const email = await getUserEmailById(subject.userId) - return { - subject: { ...subject, email }, - userEmail: email, - } + return { subject: { ...subject, email } } } case 'authenticated_email': - return { subject: { ...subject }, userEmail: subject.email } + return { subject: { ...subject } } case 'external_user': - return { subject: { ...subject }, userEmail: null } + return { subject: { ...subject } } } }