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
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
internalKnowledgeActorUserId,
internalKnowledgeAuthType,
internalKnowledgeProvenanceUserId,
toInternalKnowledgeChunk,
} from '@/lib/knowledge/api/internal-route'
import {
Expand Down Expand Up @@ -38,7 +38,7 @@ function resolveContentProvenance(
request,
payload,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeActorUserId(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, workspaceId),
...(workspaceId ? { workspaceId } : {}),
selectionKeys: includeContent ? ['chunk-content'] : [],
})
Expand All @@ -65,7 +65,7 @@ export const GET = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
request,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeActorUserId(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
chunks: [
Expand Down Expand Up @@ -102,7 +102,7 @@ export const PUT = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
request,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeActorUserId(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
chunks: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
internalKnowledgeActorUserId,
internalKnowledgeAuthType,
internalKnowledgeProvenanceUserId,
toInternalKnowledgeChunk,
} from '@/lib/knowledge/api/internal-route'
import {
Expand Down Expand Up @@ -39,7 +39,7 @@ function resolveContentProvenance(
request,
payload,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeActorUserId(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, workspaceId),
...(workspaceId ? { workspaceId } : {}),
selectionKeys: includeContent ? ['chunk-content'] : [],
})
Expand Down Expand Up @@ -70,7 +70,7 @@ export const GET = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
request,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeActorUserId(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
chunks: result.chunks.map((chunk) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {
} from '@/lib/api/contracts/knowledge'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import {
internalKnowledgeActorUserId,
internalKnowledgeAnalytics,
internalKnowledgeAuthType,
internalKnowledgeProvenanceUserId,
resolveInternalKnowledgeBillingAttribution,
toInternalKnowledgeDocument,
} from '@/lib/knowledge/api/internal-route'
Expand Down Expand Up @@ -45,7 +45,7 @@ export const GET = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
request,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeActorUserId(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
documents: [
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/knowledge/[id]/documents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
internalKnowledgeActorUserId,
internalKnowledgeAnalytics,
internalKnowledgeAuthType,
internalKnowledgeProvenanceUserId,
resolveInternalKnowledgeBillingAttribution,
toInternalKnowledgeDocument,
} from '@/lib/knowledge/api/internal-route'
Expand Down Expand Up @@ -68,7 +68,7 @@ export const GET = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
request,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeActorUserId(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
documents: result.documents.map((document) => ({
Expand Down
20 changes: 18 additions & 2 deletions apps/sim/app/api/table/[tableId]/rows/upsert/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,28 @@ describe('POST /api/table/[tableId]/rows/upsert', () => {
mocks.authenticate.mockResolvedValue({
kind: 'delegated',
serviceId: 'executor',
subjectUserId: 'user-1',
workspaceId: WORKSPACE_ID,
delegationId: 'delegation-1',
audience: 'table',
issuedAt: new Date('2026-01-01'),
expiresAt: new Date('2026-01-02'),
expiresAt: new Date('2099-01-02'),
delegationContext: {
kind: 'workflow_execution',
workflowId: 'workflow-1',
currentWorkflow: {
workflowId: 'workflow-1',
mode: 'deployment',
deploymentVersionId: 'deployment-1',
},
principal: {
kind: 'system',
serviceId: 'webhook',
workspaceId: WORKSPACE_ID,
workflowId: 'workflow-1',
webhookId: 'webhook-1',
provider: 'generic',
},
},
})

await POST(request({ ...BODY, data: { Name: 'Ada' }, conflictTarget: 'Name' }), routeContext())
Expand Down
39 changes: 39 additions & 0 deletions apps/sim/lib/auth/principal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,45 @@ describe('principal actors', () => {
).toMatchObject({ attributedUserId: 'user-3' })
})

it('uses the workspace billing owner only for actorless execution attribution', () => {
const principal = {
kind: 'delegated' as const,
serviceId: 'executor' as const,
workspaceId: 'workspace-1',
delegationId: 'delegation-1',
audience: 'sim:tables',
issuedAt: new Date('2026-01-01T00:00:00Z'),
expiresAt: new Date('2026-01-01T00:05:00Z'),
delegationContext: {
kind: 'workflow_execution' as const,
workflowId: 'workflow-1',
principal: {
kind: 'system' as const,
serviceId: 'webhook' as const,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
webhookId: 'webhook-1',
provider: 'generic',
},
},
}

expect(
resolvePrincipalAttribution(principal, {
workspaceBillingOwnerUserId: 'billing-owner-1',
})
).toEqual({
actor: {
kind: 'delegated',
serviceId: 'executor',
delegationId: 'delegation-1',
},
attributedUserId: 'billing-owner-1',
})
expect(resolvePrincipalSubject(principal)).toBeNull()
expect(() => resolvePrincipalAttribution(principal)).toThrow(PrincipalSubjectUserRequiredError)
})

it('fails fast when workspace-key attribution has no billing owner', () => {
expect(() =>
resolvePrincipalAttribution({
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/lib/billing/core/billing-attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
requireAccountBillingDecisionHeader,
requireBillingAttributionHeader,
requireBillingRequestIdHeader,
requireWorkspaceBillingAttributionHeader,
resolveBillingAttribution,
resolveLegacyV0BillingAttribution,
resolveSystemBillingAttribution,
Expand Down Expand Up @@ -426,6 +427,19 @@ describe('serialized attribution boundaries', () => {
).toThrow('Billing attribution header is required')
})

it('restores an executor snapshot by canonical workspace without making its actor authority', () => {
const headers = new Headers({
'x-sim-billing-attribution': serializeBillingAttributionHeader(attribution),
})

expect(
requireWorkspaceBillingAttributionHeader(headers, { workspaceId: 'workspace-b' })
).toEqual(attribution)
expect(() =>
requireWorkspaceBillingAttributionHeader(headers, { workspaceId: 'workspace-other' })
).toThrow('does not match the authenticated request scope')
})

it('rejects inconsistent or cross-scope serialized snapshots', () => {
expect(() =>
assertBillingAttributionSnapshot({
Expand Down
21 changes: 19 additions & 2 deletions apps/sim/lib/billing/core/billing-attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,8 @@ export function requireBillingRequestIdHeader(headers: Pick<Headers, 'get'>): st

function parseBillingAttributionHeader(
headers: Pick<Headers, 'get'>,
expected: ResolveBillingAttributionParams
expected: Pick<ResolveBillingAttributionParams, 'workspaceId'> &
Partial<Pick<ResolveBillingAttributionParams, 'actorUserId'>>
): BillingAttributionSnapshot | undefined {
const encoded = headers.get(BILLING_ATTRIBUTION_HEADER)
if (!encoded) return undefined
Expand All @@ -423,7 +424,7 @@ function parseBillingAttributionHeader(

const attribution = assertBillingAttributionSnapshot(parsed)
if (
attribution.actorUserId !== expected.actorUserId ||
(expected.actorUserId !== undefined && attribution.actorUserId !== expected.actorUserId) ||
attribution.workspaceId !== expected.workspaceId
) {
throw new Error('Billing attribution header does not match the authenticated request scope')
Expand All @@ -447,6 +448,22 @@ export function requireBillingAttributionHeader(
return attribution
}

/**
* Restores the executor's captured billing decision without treating its actor as authorization.
* The authenticated executor is authoritative for the snapshot; the canonical use case supplies
* the workspace scope that must still match.
*/
export function requireWorkspaceBillingAttributionHeader(
headers: Pick<Headers, 'get'>,
expected: Pick<ResolveBillingAttributionParams, 'workspaceId'>
): BillingAttributionSnapshot {
const attribution = parseBillingAttributionHeader(headers, expected)
if (!attribution) {
throw new Error('Billing attribution header is required for this internal request')
}
return attribution
}

/**
* Compares two independently restored snapshots after canonical validation.
*/
Expand Down
Loading
Loading