Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/docs/content/docs/en/platform/credentials.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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." },
]} />
1 change: 1 addition & 0 deletions apps/sim/app/api/v2/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export const POST = withRouteHandler(
result = await executeWorkflowService({
workflowId,
userId,
isPublicApiAccess,
input: body.input ?? {},
triggerType: 'api',
requestId,
Expand Down
16 changes: 15 additions & 1 deletion apps/sim/app/api/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,8 @@ type AsyncExecutionParams = {
executionId: string
copilotToolCallId?: string
callChain?: string[]
enforceCredentialAccess?: boolean
isPublicApiAccess?: boolean
executionTimeoutMs: number
trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
}
Expand Down Expand Up @@ -1245,6 +1247,8 @@ async function handleExecutePost(
executionId,
copilotToolCallId,
callChain,
enforceCredentialAccess: useAuthenticatedUserAsActor,
isPublicApiAccess,
executionTimeoutMs: preprocessResult.executionTimeout.async,
trustedInitialResolvedSecretTraceProvenance,
})
Expand Down Expand Up @@ -1382,6 +1386,7 @@ async function handleExecutePost(
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
isPublicApiAccess,
workflowStateOverride: effectiveWorkflowStateOverride,
largeValueExecutionIds,
largeValueKeys,
Expand Down Expand Up @@ -1627,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,
Expand Down Expand Up @@ -1698,6 +1709,8 @@ async function handleExecutePost(
base64MaxBytes,
abortSignal,
executionMode: 'stream',
enforceCredentialAccess: useAuthenticatedUserAsActor,
isPublicApiAccess,
billingAttribution,
largeValueKeys,
fileKeys,
Expand Down Expand Up @@ -2104,6 +2117,7 @@ async function handleExecutePost(
startTime: new Date().toISOString(),
isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
isPublicApiAccess,
workflowStateOverride: effectiveWorkflowStateOverride,
largeValueExecutionIds,
largeValueKeys,
Expand Down
10 changes: 10 additions & 0 deletions apps/sim/background/workflow-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/executor/execution/snapshot-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions apps/sim/executor/execution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions apps/sim/lib/environment/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
import {
getEffectiveDecryptedEnv,
getEffectiveEnvironmentSnapshot,
getExecutionEnvironment,
getPersonalAndWorkspaceEnv,
invalidateEffectiveDecryptedEnvCache,
upsertWorkspaceEnvVars,
Expand Down Expand Up @@ -119,6 +120,86 @@ 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')
/**
* 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')

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('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,
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()
Expand Down
92 changes: 92 additions & 0 deletions apps/sim/lib/environment/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,98 @@ 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.
*
* 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 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 | undefined,
workspaceUserId: string,
workspaceId?: string
): Promise<EnvironmentResolutionSnapshot> {
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, { workspaceAccess: actorAccess }),
])

/**
* 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[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/lib/workflows/executor/enqueue-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -77,6 +80,8 @@ export async function enqueueWorkflowExecution(
callChain,
executionTimeoutMs,
trustedInitialResolvedSecretTraceProvenance,
enforceCredentialAccess,
isPublicApiAccess,
} = params
const asyncLogger = logger.withMetadata({
requestId,
Expand Down Expand Up @@ -107,6 +112,8 @@ export async function enqueueWorkflowExecution(
requestId,
correlation,
callChain,
enforceCredentialAccess,
isPublicApiAccess,
executionMode: 'async',
admissionCompleted: true,
executionTimeoutMs,
Expand Down
Loading
Loading