Skip to content

Commit d5ce258

Browse files
icecrasher321claude
andcommitted
fix(execution): stop treating the workflow owner as a live execution identity
A background run acts as the workspace billing account; `workflow.userId` is only the personal-variable fallback. Several surfaces treated that stored pointer as a live permission, so each broke when its owner left the workspace. Deployed chat read `chat.userId` — the person who clicked "Deploy as chat" — where every other trigger reads `workflow.userId`. Org member removal reassigns `workflow.userId` to keep it an active workspace identity and has no equivalent for the chat row, so the same transaction repaired the pointer every other trigger reads and broke the only one chat read. Chat now passes the owner. `getExecutionEnvironment` already tolerated a stale actor but not a stale personal identity. Both are stored pointers, so a personal identity that cannot reach the workspace now contributes no personal namespace — the judgment already applied to an anonymous public-API run, and it stops lending a removed member's secrets to their former organization. Only "neither identity reachable" raises. The public API gated `validatePublicApiAllowed` and the workflow read on the owner, though an anonymous call acts as the billing account and resolves no personal variables at all. Both now use `getWorkspaceBilledAccountUserId`. The enable-time gate, which checks the acting user, is unchanged. Custom-block children and webhook provider-config resolved both environment slices as the owner. They now split the two identities like any deployed run, which also closes a silent inconsistency: a custom block saw a narrower workspace-secret selection than a schedule on the very same workflow. The ban gate no longer blocks on the workflow owner — banning one member should not take down the schedules, webhooks, and chats their teammates depend on. Logs gain a run-level `executedByEmail`, joined from the immutable per-run attribution rather than from a workflow row that ownership transfer rewrites. `workflow.ownerEmail` stays as a deprecated field, fed by its own aliased join, because it is required in the published v2 schema. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 04f7cd2 commit d5ce258

30 files changed

Lines changed: 705 additions & 118 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,13 @@ When a workflow runs, secrets resolve in this order:
177177
| Run started by | Personal secrets come from |
178178
| --- | --- |
179179
| Clicking Run, or a personal API key | The person running it |
180-
| A workspace API key, schedule, or webhook | The workflow owner |
180+
| A workspace API key, schedule, webhook, or deployed chat | The workflow owner |
181181
| A public API URL with no authentication | Nobody — personal secrets do not resolve |
182182

183183
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**.
184184

185+
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.
186+
185187
## Best Practices
186188

187189
- **Use workspace secrets for production** so workflows work regardless of who triggers them
@@ -193,7 +195,8 @@ The workflow owner is the fallback only where nobody can be identified but someb
193195
{ question: "Are my secrets encrypted at rest?", answer: "Yes. Values saved under Secrets are encrypted before being stored in the database." },
194196
{ 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." },
195197
{ 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." },
196-
{ 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." },
198+
{ 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." },
199+
{ 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." },
197200
{ 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." },
198201
{ 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." },
199202
]} />

apps/docs/openapi-v2-logs.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,19 @@
13441344
],
13451345
"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."
13461346
},
1347+
"executedByEmail": {
1348+
"anyOf": [
1349+
{
1350+
"type": "string",
1351+
"format": "email",
1352+
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
1353+
},
1354+
{
1355+
"type": "null"
1356+
}
1357+
],
1358+
"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."
1359+
},
13471360
"workflow": {
13481361
"type": "object",
13491362
"properties": {
@@ -1398,7 +1411,8 @@
13981411
"type": "null"
13991412
}
14001413
],
1401-
"description": "Workflow owner email, or null when unavailable."
1414+
"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.",
1415+
"deprecated": true
14021416
},
14031417
"workspaceId": {
14041418
"anyOf": [
@@ -1577,6 +1591,7 @@
15771591
"endedAt",
15781592
"totalDurationMs",
15791593
"files",
1594+
"executedByEmail",
15801595
"workflow",
15811596
"workflowState",
15821597
"traceSpans",
@@ -1614,6 +1629,7 @@
16141629
"endedAt": "2026-01-15T10:30:01.250Z",
16151630
"totalDurationMs": 1250,
16161631
"files": null,
1632+
"executedByEmail": "billing@example.com",
16171633
"workflow": {
16181634
"id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36",
16191635
"name": "Customer Support Agent",

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,16 @@ export const POST = withRouteHandler(
273273

274274
const workflowForExecution = {
275275
id: deployment.workflowId,
276-
userId: deployment.userId,
276+
/**
277+
* The workflow owner, not the chat's creator: `executeWorkflow` reads this
278+
* one field to set `workflowUserId`, the personal-environment fallback for
279+
* runs with no identifiable caller. `chat.userId` records who deployed the
280+
* chat and is never maintained as an execution identity — member removal
281+
* reassigns `workflow.userId` to keep it an active workspace identity and
282+
* has no equivalent for the chat row — so reading it here made deployed
283+
* chat resolve a pointer that every other trigger had already repaired.
284+
*/
285+
userId: workflowRecord.userId,
277286
workspaceId,
278287
isDeployed: workflowRecord?.isDeployed ?? false,
279288
variables: (workflowRecord?.variables as Record<string, unknown>) ?? undefined,

apps/sim/app/api/v1/logs/[id]/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ export const GET = withRouteHandler(
5151
name: log.workflowName || 'Deleted Workflow',
5252
description: log.workflowDescription,
5353
folderId: log.workflowFolderId,
54-
userId: log.workflowUserId,
5554
workspaceId: log.workflowWorkspaceId,
5655
createdAt: log.workflowCreatedAt,
5756
updatedAt: log.workflowUpdatedAt,

apps/sim/app/api/v2/logs/[runId]/route.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const log = {
4949
files: null,
5050
workflowName: 'Support Agent',
5151
workflowDescription: null,
52+
executedByEmail: 'actor@example.com',
5253
workflowOwnerEmail: 'owner@example.com',
5354
workflowWorkspaceId: 'workspace-1',
5455
workflowCreatedAt: new Date('2026-01-01T00:00:00Z'),
@@ -86,8 +87,16 @@ describe('GET /api/v2/logs/[runId]', () => {
8687
const body = await response.json()
8788

8889
expect(response.status).toBe(200)
90+
/**
91+
* The two identities are deliberately different people here. `ownerEmail` is
92+
* deprecated but still a required field of the published schema, so it must
93+
* keep resolving from the workflow owner rather than quietly aliasing to the
94+
* executing identity — dropping its own join would make both read the same
95+
* and break every client still on it.
96+
*/
8997
expect(body.data).toMatchObject({
9098
runId: 'run-1',
99+
executedByEmail: 'actor@example.com',
91100
workflow: { folderPath: '/agents', ownerEmail: 'owner@example.com' },
92101
finalOutput: { ok: true },
93102
})

apps/sim/app/api/v2/logs/[runId]/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@ export const GET = defineV2JsonRoute({
4949
endedAt: log.endedAt ? log.endedAt.toISOString() : null,
5050
totalDurationMs: log.totalDurationMs,
5151
files: projectLogFiles(log),
52+
executedByEmail: log.executedByEmail,
5253
workflow: {
5354
id: log.workflowId,
5455
name: log.workflowName || 'Deleted Workflow',
5556
description: log.workflowDescription,
5657
folderPath: workflowFolderPath,
58+
/** Deprecated in favour of the run-level `executedByEmail`. */
5759
ownerEmail: log.workflowOwnerEmail,
5860
workspaceId: log.workflowWorkspaceId,
5961
createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null,

apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,22 @@ function callPublicExecute(body: Record<string, unknown>, headers: Record<string
218218
return POST(req, { params: Promise.resolve({ workflowId: 'workflow-1' }) })
219219
}
220220

221+
/**
222+
* Queues the two reads the anonymous public path makes, in order: the workflow's
223+
* public-API eligibility, then the workspace billing account it runs as. Keeping
224+
* them together stops a caller queueing only the first and getting an
225+
* indistinguishable 401 from the missing second.
226+
*/
227+
function queuePublicWorkflowReads(
228+
overrides: { isPublicApi?: boolean; isDeployed?: boolean; billedAccountUserId?: string } = {}
229+
) {
230+
const { isPublicApi = true, isDeployed = true, billedAccountUserId = 'billing-1' } = overrides
231+
dbChainMockFns.limit.mockResolvedValueOnce([
232+
{ isPublicApi, isDeployed, workspaceId: 'workspace-1' },
233+
])
234+
dbChainMockFns.limit.mockResolvedValueOnce([{ billedAccountUserId }])
235+
}
236+
221237
function authenticatePersonalKey() {
222238
mockAuthenticateV2ApiKey.mockResolvedValue({
223239
principal: { kind: 'personal_api_key', userId: 'actor-1', keyId: 'key-1' },
@@ -759,9 +775,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
759775

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

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

777-
dbChainMockFns.limit.mockResolvedValueOnce([
778-
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
779-
])
791+
queuePublicWorkflowReads()
780792
const asyncRes = await callPublicExecute({ input: {}, async: true })
781793
expect(asyncRes.status).toBe(400)
782794
})
783795

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

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

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

799809
it('returns not found when a public workflow disappears before authorization', async () => {
800810
dbChainMockFns.limit.mockReset()
801-
dbChainMockFns.limit.mockResolvedValueOnce([
802-
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
803-
])
811+
queuePublicWorkflowReads()
804812
mockAuthorize.mockResolvedValueOnce({
805813
allowed: false,
806814
status: 404,
@@ -837,7 +845,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => {
837845
it('401s non-public workflows without a key', async () => {
838846
dbChainMockFns.limit.mockReset()
839847
dbChainMockFns.limit.mockResolvedValueOnce([
840-
{ isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
848+
{ isPublicApi: false, isDeployed: true, workspaceId: 'workspace-1' },
841849
])
842850

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

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

893899
dbChainMockFns.limit.mockReset()
894-
dbChainMockFns.limit.mockResolvedValueOnce([
895-
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
896-
])
900+
queuePublicWorkflowReads()
897901
const anonymous = await callPublicExecute({ input: {} }, { 'X-Sim-Via': 'wf-a, wf-b' })
898902
expect(anonymous.status).toBe(200)
899903
expect(mockExecuteWorkflowCore.mock.calls[1][0].snapshot.metadata.callChain).toEqual([

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
v2RateLimits,
2222
} from '@/lib/api/server/routes'
2323
import type { V2ApiKeyPrincipal } from '@/lib/api/server/routes/v2-api-key-auth'
24+
import { getWorkspaceBilledAccountUserId } from '@/lib/billing/core/billing-attribution'
2425
import { tryAdmit } from '@/lib/core/admission/gate'
2526
import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure'
2627
import type { ForbiddenDetailCode } from '@/lib/core/application'
@@ -173,7 +174,6 @@ export const POST = withRouteHandler(
173174
.select({
174175
isPublicApi: workflowTable.isPublicApi,
175176
isDeployed: workflowTable.isDeployed,
176-
userId: workflowTable.userId,
177177
workspaceId: workflowTable.workspaceId,
178178
})
179179
.from(workflowTable)
@@ -183,15 +183,27 @@ export const POST = withRouteHandler(
183183
if (!wf?.isPublicApi || !wf.isDeployed || !wf.workspaceId) {
184184
return v2Error('UNAUTHORIZED', 'Unauthorized')
185185
}
186+
/**
187+
* An anonymous public-API call has no caller, so it acts as the workspace
188+
* billing account — the identity preprocessing elects for exactly this
189+
* case. The workflow owner is only the personal-variable fallback, and a
190+
* public run resolves no personal variables at all, so gating on the
191+
* owner's governance config and workspace read would fail a public
192+
* endpoint the moment that stored pointer's access lapsed.
193+
*/
194+
const billedAccountUserId = await getWorkspaceBilledAccountUserId(wf.workspaceId)
195+
if (!billedAccountUserId) {
196+
return v2Error('UNAUTHORIZED', 'Unauthorized')
197+
}
186198
try {
187-
await validatePublicApiAllowed(wf.userId, wf.workspaceId)
199+
await validatePublicApiAllowed(billedAccountUserId, wf.workspaceId)
188200
} catch (err) {
189201
if (err instanceof PublicApiNotAllowedError) {
190202
return v2Error('UNAUTHORIZED', 'Unauthorized')
191203
}
192204
throw err
193205
}
194-
publicApiUserId = wf.userId
206+
publicApiUserId = billedAccountUserId
195207
isPublicApiAccess = true
196208
}
197209

@@ -343,7 +355,7 @@ export const POST = withRouteHandler(
343355
}
344356
} else {
345357
if (!publicApiUserId) {
346-
throw new Error('Public workflow execution is missing its owner')
358+
throw new Error('Public workflow execution is missing its workspace billing account')
347359
}
348360
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
349361
workflowId,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838

3939
const {
4040
mockAssertBillingAttributionSnapshot,
41+
mockGetWorkspaceBilledAccountUserId,
4142
mockClaimExecutionId,
4243
mockClaimWorkflowToolExecution,
4344
mockCheckNeedsRedeployment,
@@ -89,12 +90,14 @@ const {
8990
mockReleaseExecutionSlot: vi.fn(),
9091
mockReleaseWorkflowToolExecutionClaim: vi.fn(),
9192
mockRequireBillingAttributionHeader: vi.fn(),
93+
mockGetWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('billing-1'),
9294
mockShouldExecuteInline: vi.fn().mockReturnValue(false),
9395
mockValidatePublicApiAllowed: vi.fn(),
9496
}))
9597

9698
vi.mock('@/lib/billing/core/billing-attribution', () => ({
9799
assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot,
100+
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
98101
requireBillingAttributionHeader: mockRequireBillingAttributionHeader,
99102
}))
100103

@@ -345,7 +348,6 @@ function configureExecutionCaller(caller: ExecutionCallerCase, requestCount = 1)
345348
{
346349
isPublicApi: true,
347350
isDeployed: true,
348-
userId: 'owner-1',
349351
workspaceId: 'workspace-1',
350352
},
351353
])

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservati
2020
import {
2121
assertBillingAttributionSnapshot,
2222
type BillingAttributionSnapshot,
23+
getWorkspaceBilledAccountUserId,
2324
requireBillingAttributionHeader,
2425
} from '@/lib/billing/core/billing-attribution'
2526
import {
@@ -596,7 +597,6 @@ async function handleExecutePost(
596597
.select({
597598
isPublicApi: workflowTable.isPublicApi,
598599
isDeployed: workflowTable.isDeployed,
599-
userId: workflowTable.userId,
600600
workspaceId: workflowTable.workspaceId,
601601
})
602602
.from(workflowTable)
@@ -607,16 +607,29 @@ async function handleExecutePost(
607607
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
608608
}
609609

610+
/**
611+
* An anonymous public-API call has no caller, so it acts as the workspace
612+
* billing account — the identity preprocessing elects for exactly this
613+
* case. The workflow owner is only the personal-variable fallback, and a
614+
* public run resolves no personal variables at all, so gating on the
615+
* owner's governance config would fail a public endpoint the moment that
616+
* stored pointer's access lapsed.
617+
*/
618+
const billedAccountUserId = await getWorkspaceBilledAccountUserId(wf.workspaceId)
619+
if (!billedAccountUserId) {
620+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
621+
}
622+
610623
try {
611-
await validatePublicApiAllowed(wf.userId, wf.workspaceId)
624+
await validatePublicApiAllowed(billedAccountUserId, wf.workspaceId)
612625
} catch (err) {
613626
if (err instanceof PublicApiNotAllowedError) {
614627
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
615628
}
616629
throw err
617630
}
618631

619-
userId = wf.userId
632+
userId = billedAccountUserId
620633
isPublicApiAccess = true
621634
} else {
622635
userId = auth.userId

0 commit comments

Comments
 (0)