Skip to content

Commit 7a5b8f4

Browse files
fix(workflows): authorize automated runs by deployment
1 parent 2795922 commit 7a5b8f4

20 files changed

Lines changed: 585 additions & 55 deletions

File tree

apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
99
import { OrchestrationError } from '@/lib/core/orchestration/types'
1010
import {
11-
internalKnowledgeActorUserId,
1211
internalKnowledgeAuthType,
12+
internalKnowledgeProvenanceUserId,
1313
toInternalKnowledgeChunk,
1414
} from '@/lib/knowledge/api/internal-route'
1515
import {
@@ -38,7 +38,7 @@ function resolveContentProvenance(
3838
request,
3939
payload,
4040
authType: internalKnowledgeAuthType(principal),
41-
userId: internalKnowledgeActorUserId(principal),
41+
userId: internalKnowledgeProvenanceUserId(request, principal, workspaceId),
4242
...(workspaceId ? { workspaceId } : {}),
4343
selectionKeys: includeContent ? ['chunk-content'] : [],
4444
})
@@ -65,7 +65,7 @@ export const GET = defineInternalJsonRoute({
6565
finalizeKnowledgePersistedResponse({
6666
request,
6767
authType: internalKnowledgeAuthType(principal),
68-
userId: internalKnowledgeActorUserId(principal),
68+
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
6969
workspaceId: result.workspaceId,
7070
body,
7171
chunks: [
@@ -102,7 +102,7 @@ export const PUT = defineInternalJsonRoute({
102102
finalizeKnowledgePersistedResponse({
103103
request,
104104
authType: internalKnowledgeAuthType(principal),
105-
userId: internalKnowledgeActorUserId(principal),
105+
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
106106
workspaceId: result.workspaceId,
107107
body,
108108
chunks: [

apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
99
import { OrchestrationError } from '@/lib/core/orchestration/types'
1010
import {
11-
internalKnowledgeActorUserId,
1211
internalKnowledgeAuthType,
12+
internalKnowledgeProvenanceUserId,
1313
toInternalKnowledgeChunk,
1414
} from '@/lib/knowledge/api/internal-route'
1515
import {
@@ -39,7 +39,7 @@ function resolveContentProvenance(
3939
request,
4040
payload,
4141
authType: internalKnowledgeAuthType(principal),
42-
userId: internalKnowledgeActorUserId(principal),
42+
userId: internalKnowledgeProvenanceUserId(request, principal, workspaceId),
4343
...(workspaceId ? { workspaceId } : {}),
4444
selectionKeys: includeContent ? ['chunk-content'] : [],
4545
})
@@ -70,7 +70,7 @@ export const GET = defineInternalJsonRoute({
7070
finalizeKnowledgePersistedResponse({
7171
request,
7272
authType: internalKnowledgeAuthType(principal),
73-
userId: internalKnowledgeActorUserId(principal),
73+
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
7474
workspaceId: result.workspaceId,
7575
body,
7676
chunks: result.chunks.map((chunk) => ({

apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import {
55
} from '@/lib/api/contracts/knowledge'
66
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
77
import {
8-
internalKnowledgeActorUserId,
98
internalKnowledgeAnalytics,
109
internalKnowledgeAuthType,
10+
internalKnowledgeProvenanceUserId,
1111
resolveInternalKnowledgeBillingAttribution,
1212
toInternalKnowledgeDocument,
1313
} from '@/lib/knowledge/api/internal-route'
@@ -45,7 +45,7 @@ export const GET = defineInternalJsonRoute({
4545
finalizeKnowledgePersistedResponse({
4646
request,
4747
authType: internalKnowledgeAuthType(principal),
48-
userId: internalKnowledgeActorUserId(principal),
48+
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
4949
workspaceId: result.workspaceId,
5050
body,
5151
documents: [

apps/sim/app/api/knowledge/[id]/documents/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import {
77
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
88
import { OrchestrationError } from '@/lib/core/orchestration/types'
99
import {
10-
internalKnowledgeActorUserId,
1110
internalKnowledgeAnalytics,
1211
internalKnowledgeAuthType,
12+
internalKnowledgeProvenanceUserId,
1313
resolveInternalKnowledgeBillingAttribution,
1414
toInternalKnowledgeDocument,
1515
} from '@/lib/knowledge/api/internal-route'
@@ -68,7 +68,7 @@ export const GET = defineInternalJsonRoute({
6868
finalizeKnowledgePersistedResponse({
6969
request,
7070
authType: internalKnowledgeAuthType(principal),
71-
userId: internalKnowledgeActorUserId(principal),
71+
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
7272
workspaceId: result.workspaceId,
7373
body,
7474
documents: result.documents.map((document) => ({

apps/sim/app/api/table/[tableId]/rows/upsert/route.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,28 @@ describe('POST /api/table/[tableId]/rows/upsert', () => {
125125
mocks.authenticate.mockResolvedValue({
126126
kind: 'delegated',
127127
serviceId: 'executor',
128-
subjectUserId: 'user-1',
129128
workspaceId: WORKSPACE_ID,
130129
delegationId: 'delegation-1',
131130
audience: 'table',
132131
issuedAt: new Date('2026-01-01'),
133-
expiresAt: new Date('2026-01-02'),
132+
expiresAt: new Date('2099-01-02'),
133+
delegationContext: {
134+
kind: 'workflow_execution',
135+
workflowId: 'workflow-1',
136+
currentWorkflow: {
137+
workflowId: 'workflow-1',
138+
mode: 'deployment',
139+
deploymentVersionId: 'deployment-1',
140+
},
141+
principal: {
142+
kind: 'system',
143+
serviceId: 'webhook',
144+
workspaceId: WORKSPACE_ID,
145+
workflowId: 'workflow-1',
146+
webhookId: 'webhook-1',
147+
provider: 'generic',
148+
},
149+
},
134150
})
135151

136152
await POST(request({ ...BODY, data: { Name: 'Ada' }, conflictTarget: 'Name' }), routeContext())

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,45 @@ describe('principal actors', () => {
357357
).toMatchObject({ attributedUserId: 'user-3' })
358358
})
359359

360+
it('uses the workspace billing owner only for actorless execution attribution', () => {
361+
const principal = {
362+
kind: 'delegated' as const,
363+
serviceId: 'executor' as const,
364+
workspaceId: 'workspace-1',
365+
delegationId: 'delegation-1',
366+
audience: 'sim:tables',
367+
issuedAt: new Date('2026-01-01T00:00:00Z'),
368+
expiresAt: new Date('2026-01-01T00:05:00Z'),
369+
delegationContext: {
370+
kind: 'workflow_execution' as const,
371+
workflowId: 'workflow-1',
372+
principal: {
373+
kind: 'system' as const,
374+
serviceId: 'webhook' as const,
375+
workspaceId: 'workspace-1',
376+
workflowId: 'workflow-1',
377+
webhookId: 'webhook-1',
378+
provider: 'generic',
379+
},
380+
},
381+
}
382+
383+
expect(
384+
resolvePrincipalAttribution(principal, {
385+
workspaceBillingOwnerUserId: 'billing-owner-1',
386+
})
387+
).toEqual({
388+
actor: {
389+
kind: 'delegated',
390+
serviceId: 'executor',
391+
delegationId: 'delegation-1',
392+
},
393+
attributedUserId: 'billing-owner-1',
394+
})
395+
expect(resolvePrincipalSubject(principal)).toBeNull()
396+
expect(() => resolvePrincipalAttribution(principal)).toThrow(PrincipalSubjectUserRequiredError)
397+
})
398+
360399
it('fails fast when workspace-key attribution has no billing owner', () => {
361400
expect(() =>
362401
resolvePrincipalAttribution({

apps/sim/lib/billing/core/billing-attribution.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
requireAccountBillingDecisionHeader,
4545
requireBillingAttributionHeader,
4646
requireBillingRequestIdHeader,
47+
requireWorkspaceBillingAttributionHeader,
4748
resolveBillingAttribution,
4849
resolveLegacyV0BillingAttribution,
4950
resolveSystemBillingAttribution,
@@ -426,6 +427,19 @@ describe('serialized attribution boundaries', () => {
426427
).toThrow('Billing attribution header is required')
427428
})
428429

430+
it('restores an executor snapshot by canonical workspace without making its actor authority', () => {
431+
const headers = new Headers({
432+
'x-sim-billing-attribution': serializeBillingAttributionHeader(attribution),
433+
})
434+
435+
expect(
436+
requireWorkspaceBillingAttributionHeader(headers, { workspaceId: 'workspace-b' })
437+
).toEqual(attribution)
438+
expect(() =>
439+
requireWorkspaceBillingAttributionHeader(headers, { workspaceId: 'workspace-other' })
440+
).toThrow('does not match the authenticated request scope')
441+
})
442+
429443
it('rejects inconsistent or cross-scope serialized snapshots', () => {
430444
expect(() =>
431445
assertBillingAttributionSnapshot({

apps/sim/lib/billing/core/billing-attribution.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,8 @@ export function requireBillingRequestIdHeader(headers: Pick<Headers, 'get'>): st
406406

407407
function parseBillingAttributionHeader(
408408
headers: Pick<Headers, 'get'>,
409-
expected: ResolveBillingAttributionParams
409+
expected: Pick<ResolveBillingAttributionParams, 'workspaceId'> &
410+
Partial<Pick<ResolveBillingAttributionParams, 'actorUserId'>>
410411
): BillingAttributionSnapshot | undefined {
411412
const encoded = headers.get(BILLING_ATTRIBUTION_HEADER)
412413
if (!encoded) return undefined
@@ -423,7 +424,7 @@ function parseBillingAttributionHeader(
423424

424425
const attribution = assertBillingAttributionSnapshot(parsed)
425426
if (
426-
attribution.actorUserId !== expected.actorUserId ||
427+
(expected.actorUserId !== undefined && attribution.actorUserId !== expected.actorUserId) ||
427428
attribution.workspaceId !== expected.workspaceId
428429
) {
429430
throw new Error('Billing attribution header does not match the authenticated request scope')
@@ -447,6 +448,22 @@ export function requireBillingAttributionHeader(
447448
return attribution
448449
}
449450

451+
/**
452+
* Restores the executor's captured billing decision without treating its actor as authorization.
453+
* The authenticated executor is authoritative for the snapshot; the canonical use case supplies
454+
* the workspace scope that must still match.
455+
*/
456+
export function requireWorkspaceBillingAttributionHeader(
457+
headers: Pick<Headers, 'get'>,
458+
expected: Pick<ResolveBillingAttributionParams, 'workspaceId'>
459+
): BillingAttributionSnapshot {
460+
const attribution = parseBillingAttributionHeader(headers, expected)
461+
if (!attribution) {
462+
throw new Error('Billing attribution header is required for this internal request')
463+
}
464+
return attribution
465+
}
466+
450467
/**
451468
* Compares two independently restored snapshots after canonical validation.
452469
*/

0 commit comments

Comments
 (0)