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
7 changes: 5 additions & 2 deletions apps/docs/content/docs/platform/credentials.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,13 @@ When a workflow runs, secrets resolve in this order:
| 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 workspace API key, schedule, webhook, or deployed chat | 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**.

If the workflow owner later leaves the workspace, the run keeps working: it resolves workspace secrets as normal and simply resolves no personal ones, so any block that needed a personal secret fails on its own with the missing key named. Move that secret to **Workspace** to fix it for good.

## Best Practices

- **Use workspace secrets for production** so workflows work regardless of who triggers them
Expand All @@ -193,7 +195,8 @@ The workflow owner is the fallback only where nobody can be identified but someb
{ 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: "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: "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, webhook, or deployed chat 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: "What happens to automated runs if the workflow owner leaves the workspace?", answer: "They keep running. Workspace secrets resolve as normal, because they are checked against the workspace's billing account rather than the owner. Personal secrets stop resolving, so any block that referenced one fails with that key named — move it to Workspace to fix it permanently." },
{ 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." },
]} />
18 changes: 17 additions & 1 deletion apps/docs/openapi-v2-logs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,19 @@
],
"description": "Files the run produced, or null when none are recorded. Only the run's own output files appear; input attachments a caller supplied are addressed through the files API instead."
},
"executedByEmail": {
"anyOf": [
{
"type": "string",
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
},
{
"type": "null"
}
],
"description": "Email of the identity the run executed as: the caller for an interactive or personal-API-key run, and the workspace billing account for a schedule, webhook, deployed chat, or public API call. Null when the run failed before an identity was resolved."
},
"workflow": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1398,7 +1411,8 @@
"type": "null"
}
],
"description": "Workflow owner email, or null when unavailable."
"description": "Deprecated — use the run-level `executedByEmail` instead. Email of the workflow's current owner, or null when unavailable. This is a property of the workflow as it stands today, not of the run: it changes when workflow ownership is reassigned, and the owner is not the identity a background run executes as.",
"deprecated": true
},
"workspaceId": {
"anyOf": [
Expand Down Expand Up @@ -1577,6 +1591,7 @@
"endedAt",
"totalDurationMs",
"files",
"executedByEmail",
"workflow",
"workflowState",
"traceSpans",
Expand Down Expand Up @@ -1614,6 +1629,7 @@
"endedAt": "2026-01-15T10:30:01.250Z",
"totalDurationMs": 1250,
"files": null,
"executedByEmail": "billing@example.com",
"workflow": {
"id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
"name": "Customer Support Agent",
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/app/api/chat/[identifier]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ export const POST = withRouteHandler(
const preprocessResult = await preprocessExecution({
workflowId: deployment.workflowId,
userId: deployment.userId,
// Whoever deployed this chat, not whoever is talking to it.
userIdIsStoredReference: true,
triggerType: 'chat',
executionId,
requestId,
Expand Down Expand Up @@ -273,7 +275,16 @@ export const POST = withRouteHandler(

const workflowForExecution = {
id: deployment.workflowId,
userId: deployment.userId,
/**
* The workflow owner, not the chat's creator: `executeWorkflow` reads this
* one field to set `workflowUserId`, the personal-environment fallback for
* runs with no identifiable caller. `chat.userId` records who deployed the
* chat and is never maintained as an execution identity — member removal
* reassigns `workflow.userId` to keep it an active workspace identity and
* has no equivalent for the chat row — so reading it here made deployed
* chat resolve a pointer that every other trigger had already repaired.
*/
userId: workflowRecord.userId,
workspaceId,
isDeployed: workflowRecord?.isDeployed ?? false,
variables: (workflowRecord?.variables as Record<string, unknown>) ?? undefined,
Expand Down
1 change: 0 additions & 1 deletion apps/sim/app/api/v1/logs/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export const GET = withRouteHandler(
name: log.workflowName || 'Deleted Workflow',
description: log.workflowDescription,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt,
updatedAt: log.workflowUpdatedAt,
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/app/api/v2/logs/[runId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const log = {
files: null,
workflowName: 'Support Agent',
workflowDescription: null,
executedByEmail: 'actor@example.com',
workflowOwnerEmail: 'owner@example.com',
workflowWorkspaceId: 'workspace-1',
workflowCreatedAt: new Date('2026-01-01T00:00:00Z'),
Expand Down Expand Up @@ -86,8 +87,16 @@ describe('GET /api/v2/logs/[runId]', () => {
const body = await response.json()

expect(response.status).toBe(200)
/**
* The two identities are deliberately different people here. `ownerEmail` is
* deprecated but still a required field of the published schema, so it must
* keep resolving from the workflow owner rather than quietly aliasing to the
* executing identity — dropping its own join would make both read the same
* and break every client still on it.
*/
expect(body.data).toMatchObject({
runId: 'run-1',
executedByEmail: 'actor@example.com',
workflow: { folderPath: '/agents', ownerEmail: 'owner@example.com' },
finalOutput: { ok: true },
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/v2/logs/[runId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ export const GET = defineV2JsonRoute({
endedAt: log.endedAt ? log.endedAt.toISOString() : null,
totalDurationMs: log.totalDurationMs,
files: projectLogFiles(log),
executedByEmail: log.executedByEmail,
workflow: {
id: log.workflowId,
name: log.workflowName || 'Deleted Workflow',
description: log.workflowDescription,
folderPath: workflowFolderPath,
/** Deprecated in favour of the run-level `executedByEmail`. */
ownerEmail: log.workflowOwnerEmail,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null,
Expand Down
42 changes: 23 additions & 19 deletions apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,22 @@ function callPublicExecute(body: Record<string, unknown>, headers: Record<string
return POST(req, { params: Promise.resolve({ workflowId: 'workflow-1' }) })
}

/**
* Queues the two reads the anonymous public path makes, in order: the workflow's
* public-API eligibility, then the workspace billing account it runs as. Keeping
* them together stops a caller queueing only the first and getting an
* indistinguishable 401 from the missing second.
*/
function queuePublicWorkflowReads(
overrides: { isPublicApi?: boolean; isDeployed?: boolean; billedAccountUserId?: string } = {}
) {
const { isPublicApi = true, isDeployed = true, billedAccountUserId = 'billing-1' } = overrides
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi, isDeployed, workspaceId: 'workspace-1' },
])
dbChainMockFns.limit.mockResolvedValueOnce([{ billedAccountUserId }])
}

function authenticatePersonalKey() {
mockAuthenticateV2ApiKey.mockResolvedValue({
principal: { kind: 'personal_api_key', userId: 'actor-1', keyId: 'key-1' },
Expand Down Expand Up @@ -759,9 +775,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {

it('runs the anonymous public path sync but refuses async', async () => {
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
])
queuePublicWorkflowReads()

const okRes = await callPublicExecute({ input: {} })
expect(okRes.status).toBe(200)
Expand All @@ -774,18 +788,14 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
expect.objectContaining({ rateLimitCounter: 'sync' })
)

dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
])
queuePublicWorkflowReads()
const asyncRes = await callPublicExecute({ input: {}, async: true })
expect(asyncRes.status).toBe(400)
})

it('never permits manual execution on the anonymous public path', async () => {
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
])
queuePublicWorkflowReads()

const response = await callPublicExecute({ run: { source: 'manual' } })

Expand All @@ -798,9 +808,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {

it('returns not found when a public workflow disappears before authorization', async () => {
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
])
queuePublicWorkflowReads()
mockAuthorize.mockResolvedValueOnce({
allowed: false,
status: 404,
Expand Down Expand Up @@ -837,7 +845,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
it('401s non-public workflows without a key', async () => {
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
{ isPublicApi: false, isDeployed: true, workspaceId: 'workspace-1' },
])

const res = await callPublicExecute({ input: {} })
Expand Down Expand Up @@ -870,9 +878,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
expect(keyedBody.error.message).toContain('Maximum workflow call chain depth (25) exceeded')

dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
])
queuePublicWorkflowReads()
const anonymous = await callPublicExecute({ input: {} }, { 'X-Sim-Via': maxChain })
expect(anonymous.status).toBe(409)
expect((await anonymous.json()).error.code).toBe('CONFLICT')
Expand All @@ -891,9 +897,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
])

dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
])
queuePublicWorkflowReads()
const anonymous = await callPublicExecute({ input: {} }, { 'X-Sim-Via': 'wf-a, wf-b' })
expect(anonymous.status).toBe(200)
expect(mockExecuteWorkflowCore.mock.calls[1][0].snapshot.metadata.callChain).toEqual([
Expand Down
20 changes: 16 additions & 4 deletions apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
v2RateLimits,
} from '@/lib/api/server/routes'
import type { V2ApiKeyPrincipal } from '@/lib/api/server/routes/v2-api-key-auth'
import { getWorkspaceBilledAccountUserId } from '@/lib/billing/core/billing-attribution'
import { tryAdmit } from '@/lib/core/admission/gate'
import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure'
import type { ForbiddenDetailCode } from '@/lib/core/application'
Expand Down Expand Up @@ -173,7 +174,6 @@ export const POST = withRouteHandler(
.select({
isPublicApi: workflowTable.isPublicApi,
isDeployed: workflowTable.isDeployed,
userId: workflowTable.userId,
workspaceId: workflowTable.workspaceId,
})
.from(workflowTable)
Expand All @@ -183,15 +183,27 @@ export const POST = withRouteHandler(
if (!wf?.isPublicApi || !wf.isDeployed || !wf.workspaceId) {
return v2Error('UNAUTHORIZED', 'Unauthorized')
}
/**
* An anonymous public-API call has no caller, so it acts as the workspace
* billing account — the identity preprocessing elects for exactly this
* case. The workflow owner is only the personal-variable fallback, and a
* public run resolves no personal variables at all, so gating on the
* owner's governance config and workspace read would fail a public
* endpoint the moment that stored pointer's access lapsed.
*/
const billedAccountUserId = await getWorkspaceBilledAccountUserId(wf.workspaceId)
if (!billedAccountUserId) {
return v2Error('UNAUTHORIZED', 'Unauthorized')
}
try {
await validatePublicApiAllowed(wf.userId, wf.workspaceId)
await validatePublicApiAllowed(billedAccountUserId, wf.workspaceId)
} catch (err) {
if (err instanceof PublicApiNotAllowedError) {
return v2Error('UNAUTHORIZED', 'Unauthorized')
}
throw err
}
publicApiUserId = wf.userId
publicApiUserId = billedAccountUserId
isPublicApiAccess = true
}

Expand Down Expand Up @@ -343,7 +355,7 @@ export const POST = withRouteHandler(
}
} else {
if (!publicApiUserId) {
throw new Error('Public workflow execution is missing its owner')
throw new Error('Public workflow execution is missing its workspace billing account')
}
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {

const {
mockAssertBillingAttributionSnapshot,
mockGetWorkspaceBilledAccountUserId,
mockClaimExecutionId,
mockClaimWorkflowToolExecution,
mockCheckNeedsRedeployment,
Expand Down Expand Up @@ -89,12 +90,14 @@ const {
mockReleaseExecutionSlot: vi.fn(),
mockReleaseWorkflowToolExecutionClaim: vi.fn(),
mockRequireBillingAttributionHeader: vi.fn(),
mockGetWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('billing-1'),
mockShouldExecuteInline: vi.fn().mockReturnValue(false),
mockValidatePublicApiAllowed: vi.fn(),
}))

vi.mock('@/lib/billing/core/billing-attribution', () => ({
assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
}))

Expand Down Expand Up @@ -345,7 +348,6 @@ function configureExecutionCaller(caller: ExecutionCallerCase, requestCount = 1)
{
isPublicApi: true,
isDeployed: true,
userId: 'owner-1',
workspaceId: 'workspace-1',
},
])
Expand Down
19 changes: 16 additions & 3 deletions apps/sim/app/api/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservati
import {
assertBillingAttributionSnapshot,
type BillingAttributionSnapshot,
getWorkspaceBilledAccountUserId,
requireBillingAttributionHeader,
} from '@/lib/billing/core/billing-attribution'
import {
Expand Down Expand Up @@ -596,7 +597,6 @@ async function handleExecutePost(
.select({
isPublicApi: workflowTable.isPublicApi,
isDeployed: workflowTable.isDeployed,
userId: workflowTable.userId,
workspaceId: workflowTable.workspaceId,
})
.from(workflowTable)
Expand All @@ -607,16 +607,29 @@ async function handleExecutePost(
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

/**
* An anonymous public-API call has no caller, so it acts as the workspace
* billing account — the identity preprocessing elects for exactly this
* case. The workflow owner is only the personal-variable fallback, and a
* public run resolves no personal variables at all, so gating on the
* owner's governance config would fail a public endpoint the moment that
* stored pointer's access lapsed.
*/
const billedAccountUserId = await getWorkspaceBilledAccountUserId(wf.workspaceId)
if (!billedAccountUserId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

try {
await validatePublicApiAllowed(wf.userId, wf.workspaceId)
await validatePublicApiAllowed(billedAccountUserId, wf.workspaceId)
} catch (err) {
if (err instanceof PublicApiNotAllowedError) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
throw err
}

userId = wf.userId
userId = billedAccountUserId
isPublicApiAccess = true
} else {
userId = auth.userId
Expand Down
Loading
Loading