Skip to content

Commit f2e20aa

Browse files
fix(execution): stop treating the workflow owner as a live execution identity (#7330)
* 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> * fix(execution): suspend personal secrets for banned owners, and gate the ban candidate correctly Review round 1 (Greptile P1 security, cubic P1 + P2). Removing the workflow owner from the ban gate let a suspended account's personal secrets keep flowing into background runs: a ban revokes neither workspace membership nor the pointer naming that person, so the run continued on their own keys. `getExecutionEnvironment` now drops the personal namespace when that identity is suspended, the same answer it already gives a departed one — the run survives, their credentials do not. Placing it in the shared resolver rather than in `execution-core` covers the webhook and custom-block paths too, and keeps the ban module out of the executor's import graph. The ban candidate itself was also inconsistent: callers overload `userId`, so it is an authenticated caller on a manual run but a stored pointer everywhere else — the workflow owner from `checkWebhookPreprocessing`, the chat's creator from the deployed-chat route, `'unknown'` from a schedule. Reading it unconditionally meant the same ban suspended a webhook while the schedule beside it kept running. It is now gated on `useAuthenticatedUserAsActor`, which is exactly the flag that distinguishes the two — `workflow-column-execution` toggles them together. Also corrects the custom-block authority TSDoc, which still claimed the owner supplies both environment slices after this branch split them. Regenerates the CLI API client for the v2 log contract change, which CI's `check:cli-api` audit caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(execution): enforce suspension on every personal-secret path, and align webhook cleanup Review round 2 (Greptile P1 security, cubic P0 — both found the same gap). The suspension check sat next to the split-identity access lookups, so it was skipped by the single-identity shortcut above it. That shortcut is taken whenever the two identities coincide — which is exactly what happens when a custom-block publisher is also their workspace's billing account. The check now runs before the shortcut, unconditionally. Round 1's placement rested on "admission already cleared this identity", and that is not true everywhere: a custom-block child is admitted by `admitCustomBlockChildExecution`, which checks usage limits and nothing else, and a provider URL-validation challenge resolves its secret with no admission at all. Neither path has ever had a ban gate. Only the personal namespace is withheld. Workspace variables belong to the workspace rather than to a person, so they keep resolving and a suspended member's teammates keep working — the reason admission stopped blocking on this identity to begin with. Webhook cleanup now resolves through the same two-identity reader as delivery. Reading both slices as the owner let cleanup see a narrower selection than the delivery that created the subscription: a non-admin owner without a credential grant left `{{VAR}}` unresolved, the provider was handed the literal reference as its credential, and the non-fatal catch silently orphaned the subscription. `resolveBackgroundWebhookEnv` imports the billing reader statically. The dynamic import bought nothing — every boundary audit passes without it — and made each worker pay a cold module load on the first webhook resolution. Restores the `@sim/testing` mock-shape test, which pins that the default snapshot carries every field of the real one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(webhooks): route every background env resolution through the identity resolver Review round 3 (cubic P1). `resolveBackgroundWebhookEnv` short-circuited to `getEffectiveDecryptedEnv` for the two cases with no second identity — a legacy workspaceless webhook, and a workspace with no billing account — which read the owner's variables without passing the resolver's suspension check. cubic flagged the first; the second was the same bypass one line down. Both now name the owner as both identities and go through the resolver, which produces the identical resolution while putting them behind the same gate. Also corrects `provider-subscriptions.test.ts`, which still asserted cleanup resolves via `getEffectiveDecryptedEnv`. That assertion was passing intermittently rather than failing outright: `mockGetEffectiveDecryptedEnv` is a shared singleton on `environmentUtilsMockFns`, so whether it had been called depended on which other files shared the worker. It now asserts the two-identity call, with the billing reader mocked so the split is actually exercised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(execution): make the suspension gate fail closed on an undeclared userId Review round 4 (Greptile P1 security). Round 2 keyed the ban candidate on `useAuthenticatedUserAsActor`, assuming that flag separates a live caller from a stored reference. It does not. The interactive resume route reads `access.auth?.userId` and passes that live resumer as `userId` while leaving the flag false on purpose — attribution is captured before the pause and must not move — so a suspended user could resume a paused run whose persisted attribution named a different, unsuspended actor. The distinction is per-caller and cannot be inferred, so it is now declared. `userIdIsStoredReference` defaults to false, which means an undeclared call site keeps blocking; only the three that genuinely pass a stored reference opt out: the webhook processor (the workflow owner), the deployed-chat route (the chat's creator), and table-cell dispatch (the owner, but only when nothing triggered it). Resume, manual, API, and async paths are candidates again. Withholding a suspended account's personal variables stays where it was, in `getExecutionEnvironment`, so the two concerns remain separable: a suspended stored reference does not block the run, and does not lend its credentials either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1c197c1 commit f2e20aa

36 files changed

Lines changed: 929 additions & 123 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: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ export const POST = withRouteHandler(
180180
const preprocessResult = await preprocessExecution({
181181
workflowId: deployment.workflowId,
182182
userId: deployment.userId,
183+
// Whoever deployed this chat, not whoever is talking to it.
184+
userIdIsStoredReference: true,
183185
triggerType: 'chat',
184186
executionId,
185187
requestId,
@@ -273,7 +275,16 @@ export const POST = withRouteHandler(
273275

274276
const workflowForExecution = {
275277
id: deployment.workflowId,
276-
userId: deployment.userId,
278+
/**
279+
* The workflow owner, not the chat's creator: `executeWorkflow` reads this
280+
* one field to set `workflowUserId`, the personal-environment fallback for
281+
* runs with no identifiable caller. `chat.userId` records who deployed the
282+
* chat and is never maintained as an execution identity — member removal
283+
* reassigns `workflow.userId` to keep it an active workspace identity and
284+
* has no equivalent for the chat row — so reading it here made deployed
285+
* chat resolve a pointer that every other trigger had already repaired.
286+
*/
287+
userId: workflowRecord.userId,
277288
workspaceId,
278289
isDeployed: workflowRecord?.isDeployed ?? false,
279290
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)