Skip to content

Commit fa394e5

Browse files
fix(execution): resolve secrets against the acting principal, not the workflow owner (#6690)
* fix(execution): resolve secrets against the acting principal, not the workflow owner * fix(execution): resolve anonymous public-API runs as the workspace billing account * fix(execution): propagate run identity across dispatch paths and scope public runs to workspace secrets
1 parent 009f5fe commit fa394e5

16 files changed

Lines changed: 487 additions & 19 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/v2/workflows/[id]/execute/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ export const POST = withRouteHandler(
285285
result = await executeWorkflowService({
286286
workflowId,
287287
userId,
288+
isPublicApiAccess,
288289
input: body.input ?? {},
289290
triggerType: 'api',
290291
requestId,

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

Lines changed: 15 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
})
@@ -1382,6 +1386,7 @@ async function handleExecutePost(
13821386
startTime: new Date().toISOString(),
13831387
isClientSession,
13841388
enforceCredentialAccess: useAuthenticatedUserAsActor,
1389+
isPublicApiAccess,
13851390
workflowStateOverride: effectiveWorkflowStateOverride,
13861391
largeValueExecutionIds,
13871392
largeValueKeys,
@@ -1627,7 +1632,13 @@ async function handleExecutePost(
16271632
const streamVariables = cachedWorkflowData?.variables ?? (workflow as any).variables
16281633
const streamWorkflow = {
16291634
id: workflow.id,
1630-
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,
16311642
workspaceId,
16321643
isDeployed: workflow.isDeployed,
16331644
variables: streamVariables,
@@ -1698,6 +1709,8 @@ async function handleExecutePost(
16981709
base64MaxBytes,
16991710
abortSignal,
17001711
executionMode: 'stream',
1712+
enforceCredentialAccess: useAuthenticatedUserAsActor,
1713+
isPublicApiAccess,
17011714
billingAttribution,
17021715
largeValueKeys,
17031716
fileKeys,
@@ -2104,6 +2117,7 @@ async function handleExecutePost(
21042117
startTime: new Date().toISOString(),
21052118
isClientSession,
21062119
enforceCredentialAccess: useAuthenticatedUserAsActor,
2120+
isPublicApiAccess,
21072121
workflowStateOverride: effectiveWorkflowStateOverride,
21082122
largeValueExecutionIds,
21092123
largeValueKeys,

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/executor/execution/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ export interface ExecutionMetadata {
3434
startTime: string
3535
isClientSession?: boolean
3636
enforceCredentialAccess?: boolean
37+
/**
38+
* The run entered through the anonymous public-API path, so nobody in the
39+
* workspace triggered it. Unlike a schedule, webhook, or workspace API key —
40+
* all configured by someone here, which is why those still fall back to the
41+
* workflow owner's personal variables — this endpoint is callable by anyone,
42+
* and resolving one human's personal namespace for an anonymous caller is not
43+
* something the owner opted into. Such runs use the workspace's own billing
44+
* principal for both environment slices instead.
45+
*/
46+
isPublicApiAccess?: boolean
3747
pendingBlocks?: string[]
3848
resumeFromSnapshot?: boolean
3949
resumeTerminalNoop?: boolean

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
5050
import {
5151
getEffectiveDecryptedEnv,
5252
getEffectiveEnvironmentSnapshot,
53+
getExecutionEnvironment,
5354
getPersonalAndWorkspaceEnv,
5455
invalidateEffectiveDecryptedEnvCache,
5556
upsertWorkspaceEnvVars,
@@ -119,6 +120,86 @@ describe('getPersonalAndWorkspaceEnv access filtering', () => {
119120
})
120121
})
121122

123+
describe('getExecutionEnvironment', () => {
124+
beforeEach(() => {
125+
vi.clearAllMocks()
126+
resetDbChainMock()
127+
mockGetAccessibleEnvCredentials.mockResolvedValue([])
128+
encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({
129+
decrypted: `plain:${encryptedValue}`,
130+
}))
131+
})
132+
133+
/** Grants workspace-admin access to one identity so the two slices diverge observably. */
134+
function grantAdminTo(adminUserId: string) {
135+
mockCheckWorkspaceAccess.mockImplementation(async (_workspaceId: string, userId: string) => ({
136+
exists: true,
137+
hasAccess: true,
138+
canWrite: true,
139+
canAdmin: userId === adminUserId,
140+
}))
141+
}
142+
143+
it('resolves each slice against its own identity', async () => {
144+
grantAdminTo('actor-1')
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+
*/
151+
queueTableRows(environment, [{ variables: { ACTOR_ONLY: 'actor-cipher' } }])
152+
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
153+
queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }])
154+
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
155+
156+
const snapshot = await getExecutionEnvironment('owner-1', 'actor-1', 'workspace-1')
157+
158+
expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' })
159+
expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' })
160+
})
161+
162+
it('resolves once when both identities are the same', async () => {
163+
grantAdminTo('owner-1')
164+
queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }])
165+
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
166+
167+
const snapshot = await getExecutionEnvironment('owner-1', 'owner-1', 'workspace-1')
168+
169+
expect(mockCheckWorkspaceAccess).toHaveBeenCalledOnce()
170+
expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' })
171+
expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' })
172+
})
173+
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+
186+
it('falls back to the personal identity when the actor cannot reach the workspace', async () => {
187+
mockCheckWorkspaceAccess.mockImplementation(async (_workspaceId: string, userId: string) => ({
188+
exists: true,
189+
hasAccess: userId === 'owner-1',
190+
canWrite: true,
191+
canAdmin: true,
192+
}))
193+
queueTableRows(environment, [{ variables: { PERSONAL_KEY: 'personal-cipher' } }])
194+
queueTableRows(workspaceEnvironment, [{ variables: { WORKSPACE_KEY: 'workspace-cipher' } }])
195+
196+
const snapshot = await getExecutionEnvironment('owner-1', 'departed-payer', 'workspace-1')
197+
198+
expect(snapshot.personalDecrypted).toEqual({ PERSONAL_KEY: 'plain:personal-cipher' })
199+
expect(snapshot.workspaceDecrypted).toEqual({ WORKSPACE_KEY: 'plain:workspace-cipher' })
200+
})
201+
})
202+
122203
describe('upsertWorkspaceEnvVars', () => {
123204
beforeEach(() => {
124205
vi.clearAllMocks()

apps/sim/lib/environment/utils.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,98 @@ export async function getPersonalAndWorkspaceEnv(
282282
}
283283
}
284284

285+
/**
286+
* Resolves one execution's environment from two independent identities.
287+
*
288+
* Workspace variables authorize against the execution actor, so the
289+
* credential-membership filter in {@link getPersonalAndWorkspaceEnv} is applied
290+
* to whoever caused the run rather than to whoever happens to own the workflow.
291+
* Personal variables keep the identity that owns them — the session user on an
292+
* interactive run, the workflow owner on a background one — because a deployed
293+
* workflow is routinely authored against its owner's personal keys and would
294+
* otherwise lose them the moment anyone else triggered it.
295+
*
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+
*
299+
* A run whose two identities coincide, which is every interactive run, resolves
300+
* exactly as before through a single query.
301+
*
302+
* When the actor has no access to the workspace at all, the personal identity is
303+
* reused for both slices and the fault is reported rather than raised.
304+
* `workspace.billedAccountUserId` is a stored column rather than a derivation,
305+
* so an organization ownership transfer can leave it pointing at a user with no
306+
* remaining access; failing here would take down every background execution in
307+
* that workspace for a misconfiguration the run itself did not cause. The error
308+
* 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.
314+
*/
315+
export async function getExecutionEnvironment(
316+
personalUserId: string | undefined,
317+
workspaceUserId: string,
318+
workspaceId?: string
319+
): 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+
334+
if (!workspaceId || workspaceUserId === personalUserId) {
335+
return getPersonalAndWorkspaceEnv(personalUserId, workspaceId)
336+
}
337+
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+
348+
const [personal, actor] = await Promise.all([
349+
getPersonalAndWorkspaceEnv(personalUserId, workspaceId),
350+
getPersonalAndWorkspaceEnv(workspaceUserId, workspaceId, { workspaceAccess: actorAccess }),
351+
])
352+
353+
/**
354+
* Each snapshot reports decryption failures across both of its own slices, so
355+
* a name is only carried over when it belongs to the slice being kept.
356+
*/
357+
const decryptionFailures = [
358+
...new Set([
359+
...personal.decryptionFailures.filter((key) => key in personal.personalEncrypted),
360+
...actor.decryptionFailures.filter((key) => key in actor.workspaceEncrypted),
361+
]),
362+
]
363+
364+
return {
365+
personalEncrypted: personal.personalEncrypted,
366+
workspaceEncrypted: actor.workspaceEncrypted,
367+
personalDecrypted: personal.personalDecrypted,
368+
workspaceDecrypted: actor.workspaceDecrypted,
369+
personalOwners: personal.personalOwners,
370+
conflicts: Object.keys(personal.personalEncrypted).filter(
371+
(key) => key in actor.workspaceEncrypted
372+
),
373+
decryptionFailures,
374+
}
375+
}
376+
285377
export interface EnvUpsertResult {
286378
added: string[]
287379
updated: string[]

apps/sim/lib/workflows/application/run-workflow-from-copilot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ async function executeCopilotRun(params: {
236236
enabled: true,
237237
useDraftState: params.input.useDraftState,
238238
workflowTriggerType: 'copilot',
239+
/** `requirePrincipalSubjectUserId` above rejects every principal that cannot name a caller. */
240+
enforceCredentialAccess: true,
239241
triggerBlockId: params.triggerBlockId,
240242
stopAfterBlockId: params.stopAfterBlockId,
241243
runFromBlock: params.runFromBlock,

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,

0 commit comments

Comments
 (0)