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
3 changes: 3 additions & 0 deletions apps/sim/executor/execution/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,9 @@ export class DAGExecutor {
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
metadata: {
...this.contextExtensions.metadata,
...(this.contextExtensions.billingAttribution
? { billingAttribution: this.contextExtensions.billingAttribution }
: {}),
startTime: new Date().toISOString(),
duration: 0,
useDraftState:
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/executor/execution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ export interface ContextExtensions {
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
/**
* Immutable actor/payer decision for this execution. Child workflow
* executions receive it here (they carry no full metadata), so internal
* tool calls inside the child still attach the billing attribution header.
* Takes precedence over `metadata.billingAttribution` when both are set.
*/
billingAttribution?: BillingAttributionSnapshot
stream?: boolean
selectedOutputs?: string[]
edges?: Array<{ source: string; target: string }>
Expand Down
56 changes: 52 additions & 4 deletions apps/sim/executor/handlers/workflow/workflow-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,27 @@ import {
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'

const { mockExecutorExecute, mockCreateSnapshot } = vi.hoisted(() => ({
mockExecutorExecute: vi.fn(),
mockCreateSnapshot: vi.fn(),
}))
const { mockExecutorExecute, mockCreateSnapshot, mockResolveBillingAttribution, executorOptions } =
vi.hoisted(() => ({
mockExecutorExecute: vi.fn(),
mockCreateSnapshot: vi.fn(),
mockResolveBillingAttribution: vi.fn(),
executorOptions: [] as Array<Record<string, any>>,
}))

vi.mock('@/executor', () => ({
Executor: class {
constructor(options: Record<string, any>) {
executorOptions.push(options)
}
execute = mockExecutorExecute
},
}))

vi.mock('@/lib/billing/core/billing-attribution', () => ({
resolveBillingAttribution: mockResolveBillingAttribution,
}))

vi.mock('@/lib/logs/execution/snapshot/service', () => ({
snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot },
}))
Expand Down Expand Up @@ -92,6 +102,7 @@ describe('WorkflowBlockHandler', () => {

// Reset all mocks
vi.clearAllMocks()
executorOptions.length = 0

// Setup default fetch mock
mockFetch.mockResolvedValue({
Expand Down Expand Up @@ -273,6 +284,43 @@ describe('WorkflowBlockHandler', () => {
expect(mockExecutorExecute).toHaveBeenCalledWith('child-workflow-id')
})

it('threads the parent billing attribution into the child execution context', async () => {
const billingAttribution = {
actorUserId: 'actor-1',
workspaceId: 'workspace-parent',
organizationId: 'org-1',
billedAccountUserId: 'owner-1',
billingEntity: { type: 'organization', id: 'org-1' },
billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' },
payerSubscription: null,
}
const ctx = {
...mockContext,
workspaceId: 'workspace-parent',
metadata: { ...mockContext.metadata, billingAttribution },
} as ExecutionContext

mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
name: 'Child Workflow',
workspaceId: 'workspace-parent',
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
},
}),
})
mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } })
mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } })

await handler.execute(ctx, mockBlock, inputs)

expect(executorOptions).toHaveLength(1)
expect(executorOptions[0].contextExtensions.billingAttribution).toBe(billingAttribution)
expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
})

it('should fail closed when the executing context has no workspace', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/executor/handlers/workflow/workflow-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain'
import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
Expand Down Expand Up @@ -360,6 +361,7 @@ export class WorkflowBlockHandler implements BlockHandler {
let childUserId = ctx.userId
let childWorkspaceId = ctx.workspaceId
let childEnvVarValues = ctx.environmentVariables
let childBillingAttribution = ctx.metadata.billingAttribution
if (isCustomBlock) {
if (!loadUserId) {
throw new Error('Custom block source workflow has no owner')
Expand All @@ -371,6 +373,15 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkspaceId = childWorkflow.workspaceId
const ownerEnv = await getPersonalAndWorkspaceEnv(loadUserId, childWorkflow.workspaceId)
childEnvVarValues = { ...ownerEnv.personalDecrypted, ...ownerEnv.workspaceDecrypted }
// Custom-block children authenticate internal tool calls as the source
// owner in the source workspace, so the consumer's snapshot would fail
// the internal routes' actor/workspace scope match. Resolve the
// source-scoped payer instead — the same decision those routes made
// themselves before attribution headers became required.
childBillingAttribution = await resolveBillingAttribution({
actorUserId: loadUserId,
workspaceId: childWorkflow.workspaceId,
})
Comment on lines +381 to +384

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Custom Blocks Charge Source Workspace

When a cross-workspace custom block runs, this branch resolves the billing snapshot for loadUserId and childWorkflow.workspaceId instead of the consuming execution. A consumer with valid billing can now fail because the source workspace has no payer, or usage can be attributed to the source owner even though this custom-block path is supposed to bill the consumer workflow.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no cross-customer charge. Custom blocks are org-scoped: getCustomBlockAuthority only resolves blocks where customBlock.organizationId matches the consumer workspace's organization, and returns null for non-org workspaces. Source and consumer workspaces always bill the same org account. The difference is bookkeeping only (usage-log actor, member cap), identical to pre-enforcement.

The consumer's snapshot cannot be used here. The internal routes reject any header whose actorUserId/workspaceId don't match the authenticated request scope — and the request scope for a custom-block child is the source owner in the source workspace by design (same identity as a deployed API/schedule run of the source workflow). Attaching the consumer's snapshot would fail that check on every call.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right on both counts. The getCustomBlockAuthority code confirms it:

const consumerWs = await getWorkspaceWithOwner(consumerWorkspaceId, { includeArchived: true })
if (!consumerWs?.organizationId) return null
// ...
.where(and(eq(customBlock.type, type), eq(customBlock.organizationId, consumerWs.organizationId)))

The org-id equality enforcer means source and consumer are always in the same org (and personal workspaces can't use custom blocks at all). The "cross-workspace" concern I raised collapses to intra-org bookkeeping, and since the internal routes authenticate requests as the source owner in the source workspace, attaching the consumer's snapshot would fail that scope check on every call. The implementation is correct. Withdrawing the concern.

}

const subExecutor = new Executor({
Expand All @@ -388,6 +399,10 @@ export class WorkflowBlockHandler implements BlockHandler {
workspaceId: childWorkspaceId,
userId: childUserId,
executionId: ctx.executionId,
// Same-workspace children share the parent's frozen payer decision so
// internal tool calls (knowledge, guardrails, MCP, Mothership) can
// attach the required billing attribution header.
billingAttribution: childBillingAttribution,
abortSignal: ctx.abortSignal,
// Propagate in-flight block-output redaction into child workflows so
// nested blocks mask outputs too (recurses: each child forwards it).
Expand Down
Loading