Skip to content

Commit b9dfb5d

Browse files
fix(workflows): bind automated runs to billing actors
1 parent 2795922 commit b9dfb5d

14 files changed

Lines changed: 301 additions & 35 deletions

File tree

apps/sim/background/schedule-execution.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@ async function runWorkflowExecution({
598598
serviceId: 'schedule',
599599
workspaceId,
600600
workflowId: payload.workflowId,
601+
subject: { kind: 'sim_user', userId: actorUserId },
601602
},
602603
billingAttribution,
603604
sessionUserId: undefined,

apps/sim/background/webhook-execution.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ describe('executeWebhookJob fault vs error handling', () => {
240240
workflowId: 'workflow-1',
241241
workspaceId: 'workspace-1',
242242
provider: 'gmail',
243+
subject: { kind: 'sim_user', userId: 'user-1' },
243244
},
244245
},
245246
userId: 'user-1',
@@ -337,6 +338,7 @@ describe('executeWebhookJob fault vs error handling', () => {
337338
workflowId: 'workflow-1',
338339
workspaceId: 'workspace-1',
339340
provider: 'gmail',
341+
subject: { kind: 'sim_user', userId: 'user-1' },
340342
},
341343
}),
342344
expect.anything(),

apps/sim/background/webhook-execution.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ function parseWebhookJobPrincipal(payload: WebhookExecutionJobPayload): Workflow
493493
workflowId: payload.workflowId,
494494
workspaceId: payload.workspaceId,
495495
provider: payload.provider,
496+
subject: { kind: 'sim_user', userId: payload.userId },
496497
})
497498
}
498499

apps/sim/lib/auth/internal.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,32 @@ describe('internal executor delegation claims', () => {
135135
})
136136
})
137137

138+
it('derives executor subject authority from a generic webhook billing actor', async () => {
139+
const token = await generateInternalDelegationToken({
140+
workflowId: 'workflow-1',
141+
executionId: 'execution-1',
142+
principal: {
143+
kind: 'system',
144+
serviceId: 'webhook',
145+
workspaceId: 'workspace-1',
146+
workflowId: 'workflow-1',
147+
webhookId: 'webhook-1',
148+
provider: 'generic',
149+
subject: { kind: 'sim_user', userId: 'billing-actor-1' },
150+
},
151+
})
152+
153+
await expect(verifyInternalDelegationToken(token)).resolves.toMatchObject({
154+
subjectUserId: 'billing-actor-1',
155+
principal: {
156+
kind: 'system',
157+
serviceId: 'webhook',
158+
subject: { kind: 'sim_user', userId: 'billing-actor-1' },
159+
},
160+
})
161+
expect(decodeJwt(token).sub).toBe('billing-actor-1')
162+
})
163+
138164
it('round-trips the currently executing deployed workflow authority', async () => {
139165
const token = await generateInternalDelegationToken({
140166
subjectUserId: 'user-1',

apps/sim/lib/auth/principal.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,21 @@ describe('principal persistence', () => {
168168
expect(parsePrincipal(serializePrincipal(principal))).toEqual(principal)
169169
})
170170

171+
it('round trips a generic webhook billing actor subject', () => {
172+
const principal = {
173+
kind: 'system' as const,
174+
serviceId: 'webhook' as const,
175+
workspaceId: 'workspace-1',
176+
workflowId: 'workflow-1',
177+
webhookId: 'webhook-1',
178+
provider: 'generic',
179+
subject: { kind: 'sim_user' as const, userId: 'billing-actor-1' },
180+
}
181+
182+
expect(parsePrincipal(serializePrincipal(principal))).toEqual(principal)
183+
expect(requirePrincipalSubjectUserId(principal)).toBe('billing-actor-1')
184+
})
185+
171186
it('rejects incomplete or cross-provider webhook identity', () => {
172187
expect(() =>
173188
parsePrincipal({
@@ -179,7 +194,7 @@ describe('principal persistence', () => {
179194
workflowId: 'workflow-1',
180195
},
181196
})
182-
).toThrow('require webhookId and provider')
197+
).toThrow('require webhookId, provider, and subject')
183198
expect(() =>
184199
parsePrincipal({
185200
version: 1,
@@ -200,6 +215,20 @@ describe('principal persistence', () => {
200215
})
201216
).toThrow('subject provider must match')
202217
})
218+
219+
it('rejects a persisted schedule without its billing actor subject', () => {
220+
expect(() =>
221+
parsePrincipal({
222+
version: 1,
223+
principal: {
224+
kind: 'system',
225+
serviceId: 'schedule',
226+
workspaceId: 'workspace-1',
227+
workflowId: 'workflow-1',
228+
},
229+
})
230+
).toThrow('Schedule system principals require a Sim user subject')
231+
})
203232
})
204233

205234
describe('principal subjects', () => {
@@ -234,8 +263,9 @@ describe('principal subjects', () => {
234263
serviceId: 'schedule',
235264
workspaceId: 'workspace-1',
236265
workflowId: 'workflow-1',
266+
subject: { kind: 'sim_user', userId: 'billing-actor-1' },
237267
})
238-
).toBeNull()
268+
).toEqual({ kind: 'sim_user', userId: 'billing-actor-1' })
239269
})
240270
})
241271

apps/sim/lib/credential-groups/application/authorization.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ describe('requireCredentialGroupCredentialAccess', () => {
145145
const principal = executorPrincipal()
146146
principal.delegationContext!.principal = {
147147
kind: 'system',
148-
serviceId: 'schedule',
148+
serviceId: 'internal',
149149
workspaceId: 'workspace-1',
150150
workflowId: 'root-workflow',
151151
}
@@ -164,7 +164,7 @@ describe('requireCredentialGroupCredentialAccess', () => {
164164
const principal = executorPrincipal()
165165
principal.delegationContext!.principal = {
166166
kind: 'system',
167-
serviceId: 'schedule',
167+
serviceId: 'internal',
168168
workspaceId: 'workspace-1',
169169
workflowId: 'root-workflow',
170170
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { internalKnowledgeActorUserId } from '@/lib/knowledge/api/internal-route'
6+
7+
describe('internal knowledge principal subject', () => {
8+
it('uses the generic webhook billing actor carried by executor delegation', () => {
9+
expect(
10+
internalKnowledgeActorUserId({
11+
kind: 'delegated',
12+
serviceId: 'executor',
13+
subjectUserId: 'billing-actor-1',
14+
workspaceId: 'workspace-1',
15+
delegationId: 'executor-1',
16+
audience: 'sim:knowledge',
17+
issuedAt: new Date('2026-01-01'),
18+
expiresAt: new Date('2099-01-01'),
19+
delegationContext: {
20+
kind: 'workflow_execution',
21+
workflowId: 'workflow-1',
22+
principal: {
23+
kind: 'system',
24+
serviceId: 'webhook',
25+
workspaceId: 'workspace-1',
26+
workflowId: 'workflow-1',
27+
webhookId: 'webhook-1',
28+
provider: 'generic',
29+
subject: { kind: 'sim_user', userId: 'billing-actor-1' },
30+
},
31+
},
32+
})
33+
).toBe('billing-actor-1')
34+
})
35+
})

apps/sim/lib/table/application/rows.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,30 @@ const TABLE: TableDefinition = {
199199
}
200200

201201
const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' }
202+
const GENERIC_WEBHOOK_EXECUTOR = {
203+
kind: 'delegated' as const,
204+
serviceId: 'executor' as const,
205+
subjectUserId: 'billing-actor-1',
206+
workspaceId: TABLE.workspaceId,
207+
delegationId: 'executor-1',
208+
audience: 'sim:tables',
209+
issuedAt: new Date('2026-01-01'),
210+
expiresAt: new Date('2099-01-01'),
211+
resourceScope: { tableId: TABLE.id },
212+
delegationContext: {
213+
kind: 'workflow_execution' as const,
214+
workflowId: 'workflow-1',
215+
principal: {
216+
kind: 'system' as const,
217+
serviceId: 'webhook' as const,
218+
workspaceId: TABLE.workspaceId,
219+
workflowId: 'workflow-1',
220+
webhookId: 'webhook-1',
221+
provider: 'generic',
222+
subject: { kind: 'sim_user' as const, userId: 'billing-actor-1' },
223+
},
224+
},
225+
}
202226

203227
/**
204228
* The active-table context every row command resolves before it does any work.
@@ -990,6 +1014,20 @@ describe('table row write secret provenance defaulting', () => {
9901014
{}
9911015
)
9921016
})
1017+
1018+
it('executes a generic webhook upsert as its principal billing actor', async () => {
1019+
await upsertTableRow.execute({
1020+
principal: GENERIC_WEBHOOK_EXECUTOR,
1021+
input: { tableId: TABLE.id, data: { name: 'Ada' } },
1022+
})
1023+
1024+
expect(mockUpsertRow).toHaveBeenCalledWith(
1025+
expect.objectContaining({ userId: 'billing-actor-1' }),
1026+
TABLE,
1027+
expect.any(String),
1028+
{}
1029+
)
1030+
})
9931031
})
9941032

9951033
/**
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
assertWebhookExecutionPrincipal,
7+
createWebhookExecutionPrincipal,
8+
} from '@/lib/webhooks/execution-principal'
9+
10+
describe('webhook execution principals', () => {
11+
it('represents a generic webhook as its billing actor', () => {
12+
const principal = createWebhookExecutionPrincipal({
13+
webhookId: 'webhook-1',
14+
workflowId: 'workflow-1',
15+
workspaceId: 'workspace-1',
16+
provider: 'generic',
17+
subject: { kind: 'sim_user', userId: 'billing-actor-1' },
18+
})
19+
20+
expect(principal.subject).toEqual({ kind: 'sim_user', userId: 'billing-actor-1' })
21+
expect(() =>
22+
assertWebhookExecutionPrincipal(principal, {
23+
webhookId: 'webhook-1',
24+
workflowId: 'workflow-1',
25+
workspaceId: 'workspace-1',
26+
provider: 'generic',
27+
userId: 'billing-actor-1',
28+
})
29+
).not.toThrow()
30+
})
31+
32+
it('rejects a generic webhook whose subject is not its billing actor', () => {
33+
expect(() =>
34+
createWebhookExecutionPrincipal({
35+
webhookId: 'webhook-1',
36+
workflowId: 'workflow-1',
37+
workspaceId: 'workspace-1',
38+
provider: 'generic',
39+
subject: {
40+
kind: 'external_user',
41+
provider: 'generic',
42+
tenantId: 'tenant-1',
43+
subjectId: 'subject-1',
44+
},
45+
})
46+
).toThrow('Generic webhook execution requires a Sim user billing actor')
47+
})
48+
49+
it('rejects a serialized Sim subject that disagrees with the admitted billing actor', () => {
50+
const principal = createWebhookExecutionPrincipal({
51+
webhookId: 'webhook-1',
52+
workflowId: 'workflow-1',
53+
workspaceId: 'workspace-1',
54+
provider: 'generic',
55+
subject: { kind: 'sim_user', userId: 'billing-actor-1' },
56+
})
57+
58+
expect(() =>
59+
assertWebhookExecutionPrincipal(principal, {
60+
webhookId: 'webhook-1',
61+
workflowId: 'workflow-1',
62+
workspaceId: 'workspace-1',
63+
provider: 'generic',
64+
userId: 'different-user',
65+
})
66+
).toThrow('Webhook job principal does not match its canonical execution scope')
67+
})
68+
})

apps/sim/lib/webhooks/execution-principal.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {
2-
ExternalUserSubject,
2+
PrincipalSubject,
33
WebhookSystemPrincipal,
44
WorkflowExecutionPrincipal,
55
} from '@sim/auth/principal'
@@ -14,23 +14,26 @@ export function createWebhookExecutionPrincipal(input: {
1414
workflowId: string
1515
workspaceId: string
1616
provider: string
17-
subject?: ExternalUserSubject
17+
subject: PrincipalSubject
1818
}): WebhookSystemPrincipal {
1919
const provider = normalizeWebhookPrincipalProvider(input.provider)
2020
if (!input.webhookId.trim()) throw new Error('Webhook execution requires a webhook ID')
2121
if (!input.workflowId.trim()) throw new Error('Webhook execution requires a workflow ID')
2222
if (!input.workspaceId.trim()) throw new Error('Webhook execution requires a workspace ID')
23-
if (input.subject && input.subject.provider !== provider) {
23+
if (input.subject.kind === 'external_user' && input.subject.provider !== provider) {
2424
throw new Error('Webhook execution subject provider must match the webhook provider')
2525
}
26+
if (provider === 'generic' && input.subject.kind !== 'sim_user') {
27+
throw new Error('Generic webhook execution requires a Sim user billing actor')
28+
}
2629
return {
2730
kind: 'system',
2831
serviceId: 'webhook',
2932
webhookId: input.webhookId,
3033
workflowId: input.workflowId,
3134
workspaceId: input.workspaceId,
3235
provider,
33-
...(input.subject ? { subject: input.subject } : {}),
36+
subject: input.subject,
3437
}
3538
}
3639

@@ -41,15 +44,19 @@ export function assertWebhookExecutionPrincipal(
4144
workflowId: string
4245
workspaceId: string
4346
provider: string
47+
userId: string
4448
}
4549
): asserts principal is WebhookSystemPrincipal {
50+
const provider = normalizeWebhookPrincipalProvider(expected.provider)
4651
if (
4752
principal.kind !== 'system' ||
4853
principal.serviceId !== 'webhook' ||
4954
principal.webhookId !== expected.webhookId ||
5055
principal.workflowId !== expected.workflowId ||
5156
principal.workspaceId !== expected.workspaceId ||
52-
principal.provider !== normalizeWebhookPrincipalProvider(expected.provider)
57+
principal.provider !== provider ||
58+
(provider === 'generic' && principal.subject.kind !== 'sim_user') ||
59+
(principal.subject.kind === 'sim_user' && principal.subject.userId !== expected.userId)
5360
) {
5461
throw new Error('Webhook job principal does not match its canonical execution scope')
5562
}

0 commit comments

Comments
 (0)