Skip to content

Commit 3c4dd6b

Browse files
committed
fix(execution): propagate run identity across dispatch paths and scope public runs to workspace secrets
1 parent 0717b01 commit 3c4dd6b

11 files changed

Lines changed: 157 additions & 39 deletions

File tree

apps/docs/content/docs/en/platform/credentials.mdx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,16 @@ When a workspace secret and a personal secret share the same key name, the **wor
124124

125125
When a workflow runs, secrets resolve in this order:
126126

127-
1. **Workspace secrets** are checked first
128-
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)
127+
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.
128+
2. **Personal secrets** are used as a fallback, from whichever identity is running:
129+
130+
| Run started by | Personal secrets come from |
131+
| --- | --- |
132+
| Clicking Run, or a personal API key | The person running it |
133+
| A workspace API key, schedule, or webhook | The workflow owner |
134+
| A public API URL with no authentication | Nobody — personal secrets do not resolve |
135+
136+
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**.
129137

130138
## Best Practices
131139

@@ -138,7 +146,7 @@ When a workflow runs, secrets resolve in this order:
138146
{ question: "Are my secrets encrypted at rest?", answer: "Yes. Values saved under Secrets are encrypted before being stored in the database." },
139147
{ 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." },
140148
{ 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." },
141-
{ 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." },
149+
{ 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." },
142150
{ 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." },
143151
{ 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." },
144152
]} />

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,8 @@ type AsyncExecutionParams = {
385385
executionId: string
386386
copilotToolCallId?: string
387387
callChain?: string[]
388+
enforceCredentialAccess?: boolean
389+
isPublicApiAccess?: boolean
388390
executionTimeoutMs: number
389391
trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
390392
}
@@ -1245,6 +1247,8 @@ async function handleExecutePost(
12451247
executionId,
12461248
copilotToolCallId,
12471249
callChain,
1250+
enforceCredentialAccess: useAuthenticatedUserAsActor,
1251+
isPublicApiAccess,
12481252
executionTimeoutMs: preprocessResult.executionTimeout.async,
12491253
trustedInitialResolvedSecretTraceProvenance,
12501254
})
@@ -1628,7 +1632,13 @@ async function handleExecutePost(
16281632
const streamVariables = cachedWorkflowData?.variables ?? (workflow as any).variables
16291633
const streamWorkflow = {
16301634
id: workflow.id,
1631-
userId: actorUserId,
1635+
/**
1636+
* The owner, not the actor: `executeWorkflow` reads this one field to set
1637+
* `workflowUserId`, which is the personal-environment fallback for runs with
1638+
* no identifiable caller. Passing the actor here made the streaming path
1639+
* resolve the actor where the JSON path resolves the owner.
1640+
*/
1641+
userId: workflow.userId,
16321642
workspaceId,
16331643
isDeployed: workflow.isDeployed,
16341644
variables: streamVariables,

apps/sim/background/workflow-execution.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ export type WorkflowExecutionPayload = {
7676
executionTimeoutMs?: number
7777
/** Authenticated input provenance validated by the workflow execution boundary. */
7878
trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
79+
/**
80+
* Identity decisions the enqueuing surface already made. They must ride the
81+
* payload because the worker has no request to re-derive them from, and a
82+
* queued run that dropped them would resolve its personal variables as the
83+
* workflow owner while still authorizing workspace variables as the actor.
84+
*/
85+
enforceCredentialAccess?: boolean
86+
isPublicApiAccess?: boolean
7987
}
8088

8189
/**
@@ -193,6 +201,8 @@ export async function executeWorkflowJob(
193201
useDraftState: false,
194202
startTime: new Date().toISOString(),
195203
isClientSession: false,
204+
enforceCredentialAccess: payload.enforceCredentialAccess ?? false,
205+
isPublicApiAccess: payload.isPublicApiAccess ?? false,
196206
callChain: payload.callChain,
197207
correlation,
198208
executionMode: payload.executionMode ?? 'async',

apps/sim/executor/execution/snapshot-serializer.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,13 @@ export function serializePauseSnapshot(
273273
useDraftState,
274274
startTime: metadataFromContext?.startTime ?? new Date().toISOString(),
275275
isClientSession: metadataFromContext?.isClientSession,
276+
/**
277+
* Both identity flags survive pause/resume. Dropping them would silently
278+
* re-resolve a resumed run's personal variables as the workflow owner even
279+
* though the original run authorized as its caller.
280+
*/
281+
enforceCredentialAccess: metadataFromContext?.enforceCredentialAccess,
282+
isPublicApiAccess: metadataFromContext?.isPublicApiAccess,
276283
executionMode: metadataFromContext?.executionMode,
277284
/** Preserve deployed-chat thinking gate across HITL pause/resume. */
278285
includeThinking: metadataFromContext?.includeThinking === true ? true : undefined,

apps/sim/lib/environment/utils.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,16 @@ describe('getExecutionEnvironment', () => {
142142

143143
it('resolves each slice against its own identity', async () => {
144144
grantAdminTo('actor-1')
145-
queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }])
146-
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
145+
/**
146+
* Queued rows are FIFO per table, and the actor resolves first: its access was
147+
* already decided, so it skips the `checkWorkspaceAccess` await the personal
148+
* resolution still performs. Only the actor is a workspace admin, so the owner's
149+
* own workspace slice resolves empty and could not be the one that lands.
150+
*/
147151
queueTableRows(environment, [{ variables: { ACTOR_ONLY: 'actor-cipher' } }])
148152
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
153+
queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }])
154+
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
149155

150156
const snapshot = await getExecutionEnvironment('owner-1', 'actor-1', 'workspace-1')
151157

@@ -165,6 +171,18 @@ describe('getExecutionEnvironment', () => {
165171
expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' })
166172
})
167173

174+
it('drops the personal slice entirely when no personal identity is supplied', async () => {
175+
grantAdminTo('billing-account')
176+
queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }])
177+
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
178+
179+
const snapshot = await getExecutionEnvironment(undefined, 'billing-account', 'workspace-1')
180+
181+
expect(snapshot.personalDecrypted).toEqual({})
182+
expect(snapshot.personalEncrypted).toEqual({})
183+
expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' })
184+
})
185+
168186
it('falls back to the personal identity when the actor cannot reach the workspace', async () => {
169187
mockCheckWorkspaceAccess.mockImplementation(async (_workspaceId: string, userId: string) => ({
170188
exists: true,

apps/sim/lib/environment/utils.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -293,41 +293,63 @@ export async function getPersonalAndWorkspaceEnv(
293293
* workflow is routinely authored against its owner's personal keys and would
294294
* otherwise lose them the moment anyone else triggered it.
295295
*
296+
* An undefined `personalUserId` means no personal namespace belongs in this run at
297+
* all, which is how an anonymous public-API call resolves: workspace variables only.
298+
*
296299
* A run whose two identities coincide, which is every interactive run, resolves
297300
* exactly as before through a single query.
298301
*
299-
* When the actor cannot reach the workspace at all, the personal identity is
302+
* When the actor has no access to the workspace at all, the personal identity is
300303
* reused for both slices and the fault is reported rather than raised.
301304
* `workspace.billedAccountUserId` is a stored column rather than a derivation,
302305
* so an organization ownership transfer can leave it pointing at a user with no
303306
* remaining access; failing here would take down every background execution in
304307
* that workspace for a misconfiguration the run itself did not cause. The error
305308
* line is what makes that state visible while it is repaired.
309+
*
310+
* That fallback is gated on the access decision alone, never on a failed query.
311+
* Widening to a `catch` would let a transient database fault silently promote the
312+
* run to the owner's broader secret selection, which is the opposite of what an
313+
* infrastructure error should do — those propagate and fail the run.
306314
*/
307315
export async function getExecutionEnvironment(
308-
personalUserId: string,
316+
personalUserId: string | undefined,
309317
workspaceUserId: string,
310318
workspaceId?: string
311319
): Promise<EnvironmentResolutionSnapshot> {
320+
if (personalUserId === undefined) {
321+
const workspaceOnly = await getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId)
322+
return {
323+
...workspaceOnly,
324+
personalEncrypted: {},
325+
personalDecrypted: {},
326+
personalOwners: {},
327+
conflicts: [],
328+
decryptionFailures: workspaceOnly.decryptionFailures.filter(
329+
(key) => key in workspaceOnly.workspaceEncrypted
330+
),
331+
}
332+
}
333+
312334
if (!workspaceId || workspaceUserId === personalUserId) {
313335
return getPersonalAndWorkspaceEnv(personalUserId, workspaceId)
314336
}
315337

338+
const actorAccess = await checkWorkspaceAccess(workspaceId, workspaceUserId)
339+
if (!actorAccess.hasAccess) {
340+
logger.error('Execution actor cannot reach the workspace; falling back to the owner', {
341+
personalUserId,
342+
workspaceUserId,
343+
workspaceId,
344+
})
345+
return getPersonalAndWorkspaceEnv(personalUserId, workspaceId)
346+
}
347+
316348
const [personal, actor] = await Promise.all([
317349
getPersonalAndWorkspaceEnv(personalUserId, workspaceId),
318-
getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId).catch((error) => {
319-
logger.error('Execution actor cannot reach the workspace; falling back to the owner', {
320-
personalUserId,
321-
workspaceUserId,
322-
workspaceId,
323-
error: getErrorMessage(error, 'Unknown error'),
324-
})
325-
return undefined
326-
}),
350+
getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId, { workspaceAccess: actorAccess }),
327351
])
328352

329-
if (!actor) return personal
330-
331353
/**
332354
* Each snapshot reports decryption failures across both of its own slices, so
333355
* a name is only carried over when it belongs to the slice being kept.

apps/sim/lib/workflows/executor/enqueue-execution.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export interface EnqueueWorkflowExecutionParams {
3333
callChain?: string[]
3434
executionTimeoutMs: number
3535
trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
36+
/** Identity decisions the enqueuing surface made; the worker cannot re-derive them. */
37+
enforceCredentialAccess?: boolean
38+
isPublicApiAccess?: boolean
3639
}
3740

3841
/**
@@ -77,6 +80,8 @@ export async function enqueueWorkflowExecution(
7780
callChain,
7881
executionTimeoutMs,
7982
trustedInitialResolvedSecretTraceProvenance,
83+
enforceCredentialAccess,
84+
isPublicApiAccess,
8085
} = params
8186
const asyncLogger = logger.withMetadata({
8287
requestId,
@@ -107,6 +112,8 @@ export async function enqueueWorkflowExecution(
107112
requestId,
108113
correlation,
109114
callChain,
115+
enforceCredentialAccess,
116+
isPublicApiAccess,
110117
executionMode: 'async',
111118
admissionCompleted: true,
112119
executionTimeoutMs,

apps/sim/lib/workflows/executor/execute-service.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,8 @@ export async function executeWorkflowService(
337337
triggerType,
338338
executionId,
339339
callChain,
340+
enforceCredentialAccess: useAuthenticatedUserAsActor,
341+
isPublicApiAccess,
340342
executionTimeoutMs: preprocessResult.executionTimeout.async,
341343
})
342344
executionIdClaimCommitted = enqueue.retainExecutionClaim
@@ -430,7 +432,13 @@ export async function executeWorkflowService(
430432
const resolvedSelectedOutputs = resolveOutputIds(selectedOutputs, workflowBlocks)
431433
const streamWorkflow = {
432434
id: workflow.id,
433-
userId: actorUserId,
435+
/**
436+
* The owner, not the actor: `executeWorkflow` reads this one field to set
437+
* `workflowUserId`, which is the personal-environment fallback for runs with
438+
* no identifiable caller. Passing the actor here made the streaming path
439+
* resolve the actor where the JSON path resolves the owner.
440+
*/
441+
userId: workflow.userId,
434442
workspaceId,
435443
isDeployed: workflow.isDeployed,
436444
variables: workflowVariables,

apps/sim/lib/workflows/executor/execution-core.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1699,7 +1699,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
16991699
})
17001700
})
17011701

1702-
it('resolves both slices as the billing account for an anonymous public-API run', async () => {
1702+
it('resolves no personal vars and workspace vars as the billing account on a public-API run', async () => {
17031703
const snapshot = {
17041704
...createSnapshot(),
17051705
metadata: {
@@ -1715,9 +1715,9 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
17151715

17161716
getPersonalAndWorkspaceEnvMock.mockImplementation(async (userId: string) => ({
17171717
personalEncrypted: { PERSONAL: `enc-personal-${userId}` },
1718-
workspaceEncrypted: {},
1718+
workspaceEncrypted: { WORKSPACE: `enc-workspace-${userId}` },
17191719
personalDecrypted: { PERSONAL: `personal-${userId}` },
1720-
workspaceDecrypted: {},
1720+
workspaceDecrypted: { WORKSPACE: `workspace-${userId}` },
17211721
personalOwners: {},
17221722
conflicts: [],
17231723
decryptionFailures: [],
@@ -1738,7 +1738,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
17381738
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('billing-account', 'workspace-1')
17391739
expect(getPersonalAndWorkspaceEnvMock).not.toHaveBeenCalledWith('workflow-owner', 'workspace-1')
17401740
expect(executorConstructorMock.mock.calls[0]?.[0]?.envVarValues).toEqual({
1741-
PERSONAL: 'personal-billing-account',
1741+
WORKSPACE: 'workspace-billing-account',
17421742
})
17431743
})
17441744

apps/sim/lib/workflows/executor/execution-core.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -440,34 +440,39 @@ async function executeWorkflowCoreImpl(
440440
* so a session, personal API key, or delegated run reads its own personal
441441
* variables rather than borrowing the workflow owner's.
442442
*
443-
* An anonymous public-API run reaches the actor by the opposite argument —
444-
* there is no caller to read as, and no owner consented to lending their
445-
* personal namespace to the whole internet — so it uses the workspace's own
446-
* billing principal, which is already what its workspace variables resolve as.
447-
*
448443
* The workflow owner remains the fallback for a workspace API key, schedule,
449444
* or webhook. Someone in the workspace configured each of those, and a
450445
* deployed workflow is routinely authored against its owner's personal keys.
446+
*
447+
* An anonymous public-API run resolves no personal variables at all. Anyone
448+
* can call that endpoint, so there is no caller to read as and no person whose
449+
* private namespace it would be reasonable to lend — such a workflow runs on
450+
* workspace secrets alone.
451451
*/
452-
const personalEnvUserId =
452+
const identifiedCallerUserId =
453453
(metadata.isClientSession && metadata.sessionUserId) ||
454-
(metadata.enforceCredentialAccess || metadata.isPublicApiAccess
455-
? metadata.userId
456-
: undefined) ||
457-
metadata.workflowUserId
454+
(metadata.enforceCredentialAccess ? metadata.userId : undefined)
458455

459-
if (!personalEnvUserId) {
456+
const personalEnvUserId = metadata.isPublicApiAccess
457+
? undefined
458+
: identifiedCallerUserId || metadata.workflowUserId
459+
460+
if (!metadata.isPublicApiAccess && !personalEnvUserId) {
460461
throw new Error('Missing workflowUserId in execution metadata')
461462
}
462463

463464
/**
464465
* The actor already carries the identity each trigger kind should authorize
465466
* workspace secrets against: the caller for a session, personal API key, or
466467
* delegated principal, and the workspace billing account for a workspace API
467-
* key, schedule, or webhook, where no caller is identifiable. Deriving it
468-
* again here would only risk disagreeing with the principal layer.
468+
* key, schedule, webhook, or anonymous public-API call, where no caller is
469+
* identifiable. Deriving it again here would only risk disagreeing with the
470+
* principal layer.
469471
*/
470472
const workspaceEnvUserId = metadata.userId || personalEnvUserId
473+
if (!workspaceEnvUserId) {
474+
throw new Error('Missing execution actor in execution metadata')
475+
}
471476

472477
/**
473478
* Resolves the workflow state from the override, the draft tables, or the
@@ -563,7 +568,7 @@ async function executeWorkflowCoreImpl(
563568
restoredCheckpointVersion: restoredState?.resolvedSecretTraceCheckpointVersion,
564569
restoreTrusted,
565570
requireRestoredProvenance,
566-
scope: { userId: personalEnvUserId, workspaceId: providedWorkspaceId },
571+
scope: { userId: personalEnvUserId ?? workspaceEnvUserId, workspaceId: providedWorkspaceId },
567572
})
568573
if (restoredState && !restoreTrusted) {
569574
resolvedSecretTraceRegistry.markIncomplete('restored-provenance-untrusted')

0 commit comments

Comments
 (0)