diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts index a5d4fdb84d4..fbfaaad7c9e 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts @@ -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 { @@ -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'] : [], }) @@ -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: [ @@ -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: [ diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index d805898f287..67e4fde4393 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -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 { @@ -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'] : [], }) @@ -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) => ({ diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index 26cea872efa..1b3c6202899 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -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' @@ -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: [ diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index ae6bb49230b..a6fc71b3c5d 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -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' @@ -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) => ({ diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.test.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.test.ts index 2d07bd5e0d0..071321e7549 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.test.ts @@ -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()) diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index ae979fc6a9d..1121a6d9fc5 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -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({ diff --git a/apps/sim/lib/billing/core/billing-attribution.test.ts b/apps/sim/lib/billing/core/billing-attribution.test.ts index 41068d71088..e99f4fd2590 100644 --- a/apps/sim/lib/billing/core/billing-attribution.test.ts +++ b/apps/sim/lib/billing/core/billing-attribution.test.ts @@ -44,6 +44,7 @@ import { requireAccountBillingDecisionHeader, requireBillingAttributionHeader, requireBillingRequestIdHeader, + requireWorkspaceBillingAttributionHeader, resolveBillingAttribution, resolveLegacyV0BillingAttribution, resolveSystemBillingAttribution, @@ -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({ diff --git a/apps/sim/lib/billing/core/billing-attribution.ts b/apps/sim/lib/billing/core/billing-attribution.ts index d98a0bb26b9..7ebfc453b93 100644 --- a/apps/sim/lib/billing/core/billing-attribution.ts +++ b/apps/sim/lib/billing/core/billing-attribution.ts @@ -406,7 +406,8 @@ export function requireBillingRequestIdHeader(headers: Pick): st function parseBillingAttributionHeader( headers: Pick, - expected: ResolveBillingAttributionParams + expected: Pick & + Partial> ): BillingAttributionSnapshot | undefined { const encoded = headers.get(BILLING_ATTRIBUTION_HEADER) if (!encoded) return undefined @@ -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') @@ -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, + expected: Pick +): 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. */ diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index ac6c4ab6886..c53e266ca76 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -1,7 +1,11 @@ /** * @vitest-environment node */ -import type { SessionPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' +import type { + DelegatedPrincipal, + SessionPrincipal, + WorkspaceApiKeyPrincipal, +} from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -52,6 +56,43 @@ const workspaceKeyPrincipal: WorkspaceApiKeyPrincipal = { keyId: 'key-1', } +const executorOperation = defineWorkspaceOperation({ + id: 'test.executor-write', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], +}) + +function executorPrincipal( + originalPrincipal: NonNullable['principal'], + currentWorkflow?: NonNullable['currentWorkflow'] +): DelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:test', + issuedAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + resourceScope: { executionId: 'execution-1' }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'root-workflow-1', + principal: originalPrincipal, + ...(currentWorkflow ? { currentWorkflow } : {}), + }, + } +} + +const executorAuthorization = { + delegation: { + audience: 'sim:test', + isWithinScope: () => true, + }, +} + const context = { workspaceId: 'workspace-1', workspaceOrganizationId: 'organization-1', @@ -115,4 +156,111 @@ describe('authorizeWorkspaceOperation', () => { authorizeWorkspaceOperation(principal, workspaceKeyOperation, context) ).rejects.toBeInstanceOf(PrincipalKindAuthorizationError) }) + + it.each([ + { + name: 'generic webhook', + principal: { + kind: 'system' as const, + serviceId: 'webhook' as const, + workspaceId: 'workspace-1', + workflowId: 'root-workflow-1', + webhookId: 'webhook-1', + provider: 'generic', + }, + }, + { + name: 'Slack webhook', + principal: { + kind: 'system' as const, + serviceId: 'webhook' as const, + workspaceId: 'workspace-1', + workflowId: 'root-workflow-1', + webhookId: 'webhook-1', + provider: 'slack', + subject: { + kind: 'external_user' as const, + provider: 'slack', + tenantId: 'tenant-1', + subjectId: 'subject-1', + }, + }, + }, + { + name: 'schedule', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'root-workflow-1', + }, + }, + ])('authorizes a $name by its bound deployed workflow', async ({ principal }) => { + await expect( + authorizeWorkspaceOperation( + executorPrincipal(principal, { + workflowId: 'current-workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }), + executorOperation, + context, + executorAuthorization + ) + ).resolves.toBeUndefined() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + }) + + it.each([ + { name: 'missing', currentWorkflow: undefined }, + { + name: 'draft', + currentWorkflow: { workflowId: 'current-workflow-1', mode: 'draft' as const }, + }, + ])('rejects actorless execution with a $name workflow authority', async ({ currentWorkflow }) => { + await expect( + authorizeWorkspaceOperation( + executorPrincipal( + { + kind: 'system', + serviceId: 'webhook', + workspaceId: 'workspace-1', + workflowId: 'root-workflow-1', + webhookId: 'webhook-1', + provider: 'generic', + }, + currentWorkflow + ), + executorOperation, + context, + executorAuthorization + ) + ).rejects.toMatchObject({ name: 'DelegatedWorkspaceAuthorizationError' }) + }) + + it('keeps a real human execution on the human workspace-role path in draft mode', async () => { + mocks.resolvePermission.mockResolvedValue('write') + + await expect( + authorizeWorkspaceOperation( + { + ...executorPrincipal( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { workflowId: 'current-workflow-1', mode: 'draft' } + ), + subjectUserId: 'user-1', + }, + executorOperation, + context, + executorAuthorization + ) + ).resolves.toBeUndefined() + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) }) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index dee740f955c..afbd6c5342f 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -212,7 +212,10 @@ export async function authorizeWorkspaceOperation { }) }) + it('admits workspace-scoped execution without fabricating a destination user', () => { + expect( + durableSecretProvenanceFromPrivateBundle( + privateBundle({ userId: 'workflow-owner', workspaceId: 'workspace-1' }), + 'value', + { workspaceId: 'workspace-1' } + ) + ).toMatchObject({ status: 'exact' }) + }) + it('rejects a source from another or no workspace', () => { expect( durableSecretProvenanceFromPrivateBundle( diff --git a/apps/sim/lib/execution/durable-secret-provenance.ts b/apps/sim/lib/execution/durable-secret-provenance.ts index d6918c764b9..e4424a47ec7 100644 --- a/apps/sim/lib/execution/durable-secret-provenance.ts +++ b/apps/sim/lib/execution/durable-secret-provenance.ts @@ -126,7 +126,9 @@ export function durableSecretProvenanceFromRegistry( */ export function isPrivateSecretProvenanceScopeCompatible( sourceScope: ResolvedSecretTraceScopeV1 | undefined, - destinationScope: { userId: string; workspaceId?: string } + destinationScope: + | { workspaceId: string; userId?: string } + | { userId: string; workspaceId?: undefined } ): sourceScope is ResolvedSecretTraceScopeV1 { if (!sourceScope) return false if (destinationScope.workspaceId !== undefined) { @@ -139,7 +141,9 @@ export function isPrivateSecretProvenanceScopeCompatible( export function durableSecretProvenanceFromPrivateBundle( value: unknown, selectionKey: string, - destinationScope: { userId: string; workspaceId?: string } + destinationScope: + | { workspaceId: string; userId?: string } + | { userId: string; workspaceId?: undefined } ): DurableSecretProvenance | undefined { if (!isPrivateSecretProvenanceBundleV1(value)) return undefined const bundle: PrivateSecretProvenanceBundleV1 = value diff --git a/apps/sim/lib/knowledge/api/internal-route.test.ts b/apps/sim/lib/knowledge/api/internal-route.test.ts new file mode 100644 index 00000000000..4ea8504ec64 --- /dev/null +++ b/apps/sim/lib/knowledge/api/internal-route.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { + BILLING_ATTRIBUTION_HEADER, + serializeBillingAttributionHeader, +} from '@/lib/billing/core/billing-attribution' +import { + internalKnowledgeProvenanceUserId, + resolveInternalKnowledgeBillingAttribution, +} from '@/lib/knowledge/api/internal-route' +import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' + +const BILLING_ATTRIBUTION = { + actorUserId: 'execution-billing-actor-1', + billedAccountUserId: 'billing-owner-1', + billingEntity: { type: 'user' as const, id: 'billing-owner-1' }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + organizationId: null, + payerSubscription: null, + workspaceId: 'workspace-1', +} + +function request(): NextRequest { + return new NextRequest('http://localhost/api/knowledge/search', { + headers: { + [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(BILLING_ATTRIBUTION), + }, + }) +} + +function executorPrincipal( + originalPrincipal: NonNullable< + WorkflowExecutionDelegatedPrincipal['delegationContext'] + >['principal'] +): WorkflowExecutionDelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'executor', + workspaceId: 'workspace-1', + delegationId: 'executor-1', + audience: 'sim:knowledge', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + ...(originalPrincipal ? { principal: originalPrincipal } : {}), + }, + } +} + +describe('internal Knowledge execution attribution', () => { + it.each([ + { + name: 'generic webhook', + principal: { + kind: 'system' as const, + serviceId: 'webhook' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + webhookId: 'webhook-1', + provider: 'generic', + }, + }, + { + name: 'Slack webhook', + principal: { + kind: 'system' as const, + serviceId: 'webhook' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + webhookId: 'webhook-1', + provider: 'slack', + subject: { + kind: 'external_user' as const, + provider: 'slack', + tenantId: 'tenant-1', + subjectId: 'subject-1', + }, + }, + }, + ])('restores billing and provenance attribution for a $name execution', async ({ principal }) => { + const executor = executorPrincipal(principal) + + await expect( + resolveInternalKnowledgeBillingAttribution(request(), executor, 'workspace-1') + ).resolves.toEqual(BILLING_ATTRIBUTION) + expect(internalKnowledgeProvenanceUserId(request(), executor, 'workspace-1')).toBe( + 'billing-owner-1' + ) + expect( + resolveKnowledgeAttributedUserId(executor, { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + ).toBe('billing-owner-1') + }) + + it('rejects a billing snapshot from another workspace', async () => { + const principal = executorPrincipal({ + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }) + + await expect( + resolveInternalKnowledgeBillingAttribution(request(), principal, 'workspace-2') + ).rejects.toThrow('does not match the authenticated request scope') + }) +}) diff --git a/apps/sim/lib/knowledge/api/internal-route.ts b/apps/sim/lib/knowledge/api/internal-route.ts index fb5fdd6212f..2ce9787f8a7 100644 --- a/apps/sim/lib/knowledge/api/internal-route.ts +++ b/apps/sim/lib/knowledge/api/internal-route.ts @@ -1,6 +1,7 @@ import { type Principal, requirePrincipalSubjectUserId, + resolvePrincipalSubject, type SessionPrincipal, } from '@sim/auth/principal' import type { NextRequest } from 'next/server' @@ -16,7 +17,7 @@ import { type DocumentData, documentDataSchema } from '@/lib/api/contracts/knowl import { type TagDefinitionData, tagDefinitionDataSchema } from '@/lib/api/contracts/knowledge/tags' import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid' import { - requireBillingAttributionHeader, + requireWorkspaceBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' import { PlatformEvents } from '@/lib/core/telemetry' @@ -34,6 +35,21 @@ export function internalKnowledgeActorUserId(principal: Principal): string { return requirePrincipalSubjectUserId(principal) } +export function internalKnowledgeProvenanceUserId( + request: NextRequest, + principal: Principal, + workspaceId: string | undefined +): string { + if (principal.kind !== 'delegated') return internalKnowledgeActorUserId(principal) + const subject = resolvePrincipalSubject(principal) + if (subject?.kind === 'sim_user') return subject.userId + if (!workspaceId) { + throw new Error('Delegated Knowledge provenance requires a workspace scope') + } + return requireWorkspaceBillingAttributionHeader(request.headers, { workspaceId }) + .billedAccountUserId +} + export function internalKnowledgeAuthType(principal: Principal): AuthTypeValue { return principal.kind === 'delegated' ? AuthType.INTERNAL_JWT : AuthType.SESSION } @@ -43,10 +59,18 @@ export async function resolveInternalKnowledgeBillingAttribution( principal: Principal, workspaceId: string ) { - const actorUserId = internalKnowledgeActorUserId(principal) - return await (principal.kind === 'delegated' - ? requireBillingAttributionHeader(request.headers, { actorUserId, workspaceId }) - : resolveBillingAttribution({ actorUserId, workspaceId })) + if (principal.kind === 'delegated') { + return requireWorkspaceBillingAttributionHeader(request.headers, { workspaceId }) + } + return await resolveBillingAttribution({ + actorUserId: internalKnowledgeActorUserId(principal), + workspaceId, + }) +} + +function internalKnowledgeAnalyticsUserId(principal: Principal): string | null { + const subject = resolvePrincipalSubject(principal) + return subject?.kind === 'sim_user' ? subject.userId : null } function serializeDate(date: Date | string): string { @@ -243,7 +267,6 @@ export const internalKnowledgeAnalytics = { } | { kind: 'bulk'; workspaceId?: string; data: { total: number }; knowledgeBaseId?: string } }): void { - const userId = internalKnowledgeActorUserId(principal) const documentCount = result.kind === 'bulk' ? result.data.total : 1 const knowledgeBaseId = result.kind === 'single' ? result.data.knowledgeBaseId : result.knowledgeBaseId @@ -258,6 +281,8 @@ export const internalKnowledgeAnalytics = { ? { mimeType: result.data.mimeType, fileSize: result.data.fileSize } : { recipe: input.processingOptions?.recipe }), }) + const userId = internalKnowledgeAnalyticsUserId(principal) + if (!userId) return captureServerEvent( userId, 'knowledge_base_document_uploaded', @@ -297,8 +322,10 @@ export const internalKnowledgeAnalytics = { }): void { const workspaceId = result.workspaceId if (!workspaceId) throw new Error('Deleted document result is missing its workspace scope') + const userId = internalKnowledgeAnalyticsUserId(principal) + if (!userId) return captureServerEvent( - internalKnowledgeActorUserId(principal), + userId, 'knowledge_base_document_deleted', { knowledge_base_id: result.knowledgeBaseId, workspace_id: workspaceId }, { groups: { workspace: workspaceId } } @@ -319,8 +346,10 @@ export const internalKnowledgeAnalytics = { } } }): void { + const userId = internalKnowledgeAnalyticsUserId(principal) + if (!userId) return captureServerEvent( - internalKnowledgeActorUserId(principal), + userId, 'knowledge_base_connector_added', { knowledge_base_id: connector.knowledgeBaseId, @@ -350,8 +379,10 @@ export const internalKnowledgeAnalytics = { if (!result.workspaceId) { throw new Error('Deleted connector result is missing its workspace analytics scope') } + const userId = internalKnowledgeAnalyticsUserId(principal) + if (!userId) return captureServerEvent( - internalKnowledgeActorUserId(principal), + userId, 'knowledge_base_connector_removed', { knowledge_base_id: result.knowledgeBaseId, @@ -377,8 +408,10 @@ export const internalKnowledgeAnalytics = { if (!result.workspaceId) { throw new Error('Synced connector result is missing its workspace analytics scope') } + const userId = internalKnowledgeAnalyticsUserId(principal) + if (!userId) return captureServerEvent( - internalKnowledgeActorUserId(principal), + userId, 'knowledge_base_connector_synced', { knowledge_base_id: result.knowledgeBaseId, diff --git a/apps/sim/lib/table/application/row-secret-provenance.test.ts b/apps/sim/lib/table/application/row-secret-provenance.test.ts index 100b56f2eeb..ae863ddc0c5 100644 --- a/apps/sim/lib/table/application/row-secret-provenance.test.ts +++ b/apps/sim/lib/table/application/row-secret-provenance.test.ts @@ -36,12 +36,28 @@ const SESSION = { kind: 'session' as const, userId: 'user-1', sessionId: 'sessio const EXECUTOR = { kind: 'delegated' as const, serviceId: 'executor' as const, - subjectUserId: 'user-1', workspaceId: 'workspace-1', 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' as const, + workflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + }, + principal: { + kind: 'system' as const, + serviceId: 'webhook' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + webhookId: 'webhook-1', + provider: 'generic', + }, + }, } /** @@ -175,7 +191,7 @@ describe('row write provenance', () => { expect(stamps[0]).toEqual({ complete: true, columns: { col_aaa: traceProvenance() } }) }) - it('checks the scope against the acting principal, not a billing owner', () => { + it('checks a deployed actorless execution against its authorized workspace', () => { resolve({ principal: EXECUTOR, envelope: { @@ -191,7 +207,7 @@ describe('row write provenance', () => { expect(mocks.scopeCompatible).toHaveBeenCalledWith( { userId: 'billing-owner', workspaceId: 'workspace-1' }, - { userId: 'user-1', workspaceId: 'workspace-1' } + { workspaceId: 'workspace-1' } ) }) diff --git a/apps/sim/lib/table/application/row-secret-provenance.ts b/apps/sim/lib/table/application/row-secret-provenance.ts index 3911703ac42..e5a110a807a 100644 --- a/apps/sim/lib/table/application/row-secret-provenance.ts +++ b/apps/sim/lib/table/application/row-secret-provenance.ts @@ -1,4 +1,4 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { Principal } from '@sim/auth/principal' import { isPrivateSecretProvenanceScopeCompatible } from '@/lib/execution/durable-secret-provenance' import { isPrivateSecretProvenanceBundleV1 } from '@/lib/execution/model-input-provenance' import { buildIdByName } from '@/lib/table/column-keys' @@ -124,13 +124,11 @@ export function resolveRowWriteProvenance(options: { complete: true, columns: {}, })) - const subjectUserId = requirePrincipalSubjectUserId(principal) for (const selection of bundle.selections) { const touched = touchedBySelectionKey.get(selection.key) if ( !touched || !isPrivateSecretProvenanceScopeCompatible(selection.provenance.scope, { - userId: subjectUserId, workspaceId: options.workspaceId, }) ) { diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index 6ba968fa788..d9299413cfd 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -199,6 +199,33 @@ const TABLE: TableDefinition = { } const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const GENERIC_WEBHOOK_EXECUTOR = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + workspaceId: TABLE.workspaceId, + delegationId: 'executor-1', + audience: 'sim:tables', + issuedAt: new Date('2026-01-01'), + expiresAt: new Date('2099-01-01'), + resourceScope: { tableId: TABLE.id }, + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + }, + principal: { + kind: 'system' as const, + serviceId: 'webhook' as const, + workspaceId: TABLE.workspaceId, + workflowId: 'workflow-1', + webhookId: 'webhook-1', + provider: 'generic', + }, + }, +} /** * The active-table context every row command resolves before it does any work. @@ -366,7 +393,7 @@ describe('replaceProjectedWireRows application command', () => { ) expect(mockIsScopeCompatible).toHaveBeenCalledWith( { userId: 'user-1', workspaceId: TABLE.workspaceId }, - { userId: 'user-1', workspaceId: TABLE.workspaceId } + { workspaceId: TABLE.workspaceId } ) expect(mockReplaceRowsWithTx).toHaveBeenCalledWith( expect.anything(), @@ -404,7 +431,7 @@ describe('replaceProjectedWireRows application command', () => { expect(mockIsScopeCompatible).toHaveBeenCalledWith( { userId: 'user-1', workspaceId: 'workspace-other' }, - { userId: 'user-1', workspaceId: TABLE.workspaceId } + { workspaceId: TABLE.workspaceId } ) expect(mockReplaceRowsWithTx).toHaveBeenCalledWith( expect.anything(), @@ -990,6 +1017,20 @@ describe('table row write secret provenance defaulting', () => { {} ) }) + + it('authorizes a generic webhook by deployment and uses the billing owner for storage attribution', async () => { + await upsertTableRow.execute({ + principal: GENERIC_WEBHOOK_EXECUTOR, + input: { tableId: TABLE.id, data: { name: 'Ada' } }, + }) + + expect(mockUpsertRow).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'billing-owner-1' }), + TABLE, + expect.any(String), + {} + ) + }) }) /** diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index ebf52c121d6..3812102e85c 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -1,10 +1,6 @@ import { isDeepStrictEqual } from 'node:util' import { AuditAction, AuditResourceType } from '@sim/audit' -import { - type Principal, - requirePrincipalSubjectUserId, - resolvePrincipalAttribution, -} from '@sim/auth/principal' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { db } from '@sim/db' import { getRequestContext } from '@sim/logger' import { generateId } from '@sim/utils/id' @@ -135,8 +131,8 @@ interface TableResult { type TableRowsProvenance = Awaited> async function loadAuthorizedRowsProvenance( - principal: Parameters[0], workspaceId: string, + attributedUserId: string, // The loader reads only id, updatedAt and the selected values, so a row // without its executions sidecar is enough — see `TABLE_ROW_SIDECAR_SELECTION`. rows: TableRowSummary[], @@ -149,7 +145,7 @@ async function loadAuthorizedRowsProvenance( // which is how the unmigrated `rows`/`query` routes have always behaved. rows.map((row) => ({ id: row.id, updatedAt: row.updatedAt, selectedValues: row.data })), { - userId: requirePrincipalSubjectUserId(principal), + userId: attributedUserId, workspaceId, } ) @@ -478,8 +474,8 @@ export const queryTableRows = defineAuthorizedTableUseCase({ table: context.table, ...result, secretProvenance: await loadAuthorizedRowsProvenance( - principal, context.workspaceId, + actorUserId(principal, context.billedAccountUserId), result.rows, input.includePersistedSecretProvenance ), @@ -559,8 +555,8 @@ export const readTableRow = defineAuthorizedTableUseCase({ row, ...(runState ? { runState } : {}), secretProvenance: await loadAuthorizedRowsProvenance( - principal, context.workspaceId, + actorUserId(principal, context.billedAccountUserId), [row], input.includePersistedSecretProvenance ), @@ -844,7 +840,6 @@ function projectedRowsForTable( function projectedRowsSecretProvenance( rows: RowData[], - principal: Parameters[0], workspaceId: string, policy: ReplaceProjectedWireRowsInput['secretProvenance'] ): TableRowSecretProvenanceWrite[] { @@ -853,7 +848,6 @@ function projectedRowsSecretProvenance( if (!registry) return rows.map(createUnknownTableRowSecretProvenance) const destinationScope = { - userId: requirePrincipalSubjectUserId(principal), workspaceId, } return rows.map((row) => { @@ -911,7 +905,6 @@ export const replaceProjectedWireRows = defineAuthorizedTableUseCase({ userId: actorUserId(principal, context.billedAccountUserId), secretProvenance: projectedRowsSecretProvenance( provenanceRows, - principal, context.workspaceId, input.secretProvenance ), @@ -1017,8 +1010,8 @@ export const updateTableRow = defineAuthorizedTableUseCase({ row, changed: Object.keys(data).length > 0, secretProvenance: await loadAuthorizedRowsProvenance( - principal, context.workspaceId, + actorUserId(principal, context.billedAccountUserId), [row], input.includePersistedSecretProvenance ), @@ -1308,8 +1301,8 @@ export const upsertTableRow = defineAuthorizedTableUseCase({ row: result.row, operation: result.operation, secretProvenance: await loadAuthorizedRowsProvenance( - principal, context.workspaceId, + actorUserId(principal, context.billedAccountUserId), [result.row], input.includePersistedSecretProvenance ), diff --git a/apps/sim/lib/webhooks/execution-principal.test.ts b/apps/sim/lib/webhooks/execution-principal.test.ts new file mode 100644 index 00000000000..347da65270c --- /dev/null +++ b/apps/sim/lib/webhooks/execution-principal.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + assertWebhookExecutionPrincipal, + createWebhookExecutionPrincipal, +} from '@/lib/webhooks/execution-principal' + +describe('webhook execution principals', () => { + it('represents a generic webhook without inventing a person', () => { + const principal = createWebhookExecutionPrincipal({ + webhookId: 'webhook-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + provider: 'generic', + }) + + expect(principal.subject).toBeUndefined() + expect(() => + assertWebhookExecutionPrincipal(principal, { + webhookId: 'webhook-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + provider: 'generic', + }) + ).not.toThrow() + }) + + it('preserves a verified external webhook actor', () => { + const subject = { + kind: 'external_user' as const, + provider: 'slack', + tenantId: 'tenant-1', + subjectId: 'subject-1', + } + const principal = createWebhookExecutionPrincipal({ + webhookId: 'webhook-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + provider: 'slack', + subject, + }) + + expect(principal.subject).toEqual(subject) + }) + + it('rejects an external actor from another provider', () => { + expect(() => + createWebhookExecutionPrincipal({ + webhookId: 'webhook-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + provider: 'generic', + subject: { + kind: 'external_user', + provider: 'slack', + tenantId: 'tenant-1', + subjectId: 'subject-1', + }, + }) + ).toThrow('Webhook execution subject provider must match the webhook provider') + }) +}) diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 40cfe21adec..0b435434183 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -493,6 +493,11 @@ export function resolvePrincipalAuditAttribution(principal: Principal): Principa } } +/** + * Projects an already-authorized principal into a legacy user attribution field. + * A workspace billing owner may fill that field for actorless delegated execution, + * but never changes the principal, audit actor, or authorization decision. + */ export function resolvePrincipalAttribution( principal: Principal, context: PrincipalAttributionContext = {} @@ -512,9 +517,13 @@ export function resolvePrincipalAttribution( } case 'system': throw new Error('System principals do not support user attribution') - case 'delegated': - if (!actor.subjectUserId) throw new PrincipalSubjectUserRequiredError(actor.kind) - return { actor, attributedUserId: actor.subjectUserId } + case 'delegated': { + if (actor.subjectUserId) return { actor, attributedUserId: actor.subjectUserId } + if (actor.serviceId !== 'executor') throw new PrincipalSubjectUserRequiredError(actor.kind) + const attributedUserId = context.workspaceBillingOwnerUserId + if (!attributedUserId) throw new PrincipalSubjectUserRequiredError(actor.kind) + return { actor, attributedUserId } + } case 'credential_group_enrollment': throw new PrincipalSubjectUserRequiredError(actor.kind) }