From b2a7295f19a91b35d6a5d9355c39db2625508290 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 13 Aug 2026 18:11:54 -0700 Subject: [PATCH 1/3] fix(execution): resolve secrets against the acting principal, not the workflow owner --- .../app/api/workflows/[id]/execute/route.ts | 1 + apps/sim/lib/environment/utils.test.ts | 63 ++++++++++++++++ apps/sim/lib/environment/utils.ts | 70 ++++++++++++++++++ .../application/run-workflow-from-copilot.ts | 2 + .../lib/workflows/executor/execute-service.ts | 1 + .../workflows/executor/execute-workflow.ts | 9 +++ .../workflows/executor/execution-core.test.ts | 71 +++++++++++++++++-- .../lib/workflows/executor/execution-core.ts | 30 ++++++-- packages/db/schema.ts | 21 ++++++ .../src/mocks/environment-utils.mock.ts | 32 +++++++++ 10 files changed, 289 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 25704a10308..16aef9eb70d 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1698,6 +1698,7 @@ async function handleExecutePost( base64MaxBytes, abortSignal, executionMode: 'stream', + enforceCredentialAccess: useAuthenticatedUserAsActor, billingAttribution, largeValueKeys, fileKeys, diff --git a/apps/sim/lib/environment/utils.test.ts b/apps/sim/lib/environment/utils.test.ts index 91f0bdf6b3b..47274d23816 100644 --- a/apps/sim/lib/environment/utils.test.ts +++ b/apps/sim/lib/environment/utils.test.ts @@ -50,6 +50,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { getEffectiveDecryptedEnv, getEffectiveEnvironmentSnapshot, + getExecutionEnvironment, getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, upsertWorkspaceEnvVars, @@ -119,6 +120,68 @@ describe('getPersonalAndWorkspaceEnv access filtering', () => { }) }) +describe('getExecutionEnvironment', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetAccessibleEnvCredentials.mockResolvedValue([]) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + }) + + /** Grants workspace-admin access to one identity so the two slices diverge observably. */ + function grantAdminTo(adminUserId: string) { + mockCheckWorkspaceAccess.mockImplementation(async (_workspaceId: string, userId: string) => ({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: userId === adminUserId, + })) + } + + it('resolves each slice against its own identity', async () => { + grantAdminTo('actor-1') + queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + queueTableRows(environment, [{ variables: { ACTOR_ONLY: 'actor-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + + const snapshot = await getExecutionEnvironment('owner-1', 'actor-1', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' }) + expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' }) + }) + + it('resolves once when both identities are the same', async () => { + grantAdminTo('owner-1') + queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + + const snapshot = await getExecutionEnvironment('owner-1', 'owner-1', 'workspace-1') + + expect(mockCheckWorkspaceAccess).toHaveBeenCalledOnce() + expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' }) + expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' }) + }) + + it('falls back to the personal identity when the actor cannot reach the workspace', async () => { + mockCheckWorkspaceAccess.mockImplementation(async (_workspaceId: string, userId: string) => ({ + exists: true, + hasAccess: userId === 'owner-1', + canWrite: true, + canAdmin: true, + })) + queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + + const snapshot = await getExecutionEnvironment('owner-1', 'departed-payer', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' }) + expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' }) + }) +}) + describe('upsertWorkspaceEnvVars', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 65d9b89be0b..4d21a71b71e 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -282,6 +282,76 @@ export async function getPersonalAndWorkspaceEnv( } } +/** + * Resolves one execution's environment from two independent identities. + * + * Workspace variables authorize against the execution actor, so the + * credential-membership filter in {@link getPersonalAndWorkspaceEnv} is applied + * to whoever caused the run rather than to whoever happens to own the workflow. + * Personal variables keep the identity that owns them — the session user on an + * interactive run, the workflow owner on a background one — because a deployed + * workflow is routinely authored against its owner's personal keys and would + * otherwise lose them the moment anyone else triggered it. + * + * A run whose two identities coincide, which is every interactive run, resolves + * exactly as before through a single query. + * + * When the actor cannot reach the workspace at all, the personal identity is + * reused for both slices and the fault is reported rather than raised. + * `workspace.billedAccountUserId` is a stored column rather than a derivation, + * so an organization ownership transfer can leave it pointing at a user with no + * remaining access; failing here would take down every background execution in + * that workspace for a misconfiguration the run itself did not cause. The error + * line is what makes that state visible while it is repaired. + */ +export async function getExecutionEnvironment( + personalUserId: string, + workspaceUserId: string, + workspaceId?: string +): Promise { + if (!workspaceId || workspaceUserId === personalUserId) { + return getPersonalAndWorkspaceEnv(personalUserId, workspaceId) + } + + const [personal, actor] = await Promise.all([ + getPersonalAndWorkspaceEnv(personalUserId, workspaceId), + getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId).catch((error) => { + logger.error('Execution actor cannot reach the workspace; falling back to the owner', { + personalUserId, + workspaceUserId, + workspaceId, + error: getErrorMessage(error, 'Unknown error'), + }) + return undefined + }), + ]) + + if (!actor) return personal + + /** + * Each snapshot reports decryption failures across both of its own slices, so + * a name is only carried over when it belongs to the slice being kept. + */ + const decryptionFailures = [ + ...new Set([ + ...personal.decryptionFailures.filter((key) => key in personal.personalEncrypted), + ...actor.decryptionFailures.filter((key) => key in actor.workspaceEncrypted), + ]), + ] + + return { + personalEncrypted: personal.personalEncrypted, + workspaceEncrypted: actor.workspaceEncrypted, + personalDecrypted: personal.personalDecrypted, + workspaceDecrypted: actor.workspaceDecrypted, + personalOwners: personal.personalOwners, + conflicts: Object.keys(personal.personalEncrypted).filter( + (key) => key in actor.workspaceEncrypted + ), + decryptionFailures, + } +} + export interface EnvUpsertResult { added: string[] updated: string[] diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 6a8b3527c4e..cfb818c1cc6 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -236,6 +236,8 @@ async function executeCopilotRun(params: { enabled: true, useDraftState: params.input.useDraftState, workflowTriggerType: 'copilot', + /** `requirePrincipalSubjectUserId` above rejects every principal that cannot name a caller. */ + enforceCredentialAccess: true, triggerBlockId: params.triggerBlockId, stopAfterBlockId: params.stopAfterBlockId, runFromBlock: params.runFromBlock, diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index 150d24e0bd2..b892b982bc6 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -479,6 +479,7 @@ export async function executeWorkflowService( base64MaxBytes, abortSignal: streamAbortSignal, executionMode: 'stream', + enforceCredentialAccess: useAuthenticatedUserAsActor, billingAttribution, largeValueKeys: [], fileKeys: [], diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index d054976c871..c74d2cdcc9f 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -56,6 +56,14 @@ export interface ExecuteWorkflowOptions { /** Trusted encrypted provenance supplied by a server-only caller before execution starts. */ trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 executionMode?: 'sync' | 'stream' | 'async' + /** + * Whether the run has an identifiable caller to authorize against, from + * `principal.kind !== 'workspace_api_key'` (see {@link ExecutionMetadata.enforceCredentialAccess}). + * Streaming runs reach the executor through here rather than through the route's + * own metadata, so callers must forward it or secrets resolve as the workflow + * owner on the streaming path and as the caller everywhere else. + */ + enforceCredentialAccess?: boolean /** Immutable actor/payer decision captured by preprocessing. */ billingAttribution?: BillingAttributionSnapshot /** Server-issued run identity persisted with the execution log and snapshot. */ @@ -126,6 +134,7 @@ export async function executeWorkflow( useDraftState: streamConfig?.useDraftState ?? false, startTime: new Date().toISOString(), isClientSession: false, + enforceCredentialAccess: streamConfig?.enforceCredentialAccess ?? false, largeValueExecutionIds: Array.from(new Set([executionId])), largeValueKeys: streamConfig?.largeValueKeys, fileKeys: streamConfig?.fileKeys, diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 91aec41b9ec..01af35d371f 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -315,11 +315,18 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { await executionPromise + /** + * The default snapshot is a server-side run, so its personal and workspace + * identities differ and the environment resolves once per identity. Both + * lookups still overlap the workflow load, which is what this pins. + */ expect(callOrder).toEqual([ 'load-workflow:start', 'load-env:start', + 'load-env:start', 'load-workflow:end', 'load-env:end', + 'load-env:end', 'safeStart', 'executor-construct', ]) @@ -1622,6 +1629,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { ...createSnapshot().metadata, isClientSession: true, sessionUserId: 'session-user', + userId: 'session-user', workflowUserId: 'workflow-owner', }, } @@ -1646,9 +1654,10 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { }) expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('session-user', 'workspace-1') + expect(getPersonalAndWorkspaceEnvMock).not.toHaveBeenCalledWith('workflow-owner', 'workspace-1') }) - it('uses workflowUserId for env resolution in server-side execution', async () => { + it('resolves personal vars as the workflow owner and workspace vars as the actor', async () => { const snapshot = { ...createSnapshot(), metadata: { @@ -1660,12 +1669,58 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { }, } - getPersonalAndWorkspaceEnvMock.mockResolvedValue({ - personalEncrypted: {}, + getPersonalAndWorkspaceEnvMock.mockImplementation(async (userId: string) => ({ + personalEncrypted: { PERSONAL: `enc-personal-${userId}` }, + workspaceEncrypted: { WORKSPACE: `enc-workspace-${userId}` }, + personalDecrypted: { PERSONAL: `personal-${userId}` }, + workspaceDecrypted: { WORKSPACE: `workspace-${userId}` }, + personalOwners: {}, + conflicts: [], + decryptionFailures: [], + })) + safeStartMock.mockResolvedValue(true) + executorExecuteMock.mockResolvedValue({ + output: { done: true }, + logs: [], + metadata: { duration: 123, startTime: 'start', endTime: 'end' }, + }) + + await executeWorkflowCore({ + snapshot: snapshot as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + + expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('workflow-owner', 'workspace-1') + expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('billing-actor', 'workspace-1') + expect(executorConstructorMock.mock.calls[0]?.[0]?.envVarValues).toEqual({ + PERSONAL: 'personal-workflow-owner', + WORKSPACE: 'workspace-billing-actor', + }) + }) + + it('resolves both slices as the caller when the run has an identifiable one', async () => { + const snapshot = { + ...createSnapshot(), + metadata: { + ...createSnapshot().metadata, + isClientSession: false, + sessionUserId: undefined, + enforceCredentialAccess: true, + workflowUserId: 'workflow-owner', + userId: 'api-key-caller', + }, + } + + getPersonalAndWorkspaceEnvMock.mockImplementation(async (userId: string) => ({ + personalEncrypted: { PERSONAL: `enc-personal-${userId}` }, workspaceEncrypted: {}, - personalDecrypted: {}, + personalDecrypted: { PERSONAL: `personal-${userId}` }, workspaceDecrypted: {}, - }) + personalOwners: {}, + conflicts: [], + decryptionFailures: [], + })) safeStartMock.mockResolvedValue(true) executorExecuteMock.mockResolvedValue({ output: { done: true }, @@ -1679,7 +1734,11 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { loggingSession: loggingSession as any, }) - expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('workflow-owner', 'workspace-1') + expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('api-key-caller', 'workspace-1') + expect(getPersonalAndWorkspaceEnvMock).not.toHaveBeenCalledWith('workflow-owner', 'workspace-1') + expect(executorConstructorMock.mock.calls[0]?.[0]?.envVarValues).toEqual({ + PERSONAL: 'personal-api-key-caller', + }) }) it('throws when workflowUserId is missing in server-side execution', async () => { diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index f0fb936cb29..d7a18396b26 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -18,7 +18,7 @@ import { getTimeoutErrorMessage, isTimeoutAbortReason, } from '@/lib/core/execution-limits' -import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { getExecutionEnvironment } from '@/lib/environment/utils' import { clearExecutionCancellation } from '@/lib/execution/cancellation' import { warmLargeValueRefs } from '@/lib/execution/payloads/hydration' import { parseLargeExecutionValue } from '@/lib/execution/payloads/large-execution-value' @@ -433,15 +433,35 @@ async function executeWorkflowCoreImpl( } try { + /** + * Personal variables belong to whoever is running, whenever that is knowable. + * `enforceCredentialAccess` is the principal layer's own answer to "is there + * an identifiable caller": it is set from `principal.kind !== 'workspace_api_key'`, + * so a session, personal API key, or delegated run reads its own personal + * variables rather than borrowing the workflow owner's. + * + * The workflow owner remains the fallback for a workspace API key, schedule, + * or webhook. Nobody is running those, and a deployed workflow is routinely + * authored against its owner's personal keys. + */ const personalEnvUserId = - metadata.isClientSession && metadata.sessionUserId - ? metadata.sessionUserId - : metadata.workflowUserId + (metadata.isClientSession && metadata.sessionUserId) || + (metadata.enforceCredentialAccess ? metadata.userId : undefined) || + metadata.workflowUserId if (!personalEnvUserId) { throw new Error('Missing workflowUserId in execution metadata') } + /** + * The actor already carries the identity each trigger kind should authorize + * workspace secrets against: the caller for a session, personal API key, or + * delegated principal, and the workspace billing account for a workspace API + * key, schedule, or webhook, where no caller is identifiable. Deriving it + * again here would only risk disagreeing with the principal layer. + */ + const workspaceEnvUserId = metadata.userId || personalEnvUserId + /** * Resolves the workflow state from the override, the draft tables, or the * deployed snapshot. The async load (draft/deployed) has no data dependency @@ -495,7 +515,7 @@ async function executeWorkflowCoreImpl( const [workflowState, env] = await Promise.all([ loadWorkflowState(), - getPersonalAndWorkspaceEnv(personalEnvUserId, providedWorkspaceId), + getExecutionEnvironment(personalEnvUserId, workspaceEnvUserId, providedWorkspaceId), ]) const { blocks, loops, parallels } = workflowState diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 4c875f80f09..e6f89597652 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -239,6 +239,27 @@ export const workflow = pgTable( 'workflow', { id: text('id').primaryKey(), + /** + * Creator and owner. Legitimate as ownership: it anchors personal + * (workspace-less) workflows, cascades the workflow away with the account, + * and names the owner for webhook config and deploy-as-block resolution. + * + * @deprecated As an execution identity. Do not use it to decide who a run + * acts as, what it may read, or what it may authorize. The acting principal + * is `ExecutionMetadata.userId`, which the principal layer + * (`resolvePrincipalAttribution`) resolves to the caller for a session, + * personal API key, or delegated run, and to the workspace billing account + * for a workspace API key, schedule, or webhook. + * + * Exactly one execution use survives, carried as + * `ExecutionMetadata.workflowUserId`: the personal-environment fallback in + * `executeWorkflowCore`, for runs with no identifiable caller — workspace + * API keys, schedules, webhooks, and unauthenticated public-API calls. Those + * have nobody to resolve personal variables as, and a deployed workflow is + * routinely authored against its owner's personal keys, so dropping the + * fallback would break them. Workspace variables never fall back here; they + * always authorize against the actor. + */ userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), diff --git a/packages/testing/src/mocks/environment-utils.mock.ts b/packages/testing/src/mocks/environment-utils.mock.ts index 69b12c37b32..07d8fed4790 100644 --- a/packages/testing/src/mocks/environment-utils.mock.ts +++ b/packages/testing/src/mocks/environment-utils.mock.ts @@ -30,12 +30,40 @@ function emptyPersonalAndWorkspaceEnv(): { * environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'k' }) * ``` */ +/** + * Mirrors the real resolver: one lookup when both identities match, two when the + * execution actor differs from the identity owning the personal variables. + * Delegating keeps `mockGetPersonalAndWorkspaceEnv` the single place a test has + * to stub environment data. + */ +async function delegateExecutionEnvironment( + personalUserId: string, + workspaceUserId: string, + workspaceId?: string +) { + const resolve = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv + if (!workspaceId || workspaceUserId === personalUserId) { + return resolve(personalUserId, workspaceId) + } + + const [personal, actor] = await Promise.all([ + resolve(personalUserId, workspaceId), + resolve(workspaceUserId, workspaceId), + ]) + return { + ...personal, + workspaceEncrypted: actor.workspaceEncrypted, + workspaceDecrypted: actor.workspaceDecrypted, + } +} + export const environmentUtilsMockFns = { mockInvalidateEffectiveDecryptedEnvCache: vi.fn(), mockGetEnvironmentVariableKeys: vi.fn().mockResolvedValue({ variableNames: [], count: 0 }), mockGetPersonalAndWorkspaceEnv: vi .fn() .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()), + mockGetExecutionEnvironment: vi.fn().mockImplementation(delegateExecutionEnvironment), mockGetEffectiveEnvironmentSnapshot: vi .fn() .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()), @@ -55,6 +83,9 @@ export function resetEnvironmentUtilsMock(): void { environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv .mockReset() .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()) + environmentUtilsMockFns.mockGetExecutionEnvironment + .mockReset() + .mockImplementation(delegateExecutionEnvironment) environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot .mockReset() .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()) @@ -79,6 +110,7 @@ export const environmentUtilsMock = { environmentUtilsMockFns.mockInvalidateEffectiveDecryptedEnvCache, getEnvironmentVariableKeys: environmentUtilsMockFns.mockGetEnvironmentVariableKeys, getPersonalAndWorkspaceEnv: environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv, + getExecutionEnvironment: environmentUtilsMockFns.mockGetExecutionEnvironment, getEffectiveEnvironmentSnapshot: environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot, upsertPersonalEnvVars: environmentUtilsMockFns.mockUpsertPersonalEnvVars, upsertWorkspaceEnvVars: environmentUtilsMockFns.mockUpsertWorkspaceEnvVars, From 0717b01e91703e2c90c6e3595ca2332f9c0e7ca2 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 13 Aug 2026 18:20:29 -0700 Subject: [PATCH 2/3] fix(execution): resolve anonymous public-API runs as the workspace billing account --- .../api/v2/workflows/[id]/execute/route.ts | 1 + .../app/api/workflows/[id]/execute/route.ts | 3 ++ apps/sim/executor/execution/types.ts | 10 +++++ .../lib/workflows/executor/execute-service.ts | 5 +++ .../workflows/executor/execute-workflow.ts | 3 ++ .../workflows/executor/execution-core.test.ts | 43 +++++++++++++++++++ .../lib/workflows/executor/execution-core.ts | 13 ++++-- 7 files changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 59dea2ef5f9..1ac2c88dbec 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -285,6 +285,7 @@ export const POST = withRouteHandler( result = await executeWorkflowService({ workflowId, userId, + isPublicApiAccess, input: body.input ?? {}, triggerType: 'api', requestId, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 16aef9eb70d..7c5e6433f7c 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1382,6 +1382,7 @@ async function handleExecutePost( startTime: new Date().toISOString(), isClientSession, enforceCredentialAccess: useAuthenticatedUserAsActor, + isPublicApiAccess, workflowStateOverride: effectiveWorkflowStateOverride, largeValueExecutionIds, largeValueKeys, @@ -1699,6 +1700,7 @@ async function handleExecutePost( abortSignal, executionMode: 'stream', enforceCredentialAccess: useAuthenticatedUserAsActor, + isPublicApiAccess, billingAttribution, largeValueKeys, fileKeys, @@ -2105,6 +2107,7 @@ async function handleExecutePost( startTime: new Date().toISOString(), isClientSession, enforceCredentialAccess: useAuthenticatedUserAsActor, + isPublicApiAccess, workflowStateOverride: effectiveWorkflowStateOverride, largeValueExecutionIds, largeValueKeys, diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index ff08d69c447..f5f58c98cde 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -34,6 +34,16 @@ export interface ExecutionMetadata { startTime: string isClientSession?: boolean enforceCredentialAccess?: boolean + /** + * The run entered through the anonymous public-API path, so nobody in the + * workspace triggered it. Unlike a schedule, webhook, or workspace API key — + * all configured by someone here, which is why those still fall back to the + * workflow owner's personal variables — this endpoint is callable by anyone, + * and resolving one human's personal namespace for an anonymous caller is not + * something the owner opted into. Such runs use the workspace's own billing + * principal for both environment slices instead. + */ + isPublicApiAccess?: boolean pendingBlocks?: string[] resumeFromSnapshot?: boolean resumeTerminalNoop?: boolean diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index b892b982bc6..5948b640204 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -77,6 +77,8 @@ export interface ExecuteWorkflowServiceParams { executionId?: string callChain?: string[] useAuthenticatedUserAsActor?: boolean + /** Anonymous public-API run (see {@link ExecutionMetadata.isPublicApiAccess}). */ + isPublicApiAccess?: boolean /** Pre-fetched workflow row (already authorized by the caller). */ workflowRecord?: WorkflowRecord upstreamBillingAttribution?: BillingAttributionSnapshot @@ -201,6 +203,7 @@ export async function executeWorkflowService( requestId, callChain, useAuthenticatedUserAsActor = false, + isPublicApiAccess = false, workflowRecord, upstreamBillingAttribution, deploymentVersionId, @@ -480,6 +483,7 @@ export async function executeWorkflowService( abortSignal: streamAbortSignal, executionMode: 'stream', enforceCredentialAccess: useAuthenticatedUserAsActor, + isPublicApiAccess, billingAttribution, largeValueKeys: [], fileKeys: [], @@ -519,6 +523,7 @@ export async function executeWorkflowService( startTime: new Date().toISOString(), isClientSession: false, enforceCredentialAccess: useAuthenticatedUserAsActor, + isPublicApiAccess, largeValueExecutionIds: [executionId], largeValueKeys: [], fileKeys: [], diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index c74d2cdcc9f..ad20d6d3807 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -64,6 +64,8 @@ export interface ExecuteWorkflowOptions { * owner on the streaming path and as the caller everywhere else. */ enforceCredentialAccess?: boolean + /** Anonymous public-API run (see {@link ExecutionMetadata.isPublicApiAccess}). */ + isPublicApiAccess?: boolean /** Immutable actor/payer decision captured by preprocessing. */ billingAttribution?: BillingAttributionSnapshot /** Server-issued run identity persisted with the execution log and snapshot. */ @@ -135,6 +137,7 @@ export async function executeWorkflow( startTime: new Date().toISOString(), isClientSession: false, enforceCredentialAccess: streamConfig?.enforceCredentialAccess ?? false, + isPublicApiAccess: streamConfig?.isPublicApiAccess ?? false, largeValueExecutionIds: Array.from(new Set([executionId])), largeValueKeys: streamConfig?.largeValueKeys, fileKeys: streamConfig?.fileKeys, diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 01af35d371f..bb541c497b0 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -1699,6 +1699,49 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { }) }) + it('resolves both slices as the billing account for an anonymous public-API run', async () => { + const snapshot = { + ...createSnapshot(), + metadata: { + ...createSnapshot().metadata, + isClientSession: false, + sessionUserId: undefined, + enforceCredentialAccess: false, + isPublicApiAccess: true, + workflowUserId: 'workflow-owner', + userId: 'billing-account', + }, + } + + getPersonalAndWorkspaceEnvMock.mockImplementation(async (userId: string) => ({ + personalEncrypted: { PERSONAL: `enc-personal-${userId}` }, + workspaceEncrypted: {}, + personalDecrypted: { PERSONAL: `personal-${userId}` }, + workspaceDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: [], + })) + safeStartMock.mockResolvedValue(true) + executorExecuteMock.mockResolvedValue({ + output: { done: true }, + logs: [], + metadata: { duration: 123, startTime: 'start', endTime: 'end' }, + }) + + await executeWorkflowCore({ + snapshot: snapshot as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + + expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('billing-account', 'workspace-1') + expect(getPersonalAndWorkspaceEnvMock).not.toHaveBeenCalledWith('workflow-owner', 'workspace-1') + expect(executorConstructorMock.mock.calls[0]?.[0]?.envVarValues).toEqual({ + PERSONAL: 'personal-billing-account', + }) + }) + it('resolves both slices as the caller when the run has an identifiable one', async () => { const snapshot = { ...createSnapshot(), diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index d7a18396b26..86151e15f06 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -440,13 +440,20 @@ async function executeWorkflowCoreImpl( * so a session, personal API key, or delegated run reads its own personal * variables rather than borrowing the workflow owner's. * + * An anonymous public-API run reaches the actor by the opposite argument — + * there is no caller to read as, and no owner consented to lending their + * personal namespace to the whole internet — so it uses the workspace's own + * billing principal, which is already what its workspace variables resolve as. + * * The workflow owner remains the fallback for a workspace API key, schedule, - * or webhook. Nobody is running those, and a deployed workflow is routinely - * authored against its owner's personal keys. + * or webhook. Someone in the workspace configured each of those, and a + * deployed workflow is routinely authored against its owner's personal keys. */ const personalEnvUserId = (metadata.isClientSession && metadata.sessionUserId) || - (metadata.enforceCredentialAccess ? metadata.userId : undefined) || + (metadata.enforceCredentialAccess || metadata.isPublicApiAccess + ? metadata.userId + : undefined) || metadata.workflowUserId if (!personalEnvUserId) { From 3c4dd6b8d518896e4d0f73fc4781b55507ba6b41 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 13 Aug 2026 18:43:50 -0700 Subject: [PATCH 3/3] fix(execution): propagate run identity across dispatch paths and scope public runs to workspace secrets --- .../content/docs/en/platform/credentials.mdx | 14 ++++-- .../app/api/workflows/[id]/execute/route.ts | 12 ++++- apps/sim/background/workflow-execution.ts | 10 ++++ .../executor/execution/snapshot-serializer.ts | 7 +++ apps/sim/lib/environment/utils.test.ts | 22 ++++++++- apps/sim/lib/environment/utils.ts | 48 ++++++++++++++----- .../workflows/executor/enqueue-execution.ts | 7 +++ .../lib/workflows/executor/execute-service.ts | 10 +++- .../workflows/executor/execution-core.test.ts | 8 ++-- .../lib/workflows/executor/execution-core.ts | 33 +++++++------ .../src/mocks/environment-utils.mock.ts | 25 +++++++++- 11 files changed, 157 insertions(+), 39 deletions(-) diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index 7b03b1a72d9..ef1425bd3c8 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -124,8 +124,16 @@ When a workspace secret and a personal secret share the same key name, the **wor When a workflow runs, secrets resolve in this order: -1. **Workspace secrets** are checked first -2. **Personal secrets** are used as a fallback — from the user who triggered the run (manual) or the workflow owner (automated runs via API, webhook, or schedule) +1. **Workspace secrets** are checked first, and always resolve against the identity running the workflow — the caller when one can be identified, otherwise the workspace's billing account. A run only sees the workspace secrets that identity is allowed to use. +2. **Personal secrets** are used as a fallback, from whichever identity is running: + +| Run started by | Personal secrets come from | +| --- | --- | +| Clicking Run, or a personal API key | The person running it | +| A workspace API key, schedule, or webhook | The workflow owner | +| A public API URL with no authentication | Nobody — personal secrets do not resolve | + +The workflow owner is the fallback only where nobody can be identified but somebody in the workspace set the trigger up, since those workflows are usually built against the owner's own keys. A public URL can be called by anyone, so it never borrows a person's keys at all — put every secret such a workflow needs in **Workspace**. ## Best Practices @@ -138,7 +146,7 @@ When a workflow runs, secrets resolve in this order: { question: "Are my secrets encrypted at rest?", answer: "Yes. Values saved under Secrets are encrypted before being stored in the database." }, { question: "Can a saved secret still appear in a workflow result?", answer: "Yes. Functional workflow data is not rewritten, so the raw value can still reach downstream blocks and tools and can appear in workflow execution responses, streams, or callbacks if your workflow deliberately returns or prints it. Log-facing views and read APIs receive a protected copy after a successful {{KEY}} resolution. Before content is sent to a model, exact values from the run's authorized secret catalog are replaced with placeholders, but encoded or otherwise transformed values remain outside that protection." }, { question: "What happens if both a workspace secret and a personal secret have the same key name?", answer: "Among secrets available to the execution actor, the workspace secret takes precedence and the personal secret is the fallback. An inaccessible workspace secret does not shadow an authorized personal value." }, - { question: "Who determines which personal secret is used for automated runs?", answer: "For manual runs, the personal secrets of the user who clicked Run are used as fallback. For automated runs triggered by API, webhook, or schedule, the personal secrets of the workflow owner are used instead." }, + { question: "Who determines which personal secret is used for automated runs?", answer: "Whoever is running it, when that can be identified. Clicking Run or calling with a personal API key uses that person's personal secrets. A workspace API key, schedule, or webhook has no identifiable caller, so it falls back to the workflow owner's — those triggers are set up inside the workspace and the workflow is usually built against the owner's own keys. A public API URL with no authentication can be called by anyone, so no personal secrets resolve at all — those workflows run on workspace secrets only." }, { question: "Can I import secrets from a .env file?", answer: "Yes. Paste .env-style content (KEY=VALUE format) into any key or value field and the secrets will be auto-populated. The parser supports export KEY=VALUE, quoted values, and inline comments." }, { question: "What happens if I delete a secret that is used in a workflow?", answer: "The workflow will fail at any block that references the deleted secret during execution because the value cannot be resolved. Update any references before deleting a secret." }, ]} /> diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 7c5e6433f7c..53040cb8ed0 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -385,6 +385,8 @@ type AsyncExecutionParams = { executionId: string copilotToolCallId?: string callChain?: string[] + enforceCredentialAccess?: boolean + isPublicApiAccess?: boolean executionTimeoutMs: number trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 } @@ -1245,6 +1247,8 @@ async function handleExecutePost( executionId, copilotToolCallId, callChain, + enforceCredentialAccess: useAuthenticatedUserAsActor, + isPublicApiAccess, executionTimeoutMs: preprocessResult.executionTimeout.async, trustedInitialResolvedSecretTraceProvenance, }) @@ -1628,7 +1632,13 @@ async function handleExecutePost( const streamVariables = cachedWorkflowData?.variables ?? (workflow as any).variables const streamWorkflow = { id: workflow.id, - userId: actorUserId, + /** + * The owner, not the actor: `executeWorkflow` reads this one field to set + * `workflowUserId`, which is the personal-environment fallback for runs with + * no identifiable caller. Passing the actor here made the streaming path + * resolve the actor where the JSON path resolves the owner. + */ + userId: workflow.userId, workspaceId, isDeployed: workflow.isDeployed, variables: streamVariables, diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index c29ad7601f7..0621f671960 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -76,6 +76,14 @@ export type WorkflowExecutionPayload = { executionTimeoutMs?: number /** Authenticated input provenance validated by the workflow execution boundary. */ trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 + /** + * Identity decisions the enqueuing surface already made. They must ride the + * payload because the worker has no request to re-derive them from, and a + * queued run that dropped them would resolve its personal variables as the + * workflow owner while still authorizing workspace variables as the actor. + */ + enforceCredentialAccess?: boolean + isPublicApiAccess?: boolean } /** @@ -193,6 +201,8 @@ export async function executeWorkflowJob( useDraftState: false, startTime: new Date().toISOString(), isClientSession: false, + enforceCredentialAccess: payload.enforceCredentialAccess ?? false, + isPublicApiAccess: payload.isPublicApiAccess ?? false, callChain: payload.callChain, correlation, executionMode: payload.executionMode ?? 'async', diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index 3ea34a1b5be..576df10c30a 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -273,6 +273,13 @@ export function serializePauseSnapshot( useDraftState, startTime: metadataFromContext?.startTime ?? new Date().toISOString(), isClientSession: metadataFromContext?.isClientSession, + /** + * Both identity flags survive pause/resume. Dropping them would silently + * re-resolve a resumed run's personal variables as the workflow owner even + * though the original run authorized as its caller. + */ + enforceCredentialAccess: metadataFromContext?.enforceCredentialAccess, + isPublicApiAccess: metadataFromContext?.isPublicApiAccess, executionMode: metadataFromContext?.executionMode, /** Preserve deployed-chat thinking gate across HITL pause/resume. */ includeThinking: metadataFromContext?.includeThinking === true ? true : undefined, diff --git a/apps/sim/lib/environment/utils.test.ts b/apps/sim/lib/environment/utils.test.ts index 47274d23816..4bf4e8fa69a 100644 --- a/apps/sim/lib/environment/utils.test.ts +++ b/apps/sim/lib/environment/utils.test.ts @@ -142,10 +142,16 @@ describe('getExecutionEnvironment', () => { it('resolves each slice against its own identity', async () => { grantAdminTo('actor-1') - queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) - queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + /** + * Queued rows are FIFO per table, and the actor resolves first: its access was + * already decided, so it skips the `checkWorkspaceAccess` await the personal + * resolution still performs. Only the actor is a workspace admin, so the owner's + * own workspace slice resolves empty and could not be the one that lands. + */ queueTableRows(environment, [{ variables: { ACTOR_ONLY: 'actor-cipher' } }]) queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) const snapshot = await getExecutionEnvironment('owner-1', 'actor-1', 'workspace-1') @@ -165,6 +171,18 @@ describe('getExecutionEnvironment', () => { expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' }) }) + it('drops the personal slice entirely when no personal identity is supplied', async () => { + grantAdminTo('billing-account') + queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }]) + + const snapshot = await getExecutionEnvironment(undefined, 'billing-account', 'workspace-1') + + expect(snapshot.personalDecrypted).toEqual({}) + expect(snapshot.personalEncrypted).toEqual({}) + expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' }) + }) + it('falls back to the personal identity when the actor cannot reach the workspace', async () => { mockCheckWorkspaceAccess.mockImplementation(async (_workspaceId: string, userId: string) => ({ exists: true, diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 4d21a71b71e..d9ecddafb4b 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -293,41 +293,63 @@ export async function getPersonalAndWorkspaceEnv( * workflow is routinely authored against its owner's personal keys and would * otherwise lose them the moment anyone else triggered it. * + * An undefined `personalUserId` means no personal namespace belongs in this run at + * all, which is how an anonymous public-API call resolves: workspace variables only. + * * A run whose two identities coincide, which is every interactive run, resolves * exactly as before through a single query. * - * When the actor cannot reach the workspace at all, the personal identity is + * When the actor has no access to the workspace at all, the personal identity is * reused for both slices and the fault is reported rather than raised. * `workspace.billedAccountUserId` is a stored column rather than a derivation, * so an organization ownership transfer can leave it pointing at a user with no * remaining access; failing here would take down every background execution in * that workspace for a misconfiguration the run itself did not cause. The error * line is what makes that state visible while it is repaired. + * + * That fallback is gated on the access decision alone, never on a failed query. + * Widening to a `catch` would let a transient database fault silently promote the + * run to the owner's broader secret selection, which is the opposite of what an + * infrastructure error should do — those propagate and fail the run. */ export async function getExecutionEnvironment( - personalUserId: string, + personalUserId: string | undefined, workspaceUserId: string, workspaceId?: string ): Promise { + if (personalUserId === undefined) { + const workspaceOnly = await getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId) + return { + ...workspaceOnly, + personalEncrypted: {}, + personalDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: workspaceOnly.decryptionFailures.filter( + (key) => key in workspaceOnly.workspaceEncrypted + ), + } + } + if (!workspaceId || workspaceUserId === personalUserId) { return getPersonalAndWorkspaceEnv(personalUserId, workspaceId) } + const actorAccess = await checkWorkspaceAccess(workspaceId, workspaceUserId) + if (!actorAccess.hasAccess) { + logger.error('Execution actor cannot reach the workspace; falling back to the owner', { + personalUserId, + workspaceUserId, + workspaceId, + }) + return getPersonalAndWorkspaceEnv(personalUserId, workspaceId) + } + const [personal, actor] = await Promise.all([ getPersonalAndWorkspaceEnv(personalUserId, workspaceId), - getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId).catch((error) => { - logger.error('Execution actor cannot reach the workspace; falling back to the owner', { - personalUserId, - workspaceUserId, - workspaceId, - error: getErrorMessage(error, 'Unknown error'), - }) - return undefined - }), + getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId, { workspaceAccess: actorAccess }), ]) - if (!actor) return personal - /** * Each snapshot reports decryption failures across both of its own slices, so * a name is only carried over when it belongs to the slice being kept. diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts index abf54a1c0f4..2eec38cacdc 100644 --- a/apps/sim/lib/workflows/executor/enqueue-execution.ts +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -33,6 +33,9 @@ export interface EnqueueWorkflowExecutionParams { callChain?: string[] executionTimeoutMs: number trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 + /** Identity decisions the enqueuing surface made; the worker cannot re-derive them. */ + enforceCredentialAccess?: boolean + isPublicApiAccess?: boolean } /** @@ -77,6 +80,8 @@ export async function enqueueWorkflowExecution( callChain, executionTimeoutMs, trustedInitialResolvedSecretTraceProvenance, + enforceCredentialAccess, + isPublicApiAccess, } = params const asyncLogger = logger.withMetadata({ requestId, @@ -107,6 +112,8 @@ export async function enqueueWorkflowExecution( requestId, correlation, callChain, + enforceCredentialAccess, + isPublicApiAccess, executionMode: 'async', admissionCompleted: true, executionTimeoutMs, diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index 5948b640204..a541692505f 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -337,6 +337,8 @@ export async function executeWorkflowService( triggerType, executionId, callChain, + enforceCredentialAccess: useAuthenticatedUserAsActor, + isPublicApiAccess, executionTimeoutMs: preprocessResult.executionTimeout.async, }) executionIdClaimCommitted = enqueue.retainExecutionClaim @@ -430,7 +432,13 @@ export async function executeWorkflowService( const resolvedSelectedOutputs = resolveOutputIds(selectedOutputs, workflowBlocks) const streamWorkflow = { id: workflow.id, - userId: actorUserId, + /** + * The owner, not the actor: `executeWorkflow` reads this one field to set + * `workflowUserId`, which is the personal-environment fallback for runs with + * no identifiable caller. Passing the actor here made the streaming path + * resolve the actor where the JSON path resolves the owner. + */ + userId: workflow.userId, workspaceId, isDeployed: workflow.isDeployed, variables: workflowVariables, diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index bb541c497b0..d50662237a0 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -1699,7 +1699,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { }) }) - it('resolves both slices as the billing account for an anonymous public-API run', async () => { + it('resolves no personal vars and workspace vars as the billing account on a public-API run', async () => { const snapshot = { ...createSnapshot(), metadata: { @@ -1715,9 +1715,9 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { getPersonalAndWorkspaceEnvMock.mockImplementation(async (userId: string) => ({ personalEncrypted: { PERSONAL: `enc-personal-${userId}` }, - workspaceEncrypted: {}, + workspaceEncrypted: { WORKSPACE: `enc-workspace-${userId}` }, personalDecrypted: { PERSONAL: `personal-${userId}` }, - workspaceDecrypted: {}, + workspaceDecrypted: { WORKSPACE: `workspace-${userId}` }, personalOwners: {}, conflicts: [], decryptionFailures: [], @@ -1738,7 +1738,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('billing-account', 'workspace-1') expect(getPersonalAndWorkspaceEnvMock).not.toHaveBeenCalledWith('workflow-owner', 'workspace-1') expect(executorConstructorMock.mock.calls[0]?.[0]?.envVarValues).toEqual({ - PERSONAL: 'personal-billing-account', + WORKSPACE: 'workspace-billing-account', }) }) diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 86151e15f06..16999cac0c6 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -440,23 +440,24 @@ async function executeWorkflowCoreImpl( * so a session, personal API key, or delegated run reads its own personal * variables rather than borrowing the workflow owner's. * - * An anonymous public-API run reaches the actor by the opposite argument — - * there is no caller to read as, and no owner consented to lending their - * personal namespace to the whole internet — so it uses the workspace's own - * billing principal, which is already what its workspace variables resolve as. - * * The workflow owner remains the fallback for a workspace API key, schedule, * or webhook. Someone in the workspace configured each of those, and a * deployed workflow is routinely authored against its owner's personal keys. + * + * An anonymous public-API run resolves no personal variables at all. Anyone + * can call that endpoint, so there is no caller to read as and no person whose + * private namespace it would be reasonable to lend — such a workflow runs on + * workspace secrets alone. */ - const personalEnvUserId = + const identifiedCallerUserId = (metadata.isClientSession && metadata.sessionUserId) || - (metadata.enforceCredentialAccess || metadata.isPublicApiAccess - ? metadata.userId - : undefined) || - metadata.workflowUserId + (metadata.enforceCredentialAccess ? metadata.userId : undefined) - if (!personalEnvUserId) { + const personalEnvUserId = metadata.isPublicApiAccess + ? undefined + : identifiedCallerUserId || metadata.workflowUserId + + if (!metadata.isPublicApiAccess && !personalEnvUserId) { throw new Error('Missing workflowUserId in execution metadata') } @@ -464,10 +465,14 @@ async function executeWorkflowCoreImpl( * The actor already carries the identity each trigger kind should authorize * workspace secrets against: the caller for a session, personal API key, or * delegated principal, and the workspace billing account for a workspace API - * key, schedule, or webhook, where no caller is identifiable. Deriving it - * again here would only risk disagreeing with the principal layer. + * key, schedule, webhook, or anonymous public-API call, where no caller is + * identifiable. Deriving it again here would only risk disagreeing with the + * principal layer. */ const workspaceEnvUserId = metadata.userId || personalEnvUserId + if (!workspaceEnvUserId) { + throw new Error('Missing execution actor in execution metadata') + } /** * Resolves the workflow state from the override, the draft tables, or the @@ -563,7 +568,7 @@ async function executeWorkflowCoreImpl( restoredCheckpointVersion: restoredState?.resolvedSecretTraceCheckpointVersion, restoreTrusted, requireRestoredProvenance, - scope: { userId: personalEnvUserId, workspaceId: providedWorkspaceId }, + scope: { userId: personalEnvUserId ?? workspaceEnvUserId, workspaceId: providedWorkspaceId }, }) if (restoredState && !restoreTrusted) { resolvedSecretTraceRegistry.markIncomplete('restored-provenance-untrusted') diff --git a/packages/testing/src/mocks/environment-utils.mock.ts b/packages/testing/src/mocks/environment-utils.mock.ts index 07d8fed4790..23501b9734e 100644 --- a/packages/testing/src/mocks/environment-utils.mock.ts +++ b/packages/testing/src/mocks/environment-utils.mock.ts @@ -37,11 +37,25 @@ function emptyPersonalAndWorkspaceEnv(): { * to stub environment data. */ async function delegateExecutionEnvironment( - personalUserId: string, + personalUserId: string | undefined, workspaceUserId: string, workspaceId?: string ) { const resolve = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv + if (personalUserId === undefined) { + const workspaceOnly = await resolve(workspaceUserId, workspaceId) + return { + ...workspaceOnly, + personalEncrypted: {}, + personalDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: (workspaceOnly.decryptionFailures ?? []).filter( + (k: string) => k in (workspaceOnly.workspaceEncrypted ?? {}) + ), + } + } + if (!workspaceId || workspaceUserId === personalUserId) { return resolve(personalUserId, workspaceId) } @@ -50,10 +64,19 @@ async function delegateExecutionEnvironment( resolve(personalUserId, workspaceId), resolve(workspaceUserId, workspaceId), ]) + const personalEncrypted = personal.personalEncrypted ?? {} + const workspaceEncrypted = actor.workspaceEncrypted ?? {} return { ...personal, workspaceEncrypted: actor.workspaceEncrypted, workspaceDecrypted: actor.workspaceDecrypted, + conflicts: Object.keys(personalEncrypted).filter((key) => key in workspaceEncrypted), + decryptionFailures: [ + ...new Set([ + ...(personal.decryptionFailures ?? []).filter((k: string) => k in personalEncrypted), + ...(actor.decryptionFailures ?? []).filter((k: string) => k in workspaceEncrypted), + ]), + ], } }