diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 6bdae38ab55..e3917a7aa3c 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -6,6 +6,7 @@ import { hybridAuthMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' vi.mock('@sim/logger', () => ({ @@ -370,6 +371,39 @@ describe('File Serve API Route', () => { expect(mockVerifyFileAccess).not.toHaveBeenCalled() }) + it('serves an actorless workflow file without synthesizing a user owner', async () => { + const principal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'test-workspace-id', + workflowId: 'workflow-1', + }, + }) + mockResolveStoredFileContext.mockResolvedValue('workspace') + mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id') + mockAuthenticateWorkspaceFile.mockResolvedValue(principal) + + const response = await GET( + new NextRequest( + 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/report.pdf' + ), + { + params: Promise.resolve({ + path: ['workspace', 'test-workspace-id', 'report.pdf'], + }), + } + ) + + expect(response.status).toBe(200) + expect(mockResolveServableDocBytes).toHaveBeenCalledWith( + expect.objectContaining({ + filePrincipal: principal, + ownerKey: 'workspace:test-workspace-id', + }) + ) + }) + it('serves a mothership chat attachment stored under a workspace key', async () => { /** * The attachment shares the `workspace/…` prefix but is recorded as diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 71fdb72a46f..903461ed65d 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -1,4 +1,4 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { type Principal, resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' @@ -328,7 +328,8 @@ async function handleWorkspaceFile( input: { key, assertedWorkspaceId: workspaceId }, request, }) - const ownerKey = `user:${requirePrincipalSubjectUserId(principal)}` + const subjectUserId = resolvePrincipalSubjectUserId(principal) + const ownerKey = subjectUserId ? `user:${subjectUserId}` : `workspace:${workspaceId}` const resolved = await resolveServableBytes({ buffer: content, filename: file.name, diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index 969c620e32a..a9130456472 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -48,12 +48,12 @@ export const PATCH = defineInternalJsonRoute({ reason: 'Preserve existing internal connector-update behavior', }), errorPolicy: internalKnowledgeErrorPolicies.connectors, - mapInput: ({ params, body }, { principal, request }) => ({ + mapInput: ({ params, body }, { principal, request, authTransport }) => ({ connectorId: params.connectorId, knowledgeBaseId: params.id, updates: body, resolveBillingAttribution: (workspaceId: string) => - resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId, authTransport), source: 'ui' as const, }), useCase: updateKnowledgeConnector, diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index 9862922bcad..c0732879336 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -19,12 +19,12 @@ export const POST = defineInternalJsonRoute({ reason: 'Preserve existing internal connector-sync behavior', }), errorPolicy: internalKnowledgeErrorPolicies.connectors, - mapInput: ({ params, query }, { principal, request }) => ({ + mapInput: ({ params, query }, { principal, request, authTransport }) => ({ connectorId: params.connectorId, knowledgeBaseId: params.id, rehydrate: query.rehydrate, resolveBillingAttribution: (workspaceId: string) => - resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId, authTransport), source: 'ui' as const, }), useCase: syncKnowledgeConnector, diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index b28042f6481..8d6bfa1d3ff 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -42,7 +42,7 @@ export const POST = defineInternalJsonRoute({ reason: 'Preserve existing internal connector-create behavior', }), errorPolicy: internalKnowledgeErrorPolicies.connectors, - mapInput: ({ params, body }, { principal, request }) => ({ + mapInput: ({ params, body }, { principal, request, authTransport }) => ({ knowledgeBaseId: params.id, connectorType: body.connectorType, credentialId: body.credentialId, @@ -50,7 +50,7 @@ export const POST = defineInternalJsonRoute({ sourceConfig: body.sourceConfig, syncIntervalMinutes: body.syncIntervalMinutes, resolveBillingAttribution: (workspaceId: string) => - resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId, authTransport), source: 'ui' as const, }), useCase: createKnowledgeConnector, 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 407f9b48d2f..459970da801 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 @@ -5,7 +5,11 @@ import { getKnowledgeChunkContract, updateKnowledgeChunkContract, } from '@/lib/api/contracts/knowledge' -import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { + defineInternalJsonRoute, + type InternalAuthTransport, + internalRateLimits, +} from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { internalKnowledgeAuthType, @@ -32,13 +36,19 @@ function resolveContentProvenance( principal: Principal, payload: unknown, workspaceId: string | undefined, - includeContent: boolean + includeContent: boolean, + authTransport: InternalAuthTransport | undefined ) { const resolved = resolveKnowledgeWriteSecretProvenance({ headers: request.headers, payload, - authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request.headers, principal, workspaceId), + authType: internalKnowledgeAuthType(authTransport), + userId: internalKnowledgeProvenanceUserId( + request.headers, + principal, + workspaceId, + authTransport + ), ...(workspaceId ? { workspaceId } : {}), selectionKeys: includeContent ? ['chunk-content'] : [], }) @@ -61,11 +71,16 @@ export const GET = defineInternalJsonRoute({ }), useCase: readKnowledgeChunk, present: ({ chunk }) => ({ success: true as const, data: toInternalKnowledgeChunk(chunk) }), - finalizeResponse: ({ request, principal, result, body }) => + finalizeResponse: ({ request, principal, result, body, authTransport }) => finalizeKnowledgePersistedResponse({ headers: request.headers, - authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), + authType: internalKnowledgeAuthType(authTransport), + userId: internalKnowledgeProvenanceUserId( + request.headers, + principal, + result.workspaceId, + authTransport + ), workspaceId: result.workspaceId, body, chunks: [ @@ -87,22 +102,34 @@ export const PUT = defineInternalJsonRoute({ reason: 'Preserve existing internal chunk-update behavior', }), errorPolicy: internalKnowledgeErrorPolicies.chunks, - mapInput: ({ params, body }, { principal, request }) => ({ + mapInput: ({ params, body }, { principal, request, authTransport }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, chunkId: params.chunkId, content: body.content, enabled: body.enabled, resolveContentProvenance: ({ workspaceId }: { workspaceId?: string }) => - resolveContentProvenance(request, principal, body, workspaceId, body.content !== undefined), + resolveContentProvenance( + request, + principal, + body, + workspaceId, + body.content !== undefined, + authTransport + ), }), useCase: updateKnowledgeChunk, present: ({ chunk }) => ({ success: true as const, data: toInternalKnowledgeChunk(chunk) }), - finalizeResponse: ({ request, principal, result, body }) => + finalizeResponse: ({ request, principal, result, body, authTransport }) => finalizeKnowledgePersistedResponse({ headers: request.headers, - authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), + authType: internalKnowledgeAuthType(authTransport), + userId: internalKnowledgeProvenanceUserId( + request.headers, + principal, + result.workspaceId, + authTransport + ), workspaceId: result.workspaceId, body, chunks: [ diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.test.ts index a655c2c290b..0a54e11ec57 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.test.ts @@ -4,6 +4,7 @@ import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ list: vi.fn(), @@ -32,8 +33,9 @@ vi.mock('@/lib/knowledge/api/secret-provenance', () => ({ resolveKnowledgeWriteSecretProvenance: vi.fn(), })) +import { internalKnowledgeSessionOrExecutorAuth } from '@/lib/knowledge/api/route-policies' import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' -import { GET } from '@/app/api/knowledge/[id]/documents/[documentId]/chunks/route' +import { GET, POST } from '@/app/api/knowledge/[id]/documents/[documentId]/chunks/route' const params = () => ({ params: Promise.resolve({ id: 'knowledge-1', documentId: 'document-1' }), @@ -60,4 +62,54 @@ describe('/api/knowledge/[id]/documents/[documentId]/chunks internal route compo retryAfter: 5, }) }) + + it('passes the executor transport workspace into chunk reads', async () => { + const principal = createTestRuntimePrincipal() + vi.spyOn( + internalKnowledgeSessionOrExecutorAuth, + 'authenticateWithTransport' + ).mockResolvedValueOnce({ + principal, + transport: 'executor_jwt', + executionWorkspaceId: 'workspace-canonical', + }) + mocks.list.mockResolvedValueOnce({ + chunks: [], + pagination: { total: 0, limit: 50, offset: 0, hasMore: false }, + workspaceId: 'workspace-canonical', + documentId: 'document-1', + }) + + const response = await GET(createMockRequest('GET'), params()) + + expect(response.status).toBe(200) + expect(mocks.list.mock.calls[0][0]).toMatchObject({ + principal, + input: { assertedWorkspaceId: 'workspace-canonical' }, + }) + }) + + it('passes the executor transport workspace into chunk writes', async () => { + const principal = createTestRuntimePrincipal() + vi.spyOn( + internalKnowledgeSessionOrExecutorAuth, + 'authenticateWithTransport' + ).mockResolvedValueOnce({ + principal, + transport: 'executor_jwt', + executionWorkspaceId: 'workspace-canonical', + }) + mocks.create.mockRejectedValueOnce(new Error('stop after input mapping')) + + const response = await POST( + createMockRequest('POST', { content: 'hello', enabled: true }), + params() + ) + + expect(response.status).toBe(500) + expect(mocks.create.mock.calls[0][0]).toMatchObject({ + principal, + input: { assertedWorkspaceId: 'workspace-canonical' }, + }) + }) }) 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 8123a11cc1a..f38a916dec0 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 @@ -5,7 +5,12 @@ import { createKnowledgeChunkContract, listKnowledgeChunksContract, } from '@/lib/api/contracts/knowledge' -import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { + defineInternalJsonRoute, + type InternalAuthTransport, + internalRateLimits, + resolveInternalAuthWorkspaceId, +} from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { internalKnowledgeAuthType, @@ -33,13 +38,19 @@ function resolveContentProvenance( principal: Principal, payload: unknown, workspaceId: string | undefined, - includeContent: boolean + includeContent: boolean, + authTransport: InternalAuthTransport | undefined ) { const resolved = resolveKnowledgeWriteSecretProvenance({ headers: request.headers, payload, - authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request.headers, principal, workspaceId), + authType: internalKnowledgeAuthType(authTransport), + userId: internalKnowledgeProvenanceUserId( + request.headers, + principal, + workspaceId, + authTransport + ), ...(workspaceId ? { workspaceId } : {}), selectionKeys: includeContent ? ['chunk-content'] : [], }) @@ -55,9 +66,14 @@ export const GET = defineInternalJsonRoute({ operation: knowledgeOperations.listChunks, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal chunk-list behavior' }), errorPolicy: internalKnowledgeErrorPolicies.chunkList, - mapInput: ({ params, query }) => ({ + mapInput: ({ params, query }, { authTransport, executionWorkspaceId }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + undefined + ), ...query, }), useCase: listKnowledgeChunks, @@ -66,11 +82,16 @@ export const GET = defineInternalJsonRoute({ data: chunks.map(toInternalKnowledgeChunk), pagination, }), - finalizeResponse: ({ request, principal, result, body }) => + finalizeResponse: ({ request, principal, result, body, authTransport }) => finalizeKnowledgePersistedResponse({ headers: request.headers, - authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), + authType: internalKnowledgeAuthType(authTransport), + userId: internalKnowledgeProvenanceUserId( + request.headers, + principal, + result.workspaceId, + authTransport + ), workspaceId: result.workspaceId, body, chunks: result.chunks.map((chunk) => ({ @@ -90,20 +111,25 @@ export const POST = defineInternalJsonRoute({ reason: 'Preserve existing internal chunk-create behavior', }), errorPolicy: internalKnowledgeErrorPolicies.chunks, - mapInput: ({ params, body }, { principal, request }) => ({ + mapInput: ({ params, body }, { principal, request, authTransport, executionWorkspaceId }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + undefined + ), content: body.content, enabled: body.enabled, resolveContentProvenance: ({ workspaceId }: { workspaceId?: string }) => - resolveContentProvenance(request, principal, body, workspaceId, true), + resolveContentProvenance(request, principal, body, workspaceId, true, authTransport), }), useCase: createKnowledgeChunk, present: ({ chunk }) => ({ success: true as const, data: toInternalKnowledgeChunk(chunk) }), - finalizeResponse: ({ request, principal, result, body }) => + finalizeResponse: ({ request, principal, result, body, authTransport }) => finalizeKnowledgeProvenanceResponse({ headers: request.headers, - authType: internalKnowledgeAuthType(principal), + authType: internalKnowledgeAuthType(authTransport), userId: result.userId, workspaceId: result.workspaceId, body, @@ -117,9 +143,14 @@ export const PATCH = defineInternalJsonRoute({ operation: knowledgeOperations.bulkChunks, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal bulk-chunk behavior' }), errorPolicy: internalKnowledgeErrorPolicies.chunks, - mapInput: ({ params, body }) => ({ + mapInput: ({ params, body }, { authTransport, executionWorkspaceId }) => ({ knowledgeBaseId: params.id, documentId: params.documentId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + undefined + ), ...body, }), useCase: bulkUpdateKnowledgeChunks, 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 9bc8d8999ad..326403bb340 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -41,11 +41,16 @@ export const GET = defineInternalJsonRoute({ success: true as const, data: toInternalKnowledgeDocument(document), }), - finalizeResponse: ({ request, principal, result, body }) => + finalizeResponse: ({ request, principal, result, body, authTransport }) => finalizeKnowledgePersistedResponse({ headers: request.headers, - authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), + authType: internalKnowledgeAuthType(authTransport), + userId: internalKnowledgeProvenanceUserId( + request.headers, + principal, + result.workspaceId, + authTransport + ), workspaceId: result.workspaceId, body, documents: [ @@ -66,7 +71,7 @@ export const PUT = defineInternalJsonRoute({ reason: 'Preserve existing internal document-update behavior', }), errorPolicy: internalKnowledgeErrorPolicies.documents, - mapInput: ({ params, body }, { principal, request }) => { + mapInput: ({ params, body }, { principal, request, authTransport }) => { const { markFailedDueToTimeout, retryProcessing, ...updates } = body return { knowledgeBaseId: params.id, @@ -75,7 +80,7 @@ export const PUT = defineInternalJsonRoute({ markFailedDueToTimeout, retryProcessing, resolveBillingAttribution: (workspaceId: string) => - resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), + resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId, authTransport), source: 'ui', } }, diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index ce22bfe43ac..eaf5347fafc 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -56,11 +56,16 @@ export const GET = defineInternalJsonRoute({ pagination, }, }), - finalizeResponse: ({ request, principal, result, body }) => + finalizeResponse: ({ request, principal, result, body, authTransport }) => finalizeKnowledgePersistedResponse({ headers: request.headers, - authType: internalKnowledgeAuthType(principal), - userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId), + authType: internalKnowledgeAuthType(authTransport), + userId: internalKnowledgeProvenanceUserId( + request.headers, + principal, + result.workspaceId, + authTransport + ), workspaceId: result.workspaceId, body, documents: result.documents.map((document) => ({ diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index 5f3dcc17421..fff471fff13 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -4,6 +4,7 @@ import { internalErrorResponse, internalOrchestrationErrorPolicy, internalRateLimits, + resolveInternalAuthWorkspaceId, } from '@/lib/api/server/routes' import { internalLogsSessionOrExecutorAuth } from '@/lib/logs/api/route-policies' import { logOperations } from '@/lib/logs/application/operations' @@ -20,8 +21,12 @@ export const GET = defineInternalJsonRoute({ operation: logOperations.readDetail, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal log detail behavior' }), errorPolicy, - mapInput: ({ params, query }, { principal, request }) => ({ - workspaceId: principal.kind === 'delegated' ? principal.workspaceId : query.workspaceId, + mapInput: ({ params, query }, { request, authTransport, executionWorkspaceId }) => ({ + workspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + query.workspaceId + ), lookupColumn: 'id' as const, lookupValue: params.id, signal: request.signal, diff --git a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts index 28182c6939f..26888182743 100644 --- a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts @@ -4,6 +4,7 @@ import { internalErrorResponse, internalOrchestrationErrorPolicy, internalRateLimits, + resolveInternalAuthWorkspaceId, } from '@/lib/api/server/routes' import { internalLogsSessionOrExecutorAuth } from '@/lib/logs/api/route-policies' import { logOperations } from '@/lib/logs/application/operations' @@ -20,8 +21,12 @@ export const GET = defineInternalJsonRoute({ operation: logOperations.readDetail, rateLimit: internalRateLimits.none({ reason: 'Preserve existing execution log detail behavior' }), errorPolicy, - mapInput: ({ params, query }, { principal, request }) => ({ - workspaceId: principal.kind === 'delegated' ? principal.workspaceId : query.workspaceId, + mapInput: ({ params, query }, { request, authTransport, executionWorkspaceId }) => ({ + workspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + query.workspaceId + ), lookupColumn: 'executionId' as const, lookupValue: params.executionId, signal: request.signal, diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 102dc979ad7..81ee9428f0b 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -4,6 +4,7 @@ import { internalErrorResponse, internalOrchestrationErrorPolicy, internalRateLimits, + resolveInternalAuthWorkspaceId, } from '@/lib/api/server/routes' import { internalLogsSessionOrExecutorAuth } from '@/lib/logs/api/route-policies' import { listLogsUseCase } from '@/lib/logs/application/list-logs' @@ -20,9 +21,13 @@ export const GET = defineInternalJsonRoute({ operation: logOperations.list, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal logs list behavior' }), errorPolicy, - mapInput: ({ query }, { principal, request }) => ({ + mapInput: ({ query }, { request, authTransport, executionWorkspaceId }) => ({ ...query, - workspaceId: principal.kind === 'delegated' ? principal.workspaceId : query.workspaceId, + workspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + query.workspaceId + ), signal: request.signal, }), useCase: listLogsUseCase, diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts index 97063c761e8..d02e28932f5 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.test.ts @@ -106,6 +106,9 @@ function createPausedExecution(overrides: PausedExecutionOverrides = {}) { 'billingAttribution' in overrides ? overrides.billingAttribution : structuredClone(PERSISTED_ATTRIBUTION) + const snapshotWorkflowId = overrides.snapshotWorkflowId ?? WORKFLOW_ID + const snapshotExecutionId = overrides.snapshotExecutionId ?? EXECUTION_ID + const snapshotActorUserId = overrides.snapshotActorUserId ?? PERSISTED_ACTOR_ID return { id: 'paused-execution-1', @@ -113,20 +116,29 @@ function createPausedExecution(overrides: PausedExecutionOverrides = {}) { executionId: overrides.executionId ?? EXECUTION_ID, executionSnapshot: { snapshot: JSON.stringify({ - version: 1, + version: 2, metadata: { requestId: 'request-original', - workflowId: overrides.snapshotWorkflowId ?? WORKFLOW_ID, - executionId: overrides.snapshotExecutionId ?? EXECUTION_ID, + workflowId: snapshotWorkflowId, + executionId: snapshotExecutionId, workspaceId: overrides.snapshotWorkspaceId ?? WORKSPACE_ID, - userId: overrides.snapshotActorUserId ?? PERSISTED_ACTOR_ID, + userId: snapshotActorUserId, principal: { - version: 1, + version: 2, principal: { kind: 'session', - userId: overrides.snapshotActorUserId ?? PERSISTED_ACTOR_ID, + userId: snapshotActorUserId, sessionId: 'session-original', }, + executionMetadata: { + executionId: snapshotExecutionId, + rootWorkflowId: snapshotWorkflowId, + currentWorkflow: { + workflowId: snapshotWorkflowId, + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, + }, }, billingAttribution, triggerType: 'manual', diff --git a/apps/sim/app/api/table/[tableId]/query/route.test.ts b/apps/sim/app/api/table/[tableId]/query/route.test.ts index cd372995ef8..dba8292a902 100644 --- a/apps/sim/app/api/table/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/query/route.test.ts @@ -18,7 +18,10 @@ const { mocks, MockTableV2FeatureDisabledError } = vi.hoisted(() => { }) vi.mock('@/lib/table/api', () => ({ - internalTableSessionOrExecutorAuth: { authenticate: mocks.authenticate }, + internalTableSessionOrExecutorAuth: { + authenticate: vi.fn(), + authenticateWithTransport: mocks.authenticate, + }, })) vi.mock('@/lib/table/api/row-route-policies', () => ({ @@ -63,22 +66,25 @@ const ROW = { function sessionPrincipal() { mocks.authenticate.mockResolvedValue({ - kind: 'session', - userId: 'user-1', - sessionId: 'session-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + transport: 'session', }) } function executorPrincipal() { mocks.authenticate.mockResolvedValue({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-01-01'), - expiresAt: new Date('2026-01-02'), + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-canonical', + delegationId: 'delegation-1', + audience: 'sim:tables', + issuedAt: new Date('2026-01-01'), + expiresAt: new Date('2026-01-02'), + }, + transport: 'executor_jwt', + executionWorkspaceId: 'workspace-canonical', }) } diff --git a/apps/sim/app/api/table/[tableId]/query/route.ts b/apps/sim/app/api/table/[tableId]/query/route.ts index 0826de5b7f2..162cc83c520 100644 --- a/apps/sim/app/api/table/[tableId]/query/route.ts +++ b/apps/sim/app/api/table/[tableId]/query/route.ts @@ -1,5 +1,9 @@ import { rowQueryContract, TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' -import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { + defineInternalJsonRoute, + internalRateLimits, + resolveInternalAuthWorkspaceId, +} from '@/lib/api/server/routes' import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { internalTableV2QueryErrorPolicy } from '@/lib/table/api/row-route-policies' import { tableOperations } from '@/lib/table/application/operations' @@ -8,7 +12,7 @@ import { finalizeTableRowsProvenance, negotiateTableRowsProvenance, } from '@/app/api/table/row-secret-provenance' -import { presentQueryRowForPrincipal } from '@/app/api/table/row-wire' +import { presentQueryRowForKeying, rowKeyingForAuthTransport } from '@/app/api/table/row-wire' export const POST = defineInternalJsonRoute({ contract: rowQueryContract, @@ -19,9 +23,13 @@ export const POST = defineInternalJsonRoute({ }), errorPolicy: internalTableV2QueryErrorPolicy, parseOptions: { maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES }, - mapInput: ({ params, body }, { principal, request }) => ({ + mapInput: ({ params, body }, { request, authTransport, executionWorkspaceId }) => ({ tableId: params.tableId, - assertedWorkspaceId: principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), predicate: body.predicate, sort: body.sort, columns: body.columns, @@ -33,15 +41,15 @@ export const POST = defineInternalJsonRoute({ requireV2Feature: true, includePersistedSecretProvenance: negotiateTableRowsProvenance( request, - principal.kind === 'delegated' + authTransport === 'executor_jwt' ), }), useCase: queryTableRows, - present: (result, { principal }) => ({ + present: (result, { authTransport }) => ({ success: true as const, data: { rows: result.rows.map((row) => - presentQueryRowForPrincipal(row, result.table.schema, principal) + presentQueryRowForKeying(row, result.table.schema, rowKeyingForAuthTransport(authTransport)) ), rowCount: result.rowCount, totalCount: result.totalCount, diff --git a/apps/sim/app/api/table/[tableId]/route.test.ts b/apps/sim/app/api/table/[tableId]/route.test.ts index eea9e53d5cf..792c0118b71 100644 --- a/apps/sim/app/api/table/[tableId]/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/route.test.ts @@ -4,6 +4,7 @@ import { hybridAuthMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const { mockCheckAccess, @@ -15,6 +16,7 @@ const { mockFindActiveFolder, mockGetLimits, mockAuthenticate, + mockAuthenticateWithTransport, mockReadTable, } = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), @@ -26,11 +28,15 @@ const { mockFindActiveFolder: vi.fn(), mockGetLimits: vi.fn(), mockAuthenticate: vi.fn(), + mockAuthenticateWithTransport: vi.fn(), mockReadTable: vi.fn(), })) vi.mock('@/lib/table/api', () => ({ - internalTableSessionOrExecutorAuth: { authenticate: mockAuthenticate }, + internalTableSessionOrExecutorAuth: { + authenticate: mockAuthenticate, + authenticateWithTransport: mockAuthenticateWithTransport, + }, internalTableErrorPolicies: { concealTableAuthorization: { project: () => null }, }, @@ -182,15 +188,16 @@ describe('PATCH /api/table/[tableId] folder moves', () => { describe('GET /api/table/[tableId] application adapter', () => { beforeEach(() => { vi.clearAllMocks() - mockAuthenticate.mockResolvedValue({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-01-01'), - expiresAt: new Date('2026-01-02'), + const principal = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + }) + mockAuthenticate.mockResolvedValue(principal) + mockAuthenticateWithTransport.mockResolvedValue({ + principal, + transport: 'executor_jwt', + executionWorkspaceId: 'workspace-canonical', }) mockReadTable.mockResolvedValue({ table: { diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 2e0a6286181..4b162d4ff86 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -10,6 +10,7 @@ import { defineInternalJsonRoute, internalErrorResponse, internalRateLimits, + resolveInternalAuthWorkspaceId, } from '@/lib/api/server/routes' import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' @@ -53,9 +54,13 @@ export const GET = defineInternalJsonRoute({ ...internalTableErrorPolicies.concealTableAuthorization, unhandled: () => internalErrorResponse(500, { error: 'Failed to get table' }), }, - mapInput: ({ params, query }, { principal }) => ({ + mapInput: ({ params, query }, { authTransport, executionWorkspaceId }) => ({ tableId: params.tableId, - workspaceId: principal.kind === 'delegated' ? principal.workspaceId : query.workspaceId, + workspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + query.workspaceId + ), }), useCase: readTableDetailsUseCase, present: ({ table, maxRows }) => ({ diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts index cf4935b3b1e..b6548b50636 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.test.ts @@ -37,7 +37,13 @@ vi.mock('@/lib/table/application/rows', async (importOriginal) => { vi.mock('@/lib/table/api', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, internalTableSessionOrExecutorAuth: { authenticate: mocks.authenticate } } + return { + ...actual, + internalTableSessionOrExecutorAuth: { + authenticate: vi.fn(), + authenticateWithTransport: mocks.authenticate, + }, + } }) import { InternalUnauthenticatedError } from '@/lib/api/server/routes' @@ -74,22 +80,25 @@ const ROW = { function sessionPrincipal() { mocks.authenticate.mockResolvedValue({ - kind: 'session', - userId: 'user-1', - sessionId: 'session-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + transport: 'session', }) } function executorPrincipal() { 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'), + principal: { + 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'), + }, + transport: 'executor_jwt', + executionWorkspaceId: WORKSPACE_ID, }) } diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 7fa9f78e1cd..f2cf5979973 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -4,7 +4,11 @@ import { getTableRowContract, updateTableRowContract, } from '@/lib/api/contracts/tables' -import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { + defineInternalJsonRoute, + internalRateLimits, + resolveInternalAuthWorkspaceId, +} from '@/lib/api/server/routes' import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { internalTableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' import { tableOperations } from '@/lib/table/application/operations' @@ -15,7 +19,7 @@ import { negotiateTableRowsProvenance, readTableRowProvenanceEnvelope, } from '@/app/api/table/row-secret-provenance' -import { presentRowForPrincipal, rowKeyingForPrincipal } from '@/app/api/table/row-wire' +import { presentRowForKeying, rowKeyingForAuthTransport } from '@/app/api/table/row-wire' export const dynamic = 'force-dynamic' @@ -29,19 +33,25 @@ export const GET = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: internalTableRowsErrorPolicy, - mapInput: ({ params, query }, { principal, request }) => ({ + mapInput: ({ params, query }, { request, authTransport, executionWorkspaceId }) => ({ tableId: params.tableId, rowId: params.rowId, - assertedWorkspaceId: principal.kind === 'delegated' ? principal.workspaceId : query.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + query.workspaceId + ), includePersistedSecretProvenance: negotiateTableRowsProvenance( request, - principal.kind !== 'session' + authTransport === 'executor_jwt' ), }), useCase: readTableRow, - present: ({ table, row }, { principal }) => ({ + present: ({ table, row }, { authTransport }) => ({ success: true as const, - data: { row: presentRowForPrincipal(row, table.schema, principal) }, + data: { + row: presentRowForKeying(row, table.schema, rowKeyingForAuthTransport(authTransport)), + }, }), finalizeResponse: ({ result }) => finalizeTableRowsProvenance(result.secretProvenance), }) @@ -52,30 +62,33 @@ export const PATCH = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: internalTableRowsErrorPolicy, - mapInput: ({ params, body }, { principal, request }) => { + mapInput: ({ params, body }, { request, authTransport, executionWorkspaceId }) => { return { tableId: params.tableId, rowId: params.rowId, - assertedWorkspaceId: - principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), data: body.data as RowData, - dataKeying: rowKeyingForPrincipal(principal), + dataKeying: rowKeyingForAuthTransport(authTransport), strictWrite: false, // Handed over unresolved: interpreting the selections needs the canonical // schema, which this adapter must not load. secretProvenanceEnvelope: readTableRowProvenanceEnvelope(request, body), includePersistedSecretProvenance: negotiateTableRowsProvenance( request, - principal.kind !== 'session' + authTransport === 'executor_jwt' ), actorClientId: readClientId(request), } }, useCase: updateTableRow, - present: ({ table, row }, { principal }) => ({ + present: ({ table, row }, { authTransport }) => ({ success: true as const, data: { - row: presentRowForPrincipal(row, table.schema, principal), + row: presentRowForKeying(row, table.schema, rowKeyingForAuthTransport(authTransport)), message: 'Row updated successfully', }, }), @@ -88,10 +101,14 @@ export const DELETE = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: internalTableRowsErrorPolicy, - mapInput: ({ params, body }, { principal, request }) => ({ + mapInput: ({ params, body }, { request, authTransport, executionWorkspaceId }) => ({ tableId: params.tableId, rowId: params.rowId, - assertedWorkspaceId: principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), actorClientId: readClientId(request), }), useCase: deleteTableRow, diff --git a/apps/sim/app/api/table/[tableId]/rows/route.test.ts b/apps/sim/app/api/table/[tableId]/rows/route.test.ts index e57958b51cf..d6afaa4816f 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.test.ts @@ -15,7 +15,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/table/api', () => ({ - internalTableSessionOrExecutorAuth: { authenticate: mocks.authenticate }, + internalTableSessionOrExecutorAuth: { + authenticate: vi.fn(), + authenticateWithTransport: mocks.authenticate, + }, })) vi.mock('@/lib/table/api/row-route-policies', () => ({ @@ -60,22 +63,25 @@ const routeContext = { params: Promise.resolve({ tableId: 'table-1' }) } function sessionPrincipal() { mocks.authenticate.mockResolvedValue({ - kind: 'session', - userId: 'user-1', - sessionId: 'session-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + transport: 'session', }) } function executorPrincipal() { mocks.authenticate.mockResolvedValue({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-01-01'), - expiresAt: new Date('2026-01-02'), + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-canonical', + delegationId: 'delegation-1', + audience: 'sim:tables', + issuedAt: new Date('2026-01-01'), + expiresAt: new Date('2026-01-02'), + }, + transport: 'executor_jwt', + executionWorkspaceId: 'workspace-canonical', }) } diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 9e4207bb70f..823a528ffc3 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -10,6 +10,7 @@ import { defineInternalJsonRoute, internalErrorResponse, internalRateLimits, + resolveInternalAuthWorkspaceId, } from '@/lib/api/server/routes' import type { Filter, RowData, Sort, SortSpec, TablePredicate } from '@/lib/table' import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' @@ -29,7 +30,7 @@ import { negotiateTableRowsProvenance, readTableRowProvenanceEnvelope, } from '@/app/api/table/row-secret-provenance' -import { presentQueryRowForPrincipal, rowKeyingForPrincipal } from '@/app/api/table/row-wire' +import { presentQueryRowForKeying, rowKeyingForAuthTransport } from '@/app/api/table/row-wire' const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal table rows behavior', @@ -48,17 +49,21 @@ export const POST = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: rowErrorPolicy('Failed to insert row'), - mapInput: ({ params, body }, { principal, request }) => { + mapInput: ({ params, body }, { request, authTransport, executionWorkspaceId }) => { + const rowKeying = rowKeyingForAuthTransport(authTransport) const shared = { tableId: params.tableId, - assertedWorkspaceId: - principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), strictWrite: false, - dataKeying: rowKeyingForPrincipal(principal), + dataKeying: rowKeying, secretProvenanceEnvelope: readTableRowProvenanceEnvelope(request, body), includePersistedSecretProvenance: negotiateTableRowsProvenance( request, - principal.kind === 'delegated' + authTransport === 'executor_jwt' ), } return 'rows' in body @@ -79,12 +84,13 @@ export const POST = defineInternalJsonRoute({ } }, useCase: createTableRows, - present: (result, { principal }) => - result.kind === 'single' + present: (result, { authTransport }) => { + const rowKeying = rowKeyingForAuthTransport(authTransport) + return result.kind === 'single' ? { success: true as const, data: { - row: presentQueryRowForPrincipal(result.row, result.table.schema, principal), + row: presentQueryRowForKeying(result.row, result.table.schema, rowKeying), message: 'Row inserted successfully', }, } @@ -92,12 +98,13 @@ export const POST = defineInternalJsonRoute({ success: true as const, data: { rows: result.rows.map((row) => - presentQueryRowForPrincipal(row, result.table.schema, principal) + presentQueryRowForKeying(row, result.table.schema, rowKeying) ), insertedCount: result.rows.length, message: `Successfully inserted ${result.rows.length} rows`, }, - }, + } + }, finalizeResponse: ({ result }) => finalizeTableRowsProvenance(result.secretProvenance), }) @@ -107,20 +114,23 @@ export const GET = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: rowErrorPolicy('Failed to query rows'), - mapInput: ({ params, query }, { principal, request }) => { + mapInput: ({ params, query }, { request, authTransport, executionWorkspaceId }) => { const filter = query.filter as Filter | TablePredicate | undefined const sort = query.sort as Sort | SortSpec | undefined return { tableId: params.tableId, - assertedWorkspaceId: - principal.kind === 'delegated' ? principal.workspaceId : query.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + query.workspaceId + ), ...(filter && isTablePredicate(filter) ? { predicate: filter } : { legacyFilter: filter as Filter | undefined }), ...(Array.isArray(sort) ? { sort: sort as SortSpec } : { legacySort: sort as Sort | undefined }), - legacyKeying: rowKeyingForPrincipal(principal), + legacyKeying: rowKeyingForAuthTransport(authTransport), limit: query.limit, offset: query.offset, after: query.after, @@ -129,16 +139,16 @@ export const GET = defineInternalJsonRoute({ allowExpandedLimit: true, includePersistedSecretProvenance: negotiateTableRowsProvenance( request, - principal.kind === 'delegated' + authTransport === 'executor_jwt' ), } }, useCase: queryTableRows, - present: (result, { principal }) => ({ + present: (result, { authTransport }) => ({ success: true as const, data: { rows: result.rows.map((row) => - presentQueryRowForPrincipal(row, result.table.schema, principal) + presentQueryRowForKeying(row, result.table.schema, rowKeyingForAuthTransport(authTransport)) ), rowCount: result.rowCount, totalCount: result.totalCount, @@ -156,13 +166,17 @@ export const PUT = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: rowErrorPolicy('Failed to update rows'), - mapInput: ({ params, body }, { principal, request }) => ({ + mapInput: ({ params, body }, { request, authTransport, executionWorkspaceId }) => ({ tableId: params.tableId, - assertedWorkspaceId: principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), filter: body.filter, - filterKeying: rowKeyingForPrincipal(principal), + filterKeying: rowKeyingForAuthTransport(authTransport), data: body.data as RowData, - dataKeying: rowKeyingForPrincipal(principal), + dataKeying: rowKeyingForAuthTransport(authTransport), strictWrite: false, limit: body.limit, secretProvenanceEnvelope: readTableRowProvenanceEnvelope(request, body), @@ -185,22 +199,28 @@ export const DELETE = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: rowErrorPolicy('Failed to delete rows'), - mapInput: ({ params, body }, { principal }) => + mapInput: ({ params, body }, { authTransport, executionWorkspaceId }) => body.rowIds ? { kind: 'ids' as const, tableId: params.tableId, - assertedWorkspaceId: - principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), rowIds: body.rowIds, } : { kind: 'filter' as const, tableId: params.tableId, - assertedWorkspaceId: - principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), filter: body.filter!, - filterKeying: rowKeyingForPrincipal(principal), + filterKeying: rowKeyingForAuthTransport(authTransport), limit: body.limit, }, useCase: deleteTableRows, @@ -238,11 +258,15 @@ export const PATCH = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: rowErrorPolicy('Failed to update rows'), - mapInput: ({ params, body }, { principal, request }) => ({ + mapInput: ({ params, body }, { request, authTransport, executionWorkspaceId }) => ({ tableId: params.tableId, - assertedWorkspaceId: principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), strictWrite: false, - dataKeying: rowKeyingForPrincipal(principal), + dataKeying: rowKeyingForAuthTransport(authTransport), updates: body.updates.map((update) => ({ rowId: update.rowId, data: update.data as RowData, 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 071321e7549..b057174f348 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 @@ -22,7 +22,13 @@ vi.mock('@/lib/table/application/rows', async (importOriginal) => { vi.mock('@/lib/table/api', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, internalTableSessionOrExecutorAuth: { authenticate: mocks.authenticate } } + return { + ...actual, + internalTableSessionOrExecutorAuth: { + authenticate: vi.fn(), + authenticateWithTransport: mocks.authenticate, + }, + } }) import { InternalUnauthenticatedError } from '@/lib/api/server/routes' @@ -64,9 +70,8 @@ const BODY = { workspaceId: WORKSPACE_ID, data: { col_aaa: 'Ada' }, conflictTarg beforeEach(() => { vi.clearAllMocks() mocks.authenticate.mockResolvedValue({ - kind: 'session', - userId: 'user-1', - sessionId: 'session-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + transport: 'session', }) mocks.upsertRow.mockResolvedValue({ table: TABLE, row: ROW, operation: 'insert' }) }) @@ -123,30 +128,25 @@ describe('POST /api/table/[tableId]/rows/upsert', () => { it('tells the use case a workflow execution speaks column names', async () => { mocks.authenticate.mockResolvedValue({ - kind: 'delegated', - serviceId: 'executor', - workspaceId: WORKSPACE_ID, - delegationId: 'delegation-1', - audience: 'table', - issuedAt: new Date('2026-01-01'), - expiresAt: new Date('2099-01-02'), - delegationContext: { - kind: 'workflow_execution', + principal: { + kind: 'system', + serviceId: 'webhook', + workspaceId: WORKSPACE_ID, 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', + webhookId: 'webhook-1', + provider: 'generic', + executionMetadata: { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, }, }, + transport: 'executor_jwt', + executionWorkspaceId: 'workspace-1', }) await POST(request({ ...BODY, data: { Name: 'Ada' }, conflictTarget: 'Name' }), routeContext()) diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts index b9939bb0d6a..29a0a84ea18 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -1,5 +1,9 @@ import { upsertTableRowContract } from '@/lib/api/contracts/tables' -import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { + defineInternalJsonRoute, + internalRateLimits, + resolveInternalAuthWorkspaceId, +} from '@/lib/api/server/routes' import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { internalTableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' import { tableOperations } from '@/lib/table/application/operations' @@ -10,7 +14,7 @@ import { negotiateTableRowsProvenance, readTableRowProvenanceEnvelope, } from '@/app/api/table/row-secret-provenance' -import { presentRowForPrincipal, rowKeyingForPrincipal } from '@/app/api/table/row-wire' +import { presentRowForKeying, rowKeyingForAuthTransport } from '@/app/api/table/row-wire' export const dynamic = 'force-dynamic' @@ -23,11 +27,15 @@ export const POST = defineInternalJsonRoute({ reason: 'Preserve existing internal table upsert behavior', }), errorPolicy: internalTableRowsErrorPolicy, - mapInput: ({ params, body }, { principal, request }) => ({ + mapInput: ({ params, body }, { request, authTransport, executionWorkspaceId }) => ({ tableId: params.tableId, - assertedWorkspaceId: principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + assertedWorkspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), data: body.data as RowData, - dataKeying: rowKeyingForPrincipal(principal), + dataKeying: rowKeyingForAuthTransport(authTransport), strictWrite: false, // The conflict target follows the same keying as the data; the use case // resolves it id-or-name against the canonical schema. @@ -37,14 +45,14 @@ export const POST = defineInternalJsonRoute({ secretProvenanceEnvelope: readTableRowProvenanceEnvelope(request, body), includePersistedSecretProvenance: negotiateTableRowsProvenance( request, - principal.kind !== 'session' + authTransport === 'executor_jwt' ), }), useCase: upsertTableRow, - present: ({ table, row, operation }, { principal }) => ({ + present: ({ table, row, operation }, { authTransport }) => ({ success: true as const, data: { - row: presentRowForPrincipal(row, table.schema, principal), + row: presentRowForKeying(row, table.schema, rowKeyingForAuthTransport(authTransport)), operation, message: `Row ${operation === 'update' ? 'updated' : 'inserted'} successfully`, }, diff --git a/apps/sim/app/api/table/route.test.ts b/apps/sim/app/api/table/route.test.ts index ce7c3e045bb..2a0cfc6ae91 100644 --- a/apps/sim/app/api/table/route.test.ts +++ b/apps/sim/app/api/table/route.test.ts @@ -4,16 +4,21 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ authenticate: vi.fn(), + authenticateWithTransport: vi.fn(), createTable: vi.fn(), listTables: vi.fn(), capture: vi.fn(), })) vi.mock('@/lib/table/api', () => ({ - internalTableSessionOrExecutorAuth: { authenticate: mocks.authenticate }, + internalTableSessionOrExecutorAuth: { + authenticate: mocks.authenticate, + authenticateWithTransport: mocks.authenticateWithTransport, + }, })) vi.mock('@/lib/table/application/tables', () => ({ @@ -47,46 +52,51 @@ const TABLE = { } function sessionPrincipal() { - mocks.authenticate.mockResolvedValue({ + const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1', - }) + } as const + mocks.authenticate.mockResolvedValue(principal) + mocks.authenticateWithTransport.mockResolvedValue({ principal, transport: 'session' }) } function executorPrincipal() { - mocks.authenticate.mockResolvedValue({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-01-01'), - expiresAt: new Date('2026-01-02'), + const principal = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + executionId: 'execution-1', + rootWorkflowId: 'parent-workflow', + }) + mocks.authenticate.mockResolvedValue(principal) + mocks.authenticateWithTransport.mockResolvedValue({ + principal, + transport: 'executor_jwt', + executionWorkspaceId: 'workspace-canonical', }) } function actorlessExecutorPrincipal() { - mocks.authenticate.mockResolvedValue({ - kind: 'delegated', - serviceId: 'executor', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-01-01'), - expiresAt: new Date('2026-01-02'), - delegationContext: { - kind: 'workflow_execution', + const principal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-canonical', workflowId: 'parent-workflow', - principal: { - kind: 'system', - serviceId: 'internal', - workspaceId: 'workspace-canonical', - workflowId: 'parent-workflow', - }, + }, + executionId: 'execution-1', + rootWorkflowId: 'parent-workflow', + currentWorkflow: { + workflowId: 'parent-workflow', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', }, }) + mocks.authenticate.mockResolvedValue(principal) + mocks.authenticateWithTransport.mockResolvedValue({ + principal, + transport: 'executor_jwt', + executionWorkspaceId: 'workspace-canonical', + }) } function post(body: unknown) { @@ -149,8 +159,8 @@ describe('/api/table application adapter', () => { expect(response.status).toBe(200) expect(mocks.createTable.mock.calls[0][0]).toMatchObject({ principal: { - kind: 'delegated', - serviceId: 'executor', + kind: 'system', + serviceId: 'schedule', workspaceId: 'workspace-canonical', }, input: { workspaceId: 'workspace-canonical' }, diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index b7ba0880be1..eeec8d4c15b 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -5,6 +5,7 @@ import { internalErrorResponse, internalOrchestrationErrorPolicy, internalRateLimits, + resolveInternalAuthWorkspaceId, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' @@ -32,8 +33,12 @@ export const POST = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: createErrorPolicy, - mapInput: ({ body }, { principal }) => ({ - workspaceId: principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + mapInput: ({ body }, { authTransport, executionWorkspaceId }) => ({ + workspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + body.workspaceId + ), name: body.name, description: body.description, schema: { columns: body.schema.columns.map(normalizeColumn) }, @@ -86,8 +91,12 @@ export const GET = defineInternalJsonRoute({ auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy: listErrorPolicy, - mapInput: ({ query }, { principal }) => ({ - workspaceId: principal.kind === 'delegated' ? principal.workspaceId : query.workspaceId, + mapInput: ({ query }, { authTransport, executionWorkspaceId }) => ({ + workspaceId: resolveInternalAuthWorkspaceId( + authTransport, + executionWorkspaceId, + query.workspaceId + ), scope: query.scope, }), useCase: listTableDefinitionsUseCase, diff --git a/apps/sim/app/api/table/row-wire.test.ts b/apps/sim/app/api/table/row-wire.test.ts new file mode 100644 index 00000000000..cf55019719b --- /dev/null +++ b/apps/sim/app/api/table/row-wire.test.ts @@ -0,0 +1,22 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { rowKeyingForAuthTransport } from '@/app/api/table/row-wire' + +describe('table row route wire keying', () => { + it('maps the verified session transport to stable column IDs', () => { + expect(rowKeyingForAuthTransport('session')).toBe('ids') + }) + + it('maps the verified executor transport to column names', () => { + expect(rowKeyingForAuthTransport('executor_jwt')).toBe('names') + }) + + it('fails when the route did not provide a verified transport', () => { + expect(() => rowKeyingForAuthTransport(undefined)).toThrow( + 'Table row route requires an authenticated transport' + ) + }) +}) diff --git a/apps/sim/app/api/table/row-wire.ts b/apps/sim/app/api/table/row-wire.ts index 880f5f5695e..dfef2b44bb4 100644 --- a/apps/sim/app/api/table/row-wire.ts +++ b/apps/sim/app/api/table/row-wire.ts @@ -1,4 +1,4 @@ -import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { InternalAuthTransport } from '@/lib/api/server/routes' import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid' import type { Filter, @@ -74,27 +74,21 @@ export function rowWireTranslators( } /** - * The principal kinds the internal table row routes admit — the auth policy - * yields exactly these two. Typed as the union rather than `Principal` so a - * third kind becomes an exhaustiveness error here instead of silently taking - * the name-keyed branch, which would drop every id-keyed cell of a write and - * report success. + * Selects the table-row wire dialect from the route's verified authentication + * transport. The runtime principal remains identity only: a session actor may + * also be executing a workflow, so its principal kind cannot identify which + * HTTP dialect reached this adapter. */ -type TableRowRoutePrincipal = SessionPrincipal | WorkflowExecutionDelegatedPrincipal - -/** - * The internal table routes serve two caller kinds on the same paths, and they - * speak different column keyings: the first-party grid holds the schema it - * rendered and addresses cells by stable id, while a workflow tool execution - * speaks column names, because names are what tool enrichment surfaces to the - * model. Keying is therefore a property of the caller, not of the endpoint. - */ -export function rowKeyingForPrincipal(principal: TableRowRoutePrincipal): TableRowDataKeying { - switch (principal.kind) { +export function rowKeyingForAuthTransport( + authTransport: InternalAuthTransport | undefined +): TableRowDataKeying { + switch (authTransport) { case 'session': return 'ids' - case 'delegated': + case 'executor_jwt': return 'names' + case undefined: + throw new Error('Table row route requires an authenticated transport') } } @@ -103,15 +97,14 @@ export function rowKeyingForPrincipal(principal: TableRowRoutePrincipal): TableR * the stored cells in the caller's keying, plus position, with timestamps * already serialized. See `tableRowWireSchema`, which is its contract. */ -export function presentRowForPrincipal( +export function presentRowForKeying( row: Pick, schema: TableSchema, - principal: TableRowRoutePrincipal + keying: TableRowDataKeying ) { // Only the outbound mapper is needed here; building the full translator set // would also index the schema name→id for inbound paths a presenter cannot reach. - const dataOut = - rowKeyingForPrincipal(principal) === 'names' ? namedRowMapper(schema.columns) : identity + const dataOut = keying === 'names' ? namedRowMapper(schema.columns) : identity return { id: row.id, data: dataOut(row.data), @@ -121,13 +114,12 @@ export function presentRowForPrincipal( } } -export function presentQueryRowForPrincipal( +export function presentQueryRowForKeying( row: TableRow, schema: TableSchema, - principal: TableRowRoutePrincipal + keying: TableRowDataKeying ) { - const dataOut = - rowKeyingForPrincipal(principal) === 'names' ? namedRowMapper(schema.columns) : identity + const dataOut = keying === 'names' ? namedRowMapper(schema.columns) : identity return { id: row.id, data: dataOut(row.data), diff --git a/apps/sim/app/api/table/table-tool-auth.test.ts b/apps/sim/app/api/table/table-tool-auth.test.ts index 6766d18ed60..d897982f761 100644 --- a/apps/sim/app/api/table/table-tool-auth.test.ts +++ b/apps/sim/app/api/table/table-tool-auth.test.ts @@ -5,7 +5,7 @@ * things have to line up for that to work: * * 1. the tool must declare an in-process operation, and - * 2. the operation's policy must admit the `executor` delegated service. + * 2. the operation's policy must admit a runtime workflow principal. */ import { describe, expect, it } from 'vitest' import { tableOperations } from '@/lib/table/application/operations' @@ -29,10 +29,9 @@ describe('executor access to the migrated table row routes', () => { }) it.each(EXECUTOR_ROW_TOOLS)( - '%s runs under an operation that admits the executor', + '%s runs under an operation that admits workflow execution', (_name, _tool, operation) => { - expect(operation.delegatedServices).toContain('executor') - expect(operation.principalKinds).toContain('delegated') + expect(operation.workflowExecution).toBe('allow') } ) }) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts index fe903c3a19f..9d88bd1996f 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts @@ -4,16 +4,17 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { OrchestrationError } from '@/lib/core/orchestration/types' const { InvalidDelegationTokenError, - mockBindExecutorDelegation, + mockBindExecutorDelegationAdmission, mockReadWorkflowDefinition, mockVerifyDelegationToken, } = vi.hoisted(() => ({ InvalidDelegationTokenError: class InvalidDelegationTokenError extends Error {}, - mockBindExecutorDelegation: vi.fn(), + mockBindExecutorDelegationAdmission: vi.fn(), mockReadWorkflowDefinition: vi.fn(), mockVerifyDelegationToken: vi.fn(), })) @@ -24,7 +25,7 @@ vi.mock('@/lib/auth/internal', () => ({ })) vi.mock('@/lib/auth/internal-delegation', () => ({ - bindInternalExecutorDelegation: mockBindExecutorDelegation, + bindInternalExecutorDelegationAdmission: mockBindExecutorDelegationAdmission, InvalidInternalDelegationBindingError: class InvalidInternalDelegationBindingError extends Error {}, })) @@ -34,7 +35,8 @@ vi.mock('@/lib/workflows/application/read-workflow-definition', () => { minimumRole: 'read', workspaceApiKey: 'allow', principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const return { readWorkflowDefinition: { operation, execute: mockReadWorkflowDefinition }, @@ -57,21 +59,11 @@ const SESSION = { session: { id: 'session-123' }, } -const EXECUTOR_PRINCIPAL = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - subjectUserId: 'user-123', - workspaceId: 'workspace-456', - delegationId: 'delegation-123', - audience: 'sim:workflows', - issuedAt: new Date('2026-08-08T00:00:00.000Z'), - expiresAt: new Date('2999-08-08T00:00:00.000Z'), - delegationContext: { - kind: 'workflow_execution' as const, - workflowId: 'origin-workflow', - executionId: 'origin-run', - }, -} +const EXECUTOR_PRINCIPAL = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'user-123', sessionId: 'session-123' }, + rootWorkflowId: 'origin-workflow', + executionId: 'origin-run', +}) function createRequest(bearerToken?: string) { return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { @@ -94,12 +86,18 @@ describe('GET /api/workflows/[id]/deployed', () => { vi.clearAllMocks() authMockFns.mockGetSession.mockResolvedValue(SESSION) mockReadWorkflowDefinition.mockResolvedValue(readResult()) - mockVerifyDelegationToken.mockResolvedValue({ - subjectUserId: 'user-123', - workflowId: 'origin-workflow', - executionId: 'origin-run', + const delegation = { + serviceId: 'executor' as const, + principal: EXECUTOR_PRINCIPAL, + delegationId: 'delegation-123', + issuedAt: new Date('2026-08-08T00:00:00.000Z'), + expiresAt: new Date('2999-08-08T00:00:00.000Z'), + } + mockVerifyDelegationToken.mockResolvedValue(delegation) + mockBindExecutorDelegationAdmission.mockResolvedValue({ + principal: EXECUTOR_PRINCIPAL, + workspaceId: 'workspace-456', }) - mockBindExecutorDelegation.mockResolvedValue(EXECUTOR_PRINCIPAL) }) it('passes the authenticated session principal through the application use case', async () => { @@ -119,9 +117,8 @@ describe('GET /api/workflows/[id]/deployed', () => { const response = await GET(createRequest('signed-token'), routeParams()) expect(response.status).toBe(200) - expect(mockBindExecutorDelegation).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'origin-workflow', executionId: 'origin-run' }), - { audience: 'sim:workflows', resourceScope: undefined } + expect(mockBindExecutorDelegationAdmission).toHaveBeenCalledWith( + expect.objectContaining({ principal: EXECUTOR_PRINCIPAL }) ) expect(mockReadWorkflowDefinition).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index fa82e7b244d..c4a2b638ea8 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -514,7 +514,7 @@ export async function executeWebhookJob( assertWebhookExecutionPrincipal(principal, payload) authenticatedPayload = { ...payload, - principal: payload.principal ?? serializePrincipal(principal), + principal: payload.principal ?? serializePrincipal(principal, 1), } payloadBillingAttribution = assertBillingAttributionSnapshot( authenticatedPayload.billingAttribution diff --git a/apps/sim/executor/execution/engine.test.ts b/apps/sim/executor/execution/engine.test.ts index 49163f6b3ad..d237a0a1af0 100644 --- a/apps/sim/executor/execution/engine.test.ts +++ b/apps/sim/executor/execution/engine.test.ts @@ -4,6 +4,7 @@ import { loggerMock } from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { createTimeoutAbortController, getRemainingExecutionMs } from '@/lib/core/execution-limits' const { mockCancellationSubscribers, mockIsExecutionCancelled } = vi.hoisted(() => ({ @@ -66,12 +67,16 @@ function createMockNode(id: string, blockType = 'test'): DAGNode { } function createMockContext(overrides: Partial = {}): ExecutionContext { + const principal = createTestRuntimePrincipal({ + executionId: 'test-execution', + rootWorkflowId: 'test-workflow', + }) return { workflowId: 'test-workflow', workspaceId: 'test-workspace', executionId: 'test-execution', userId: 'test-user', - principal: { kind: 'session', userId: 'test-user', sessionId: 'test-session' }, + principal, blockStates: new Map(), executedBlocks: new Set(), blockLogs: [], @@ -83,6 +88,7 @@ function createMockContext(overrides: Partial = {}): Execution executionId: 'test-execution', startTime: new Date().toISOString(), pendingBlocks: [], + principal, }, envVars: {}, ...overrides, diff --git a/apps/sim/executor/execution/executor.test.ts b/apps/sim/executor/execution/executor.test.ts index ae4280ce553..3e921b1999e 100644 --- a/apps/sim/executor/execution/executor.test.ts +++ b/apps/sim/executor/execution/executor.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { BlockType } from '@/executor/constants' import { DAGBuilder } from '@/executor/dag/builder' import { DAGExecutor } from '@/executor/execution/executor' @@ -430,16 +431,15 @@ describe('DAGExecutor createExecutionContext useDraftState', () => { }) }) -describe('DAGExecutor executor delegation origin', () => { - it('copies the canonical origin into the runtime execution context', () => { - const executorDelegationOrigin = { - subjectUserId: 'user-1', - workflowId: 'parent-workflow', +describe('DAGExecutor runtime principal', () => { + it('copies the canonical principal into the runtime execution context', () => { + const principal = createTestRuntimePrincipal({ executionId: 'parent-execution', - } + rootWorkflowId: 'parent-workflow', + }) const executor = new DAGExecutor({ workflow: { version: '1', blocks: [], connections: [] }, - contextExtensions: { executorDelegationOrigin }, + contextExtensions: { principal }, }) const { context } = ( @@ -449,6 +449,6 @@ describe('DAGExecutor executor delegation origin', () => { ).createExecutionContext('child-workflow') expect(context.workflowId).toBe('child-workflow') - expect(context.executorDelegationOrigin).toBe(executorDelegationOrigin) + expect(context.principal).toBe(principal) }) }) diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index c567df277c8..1b1f735b282 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -423,7 +423,6 @@ export class DAGExecutor { allowLargeValueWorkflowScope: this.contextExtensions.allowLargeValueWorkflowScope, userId: this.contextExtensions.userId, principal: this.contextExtensions.principal, - executorDelegationOrigin: this.contextExtensions.executorDelegationOrigin, isDeployedContext: this.contextExtensions.isDeployedContext, enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess, piiBlockOutputRedaction: this.contextExtensions.piiBlockOutputRedaction, diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index 5c4e546f87e..c4b77290e81 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import type { DAG, DAGNode } from '@/executor/dag/builder' import { EdgeManager } from '@/executor/execution/edge-manager' import { serializePauseSnapshot } from '@/executor/execution/snapshot-serializer' @@ -9,12 +10,13 @@ import type { ExecutionContext } from '@/executor/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' function createContext(overrides: Partial = {}): ExecutionContext { + const principal = createTestRuntimePrincipal() return { workflowId: 'workflow-1', workspaceId: 'workspace-1', executionId: 'execution-1', userId: 'user-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + principal, blockStates: new Map(), executedBlocks: new Set(), blockLogs: [], @@ -24,7 +26,7 @@ function createContext(overrides: Partial = {}): ExecutionCont workflowId: 'workflow-1', workspaceId: 'workspace-1', userId: 'user-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + principal, triggerType: 'manual', useDraftState: true, startTime: '2026-01-01T00:00:00.000Z', diff --git a/apps/sim/executor/execution/snapshot.test.ts b/apps/sim/executor/execution/snapshot.test.ts index 1b00103f06e..c2db78cbe0e 100644 --- a/apps/sim/executor/execution/snapshot.test.ts +++ b/apps/sim/executor/execution/snapshot.test.ts @@ -1,20 +1,33 @@ +import { + bindPrincipalExecutionMetadata, + type WorkflowExecutionPrincipal, +} from '@sim/auth/principal' import { describe, expect, it } from 'vitest' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' +function bindPrincipal(principal: WorkflowExecutionPrincipal) { + return bindPrincipalExecutionMetadata(principal, { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, + }) +} + const metadata: ExecutionMetadata = { requestId: 'request-1', executionId: 'execution-1', workflowId: 'workflow-1', workspaceId: 'workspace-1', userId: 'user-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + principal: bindPrincipal({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }), triggerType: 'manual', + useDraftState: true, startTime: '2026-05-06T00:00:00.000Z', } describe('ExecutionSnapshot', () => { - it('normalizes untyped persisted execution state at construction', () => { + it('normalizes untyped state and persists a strict versioned runtime principal', () => { const variable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' } const snapshot = new ExecutionSnapshot( @@ -26,127 +39,150 @@ describe('ExecutionSnapshot', () => { ) expect(snapshot.toJSON()).toMatch(/^\{"metadata":/) - expect(JSON.parse(snapshot.toJSON())).toMatchObject({ version: 1 }) + expect(JSON.parse(snapshot.toJSON())).toMatchObject({ + version: 2, + metadata: { + principal: { + version: 2, + executionMetadata: metadata.principal.executionMetadata, + }, + }, + }) expect(snapshot.workflowVariables).toEqual({ 'var-1': variable }) expect(snapshot.selectedOutputs).toEqual(['agent.content', 'function.result']) }) - it('round trips a delegated principal through persisted JSON', () => { - const principal = { - kind: 'delegated' as const, - serviceId: 'copilot' as const, + it.each([ + { + name: 'manual user', + principal: { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' }, + }, + { + name: 'personal API key', + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + }, + { + name: 'workspace API key', + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + }, + { + name: 'schedule', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + }, + { + 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: 'T123', + subjectId: 'U123', + }, + }, + }, + ])('round trips the $name actor and execution authority', ({ principal }) => { + const runtimePrincipal = bindPrincipal(principal) + const snapshot = new ExecutionSnapshot( + { ...metadata, principal: runtimePrincipal }, + { blocks: [] }, + {}, + {}, + [] + ) + + const restored = ExecutionSnapshot.fromJSON(snapshot.toJSON()) + + expect(restored.metadata.principal).toEqual(runtimePrincipal) + }) + + it('round trips delegated-principal dates without changing the actor', () => { + const principal = bindPrincipal({ + kind: 'delegated', + serviceId: 'copilot', subjectUserId: 'user-1', workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-05-06T00:00:00.000Z'), expiresAt: new Date('2026-05-06T00:05:00.000Z'), - } + }) const snapshot = new ExecutionSnapshot({ ...metadata, principal }, { blocks: [] }, {}, {}, []) const restored = ExecutionSnapshot.fromJSON(snapshot.toJSON()) expect(restored.metadata.principal).toEqual(principal) expect( - restored.metadata.principal?.kind === 'delegated' && restored.metadata.principal.issuedAt + restored.metadata.principal.kind === 'delegated' && restored.metadata.principal.issuedAt ).toBeInstanceOf(Date) }) - it('rejects malformed persisted principals', () => { + it('rejects malformed persisted execution metadata', () => { + const serialized = JSON.parse(new ExecutionSnapshot(metadata, {}, {}, {}, []).toJSON()) + serialized.metadata.principal.executionMetadata.currentWorkflow.workflowId = '' + + expect(() => ExecutionSnapshot.fromJSON(JSON.stringify(serialized))).toThrow( + 'currentWorkflow.workflowId must be a non-empty string' + ) + }) + + it('rejects persisted snapshots without a principal', () => { + const { principal: _principal, ...metadataWithoutPrincipal } = metadata + expect(() => ExecutionSnapshot.fromJSON( JSON.stringify({ - version: 1, - metadata: { ...metadata, principal: { version: 99, principal: {} } }, + version: 2, + metadata: metadataWithoutPrincipal, workflow: { blocks: [] }, input: {}, workflowVariables: {}, selectedOutputs: [], }) ) - ).toThrow('Unsupported serialized principal version') + ).toThrow('Execution snapshot metadata is missing its principal') }) - it('rejects persisted snapshots without a principal', () => { - const { principal: _principal, ...metadataWithoutPrincipal } = metadata - + it('rejects versioned snapshots whose principal has no execution metadata', () => { expect(() => ExecutionSnapshot.fromJSON( JSON.stringify({ - version: 1, - metadata: metadataWithoutPrincipal, + version: 2, + metadata: { + ...metadata, + principal: { + version: 1, + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + }, + }, workflow: { blocks: [] }, input: {}, workflowVariables: {}, selectedOutputs: [], }) ) - ).toThrow('Execution snapshot metadata is missing its principal') - }) - - it('restores the recorded session user from a legacy pause snapshot', () => { - const { principal: _principal, ...legacyMetadata } = metadata - const restored = ExecutionSnapshot.fromJSON( - JSON.stringify({ - metadata: { ...legacyMetadata, sessionUserId: 'session-user-1' }, - workflow: { blocks: [] }, - input: {}, - workflowVariables: {}, - selectedOutputs: [], - }) - ) - - expect(restored.metadata.principal).toEqual({ - kind: 'session', - userId: 'session-user-1', - sessionId: 'legacy-paused-execution', - }) + ).toThrow('missing execution metadata') }) - it('restores the recorded API-key actor from a legacy pause snapshot', () => { - const { principal: _principal, ...legacyMetadata } = metadata - const restored = ExecutionSnapshot.fromJSON( - JSON.stringify({ - metadata: { ...legacyMetadata, enforceCredentialAccess: true }, - workflow: { blocks: [] }, - input: {}, - workflowVariables: {}, - selectedOutputs: [], - }) - ) - - expect(restored.metadata.principal).toEqual({ - kind: 'session', - userId: 'user-1', - sessionId: 'legacy-paused-execution', - }) - }) - - it('restores actorless legacy pause snapshots as internal system executions', () => { - const { principal: _principal, ...legacyMetadata } = metadata - const restored = ExecutionSnapshot.fromJSON( - JSON.stringify({ - metadata: legacyMetadata, - workflow: { blocks: [] }, - input: {}, - workflowVariables: {}, - selectedOutputs: [], - }) - ) - - expect(restored.metadata.principal).toEqual({ - kind: 'system', - serviceId: 'internal', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - }) - }) - - it('rejects unsupported execution snapshot versions', () => { + it.each([undefined, 1, 3])('rejects unsupported execution snapshot version %s', (version) => { expect(() => ExecutionSnapshot.fromJSON( JSON.stringify({ - version: 2, + ...(version === undefined ? {} : { version }), metadata, workflow: { blocks: [] }, input: {}, @@ -154,6 +190,6 @@ describe('ExecutionSnapshot', () => { selectedOutputs: [], }) ) - ).toThrow('Unsupported execution snapshot version 2') + ).toThrow(`Unsupported execution snapshot version ${String(version)}`) }) }) diff --git a/apps/sim/executor/execution/snapshot.ts b/apps/sim/executor/execution/snapshot.ts index 675c3d5aaad..bbf46cba899 100644 --- a/apps/sim/executor/execution/snapshot.ts +++ b/apps/sim/executor/execution/snapshot.ts @@ -1,5 +1,6 @@ import { parsePrincipal, + requirePrincipalExecutionMetadata, serializePrincipal, type WorkflowExecutionPrincipal, } from '@sim/auth/principal' @@ -7,43 +8,7 @@ import { normalizeStringArray } from '@/lib/core/utils/arrays' import { normalizeWorkflowVariables } from '@/lib/core/utils/records' import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types' -const EXECUTION_SNAPSHOT_VERSION = 1 -const LEGACY_PAUSE_SESSION_ID = 'legacy-paused-execution' - -function requireLegacyMetadataString( - metadata: Record, - field: 'userId' | 'workflowId' | 'workspaceId' -): string { - const value = metadata[field] - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`Legacy execution snapshot metadata ${field} must be a non-empty string`) - } - return value -} - -/** Restores only identity that the pre-principal snapshot format recorded unambiguously. */ -function parseLegacyPrincipal(metadata: Record): WorkflowExecutionPrincipal { - const workflowId = requireLegacyMetadataString(metadata, 'workflowId') - const workspaceId = requireLegacyMetadataString(metadata, 'workspaceId') - if (metadata.sessionUserId !== undefined) { - if (typeof metadata.sessionUserId !== 'string' || !metadata.sessionUserId.trim()) { - throw new Error('Legacy execution snapshot metadata sessionUserId must be a non-empty string') - } - return { - kind: 'session', - userId: metadata.sessionUserId, - sessionId: LEGACY_PAUSE_SESSION_ID, - } - } - if (metadata.enforceCredentialAccess === true) { - return { - kind: 'session', - userId: requireLegacyMetadataString(metadata, 'userId'), - sessionId: LEGACY_PAUSE_SESSION_ID, - } - } - return { kind: 'system', serviceId: 'internal', workspaceId, workflowId } -} +const EXECUTION_SNAPSHOT_VERSION = 2 export class ExecutionSnapshot { public readonly metadata: ExecutionMetadata @@ -70,10 +35,15 @@ export class ExecutionSnapshot { } toJSON(): string { + requirePrincipalExecutionMetadata(this.metadata.principal) + const principal = serializePrincipal(this.metadata.principal, 2) + if (principal.version !== 2) { + throw new Error('Execution snapshot principal must carry execution metadata') + } return JSON.stringify({ metadata: { ...this.metadata, - principal: serializePrincipal(this.metadata.principal), + principal, }, version: EXECUTION_SNAPSHOT_VERSION, workflow: this.workflow, @@ -94,20 +64,14 @@ export class ExecutionSnapshot { throw new Error('Execution snapshot metadata must be an object') } const serializedMetadata = parsed.metadata as Record - let principal: WorkflowExecutionPrincipal - if (parsed.version === EXECUTION_SNAPSHOT_VERSION) { - if (serializedMetadata.principal === undefined) { - throw new Error('Execution snapshot metadata is missing its principal') - } - principal = parsePrincipal(serializedMetadata.principal) - } else if (parsed.version === undefined) { - if (serializedMetadata.principal !== undefined) { - throw new Error('Unversioned execution snapshots cannot contain a principal') - } - principal = parseLegacyPrincipal(serializedMetadata) - } else { + if (parsed.version !== EXECUTION_SNAPSHOT_VERSION) { throw new Error(`Unsupported execution snapshot version ${String(parsed.version)}`) } + if (serializedMetadata.principal === undefined) { + throw new Error('Execution snapshot metadata is missing its principal') + } + const principal: WorkflowExecutionPrincipal = parsePrincipal(serializedMetadata.principal) + requirePrincipalExecutionMetadata(principal) const metadata = { ...serializedMetadata, principal, diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index 6ecc3a03a84..b2a16fdc455 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -7,7 +7,6 @@ import type { NodeMetadata } from '@/executor/dag/types' import type { BlockLog, BlockState, - ExecutorDelegationOrigin, NormalizedBlockOutput, StartBlockRunMetadata, StreamingExecution, @@ -249,8 +248,6 @@ export interface ContextExtensions { allowLargeValueWorkflowScope?: boolean userId?: string principal?: WorkflowExecutionPrincipal - /** Canonical signed execution identity inherited by regular nested workflows. */ - executorDelegationOrigin?: ExecutorDelegationOrigin /** * Immutable actor/payer decision for this execution. Child workflow * executions receive it here (they carry no full metadata), so internal diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 3664fb82227..5dbfaf475ba 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -987,7 +987,7 @@ export class AgentBlockHandler implements BlockHandler { ctx: ExecutionContext, customToolId: string ): Promise<{ schema: any; title: string } | null> { - if (!ctx.userId && !ctx.executorDelegationOrigin?.subjectUserId) { + if (!ctx.userId && !ctx.principal?.executionMetadata) { logger.error( 'Cannot fetch custom tool without userId', projectAgentDiagnosticMetadata( @@ -1289,7 +1289,7 @@ export class AgentBlockHandler implements BlockHandler { workspaceId: ctx.workspaceId, executionId: ctx.executionId, userId: ctx.userId, - executorDelegationOrigin: ctx.executorDelegationOrigin, + principal: ctx.principal, }, serverId, signal: ctx.abortSignal, @@ -1353,7 +1353,7 @@ export class AgentBlockHandler implements BlockHandler { workspaceId: ctx.workspaceId, executionId: ctx.executionId, userId: ctx.userId, - executorDelegationOrigin: ctx.executorDelegationOrigin, + principal: ctx.principal, }, toolIndex, resolveCustomBlockBinding: (blockType: string) => diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts index f1bdeaa44d5..3e3f1b0fbeb 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.test.ts @@ -1,14 +1,15 @@ /** * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { BlockType } from '@/executor/constants' import type { ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' const mocks = vi.hoisted(() => ({ createPrincipal: vi.fn(), + requireWorkspaceId: vi.fn(() => 'workspace-1'), createInviteLink: vi.fn(), enforceInviteRateLimit: vi.fn(), listCredentials: vi.fn(), @@ -50,36 +51,18 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({ vi.mock('@/lib/internal/principals/executor', () => ({ createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, + requireExecutorWorkspaceId: mocks.requireWorkspaceId, })) import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler' -const principal: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:credential-groups', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - }, -} +const principal = createTestRuntimePrincipal() const context = { workspaceId: 'workspace-1', workflowId: 'workflow-1', userId: 'user-1', - principal: principal.delegationContext.principal, - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'workflow-1', - principal: principal.delegationContext.principal, - }, + principal, } as ExecutionContext const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBlock @@ -99,7 +82,7 @@ describe('CredentialGroupBlockHandler', () => { ) }) - it('lists credentials with an optional email selector', async () => { + it('keeps the selected group on application input instead of principal metadata', async () => { mocks.listCredentials.mockResolvedValue({ credentials: [], count: 0, @@ -118,13 +101,12 @@ describe('CredentialGroupBlockHandler', () => { expect(mocks.createPrincipal).toHaveBeenCalledWith({ context, - audience: 'sim:credential-groups', - resourceScope: { credentialGroupId: 'group-1' }, }) expect(mocks.listCredentials).toHaveBeenCalledWith({ principal, input: { credentialGroupId: 'group-1', + assertedWorkspaceId: 'workspace-1', email: 'person@example.com', credentialProviderIds: ['google-email'], limit: 25, @@ -135,41 +117,23 @@ describe('CredentialGroupBlockHandler', () => { }) it('lists credentials for an actorless workflow execution', async () => { - const executionPrincipal = { - kind: 'system' as const, - serviceId: 'schedule' as const, - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - } - const actorlessPrincipal: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - workspaceId: 'workspace-1', - delegationId: 'delegation-actorless', - audience: 'sim:credential-groups', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - resourceScope: { credentialGroupId: 'group-1' }, - delegationContext: { - kind: 'workflow_execution', + const actorlessPrincipal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + currentWorkflow: { workflowId: 'workflow-1', - principal: executionPrincipal, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-version-1', - }, + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', }, - } + }) const actorlessContext = { ...context, userId: undefined, - principal: executionPrincipal, - executorDelegationOrigin: { - workflowId: 'workflow-1', - principal: executionPrincipal, - currentWorkflow: actorlessPrincipal.delegationContext.currentWorkflow, - }, + principal: actorlessPrincipal, } as ExecutionContext mocks.createPrincipal.mockResolvedValueOnce(actorlessPrincipal) mocks.listCredentials.mockResolvedValue({ @@ -186,13 +150,12 @@ describe('CredentialGroupBlockHandler', () => { expect(mocks.createPrincipal).toHaveBeenCalledWith({ context: actorlessContext, - audience: 'sim:credential-groups', - resourceScope: { credentialGroupId: 'group-1' }, }) expect(mocks.listCredentials).toHaveBeenCalledWith({ principal: actorlessPrincipal, input: { credentialGroupId: 'group-1', + assertedWorkspaceId: 'workspace-1', limit: 100, cursor: undefined, email: undefined, @@ -216,7 +179,6 @@ describe('CredentialGroupBlockHandler', () => { expect(mocks.createPrincipal).toHaveBeenCalledWith({ context, - audience: 'sim:credential-groups', }) expect(mocks.listGroups).toHaveBeenCalledWith({ principal, @@ -247,7 +209,11 @@ describe('CredentialGroupBlockHandler', () => { ) expect(mocks.sendInvite).toHaveBeenCalledWith({ principal, - input: { credentialGroupId: 'group-1', email: 'person@example.com' }, + input: { + credentialGroupId: 'group-1', + assertedWorkspaceId: 'workspace-1', + email: 'person@example.com', + }, }) }) @@ -271,8 +237,6 @@ describe('CredentialGroupBlockHandler', () => { expect(mocks.createPrincipal).toHaveBeenCalledWith({ context, - audience: 'sim:credential-groups', - resourceScope: { credentialGroupId: 'group-1' }, }) expect(mocks.enforceInviteRateLimit).toHaveBeenCalledWith('workspace-1') expect(mocks.enforceInviteRateLimit.mock.invocationCallOrder[0]).toBeLessThan( @@ -280,7 +244,11 @@ describe('CredentialGroupBlockHandler', () => { ) expect(mocks.createInviteLink).toHaveBeenCalledWith({ principal, - input: { credentialGroupId: 'group-1', email: 'person@example.com' }, + input: { + credentialGroupId: 'group-1', + assertedWorkspaceId: 'workspace-1', + email: 'person@example.com', + }, }) expect(mocks.sendInvite).not.toHaveBeenCalled() expect(result).toEqual({ diff --git a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts index 0bc0c24169a..36e86790129 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization' import { createCredentialGroupInviteLink } from '@/lib/credential-groups/application/create-invite-link' import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups' @@ -11,7 +10,10 @@ import { sendCredentialGroupInvite } from '@/lib/credential-groups/application/s import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials' import type { CredentialGroupEnrollmentStatus } from '@/lib/credential-groups/enrollments' import { enforceCredentialGroupInvitationExecutionRateLimit } from '@/lib/credential-groups/rate-limit' -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { + createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId, +} from '@/lib/internal/principals/executor' import type { BlockOutput } from '@/blocks/types' import { BlockType } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' @@ -94,19 +96,17 @@ export class CredentialGroupBlockHandler implements BlockHandler { _block: SerializedBlock, inputs: Record ): Promise { - if (!ctx.workspaceId) throw new Error('workspaceId is required for Credential Group operations') const operation = parseOperation(inputs.operation) - if (!ctx.executorDelegationOrigin) { + if (!ctx.principal?.executionMetadata) { throw new Error('Credential Group operations require an authenticated workflow execution') } + const executionWorkspaceId = requireExecutorWorkspaceId(ctx) const credentialGroupId = operation === 'list_groups' ? undefined : requireString(inputs.credentialGroupId, 'Credential Group') const principal = await createExecutorPrincipalFromExecutionContext({ context: ctx, - audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, - ...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}), }) switch (operation) { @@ -119,6 +119,7 @@ export class CredentialGroupBlockHandler implements BlockHandler { principal, input: { credentialGroupId: credentialGroupId!, + assertedWorkspaceId: executionWorkspaceId, limit: parseLimit(inputs.limit), cursor: parseOptionalString(inputs.cursor, 'Cursor'), email: parseOptionalString(inputs.email, 'Email'), @@ -133,11 +134,12 @@ export class CredentialGroupBlockHandler implements BlockHandler { return result } case 'send_invite': { - await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId) + await enforceCredentialGroupInvitationExecutionRateLimit(executionWorkspaceId) const result = await sendCredentialGroupInvite.execute({ principal, input: { credentialGroupId: credentialGroupId!, + assertedWorkspaceId: executionWorkspaceId, email: requireString(inputs.email, 'Email'), }, }) @@ -154,11 +156,12 @@ export class CredentialGroupBlockHandler implements BlockHandler { } } case 'get_invite_link': { - await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId) + await enforceCredentialGroupInvitationExecutionRateLimit(executionWorkspaceId) const result = await createCredentialGroupInviteLink.execute({ principal, input: { credentialGroupId: credentialGroupId!, + assertedWorkspaceId: executionWorkspaceId, email: requireString(inputs.email, 'Email'), }, }) @@ -185,6 +188,7 @@ export class CredentialGroupBlockHandler implements BlockHandler { principal, input: { credentialGroupId: credentialGroupId!, + assertedWorkspaceId: executionWorkspaceId, limit: parseLimit(inputs.limit), cursor: parseOptionalString(inputs.cursor, 'Cursor'), email: parseOptionalString(inputs.email, 'Email'), @@ -202,13 +206,13 @@ export class CredentialGroupBlockHandler implements BlockHandler { const result = await listCredentialGroupsForWorkflow.execute({ principal, input: { - workspaceId: ctx.workspaceId, + workspaceId: executionWorkspaceId, limit: parseLimit(inputs.limit), cursor: parseOptionalString(inputs.cursor, 'Cursor'), }, }) logger.info('Listed Credential Groups', { - workspaceId: ctx.workspaceId, + workspaceId: executionWorkspaceId, count: result.count, hasMore: result.hasMore, }) diff --git a/apps/sim/executor/handlers/pi/local/sim-tools.ts b/apps/sim/executor/handlers/pi/local/sim-tools.ts index 876bb7e99ee..6afe8b61af7 100644 --- a/apps/sim/executor/handlers/pi/local/sim-tools.ts +++ b/apps/sim/executor/handlers/pi/local/sim-tools.ts @@ -237,7 +237,7 @@ export async function buildSimToolSpecs( workspaceId: ctx.workspaceId, executionId: ctx.executionId, userId: ctx.userId, - executorDelegationOrigin: ctx.executorDelegationOrigin, + principal: ctx.principal, }, resolveCustomBlockBinding: (blockType: string) => resolveCustomBlockToolBinding(blockType, ctx.workspaceId), diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts index a95e9ef4390..778389f954b 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -6,7 +6,7 @@ import { isPlainRecord, isRecordLike } from '@sim/utils/object' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { PiiBlockOutputRedaction } from '@/executor/execution/types' import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' -import type { ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types' +import type { ExecutionContext } from '@/executor/types' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { SerializedBlock } from '@/serializer/types' @@ -72,7 +72,6 @@ export function buildCustomBlockExecutionContext( environmentVariables: Record abortSignal?: AbortSignal resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry - executorDelegationOrigin?: ExecutorDelegationOrigin principal?: WorkflowExecutionPrincipal /** The invoking run's in-flight block-output redaction policy. */ piiBlockOutputRedaction?: PiiBlockOutputRedaction @@ -86,7 +85,6 @@ export function buildCustomBlockExecutionContext( workspaceId: context.workspaceId, userId: context.userId, principal: options.principal, - executorDelegationOrigin: options.executorDelegationOrigin, executionId, isDeployedContext: context.isDeployedContext, // Inherit the accumulated chain so the handler appends + validates depth; diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 2cd95e2c907..6380666f0b8 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -6,6 +6,7 @@ import { resetEnvironmentUtilsMock, } from '@sim/testing' import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core/execution-limits' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBlock } from '@/blocks/registry' @@ -46,6 +47,7 @@ const { mockSetTraceLargeValueAccess, mockDispose, mockReadWorkflowDefinitionAsExecutor, + mockLoadWorkflowDeploymentVersionState, mockCheckWorkspaceAccess, mockProjectTraceSpansForLiveDisplay, executorOptions, @@ -70,6 +72,7 @@ const { mockSetTraceLargeValueAccess: vi.fn(), mockDispose: vi.fn(), mockReadWorkflowDefinitionAsExecutor: vi.fn(), + mockLoadWorkflowDeploymentVersionState: vi.fn(), executorOptions: [] as Array>, loggingSessionArgs: [] as Array, })) @@ -145,6 +148,10 @@ vi.mock('@/lib/internal/workflows/read-definition', () => ({ readWorkflowDefinitionAsExecutor: mockReadWorkflowDefinitionAsExecutor, })) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowDeploymentVersionState: mockLoadWorkflowDeploymentVersionState, +})) + /** * Overrides the global registry mock's getBlock so the Serializer can carry the * start block's runMetadata param through child deployed-state serialization. @@ -227,23 +234,20 @@ describe('WorkflowBlockHandler', () => { enabled: true, } + const principal = createTestRuntimePrincipal({ + executionId: 'parent-execution-id', + rootWorkflowId: 'parent-workflow-id', + }) mockContext = { workflowId: 'parent-workflow-id', executionId: 'parent-execution-id', userId: 'user-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'parent-workflow-id', - executionId: 'parent-execution-id', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'parent-workflow-id', mode: 'draft' }, - }, + principal, blockStates: new Map(), blockLogs: [], metadata: { duration: 0, - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + principal, }, environmentVariables: {}, decisions: { router: new Map(), condition: new Map() }, @@ -264,6 +268,13 @@ describe('WorkflowBlockHandler', () => { executorOptions.length = 0 loggingSessionArgs.length = 0 mockSafeStart.mockResolvedValue(true) + mockLoadWorkflowDeploymentVersionState.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + deploymentVersionId: 'deployment-version-1', + }) mockAdmitCustomBlockChildExecution.mockResolvedValue(undefined) mockBuildTraceSpans.mockReturnValue({ traceSpans: [], totalDuration: 0 }) // Setup default fetch mock @@ -441,13 +452,7 @@ describe('WorkflowBlockHandler', () => { expect(mockExecutorExecute).not.toHaveBeenCalled() expect(mockReadWorkflowDefinitionAsExecutor).toHaveBeenCalledWith( expect.objectContaining({ - origin: { - subjectUserId: 'user-1', - workflowId: 'parent-workflow-id', - executionId: 'parent-execution-id', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'parent-workflow-id', mode: 'draft' }, - }, + principal: mockContext.principal, }) ) }) @@ -647,6 +652,8 @@ describe('WorkflowBlockHandler', () => { mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], @@ -692,13 +699,10 @@ describe('WorkflowBlockHandler', () => { await handler.execute(ctx, customBlock, {}) - expect(mockReadWorkflowDefinitionAsExecutor).toHaveBeenCalledWith( - expect.objectContaining({ - origin: { - subjectUserId: 'owner-9', - workflowId: 'source-workflow-id', - }, - }) + expect(mockLoadWorkflowDeploymentVersionState).toHaveBeenCalledWith( + 'source-workflow-id', + 'deployment-version-1', + 'workspace-source' ) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ actorUserId: 'owner-9', @@ -708,19 +712,19 @@ describe('WorkflowBlockHandler', () => { expect(executorOptions[0].contextExtensions.billingAttribution).toBe(sourceAttribution) expect(executorOptions[0].contextExtensions.userId).toBe('owner-9') expect(executorOptions[0].contextExtensions.workspaceId).toBe('workspace-source') - expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({ + expect(executorOptions[0].contextExtensions.principal).toEqual({ + kind: 'system', + serviceId: 'internal', + workspaceId: 'workspace-source', workflowId: 'source-workflow-id', - executionId: loggingSessionArgs[0][1], - currentWorkflow: { - workflowId: 'source-workflow-id', - mode: 'deployment', - deploymentVersionId: 'deployment-version-1', - }, - principal: { - kind: 'system', - serviceId: 'internal', - workspaceId: 'workspace-source', - workflowId: 'source-workflow-id', + executionMetadata: { + executionId: loggingSessionArgs[0][1], + rootWorkflowId: 'source-workflow-id', + currentWorkflow: { + workflowId: 'source-workflow-id', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, }, }) }) @@ -745,6 +749,8 @@ describe('WorkflowBlockHandler', () => { mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], @@ -807,6 +813,8 @@ describe('WorkflowBlockHandler', () => { mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], @@ -823,47 +831,24 @@ describe('WorkflowBlockHandler', () => { mockGetUserEmailById.mockImplementation(async (userId: string) => userId === 'owner-9' ? 'owner@source.com' : userId === 'consumer-1' ? 'a@corp.com' : null ) - mockFetch.mockImplementation(async (url: unknown) => { - if (String(url).includes('/deployed')) { - return { - ok: true, - json: () => - Promise.resolve({ - data: { - deployedState: { - blocks: { - start: { - id: 'start', - type: 'start_trigger', - name: 'Start', - position: { x: 0, y: 0 }, - subBlocks: { - runMetadata: { id: 'runMetadata', type: 'switch', value: true }, - }, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - deploymentVersionId: 'deployment-version-1', - }, - }, - }), - } - } - return { - ok: true, - json: () => - Promise.resolve({ - data: { - name: 'Source Workflow', - workspaceId: 'workspace-source', - variables: {}, - }, - }), - } + mockLoadWorkflowDeploymentVersionState.mockResolvedValueOnce({ + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { + runMetadata: { id: 'runMetadata', type: 'switch', value: true }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + deploymentVersionId: 'deployment-version-1', }) mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) @@ -909,6 +894,8 @@ describe('WorkflowBlockHandler', () => { mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], @@ -1328,6 +1315,8 @@ describe('WorkflowBlockHandler', () => { beforeEach(() => { mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], @@ -1397,6 +1386,8 @@ describe('WorkflowBlockHandler', () => { // thing that closes the stream for an identified consumer. mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], @@ -1485,6 +1476,8 @@ describe('WorkflowBlockHandler', () => { it('withholds both the viewer id and the sink when streaming is not permitted', async () => { mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], @@ -1540,6 +1533,8 @@ describe('WorkflowBlockHandler', () => { function closeTracePolicy() { mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], @@ -1761,30 +1756,30 @@ describe('WorkflowBlockHandler', () => { expect(ctx.largeValueExecutionIds).toContain(extensions.executionId) }) - it('replaces the consumer delegation origin with the source child execution', async () => { + it('replaces the consumer runtime principal with the source child execution', async () => { const ctx = customBlockContext({ - executorDelegationOrigin: { - subjectUserId: 'consumer-1', - workflowId: 'consumer-workflow', + principal: createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'consumer-1', sessionId: 'session-consumer' }, executionId: 'parent-execution-id', - }, + rootWorkflowId: 'consumer-workflow', + }), }) await handler.execute(ctx, customBlock(), {}) - expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({ + expect(executorOptions[0].contextExtensions.principal).toEqual({ + kind: 'system', + serviceId: 'internal', + workspaceId: 'workspace-source', workflowId: 'source-workflow-id', - executionId: executorOptions[0].contextExtensions.executionId, - currentWorkflow: { - workflowId: 'source-workflow-id', - mode: 'deployment', - deploymentVersionId: 'deployment-version-1', - }, - principal: { - kind: 'system', - serviceId: 'internal', - workspaceId: 'workspace-source', - workflowId: 'source-workflow-id', + executionMetadata: { + executionId: executorOptions[0].contextExtensions.executionId, + rootWorkflowId: 'source-workflow-id', + currentWorkflow: { + workflowId: 'source-workflow-id', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, }, }) }) @@ -2024,6 +2019,8 @@ describe('WorkflowBlockHandler', () => { // back to exposing the child's raw terminal state. mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [], @@ -2080,45 +2077,28 @@ describe('WorkflowBlockHandler', () => { it('surfaces a missing-required-input failure verbatim', async () => { mockGetCustomBlockAuthority.mockResolvedValue({ workflowId: 'source-workflow-id', + deploymentVersionId: 'deployment-version-1', + workspaceId: 'workspace-source', organizationId: 'org-1', ownerUserId: 'owner-9', exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], requiredInputIds: ['field-1'], }) - mockFetch.mockImplementation(async (url: unknown) => { - if (String(url).includes('/deployed')) { - return { - ok: true, - json: () => - Promise.resolve({ - data: { - deployedState: { - blocks: { - starter: { - type: 'start_trigger', - subBlocks: { - inputFormat: { - value: [{ id: 'field-1', name: 'Username', type: 'string' }], - }, - }, - }, - }, - edges: [], - loops: {}, - parallels: {}, - deploymentVersionId: 'deployment-version-1', - }, - }, - }), - } - } - return { - ok: true, - json: () => - Promise.resolve({ - data: { name: 'Source Workflow', workspaceId: 'workspace-source', variables: {} }, - }), - } + mockLoadWorkflowDeploymentVersionState.mockResolvedValueOnce({ + blocks: { + starter: { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: [{ id: 'field-1', name: 'Username', type: 'string' }], + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + deploymentVersionId: 'deployment-version-1', }) const error = await handler @@ -2159,22 +2139,19 @@ describe('WorkflowBlockHandler', () => { const extensions = executorOptions[0].contextExtensions expect(extensions.executionId).toBe('parent-execution-id') expect(extensions.resolvedSecretTraceRegistry).toBe(registry) - expect(extensions.executorDelegationOrigin).toEqual({ - subjectUserId: 'user-1', - workflowId: 'parent-workflow-id', - executionId: 'parent-execution-id', - currentWorkflow: { workflowId: 'child-workflow-id', mode: 'draft' }, - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + expect(extensions.principal).toEqual({ + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + executionMetadata: { + executionId: 'parent-execution-id', + rootWorkflowId: 'parent-workflow-id', + currentWorkflow: { workflowId: 'child-workflow-id', mode: 'draft' }, + }, }) expect(mockReadWorkflowDefinitionAsExecutor).toHaveBeenCalledWith( expect.objectContaining({ - origin: { - subjectUserId: 'user-1', - workflowId: 'parent-workflow-id', - executionId: 'parent-execution-id', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'parent-workflow-id', mode: 'draft' }, - }, + principal: ctx.principal, }) ) expect(extensions.stream).toBe(false) @@ -2270,11 +2247,11 @@ describe('WorkflowBlockHandler', () => { workspaceId: 'workspace-1', workflowId: 'intermediate-workflow-id', executionId: 'parent-execution-id', - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'root-workflow-id', + principal: createTestRuntimePrincipal({ executionId: 'parent-execution-id', - }, + rootWorkflowId: 'root-workflow-id', + currentWorkflow: { workflowId: 'intermediate-workflow-id', mode: 'draft' }, + }), } as ExecutionContext mockFetch.mockResolvedValue({ ok: true, @@ -2291,11 +2268,17 @@ describe('WorkflowBlockHandler', () => { await handler.execute(ctx, mockBlock, { workflowId: 'grandchild-workflow-id' }) expect(mockReadWorkflowDefinitionAsExecutor).toHaveBeenCalledWith( - expect.objectContaining({ origin: ctx.executorDelegationOrigin }) + expect.objectContaining({ principal: ctx.principal }) ) - expect(executorOptions[0].contextExtensions.executorDelegationOrigin).toEqual({ - ...ctx.executorDelegationOrigin, - currentWorkflow: { workflowId: 'grandchild-workflow-id', mode: 'draft' }, + expect(executorOptions[0].contextExtensions.principal).toEqual({ + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + executionMetadata: { + executionId: 'parent-execution-id', + rootWorkflowId: 'root-workflow-id', + currentWorkflow: { workflowId: 'grandchild-workflow-id', mode: 'draft' }, + }, }) }) }) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 8e44c7f25fb..87ea9771f3f 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -1,3 +1,9 @@ +import { + type BoundWorkflowExecutionPrincipal, + bindPrincipalExecutionMetadata, + enterPrincipalWorkflowExecution, + requirePrincipalExecutionMetadata, +} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { findCause, getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -22,6 +28,7 @@ import { } from '@/lib/workflows/custom-blocks/child-execution' import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { loadWorkflowDeploymentVersionState } from '@/lib/workflows/persistence/utils' import { scopeOutputBlockId, selectChildOutputSelectors, @@ -54,7 +61,6 @@ import { type BlockHandler, type ExecutionContext, type ExecutionResult, - type ExecutorDelegationOrigin, START_BLOCK_METADATA_FIELD, type StartBlockRunMetadata, type StreamingExecution, @@ -69,6 +75,9 @@ import { Serializer } from '@/serializer' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('WorkflowBlockHandler') +type CustomBlockExecutionAuthority = NonNullable< + Awaited> +> /** * Trigger recorded on a custom block child's own log row. Distinct from @@ -255,6 +264,7 @@ export class WorkflowBlockHandler implements BlockHandler { let loadUserId = ctx.userId let exposedOutputs: CustomBlockOutput[] = [] let requiredInputIds: string[] = [] + let customBlockAuthority: CustomBlockExecutionAuthority | undefined if (isCustomBlock) { const authority = await getCustomBlockAuthority(blockTypeId as string, ctx.workspaceId) if (!authority) { @@ -271,6 +281,7 @@ export class WorkflowBlockHandler implements BlockHandler { traceChildRuns ) } + customBlockAuthority = authority workflowId = authority.workflowId loadUserId = authority.ownerUserId exposedOutputs = authority.exposedOutputs @@ -334,33 +345,21 @@ export class WorkflowBlockHandler implements BlockHandler { /** Large-value id list shared with the child (and any nested custom blocks). */ let sharedLargeValueIds: string[] | undefined let childCancellation: { signal: AbortSignal; dispose: () => void } | undefined - let childExecutorDelegationOrigin: ExecutorDelegationOrigin | undefined + let childRuntimePrincipal: BoundWorkflowExecutionPrincipal | undefined /** Settled in `finally` once the child is fully done — see `trackChildRun`. */ let settleChildRun: (() => void) | undefined try { if (!ctx.principal) { throw new Error('Workflow child loading requires an execution principal') } - let workflowReadDelegationOrigin: ExecutorDelegationOrigin - if (isCustomBlock) { - workflowReadDelegationOrigin = { - ...(loadUserId ? { subjectUserId: loadUserId } : {}), - workflowId, - } - } else { - if (!ctx.executorDelegationOrigin) { - throw new Error('Child workflow loading requires executor delegation authority') - } - workflowReadDelegationOrigin = ctx.executorDelegationOrigin - } - if (!isCustomBlock) childExecutorDelegationOrigin = workflowReadDelegationOrigin + requirePrincipalExecutionMetadata(ctx.principal) + const parentRuntimePrincipal = ctx.principal as BoundWorkflowExecutionPrincipal // A custom block runs the source's latest deployment; if the source has been // undeployed there's nothing to run. `BoundarySafeError` marks the message as // safe to cross the invocation boundary verbatim (it names no source // internals), so the catch forwards it instead of the generic failure. if (isCustomBlock) { - const deployed = await this.checkChildDeployment(workflowId, workflowReadDelegationOrigin) - if (!deployed) { + if (!customBlockAuthority?.deploymentVersionId) { throw new BoundarySafeError({ errorType: 'not_deployed', message: 'This block’s workflow is not deployed. Redeploy it to use this block.', @@ -371,7 +370,7 @@ export class WorkflowBlockHandler implements BlockHandler { if (useDeployed && !isCustomBlock) { const hasActiveDeployment = await this.checkChildDeployment( workflowId, - workflowReadDelegationOrigin + parentRuntimePrincipal ) if (!hasActiveDeployment) { throw new Error( @@ -380,9 +379,11 @@ export class WorkflowBlockHandler implements BlockHandler { } } - const childWorkflow = useDeployed - ? await this.loadChildWorkflowDeployed(workflowId, workflowReadDelegationOrigin) - : await this.loadChildWorkflow(workflowId, workflowReadDelegationOrigin) + const childWorkflow = isCustomBlock + ? await this.loadCustomBlockWorkflowDeployed(customBlockAuthority!) + : useDeployed + ? await this.loadChildWorkflowDeployed(workflowId, parentRuntimePrincipal) + : await this.loadChildWorkflow(workflowId, parentRuntimePrincipal) if (!childWorkflow) { throw new Error(`Child workflow ${workflowId} not found`) @@ -400,13 +401,10 @@ export class WorkflowBlockHandler implements BlockHandler { } : { workflowId, mode: 'draft' as const } if (!isCustomBlock) { - if (!childExecutorDelegationOrigin) { - throw new Error('Child workflow execution is missing its delegation origin') - } - childExecutorDelegationOrigin = { - ...childExecutorDelegationOrigin, - currentWorkflow: childWorkflowAuthority, - } + childRuntimePrincipal = enterPrincipalWorkflowExecution( + parentRuntimePrincipal, + childWorkflowAuthority + ) } // Custom blocks are org-scoped and deliberately cross-workspace: the source @@ -652,17 +650,19 @@ export class WorkflowBlockHandler implements BlockHandler { childExecutionId = undefined throw new Error('Custom block child logging failed to start') } - childExecutorDelegationOrigin = { - workflowId, - executionId: childExecutionId, - principal: { + childRuntimePrincipal = bindPrincipalExecutionMetadata( + { kind: 'system', serviceId: 'internal', workspaceId: sourceWorkspaceId, workflowId, }, - currentWorkflow: childWorkflowAuthority, - } + { + executionId: childExecutionId, + rootWorkflowId: workflowId, + currentWorkflow: childWorkflowAuthority, + } + ) // The child no longer shares the parent's execution id, so it no longer // hears the parent's cancellation event — bridge it explicitly. childCancellation = await createChildCancellationSignal({ @@ -842,6 +842,9 @@ export class WorkflowBlockHandler implements BlockHandler { depth: childDepth, } } + if (!childRuntimePrincipal) { + throw new Error('Child workflow execution is missing its runtime principal') + } const subExecutor = new Executor({ workflow: childWorkflow.serializedState, @@ -857,8 +860,7 @@ export class WorkflowBlockHandler implements BlockHandler { enforceCredentialAccess: ctx.enforceCredentialAccess, workspaceId: childWorkspaceId, userId: childUserId, - principal: childExecutorDelegationOrigin?.principal ?? ctx.principal, - executorDelegationOrigin: childExecutorDelegationOrigin, + principal: childRuntimePrincipal, executionId: childExecutionId ?? ctx.executionId, // Large values are cached per execution id, so a child running under its // own id still needs the invoking run's id to read values in its inputs. @@ -1267,11 +1269,11 @@ export class WorkflowBlockHandler implements BlockHandler { return metadata } - private async loadChildWorkflow(workflowId: string, origin: ExecutorDelegationOrigin) { + private async loadChildWorkflow(workflowId: string, principal: BoundWorkflowExecutionPrincipal) { let definition try { definition = await readWorkflowDefinitionAsExecutor({ - origin, + principal, workflowId, state: 'draft', }) @@ -1328,11 +1330,11 @@ export class WorkflowBlockHandler implements BlockHandler { private async checkChildDeployment( workflowId: string, - origin: ExecutorDelegationOrigin + principal: BoundWorkflowExecutionPrincipal ): Promise { try { const definition = await readWorkflowDefinitionAsExecutor({ - origin, + principal, workflowId, state: 'deployed', }) @@ -1346,11 +1348,14 @@ export class WorkflowBlockHandler implements BlockHandler { } } - private async loadChildWorkflowDeployed(workflowId: string, origin: ExecutorDelegationOrigin) { + private async loadChildWorkflowDeployed( + workflowId: string, + principal: BoundWorkflowExecutionPrincipal + ) { let definition try { definition = await readWorkflowDefinitionAsExecutor({ - origin, + principal, workflowId, state: 'deployed', }) @@ -1401,6 +1406,43 @@ export class WorkflowBlockHandler implements BlockHandler { } } + private async loadCustomBlockWorkflowDeployed(authority: CustomBlockExecutionAuthority) { + if (!authority.deploymentVersionId || !authority.workspaceId) { + throw new Error(`Deployed custom block workflow ${authority.workflowId} is unavailable`) + } + const deployedState = await loadWorkflowDeploymentVersionState( + authority.workflowId, + authority.deploymentVersionId, + authority.workspaceId + ) + const serializedWorkflow = this.serializer.serializeWorkflow( + deployedState.blocks, + deployedState.edges || [], + deployedState.loops || {}, + deployedState.parallels || {}, + true + ) + const workflowVariables = this.getWorkflowVariables(authority.workflowId, authority.variables) + const childName = authority.workflowName || DEFAULTS.WORKFLOW_NAME + const workflowStateWithVariables: WorkflowState = { + ...deployedState, + variables: workflowVariables, + metadata: { + ...this.getWorkflowStateMetadata(deployedState), + name: childName, + }, + } + return { + name: childName, + workspaceId: authority.workspaceId, + deploymentVersionId: authority.deploymentVersionId, + serializedState: serializedWorkflow, + variables: workflowVariables, + workflowState: workflowStateWithVariables, + rawBlocks: deployedState.blocks, + } + } + /** * Captures and transforms child workflow logs into trace spans */ diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts index d8d75d8f12a..f7e00034056 100644 --- a/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.ts @@ -12,7 +12,6 @@ import { type CustomBlockExecutorContext, } from '@/executor/handlers/workflow/custom-block-tool-runner' import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' -import type { ExecutorDelegationOrigin } from '@/executor/types' import { classifyExecutionError } from '@/executor/utils/errors' import { parseJSON } from '@/executor/utils/json' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -96,7 +95,6 @@ export async function runWorkflowTool( environmentVariables: Record abortSignal?: AbortSignal resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry - executorDelegationOrigin?: ExecutorDelegationOrigin principal?: WorkflowExecutionPrincipal piiBlockOutputRedaction?: PiiBlockOutputRedaction } diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index b3e98b2c497..96ec07670c8 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -1,4 +1,4 @@ -import type { WorkflowExecutionAuthority, WorkflowExecutionPrincipal } from '@sim/auth/principal' +import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { TraceSpan } from '@/lib/logs/types' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' @@ -356,21 +356,6 @@ export interface BlockState { resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 } -/** - * Canonical signed execution identity used for executor-delegated internal operations. - * - * A nested workflow changes {@link ExecutionContext.workflowId} for execution semantics, but it - * still belongs to the parent log row identified here. Custom blocks replace this origin with the - * publisher-owned child execution after opening their own source-workspace log row. - */ -export interface ExecutorDelegationOrigin { - subjectUserId?: string - workflowId: string - executionId?: string - principal?: WorkflowExecutionPrincipal - currentWorkflow?: WorkflowExecutionAuthority -} - export interface ExecutionContext { workflowId: string workspaceId?: string @@ -380,10 +365,8 @@ export interface ExecutionContext { fileKeys?: string[] allowLargeValueWorkflowScope?: boolean userId?: string - /** Original authenticated caller for resource-policy decisions. */ + /** Authenticated caller and canonical workflow execution authority. */ principal?: WorkflowExecutionPrincipal - /** Trusted origin for signed executor delegation, distinct from the currently executing child. */ - executorDelegationOrigin?: ExecutorDelegationOrigin isDeployedContext?: boolean enforceCredentialAccess?: boolean copilotToolExecution?: boolean diff --git a/apps/sim/executor/utils/credential-token.test.ts b/apps/sim/executor/utils/credential-token.test.ts index c4a7d64b160..7949f7317ab 100644 --- a/apps/sim/executor/utils/credential-token.test.ts +++ b/apps/sim/executor/utils/credential-token.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ +import { bindPrincipalExecutionMetadata } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutorDelegationOrigin } from '@/executor/types' const { mockBindExecutorManagedOAuthDelegation, mockResolveCredentialAccessToken } = vi.hoisted( () => ({ @@ -21,12 +21,14 @@ vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({ import { resolveExecutorCredentialToken } from '@/executor/utils/credential-token' -const ORIGIN: ExecutorDelegationOrigin = { - subjectUserId: 'user-1', - workflowId: 'wf-1', - executionId: 'exec-1', - currentWorkflow: { workflowId: 'wf-1' }, -} as ExecutorDelegationOrigin +const PRINCIPAL = bindPrincipalExecutionMetadata( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { + executionId: 'exec-1', + rootWorkflowId: 'wf-1', + currentWorkflow: { workflowId: 'wf-1', mode: 'draft' }, + } +) describe('resolveExecutorCredentialToken', () => { beforeEach(() => { @@ -78,32 +80,35 @@ describe('resolveExecutorCredentialToken', () => { expect(mockResolveCredentialAccessToken.mock.calls[1][0].callerUserId).toBe('user-1') }) - it('wires the managed delegation binder only when the run carries an origin', async () => { - mockBindExecutorManagedOAuthDelegation.mockResolvedValue({ kind: 'delegated' }) + it('wires the managed delegation binder only when the run carries a runtime principal', async () => { + mockBindExecutorManagedOAuthDelegation.mockResolvedValue(PRINCIPAL) await resolveExecutorCredentialToken({ requestId: 'req-1', credentialId: 'cred-1', userId: 'user-1', - executorDelegationOrigin: ORIGIN, + principal: PRINCIPAL, }) const input = mockResolveCredentialAccessToken.mock.calls[0][0] expect(input.resolveManagedPrincipal).toBeTypeOf('function') await input.resolveManagedPrincipal('managed-1') - expect(mockBindExecutorManagedOAuthDelegation).toHaveBeenCalledWith(ORIGIN, 'managed-1') + expect(mockBindExecutorManagedOAuthDelegation).toHaveBeenCalledWith(PRINCIPAL, 'managed-1') }) - it('fails before dispatch when the origin lacks current workflow authority', async () => { - await expect( - resolveExecutorCredentialToken({ - requestId: 'req-1', - credentialId: 'cred-1', - userId: 'user-1', - executorDelegationOrigin: { ...ORIGIN, currentWorkflow: undefined }, - }) - ).rejects.toThrow('Managed credential delegation is missing current workflow authority') - expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled() + it('passes the runtime principal through without inferring workflow authority', async () => { + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + + await resolveExecutorCredentialToken({ + requestId: 'req-1', + credentialId: 'cred-1', + userId: 'user-1', + principal, + }) + + const input = mockResolveCredentialAccessToken.mock.calls[0][0] + await input.resolveManagedPrincipal('managed-1') + expect(mockBindExecutorManagedOAuthDelegation).toHaveBeenCalledWith(principal, 'managed-1') }) it('throws the executeTool error contract with the tool label on failure', async () => { diff --git a/apps/sim/executor/utils/credential-token.ts b/apps/sim/executor/utils/credential-token.ts index 6f02767477d..97988248d64 100644 --- a/apps/sim/executor/utils/credential-token.ts +++ b/apps/sim/executor/utils/credential-token.ts @@ -1,3 +1,4 @@ +import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { AuthType } from '@/lib/auth/hybrid' import { bindExecutorManagedOAuthDelegation } from '@/lib/credentials/application/managed-oauth-delegation' @@ -5,7 +6,6 @@ import { type CredentialTokenPayload, resolveCredentialAccessToken, } from '@/lib/oauth/token-resolution' -import type { ExecutorDelegationOrigin } from '@/executor/types' const logger = createLogger('ExecutorCredentialToken') @@ -22,8 +22,8 @@ export interface ResolveExecutorCredentialTokenParams { impersonateEmail?: string /** Asserts the acting user alongside the credential lookup, mirroring the HTTP surface. */ enforceCredentialAccess?: boolean - /** Proves managed-credential delegations in-process when the run carries one. */ - executorDelegationOrigin?: ExecutorDelegationOrigin + /** Canonical runtime identity used to authorize managed credentials in-process. */ + principal?: WorkflowExecutionPrincipal } /** @@ -36,11 +36,7 @@ export interface ResolveExecutorCredentialTokenParams { export async function resolveExecutorCredentialToken( params: ResolveExecutorCredentialTokenParams ): Promise { - const { requestId, credentialId, userId, workflowId, toolId, executorDelegationOrigin } = params - - if (executorDelegationOrigin && !executorDelegationOrigin.currentWorkflow) { - throw new Error('Managed credential delegation is missing current workflow authority') - } + const { requestId, credentialId, userId, workflowId, toolId, principal } = params const result = await resolveCredentialAccessToken({ requestId, @@ -55,9 +51,9 @@ export async function resolveExecutorCredentialToken( userId, authType: AuthType.INTERNAL_JWT, }), - resolveManagedPrincipal: executorDelegationOrigin + resolveManagedPrincipal: principal ? (managedCredentialId: string) => - bindExecutorManagedOAuthDelegation(executorDelegationOrigin, managedCredentialId) + bindExecutorManagedOAuthDelegation(principal, managedCredentialId) : undefined, }) diff --git a/apps/sim/executor/utils/delegation.test.ts b/apps/sim/executor/utils/delegation.test.ts deleted file mode 100644 index 0681054f27b..00000000000 --- a/apps/sim/executor/utils/delegation.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { executionScopeForTarget } from '@/executor/utils/delegation' - -describe('executionScopeForTarget', () => { - it('binds the execution when the target is the running workflow', () => { - expect( - executionScopeForTarget({ workflowId: 'workflow-1', executionId: 'run-1' }, 'workflow-1') - ).toEqual({ executionId: 'run-1' }) - }) - - it('omits the execution for a child workflow, which binds on its own id', () => { - expect( - executionScopeForTarget({ workflowId: 'parent', executionId: 'run-1' }, 'child') - ).toEqual({}) - }) - - it('omits the execution outside an active run', () => { - expect(executionScopeForTarget({ workflowId: 'workflow-1' }, 'workflow-1')).toEqual({}) - }) - - it('omits the execution when the context has no workflow to compare', () => { - expect(executionScopeForTarget({ executionId: 'run-1' }, 'workflow-1')).toEqual({}) - }) -}) diff --git a/apps/sim/executor/utils/delegation.ts b/apps/sim/executor/utils/delegation.ts deleted file mode 100644 index 6bead347656..00000000000 --- a/apps/sim/executor/utils/delegation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { GenerateInternalDelegationTokenInput } from '@/lib/auth/internal' - -/** - * Binds the running execution to a delegation only when it targets the workflow that - * is actually running. - * - * A child workflow is a separate resource and binds on its own id, so forwarding the - * parent's `executionId` would assert a run that does not cover the target and the - * delegation would fail to bind. Callers spread the result into their delegation input. - * - * Kept free of runtime imports so client-reachable modules can read it without pulling - * in the executor graph. - */ -export function executionScopeForTarget( - context: { workflowId?: string; executionId?: string }, - targetWorkflowId: string -): Pick { - return context.workflowId === targetWorkflowId && context.executionId - ? { executionId: context.executionId } - : {} -} diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index d19270b906d..34d1dab349e 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -4,6 +4,7 @@ export { defineInternalJsonRoute, extendInternalErrorPolicy, type InternalAuthPolicy, + type InternalAuthTransport, type InternalErrorPolicy, InternalUnauthenticatedError, internalErrorResponse, @@ -11,6 +12,7 @@ export { internalOrchestrationErrorPolicy, internalRateLimits, internalSessionAuth, + resolveInternalAuthWorkspaceId, } from '@/lib/api/server/routes/internal-json-route' export { concealCrossTenantResourceError, diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 32c7dd2a1e5..14a79c3a0d8 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -21,6 +21,7 @@ import { internalErrorResponse, internalOrchestrationErrorPolicy, internalRateLimits, + resolveInternalAuthWorkspaceId, } from '@/lib/api/server/routes/internal-json-route' import { OrchestrationError } from '@/lib/core/orchestration/types' import { HttpError } from '@/lib/core/utils/http-error' @@ -108,6 +109,64 @@ describe('defineInternalJsonRoute', () => { expect(execute).not.toHaveBeenCalled() }) + it('passes verified authentication transport to route mapping and presentation', async () => { + const authenticate = vi.fn(async () => ({ + kind: 'session' as const, + userId: 'unreachable', + sessionId: 'unreachable', + })) + const authenticateWithTransport = vi.fn(async () => ({ + principal: { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', + }, + transport: 'executor_jwt' as const, + executionWorkspaceId: 'workspace-canonical', + })) + const handler = defineInternalJsonRoute({ + contract, + auth: { authenticate, authenticateWithTransport }, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: (_input, { authTransport, executionWorkspaceId }) => + `${authTransport}:${executionWorkspaceId}`, + useCase: { + operation, + async execute({ input }) { + return { value: input ?? 'missing' } + }, + }, + present: (result, { authTransport, executionWorkspaceId }) => ({ + value: `${result.value}:${authTransport ?? 'missing'}:${executionWorkspaceId ?? 'missing'}`, + }), + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(authenticate).not.toHaveBeenCalled() + expect(authenticateWithTransport).toHaveBeenCalledOnce() + await expect(response.json()).resolves.toEqual({ + value: 'executor_jwt:workspace-canonical:executor_jwt:workspace-canonical', + }) + }) + + it('selects session request scope and executor canonical scope explicitly', () => { + expect(resolveInternalAuthWorkspaceId('session', undefined, 'workspace-session')).toBe( + 'workspace-session' + ) + expect( + resolveInternalAuthWorkspaceId('executor_jwt', 'workspace-canonical', 'workspace-forged') + ).toBe('workspace-canonical') + expect(() => + resolveInternalAuthWorkspaceId('executor_jwt', undefined, 'workspace-forged') + ).toThrow('Executor JWT transport is missing its canonical workspace') + expect(() => resolveInternalAuthWorkspaceId(undefined, undefined, 'workspace-session')).toThrow( + 'Internal route requires an authenticated transport' + ) + }) + it('renders typed error descriptors through the shared builder', async () => { const handler = defineInternalJsonRoute({ contract, diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index ba0d6a5fe0e..57128a79a19 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -1,9 +1,8 @@ import { - type DelegatedPrincipal, + type BoundWorkflowExecutionPrincipal, type Principal, resolvePrincipalSubjectUserId, type SessionPrincipal, - type WorkflowExecutionDelegatedPrincipal, } from '@sim/auth/principal' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' @@ -30,7 +29,7 @@ import { verifyInternalDelegationToken, } from '@/lib/auth/internal' import { - bindInternalExecutorDelegation, + bindInternalExecutorDelegationAdmission, InvalidInternalDelegationBindingError, } from '@/lib/auth/internal-delegation' import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' @@ -49,58 +48,67 @@ export class InternalUnauthenticatedError extends Error { } } +async function authenticateInternalSession(): Promise { + const session = await getSession() + if (!session?.user?.id) throw new InternalUnauthenticatedError() + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + return { kind: 'session', userId: session.user.id, sessionId } +} + export const internalSessionAuth = { - async authenticate(): Promise { - const session = await getSession() - if (!session?.user?.id) throw new InternalUnauthenticatedError() - const sessionId = session.session?.id - if (!sessionId) throw new Error('Authenticated session is missing its session ID') - return { kind: 'session', userId: session.user.id, sessionId } + authenticate: authenticateInternalSession, + async authenticateWithTransport(): Promise> { + return { principal: await authenticateInternalSession(), transport: 'session' } }, } as const -interface InternalSessionOrExecutorAuthOptions { - audience: string - resourceScope?( - params: Record - ): DelegatedPrincipal['resourceScope'] -} +export function createInternalSessionOrExecutorAuth(): InternalAuthPolicy< + SessionPrincipal | BoundWorkflowExecutionPrincipal +> { + type InternalSessionOrExecutorPrincipal = SessionPrincipal | BoundWorkflowExecutionPrincipal -export function createInternalSessionOrExecutorAuth( - options: InternalSessionOrExecutorAuthOptions -): InternalAuthPolicy { - if (!options.audience.trim()) throw new Error('Internal executor auth audience must not be empty') + async function authenticateWithTransport( + request: NextRequest + ): Promise> { + if (request.headers.has('x-api-key')) { + throw new InternalUnauthenticatedError('Authentication required') + } - return { - async authenticate(request, params) { - if (request.headers.has('x-api-key')) { - throw new InternalUnauthenticatedError('Authentication required') - } + const authorization = request.headers.get('authorization') + if (!authorization) { + return { principal: await authenticateInternalSession(), transport: 'session' } + } + if (!authorization.startsWith('Bearer ')) { + throw new InternalUnauthenticatedError('Authentication required') + } - const authorization = request.headers.get('authorization') - if (!authorization) return internalSessionAuth.authenticate() - if (!authorization.startsWith('Bearer ')) { - throw new InternalUnauthenticatedError('Authentication required') - } + let delegation + try { + delegation = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + } catch (error) { + if (!(error instanceof InvalidInternalDelegationTokenError)) throw error + throw new InternalUnauthenticatedError('Authentication required') + } - let delegation - try { - delegation = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) - } catch (error) { - if (!(error instanceof InvalidInternalDelegationTokenError)) throw error - throw new InternalUnauthenticatedError('Authentication required') + try { + const admission = await bindInternalExecutorDelegationAdmission(delegation) + return { + principal: admission.principal, + transport: 'executor_jwt', + executionWorkspaceId: admission.workspaceId, } + } catch (error) { + if (!(error instanceof InvalidInternalDelegationBindingError)) throw error + throw new InternalUnauthenticatedError('Authentication required') + } + } - try { - return await bindInternalExecutorDelegation(delegation, { - audience: options.audience, - resourceScope: options.resourceScope?.(params), - }) - } catch (error) { - if (!(error instanceof InvalidInternalDelegationBindingError)) throw error - throw new InternalUnauthenticatedError('Authentication required') - } + return { + async authenticate(request) { + return (await authenticateWithTransport(request)).principal }, + authenticateWithTransport, } } @@ -221,11 +229,50 @@ export const internalJsonPresenters = { }, } as const +export type InternalAuthTransport = 'session' | 'executor_jwt' + +/** Selects request scope for sessions and canonical run scope for executor JWTs. */ +export function resolveInternalAuthWorkspaceId( + authTransport: InternalAuthTransport | undefined, + executionWorkspaceId: string | undefined, + sessionWorkspaceId: string +): string +export function resolveInternalAuthWorkspaceId( + authTransport: InternalAuthTransport | undefined, + executionWorkspaceId: string | undefined, + sessionWorkspaceId: string | undefined +): string | undefined +export function resolveInternalAuthWorkspaceId( + authTransport: InternalAuthTransport | undefined, + executionWorkspaceId: string | undefined, + sessionWorkspaceId: string | undefined +): string | undefined { + switch (authTransport) { + case 'session': + return sessionWorkspaceId + case 'executor_jwt': + if (!executionWorkspaceId?.trim()) { + throw new Error('Executor JWT transport is missing its canonical workspace') + } + return executionWorkspaceId + case undefined: + throw new Error('Internal route requires an authenticated transport') + } +} + +export type InternalAuthenticatedPrincipal

= + | { principal: P; transport: 'session' } + | { principal: P; transport: 'executor_jwt'; executionWorkspaceId: string } + export interface InternalAuthPolicy

{ authenticate( request: NextRequest, params: Record ): Promise

+ authenticateWithTransport?( + request: NextRequest, + params: Record + ): Promise> } export interface InternalJsonResponseFinalization { @@ -252,6 +299,8 @@ type InternalJsonParseOptions = Pick< export interface InternalJsonPresenterContext { principal: P input: I + authTransport?: InternalAuthTransport + executionWorkspaceId?: string } type InternalJsonPresentFn = ( @@ -275,7 +324,15 @@ type InternalJsonRouteOptions< > = { contract: C operation: O - mapInput(input: ParsedRequest, context: { principal: P; request: NextRequest }): I | Promise + mapInput( + input: ParsedRequest, + context: { + principal: P + request: NextRequest + authTransport?: InternalAuthTransport + executionWorkspaceId?: string + } + ): I | Promise useCase: OperationUseCase, I, R> auth: InternalAuthPolicy

rateLimit: InternalRateLimitPolicy @@ -297,6 +354,8 @@ type InternalJsonRouteOptions< input: NoInfer result: NoInfer body: ContractJsonResponse + authTransport?: InternalAuthTransport + executionWorkspaceId?: string }): InternalJsonResponseFinalization | Promise } & InternalJsonPresenter @@ -357,8 +416,20 @@ export function defineInternalJsonRoute< const rawParams = context?.params ? await context.params : {} let principal: P + let authTransport: InternalAuthTransport | undefined + let executionWorkspaceId: string | undefined try { - principal = await options.auth.authenticate(request, rawParams) + if (options.auth.authenticateWithTransport) { + const authentication = await options.auth.authenticateWithTransport(request, rawParams) + principal = authentication.principal + authTransport = authentication.transport + executionWorkspaceId = + authentication.transport === 'executor_jwt' + ? authentication.executionWorkspaceId + : undefined + } else { + principal = await options.auth.authenticate(request, rawParams) + } } catch (error) { if (error instanceof InternalUnauthenticatedError) { return createJsonErrorResponse(internalErrorResponse(401, { error: error.message })) @@ -386,14 +457,26 @@ export function defineInternalJsonRoute< if (!parsed.success) return responseWithRequestId(parsed.response) try { - const input = await options.mapInput(parsed.data, { principal, request }) + const input = await options.mapInput(parsed.data, { + principal, + request, + authTransport, + executionWorkspaceId, + }) const result = await options.useCase.execute({ principal, input, request, }) await options.onSuccess?.({ principal, input, result }) - const body = options.present ? await options.present(result, { principal, input }) : result + const body = options.present + ? await options.present(result, { + principal, + input, + authTransport, + executionWorkspaceId, + }) + : result const responseSchema = options.contract.response if (responseSchema.mode !== 'json') { throw new Error('Internal JSON route response mode changed after initialization') @@ -413,6 +496,8 @@ export function defineInternalJsonRoute< input, result, body: validatedBody, + authTransport, + executionWorkspaceId, }) : undefined return NextResponse.json( diff --git a/apps/sim/lib/auth/internal-delegation.test.ts b/apps/sim/lib/auth/internal-delegation.test.ts index 2450d21bdb6..8edd9d9ab4a 100644 --- a/apps/sim/lib/auth/internal-delegation.test.ts +++ b/apps/sim/lib/auth/internal-delegation.test.ts @@ -1,19 +1,23 @@ /** * @vitest-environment node */ +import { + bindPrincipalExecutionMetadata, + enterPrincipalWorkflowExecution, + type WorkflowExecutionPrincipal, +} from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockResolveWorkflow, mockResolveRun, mockResolveExecution, mockResolveDeploymentVersion } = - vi.hoisted(() => ({ +const { mockResolveWorkflow, mockResolveExecution, mockResolveDeploymentVersion } = vi.hoisted( + () => ({ mockResolveWorkflow: vi.fn(), - mockResolveRun: vi.fn(), mockResolveExecution: vi.fn(), mockResolveDeploymentVersion: vi.fn(), - })) + }) +) vi.mock('@/lib/workflows/application/context', () => ({ resolveActiveWorkflowApplicationContext: mockResolveWorkflow, - resolveActiveWorkflowRunApplicationContext: mockResolveRun, resolveActiveWorkflowExecutionApplicationContext: mockResolveExecution, resolveActiveWorkflowDeploymentVersionApplicationContext: mockResolveDeploymentVersion, })) @@ -24,32 +28,42 @@ import { } from '@/lib/auth/internal-delegation' import { OrchestrationError } from '@/lib/core/orchestration/types' -const claims = { - serviceId: 'executor' as const, - subjectUserId: 'user-1', - workflowId: 'workflow-1', - delegationId: 'delegation-1', - issuedAt: new Date('2026-08-08T12:00:00.000Z'), - expiresAt: new Date('2026-08-08T12:05:00.000Z'), +function rootPrincipal( + principal: WorkflowExecutionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } +) { + return bindPrincipalExecutionMetadata(principal, { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, + }) +} + +function claims(principal = rootPrincipal()) { + return { + serviceId: 'executor' as const, + principal, + delegationId: 'delegation-1', + issuedAt: new Date('2026-08-08T12:00:00.000Z'), + expiresAt: new Date('2026-08-08T12:05:00.000Z'), + } } describe('bindInternalExecutorDelegation', () => { beforeEach(() => { vi.clearAllMocks() mockResolveWorkflow.mockResolvedValue({ - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - }) - mockResolveRun.mockResolvedValue({ - workflowId: 'workflow-1', + workflowId: 'child-workflow', workspaceId: 'workspace-1', - runId: 'execution-1', }) mockResolveExecution.mockResolvedValue({ workflowId: 'workflow-1', workspaceId: 'workspace-1', runId: 'execution-1', - deploymentVersionId: 'deployment-version-1', + deploymentVersionId: null, }) mockResolveDeploymentVersion.mockResolvedValue({ workflowId: 'child-workflow', @@ -58,270 +72,190 @@ describe('bindInternalExecutorDelegation', () => { }) }) - it('derives workspace authority from the canonical workflow', async () => { - await expect( - bindInternalExecutorDelegation(claims, { audience: 'sim:knowledge' }) - ).resolves.toEqual({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:knowledge', - issuedAt: claims.issuedAt, - expiresAt: claims.expiresAt, - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - }, - }) - expect(mockResolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) - expect(mockResolveRun).not.toHaveBeenCalled() - }) - - it('canonically binds an execution to its signed workflow', async () => { - const executionClaims = { ...claims, executionId: 'execution-1' } + it('revalidates the canonical run and returns the same semantic runtime principal', async () => { + const principal = rootPrincipal() - const principal = await bindInternalExecutorDelegation(executionClaims, { - audience: 'sim:workspace-files', - resourceScope: { fileId: 'file-1' }, - }) - - expect(mockResolveRun).toHaveBeenCalledWith({ + await expect(bindInternalExecutorDelegation(claims(principal))).resolves.toBe(principal) + expect(mockResolveExecution).toHaveBeenCalledWith({ runId: 'execution-1', assertedWorkflowId: 'workflow-1', }) - expect(principal).toMatchObject({ - workspaceId: 'workspace-1', - resourceScope: { fileId: 'file-1' }, - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - executionId: 'execution-1', - }, - }) }) - it('binds the trusted legacy execution actor only for an actorless principal', async () => { - const principal = await bindInternalExecutorDelegation( - { - ...claims, - subjectUserId: undefined, - principal: { - kind: 'system', - serviceId: 'schedule', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', + it.each([ + { + name: 'workspace API key', + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + }, + { + name: 'schedule', + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + }, + { + 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 subject', + 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: 'T123', + subjectId: 'U123', }, }, - { - audience: 'sim:workspace-files', - compatibilityActorUserId: 'execution-actor', - } - ) - - expect(principal.subjectUserId).toBeUndefined() - expect(principal.delegationContext.compatibilityActor).toEqual({ - kind: 'legacy_execution_user', - userId: 'execution-actor', - }) - }) + }, + ])('preserves the original actor for $name', async ({ principal }) => { + const runtimePrincipal = rootPrincipal(principal) - it('rejects a compatibility actor when the delegation has a user subject', async () => { - await expect( - bindInternalExecutorDelegation(claims, { - audience: 'sim:workspace-files', - compatibilityActorUserId: 'execution-actor', - }) - ).rejects.toThrow('cannot bind a compatibility actor to a user subject') - expect(mockResolveWorkflow).not.toHaveBeenCalled() + await expect(bindInternalExecutorDelegation(claims(runtimePrincipal))).resolves.toBe( + runtimePrincipal + ) }) - it('binds deployed child authority to its exact historical deployment version', async () => { - const currentWorkflow = { + it('binds a deployed child to its exact historical deployment version', async () => { + const principal = enterPrincipalWorkflowExecution(rootPrincipal(), { workflowId: 'child-workflow', - mode: 'deployment' as const, + mode: 'deployment', deploymentVersionId: 'deployment-version-1', - } - - const principal = await bindInternalExecutorDelegation( - { ...claims, executionId: 'execution-1', currentWorkflow }, - { audience: 'sim:credential-groups' } - ) - - expect(mockResolveExecution).toHaveBeenCalledWith({ - runId: 'execution-1', - assertedWorkflowId: 'workflow-1', }) + + await expect(bindInternalExecutorDelegation(claims(principal))).resolves.toBe(principal) expect(mockResolveDeploymentVersion).toHaveBeenCalledWith({ workflowId: 'child-workflow', deploymentVersionId: 'deployment-version-1', assertedWorkspaceId: 'workspace-1', }) - expect(principal.delegationContext.currentWorkflow).toEqual(currentWorkflow) }) - it('rejects a deployed child version that does not belong to the claimed workflow', async () => { - mockResolveDeploymentVersion.mockRejectedValue( - new OrchestrationError('not_found', 'Workflow deployment version not found') - ) + it('binds a regular draft child in the canonical root workspace', async () => { + const principal = enterPrincipalWorkflowExecution(rootPrincipal(), { + workflowId: 'child-workflow', + mode: 'draft', + }) - await expect( - bindInternalExecutorDelegation( - { - ...claims, - executionId: 'execution-1', - currentWorkflow: { - workflowId: 'child-workflow', - mode: 'deployment', - deploymentVersionId: 'deployment-version-1', - }, - }, - { audience: 'sim:credential-groups' } - ) - ).rejects.toBeInstanceOf(InvalidInternalDelegationBindingError) + await expect(bindInternalExecutorDelegation(claims(principal))).resolves.toBe(principal) + expect(mockResolveWorkflow).toHaveBeenCalledWith({ + workflowId: 'child-workflow', + assertedWorkspaceId: 'workspace-1', + }) }) - it('rejects current workflow authority from another workspace', async () => { - mockResolveWorkflow.mockResolvedValueOnce({ + it('rejects a deployed child version that is not canonical', async () => { + mockResolveDeploymentVersion.mockRejectedValue( + new OrchestrationError('not_found', 'Workflow deployment version not found') + ) + const principal = enterPrincipalWorkflowExecution(rootPrincipal(), { workflowId: 'child-workflow', - workspaceId: 'workspace-2', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', }) - await expect( - bindInternalExecutorDelegation( - { - ...claims, - executionId: 'execution-1', - currentWorkflow: { workflowId: 'child-workflow', mode: 'draft' }, - }, - { audience: 'sim:credential-groups' } - ) - ).rejects.toBeInstanceOf(InvalidInternalDelegationBindingError) - expect(mockResolveDeploymentVersion).not.toHaveBeenCalled() + await expect(bindInternalExecutorDelegation(claims(principal))).rejects.toBeInstanceOf( + InvalidInternalDelegationBindingError + ) }) - it('does not disguise current-workflow infrastructure failures as invalid credentials', async () => { - const infrastructureError = new Error('deployment database unavailable') - mockResolveDeploymentVersion.mockRejectedValue(infrastructureError) + it('rejects a regular child outside the canonical workspace', async () => { + mockResolveWorkflow.mockRejectedValue( + new OrchestrationError('not_found', 'Workflow not found in workspace') + ) + const principal = enterPrincipalWorkflowExecution(rootPrincipal(), { + workflowId: 'child-workflow', + mode: 'draft', + }) - await expect( - bindInternalExecutorDelegation( - { - ...claims, - executionId: 'execution-1', - currentWorkflow: { - workflowId: 'child-workflow', - mode: 'deployment', - deploymentVersionId: 'deployment-version-1', - }, - }, - { audience: 'sim:credential-groups' } - ) - ).rejects.toBe(infrastructureError) + await expect(bindInternalExecutorDelegation(claims(principal))).rejects.toBeInstanceOf( + InvalidInternalDelegationBindingError + ) }) - it('rejects current workflow authority without a canonical execution binding', async () => { - await expect( - bindInternalExecutorDelegation( - { - ...claims, - currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, - }, - { audience: 'sim:credential-groups' } - ) - ).rejects.toBeInstanceOf(InvalidInternalDelegationBindingError) + it('rejects a principal whose own workspace disagrees with the canonical run', async () => { + const principal = rootPrincipal({ + kind: 'workspace_api_key', + workspaceId: 'workspace-2', + keyId: 'key-1', + }) - expect(mockResolveExecution).not.toHaveBeenCalled() + await expect(bindInternalExecutorDelegation(claims(principal))).rejects.toBeInstanceOf( + InvalidInternalDelegationBindingError + ) }) it('binds root deployment authority to the immutable version recorded on the run', async () => { - const currentWorkflow = { + mockResolveExecution.mockResolvedValueOnce({ workflowId: 'workflow-1', - mode: 'deployment' as const, + workspaceId: 'workspace-1', + runId: 'execution-1', deploymentVersionId: 'deployment-version-1', - } - - const principal = await bindInternalExecutorDelegation( - { ...claims, executionId: 'execution-1', currentWorkflow }, - { audience: 'sim:credential-groups' } + }) + const principal = bindPrincipalExecutionMetadata( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, + } ) - expect(principal.delegationContext.currentWorkflow).toEqual(currentWorkflow) + await expect(bindInternalExecutorDelegation(claims(principal))).resolves.toBe(principal) expect(mockResolveDeploymentVersion).not.toHaveBeenCalled() }) - it('rejects root deployment authority that disagrees with the durable run version', async () => { - mockResolveExecution.mockResolvedValueOnce({ + it('rejects root authority that disagrees with the durable run mode or version', async () => { + mockResolveExecution.mockResolvedValue({ workflowId: 'workflow-1', workspaceId: 'workspace-1', runId: 'execution-1', - deploymentVersionId: 'deployment-version-new', + deploymentVersionId: 'deployment-version-2', }) - await expect( - bindInternalExecutorDelegation( - { - ...claims, - executionId: 'execution-1', - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-version-old', - }, - }, - { audience: 'sim:credential-groups' } - ) - ).rejects.toBeInstanceOf(InvalidInternalDelegationBindingError) - }) - - it('rejects draft root authority for a durably deployed run', async () => { - await expect( - bindInternalExecutorDelegation( - { - ...claims, - executionId: 'execution-1', - currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, - }, - { audience: 'sim:credential-groups' } - ) - ).rejects.toBeInstanceOf(InvalidInternalDelegationBindingError) - }) - - it('fails before canonical loading when the domain audience is missing', async () => { - await expect(bindInternalExecutorDelegation(claims, { audience: ' ' })).rejects.toThrow( - 'Internal delegation audience must not be empty' + await expect(bindInternalExecutorDelegation(claims(rootPrincipal()))).rejects.toBeInstanceOf( + InvalidInternalDelegationBindingError ) - expect(mockResolveWorkflow).not.toHaveBeenCalled() - }) - - it('fails before canonical loading when the compatibility actor is empty', async () => { - await expect( - bindInternalExecutorDelegation(claims, { - audience: 'sim:workspace-files', - compatibilityActorUserId: ' ', - }) - ).rejects.toThrow('Internal delegation execution actor must not be empty') - expect(mockResolveWorkflow).not.toHaveBeenCalled() }) - it('classifies a missing canonical execution as an invalid delegation binding', async () => { - mockResolveRun.mockRejectedValue(new OrchestrationError('not_found', 'Workflow run not found')) + it('classifies a missing canonical execution as an invalid binding', async () => { + mockResolveExecution.mockRejectedValue( + new OrchestrationError('not_found', 'Workflow run not found') + ) - await expect( - bindInternalExecutorDelegation( - { ...claims, executionId: 'execution-1' }, - { audience: 'sim:workspace-files' } - ) - ).rejects.toBeInstanceOf(InvalidInternalDelegationBindingError) + await expect(bindInternalExecutorDelegation(claims())).rejects.toBeInstanceOf( + InvalidInternalDelegationBindingError + ) }) it('does not disguise canonical-load infrastructure failures as invalid credentials', async () => { const infrastructureError = new Error('database unavailable') - mockResolveWorkflow.mockRejectedValue(infrastructureError) + mockResolveExecution.mockRejectedValue(infrastructureError) - await expect( - bindInternalExecutorDelegation(claims, { audience: 'sim:workspace-files' }) - ).rejects.toBe(infrastructureError) + await expect(bindInternalExecutorDelegation(claims())).rejects.toBe(infrastructureError) }) }) diff --git a/apps/sim/lib/auth/internal-delegation.ts b/apps/sim/lib/auth/internal-delegation.ts index 502ab3859b8..850d7d0635c 100644 --- a/apps/sim/lib/auth/internal-delegation.ts +++ b/apps/sim/lib/auth/internal-delegation.ts @@ -1,23 +1,26 @@ -import type { - BoundWorkflowExecutionDelegatedPrincipal, - DelegatedPrincipal, +import { + type BoundWorkflowExecutionPrincipal, + requirePrincipalExecutionMetadata, + resolvePrincipalSubject, + withPrincipalExecutionActor, } from '@sim/auth/principal' import type { VerifiedInternalDelegation } from '@/lib/auth/internal' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { - type ActiveWorkflowApplicationContext, resolveActiveWorkflowApplicationContext, resolveActiveWorkflowDeploymentVersionApplicationContext, resolveActiveWorkflowExecutionApplicationContext, - resolveActiveWorkflowRunApplicationContext, } from '@/lib/workflows/application/context' export interface BindInternalExecutorDelegationOptions { - audience: string - resourceScope?: DelegatedPrincipal['resourceScope'] compatibilityActorUserId?: string } +export interface BoundRuntimeWorkflowExecution { + principal: BoundWorkflowExecutionPrincipal + workspaceId: string +} + export class InvalidInternalDelegationBindingError extends Error { constructor() { super('Internal delegation no longer resolves to an active workflow execution') @@ -25,103 +28,108 @@ export class InvalidInternalDelegationBindingError extends Error { } } -/** Binds signed executor claims to the workflow's canonical active workspace. */ -export async function bindInternalExecutorDelegation( - claims: VerifiedInternalDelegation, - options: BindInternalExecutorDelegationOptions -): Promise { - if (!options.audience.trim()) throw new Error('Internal delegation audience must not be empty') +function requireCanonicalPrincipalScope( + principal: BoundWorkflowExecutionPrincipal, + workspaceId: string, + rootWorkflowId: string +): void { + if ( + (principal.kind === 'workspace_api_key' || + principal.kind === 'system' || + principal.kind === 'delegated') && + principal.workspaceId !== workspaceId + ) { + throw new InvalidInternalDelegationBindingError() + } + if (principal.kind === 'system' && principal.workflowId !== rootWorkflowId) { + throw new InvalidInternalDelegationBindingError() + } +} + +/** Revalidates execution authority and returns its principal and canonical workspace. */ +export async function bindRuntimeWorkflowExecution( + principal: BoundWorkflowExecutionPrincipal, + options: BindInternalExecutorDelegationOptions = {} +): Promise { if (options.compatibilityActorUserId !== undefined && !options.compatibilityActorUserId.trim()) { throw new Error('Internal delegation execution actor must not be empty') } - if (claims.subjectUserId && options.compatibilityActorUserId) { - throw new Error('Internal delegation cannot bind a compatibility actor to a user subject') + const executionMetadata = requirePrincipalExecutionMetadata(principal) + const subject = resolvePrincipalSubject(principal) + if (subject && options.compatibilityActorUserId !== undefined) { + throw new Error('Internal delegation cannot bind a compatibility actor to a subject') } - let context: ActiveWorkflowApplicationContext - let rootDeploymentVersionId: string | null | undefined + let workspaceId: string try { - if (claims.currentWorkflow) { - if (!claims.executionId) throw new InvalidInternalDelegationBindingError() - const executionContext = await resolveActiveWorkflowExecutionApplicationContext({ - runId: claims.executionId, - assertedWorkflowId: claims.workflowId, - }) - context = executionContext - rootDeploymentVersionId = executionContext.deploymentVersionId - } else if (claims.executionId) { - context = await resolveActiveWorkflowRunApplicationContext({ - runId: claims.executionId, - assertedWorkflowId: claims.workflowId, + const rootContext = await resolveActiveWorkflowExecutionApplicationContext({ + runId: executionMetadata.executionId, + assertedWorkflowId: executionMetadata.rootWorkflowId, + }) + requireCanonicalPrincipalScope( + principal, + rootContext.workspaceId, + executionMetadata.rootWorkflowId + ) + workspaceId = rootContext.workspaceId + + const currentWorkflow = executionMetadata.currentWorkflow + if (currentWorkflow.workflowId === executionMetadata.rootWorkflowId) { + const matchesRootAuthority = + currentWorkflow.mode === 'draft' + ? rootContext.deploymentVersionId === null + : rootContext.deploymentVersionId === currentWorkflow.deploymentVersionId + if (!matchesRootAuthority) throw new InvalidInternalDelegationBindingError() + } else if (currentWorkflow.mode === 'deployment') { + await resolveActiveWorkflowDeploymentVersionApplicationContext({ + workflowId: currentWorkflow.workflowId, + deploymentVersionId: currentWorkflow.deploymentVersionId, + assertedWorkspaceId: rootContext.workspaceId, }) } else { - context = await resolveActiveWorkflowApplicationContext({ workflowId: claims.workflowId }) + await resolveActiveWorkflowApplicationContext({ + workflowId: currentWorkflow.workflowId, + assertedWorkspaceId: rootContext.workspaceId, + }) } } catch (error) { - if (asOrchestrationError(error)?.code === 'not_found') { + if ( + error instanceof InvalidInternalDelegationBindingError || + asOrchestrationError(error)?.code === 'not_found' + ) { throw new InvalidInternalDelegationBindingError() } throw error } - if (claims.currentWorkflow) { - if (claims.currentWorkflow.workflowId === context.workflowId) { - const matchesRootExecution = - claims.currentWorkflow.mode === 'draft' - ? rootDeploymentVersionId === null - : rootDeploymentVersionId === claims.currentWorkflow.deploymentVersionId - if (!matchesRootExecution) { - throw new InvalidInternalDelegationBindingError() - } - } else { - try { - const currentContext = - claims.currentWorkflow.mode === 'deployment' - ? await resolveActiveWorkflowDeploymentVersionApplicationContext({ - workflowId: claims.currentWorkflow.workflowId, - deploymentVersionId: claims.currentWorkflow.deploymentVersionId, - assertedWorkspaceId: context.workspaceId, - }) - : await resolveActiveWorkflowApplicationContext({ - workflowId: claims.currentWorkflow.workflowId, - assertedWorkspaceId: context.workspaceId, - }) - if (currentContext.workspaceId !== context.workspaceId) { - throw new InvalidInternalDelegationBindingError() - } - } catch (error) { - if (asOrchestrationError(error)?.code === 'not_found') { - throw new InvalidInternalDelegationBindingError() - } - throw error - } - } - } - return { - kind: 'delegated', - serviceId: 'executor', - ...(claims.subjectUserId ? { subjectUserId: claims.subjectUserId } : {}), - workspaceId: context.workspaceId, - delegationId: claims.delegationId, - audience: options.audience, - issuedAt: claims.issuedAt, - expiresAt: claims.expiresAt, - ...(options.resourceScope ? { resourceScope: options.resourceScope } : {}), - delegationContext: { - kind: 'workflow_execution', - workflowId: context.workflowId, - ...(claims.executionId ? { executionId: claims.executionId } : {}), - ...(claims.principal ? { principal: claims.principal } : {}), - ...(claims.currentWorkflow ? { currentWorkflow: claims.currentWorkflow } : {}), - ...(options.compatibilityActorUserId - ? { - compatibilityActor: { - kind: 'legacy_execution_user', - userId: options.compatibilityActorUserId, - } as const, - } - : {}), - }, + principal: + options.compatibilityActorUserId !== undefined + ? withPrincipalExecutionActor(principal, options.compatibilityActorUserId) + : principal, + workspaceId, } } + +/** Revalidates execution authority and returns the same semantic runtime principal. */ +export async function bindRuntimeWorkflowExecutionPrincipal( + principal: BoundWorkflowExecutionPrincipal, + options: BindInternalExecutorDelegationOptions = {} +): Promise { + return (await bindRuntimeWorkflowExecution(principal, options)).principal +} + +/** Revalidates the runtime principal admitted by an internal executor token. */ +export function bindInternalExecutorDelegation( + claims: VerifiedInternalDelegation, + options: BindInternalExecutorDelegationOptions = {} +): Promise { + return bindRuntimeWorkflowExecutionPrincipal(claims.principal, options) +} + +/** Revalidates JWT admission while retaining canonical transport workspace scope. */ +export function bindInternalExecutorDelegationAdmission( + claims: VerifiedInternalDelegation +): Promise { + return bindRuntimeWorkflowExecution(claims.principal) +} diff --git a/apps/sim/lib/auth/internal.test.ts b/apps/sim/lib/auth/internal.test.ts index 6bf619889b0..dad937805f8 100644 --- a/apps/sim/lib/auth/internal.test.ts +++ b/apps/sim/lib/auth/internal.test.ts @@ -2,6 +2,11 @@ * @vitest-environment node */ +import { + bindPrincipalExecutionMetadata, + enterPrincipalWorkflowExecution, + type WorkflowExecutionPrincipal, +} from '@sim/auth/principal' import { resetEnvMock } from '@sim/testing' import { decodeJwt } from 'jose' import { afterAll, describe, expect, it, vi } from 'vitest' @@ -48,74 +53,53 @@ describe('internal JWT claims', () => { }) describe('internal executor delegation claims', () => { - it('round-trips a subject-bearing workflow execution delegation', async () => { - const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', + const cases: Array<{ + name: string + principal: WorkflowExecutionPrincipal + expectedSubject?: string + }> = [ + { + name: 'manual session', + principal: { kind: 'session', userId: 'manual-user', sessionId: 'session-1' }, + expectedSubject: 'manual-user', + }, + { + name: 'personal API key', + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key-1' }, + expectedSubject: 'key-user', + }, + { + name: 'workspace API key', + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + }, + { + name: 'schedule', principal: { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'copilot-1', - audience: 'sim:workflows', - issuedAt: new Date('2026-01-01T00:00:00.000Z'), - expiresAt: new Date('2026-01-01T00:05:00.000Z'), - }, - }) - - const delegation = await verifyInternalDelegationToken(token) - - expect(delegation).toMatchObject({ - serviceId: 'executor', - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - principal: expect.objectContaining({ - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - }), - }) - expect(delegation.delegationId).toBeTruthy() - expect(delegation.issuedAt).toBeInstanceOf(Date) - expect(delegation.expiresAt.getTime()).toBeGreaterThan(delegation.issuedAt.getTime()) - }) - - it('round-trips an actorless workspace-key delegation without inventing a user', async () => { - const token = await generateInternalDelegationToken({ - workflowId: 'workflow-1', - executionId: 'execution-1', - principal: { - kind: 'workspace_api_key', + kind: 'system', + serviceId: 'schedule', workspaceId: 'workspace-1', - keyId: 'key-1', + workflowId: 'workflow-1', }, - }) - - await expect(verifyInternalDelegationToken(token)).resolves.toMatchObject({ - serviceId: 'executor', - workflowId: 'workflow-1', - executionId: 'execution-1', + }, + { + name: 'generic webhook', principal: { - kind: 'workspace_api_key', + kind: 'system', + serviceId: 'webhook', workspaceId: 'workspace-1', - keyId: 'key-1', + workflowId: 'workflow-1', + webhookId: 'webhook-generic', + provider: 'generic', }, - }) - expect(decodeJwt(token).sub).toBeUndefined() - }) - - it('round-trips an external webhook subject without inventing a Sim user', async () => { - const token = await generateInternalDelegationToken({ - workflowId: 'workflow-1', + }, + { + name: 'Slack webhook', principal: { kind: 'system', serviceId: 'webhook', workspaceId: 'workspace-1', workflowId: 'workflow-1', - webhookId: 'webhook-1', + webhookId: 'webhook-slack', provider: 'slack', subject: { kind: 'external_user', @@ -124,102 +108,124 @@ describe('internal executor delegation claims', () => { subjectId: 'U123', }, }, - }) + }, + { + name: 'deployed API', + principal: { + kind: 'system', + serviceId: 'public_api', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + }, + { + name: 'deployed chat', + principal: { + kind: 'system', + serviceId: 'chat', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + }, + ] - const delegation = await verifyInternalDelegationToken(token) - expect(delegation.subjectUserId).toBeUndefined() - expect(delegation.principal).toMatchObject({ - kind: 'system', - serviceId: 'webhook', - subject: { kind: 'external_user', tenantId: 'T123', subjectId: 'U123' }, + it.each(cases)('round-trips the $name runtime principal unchanged', async (testCase) => { + const principal = bindPrincipalExecutionMetadata(testCase.principal, { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, }) + const token = await generateInternalDelegationToken({ principal }) + const delegation = await verifyInternalDelegationToken(token) + + expect(delegation.principal).toEqual(principal) + expect(decodeJwt(token).sub).toBe(testCase.expectedSubject) + expect(delegation.delegationId).toBeTruthy() + expect(delegation.issuedAt).toBeInstanceOf(Date) + expect(delegation.expiresAt.getTime()).toBeGreaterThan(delegation.issuedAt.getTime()) }) - it('round-trips the currently executing deployed workflow authority', async () => { - const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'root-workflow', - executionId: 'execution-1', - currentWorkflow: { - workflowId: 'child-workflow', - mode: 'deployment', - deploymentVersionId: 'deployment-version-1', - }, + it('keeps the original actor and root execution while entering a deployed child', async () => { + const root = bindPrincipalExecutionMetadata( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { + executionId: 'execution-1', + rootWorkflowId: 'root-workflow', + currentWorkflow: { + workflowId: 'root-workflow', + mode: 'deployment', + deploymentVersionId: 'root-version-1', + }, + } + ) + const child = enterPrincipalWorkflowExecution(root, { + workflowId: 'child-workflow', + mode: 'deployment', + deploymentVersionId: 'child-version-1', }) + const token = await generateInternalDelegationToken({ principal: child }) await expect(verifyInternalDelegationToken(token)).resolves.toMatchObject({ - workflowId: 'root-workflow', - currentWorkflow: { - workflowId: 'child-workflow', - mode: 'deployment', - deploymentVersionId: 'deployment-version-1', + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + executionMetadata: { + executionId: 'execution-1', + rootWorkflowId: 'root-workflow', + currentWorkflow: { + workflowId: 'child-workflow', + mode: 'deployment', + deploymentVersionId: 'child-version-1', + }, + }, }, }) }) - it('refuses to issue current workflow authority without an execution binding', async () => { - await expect( - generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'root-workflow', - currentWorkflow: { workflowId: 'root-workflow', mode: 'draft' }, - }) - ).rejects.toThrow('Internal delegation currentWorkflow requires executionId') - }) - - it('rejects malformed workflow authority instead of dropping its fields', async () => { + it('refuses to issue a delegation without execution metadata', async () => { await expect( generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'root-workflow', - currentWorkflow: { - workflowId: 'child-workflow', - mode: 'draft', - unexpected: true, + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', } as never, }) - ).rejects.toBeInstanceOf(InvalidInternalDelegationTokenError) + ).rejects.toThrow('missing execution metadata') }) - it('rejects laundering actorless or external principals into a Sim user subject', async () => { - await expect( - generateInternalDelegationToken({ - subjectUserId: 'billing-owner', - workflowId: 'workflow-1', - principal: { - kind: 'workspace_api_key', - workspaceId: 'workspace-1', - keyId: 'key-1', - }, - }) - ).rejects.toThrow('Actorless workflow principals cannot be represented as Sim users') - + it('rejects malformed workflow authority instead of dropping its fields', async () => { await expect( generateInternalDelegationToken({ - subjectUserId: 'billing-owner', - workflowId: 'workflow-1', principal: { - kind: 'system', - serviceId: 'webhook', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - webhookId: 'webhook-1', - provider: 'slack', - subject: { - kind: 'external_user', - provider: 'slack', - tenantId: 'T123', - subjectId: 'U123', + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + executionMetadata: { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'draft', + unexpected: true, + }, }, - }, + } as never, }) - ).rejects.toThrow('External workflow subjects cannot be represented as Sim users') + ).rejects.toThrow('unsupported field unexpected') }) it('derives issued-at and expiry from one timestamp', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', + principal: bindPrincipalExecutionMetadata( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, + } + ), }) const payload = decodeJwt(token) @@ -229,15 +235,6 @@ describe('internal executor delegation claims', () => { expect(payload.exp - payload.iat).toBe(5 * 60) }) - it('rejects missing delegation scope at issuance', async () => { - await expect( - generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: ' ', - }) - ).rejects.toThrow('Internal delegation workflowId must not be empty') - }) - it('does not accept legacy subject or actorless tokens as executor delegations', async () => { const legacySubjectToken = await generateInternalToken('user-1') const actorlessToken = await generateInternalToken() diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts index fe410edf9a4..68920aa6603 100644 --- a/apps/sim/lib/auth/internal.ts +++ b/apps/sim/lib/auth/internal.ts @@ -1,9 +1,9 @@ import { + type BoundWorkflowExecutionPrincipal, parsePrincipal, + requirePrincipalExecutionMetadata, resolvePrincipalSubject, serializePrincipal, - type WorkflowExecutionAuthority, - type WorkflowExecutionPrincipal, } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' @@ -23,20 +23,12 @@ export interface InternalTokenClaims { } export interface GenerateInternalDelegationTokenInput { - subjectUserId?: string - workflowId: string - executionId?: string - principal?: WorkflowExecutionPrincipal - currentWorkflow?: WorkflowExecutionAuthority + principal: BoundWorkflowExecutionPrincipal } export interface VerifiedInternalDelegation { serviceId: 'executor' - subjectUserId?: string - workflowId: string - executionId?: string - principal?: WorkflowExecutionPrincipal - currentWorkflow?: WorkflowExecutionAuthority + principal: BoundWorkflowExecutionPrincipal delegationId: string issuedAt: Date expiresAt: Date @@ -97,84 +89,18 @@ export async function generateInternalToken( return token } -function requireNonEmptyDelegationClaim(value: string, name: string): string { - if (!value.trim()) throw new Error(`Internal delegation ${name} must not be empty`) - return value -} - -function parseWorkflowExecutionAuthority(value: unknown): WorkflowExecutionAuthority { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new InvalidInternalDelegationTokenError() - } - const authority = value as Record - const workflowId = readVerifiedDelegationClaim(authority.workflowId) - if (!workflowId) throw new InvalidInternalDelegationTokenError() - if (authority.mode === 'draft') { - if (Object.keys(authority).some((key) => !['workflowId', 'mode'].includes(key))) { - throw new InvalidInternalDelegationTokenError() - } - return { workflowId, mode: 'draft' } - } - if (authority.mode === 'deployment') { - const deploymentVersionId = readVerifiedDelegationClaim(authority.deploymentVersionId) - if ( - !deploymentVersionId || - Object.keys(authority).some( - (key) => !['workflowId', 'mode', 'deploymentVersionId'].includes(key) - ) - ) { - throw new InvalidInternalDelegationTokenError() - } - return { workflowId, mode: 'deployment', deploymentVersionId } - } - throw new InvalidInternalDelegationTokenError() -} - -/** Generates an executor token bound to its workflow origin and authenticated caller. */ +/** Generates a transport token for an already-bound runtime principal. */ export async function generateInternalDelegationToken( input: GenerateInternalDelegationTokenInput ): Promise { - const suppliedSubjectUserId = input.subjectUserId - ? requireNonEmptyDelegationClaim(input.subjectUserId, 'subjectUserId') - : undefined - const principalSubject = input.principal ? resolvePrincipalSubject(input.principal) : null - if (principalSubject?.kind === 'external_user' && suppliedSubjectUserId) { - throw new Error('External workflow subjects cannot be represented as Sim users') - } - if (!principalSubject && input.principal && suppliedSubjectUserId) { - throw new Error('Actorless workflow principals cannot be represented as Sim users') - } - if ( - principalSubject?.kind === 'sim_user' && - suppliedSubjectUserId && - suppliedSubjectUserId !== principalSubject.userId - ) { - throw new Error('Internal delegation subject does not match its workflow principal') - } - const subjectUserId = - principalSubject?.kind === 'sim_user' ? principalSubject.userId : suppliedSubjectUserId - if (!subjectUserId && !input.principal) { - throw new Error('Internal delegation requires a workflow principal or Sim user subject') - } - const workflowId = requireNonEmptyDelegationClaim(input.workflowId, 'workflowId') - const currentWorkflow = input.currentWorkflow - ? parseWorkflowExecutionAuthority(input.currentWorkflow) - : undefined + requirePrincipalExecutionMetadata(input.principal) + const principalSubject = resolvePrincipalSubject(input.principal) const issuedAtSeconds = Math.floor(Date.now() / 1000) - const executionId = input.executionId - ? requireNonEmptyDelegationClaim(input.executionId, 'executionId') - : undefined - if (currentWorkflow && !executionId) { - throw new Error('Internal delegation currentWorkflow requires executionId') - } let token = new SignJWT({ type: 'internal_delegation', serviceId: 'executor', - workflowId, - ...(input.principal ? { principal: serializePrincipal(input.principal) } : {}), - ...(currentWorkflow ? { currentWorkflow } : {}), - ...(executionId ? { executionId } : {}), + principal: serializePrincipal(input.principal, 2), }) .setProtectedHeader({ alg: 'HS256' }) .setJti(generateId()) @@ -182,7 +108,7 @@ export async function generateInternalDelegationToken( .setExpirationTime(issuedAtSeconds + INTERNAL_DELEGATION_TTL_SECONDS) .setIssuer(INTERNAL_DELEGATION_ISSUER) .setAudience(INTERNAL_DELEGATION_AUDIENCE) - if (subjectUserId) token = token.setSubject(subjectUserId) + if (principalSubject?.kind === 'sim_user') token = token.setSubject(principalSubject.userId) return token.sign(getJwtSecret()) } @@ -190,7 +116,7 @@ function readVerifiedDelegationClaim(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value : null } -/** Verifies a scoped executor delegation without accepting unbound legacy tokens. */ +/** Verifies transport admission and restores the signed runtime principal unchanged. */ export async function verifyInternalDelegationToken( token: string ): Promise { @@ -208,31 +134,36 @@ export async function verifyInternalDelegationToken( throw new InvalidInternalDelegationTokenError() } + const allowedClaims = new Set([ + 'type', + 'serviceId', + 'principal', + 'sub', + 'jti', + 'iat', + 'exp', + 'iss', + 'aud', + ]) + if (Object.keys(payload).some((claim) => !allowedClaims.has(claim))) { + throw new InvalidInternalDelegationTokenError() + } + const subjectUserId = readVerifiedDelegationClaim(payload.sub) - const workflowId = readVerifiedDelegationClaim(payload.workflowId) - const executionId = - payload.executionId === undefined ? undefined : readVerifiedDelegationClaim(payload.executionId) const delegationId = readVerifiedDelegationClaim(payload.jti) const nowSeconds = Math.floor(Date.now() / 1000) - let principal: WorkflowExecutionPrincipal | undefined - let currentWorkflow: WorkflowExecutionAuthority | undefined - if (payload.principal !== undefined) { - try { - principal = parsePrincipal(payload.principal) - } catch { - throw new InvalidInternalDelegationTokenError() - } - } - if (payload.currentWorkflow !== undefined) { - currentWorkflow = parseWorkflowExecutionAuthority(payload.currentWorkflow) + let principal: BoundWorkflowExecutionPrincipal + try { + const parsed = parsePrincipal(payload.principal) + requirePrincipalExecutionMetadata(parsed) + principal = parsed as BoundWorkflowExecutionPrincipal + } catch { + throw new InvalidInternalDelegationTokenError() } if ( payload.type !== 'internal_delegation' || payload.serviceId !== 'executor' || - !workflowId || - executionId === null || - (currentWorkflow !== undefined && executionId === undefined) || !delegationId || typeof payload.iat !== 'number' || typeof payload.exp !== 'number' || @@ -245,21 +176,16 @@ export async function verifyInternalDelegationToken( const principalSubject = principal ? resolvePrincipalSubject(principal) : null if ( - (!principal && !subjectUserId) || (principalSubject?.kind === 'sim_user' && principalSubject.userId !== subjectUserId) || (principalSubject?.kind === 'external_user' && subjectUserId) || - (principal && !principalSubject && subjectUserId) + (!principalSubject && subjectUserId) ) { throw new InvalidInternalDelegationTokenError() } return { serviceId: 'executor', - ...(subjectUserId ? { subjectUserId } : {}), - workflowId, - ...(principal ? { principal } : {}), - ...(currentWorkflow ? { currentWorkflow } : {}), - ...(executionId ? { executionId } : {}), + principal, delegationId, issuedAt: new Date(payload.iat * 1000), expiresAt: new Date(payload.exp * 1000), diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index 8e5e4c108c9..5f8f1bafe18 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -2,6 +2,8 @@ * @vitest-environment node */ import { + bindPrincipalExecutionMetadata, + enterPrincipalWorkflowExecution, PrincipalSubjectUserRequiredError, parsePrincipal, requirePrincipalSubjectUserId, @@ -12,6 +14,7 @@ import { resolvePrincipalSubjectUserId, serializePrincipal, toPrincipalActor, + withPrincipalExecutionActor, } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' @@ -56,7 +59,7 @@ describe('principal subject users', () => { expect( resolvePrincipalSubjectUserId({ kind: 'delegated', - serviceId: 'executor', + serviceId: 'copilot', subjectUserId: 'delegated-user', workspaceId: 'workspace-1', delegationId: 'delegation-1', @@ -81,21 +84,16 @@ describe('principal subject users', () => { ).toBeUndefined() expect( resolvePrincipalSubjectUserId({ - kind: 'delegated', - serviceId: 'executor', + kind: 'system', + serviceId: 'schedule', workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:test', - issuedAt: new Date('2026-01-01T00:00:00Z'), - expiresAt: new Date('2026-01-01T00:05:00Z'), - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - principal: { - kind: 'system', - serviceId: 'schedule', - workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionMetadata: { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { workflowId: 'workflow-1', + mode: 'draft', }, }, }) @@ -110,43 +108,54 @@ describe('principal subject users', () => { }) it('resolves only a principal-bound compatibility actor for actorless execution', () => { - const principal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:test', - 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', - currentWorkflow: { + const principal = withPrincipalExecutionActor( + bindPrincipalExecutionMetadata( + { + kind: 'system', + serviceId: 'public_api', + workspaceId: 'workspace-1', workflowId: 'workflow-1', - mode: 'deployment' as const, - deploymentVersionId: 'deployment-1', }, - compatibilityActor: { - kind: 'legacy_execution_user' as const, - userId: 'execution-actor', - }, - }, - } + { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + } + ), + 'execution-actor' + ) expect(resolvePrincipalSubjectUserId(principal)).toBeUndefined() expect(resolvePrincipalExecutionActorUserId(principal)).toBe('execution-actor') expect( - resolvePrincipalExecutionActorUserId({ - ...principal, - subjectUserId: 'authenticated-user', - }) + resolvePrincipalExecutionActorUserId( + bindPrincipalExecutionMetadata( + { kind: 'session', userId: 'authenticated-user', sessionId: 'session-1' }, + { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + } + ) + ) ).toBe('authenticated-user') expect( resolvePrincipalExecutionActorUserId({ ...principal, - delegationContext: { - ...principal.delegationContext, - currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, + executionMetadata: { + ...principal.executionMetadata, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'draft', + }, }, }) ).toBeUndefined() @@ -213,6 +222,12 @@ describe('principal persistence', () => { version: 2, principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, }) + ).toThrow('Serialized principal is missing executionMetadata') + expect(() => + parsePrincipal({ + version: 3, + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + }) ).toThrow('Unsupported serialized principal version') expect(() => parsePrincipal({ @@ -277,6 +292,42 @@ describe('principal persistence', () => { expect(parsePrincipal(serializePrincipal(principal))).toEqual(principal) }) + it('changes only current workflow authority when entering a regular child', () => { + const root = bindPrincipalExecutionMetadata( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'root-version-1', + }, + } + ) + + const child = enterPrincipalWorkflowExecution(root, { + workflowId: 'child-workflow', + mode: 'deployment', + deploymentVersionId: 'child-version-1', + }) + + expect(child).toMatchObject({ + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + executionMetadata: { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'child-workflow', + mode: 'deployment', + deploymentVersionId: 'child-version-1', + }, + }, + }) + }) + it('rejects incomplete or cross-provider webhook identity', () => { expect(() => parsePrincipal({ @@ -466,25 +517,21 @@ describe('principal actors', () => { ).toMatchObject({ attributedUserId: 'user-3' }) }) - it('uses the workspace billing owner only for actorless execution attribution', () => { + it('keeps the system actor while projecting billing-only legacy attribution', () => { const principal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, + kind: 'system' as const, + serviceId: 'webhook' 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', + executionMetadata: { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { workflowId: 'workflow-1', - webhookId: 'webhook-1', - provider: 'generic', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-version-1', }, }, } @@ -495,14 +542,17 @@ describe('principal actors', () => { }) ).toEqual({ actor: { - kind: 'delegated', - serviceId: 'executor', - delegationId: 'delegation-1', + kind: 'system', + serviceId: 'webhook', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + webhookId: 'webhook-1', + provider: 'generic', }, attributedUserId: 'billing-owner-1', }) expect(resolvePrincipalSubject(principal)).toBeNull() - expect(() => resolvePrincipalAttribution(principal)).toThrow(PrincipalSubjectUserRequiredError) + expect(resolvePrincipalExecutionActorUserId(principal)).toBeUndefined() }) it('fails fast when workspace-key attribution has no billing owner', () => { diff --git a/apps/sim/lib/auth/runtime-principal.test-support.ts b/apps/sim/lib/auth/runtime-principal.test-support.ts new file mode 100644 index 00000000000..1e211a5b5e2 --- /dev/null +++ b/apps/sim/lib/auth/runtime-principal.test-support.ts @@ -0,0 +1,43 @@ +import { + type BoundWorkflowExecutionPrincipal, + bindPrincipalExecutionMetadata, + enterPrincipalWorkflowExecution, + type WorkflowExecutionAuthority, + type WorkflowExecutionPrincipal, + withPrincipalExecutionActor, +} from '@sim/auth/principal' + +export interface CreateTestRuntimePrincipalOptions { + principal?: WorkflowExecutionPrincipal + executionId?: string + rootWorkflowId?: string + currentWorkflow?: WorkflowExecutionAuthority + compatibilityActorUserId?: string +} + +/** Builds test execution identity exclusively through the canonical auth-owned binders. */ +export function createTestRuntimePrincipal( + options: CreateTestRuntimePrincipalOptions = {} +): BoundWorkflowExecutionPrincipal { + const principal = + options.principal ?? ({ kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const) + const rootWorkflowId = options.rootWorkflowId ?? 'workflow-1' + const currentWorkflow = + options.currentWorkflow ?? ({ workflowId: rootWorkflowId, mode: 'draft' } as const) + const root = bindPrincipalExecutionMetadata(principal, { + executionId: options.executionId ?? 'execution-1', + rootWorkflowId, + currentWorkflow: + currentWorkflow.workflowId === rootWorkflowId + ? currentWorkflow + : { workflowId: rootWorkflowId, mode: 'draft' }, + }) + const bound = + currentWorkflow.workflowId === rootWorkflowId + ? root + : enterPrincipalWorkflowExecution(root, currentWorkflow) + + return options.compatibilityActorUserId !== undefined + ? withPrincipalExecutionActor(bound, options.compatibilityActorUserId) + : bound +} diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index ba0cfe9ad73..14ad8d51775 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -89,11 +89,6 @@ export async function executeTool( executionId: context.executionId, chatId: context.chatId, toolCallId: context.toolCallId, - executorDelegationOrigin: { - subjectUserId: context.userId, - workflowId: context.workflowId, - ...(context.executionId ? { executionId: context.executionId } : {}), - }, copilotToolExecution: context.copilotToolExecution, copilotInteractionMode: context.copilotInteractionMode, billingAttribution: context.billingAttribution, diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 31f5cb917dd..360973e6cdd 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -669,11 +669,6 @@ export async function executeFunctionExecute( workflowId: context.workflowId, workspaceId: context.workspaceId, executionId: context.executionId, - executorDelegationOrigin: { - subjectUserId: context.userId, - workflowId: context.workflowId, - ...(context.executionId ? { executionId: context.executionId } : {}), - }, copilotToolExecution: context.copilotToolExecution, billingAttribution: context.billingAttribution, resolvedSecretTraceRegistry: mountedRegistry, diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index c53e266ca76..3ea8349649f 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -1,10 +1,13 @@ /** * @vitest-environment node */ -import type { - DelegatedPrincipal, - SessionPrincipal, - WorkspaceApiKeyPrincipal, +import { + bindPrincipalExecutionMetadata, + enterPrincipalWorkflowExecution, + type SessionPrincipal, + type WorkflowExecutionAuthority, + type WorkflowExecutionPrincipal, + type WorkspaceApiKeyPrincipal, } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -60,37 +63,22 @@ const executorOperation = defineWorkspaceOperation({ id: 'test.executor-write', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', }) 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, - }, + originalPrincipal: WorkflowExecutionPrincipal, + currentWorkflow: WorkflowExecutionAuthority +) { + const root = bindPrincipalExecutionMetadata(originalPrincipal, { + executionId: 'execution-1', + rootWorkflowId: 'root-workflow-1', + currentWorkflow: { workflowId: 'root-workflow-1', mode: 'draft' }, + }) + return currentWorkflow.workflowId === 'root-workflow-1' + ? root + : enterPrincipalWorkflowExecution(root, currentWorkflow) } const context = { @@ -204,20 +192,30 @@ describe('authorizeWorkspaceOperation', () => { deploymentVersionId: 'deployment-1', }), executorOperation, - context, - executorAuthorization + context ) ).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 }) => { + it('rejects an actorless caller without execution metadata', async () => { + await expect( + authorizeWorkspaceOperation( + { + kind: 'system', + serviceId: 'webhook', + workspaceId: 'workspace-1', + workflowId: 'root-workflow-1', + webhookId: 'webhook-1', + provider: 'generic', + }, + executorOperation, + context + ) + ).rejects.toBeInstanceOf(PrincipalKindAuthorizationError) + }) + + it('rejects actorless execution with draft workflow authority', async () => { await expect( authorizeWorkspaceOperation( executorPrincipal( @@ -229,11 +227,10 @@ describe('authorizeWorkspaceOperation', () => { webhookId: 'webhook-1', provider: 'generic', }, - currentWorkflow + { workflowId: 'current-workflow-1', mode: 'draft' } ), executorOperation, - context, - executorAuthorization + context ) ).rejects.toMatchObject({ name: 'DelegatedWorkspaceAuthorizationError' }) }) @@ -243,16 +240,12 @@ describe('authorizeWorkspaceOperation', () => { await expect( authorizeWorkspaceOperation( - { - ...executorPrincipal( - { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - { workflowId: 'current-workflow-1', mode: 'draft' } - ), - subjectUserId: 'user-1', - }, + executorPrincipal( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { workflowId: 'current-workflow-1', mode: 'draft' } + ), executorOperation, - context, - executorAuthorization + context ) ).resolves.toBeUndefined() expect(mocks.resolvePermission).toHaveBeenCalledWith( diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index afbd6c5342f..e4440793999 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -1,6 +1,8 @@ import { + type BoundWorkflowExecutionPrincipal, type DelegatedPrincipal, type Principal, + requirePrincipalExecutionMetadata, resolvePrincipalSubject, } from '@sim/auth/principal' import type { db } from '@sim/db' @@ -112,6 +114,13 @@ export function requireAllowedWorkspacePrincipal( principal: Principal, operation: O ): asserts principal is PrincipalForOperation { + if (principal.executionMetadata !== undefined) { + requirePrincipalExecutionMetadata(principal) + if (operation.workflowExecution !== 'allow') { + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + } + return + } if (!operation.principalKinds.some((kind) => kind === principal.kind)) { /** * A workspace key refused because the operation does not delegate to one is @@ -140,6 +149,39 @@ export function requireAllowedWorkspacePrincipal( } } +function requireExecutionPrincipalWorkspace( + principal: BoundWorkflowExecutionPrincipal, + workspaceId: string +): void { + if ( + (principal.kind === 'workspace_api_key' || + principal.kind === 'system' || + principal.kind === 'delegated') && + principal.workspaceId !== workspaceId + ) { + throw new DelegatedWorkspaceAuthorizationError() + } +} + +async function authorizeWorkflowExecution( + principal: BoundWorkflowExecutionPrincipal, + operation: WorkspaceOperation, + context: C, + options?: WorkspaceAuthorizationOptions +): Promise { + const executionMetadata = requirePrincipalExecutionMetadata(principal) + requireExecutionPrincipalWorkspace(principal, context.workspaceId) + + const subject = resolvePrincipalSubject(principal) + if (subject?.kind === 'sim_user') { + await requireCurrentHumanPermission(subject.userId, context, operation.minimumRole, options) + return + } + if (executionMetadata.currentWorkflow.mode !== 'deployment') { + throw new DelegatedWorkspaceAuthorizationError() + } +} + function requirePermission(permission: PermissionType | null, required: PermissionType): void { if (permission === null) { throw new NoWorkspaceAccessError() @@ -173,6 +215,16 @@ export async function authorizeWorkspaceOperation { requireAllowedWorkspacePrincipal(principal, operation) + if (principal.executionMetadata !== undefined) { + await authorizeWorkflowExecution( + principal as BoundWorkflowExecutionPrincipal, + operation, + context, + options + ) + return + } + switch (principal.kind) { case 'session': await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) @@ -207,17 +259,12 @@ export async function authorizeWorkspaceOperation[number] } : never +type WorkflowExecutionPrincipalForOperation = + O['workflowExecution'] extends 'allow' ? BoundWorkflowExecutionPrincipal : never + export type PrincipalForOperation< O extends { readonly principalKinds: readonly PrincipalKind[] readonly delegatedServices?: readonly DelegatedServiceId[] + readonly workflowExecution?: 'allow' }, -> = NonDelegatedPrincipalForOperation | DelegatedPrincipalForOperation +> = + | NonDelegatedPrincipalForOperation + | DelegatedPrincipalForOperation + | WorkflowExecutionPrincipalForOperation export interface WorkspaceOperation< Id extends string = string, Role extends PermissionType = PermissionType, PrincipalKinds extends readonly PrincipalKind[] = readonly PrincipalKind[], DelegatedServices extends readonly DelegatedServiceId[] = readonly DelegatedServiceId[], + WorkflowExecution extends 'allow' | undefined = 'allow' | undefined, > extends ApplicationOperation { readonly minimumRole: Role readonly workspaceApiKey: WorkspaceApiKeyPolicy readonly principalKinds: PrincipalKinds readonly delegatedServices?: DelegatedServices + readonly workflowExecution?: WorkflowExecution } type WorkspaceApiKeyPrincipalConsistency< @@ -73,17 +87,20 @@ export function defineWorkspaceOperation< const Role extends PermissionType, const PrincipalKinds extends readonly PrincipalKind[], const DelegatedServices extends readonly DelegatedServiceId[] = readonly [], + const WorkflowExecution extends 'allow' | undefined = undefined, const ResourcePolicy extends ResourcePolicyBinding | undefined = undefined, >( - operation: WorkspaceOperation & + operation: WorkspaceOperation & WorkspaceApiKeyPrincipalConsistency & DelegatedPrincipalConsistency & ResourcePolicyOperationConsistency -): WorkspaceOperation & +): WorkspaceOperation & DelegatedPrincipalConsistency & ResourcePolicyOperationConsistency { - if (operation.principalKinds.length === 0) { - throw new Error(`Operation ${operation.id} must allow at least one principal kind`) + if (operation.principalKinds.length === 0 && operation.workflowExecution !== 'allow') { + throw new Error( + `Operation ${operation.id} must allow at least one principal kind or workflow execution` + ) } if (new Set(operation.principalKinds).size !== operation.principalKinds.length) { throw new Error(`Operation ${operation.id} declares duplicate principal kinds`) diff --git a/apps/sim/lib/credential-groups/application/authorization.test.ts b/apps/sim/lib/credential-groups/application/authorization.test.ts index fec8b1a7bbb..b5a1564405d 100644 --- a/apps/sim/lib/credential-groups/application/authorization.test.ts +++ b/apps/sim/lib/credential-groups/application/authorization.test.ts @@ -2,8 +2,13 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { BoundWorkflowExecutionPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' +import { + requireCredentialGroupCredentialAccess, + requireCredentialGroupWorkflowActor, +} from '@/lib/credential-groups/application/authorization' import { compileCredentialGroupWorkflowAccessPolicy } from '@/lib/credential-groups/application/workflow-access-policy' import { credentialOperations } from '@/lib/credentials/application/operations' @@ -20,11 +25,6 @@ vi.mock('@/lib/resource-policies/repository', () => ({ requireResourcePolicy: mocks.requirePolicy, })) -import { - requireCredentialGroupCredentialAccess, - requireCredentialGroupWorkflowActor, -} from '@/lib/credential-groups/application/authorization' - const context = { workspaceId: 'workspace-1', workspaceOrganizationId: null, @@ -47,43 +47,33 @@ function storedPolicy(allowedWorkflowIds: string[] = []) { } } -function executorPrincipal(): WorkflowExecutionDelegatedPrincipal { - return { - kind: 'delegated', - serviceId: 'executor', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:managed-oauth-credentials', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution', +function slackPrincipal(): BoundWorkflowExecutionPrincipal { + return createTestRuntimePrincipal({ + rootWorkflowId: 'root-workflow', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + principal: { + kind: 'system', + serviceId: 'webhook', + workspaceId: 'workspace-1', workflowId: 'root-workflow', - principal: { - kind: 'system', - serviceId: 'webhook', - workspaceId: 'workspace-1', - workflowId: 'root-workflow', - webhookId: 'webhook-1', + webhookId: 'webhook-1', + provider: 'slack', + subject: { + kind: 'external_user', provider: 'slack', - subject: { - kind: 'external_user', - provider: 'slack', - tenantId: 'T123', - subjectId: 'U123', - }, - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'version-1', + tenantId: 'T123', + subjectId: 'U123', }, }, - } + }) } function requireAccess( - principal: WorkflowExecutionDelegatedPrincipal, + principal: BoundWorkflowExecutionPrincipal, accessContext = context ): Promise { return requireCredentialGroupCredentialAccess( @@ -103,39 +93,30 @@ describe('requireCredentialGroupCredentialAccess', () => { }) }) - it('allows an external actor to use only their own enrollment', async () => { - const principal = executorPrincipal() + it('allows an external actor to use only its own enrollment', async () => { + const principal = slackPrincipal() await expect(requireAccess(principal)).resolves.toBeUndefined() - expect(mocks.requirePolicy).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - resourceType: 'credential_group', - resourceId: 'group-1', - codec: expect.objectContaining({ resourceType: 'credential_group' }), - }) expect(mocks.loadEnrollmentAccess).toHaveBeenCalledWith('group-1', { kind: 'external_user', provider: 'slack', tenantId: 'T123', subjectId: 'U123', }) - await expect( - requireAccess(principal, { - ...context, - credentialGroupEnrollmentId: 'enrollment-2', - }) + requireAccess(principal, { ...context, credentialGroupEnrollmentId: 'enrollment-2' }) ).rejects.toMatchObject({ code: 'forbidden' }) }) - it('allows a Sim actor to use their own enrollment', async () => { - const principal = executorPrincipal() - principal.subjectUserId = 'user-1' - principal.delegationContext!.principal = { - kind: 'session', - userId: 'user-1', - sessionId: 'session-1', - } + it('allows a Sim actor to use its own enrollment', async () => { + const principal = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + }) await expect(requireAccess(principal)).resolves.toBeUndefined() expect(mocks.loadEnrollmentAccess).toHaveBeenCalledWith('group-1', { @@ -144,85 +125,69 @@ describe('requireCredentialGroupCredentialAccess', () => { }) }) - it('allows an actorless deployed workflow only when its current workflow is allowlisted', async () => { - const principal = executorPrincipal() - principal.delegationContext!.principal = { - kind: 'system', - serviceId: 'schedule', - workspaceId: 'workspace-1', - workflowId: 'root-workflow', - } + it('allows an actorless deployment only when its current workflow is allowlisted', async () => { + const principal = createTestRuntimePrincipal({ + rootWorkflowId: 'root-workflow', + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'root-workflow', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + }) mocks.requirePolicy.mockResolvedValue(storedPolicy(['workflow-1'])) await expect(requireAccess(principal)).resolves.toBeUndefined() expect(mocks.loadEnrollmentAccess).not.toHaveBeenCalled() - - principal.delegationContext!.currentWorkflow = { workflowId: 'workflow-1', mode: 'draft' } - await expect(requireAccess(principal)).rejects.toMatchObject({ - code: 'forbidden', - }) }) - it('uses the current child workflow rather than the root workflow grant', async () => { - const principal = executorPrincipal() - principal.delegationContext!.principal = { - kind: 'system', - serviceId: 'schedule', - workspaceId: 'workspace-1', - workflowId: 'root-workflow', - } - principal.delegationContext!.currentWorkflow = { - workflowId: 'child-workflow', - mode: 'deployment', - deploymentVersionId: 'child-version', - } - mocks.requirePolicy.mockResolvedValue(storedPolicy(['root-workflow'])) - - await expect(requireAccess(principal)).rejects.toMatchObject({ - code: 'forbidden', + it('uses the current child rather than the root workflow grant', async () => { + const principal = createTestRuntimePrincipal({ + rootWorkflowId: 'root-workflow', + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'root-workflow', + }, + currentWorkflow: { + workflowId: 'child-workflow', + mode: 'deployment', + deploymentVersionId: 'child-version', + }, }) - }) - - it('rejects inconsistent Sim and external subject assertions before loading policy', async () => { - const simPrincipal = executorPrincipal() - simPrincipal.subjectUserId = 'user-2' - simPrincipal.delegationContext!.principal = { - kind: 'session', - userId: 'user-1', - sessionId: 'session-1', - } - await expect(requireAccess(simPrincipal)).rejects.toMatchObject({ code: 'forbidden' }) + mocks.requirePolicy.mockResolvedValue(storedPolicy(['root-workflow'])) - const externalPrincipal = executorPrincipal() - externalPrincipal.subjectUserId = 'invented-user' - await expect(requireAccess(externalPrincipal)).rejects.toMatchObject({ code: 'forbidden' }) - expect(mocks.requirePolicy).not.toHaveBeenCalled() + await expect(requireAccess(principal)).rejects.toMatchObject({ code: 'forbidden' }) }) - it('requires the original principal and current workflow before loading policy', async () => { - const missingPrincipal = executorPrincipal() - missingPrincipal.delegationContext!.principal = undefined - await expect(requireAccess(missingPrincipal)).rejects.toThrow('missing its workflow principal') - - const missingCurrentWorkflow = executorPrincipal() - missingCurrentWorkflow.delegationContext!.currentWorkflow = undefined - await expect(requireAccess(missingCurrentWorkflow)).rejects.toThrow( - 'missing its current workflow authority' - ) + it('fails fast without execution metadata before loading policy', async () => { + await expect( + requireCredentialGroupCredentialAccess( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + context, + credentialOperations.useManagedOAuth.resourcePolicy + ) + ).rejects.toThrow('missing execution metadata') expect(mocks.requirePolicy).not.toHaveBeenCalled() }) it('loads and validates the required policy before resolving actor enrollment', async () => { mocks.requirePolicy.mockRejectedValue(new Error('Malformed resource policy')) - await expect(requireAccess(executorPrincipal())).rejects.toThrow('Malformed resource policy') + await expect(requireAccess(slackPrincipal())).rejects.toThrow('Malformed resource policy') expect(mocks.loadEnrollmentAccess).not.toHaveBeenCalled() }) }) describe('requireCredentialGroupWorkflowActor', () => { - it('returns the external subject a Slack-triggered run acts as', () => { - expect(requireCredentialGroupWorkflowActor(executorPrincipal())).toEqual({ + it('returns the verified Slack subject unchanged', () => { + expect(requireCredentialGroupWorkflowActor(slackPrincipal())).toEqual({ kind: 'external_user', provider: 'slack', tenantId: 'T123', @@ -231,48 +196,29 @@ describe('requireCredentialGroupWorkflowActor', () => { }) it('returns no subject for an actorless deployed run', () => { - const principal = executorPrincipal() - principal.delegationContext!.principal = { - kind: 'system', - serviceId: 'schedule', - workspaceId: 'workspace-1', - workflowId: 'root-workflow', - } + const principal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + }) expect(requireCredentialGroupWorkflowActor(principal)).toBeNull() }) - it('returns the Sim subject a session-actor run acts as', () => { - const principal = executorPrincipal() - principal.subjectUserId = 'user-1' - principal.delegationContext!.principal = { - kind: 'session', - userId: 'user-1', - sessionId: 'session-1', - } + it('returns the Sim subject for a session-actor run', () => { + const principal = createTestRuntimePrincipal() expect(requireCredentialGroupWorkflowActor(principal)).toEqual({ kind: 'sim_user', userId: 'user-1', }) }) - - it('rejects a delegation whose asserted subject contradicts its run', () => { - const invented = executorPrincipal() - invented.subjectUserId = 'invented-user' - expect(() => requireCredentialGroupWorkflowActor(invented)).toThrow( - 'Credential Group actor access required' - ) - - const mismatched = executorPrincipal() - mismatched.subjectUserId = 'user-2' - mismatched.delegationContext!.principal = { - kind: 'session', - userId: 'user-1', - sessionId: 'session-1', - } - expect(() => requireCredentialGroupWorkflowActor(mismatched)).toThrow( - 'Credential Group actor access required' - ) - }) }) diff --git a/apps/sim/lib/credential-groups/application/authorization.ts b/apps/sim/lib/credential-groups/application/authorization.ts index 13cc444e606..ea0a7f06050 100644 --- a/apps/sim/lib/credential-groups/application/authorization.ts +++ b/apps/sim/lib/credential-groups/application/authorization.ts @@ -1,14 +1,12 @@ import { type Principal, type PrincipalSubject, + requirePrincipalExecutionMetadata, resolvePrincipalSubject, type WorkflowExecutionAuthority, type WorkflowExecutionPrincipal, } from '@sim/auth/principal' -import type { - WorkspaceAuthorizationContext, - WorkspaceDelegationPolicy, -} from '@/lib/core/application' +import type { WorkspaceAuthorizationContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { credentialGroupWorkflowAccessPolicyCodec, @@ -19,8 +17,6 @@ import { loadCredentialGroupEnrollmentAccessForSubject } from '@/lib/credential- import type { ResourcePolicyBindingFor } from '@/lib/resource-policies/registry' import { requireResourcePolicy } from '@/lib/resource-policies/repository' -export const CREDENTIAL_GROUP_DELEGATION_AUDIENCE = 'sim:credential-groups' - export interface CredentialGroupAuthorizationContext extends WorkspaceAuthorizationContext { credentialGroupId: string } @@ -30,41 +26,19 @@ export interface CredentialGroupApplicationContext CredentialGroupCredentialListContext {} function requireWorkflowExecutionPrincipal(principal: Principal): WorkflowExecutionPrincipal { - if (principal.kind !== 'delegated' || principal.serviceId !== 'executor') { - throw new Error('Credential Group use requires an executor delegation') - } - const executionPrincipal = principal.delegationContext?.principal - if (!executionPrincipal) { - throw new Error('Executor delegation is missing its workflow principal') + if (principal.kind === 'credential_group_enrollment') { + throw new Error('Credential Group use requires a workflow execution principal') } - return executionPrincipal + requirePrincipalExecutionMetadata(principal) + return principal } function requireCurrentWorkflow(principal: Principal): WorkflowExecutionAuthority { - if (principal.kind !== 'delegated' || principal.serviceId !== 'executor') { - throw new Error('Credential Group use requires an executor delegation') - } - const currentWorkflow = principal.delegationContext?.currentWorkflow - if (!currentWorkflow) { - throw new Error('Executor delegation is missing its current workflow authority') - } - return currentWorkflow + return requirePrincipalExecutionMetadata(principal).currentWorkflow } -function requireConsistentWorkflowSubject( - principal: Principal, - executionPrincipal: WorkflowExecutionPrincipal -) { - if (principal.kind !== 'delegated' || principal.serviceId !== 'executor') { - throw new Error('Credential Group use requires an executor delegation') - } +function requireConsistentWorkflowSubject(executionPrincipal: WorkflowExecutionPrincipal) { const subject = resolvePrincipalSubject(executionPrincipal) - if ( - (subject?.kind === 'sim_user' && principal.subjectUserId !== subject.userId) || - (subject?.kind !== 'sim_user' && principal.subjectUserId !== undefined) - ) { - throw new OrchestrationError('forbidden', 'Credential Group actor access required') - } return subject } @@ -80,7 +54,7 @@ function requireConsistentWorkflowSubject( * user simply records none. */ export function requireCredentialGroupWorkflowActor(principal: Principal): PrincipalSubject | null { - return requireConsistentWorkflowSubject(principal, requireWorkflowExecutionPrincipal(principal)) + return requireConsistentWorkflowSubject(requireWorkflowExecutionPrincipal(principal)) } export async function requireCredentialGroupCredentialAccess( @@ -90,7 +64,7 @@ export async function requireCredentialGroupCredentialAccess( ): Promise { const executionPrincipal = requireWorkflowExecutionPrincipal(principal) const currentWorkflow = requireCurrentWorkflow(principal) - const subject = requireConsistentWorkflowSubject(principal, executionPrincipal) + const subject = requireConsistentWorkflowSubject(executionPrincipal) const policy = await requireResourcePolicy({ workspaceId: context.workspaceId, resourceType: 'credential_group', @@ -112,17 +86,3 @@ export async function requireCredentialGroupCredentialAccess( throw new OrchestrationError('forbidden', 'Credential Group credential access denied') } } - -export const credentialGroupDelegationPolicy = { - audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, - isWithinScope: ( - principal: Extract, - context: CredentialGroupApplicationContext - ) => principal.resourceScope?.credentialGroupId === context.credentialGroupId, -} satisfies WorkspaceDelegationPolicy - -export const credentialGroupWorkspaceDelegationPolicy = { - audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, - isWithinScope: (principal: Extract) => - principal.resourceScope?.credentialGroupId === undefined, -} satisfies WorkspaceDelegationPolicy diff --git a/apps/sim/lib/credential-groups/application/context.ts b/apps/sim/lib/credential-groups/application/context.ts index 7ca35722a39..d75425d887f 100644 --- a/apps/sim/lib/credential-groups/application/context.ts +++ b/apps/sim/lib/credential-groups/application/context.ts @@ -34,10 +34,14 @@ export async function resolveCredentialGroupWorkspaceContext(workspaceId: string } export async function resolveCredentialGroupContext( - credentialGroupId: string + credentialGroupId: string, + assertedWorkspaceId?: string ): Promise { const group = await loadCredentialGroupCredentialListContext(credentialGroupId) if (!group) throw new OrchestrationError('not_found', 'Credential group not found') + if (assertedWorkspaceId !== undefined && group.workspaceId !== assertedWorkspaceId) { + throw new OrchestrationError('not_found', 'Credential group not found') + } return { ...(await resolveCredentialGroupWorkspaceContext(group.workspaceId)), ...group } } @@ -45,9 +49,5 @@ export async function resolveCredentialGroupSettingsContext( credentialGroupId: string, assertedWorkspaceId: string ): Promise { - const context = await resolveCredentialGroupContext(credentialGroupId) - if (context.workspaceId !== assertedWorkspaceId) { - throw new OrchestrationError('not_found', 'Credential group not found') - } - return context + return resolveCredentialGroupContext(credentialGroupId, assertedWorkspaceId) } diff --git a/apps/sim/lib/credential-groups/application/create-invite-link.test.ts b/apps/sim/lib/credential-groups/application/create-invite-link.test.ts index 388ee4e8f5a..dc7271f3171 100644 --- a/apps/sim/lib/credential-groups/application/create-invite-link.test.ts +++ b/apps/sim/lib/credential-groups/application/create-invite-link.test.ts @@ -1,8 +1,9 @@ /** * @vitest-environment node */ -import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { SessionPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ createInvitationLink: vi.fn(), @@ -47,19 +48,10 @@ const context = { options: [], } -function executorPrincipal(credentialGroupId = 'group-1'): WorkflowExecutionDelegatedPrincipal { - return { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'admin-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:credential-groups', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - resourceScope: { credentialGroupId }, - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, - } +function executorPrincipal() { + return createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + }) } describe('createCredentialGroupInviteLink', () => { @@ -78,13 +70,13 @@ describe('createCredentialGroupInviteLink', () => { }) }) - it('allows only admin executor delegation', () => { + it('allows only admin workflow execution', () => { expect(createCredentialGroupInviteLink.operation).toMatchObject({ id: 'credential_groups.invites.link.create', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', }) }) @@ -105,32 +97,19 @@ describe('createCredentialGroupInviteLink', () => { }) it('issues an unattributed link for an actorless run', async () => { - // A schedule (or a webhook with no external subject) reaches this with a real - // admin-scoped delegation and no person on it. The delegation is the authority; - // the issuer is only recorded, and `created_by` is nullable — so this issues the - // link with no issuer rather than refusing, which is what it did when the - // subject was demanded here. - const { subjectUserId: _subject, ...base } = executorPrincipal() - // What actually authorizes an actorless caller: the delegation is running a - // deployment. No user is consulted anywhere in that decision. - const actorless = { - ...base, - delegationContext: { - kind: 'workflow_execution' as const, + const actorless = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', workflowId: 'workflow-1', - principal: { - kind: 'system' as const, - serviceId: 'schedule' as const, - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment' as const, - deploymentVersionId: 'version-1', - }, }, - } + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + }) const result = await createCredentialGroupInviteLink.execute({ principal: actorless, @@ -146,16 +125,6 @@ describe('createCredentialGroupInviteLink', () => { ) }) - it('rejects delegation scoped to another Credential Group', async () => { - await expect( - createCredentialGroupInviteLink.execute({ - principal: executorPrincipal('group-2'), - input: { credentialGroupId: 'group-1', email: 'person@example.com' }, - }) - ).rejects.toMatchObject({ code: 'forbidden' }) - expect(mocks.createInvitationLink).not.toHaveBeenCalled() - }) - it('requires the current subject to remain a workspace admin', async () => { mocks.resolvePermission.mockResolvedValue('write') diff --git a/apps/sim/lib/credential-groups/application/create-invite-link.ts b/apps/sim/lib/credential-groups/application/create-invite-link.ts index 9290ba46e59..667fc03ec45 100644 --- a/apps/sim/lib/credential-groups/application/create-invite-link.ts +++ b/apps/sim/lib/credential-groups/application/create-invite-link.ts @@ -3,7 +3,6 @@ import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' import { requireCredentialGroupsAvailable, resolveCredentialGroupContext, @@ -16,6 +15,7 @@ import { export interface CreateCredentialGroupInviteLinkInput { credentialGroupId: string + assertedWorkspaceId?: string email: string } @@ -23,8 +23,8 @@ export interface CreateCredentialGroupInviteLinkInput { export const createCredentialGroupInviteLink = defineAuthorizedWorkspaceUseCase({ operation: credentialGroupOperations.createInviteLink, resolveContext: ({ input }: { input: CreateCredentialGroupInviteLinkInput }) => - resolveCredentialGroupContext(input.credentialGroupId), - authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + resolveCredentialGroupContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, execute: async ({ principal, input, context }) => { if (context.status !== 'active') { throw new OrchestrationError('conflict', 'Credential group is disabled') diff --git a/apps/sim/lib/credential-groups/application/list-credentials.test.ts b/apps/sim/lib/credential-groups/application/list-credentials.test.ts index 6739b8ee581..f1f8a246889 100644 --- a/apps/sim/lib/credential-groups/application/list-credentials.test.ts +++ b/apps/sim/lib/credential-groups/application/list-credentials.test.ts @@ -1,8 +1,9 @@ /** * @vitest-environment node */ -import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { SessionPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ getWorkspaceOwnerSubscriptionAccess: vi.fn(), @@ -84,28 +85,14 @@ const workspaceContext = { } const input = { credentialGroupId: 'group-1', limit: 50 } -function executorPrincipal(credentialGroupId = 'group-1'): WorkflowExecutionDelegatedPrincipal { - return { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:credential-groups', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - resourceScope: { credentialGroupId }, - delegationContext: { - kind: 'workflow_execution', +function executorPrincipal() { + return createTestRuntimePrincipal({ + currentWorkflow: { workflowId: 'workflow-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-version-1', - }, + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', }, - } + }) } describe('listCredentialGroupCredentials', () => { @@ -148,24 +135,19 @@ describe('listCredentialGroupCredentials', () => { expect(mocks.loadGroup).not.toHaveBeenCalled() }) - it('rejects executor delegation scoped to another group', async () => { - await expect( - listCredentialGroupCredentials.execute({ - principal: executorPrincipal('group-2'), - input, - }) - ).rejects.toMatchObject({ code: 'forbidden' }) - expect(mocks.listCredentials).not.toHaveBeenCalled() - }) - it('lists credentials when the original principal has no human subject', async () => { - const principal = executorPrincipal() - principal.subjectUserId = undefined - principal.delegationContext.principal = { - kind: 'workspace_api_key', - workspaceId: 'workspace-1', - keyId: 'workspace-key-1', - } + const principal = createTestRuntimePrincipal({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, + }) await listCredentialGroupCredentials.execute({ principal, input }) @@ -184,11 +166,20 @@ describe('listCredentialGroupCredentials', () => { expect(mocks.listCredentials).toHaveBeenCalled() }) - it('does not use the executor subject to filter credential references', async () => { - const principal = executorPrincipal() - principal.subjectUserId = 'different-user' + it('conceals a credential group outside the execution workspace assertion', async () => { + await expect( + listCredentialGroupCredentials.execute({ + principal: executorPrincipal(), + input: { ...input, assertedWorkspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) - await listCredentialGroupCredentials.execute({ principal, input }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.listCredentials).not.toHaveBeenCalled() + }) + + it('does not use the runtime subject to filter credential references', async () => { + await listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input }) expect(mocks.loadEnrollmentAccess).not.toHaveBeenCalled() expect(mocks.listCredentials).toHaveBeenCalled() diff --git a/apps/sim/lib/credential-groups/application/list-credentials.ts b/apps/sim/lib/credential-groups/application/list-credentials.ts index 3dec5d6e958..ba7254b2649 100644 --- a/apps/sim/lib/credential-groups/application/list-credentials.ts +++ b/apps/sim/lib/credential-groups/application/list-credentials.ts @@ -1,7 +1,6 @@ import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization' import { requireCredentialGroupsAvailable, resolveCredentialGroupContext, @@ -20,6 +19,7 @@ import { export interface ListCredentialGroupCredentialsInput { credentialGroupId: string + assertedWorkspaceId?: string limit: number cursor?: string email?: string @@ -36,8 +36,8 @@ export interface ListCredentialGroupCredentialsResult { export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({ operation: credentialGroupOperations.listCredentials, resolveContext: ({ input }: { input: ListCredentialGroupCredentialsInput }) => - resolveCredentialGroupContext(input.credentialGroupId), - authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + resolveCredentialGroupContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, execute: async ({ input, context }): Promise => { if ( !Number.isInteger(input.limit) || diff --git a/apps/sim/lib/credential-groups/application/list-groups.ts b/apps/sim/lib/credential-groups/application/list-groups.ts index 75599f6734e..63b8c3434b8 100644 --- a/apps/sim/lib/credential-groups/application/list-groups.ts +++ b/apps/sim/lib/credential-groups/application/list-groups.ts @@ -1,9 +1,6 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { - credentialGroupWorkspaceDelegationPolicy, - requireCredentialGroupWorkflowActor, -} from '@/lib/credential-groups/application/authorization' +import { requireCredentialGroupWorkflowActor } from '@/lib/credential-groups/application/authorization' import { requireCredentialGroupsAvailable, resolveCredentialGroupWorkspaceContext, @@ -33,7 +30,7 @@ export const listCredentialGroupsForWorkflow = defineAuthorizedWorkspaceUseCase( operation: credentialGroupOperations.listGroups, resolveContext: ({ input }: { input: ListCredentialGroupsInput }) => resolveCredentialGroupWorkspaceContext(input.workspaceId), - authorizationOptions: { delegation: credentialGroupWorkspaceDelegationPolicy }, + authorizationOptions: {}, authorizeResource({ principal }) { requireCredentialGroupWorkflowActor(principal) }, diff --git a/apps/sim/lib/credential-groups/application/list-people.ts b/apps/sim/lib/credential-groups/application/list-people.ts index 6d5d2ae9043..5b0c6f0a686 100644 --- a/apps/sim/lib/credential-groups/application/list-people.ts +++ b/apps/sim/lib/credential-groups/application/list-people.ts @@ -1,10 +1,7 @@ import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { - credentialGroupDelegationPolicy, - requireCredentialGroupWorkflowActor, -} from '@/lib/credential-groups/application/authorization' +import { requireCredentialGroupWorkflowActor } from '@/lib/credential-groups/application/authorization' import { requireCredentialGroupsAvailable, resolveCredentialGroupContext, @@ -26,6 +23,7 @@ export const CREDENTIAL_GROUP_PEOPLE_STATUSES = [ export interface ListCredentialGroupPeopleInput { credentialGroupId: string + assertedWorkspaceId?: string limit: number cursor?: string email?: string @@ -35,8 +33,8 @@ export interface ListCredentialGroupPeopleInput { export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({ operation: credentialGroupOperations.listPeople, resolveContext: ({ input }: { input: ListCredentialGroupPeopleInput }) => - resolveCredentialGroupContext(input.credentialGroupId), - authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + resolveCredentialGroupContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, authorizeResource({ principal }) { requireCredentialGroupWorkflowActor(principal) }, diff --git a/apps/sim/lib/credential-groups/application/operations.ts b/apps/sim/lib/credential-groups/application/operations.ts index 9339f5dfd9f..58991b3afa9 100644 --- a/apps/sim/lib/credential-groups/application/operations.ts +++ b/apps/sim/lib/credential-groups/application/operations.ts @@ -65,36 +65,36 @@ export const credentialGroupOperations = { id: 'credential_groups.credentials.list', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', }), listGroups: defineWorkspaceOperation({ id: 'credential_groups.list', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', }), listPeople: defineWorkspaceOperation({ id: 'credential_groups.people.list', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', }), sendInvite: defineWorkspaceOperation({ id: 'credential_groups.invites.send', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', }), createInviteLink: defineWorkspaceOperation({ id: 'credential_groups.invites.link.create', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', }), startSlackConfiguration: defineWorkspaceOperation({ id: 'credential_groups.slack_configuration.start', diff --git a/apps/sim/lib/credential-groups/application/send-invite.test.ts b/apps/sim/lib/credential-groups/application/send-invite.test.ts index d72495678fa..90238931491 100644 --- a/apps/sim/lib/credential-groups/application/send-invite.test.ts +++ b/apps/sim/lib/credential-groups/application/send-invite.test.ts @@ -1,8 +1,12 @@ /** * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { + BoundWorkflowExecutionPrincipal, + WorkflowExecutionPrincipal, +} from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ inviteEnrollment: vi.fn(), @@ -49,47 +53,27 @@ const context = { options: [], } -function executorPrincipal(): WorkflowExecutionDelegatedPrincipal { - return { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'admin-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:credential-groups', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - resourceScope: { credentialGroupId: 'group-1' }, - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, - }, - } +function executorPrincipal(): BoundWorkflowExecutionPrincipal { + return createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + }) } /** A deployed run whose only actor is the external identity that triggered it. */ function unattendedPrincipal( - principal: NonNullable['principal'] -): WorkflowExecutionDelegatedPrincipal { - const { subjectUserId: _subject, ...base } = executorPrincipal() - return { - ...base, - delegationContext: { - kind: 'workflow_execution', + principal: WorkflowExecutionPrincipal +): BoundWorkflowExecutionPrincipal { + return createTestRuntimePrincipal({ + principal, + currentWorkflow: { workflowId: 'workflow-1', - principal, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'version-1', - }, + mode: 'deployment', + deploymentVersionId: 'version-1', }, - } + }) } -function slackPrincipal(): WorkflowExecutionDelegatedPrincipal { +function slackPrincipal(): BoundWorkflowExecutionPrincipal { return unattendedPrincipal({ kind: 'system', serviceId: 'webhook', @@ -101,7 +85,7 @@ function slackPrincipal(): WorkflowExecutionDelegatedPrincipal { }) } -function invite(principal: WorkflowExecutionDelegatedPrincipal) { +function invite(principal: BoundWorkflowExecutionPrincipal) { return sendCredentialGroupInvite.execute({ principal, input: { credentialGroupId: 'group-1', email: 'person@example.com' }, @@ -188,12 +172,4 @@ describe('sendCredentialGroupInvite', () => { await expect(invite(executorPrincipal())).rejects.toMatchObject({ code: 'forbidden' }) expect(mocks.inviteEnrollment).not.toHaveBeenCalled() }) - - it('rejects a delegation asserting a subject its run never had', async () => { - const spoofed = slackPrincipal() - spoofed.subjectUserId = 'invented-user' - - await expect(invite(spoofed)).rejects.toMatchObject({ code: 'forbidden' }) - expect(mocks.inviteEnrollment).not.toHaveBeenCalled() - }) }) diff --git a/apps/sim/lib/credential-groups/application/send-invite.ts b/apps/sim/lib/credential-groups/application/send-invite.ts index 3e970b81675..e1a9cc7c297 100644 --- a/apps/sim/lib/credential-groups/application/send-invite.ts +++ b/apps/sim/lib/credential-groups/application/send-invite.ts @@ -3,10 +3,7 @@ import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { - credentialGroupDelegationPolicy, - requireCredentialGroupWorkflowActor, -} from '@/lib/credential-groups/application/authorization' +import { requireCredentialGroupWorkflowActor } from '@/lib/credential-groups/application/authorization' import { requireCredentialGroupsAvailable, resolveCredentialGroupContext, @@ -20,14 +17,15 @@ import { export interface SendCredentialGroupInviteInput { credentialGroupId: string + assertedWorkspaceId?: string email: string } export const sendCredentialGroupInvite = defineAuthorizedWorkspaceUseCase({ operation: credentialGroupOperations.sendInvite, resolveContext: ({ input }: { input: SendCredentialGroupInviteInput }) => - resolveCredentialGroupContext(input.credentialGroupId), - authorizationOptions: { delegation: credentialGroupDelegationPolicy }, + resolveCredentialGroupContext(input.credentialGroupId, input.assertedWorkspaceId), + authorizationOptions: {}, authorizeResource({ principal }) { requireCredentialGroupWorkflowActor(principal) }, diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts index de2a9cd71c9..c02dadf5d81 100644 --- a/apps/sim/lib/credentials/application/connection-target.test.ts +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { bindPrincipalExecutionMetadata, withPrincipalExecutionActor } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -138,28 +139,26 @@ describe('resolveCredentialConnectionTarget', () => { }) it('uses the legacy execution actor for an actorless reconnect', async () => { - const actorlessPrincipal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:credentials', - issuedAt: new Date('2026-08-28T00:00:00.000Z'), - expiresAt: new Date('2099-08-28T00:00:00.000Z'), - delegationContext: { - kind: 'workflow_execution' as const, - workflowId: 'workflow-1', - currentWorkflow: { + const actorlessPrincipal = withPrincipalExecutionActor( + bindPrincipalExecutionMetadata( + { + kind: 'system', + serviceId: 'public_api', + workspaceId: 'workspace-1', workflowId: 'workflow-1', - mode: 'deployment' as const, - deploymentVersionId: 'deployment-1', - }, - compatibilityActor: { - kind: 'legacy_execution_user' as const, - userId: 'execution-actor', }, - }, - } + { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + } + ), + 'execution-actor' + ) await resolveCredentialConnectionTarget({ principal: actorlessPrincipal, diff --git a/apps/sim/lib/credentials/application/managed-oauth-delegation.test.ts b/apps/sim/lib/credentials/application/managed-oauth-delegation.test.ts index c6d950036b0..b21d4ca7553 100644 --- a/apps/sim/lib/credentials/application/managed-oauth-delegation.test.ts +++ b/apps/sim/lib/credentials/application/managed-oauth-delegation.test.ts @@ -1,15 +1,15 @@ /** * @vitest-environment node */ +import { bindPrincipalExecutionMetadata } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutorDelegationOrigin } from '@/executor/types' -const { mockBindInternalExecutorDelegation } = vi.hoisted(() => ({ - mockBindInternalExecutorDelegation: vi.fn(), +const { mockBindRuntimeWorkflowExecutionPrincipal } = vi.hoisted(() => ({ + mockBindRuntimeWorkflowExecutionPrincipal: vi.fn(), })) vi.mock('@/lib/auth/internal-delegation', () => ({ - bindInternalExecutorDelegation: mockBindInternalExecutorDelegation, + bindRuntimeWorkflowExecutionPrincipal: mockBindRuntimeWorkflowExecutionPrincipal, InvalidInternalDelegationBindingError: class InvalidInternalDelegationBindingError extends Error {}, })) @@ -24,75 +24,67 @@ import { InvalidManagedOAuthDelegationError, } from '@/lib/credentials/application/managed-oauth-delegation' -function delegationOrigin( - overrides: Partial = {} -): ExecutorDelegationOrigin { - return { - subjectUserId: 'user-origin', - workflowId: 'workflow-origin', - executionId: 'execution-origin', - currentWorkflow: { workflowId: 'workflow-origin' }, - ...overrides, - } as ExecutorDelegationOrigin +function runtimePrincipal() { + return bindPrincipalExecutionMetadata( + { kind: 'session', userId: 'user-origin', sessionId: 'session-origin' }, + { + executionId: 'execution-origin', + rootWorkflowId: 'workflow-origin', + currentWorkflow: { + workflowId: 'workflow-origin', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, + } + ) } describe('bindExecutorManagedOAuthDelegation', () => { beforeEach(() => { vi.clearAllMocks() - mockBindInternalExecutorDelegation.mockImplementation(async (claims, options) => ({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: claims.subjectUserId, - workspaceId: 'workspace-canonical', - delegationId: claims.delegationId, - audience: options.audience, - resourceScope: options.resourceScope, - })) + mockBindRuntimeWorkflowExecutionPrincipal.mockImplementation(async (principal) => principal) }) - it('requires current workflow authority before binding', async () => { + it('requires canonical execution metadata before binding', async () => { await expect( - bindExecutorManagedOAuthDelegation(delegationOrigin({ currentWorkflow: undefined }), 'cred-1') - ).rejects.toThrow('Managed credential delegation is missing current workflow authority') - expect(mockBindInternalExecutorDelegation).not.toHaveBeenCalled() + bindExecutorManagedOAuthDelegation( + { kind: 'session', userId: 'user-origin', sessionId: 'session-origin' }, + 'cred-1' + ) + ).rejects.toThrow('Workflow execution principal is missing execution metadata') + expect(mockBindRuntimeWorkflowExecutionPrincipal).not.toHaveBeenCalled() }) - it('binds the origin to the managed-OAuth audience scoped to one credential', async () => { - const principal = await bindExecutorManagedOAuthDelegation(delegationOrigin(), 'cred-1') + it('revalidates and returns the same semantic runtime principal', async () => { + const principal = runtimePrincipal() - expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( - expect.objectContaining({ - serviceId: 'executor', - subjectUserId: 'user-origin', - workflowId: 'workflow-origin', - executionId: 'execution-origin', - currentWorkflow: { workflowId: 'workflow-origin' }, - }), - expect.objectContaining({ - audience: 'sim:managed-oauth-credentials', - resourceScope: { credentialId: 'cred-1' }, - }) + await expect(bindExecutorManagedOAuthDelegation(principal, 'cred-1')).resolves.toEqual( + principal ) - expect(principal).toMatchObject({ - audience: 'sim:managed-oauth-credentials', - resourceScope: { credentialId: 'cred-1' }, - }) + expect(mockBindRuntimeWorkflowExecutionPrincipal).toHaveBeenCalledWith(principal) + }) + + it('rejects an empty credential assertion before binding', async () => { + await expect( + bindExecutorManagedOAuthDelegation(runtimePrincipal(), ' ') + ).rejects.toBeInstanceOf(InvalidManagedOAuthDelegationError) + expect(mockBindRuntimeWorkflowExecutionPrincipal).not.toHaveBeenCalled() }) - it('wraps binding rejections into the managed-OAuth delegation error', async () => { - mockBindInternalExecutorDelegation.mockRejectedValue( + it('wraps canonical binding rejections into the managed-OAuth delegation error', async () => { + mockBindRuntimeWorkflowExecutionPrincipal.mockRejectedValue( new InvalidInternalDelegationBindingError('stale workflow context') ) await expect( - bindExecutorManagedOAuthDelegation(delegationOrigin(), 'cred-1') + bindExecutorManagedOAuthDelegation(runtimePrincipal(), 'cred-1') ).rejects.toBeInstanceOf(InvalidManagedOAuthDelegationError) }) it('rethrows unexpected binding failures unchanged', async () => { - mockBindInternalExecutorDelegation.mockRejectedValue(new Error('db unavailable')) + mockBindRuntimeWorkflowExecutionPrincipal.mockRejectedValue(new Error('db unavailable')) - await expect(bindExecutorManagedOAuthDelegation(delegationOrigin(), 'cred-1')).rejects.toThrow( + await expect(bindExecutorManagedOAuthDelegation(runtimePrincipal(), 'cred-1')).rejects.toThrow( 'db unavailable' ) }) diff --git a/apps/sim/lib/credentials/application/managed-oauth-delegation.ts b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts index d5c68994745..d57fc065c22 100644 --- a/apps/sim/lib/credentials/application/managed-oauth-delegation.ts +++ b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts @@ -1,15 +1,17 @@ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { + type BoundWorkflowExecutionPrincipal, + requirePrincipalExecutionMetadata, + type WorkflowExecutionPrincipal, +} from '@sim/auth/principal' import { InvalidInternalDelegationTokenError, verifyInternalDelegationToken, } from '@/lib/auth/internal' import { bindInternalExecutorDelegation, + bindRuntimeWorkflowExecutionPrincipal, InvalidInternalDelegationBindingError, } from '@/lib/auth/internal-delegation' -import { MANAGED_OAUTH_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' -import { createExecutorPrincipalFromDelegationOrigin } from '@/lib/internal/principals/executor' -import type { ExecutorDelegationOrigin } from '@/executor/types' export class InvalidManagedOAuthDelegationError extends Error { constructor() { @@ -22,15 +24,13 @@ export class InvalidManagedOAuthDelegationError extends Error { export async function authenticateManagedOAuthDelegation( authorization: string, credentialId: string -): Promise { +): Promise { if (!authorization.startsWith('Bearer ')) throw new InvalidManagedOAuthDelegationError() + if (!credentialId.trim()) throw new InvalidManagedOAuthDelegationError() try { const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) - return await bindInternalExecutorDelegation(claims, { - audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, - resourceScope: { credentialId }, - }) + return await bindInternalExecutorDelegation(claims) } catch (error) { if ( error instanceof InvalidInternalDelegationTokenError || @@ -42,26 +42,16 @@ export async function authenticateManagedOAuthDelegation( } } -/** - * In-process sibling of {@link authenticateManagedOAuthDelegation}: binds the - * executor's own delegation origin to one managed credential without minting and - * re-verifying a delegation JWT — see {@link createExecutorPrincipalFromDelegationOrigin} - * for why that loses nothing. - */ +/** Revalidates the in-process runtime principal before managed credential use. */ export async function bindExecutorManagedOAuthDelegation( - origin: ExecutorDelegationOrigin, + principal: WorkflowExecutionPrincipal, credentialId: string -): Promise { - if (!origin.currentWorkflow) { - throw new Error('Managed credential delegation is missing current workflow authority') - } +): Promise { + if (!credentialId.trim()) throw new InvalidManagedOAuthDelegationError() + const executionMetadata = requirePrincipalExecutionMetadata(principal) try { - return await createExecutorPrincipalFromDelegationOrigin( - origin, - MANAGED_OAUTH_DELEGATION_AUDIENCE, - { credentialId } - ) + return await bindRuntimeWorkflowExecutionPrincipal({ ...principal, executionMetadata }) } catch (error) { if (error instanceof InvalidInternalDelegationBindingError) { throw new InvalidManagedOAuthDelegationError() diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4753c3136a8..8bd4da0ad32 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -147,8 +147,8 @@ export const credentialOperations = { id: 'credentials.managed_oauth.use', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', resourcePolicy: { resourceType: 'credential_group', action: CREDENTIAL_GROUP_CREDENTIAL_USE_ACTION, diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts index 2a7bed02cb3..11e284cfa2d 100644 --- a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.test.ts @@ -1,7 +1,11 @@ /** * @vitest-environment node */ -import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { + type BoundWorkflowExecutionPrincipal, + bindPrincipalExecutionMetadata, + type SessionPrincipal, +} from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -51,28 +55,19 @@ const input = { toolId: 'gmail_read', } -function executorPrincipal(credentialId = 'credential-1'): WorkflowExecutionDelegatedPrincipal { - return { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:managed-oauth-credentials', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - resourceScope: { credentialId }, - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, +function executorPrincipal(): BoundWorkflowExecutionPrincipal { + return bindPrincipalExecutionMetadata( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', currentWorkflow: { workflowId: 'workflow-1', mode: 'deployment', deploymentVersionId: 'version-1', }, - }, - } + } + ) } describe('resolveManagedOAuthCredentialToken', () => { @@ -97,10 +92,10 @@ describe('resolveManagedOAuthCredentialToken', () => { expect(mocks.loadContext).not.toHaveBeenCalled() }) - it('rejects a delegation scoped to another credential', async () => { + it('does not treat an unbound session as workflow execution authority', async () => { await expect( resolveManagedOAuthCredentialToken.execute({ - principal: executorPrincipal('credential-2'), + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input, }) ).rejects.toMatchObject({ code: 'forbidden' }) @@ -127,7 +122,11 @@ describe('resolveManagedOAuthCredentialToken', () => { expectedProviderId: 'google-email', requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'], }) - expect(result).toEqual({ accessToken: 'access-token', refreshed: false }) + expect(result).toEqual({ + accessToken: 'access-token', + refreshed: false, + workspaceId: 'workspace-1', + }) expect(mocks.recordAudit).toHaveBeenCalledOnce() }) @@ -151,7 +150,11 @@ describe('resolveManagedOAuthCredentialToken', () => { await expect( resolveManagedOAuthCredentialToken.execute({ principal: executorPrincipal(), input }) - ).resolves.toEqual({ accessToken: 'access-token', refreshed: false }) + ).resolves.toEqual({ + accessToken: 'access-token', + refreshed: false, + workspaceId: 'workspace-1', + }) expect(mocks.resolveToken).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts index 418a0e455b5..e0e97b54661 100644 --- a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts @@ -28,13 +28,18 @@ export const resolveManagedOAuthCredentialToken = defineAuthorizedWorkspaceUseCa async authorizeResource({ principal, context, resourcePolicy }) { await requireCredentialGroupCredentialAccess(principal, context, resourcePolicy) }, - execute: async ({ input, context }): Promise => - resolveManagedOAuthToken({ + execute: async ({ + input, + context, + }): Promise => ({ + ...(await resolveManagedOAuthToken({ credentialId: context.credentialId, workspaceId: context.workspaceId, expectedProviderId: input.expectedProviderId, requiredScopes: input.requiredScopes, - }), + })), + workspaceId: context.workspaceId, + }), projectAudit({ input, context }) { return { action: AuditAction.CREDENTIAL_ACCESSED, diff --git a/apps/sim/lib/custom-tools/application/operations.ts b/apps/sim/lib/custom-tools/application/operations.ts index fbeace41633..4715a8dd8df 100644 --- a/apps/sim/lib/custom-tools/application/operations.ts +++ b/apps/sim/lib/custom-tools/application/operations.ts @@ -33,7 +33,8 @@ export const customToolOperations = { minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', }), create: defineWorkspaceOperation({ id: 'custom_tools.create', diff --git a/apps/sim/lib/custom-tools/application/use-cases.test.ts b/apps/sim/lib/custom-tools/application/use-cases.test.ts index 8b4bb2fac7e..ccfafe75f30 100644 --- a/apps/sim/lib/custom-tools/application/use-cases.test.ts +++ b/apps/sim/lib/custom-tools/application/use-cases.test.ts @@ -1,8 +1,9 @@ /** * @vitest-environment node */ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const { mocks } = vi.hoisted(() => ({ mocks: { @@ -85,27 +86,33 @@ describe('custom tool application use cases', () => { }) describe('delegated custom-tool resolution', () => { - function executorPrincipal(overrides: Partial = {}): DelegatedPrincipal { - return { - kind: 'delegated' as const, - serviceId: 'executor' as const, - subjectUserId: 'user-1', - workspaceId: workspace.workspaceId, - delegationId: 'execution-1', - audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - ...overrides, - } + function executorPrincipal( + principal: WorkflowExecutionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, + compatibilityActorUserId?: string + ) { + return createTestRuntimePrincipal({ + principal, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + compatibilityActorUserId, + }) } - it('declares the executor and Copilot read policy explicitly', () => { + it('declares workflow execution and Copilot read policy explicitly', () => { expect(customToolOperations.readAvailableByIdOrTitle).toMatchObject({ id: 'custom_tools.read_available_by_id_or_title', minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', }) }) @@ -133,29 +140,15 @@ describe('custom tool application use cases', () => { it('preserves the legacy execution actor for an actorless deployment', async () => { const result = await readAvailableCustomToolByIdOrTitleUseCase.execute({ - principal: executorPrincipal({ - subjectUserId: undefined, - delegationContext: { - kind: 'workflow_execution', + principal: executorPrincipal( + { + kind: 'system', + serviceId: 'schedule', + workspaceId: workspace.workspaceId, workflowId: 'workflow-1', - executionId: 'execution-1', - principal: { - kind: 'system', - serviceId: 'schedule', - workspaceId: workspace.workspaceId, - workflowId: 'workflow-1', - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'version-1', - }, - compatibilityActor: { - kind: 'legacy_execution_user', - userId: 'execution-actor', - }, }, - }), + 'execution-actor' + ), input: { workspaceId: workspace.workspaceId, identifier: tool.id, @@ -177,16 +170,10 @@ describe('custom tool application use cases', () => { await expect( readAvailableCustomToolByIdOrTitleUseCase.execute({ principal: executorPrincipal({ - subjectUserId: undefined, - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'version-1', - }, - }, + kind: 'system', + serviceId: 'schedule', + workspaceId: workspace.workspaceId, + workflowId: 'workflow-1', }), input: { workspaceId: workspace.workspaceId, @@ -207,7 +194,12 @@ describe('custom tool application use cases', () => { await expect( readAvailableCustomToolByIdOrTitleUseCase.execute({ - principal: executorPrincipal(), + principal: executorPrincipal({ + kind: 'system', + serviceId: 'schedule', + workspaceId: workspace.workspaceId, + workflowId: 'workflow-1', + }), input: { workspaceId: 'workspace-2', identifier: tool.id, @@ -220,22 +212,6 @@ describe('custom tool application use cases', () => { expect(mocks.getAvailableTool).not.toHaveBeenCalled() }) - it('rejects the wrong delegation audience before lookup', async () => { - await expect( - readAvailableCustomToolByIdOrTitleUseCase.execute({ - principal: executorPrincipal({ audience: 'sim:other' }), - input: { - workspaceId: workspace.workspaceId, - identifier: tool.id, - lookup: 'id', - }, - }) - ).rejects.toMatchObject({ code: 'forbidden' }) - - expect(mocks.resolvePermission).not.toHaveBeenCalled() - expect(mocks.getAvailableTool).not.toHaveBeenCalled() - }) - it('re-checks the delegated subject current workspace access before lookup', async () => { mocks.resolvePermission.mockResolvedValueOnce(null) diff --git a/apps/sim/lib/function-execution/application/execute-function.test.ts b/apps/sim/lib/function-execution/application/execute-function.test.ts index 156ee2d7542..0b6b462d9fd 100644 --- a/apps/sim/lib/function-execution/application/execute-function.test.ts +++ b/apps/sim/lib/function-execution/application/execute-function.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -24,35 +23,22 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) -import { FUNCTION_EXECUTION_DELEGATION_AUDIENCE } from '@/lib/function-execution/application/authorization' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { executeFunction } from '@/lib/function-execution/application/execute-function' -const principal: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - resourceScope: { executionId: 'execution-1' }, - delegationContext: { - kind: 'workflow_execution', +const principal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', workflowId: 'workflow-1', - executionId: 'execution-1', - principal: { - kind: 'system', - serviceId: 'schedule', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, }, -} + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, +}) describe('executeFunction', () => { beforeEach(() => { @@ -68,18 +54,18 @@ describe('executeFunction', () => { }) it('uses only the real workflow subject for legacy file contexts', async () => { - const humanPrincipal: WorkflowExecutionDelegatedPrincipal = { - ...principal, - subjectUserId: 'invoking-user', - delegationContext: { - ...principal.delegationContext!, - principal: { - kind: 'session', - userId: 'invoking-user', - sessionId: 'session-1', - }, + const humanPrincipal = createTestRuntimePrincipal({ + principal: { + kind: 'session', + userId: 'invoking-user', + sessionId: 'session-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', }, - } + }) await executeFunction.execute({ principal: humanPrincipal, diff --git a/apps/sim/lib/function-execution/application/operations.ts b/apps/sim/lib/function-execution/application/operations.ts index ba37ad117e5..36dc2750771 100644 --- a/apps/sim/lib/function-execution/application/operations.ts +++ b/apps/sim/lib/function-execution/application/operations.ts @@ -6,6 +6,7 @@ export const functionExecutionOperations = { minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['delegated'], - delegatedServices: ['executor', 'copilot'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', }), } as const diff --git a/apps/sim/lib/guardrails/validate_hallucination.test.ts b/apps/sim/lib/guardrails/validate_hallucination.test.ts index 50c731d8715..820f4499a4a 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.test.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' const { mockDecryptSecret, mockExecuteProviderRequest, mockSearchKnowledgeAsExecutor } = vi.hoisted( @@ -50,11 +51,7 @@ function createInput(registry: ResolvedSecretTraceRegistry) { workspaceId: 'workspace-1', executionId: 'execution-1', userId: 'user-1', - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - }, + principal: createTestRuntimePrincipal(), } return { userInput: 'secret-value __var_FOREIGN', @@ -132,7 +129,9 @@ describe('validateHallucination', () => { workspaceId: 'workspace-1', context: expect.objectContaining({ workflowId: 'workflow-1', - executorDelegationOrigin: expect.objectContaining({ workflowId: 'workflow-1' }), + principal: expect.objectContaining({ + executionMetadata: expect.objectContaining({ rootWorkflowId: 'workflow-1' }), + }), }), billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, diff --git a/apps/sim/lib/internal/agiloft/execute-tool.test.ts b/apps/sim/lib/internal/agiloft/execute-tool.test.ts index d0c98cc55f8..09e9fdb4145 100644 --- a/apps/sim/lib/internal/agiloft/execute-tool.test.ts +++ b/apps/sim/lib/internal/agiloft/execute-tool.test.ts @@ -164,7 +164,7 @@ describe('executeAgiloftTool', () => { ) }) - it('uses the trusted delegation origin and forwards cancellation', async () => { + it('uses the trusted execution user and forwards cancellation', async () => { const controller = new AbortController() const input = { ...BASE, data: '{"name":"Contract"}' } @@ -176,18 +176,13 @@ describe('executeAgiloftTool', () => { ...createExecutionContext({ workflowId: 'workflow-current' }), workspaceId: 'workspace-1', userId: 'user-current', - executorDelegationOrigin: { - subjectUserId: 'user-origin', - workflowId: 'workflow-origin', - executionId: 'execution-origin', - }, }, }) ) expect(operationMocks.executeAgiloftCreateRecord).toHaveBeenCalledWith(input, { requestId: 'request-1', - userId: 'user-origin', + userId: 'user-current', signal: controller.signal, }) }) diff --git a/apps/sim/lib/internal/agiloft/execute-tool.ts b/apps/sim/lib/internal/agiloft/execute-tool.ts index e59c8ba22ba..469dc723f1f 100644 --- a/apps/sim/lib/internal/agiloft/execute-tool.ts +++ b/apps/sim/lib/internal/agiloft/execute-tool.ts @@ -76,7 +76,7 @@ async function executeOperation( try { const result = await operation(parsed.data, { requestId: request.requestId, - userId: request.context.executorDelegationOrigin?.subjectUserId ?? request.context.userId, + userId: request.context.userId, signal: request.signal, }) request.signal?.throwIfAborted() diff --git a/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.test.ts b/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.test.ts index dc2f03e8796..6ee9045911d 100644 --- a/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.test.ts +++ b/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.test.ts @@ -2,12 +2,12 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { CUSTOM_TOOL_DELEGATION_AUDIENCE } from '@/lib/custom-tools/application/authorization' import type { ExecutionContext } from '@/executor/types' const { mocks } = vi.hoisted(() => ({ mocks: { createPrincipal: vi.fn(), + requireWorkspaceId: vi.fn(() => 'canonical-workspace'), executeCopilot: vi.fn(), readUseCase: { execute: vi.fn() }, }, @@ -15,6 +15,7 @@ const { mocks } = vi.hoisted(() => ({ vi.mock('@/lib/internal/principals/executor', () => ({ createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, + requireExecutorWorkspaceId: mocks.requireWorkspaceId, })) vi.mock('@/lib/custom-tools/application/use-cases', () => ({ @@ -36,7 +37,7 @@ const principal = { subjectUserId: 'user-1', workspaceId: 'canonical-workspace', delegationId: 'delegation-1', - audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, + audience: 'sim:custom-tools', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2027-01-01T00:00:00Z'), } @@ -90,7 +91,6 @@ describe('readAvailableCustomToolByIdOrTitleAsExecutor', () => { expect(mocks.createPrincipal).toHaveBeenCalledWith({ context, - audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, }) expect(mocks.readUseCase.execute).toHaveBeenCalledWith({ principal, diff --git a/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.ts b/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.ts index 1fce57b1e89..7362f8111c8 100644 --- a/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.ts +++ b/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.ts @@ -3,12 +3,14 @@ import { type CopilotExecutionContext, requireTrustedCopilotExecutionContext, } from '@/lib/copilot/auth/application-delegation' -import { CUSTOM_TOOL_DELEGATION_AUDIENCE } from '@/lib/custom-tools/application/authorization' import { type ReadAvailableCustomToolByIdOrTitleInput, readAvailableCustomToolByIdOrTitleUseCase, } from '@/lib/custom-tools/application/use-cases' -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { + createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId, +} from '@/lib/internal/principals/executor' import type { ExecutionContext } from '@/executor/types' export interface ReadAvailableCustomToolByIdOrTitleAsExecutorInput { @@ -25,13 +27,12 @@ export async function readAvailableCustomToolByIdOrTitleAsExecutor({ context.abortSignal?.throwIfAborted() const principal = await createExecutorPrincipalFromExecutionContext({ context, - audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, }) context.abortSignal?.throwIfAborted() const { tool } = await readAvailableCustomToolByIdOrTitleUseCase.execute({ principal, input: { - workspaceId: principal.workspaceId, + workspaceId: requireExecutorWorkspaceId(context), identifier, lookup, }, diff --git a/apps/sim/lib/internal/deployments/client.ts b/apps/sim/lib/internal/deployments/client.ts index 8051584b0c3..c23258a36a0 100644 --- a/apps/sim/lib/internal/deployments/client.ts +++ b/apps/sim/lib/internal/deployments/client.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { BoundWorkflowExecutionPrincipal } from '@sim/auth/principal' import type { DeploymentsDeployBody, DeploymentsGetVersionQuery, @@ -15,7 +15,7 @@ import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow- import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' export interface DeploymentApplicationClientContext { - principal: DelegatedPrincipal + principal: BoundWorkflowExecutionPrincipal requestId: string signal?: AbortSignal } diff --git a/apps/sim/lib/internal/deployments/execute-tool.test.ts b/apps/sim/lib/internal/deployments/execute-tool.test.ts index d1dcd08c242..cda27246ad1 100644 --- a/apps/sim/lib/internal/deployments/execute-tool.test.ts +++ b/apps/sim/lib/internal/deployments/execute-tool.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ createPrincipal: vi.fn(), + requireWorkspaceId: vi.fn(() => 'workspace-1'), deploy: vi.fn(), getVersion: vi.fn(), listVersions: vi.fn(), @@ -15,6 +16,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/internal/principals/executor', () => ({ createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, + requireExecutorWorkspaceId: mocks.requireWorkspaceId, })) vi.mock('@/lib/internal/deployments/operations', () => ({ @@ -29,7 +31,6 @@ import { DelegatedWorkspaceAuthorizationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { executeDeploymentsTool } from '@/lib/internal/deployments/execute-tool' import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' -import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' const INPUTS = { deployments_deploy: { workflowId: 'workflow-1', name: 'Release 4' }, @@ -91,7 +92,6 @@ describe('executeDeploymentsTool', () => { expect(response.status).toBe(200) expect(mocks.createPrincipal).toHaveBeenCalledWith({ context: executionRequest.context, - audience: WORKFLOW_DELEGATION_AUDIENCE, }) expect(DISPATCH[toolId]).toHaveBeenCalledWith( { ...INPUTS[toolId], workspaceId: 'workspace-1' }, diff --git a/apps/sim/lib/internal/deployments/execute-tool.ts b/apps/sim/lib/internal/deployments/execute-tool.ts index 7bfe4d4bf42..09c1b3045f6 100644 --- a/apps/sim/lib/internal/deployments/execute-tool.ts +++ b/apps/sim/lib/internal/deployments/execute-tool.ts @@ -28,7 +28,6 @@ import type { InternalToolOperationCall, InternalToolOperationHandler, } from '@/lib/internal/tool-operations/types' -import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' const logger = createLogger('DeploymentsInternalOperation') @@ -93,7 +92,6 @@ async function dispatchDeploymentTool( const principal = await createExecutorPrincipalFromExecutionContext({ context: request.context, - audience: WORKFLOW_DELEGATION_AUDIENCE, }) const context = { principal, diff --git a/apps/sim/lib/internal/file/execute-tool.test.ts b/apps/sim/lib/internal/file/execute-tool.test.ts index 29304cb3047..6904399f27d 100644 --- a/apps/sim/lib/internal/file/execute-tool.test.ts +++ b/apps/sim/lib/internal/file/execute-tool.test.ts @@ -3,6 +3,7 @@ */ import { createExecutionContext } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' const mocks = vi.hoisted(() => ({ @@ -42,7 +43,6 @@ vi.mock('@/lib/workspace-files/application/search-workspace-file-content', () => import { executeFileTool } from '@/lib/internal/file/execute-tool' import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' -import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' const MANAGE_INPUTS = { file_append: { operation: 'append', fileName: 'notes.txt', content: 'next' }, @@ -56,6 +56,7 @@ const MANAGE_INPUTS = { } as const const PARSER_TOOL_IDS = ['file_fetch', 'file_parser', 'file_parser_v2', 'file_parser_v3'] as const +const PRINCIPAL = createTestRuntimePrincipal() const BILLING_ATTRIBUTION = { actorUserId: 'user-1', @@ -105,13 +106,7 @@ function request( userId: 'user-1', workspaceId: 'workspace-1', billingAttribution: BILLING_ATTRIBUTION, - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, - }, + principal: PRINCIPAL, }, requestId: 'request-1', ...overrides, @@ -121,12 +116,7 @@ function request( describe('executeFileTool', () => { beforeEach(() => { vi.clearAllMocks() - mocks.createPrincipal.mockResolvedValue({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - }) + mocks.createPrincipal.mockResolvedValue(PRINCIPAL) mocks.executeManage.mockResolvedValue(Response.json({ success: true })) mocks.executeParser.mockResolvedValue(Response.json({ success: true })) mocks.searchContent.mockResolvedValue(SEARCH_RESULT) @@ -147,7 +137,7 @@ describe('executeFileTool', () => { }, }) expect(mocks.searchContent).toHaveBeenCalledWith({ - principal: expect.objectContaining({ serviceId: 'executor' }), + principal: PRINCIPAL, input: { workspaceId: 'workspace-1', query: 'needle', maxResults: 25, signal: undefined }, }) expect(mocks.executeManage).not.toHaveBeenCalled() @@ -172,7 +162,7 @@ describe('executeFileTool', () => { }) ) expect(mocks.getProvenance).toHaveBeenCalledWith( - expect.objectContaining({ serviceId: 'executor' }), + PRINCIPAL, 'workspace-1', expect.arrayContaining([ expect.objectContaining({ @@ -294,17 +284,17 @@ describe('executeFileTool', () => { expect(mocks.createPrincipal).toHaveBeenCalledWith({ context: executionRequest.context, - audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, }) }) - it('uses the delegation origin as the file authorization subject in child workflows', async () => { - mocks.createPrincipal.mockResolvedValueOnce({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'invoking-user', - workspaceId: 'workspace-1', + it('uses the preserved runtime actor as the file authorization subject in child workflows', async () => { + const childPrincipal = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'invoking-user', sessionId: 'session-invoking' }, + executionId: 'execution-parent', + rootWorkflowId: 'workflow-parent', + currentWorkflow: { workflowId: 'workflow-child', mode: 'draft' }, }) + mocks.createPrincipal.mockResolvedValueOnce(childPrincipal) await executeFileTool( request('file_get', MANAGE_INPUTS.file_get, { context: { @@ -312,11 +302,7 @@ describe('executeFileTool', () => { executionId: 'execution-child', userId: 'workflow-owner', workspaceId: 'workspace-1', - executorDelegationOrigin: { - subjectUserId: 'invoking-user', - workflowId: 'workflow-parent', - executionId: 'execution-parent', - }, + principal: childPrincipal, }, }) ) @@ -331,35 +317,20 @@ describe('executeFileTool', () => { }) it('uses compatibility attribution without replacing an actorless deployed principal', async () => { - const principal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, - issuedAt: new Date(), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution' as const, + const principal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', workflowId: 'workflow-1', - executionId: 'execution-1', - principal: { - kind: 'system' as const, - serviceId: 'schedule' as const, - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment' as const, - deploymentVersionId: 'deployment-1', - }, - compatibilityActor: { - kind: 'legacy_execution_user' as const, - userId: 'legacy-actor', - }, }, - } + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + compatibilityActorUserId: 'legacy-actor', + }) mocks.createPrincipal.mockResolvedValueOnce(principal) await executeFileTool( @@ -370,12 +341,7 @@ describe('executeFileTool', () => { userId: 'legacy-actor', workspaceId: 'workspace-1', billingAttribution: BILLING_ATTRIBUTION, - executorDelegationOrigin: { - workflowId: 'workflow-1', - executionId: 'execution-1', - principal: principal.delegationContext.principal, - currentWorkflow: principal.delegationContext.currentWorkflow, - }, + principal, }, }) ) @@ -398,7 +364,7 @@ describe('executeFileTool', () => { ...createExecutionContext({ workflowId: 'workflow-1' }), workspaceId: 'workspace-1', userId: undefined, - executorDelegationOrigin: undefined, + principal: undefined, }, }) ) diff --git a/apps/sim/lib/internal/file/execute-tool.ts b/apps/sim/lib/internal/file/execute-tool.ts index 9b2ebddf507..a1a5ae95276 100644 --- a/apps/sim/lib/internal/file/execute-tool.ts +++ b/apps/sim/lib/internal/file/execute-tool.ts @@ -23,7 +23,6 @@ import { } from '@/lib/internal/tool-operations/identity-faults' import { parseInternalToolInput } from '@/lib/internal/tool-operations/parse-input' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' -import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' import { searchWorkspaceFileContent } from '@/lib/workspace-files/application/search-workspace-file-content' import { FILE_SEARCH_DEFAULT_MAX_RESULTS, @@ -76,7 +75,7 @@ export const executeFileTool: InternalToolOperationHandler = async (request) => } const workspaceId = request.context.workspaceId - if (!workspaceId || !request.context.executorDelegationOrigin) { + if (!workspaceId || !request.context.principal?.executionMetadata) { return Response.json({ success: false, error: 'Authentication required' }, { status: 401 }) } @@ -101,7 +100,6 @@ export const executeFileTool: InternalToolOperationHandler = async (request) => try { const principal = await createExecutorPrincipalFromExecutionContext({ context: request.context, - audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, }) if (searchInput) { request.signal?.throwIfAborted() diff --git a/apps/sim/lib/internal/file/operations.test.ts b/apps/sim/lib/internal/file/operations.test.ts index deb2f78593f..af30fff9ac7 100644 --- a/apps/sim/lib/internal/file/operations.test.ts +++ b/apps/sim/lib/internal/file/operations.test.ts @@ -163,9 +163,9 @@ vi.mock('@/app/api/files/authorization', () => ({ })) import { fileManageBodySchema } from '@/lib/api/contracts/tools/file' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { executeFileManageOperation } from '@/lib/internal/file/operations' import { FileConflictError } from '@/lib/uploads/contexts/workspace' -import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' async function POST(request: Request): Promise { const parsed = fileManageBodySchema.safeParse(await request.json()) @@ -177,12 +177,7 @@ async function POST(request: Request): Promise { } const workspaceId = parsed.data.workspaceId || 'workspace-1' return executeFileManageOperation(parsed.data, { - principal: createWorkspaceFileDelegatedPrincipal({ - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId, - delegationId: 'test-file-operation', - }), + principal: createTestRuntimePrincipal(), workspaceId, attributedUserId: 'user-1', fileAccessUserId: 'user-1', @@ -218,31 +213,20 @@ function workspaceFile(id: string, ownerUserId = 'user-1') { } function actorlessDeploymentPrincipal(workspaceId = 'workspace-1') { - return { - kind: 'delegated' as const, - serviceId: 'executor' as const, - workspaceId, - delegationId: 'delegation-1', - audience: 'sim:workspace-files', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution' as const, + return createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId, workflowId: 'workflow-1', - executionId: 'execution-1', - principal: { - kind: 'system' as const, - serviceId: 'schedule' as const, - workspaceId, - workflowId: 'workflow-1', - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment' as const, - deploymentVersionId: 'deployment-1', - }, }, - } + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + compatibilityActorUserId: 'workspace-owner', + }) } describe('file manage operations', () => { @@ -497,7 +481,7 @@ describe('file manage operations', () => { expect(response.status).toBe(200) expect(mockEnsureWorkspaceFileFolderPath).toHaveBeenCalledWith( expect.objectContaining({ - principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'user-1' }), + principal: expect.objectContaining({ kind: 'session', userId: 'user-1' }), input: { workspaceId: 'workspace-1', pathSegments: ['Reports & Plans', '2026'] }, }) ) @@ -1009,7 +993,7 @@ describe('file manage operations', () => { archiveBuffer, expect.objectContaining({ workspaceId: 'workspace-1', - principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'user-1' }), + principal: expect.objectContaining({ kind: 'session', userId: 'user-1' }), secretProvenance: { status: 'exact', entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], diff --git a/apps/sim/lib/internal/function/execute.test.ts b/apps/sim/lib/internal/function/execute.test.ts index a5ed37edef3..7fff6f3610e 100644 --- a/apps/sim/lib/internal/function/execute.test.ts +++ b/apps/sim/lib/internal/function/execute.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ createPrincipal: vi.fn(), @@ -16,7 +17,6 @@ vi.mock('@/lib/function-execution/application/execute-function', () => ({ executeFunction: { execute: mocks.execute }, })) -import { FUNCTION_EXECUTION_DELEGATION_AUDIENCE } from '@/lib/function-execution/application/authorization' import { executeFunctionTool } from '@/lib/internal/function/execute' describe('executeFunctionTool', () => { @@ -26,39 +26,28 @@ describe('executeFunctionTool', () => { }) it('binds executor calls from the canonical origin instead of the compatibility user ID', async () => { - const startedAt = Date.now() - const origin = { - workflowId: 'workflow-1', - executionId: 'execution-1', + const principal = createTestRuntimePrincipal({ principal: { kind: 'system' as const, serviceId: 'schedule' as const, workspaceId: 'workspace-1', workflowId: 'workflow-1', }, + executionId: 'execution-1', currentWorkflow: { workflowId: 'workflow-1', mode: 'deployment' as const, deploymentVersionId: 'deployment-1', }, - } - const principal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, - issuedAt: new Date(), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { kind: 'workflow_execution' as const, ...origin }, - } + compatibilityActorUserId: 'workspace-owner', + }) mocks.createPrincipal.mockResolvedValue(principal) const context = { workflowId: 'workflow-1', workspaceId: 'workspace-1', executionId: 'execution-1', userId: 'workspace-owner', - executorDelegationOrigin: origin, + principal, } const headers = new Headers() @@ -76,13 +65,7 @@ describe('executeFunctionTool', () => { expect(mocks.createPrincipal).toHaveBeenCalledWith({ context, - audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, - expiresAt: expect.any(Date), - resourceScope: { executionId: 'execution-1' }, }) - const delegatedExpiry = mocks.createPrincipal.mock.calls[0]?.[0].expiresAt as Date - expect(delegatedExpiry.getTime()).toBeGreaterThanOrEqual(startedAt + 60_000) - expect(delegatedExpiry.getTime()).toBeLessThanOrEqual(Date.now() + 60_000) expect(mocks.execute).toHaveBeenCalledWith({ principal, input: expect.objectContaining({ diff --git a/apps/sim/lib/internal/function/execute.ts b/apps/sim/lib/internal/function/execute.ts index 4d68ba712a4..42ff617790c 100644 --- a/apps/sim/lib/internal/function/execute.ts +++ b/apps/sim/lib/internal/function/execute.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { BoundWorkflowExecutionPrincipal, DelegatedPrincipal } from '@sim/auth/principal' import type { FunctionExecuteBody } from '@/lib/api/contracts' import type { InternalSandboxProfile } from '@/lib/auth/internal' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' @@ -42,7 +42,7 @@ export async function executeFunctionTool(input: ExecuteFunctionToolInput): Prom fileKeys: context.fileKeys, allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope, } - let principal: DelegatedPrincipal + let principal: DelegatedPrincipal | BoundWorkflowExecutionPrincipal if (context.copilotToolExecution === true) { if (!context.userId) throw new Error('Copilot Function execution requires a user') principal = { @@ -59,9 +59,6 @@ export async function executeFunctionTool(input: ExecuteFunctionToolInput): Prom } else { principal = await createExecutorPrincipalFromExecutionContext({ context, - audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, - expiresAt, - ...(context.executionId ? { resourceScope: { executionId: context.executionId } } : {}), }) } diff --git a/apps/sim/lib/internal/knowledge/execute-tool.test.ts b/apps/sim/lib/internal/knowledge/execute-tool.test.ts index c8a07941ed5..da035f70283 100644 --- a/apps/sim/lib/internal/knowledge/execute-tool.test.ts +++ b/apps/sim/lib/internal/knowledge/execute-tool.test.ts @@ -4,9 +4,11 @@ import { createExecutionContext } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { InvalidInternalDelegationBindingError } from '@/lib/auth/internal-delegation' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ createExecutorPrincipalFromExecutionContext: vi.fn(), + requireExecutorWorkspaceId: vi.fn(), createChunkOperation: vi.fn(), createDocumentsOperation: vi.fn(), deleteChunkOperation: vi.fn(), @@ -25,6 +27,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/internal/principals/executor', () => ({ createExecutorPrincipalFromExecutionContext: mocks.createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId: mocks.requireExecutorWorkspaceId, })) vi.mock('@/lib/internal/knowledge/operations', () => ({ @@ -47,17 +50,9 @@ vi.mock('@/lib/internal/knowledge/operations', () => ({ import { executeKnowledgeTool, KNOWLEDGE_TOOL_IDS } from '@/lib/internal/knowledge/execute-tool' import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' -const principal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - subjectUserId: 'trusted-user', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:knowledge', - issuedAt: new Date('2026-01-01T00:00:00.000Z'), - expiresAt: new Date('2026-01-01T00:05:00.000Z'), - delegationContext: { kind: 'workflow_execution' as const, workflowId: 'workflow-1' }, -} +const principal = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'trusted-user', sessionId: 'session-1' }, +}) function createRequest( overrides: Partial = {} @@ -80,6 +75,7 @@ describe('executeKnowledgeTool', () => { beforeEach(() => { vi.clearAllMocks() mocks.createExecutorPrincipalFromExecutionContext.mockResolvedValue(principal) + mocks.requireExecutorWorkspaceId.mockReturnValue('workspace-1') mocks.listTagsOperation.mockResolvedValue({ body: { success: true, @@ -107,7 +103,6 @@ describe('executeKnowledgeTool', () => { await expect(response.json()).resolves.toMatchObject({ success: true }) expect(mocks.createExecutorPrincipalFromExecutionContext).toHaveBeenCalledWith({ context: request.context, - audience: 'sim:knowledge', }) expect(mocks.listTagsOperation).toHaveBeenCalledWith( 'kb-1', diff --git a/apps/sim/lib/internal/knowledge/execute-tool.ts b/apps/sim/lib/internal/knowledge/execute-tool.ts index 03b177ab4c9..99985977188 100644 --- a/apps/sim/lib/internal/knowledge/execute-tool.ts +++ b/apps/sim/lib/internal/knowledge/execute-tool.ts @@ -35,7 +35,10 @@ import { updateChunkOperation, upsertDocumentOperation, } from '@/lib/internal/knowledge/operations' -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { + createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId, +} from '@/lib/internal/principals/executor' import { classifyInternalToolIdentityFault, internalToolIdentityFaultMessage, @@ -47,7 +50,6 @@ import { } from '@/lib/internal/tool-operations/parse-contract-input' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' -import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' const logger = createLogger('KnowledgeToolExecution') const MAX_KNOWLEDGE_BODY_BYTES = 2 * 1024 * 1024 @@ -126,7 +128,6 @@ export const executeKnowledgeTool: InternalToolOperationHandler = async (request try { principal = await createExecutorPrincipalFromExecutionContext({ context: request.context, - audience: KNOWLEDGE_DELEGATION_AUDIENCE, }) } catch (error) { const identityFault = classifyInternalToolIdentityFault(error) @@ -141,6 +142,7 @@ export const executeKnowledgeTool: InternalToolOperationHandler = async (request signal?.throwIfAborted() const context = { principal, + workspaceId: requireExecutorWorkspaceId(request.context), headers: request.headers, signal, } diff --git a/apps/sim/lib/internal/knowledge/list-tags.ts b/apps/sim/lib/internal/knowledge/list-tags.ts index f2db525ceac..7fe315a7860 100644 --- a/apps/sim/lib/internal/knowledge/list-tags.ts +++ b/apps/sim/lib/internal/knowledge/list-tags.ts @@ -1,6 +1,5 @@ import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' -import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' import { listKnowledgeTags } from '@/lib/knowledge/application/tags' export interface ListKnowledgeTagsAsExecutorInput { @@ -16,7 +15,6 @@ export async function listKnowledgeTagsAsExecutor({ }: ListKnowledgeTagsAsExecutorInput) { const principal = await createExecutorPrincipalFromExecutionContext({ context, - audience: KNOWLEDGE_DELEGATION_AUDIENCE, }) const result = await listKnowledgeTags.execute({ principal, diff --git a/apps/sim/lib/internal/knowledge/operations.test.ts b/apps/sim/lib/internal/knowledge/operations.test.ts index 36e1db99a6e..4fd01528e6a 100644 --- a/apps/sim/lib/internal/knowledge/operations.test.ts +++ b/apps/sim/lib/internal/knowledge/operations.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ requireWorkspaceBillingAttributionHeader: vi.fn(), @@ -81,20 +82,16 @@ import { syncConnectorOperation, } from '@/lib/internal/knowledge/operations' -const principal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - subjectUserId: 'trusted-user', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:knowledge', - issuedAt: new Date('2026-01-01T00:00:00.000Z'), - expiresAt: new Date('2026-01-01T00:05:00.000Z'), - delegationContext: { kind: 'workflow_execution' as const, workflowId: 'workflow-1' }, -} +const principal = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'trusted-user', sessionId: 'session-1' }, +}) function createContext(): KnowledgeOperationContext { - return { principal, headers: new Headers({ 'x-billing': 'snapshot' }) } + return { + principal, + workspaceId: 'workspace-1', + headers: new Headers({ 'x-billing': 'snapshot' }), + } } describe('Knowledge direct operations', () => { diff --git a/apps/sim/lib/internal/knowledge/operations.ts b/apps/sim/lib/internal/knowledge/operations.ts index 10d179bdece..1d06cd3bb15 100644 --- a/apps/sim/lib/internal/knowledge/operations.ts +++ b/apps/sim/lib/internal/knowledge/operations.ts @@ -1,4 +1,4 @@ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { BoundWorkflowExecutionPrincipal } from '@sim/auth/principal' import type { z } from 'zod' import { type createChunkBodySchema, @@ -53,7 +53,8 @@ import { prepareKnowledgeModelInputProvenance } from '@/lib/knowledge/model-inpu import { createKnowledgeDocumentSourceValue } from '@/lib/knowledge/secret-provenance' export interface KnowledgeOperationContext { - principal: WorkflowExecutionDelegatedPrincipal + principal: BoundWorkflowExecutionPrincipal + workspaceId: string headers: Headers signal?: AbortSignal } @@ -89,7 +90,12 @@ function resolveChunkContentProvenance( headers: context.headers, payload, authType: AuthType.INTERNAL_JWT, - userId: internalKnowledgeProvenanceUserId(context.headers, context.principal, workspaceId), + userId: internalKnowledgeProvenanceUserId( + context.headers, + context.principal, + workspaceId, + 'executor_jwt' + ), ...(workspaceId ? { workspaceId } : {}), selectionKeys: includeContent ? ['chunk-content'] : [], }) @@ -115,7 +121,7 @@ export async function listDocumentsOperation( principal: context.principal, input: { knowledgeBaseId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, enabledFilter: query.enabledFilter, search: query.search, limit: query.limit, @@ -140,7 +146,8 @@ export async function listDocumentsOperation( userId: internalKnowledgeProvenanceUserId( context.headers, context.principal, - result.workspaceId + result.workspaceId, + 'executor_jwt' ), workspaceId: result.workspaceId, body, @@ -162,7 +169,7 @@ export async function createDocumentsOperation( const documents = bodyInput.bulk ? bodyInput.documents : [bodyInput] const input = { knowledgeBaseId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, documents, bulk: bodyInput.bulk, processingOptions: bodyInput.bulk ? bodyInput.processingOptions : undefined, @@ -227,7 +234,7 @@ export async function readDocumentOperation( input: { knowledgeBaseId, documentId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, }, request: { headers: context.headers }, }) @@ -239,7 +246,8 @@ export async function readDocumentOperation( userId: internalKnowledgeProvenanceUserId( context.headers, context.principal, - result.workspaceId + result.workspaceId, + 'executor_jwt' ), workspaceId: result.workspaceId, body, @@ -263,7 +271,7 @@ export async function deleteDocumentOperation( const input = { knowledgeBaseId, documentId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, source: 'ui', } const result = await deleteKnowledgeDocument.execute({ @@ -289,7 +297,7 @@ export async function upsertDocumentOperation( throwIfAborted(context) const input = { knowledgeBaseId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, documentId: bodyInput.documentId, filename: bodyInput.filename, fileUrl: bodyInput.fileUrl, @@ -370,7 +378,7 @@ export async function listChunksOperation( input: { knowledgeBaseId, documentId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, ...query, }, request: { headers: context.headers }, @@ -387,7 +395,8 @@ export async function listChunksOperation( userId: internalKnowledgeProvenanceUserId( context.headers, context.principal, - result.workspaceId + result.workspaceId, + 'executor_jwt' ), workspaceId: result.workspaceId, body, @@ -413,7 +422,7 @@ export async function createChunkOperation( input: { knowledgeBaseId, documentId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, content: bodyInput.content, enabled: bodyInput.enabled, resolveContentProvenance: ({ workspaceId }) => @@ -448,7 +457,7 @@ export async function updateChunkOperation( knowledgeBaseId, documentId, chunkId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, content: bodyInput.content, enabled: bodyInput.enabled, resolveContentProvenance: ({ workspaceId }) => @@ -469,7 +478,8 @@ export async function updateChunkOperation( userId: internalKnowledgeProvenanceUserId( context.headers, context.principal, - result.workspaceId + result.workspaceId, + 'executor_jwt' ), workspaceId: result.workspaceId, body, @@ -498,7 +508,7 @@ export async function deleteChunkOperation( knowledgeBaseId, documentId, chunkId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, }, request: { headers: context.headers }, }) @@ -513,7 +523,7 @@ export async function listConnectorsOperation( throwIfAborted(context) const result = await listKnowledgeConnectors.execute({ principal: context.principal, - input: { knowledgeBaseId, assertedWorkspaceId: context.principal.workspaceId }, + input: { knowledgeBaseId, assertedWorkspaceId: context.workspaceId }, request: { headers: context.headers }, }) throwIfAborted(context) @@ -533,7 +543,7 @@ export async function readConnectorOperation( input: { knowledgeBaseId, connectorId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, }, request: { headers: context.headers }, }) @@ -551,7 +561,7 @@ export async function syncConnectorOperation( const input = { knowledgeBaseId, connectorId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, rehydrate, resolveBillingAttribution: (workspaceId: string) => Promise.resolve(billingAttribution(context, workspaceId)), @@ -574,7 +584,7 @@ export async function listTagsOperation( throwIfAborted(context) const result = await listKnowledgeTags.execute({ principal: context.principal, - input: { knowledgeBaseId, assertedWorkspaceId: context.principal.workspaceId }, + input: { knowledgeBaseId, assertedWorkspaceId: context.workspaceId }, request: { headers: context.headers }, }) throwIfAborted(context) @@ -591,7 +601,7 @@ export async function searchOperation( const result = await searchKnowledge.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, knowledgeBaseIds: Array.isArray(bodyInput.knowledgeBaseIds) ? bodyInput.knowledgeBaseIds : [bodyInput.knowledgeBaseIds], diff --git a/apps/sim/lib/internal/knowledge/search.ts b/apps/sim/lib/internal/knowledge/search.ts index 3ac1d3042c3..76d114e3b5b 100644 --- a/apps/sim/lib/internal/knowledge/search.ts +++ b/apps/sim/lib/internal/knowledge/search.ts @@ -1,7 +1,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' -import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' import { searchKnowledge } from '@/lib/knowledge/application/search' import type { ResolvedSecretInputPath, @@ -34,7 +33,6 @@ export async function searchKnowledgeAsExecutor({ signal?.throwIfAborted() const principal = await createExecutorPrincipalFromExecutionContext({ context, - audience: KNOWLEDGE_DELEGATION_AUDIENCE, }) const resultSecretRegistry = resolvedSecretTraceRegistry.forkForInputPaths(modelInputPaths) if (!resultSecretRegistry.isComplete()) { diff --git a/apps/sim/lib/internal/logs/execute-tool.test.ts b/apps/sim/lib/internal/logs/execute-tool.test.ts index 842d133d9b4..efc9adf245f 100644 --- a/apps/sim/lib/internal/logs/execute-tool.test.ts +++ b/apps/sim/lib/internal/logs/execute-tool.test.ts @@ -11,6 +11,7 @@ import type { ExecutionContext } from '@/executor/types' const mocks = vi.hoisted(() => ({ createPrincipal: vi.fn(), + requireWorkspaceId: vi.fn(() => 'workspace-canonical'), list: vi.fn(), get: vi.fn(), getRun: vi.fn(), @@ -19,6 +20,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/internal/principals/executor', () => ({ createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, + requireExecutorWorkspaceId: mocks.requireWorkspaceId, })) vi.mock('@/lib/internal/logs/operations', () => ({ executeLogsList: mocks.list, @@ -28,7 +30,7 @@ vi.mock('@/lib/internal/logs/operations', () => ({ })) import { executeLogsTool } from '@/lib/internal/logs/execute-tool' -import { ExecutorDelegationOriginRequiredError } from '@/lib/internal/tool-operations/identity-faults' +import { WorkflowExecutionPrincipalRequiredError } from '@/lib/internal/tool-operations/identity-faults' const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { kind: 'delegated', @@ -135,8 +137,6 @@ describe('executeLogsTool', () => { expect(mocks[testCase.operation]).toHaveBeenCalledOnce() expect(mocks.createPrincipal).toHaveBeenCalledWith({ context: CONTEXT, - audience: 'sim:logs', - ...(testCase.executionId ? { resourceScope: { executionId: testCase.executionId } } : {}), }) }) @@ -175,9 +175,7 @@ describe('executeLogsTool', () => { }) it('answers a missing execution context as unauthenticated, not as a broken tool', async () => { - // A caller with no executor delegation origin never established an identity. - // The error was untyped, so it fell past the classifier into a generic 500. - mocks.createPrincipal.mockRejectedValueOnce(new ExecutorDelegationOriginRequiredError()) + mocks.createPrincipal.mockRejectedValueOnce(new WorkflowExecutionPrincipalRequiredError()) const response = await executeLogsTool({ toolId: 'logs_query', diff --git a/apps/sim/lib/internal/logs/execute-tool.ts b/apps/sim/lib/internal/logs/execute-tool.ts index 58ba3b5ef73..823fdd6c94b 100644 --- a/apps/sim/lib/internal/logs/execute-tool.ts +++ b/apps/sim/lib/internal/logs/execute-tool.ts @@ -20,14 +20,16 @@ import { executeLogsList, type LogsToolOperationContext, } from '@/lib/internal/logs/operations' -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { + createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId, +} from '@/lib/internal/principals/executor' import { classifyInternalToolIdentityFault, internalToolIdentityFaultMessage, internalToolIdentityFaultStatus, } from '@/lib/internal/tool-operations/identity-faults' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' -import { LOGS_DELEGATION_AUDIENCE } from '@/lib/logs/application/authorization' const logger = createLogger('LogsInternalOperation') @@ -68,7 +70,7 @@ async function dispatchLogsTool( case 'logs_query_runs': { const parsed = listLogsQuerySchema.safeParse({ ...(isPlainRecord(request.input) ? request.input : {}), - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, }) return parsed.success ? dispatched(listLogsContract, executeLogsList(parsed.data, context)) @@ -126,12 +128,11 @@ export const executeLogsTool: InternalToolOperationHandler = async (request) => try { const principal = await createExecutorPrincipalFromExecutionContext({ context: request.context, - audience: LOGS_DELEGATION_AUDIENCE, - ...(requestedExecutionId ? { resourceScope: { executionId: requestedExecutionId } } : {}), }) request.signal?.throwIfAborted() const dispatched = await dispatchLogsTool(request, { principal, + workspaceId: requireExecutorWorkspaceId(request.context), signal: request.signal, }) if (dispatched instanceof Response) return dispatched diff --git a/apps/sim/lib/internal/logs/operations.test.ts b/apps/sim/lib/internal/logs/operations.test.ts index 6bb17a75fab..5d577b0ea98 100644 --- a/apps/sim/lib/internal/logs/operations.test.ts +++ b/apps/sim/lib/internal/logs/operations.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ list: vi.fn(), @@ -29,20 +29,10 @@ import { type LogsToolOperationContext, } from '@/lib/internal/logs/operations' -const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:logs', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2026-08-27T00:05:00.000Z'), - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, -} +const PRINCIPAL = createTestRuntimePrincipal() function context(): LogsToolOperationContext { - return { principal: PRINCIPAL, signal: undefined } + return { principal: PRINCIPAL, workspaceId: 'workspace-canonical', signal: undefined } } describe('Logs direct operations', () => { @@ -96,7 +86,11 @@ describe('Logs direct operations', () => { await executeLogsGetExecution('execution-1', context()) expect(mocks.snapshot).toHaveBeenCalledWith({ principal: PRINCIPAL, - input: { executionId: 'execution-1', signal: undefined }, + input: { + executionId: 'execution-1', + assertedWorkspaceId: 'workspace-canonical', + signal: undefined, + }, }) }) }) diff --git a/apps/sim/lib/internal/logs/operations.ts b/apps/sim/lib/internal/logs/operations.ts index f860a7f22b9..fae75db2b5e 100644 --- a/apps/sim/lib/internal/logs/operations.ts +++ b/apps/sim/lib/internal/logs/operations.ts @@ -1,4 +1,4 @@ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { BoundWorkflowExecutionPrincipal } from '@sim/auth/principal' import type { ContractQuery } from '@/lib/api/contracts' import type { listLogsContract } from '@/lib/api/contracts/logs' import { listLogsUseCase } from '@/lib/logs/application/list-logs' @@ -6,7 +6,8 @@ import { readExecutionSnapshotUseCase } from '@/lib/logs/application/read-execut import { readLogDetailUseCase } from '@/lib/logs/application/read-log-detail' export interface LogsToolOperationContext { - principal: WorkflowExecutionDelegatedPrincipal + principal: BoundWorkflowExecutionPrincipal + workspaceId: string signal?: AbortSignal } @@ -22,7 +23,7 @@ export async function executeLogsList( context.signal?.throwIfAborted() const result = await listLogsUseCase.execute({ principal: context.principal, - input: { ...query, workspaceId: context.principal.workspaceId, signal: context.signal }, + input: { ...query, workspaceId: context.workspaceId, signal: context.signal }, }) return complete(context, result) } @@ -31,7 +32,7 @@ export async function executeLogsGet(id: string, context: LogsToolOperationConte const result = await readLogDetailUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, lookupColumn: 'id', lookupValue: id, signal: context.signal, @@ -47,7 +48,7 @@ export async function executeLogsGetRunDetails( const result = await readLogDetailUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, lookupColumn: 'executionId', lookupValue: executionId, signal: context.signal, @@ -62,7 +63,11 @@ export async function executeLogsGetExecution( ) { const result = await readExecutionSnapshotUseCase.execute({ principal: context.principal, - input: { executionId, signal: context.signal }, + input: { + executionId, + assertedWorkspaceId: context.workspaceId, + signal: context.signal, + }, }) return complete(context, result) } diff --git a/apps/sim/lib/internal/mcp/discover-tools.ts b/apps/sim/lib/internal/mcp/discover-tools.ts index 78050f11e9c..6505a6ffb02 100644 --- a/apps/sim/lib/internal/mcp/discover-tools.ts +++ b/apps/sim/lib/internal/mcp/discover-tools.ts @@ -1,6 +1,5 @@ import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' -import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' import { discoverMcpServerToolsUseCase } from '@/lib/mcp/application/use-cases' export interface DiscoverMcpServerToolsAsExecutorInput { @@ -19,7 +18,6 @@ export async function discoverMcpServerToolsAsExecutor({ signal?.throwIfAborted() const principal = await createExecutorPrincipalFromExecutionContext({ context, - audience: MCP_SERVER_DELEGATION_AUDIENCE, }) signal?.throwIfAborted() diff --git a/apps/sim/lib/internal/mcp/execute-tool.test.ts b/apps/sim/lib/internal/mcp/execute-tool.test.ts index c85e58471af..9d09e1215a2 100644 --- a/apps/sim/lib/internal/mcp/execute-tool.test.ts +++ b/apps/sim/lib/internal/mcp/execute-tool.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' @@ -21,31 +21,10 @@ vi.mock('@/lib/mcp/application/execute-tool', () => ({ import { executeMcpTool } from '@/lib/internal/mcp/execute-tool' -const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:mcp-servers', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2099-08-27T00:05:00.000Z'), - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, -} -const NESTED_HUMAN_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - workspaceId: 'workspace-1', - delegationId: 'delegation-nested-human', - audience: 'sim:mcp-servers', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2099-08-27T00:05:00.000Z'), - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - principal: { kind: 'session', userId: 'user-origin', sessionId: 'session-origin' }, - }, -} +const PRINCIPAL = createTestRuntimePrincipal() +const NESTED_HUMAN_PRINCIPAL = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'user-origin', sessionId: 'session-origin' }, +}) const BILLING = { actorUserId: 'user-1', workspaceId: 'workspace-1', @@ -59,6 +38,7 @@ const CONTEXT: InternalToolOperationContext = { userId: 'user-1', workspaceId: 'workspace-1', workflowId: 'workflow-1', + principal: PRINCIPAL, billingAttribution: BILLING, callChain: ['workflow-parent'], } @@ -92,7 +72,6 @@ describe('executeMcpTool', () => { }) expect(mocks.createPrincipal).toHaveBeenCalledWith({ context: CONTEXT, - audience: 'sim:mcp-servers', }) expect(mocks.executeUseCase).toHaveBeenCalledWith({ principal: PRINCIPAL, diff --git a/apps/sim/lib/internal/mcp/execute-tool.ts b/apps/sim/lib/internal/mcp/execute-tool.ts index 3e900d81af2..7a47f312422 100644 --- a/apps/sim/lib/internal/mcp/execute-tool.ts +++ b/apps/sim/lib/internal/mcp/execute-tool.ts @@ -16,7 +16,6 @@ import { internalToolIdentityFaultStatus, } from '@/lib/internal/tool-operations/identity-faults' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' -import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' import { executeMcpToolUseCase, McpToolsNotAllowedError } from '@/lib/mcp/application/execute-tool' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' @@ -126,7 +125,6 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { try { const principal = await createExecutorPrincipalFromExecutionContext({ context: request.context, - audience: MCP_SERVER_DELEGATION_AUDIENCE, }) request.signal?.throwIfAborted() const subject = resolvePrincipalSubject(principal) diff --git a/apps/sim/lib/internal/memory/execute-tool.test.ts b/apps/sim/lib/internal/memory/execute-tool.test.ts index 9f5eab78047..1423ff8eae1 100644 --- a/apps/sim/lib/internal/memory/execute-tool.test.ts +++ b/apps/sim/lib/internal/memory/execute-tool.test.ts @@ -2,12 +2,13 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import type { ExecutionContext } from '@/executor/types' const mocks = vi.hoisted(() => ({ createPrincipal: vi.fn(), + requireWorkspaceId: vi.fn(() => 'workspace-canonical'), add: vi.fn(), list: vi.fn(), get: vi.fn(), @@ -17,6 +18,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/internal/principals/executor', () => ({ createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, + requireExecutorWorkspaceId: mocks.requireWorkspaceId, })) vi.mock('@/lib/internal/memory/operations', () => ({ @@ -33,43 +35,21 @@ vi.mock('@/lib/internal/memory/provenance', () => ({ import { executeMemoryTool } from '@/lib/internal/memory/execute-tool' -const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:memory', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2026-08-27T00:05:00.000Z'), - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, -} +const PRINCIPAL = createTestRuntimePrincipal() -const ACTORLESS_DEPLOYED_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-actorless', - audience: 'sim:memory', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2026-08-27T00:05:00.000Z'), - delegationContext: { - kind: 'workflow_execution', +const ACTORLESS_DEPLOYED_PRINCIPAL = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-canonical', workflowId: 'workflow-1', - executionId: 'execution-1', - principal: { - kind: 'system', - serviceId: 'schedule', - workspaceId: 'workspace-canonical', - workflowId: 'workflow-1', - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, }, -} + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, +}) const CONTEXT = { userId: 'user-1', workflowId: 'workflow-1' } as ExecutionContext @@ -135,7 +115,6 @@ describe('executeMemoryTool', () => { expect(mocks[testCase.operation]).toHaveBeenCalledOnce() expect(mocks.createPrincipal).toHaveBeenCalledWith({ context: CONTEXT, - audience: 'sim:memory', }) }) @@ -166,12 +145,7 @@ describe('executeMemoryTool', () => { workflowId: 'workflow-1', workspaceId: 'workspace-canonical', executionId: 'execution-1', - executorDelegationOrigin: { - workflowId: 'workflow-1', - executionId: 'execution-1', - principal: ACTORLESS_DEPLOYED_PRINCIPAL.delegationContext?.principal, - currentWorkflow: ACTORLESS_DEPLOYED_PRINCIPAL.delegationContext?.currentWorkflow, - }, + principal: ACTORLESS_DEPLOYED_PRINCIPAL, } mocks.createPrincipal.mockResolvedValueOnce(ACTORLESS_DEPLOYED_PRINCIPAL) mocks.list.mockResolvedValueOnce({ diff --git a/apps/sim/lib/internal/memory/execute-tool.ts b/apps/sim/lib/internal/memory/execute-tool.ts index b3c0fe5dd91..61bfd604dc5 100644 --- a/apps/sim/lib/internal/memory/execute-tool.ts +++ b/apps/sim/lib/internal/memory/execute-tool.ts @@ -23,14 +23,16 @@ import { type MemoryToolOperationResult, } from '@/lib/internal/memory/operations' import { createMemoryToolResponse, MemoryProvenanceError } from '@/lib/internal/memory/provenance' -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { + createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId, +} from '@/lib/internal/principals/executor' import { classifyInternalToolIdentityFault, internalToolIdentityFaultMessage, internalToolIdentityFaultStatus, } from '@/lib/internal/tool-operations/identity-faults' import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types' -import { MEMORY_DELEGATION_AUDIENCE } from '@/lib/memory/application/authorization' const logger = createLogger('MemoryInternalOperation') @@ -81,7 +83,7 @@ async function dispatchMemoryTool( case 'memory_get_all': { const parsed = memoryListQuerySchema.safeParse({ ...(isPlainRecord(request.input) ? request.input : {}), - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, }) return parsed.success ? dispatched(listMemoriesContract, executeMemoryList(parsed.data, context)) @@ -96,7 +98,7 @@ async function dispatchMemoryTool( case 'memory_delete': { const parsed = memoryDeleteQuerySchema.safeParse({ ...(isPlainRecord(request.input) ? request.input : {}), - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, }) return parsed.success ? dispatched(deleteMemoryByQueryContract, executeMemoryDelete(parsed.data, context)) @@ -123,11 +125,11 @@ export const executeMemoryTool: InternalToolOperationHandler = async (request) = try { const principal = await createExecutorPrincipalFromExecutionContext({ context: request.context, - audience: MEMORY_DELEGATION_AUDIENCE, }) request.signal?.throwIfAborted() const dispatched = await dispatchMemoryTool(request, { principal, + workspaceId: requireExecutorWorkspaceId(request.context), headers: request.headers, signal: request.signal, }) diff --git a/apps/sim/lib/internal/memory/operations.test.ts b/apps/sim/lib/internal/memory/operations.test.ts index 8a202a47365..2b4d0636567 100644 --- a/apps/sim/lib/internal/memory/operations.test.ts +++ b/apps/sim/lib/internal/memory/operations.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ append: vi.fn(), @@ -41,17 +41,7 @@ import { type MemoryToolOperationContext, } from '@/lib/internal/memory/operations' -const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:memory', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2026-08-27T00:05:00.000Z'), - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, -} +const PRINCIPAL = createTestRuntimePrincipal() const RECORD = { id: 'memory-1', @@ -61,7 +51,7 @@ const RECORD = { } function context(): MemoryToolOperationContext { - return { principal: PRINCIPAL, headers: new Headers() } + return { principal: PRINCIPAL, workspaceId: 'workspace-canonical', headers: new Headers() } } describe('Memory direct operations', () => { diff --git a/apps/sim/lib/internal/memory/operations.ts b/apps/sim/lib/internal/memory/operations.ts index 29103641ced..9d6f0603ca5 100644 --- a/apps/sim/lib/internal/memory/operations.ts +++ b/apps/sim/lib/internal/memory/operations.ts @@ -1,4 +1,4 @@ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { BoundWorkflowExecutionPrincipal } from '@sim/auth/principal' import type { ContractBody, ContractQuery } from '@/lib/api/contracts' import type { createMemoryContract, @@ -21,7 +21,8 @@ import { } from '@/lib/memory/application/use-cases' export interface MemoryToolOperationContext { - principal: WorkflowExecutionDelegatedPrincipal + principal: BoundWorkflowExecutionPrincipal + workspaceId: string headers: Headers signal?: AbortSignal } @@ -49,7 +50,7 @@ export async function executeMemoryAdd( const result = await appendMemoryUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, key: body.key ?? '', data: body.data, ...(resolveWriteProvenance ? { resolveWriteProvenance } : {}), @@ -76,7 +77,7 @@ export async function executeMemoryList( const result = await listMemoriesUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, query: query.query, limit: query.limit, includePersistedSecretProvenance: memoryToolRequestsProvenance(context.headers), @@ -107,7 +108,7 @@ export async function executeMemoryGet( const result = await readMemoryUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, key, includePersistedSecretProvenance: memoryToolRequestsProvenance(context.headers), resolveBillingAttribution: async (workspaceId) => @@ -132,7 +133,7 @@ export async function executeMemoryDelete( const result = await deleteMemoryUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, key: query.conversationId ?? '', signal: context.signal, }, diff --git a/apps/sim/lib/internal/principals/executor.test.ts b/apps/sim/lib/internal/principals/executor.test.ts index 3409852c2e0..5a054b2f015 100644 --- a/apps/sim/lib/internal/principals/executor.test.ts +++ b/apps/sim/lib/internal/principals/executor.test.ts @@ -2,24 +2,49 @@ * @vitest-environment node */ +import { + bindPrincipalExecutionMetadata, + type WorkflowExecutionPrincipal, +} from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/executor/types' -const { mockBindInternalExecutorDelegation } = vi.hoisted(() => ({ - mockBindInternalExecutorDelegation: vi.fn(), +const { mockBindRuntimeWorkflowExecutionPrincipal } = vi.hoisted(() => ({ + mockBindRuntimeWorkflowExecutionPrincipal: vi.fn(), })) vi.mock('@/lib/auth/internal-delegation', () => ({ - bindInternalExecutorDelegation: mockBindInternalExecutorDelegation, + bindRuntimeWorkflowExecutionPrincipal: mockBindRuntimeWorkflowExecutionPrincipal, })) -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { + createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId, +} from '@/lib/internal/principals/executor' + +function runtimePrincipal(principal: WorkflowExecutionPrincipal) { + return bindPrincipalExecutionMetadata(principal, { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, + }) +} function executionContext(overrides: Partial = {}): ExecutionContext { return { - workflowId: 'workflow-current', - executionId: 'execution-current', - userId: 'user-current', + workflowId: 'workflow-1', + executionId: 'execution-1', + workspaceId: 'workspace-1', + userId: 'legacy-execution-user', + principal: runtimePrincipal({ + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }), ...overrides, } as ExecutionContext } @@ -27,79 +52,20 @@ function executionContext(overrides: Partial = {}): ExecutionC describe('createExecutorPrincipalFromExecutionContext', () => { beforeEach(() => { vi.clearAllMocks() - mockBindInternalExecutorDelegation.mockImplementation(async (claims, options) => ({ - kind: 'delegated', - serviceId: 'executor', - ...(claims.subjectUserId ? { subjectUserId: claims.subjectUserId } : {}), - workspaceId: 'workspace-canonical', - delegationId: claims.delegationId, - audience: options.audience, - issuedAt: claims.issuedAt, - expiresAt: claims.expiresAt, - resourceScope: options.resourceScope, - delegationContext: { - kind: 'workflow_execution', - workflowId: claims.workflowId, - ...(claims.executionId ? { executionId: claims.executionId } : {}), - ...(claims.principal ? { principal: claims.principal } : {}), - ...(claims.currentWorkflow ? { currentWorkflow: claims.currentWorkflow } : {}), - ...(options.compatibilityActorUserId - ? { - compatibilityActor: { - kind: 'legacy_execution_user' as const, - userId: options.compatibilityActorUserId, - }, - } - : {}), - }, - })) - }) - - it('uses the signed delegation origin ahead of nested execution identity', async () => { - await createExecutorPrincipalFromExecutionContext({ - context: executionContext({ - executorDelegationOrigin: { - subjectUserId: 'user-origin', - workflowId: 'workflow-origin', - executionId: 'execution-origin', - }, - }), - audience: 'sim:tables', - resourceScope: { tableId: 'table-1' }, - }) - - expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( - expect.objectContaining({ - subjectUserId: 'user-origin', - workflowId: 'workflow-origin', - executionId: 'execution-origin', - }), - { - audience: 'sim:tables', - resourceScope: { tableId: 'table-1' }, - } - ) + mockBindRuntimeWorkflowExecutionPrincipal.mockImplementation(async (principal) => principal) }) - it('uses an explicit trusted execution deadline as the delegation expiry', async () => { - const expiresAt = new Date('2026-01-01T01:00:00.000Z') - - await createExecutorPrincipalFromExecutionContext({ - context: executionContext({ - executorDelegationOrigin: { - subjectUserId: 'user-origin', - workflowId: 'workflow-origin', - executionId: 'execution-origin', - }, - }), - audience: 'sim:function-executions', - expiresAt, + it('revalidates and returns the same semantic runtime principal', async () => { + const principal = runtimePrincipal({ + kind: 'session', + userId: 'user-origin', + sessionId: 'session-origin', }) - expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( - expect.objectContaining({ expiresAt }), - { audience: 'sim:function-executions' } - ) + await expect( + createExecutorPrincipalFromExecutionContext({ context: executionContext({ principal }) }) + ).resolves.toBe(principal) + expect(mockBindRuntimeWorkflowExecutionPrincipal).toHaveBeenCalledWith(principal, undefined) }) it.each([ @@ -108,124 +74,89 @@ describe('createExecutorPrincipalFromExecutionContext', () => { principal: { kind: 'system' as const, serviceId: 'schedule' as const, - workspaceId: 'workspace-canonical', - workflowId: 'workflow-origin', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', }, }, { name: 'workspace API key', principal: { kind: 'workspace_api_key' as const, - workspaceId: 'workspace-canonical', + workspaceId: 'workspace-1', keyId: 'workspace-key-1', }, }, - { - name: 'webhook external subject', - principal: { - kind: 'system' as const, - serviceId: 'webhook' as const, - workspaceId: 'workspace-canonical', - workflowId: 'workflow-origin', - webhookId: 'webhook-1', + ])( + 'preserves the $name principal while carrying legacy attribution separately', + async (entry) => { + const principal = runtimePrincipal(entry.principal) + + await createExecutorPrincipalFromExecutionContext({ + context: executionContext({ principal }), + }) + + expect(mockBindRuntimeWorkflowExecutionPrincipal).toHaveBeenCalledWith(principal, { + compatibilityActorUserId: 'legacy-execution-user', + }) + } + ) + + it('does not substitute the execution user for a verified external subject', async () => { + const principal = runtimePrincipal({ + kind: 'system', + serviceId: 'webhook', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + webhookId: 'webhook-1', + provider: 'slack', + subject: { + kind: 'external_user', provider: 'slack', - subject: { - kind: 'external_user' as const, - provider: 'slack', - tenantId: 'team-1', - subjectId: 'external-user-1', - }, + tenantId: 'team-1', + subjectId: 'external-user-1', }, - }, - ])('preserves an actorless $name principal and deployment authority', async ({ principal }) => { - const currentWorkflow = { - workflowId: 'workflow-origin', - mode: 'deployment' as const, - deploymentVersionId: 'deployment-1', - } + }) await createExecutorPrincipalFromExecutionContext({ - context: executionContext({ - executorDelegationOrigin: { - workflowId: 'workflow-origin', - executionId: 'execution-origin', - principal, - currentWorkflow, - }, - }), - audience: 'sim:tables', + context: executionContext({ principal }), }) - expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'workflow-origin', - executionId: 'execution-origin', - principal, - currentWorkflow, - }), - { audience: 'sim:tables', compatibilityActorUserId: 'user-current' } - ) - expect(mockBindInternalExecutorDelegation.mock.calls[0]?.[0]).not.toHaveProperty( - 'subjectUserId' - ) + expect(mockBindRuntimeWorkflowExecutionPrincipal).toHaveBeenCalledWith(principal, undefined) }) - it('derives the subject from the preserved human principal', async () => { - const principal = { - kind: 'session' as const, - userId: 'user-origin', - sessionId: 'session-origin', - } - - await createExecutorPrincipalFromExecutionContext({ - context: executionContext({ - executorDelegationOrigin: { - workflowId: 'workflow-origin', - executionId: 'execution-origin', - principal, - currentWorkflow: { workflowId: 'workflow-origin', mode: 'draft' }, - }, - }), - audience: 'sim:tables', - }) - - expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( - expect.objectContaining({ - subjectUserId: 'user-origin', - principal, - currentWorkflow: { workflowId: 'workflow-origin', mode: 'draft' }, - }), - { audience: 'sim:tables' } - ) + it('fails closed without the runtime principal', async () => { + await expect( + createExecutorPrincipalFromExecutionContext({ + context: executionContext({ principal: undefined }), + }) + ).rejects.toThrow('Workflow execution principal is required') + expect(mockBindRuntimeWorkflowExecutionPrincipal).not.toHaveBeenCalled() }) - it('rejects a supplied subject that disagrees with the preserved principal', async () => { + it('fails closed when the runtime principal lacks execution metadata', async () => { await expect( createExecutorPrincipalFromExecutionContext({ context: executionContext({ - executorDelegationOrigin: { - subjectUserId: 'forged-user', - workflowId: 'workflow-origin', - principal: { - kind: 'session', - userId: 'user-origin', - sessionId: 'session-origin', - }, + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', }, }), - audience: 'sim:tables', }) - ).rejects.toThrow('Executor subject does not match its workflow principal') - expect(mockBindInternalExecutorDelegation).not.toHaveBeenCalled() + ).rejects.toThrow('missing execution metadata') + expect(mockBindRuntimeWorkflowExecutionPrincipal).not.toHaveBeenCalled() }) +}) - it('fails closed without a canonical delegation origin', async () => { - await expect( - createExecutorPrincipalFromExecutionContext({ - context: executionContext(), - audience: 'sim:tables', - }) - ).rejects.toThrow('Executor delegation origin is required') - expect(mockBindInternalExecutorDelegation).not.toHaveBeenCalled() +describe('requireExecutorWorkspaceId', () => { + it('returns the explicitly transported workspace assertion', () => { + expect(requireExecutorWorkspaceId({ workspaceId: 'workspace-1' })).toBe('workspace-1') + }) + + it.each([undefined, '', ' '])('fails closed for invalid workspace %s', (workspaceId) => { + expect(() => requireExecutorWorkspaceId({ workspaceId })).toThrow( + 'Workflow execution workspace is required' + ) }) }) diff --git a/apps/sim/lib/internal/principals/executor.ts b/apps/sim/lib/internal/principals/executor.ts index 1c40c232116..1ac426c5dd2 100644 --- a/apps/sim/lib/internal/principals/executor.ts +++ b/apps/sim/lib/internal/principals/executor.ts @@ -1,90 +1,33 @@ -import { type DelegatedPrincipal, resolvePrincipalSubject } from '@sim/auth/principal' -import { generateId } from '@sim/utils/id' -import { bindInternalExecutorDelegation } from '@/lib/auth/internal-delegation' -import { ExecutorDelegationOriginRequiredError } from '@/lib/internal/tool-operations/identity-faults' +import { + type BoundWorkflowExecutionPrincipal, + requirePrincipalExecutionMetadata, + resolvePrincipalSubject, +} from '@sim/auth/principal' +import { bindRuntimeWorkflowExecutionPrincipal } from '@/lib/auth/internal-delegation' +import { WorkflowExecutionPrincipalRequiredError } from '@/lib/internal/tool-operations/identity-faults' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' -import type { ExecutorDelegationOrigin } from '@/executor/types' - -const EXECUTOR_DELEGATION_TTL_MS = 5 * 60 * 1000 - -export function resolveExecutorOriginSubject(origin: ExecutorDelegationOrigin): string | undefined { - const principalSubject = origin.principal ? resolvePrincipalSubject(origin.principal) : null - if (principalSubject?.kind === 'external_user' && origin.subjectUserId) { - throw new Error('External workflow subjects cannot be represented as Sim users') - } - if (!principalSubject && origin.principal && origin.subjectUserId) { - throw new Error('Actorless workflow principals cannot be represented as Sim users') - } - if ( - principalSubject?.kind === 'sim_user' && - origin.subjectUserId && - origin.subjectUserId !== principalSubject.userId - ) { - throw new Error('Executor subject does not match its workflow principal') - } - - const subjectUserId = - principalSubject?.kind === 'sim_user' ? principalSubject.userId : origin.subjectUserId - if (!subjectUserId && !origin.principal) throw new Error('Authentication required') - return subjectUserId -} - -/** - * Binds an executor delegation origin to a delegated principal in-process, - * without minting and re-verifying a delegation JWT. The underlying binding - * still re-validates the workflow and deployment context, so trust matches the - * wire path minus the signature check, which proves nothing in-process. - */ -export async function createExecutorPrincipalFromDelegationOrigin( - origin: ExecutorDelegationOrigin, - audience: string, - resourceScope?: DelegatedPrincipal['resourceScope'], - expiresAt?: Date, - compatibilityActorUserId?: string -) { - if (!origin.workflowId.trim()) throw new Error('Authentication required') - const subjectUserId = resolveExecutorOriginSubject(origin) - const issuedAt = new Date() - return bindInternalExecutorDelegation( - { - serviceId: 'executor', - ...(subjectUserId ? { subjectUserId } : {}), - workflowId: origin.workflowId, - ...(origin.executionId ? { executionId: origin.executionId } : {}), - ...(origin.principal ? { principal: origin.principal } : {}), - ...(origin.currentWorkflow ? { currentWorkflow: origin.currentWorkflow } : {}), - delegationId: generateId(), - issuedAt, - expiresAt: expiresAt ?? new Date(issuedAt.getTime() + EXECUTOR_DELEGATION_TTL_MS), - }, - { - audience, - ...(resourceScope ? { resourceScope } : {}), - ...(!subjectUserId && compatibilityActorUserId ? { compatibilityActorUserId } : {}), - } - ) -} export interface CreateExecutorPrincipalFromExecutionContextInput { context: InternalToolOperationContext - audience: string - resourceScope?: DelegatedPrincipal['resourceScope'] - expiresAt?: Date } +/** Returns the executor's asserted workspace scope or fails at the tool boundary. */ +export function requireExecutorWorkspaceId( + context: Pick +): string { + if (!context.workspaceId?.trim()) throw new Error('Workflow execution workspace is required') + return context.workspaceId +} + +/** Revalidates the runtime principal before an in-process application operation. */ export async function createExecutorPrincipalFromExecutionContext({ context, - audience, - resourceScope, - expiresAt, -}: CreateExecutorPrincipalFromExecutionContextInput) { - const origin = context.executorDelegationOrigin - if (!origin) throw new ExecutorDelegationOriginRequiredError() - return createExecutorPrincipalFromDelegationOrigin( - origin, - audience, - resourceScope, - expiresAt, - context.userId +}: CreateExecutorPrincipalFromExecutionContextInput): Promise { + if (!context.principal) throw new WorkflowExecutionPrincipalRequiredError() + requirePrincipalExecutionMetadata(context.principal) + const subject = resolvePrincipalSubject(context.principal) + return bindRuntimeWorkflowExecutionPrincipal( + context.principal as BoundWorkflowExecutionPrincipal, + !subject && context.userId ? { compatibilityActorUserId: context.userId } : undefined ) } diff --git a/apps/sim/lib/internal/table/execute-tool.test.ts b/apps/sim/lib/internal/table/execute-tool.test.ts index db9855e0612..d07b0a8902a 100644 --- a/apps/sim/lib/internal/table/execute-tool.test.ts +++ b/apps/sim/lib/internal/table/execute-tool.test.ts @@ -9,6 +9,7 @@ import type { ExecutionContext } from '@/executor/types' const mocks = vi.hoisted(() => ({ createPrincipal: vi.fn(), + requireWorkspaceId: vi.fn(() => 'workspace-canonical'), create: vi.fn(), list: vi.fn(), getSchema: vi.fn(), @@ -25,6 +26,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/internal/principals/executor', () => ({ createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, + requireExecutorWorkspaceId: mocks.requireWorkspaceId, })) vi.mock('@/lib/internal/table/operations', () => ({ @@ -276,8 +278,6 @@ describe('executeTableTool', () => { expect(mocks[testCase.operation]).toHaveBeenCalledOnce() expect(mocks.createPrincipal).toHaveBeenCalledWith({ context: CONTEXT, - audience: 'sim:tables', - ...(testCase.tableId ? { resourceScope: { tableId: testCase.tableId } } : {}), }) }) diff --git a/apps/sim/lib/internal/table/execute-tool.ts b/apps/sim/lib/internal/table/execute-tool.ts index d0776a23fde..a2d7b00e55d 100644 --- a/apps/sim/lib/internal/table/execute-tool.ts +++ b/apps/sim/lib/internal/table/execute-tool.ts @@ -16,7 +16,10 @@ import { upsertTableRowContract, } from '@/lib/api/contracts/tables' import { type InternalErrorPolicy, internalOrchestrationErrorPolicy } from '@/lib/api/server/routes' -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { + createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId, +} from '@/lib/internal/principals/executor' import { executeTableCreate, executeTableDeleteRow, @@ -46,7 +49,6 @@ import { internalTableRowsErrorPolicy, internalTableV2QueryErrorPolicy, } from '@/lib/table/api/row-route-policies' -import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' const logger = createLogger('TableInternalOperation') @@ -274,13 +276,12 @@ export const executeTableTool: InternalToolOperationHandler = async (request) => try { const principal = await createExecutorPrincipalFromExecutionContext({ context: request.context, - audience: TABLE_DELEGATION_AUDIENCE, - ...(tableId ? { resourceScope: { tableId } } : {}), }) request.signal?.throwIfAborted() const result = await dispatchTableTool(request, { principal, + workspaceId: requireExecutorWorkspaceId(request.context), headers: request.headers, requestId: request.requestId, signal: request.signal, diff --git a/apps/sim/lib/internal/table/operations.test.ts b/apps/sim/lib/internal/table/operations.test.ts index fa8ead47374..b00d167a44a 100644 --- a/apps/sim/lib/internal/table/operations.test.ts +++ b/apps/sim/lib/internal/table/operations.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { createTableDefinition } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ createRows: vi.fn(), @@ -36,18 +36,7 @@ import { type TableToolOperationContext, } from '@/lib/internal/table/operations' -const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2026-08-27T00:05:00.000Z'), - resourceScope: { tableId: 'table-1' }, - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, -} +const PRINCIPAL = createTestRuntimePrincipal() const TABLE = createTableDefinition({ id: 'table-1', @@ -67,6 +56,7 @@ const ROW = { function operationContext(): TableToolOperationContext { return { principal: PRINCIPAL, + workspaceId: 'workspace-canonical', headers: new Headers(), requestId: 'request-1', } diff --git a/apps/sim/lib/internal/table/operations.ts b/apps/sim/lib/internal/table/operations.ts index e55bab8afa5..3bc209bc96d 100644 --- a/apps/sim/lib/internal/table/operations.ts +++ b/apps/sim/lib/internal/table/operations.ts @@ -1,4 +1,4 @@ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { BoundWorkflowExecutionPrincipal } from '@sim/auth/principal' import type { ContractBody, ContractQuery } from '@/lib/api/contracts' import type { createTableContract, @@ -45,7 +45,8 @@ import { isTablePredicate } from '@/lib/table/query-builder/converters' import { normalizeColumn } from '@/lib/table/wire' export interface TableToolOperationContext { - principal: WorkflowExecutionDelegatedPrincipal + principal: BoundWorkflowExecutionPrincipal + workspaceId: string headers: Headers requestId: string signal?: AbortSignal @@ -68,7 +69,7 @@ export async function executeTableCreate( const result = await createTableUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, name: body.name, description: body.description, schema: { @@ -95,7 +96,7 @@ export async function executeTableList( const result = await listTableDefinitionsUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: context.workspaceId, }, }) const tables = result.tables.map(presentTableListItem) @@ -110,7 +111,7 @@ export async function executeTableGetSchema( ): Promise { const result = await readTableDetailsUseCase.execute({ principal: context.principal, - input: { tableId, workspaceId: context.principal.workspaceId }, + input: { tableId, workspaceId: context.workspaceId }, }) return complete(context, { body: { @@ -132,7 +133,7 @@ export async function executeTableGetRow( input: { tableId, rowId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, includePersistedSecretProvenance, requestId: context.requestId, }, @@ -160,7 +161,7 @@ export async function executeTableInsertRows( ? { kind: 'batch', tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, rows: body.rows as RowData[], orderKeys: body.orderKeys, strictWrite: false, @@ -172,7 +173,7 @@ export async function executeTableInsertRows( : { kind: 'single', tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, data: body.data as RowData, position: body.position, afterRowId: body.afterRowId, @@ -223,7 +224,7 @@ export async function executeTableQueryRows( principal: context.principal, input: { tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, ...(filter && isTablePredicate(filter) ? { predicate: filter } : { legacyFilter: filter as Filter | undefined }), @@ -266,7 +267,7 @@ export async function executeTableQueryRowsV2( principal: context.principal, input: { tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, predicate: body.predicate, sort: body.sort, columns: body.columns, @@ -307,7 +308,7 @@ export async function executeTableUpdateRow( input: { tableId, rowId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, data: body.data as RowData, dataKeying: 'names', strictWrite: false, @@ -337,7 +338,7 @@ export async function executeTableUpdateRowsByFilter( principal: context.principal, input: { tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, filter: body.filter, filterKeying: 'names', data: body.data as RowData, @@ -372,7 +373,7 @@ export async function executeTableDeleteRow( input: { tableId, rowId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, requestId: context.requestId, }, }) @@ -395,14 +396,14 @@ export async function executeTableDeleteRows( ? { kind: 'ids', tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, rowIds: body.rowIds, requestId: context.requestId, } : { kind: 'filter', tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, filter: body.filter!, filterKeying: 'names', limit: body.limit, @@ -453,7 +454,7 @@ export async function executeTableUpsertRow( principal: context.principal, input: { tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: context.workspaceId, data: body.data as RowData, dataKeying: 'names', strictWrite: false, diff --git a/apps/sim/lib/internal/table/read-schema.test.ts b/apps/sim/lib/internal/table/read-schema.test.ts index bcd14a1e24a..4ea496bdba5 100644 --- a/apps/sim/lib/internal/table/read-schema.test.ts +++ b/apps/sim/lib/internal/table/read-schema.test.ts @@ -2,16 +2,18 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ createPrincipal: vi.fn(), + requireWorkspaceId: vi.fn(() => 'workspace-canonical'), readTable: vi.fn(), })) vi.mock('@/lib/internal/principals/executor', () => ({ createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, + requireExecutorWorkspaceId: mocks.requireWorkspaceId, })) vi.mock('@/lib/table/application/tables', () => ({ @@ -20,18 +22,7 @@ vi.mock('@/lib/table/application/tables', () => ({ import { readTableSchemaAsExecutor } from '@/lib/internal/table/read-schema' -const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-canonical', - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2026-08-27T00:05:00.000Z'), - resourceScope: { tableId: 'table-1' }, - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, -} +const PRINCIPAL = createTestRuntimePrincipal() describe('readTableSchemaAsExecutor', () => { beforeEach(() => { @@ -55,11 +46,7 @@ describe('readTableSchemaAsExecutor', () => { tableId: 'table-1', context: { workflowId: 'workflow-1', - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - }, + principal: PRINCIPAL, }, }) @@ -99,11 +86,7 @@ describe('readTableSchemaAsExecutor', () => { tableId: 'table-1', context: { workflowId: 'workflow-1', - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - }, + principal: PRINCIPAL, }, }) @@ -124,10 +107,7 @@ describe('readTableSchemaAsExecutor', () => { tableId: 'table-1', context: { workflowId: 'workflow-1', - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'workflow-1', - }, + principal: PRINCIPAL, }, }) ).rejects.toThrow('Invalid table column 0 while enriching schema for table-1') diff --git a/apps/sim/lib/internal/table/read-schema.ts b/apps/sim/lib/internal/table/read-schema.ts index 259b72db9d9..e56b976d436 100644 --- a/apps/sim/lib/internal/table/read-schema.ts +++ b/apps/sim/lib/internal/table/read-schema.ts @@ -1,6 +1,8 @@ -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { + createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId, +} from '@/lib/internal/principals/executor' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' -import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' import { readTableDefinitionUseCase } from '@/lib/table/application/tables' import { isColumnType } from '@/lib/table/column-types' import type { TableSummary } from '@/lib/table/types' @@ -16,12 +18,10 @@ export async function readTableSchemaAsExecutor({ }: ReadTableSchemaAsExecutorInput): Promise { const principal = await createExecutorPrincipalFromExecutionContext({ context, - audience: TABLE_DELEGATION_AUDIENCE, - resourceScope: { tableId }, }) const { table } = await readTableDefinitionUseCase.execute({ principal, - input: { tableId, workspaceId: principal.workspaceId }, + input: { tableId, workspaceId: requireExecutorWorkspaceId(context) }, }) if (!table || typeof table.name !== 'string' || !Array.isArray(table.schema?.columns)) { diff --git a/apps/sim/lib/internal/tool-operations/identity-faults.ts b/apps/sim/lib/internal/tool-operations/identity-faults.ts index 80a03624a7b..49ee3d9ed44 100644 --- a/apps/sim/lib/internal/tool-operations/identity-faults.ts +++ b/apps/sim/lib/internal/tool-operations/identity-faults.ts @@ -30,10 +30,10 @@ export type InternalToolIdentityFault = 'unauthenticated' | 'subject_user_requir * import the executor-principal module: nearly every handler test mocks that * module, and an `instanceof` against a mock that omits the export throws. */ -export class ExecutorDelegationOriginRequiredError extends Error { +export class WorkflowExecutionPrincipalRequiredError extends Error { constructor() { - super('Executor delegation origin is required') - this.name = 'ExecutorDelegationOriginRequiredError' + super('Workflow execution principal is required') + this.name = 'WorkflowExecutionPrincipalRequiredError' } } @@ -53,7 +53,7 @@ export function classifyInternalToolIdentityFault( if (error instanceof PrincipalSubjectUserRequiredError) return 'subject_user_required' if ( error instanceof InvalidInternalDelegationBindingError || - error instanceof ExecutorDelegationOriginRequiredError || + error instanceof WorkflowExecutionPrincipalRequiredError || (error instanceof Error && error.message === 'Authentication required') ) { return 'unauthenticated' diff --git a/apps/sim/lib/internal/tool-operations/types.ts b/apps/sim/lib/internal/tool-operations/types.ts index bac7f82926b..8798162831a 100644 --- a/apps/sim/lib/internal/tool-operations/types.ts +++ b/apps/sim/lib/internal/tool-operations/types.ts @@ -1,5 +1,5 @@ +import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import type { ExecutorDelegationOrigin } from '@/executor/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { ToolResponse } from '@/tools/types' @@ -16,7 +16,7 @@ export interface InternalToolOperationContext { workspaceId?: string executionId?: string userId?: string - executorDelegationOrigin?: ExecutorDelegationOrigin + principal?: WorkflowExecutionPrincipal copilotToolExecution?: boolean copilotInteractionMode?: 'interactive' | 'headless' chatId?: string diff --git a/apps/sim/lib/internal/windchill/execute-tool.test.ts b/apps/sim/lib/internal/windchill/execute-tool.test.ts index 2f1e3780de6..cff05ddb581 100644 --- a/apps/sim/lib/internal/windchill/execute-tool.test.ts +++ b/apps/sim/lib/internal/windchill/execute-tool.test.ts @@ -6,11 +6,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ createExecutorPrincipalFromExecutionContext: vi.fn(), + requireExecutorWorkspaceId: vi.fn(() => 'workspace-1'), executeWindchillOperation: vi.fn(), })) vi.mock('@/lib/internal/principals/executor', () => ({ createExecutorPrincipalFromExecutionContext: mocks.createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId: mocks.requireExecutorWorkspaceId, })) vi.mock('@/lib/internal/windchill/operations', () => ({ @@ -127,12 +129,12 @@ describe('executeWindchillTool', () => { executionId: 'execution-1', userId: 'user-1', }), - audience: 'sim:windchill', }) expect(mocks.executeWindchillOperation).toHaveBeenCalledWith(operationInput, { principal: PRINCIPAL, requestId: 'request-1', signal: controller.signal, + workspaceId: 'workspace-1', }) }) diff --git a/apps/sim/lib/internal/windchill/execute-tool.ts b/apps/sim/lib/internal/windchill/execute-tool.ts index b49cf727672..87a5733a2ef 100644 --- a/apps/sim/lib/internal/windchill/execute-tool.ts +++ b/apps/sim/lib/internal/windchill/execute-tool.ts @@ -8,7 +8,10 @@ import { windchillOperationBodySchema } from '@/lib/api/contracts/tools/windchil import { getValidationErrorMessage } from '@/lib/api/server' import { DEFAULT_MAX_JSON_BODY_BYTES } from '@/lib/api/server/validation' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { + createExecutorPrincipalFromExecutionContext, + requireExecutorWorkspaceId, +} from '@/lib/internal/principals/executor' import { classifyInternalToolIdentityFault, internalToolIdentityFaultMessage, @@ -21,8 +24,6 @@ import { executeWindchillOperation } from '@/lib/internal/windchill/operations' import { sanitizeWindchillError } from '@/tools/windchill/utils' const logger = createLogger('WindchillInternalOperation') -const WINDCHILL_DELEGATION_AUDIENCE = 'sim:windchill' - const WINDCHILL_INTERNAL_TOOL_IDS = new Set([ 'windchill_create_document', 'windchill_create_documents', @@ -80,7 +81,6 @@ export const executeWindchillTool: InternalToolOperationHandler = async (request try { const principal = await createExecutorPrincipalFromExecutionContext({ context, - audience: WINDCHILL_DELEGATION_AUDIENCE, }) signal?.throwIfAborted() const input = parseInput(request.input) @@ -91,6 +91,7 @@ export const executeWindchillTool: InternalToolOperationHandler = async (request const output = await executeWindchillOperation(input, { principal, + workspaceId: requireExecutorWorkspaceId(context), requestId, signal, }) diff --git a/apps/sim/lib/internal/windchill/operations.test.ts b/apps/sim/lib/internal/windchill/operations.test.ts index badf46d0ba1..fbafde58b47 100644 --- a/apps/sim/lib/internal/windchill/operations.test.ts +++ b/apps/sim/lib/internal/windchill/operations.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WindchillOperationBody } from '@/lib/api/contracts/tools/windchill' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' const mocks = vi.hoisted(() => ({ @@ -58,21 +59,21 @@ const BASE = { } const DOCUMENT_OID = 'OR:wt.doc.WTDocument:1' const SECOND_DOCUMENT_OID = 'OR:wt.doc.WTDocument:2' -const PRINCIPAL = { - kind: 'delegated' as const, - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:windchill', - issuedAt: new Date('2026-01-01T00:00:00.000Z'), - expiresAt: new Date('2026-01-01T01:00:00.000Z'), - delegationContext: { - kind: 'workflow_execution' as const, +const PRINCIPAL = createTestRuntimePrincipal() +const ACTORLESS_PRINCIPAL = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', workflowId: 'workflow-1', - executionId: 'execution-1', }, -} + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + compatibilityActorUserId: 'execution-actor', +}) const MUTATION_CASES = [ { @@ -302,7 +303,12 @@ describe('Windchill operations', () => { documentOid: DOCUMENT_OID, primaryFile: rawFile, }, - { principal: PRINCIPAL, requestId: 'request-1', signal: controller.signal } + { + principal: PRINCIPAL, + workspaceId: 'workspace-1', + requestId: 'request-1', + signal: controller.signal, + } ) expect(mocks.assertToolFileAccess).toHaveBeenCalledWith( @@ -352,22 +358,7 @@ describe('Windchill operations', () => { primaryFile: rawFile, }, { - principal: { - ...PRINCIPAL, - subjectUserId: undefined, - delegationContext: { - ...PRINCIPAL.delegationContext, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, - compatibilityActor: { - kind: 'legacy_execution_user', - userId: 'execution-actor', - }, - }, - }, + principal: ACTORLESS_PRINCIPAL, requestId: 'request-1', } ) @@ -430,7 +421,12 @@ describe('Windchill operations', () => { operation: 'windchill_download_primary_content', documentOid: DOCUMENT_OID, }, - { principal: PRINCIPAL, requestId: 'request-1', signal: controller.signal } + { + principal: PRINCIPAL, + workspaceId: 'workspace-1', + requestId: 'request-1', + signal: controller.signal, + } ) expect(mocks.resolveWindchillContentUrl).toHaveBeenCalledWith( @@ -471,22 +467,7 @@ describe('Windchill operations', () => { documentOid: DOCUMENT_OID, }, { - principal: { - ...PRINCIPAL, - subjectUserId: undefined, - delegationContext: { - ...PRINCIPAL.delegationContext, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, - compatibilityActor: { - kind: 'legacy_execution_user', - userId: 'execution-actor', - }, - }, - }, + principal: ACTORLESS_PRINCIPAL, requestId: 'request-1', } ) diff --git a/apps/sim/lib/internal/windchill/operations.ts b/apps/sim/lib/internal/windchill/operations.ts index c9967e51bbb..f0f0199b906 100644 --- a/apps/sim/lib/internal/windchill/operations.ts +++ b/apps/sim/lib/internal/windchill/operations.ts @@ -1,5 +1,5 @@ import { - type BoundWorkflowExecutionDelegatedPrincipal, + type BoundWorkflowExecutionPrincipal, resolvePrincipalExecutionActorUserId, } from '@sim/auth/principal' import { createLogger } from '@sim/logger' @@ -440,9 +440,7 @@ async function loadUploadFiles( return files } -function requireWindchillExecutionUserId( - principal: BoundWorkflowExecutionDelegatedPrincipal -): string { +function requireWindchillExecutionUserId(principal: BoundWorkflowExecutionPrincipal): string { const userId = resolvePrincipalExecutionActorUserId(principal) if (!userId) { throw new WindchillOperationError('Windchill file operations require an execution actor', 403) @@ -469,24 +467,26 @@ function contentDispositionFileName(value: string | null): string | null { async function storeDownloadedFile({ principal, + workspaceId, buffer, fileName, contentType, signal, }: { - principal: BoundWorkflowExecutionDelegatedPrincipal + principal: BoundWorkflowExecutionPrincipal + workspaceId: string buffer: Buffer fileName: string contentType: string signal?: AbortSignal }): Promise { signal?.throwIfAborted() - const { workflowId, executionId } = principal.delegationContext + const { rootWorkflowId: workflowId, executionId } = principal.executionMetadata const userId = requireWindchillExecutionUserId(principal) if (executionId) { const file = await uploadExecutionFile( { - workspaceId: principal.workspaceId, + workspaceId, workflowId, executionId, }, @@ -514,7 +514,8 @@ async function executeDownload( | { operation: 'windchill_download_primary_content' } | { operation: 'windchill_download_attachment' } >, - principal: BoundWorkflowExecutionDelegatedPrincipal, + principal: BoundWorkflowExecutionPrincipal, + workspaceId: string, signal?: AbortSignal ): Promise { const documentUrl = windchillDocumentUrl(body.baseUrl, body.documentOid) @@ -543,6 +544,7 @@ async function executeDownload( const mimeType = safeMimeType(downloaded.contentType) const file = await storeDownloadedFile({ principal, + workspaceId, buffer: downloaded.buffer, fileName, contentType: mimeType, @@ -557,7 +559,8 @@ async function executeDownload( } export interface WindchillOperationContext { - principal: BoundWorkflowExecutionDelegatedPrincipal + principal: BoundWorkflowExecutionPrincipal + workspaceId: string requestId: string signal?: AbortSignal } @@ -566,14 +569,14 @@ export async function executeWindchillOperation( body: WindchillOperationBody, context: WindchillOperationContext ): Promise { - const { principal, requestId, signal } = context + const { principal, requestId, signal, workspaceId } = context signal?.throwIfAborted() if ( body.operation === 'windchill_download_primary_content' || body.operation === 'windchill_download_attachment' ) { - return executeDownload(body, principal, signal) + return executeDownload(body, principal, workspaceId, signal) } if ( diff --git a/apps/sim/lib/internal/workflows/read-definition.test.ts b/apps/sim/lib/internal/workflows/read-definition.test.ts index fe725bac4e1..2101d8714ab 100644 --- a/apps/sim/lib/internal/workflows/read-definition.test.ts +++ b/apps/sim/lib/internal/workflows/read-definition.test.ts @@ -2,14 +2,15 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' -const { mockBindInternalExecutorDelegation, mockReadWorkflowDefinition } = vi.hoisted(() => ({ - mockBindInternalExecutorDelegation: vi.fn(), +const { mockBindRuntimeExecution, mockReadWorkflowDefinition } = vi.hoisted(() => ({ + mockBindRuntimeExecution: vi.fn(), mockReadWorkflowDefinition: vi.fn(), })) vi.mock('@/lib/auth/internal-delegation', () => ({ - bindInternalExecutorDelegation: mockBindInternalExecutorDelegation, + bindRuntimeWorkflowExecution: mockBindRuntimeExecution, })) vi.mock('@/lib/workflows/application/read-workflow-definition', () => ({ @@ -17,133 +18,83 @@ vi.mock('@/lib/workflows/application/read-workflow-definition', () => ({ })) import { readWorkflowDefinitionAsExecutor } from '@/lib/internal/workflows/read-definition' -import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' describe('readWorkflowDefinitionAsExecutor', () => { beforeEach(() => { vi.clearAllMocks() }) - it('binds the trusted workflow execution origin before reading the child', async () => { - const principal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: WORKFLOW_DELEGATION_AUDIENCE, - issuedAt: new Date(), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution', - workflowId: 'parent-workflow', - executionId: 'execution-1', - }, - } + it('revalidates the trusted runtime principal before reading the child', async () => { + const principal = createTestRuntimePrincipal({ + executionId: 'execution-1', + rootWorkflowId: 'parent-workflow', + }) const definition = { workflow: { id: 'child-workflow' }, state: { blocks: {} } } - mockBindInternalExecutorDelegation.mockResolvedValue(principal) + mockBindRuntimeExecution.mockResolvedValue({ principal, workspaceId: 'workspace-1' }) mockReadWorkflowDefinition.mockResolvedValue(definition) const result = await readWorkflowDefinitionAsExecutor({ - origin: { - subjectUserId: 'user-1', - workflowId: 'parent-workflow', - executionId: 'execution-1', - }, + principal, workflowId: 'child-workflow', state: 'deployed', }) expect(result).toBe(definition) - expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( - expect.objectContaining({ - serviceId: 'executor', - subjectUserId: 'user-1', - workflowId: 'parent-workflow', - executionId: 'execution-1', - delegationId: expect.any(String), - issuedAt: expect.any(Date), - expiresAt: expect.any(Date), - }), - { audience: WORKFLOW_DELEGATION_AUDIENCE } - ) + expect(mockBindRuntimeExecution).toHaveBeenCalledWith(principal) expect(mockReadWorkflowDefinition).toHaveBeenCalledWith({ principal, - input: { workflowId: 'child-workflow', state: 'deployed' }, + input: { + workflowId: 'child-workflow', + state: 'deployed', + assertedWorkspaceId: 'workspace-1', + }, }) }) it('preserves an actorless principal and current workflow authority', async () => { - const sourcePrincipal = { - kind: 'system' as const, - serviceId: 'internal' as const, - workspaceId: 'workspace-1', - workflowId: 'parent-workflow', - } - const delegatedPrincipal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: WORKFLOW_DELEGATION_AUDIENCE, - issuedAt: new Date(), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution' as const, + const principal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'internal', + workspaceId: 'workspace-1', workflowId: 'parent-workflow', - executionId: 'execution-1', - principal: sourcePrincipal, - currentWorkflow: { - workflowId: 'parent-workflow', - mode: 'draft' as const, - }, }, - } - mockBindInternalExecutorDelegation.mockResolvedValue(delegatedPrincipal) + executionId: 'execution-1', + rootWorkflowId: 'parent-workflow', + }) + mockBindRuntimeExecution.mockResolvedValue({ principal, workspaceId: 'workspace-1' }) mockReadWorkflowDefinition.mockResolvedValue({ workflow: {}, state: null }) await readWorkflowDefinitionAsExecutor({ - origin: { - workflowId: 'parent-workflow', - executionId: 'execution-1', - principal: sourcePrincipal, - currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, - }, + principal, workflowId: 'child-workflow', state: 'draft', }) - expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( - expect.objectContaining({ - serviceId: 'executor', - workflowId: 'parent-workflow', - executionId: 'execution-1', - principal: sourcePrincipal, - currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, - }), - { audience: WORKFLOW_DELEGATION_AUDIENCE } - ) - expect(mockBindInternalExecutorDelegation.mock.calls[0][0]).not.toHaveProperty('subjectUserId') + expect(mockBindRuntimeExecution).toHaveBeenCalledWith(principal) expect(mockReadWorkflowDefinition).toHaveBeenCalledWith({ - principal: delegatedPrincipal, - input: { workflowId: 'child-workflow', state: 'draft' }, + principal, + input: { + workflowId: 'child-workflow', + state: 'draft', + assertedWorkspaceId: 'workspace-1', + }, }) }) - it('rejects a subject that conflicts with the preserved workflow principal', async () => { + it('propagates canonical principal binding failures', async () => { + const principal = createTestRuntimePrincipal({ rootWorkflowId: 'parent-workflow' }) + mockBindRuntimeExecution.mockRejectedValue(new Error('Execution principal is noncanonical')) + await expect( readWorkflowDefinitionAsExecutor({ - origin: { - subjectUserId: 'user-2', - workflowId: 'parent-workflow', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - }, + principal, workflowId: 'child-workflow', state: 'draft', }) - ).rejects.toThrow('Executor subject does not match its workflow principal') + ).rejects.toThrow('Execution principal is noncanonical') - expect(mockBindInternalExecutorDelegation).not.toHaveBeenCalled() + expect(mockBindRuntimeExecution).toHaveBeenCalledWith(principal) expect(mockReadWorkflowDefinition).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/internal/workflows/read-definition.ts b/apps/sim/lib/internal/workflows/read-definition.ts index 96d0bcb4cac..dbd21ef27a6 100644 --- a/apps/sim/lib/internal/workflows/read-definition.ts +++ b/apps/sim/lib/internal/workflows/read-definition.ts @@ -1,84 +1,24 @@ -import { - resolvePrincipalSubject, - type WorkflowExecutionAuthority, - type WorkflowExecutionPrincipal, -} from '@sim/auth/principal' -import { generateId } from '@sim/utils/id' -import { bindInternalExecutorDelegation } from '@/lib/auth/internal-delegation' -import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' +import type { BoundWorkflowExecutionPrincipal } from '@sim/auth/principal' +import { bindRuntimeWorkflowExecution } from '@/lib/auth/internal-delegation' import { type ReadWorkflowDefinitionInput, readWorkflowDefinition, } from '@/lib/workflows/application/read-workflow-definition' -export interface ExecutorWorkflowDefinitionOrigin { - subjectUserId?: string - workflowId: string - executionId?: string - principal?: WorkflowExecutionPrincipal - currentWorkflow?: WorkflowExecutionAuthority -} - export interface ReadWorkflowDefinitionAsExecutorInput { - origin: ExecutorWorkflowDefinitionOrigin + principal: BoundWorkflowExecutionPrincipal workflowId: string state: ReadWorkflowDefinitionInput['state'] } -const EXECUTOR_DELEGATION_TTL_MS = 5 * 60 * 1000 - -function resolveExecutorSubject(origin: ExecutorWorkflowDefinitionOrigin): string | undefined { - const principalSubject = origin.principal ? resolvePrincipalSubject(origin.principal) : null - if (principalSubject?.kind === 'external_user' && origin.subjectUserId) { - throw new Error('External workflow subjects cannot be represented as Sim users') - } - if (!principalSubject && origin.principal && origin.subjectUserId) { - throw new Error('Actorless workflow principals cannot be represented as Sim users') - } - if ( - principalSubject?.kind === 'sim_user' && - origin.subjectUserId && - origin.subjectUserId !== principalSubject.userId - ) { - throw new Error('Executor subject does not match its workflow principal') - } - - const subjectUserId = - principalSubject?.kind === 'sim_user' ? principalSubject.userId : origin.subjectUserId - if (!subjectUserId && !origin.principal) { - throw new Error('Executor workflow definition read requires a workflow principal or subject') - } - return subjectUserId -} - -async function createWorkflowDefinitionExecutorPrincipal(origin: ExecutorWorkflowDefinitionOrigin) { - const issuedAt = new Date() - const subjectUserId = resolveExecutorSubject(origin) - return bindInternalExecutorDelegation( - { - serviceId: 'executor', - ...(subjectUserId ? { subjectUserId } : {}), - workflowId: origin.workflowId, - ...(origin.executionId ? { executionId: origin.executionId } : {}), - ...(origin.principal ? { principal: origin.principal } : {}), - ...(origin.currentWorkflow ? { currentWorkflow: origin.currentWorkflow } : {}), - delegationId: generateId(), - issuedAt, - expiresAt: new Date(issuedAt.getTime() + EXECUTOR_DELEGATION_TTL_MS), - }, - { audience: WORKFLOW_DELEGATION_AUDIENCE } - ) -} - export async function readWorkflowDefinitionAsExecutor({ - origin, + principal, workflowId, state, }: ReadWorkflowDefinitionAsExecutorInput) { - const principal = await createWorkflowDefinitionExecutorPrincipal(origin) - + const runtime = await bindRuntimeWorkflowExecution(principal) return readWorkflowDefinition.execute({ - principal, - input: { workflowId, state }, + principal: runtime.principal, + input: { workflowId, state, assertedWorkspaceId: runtime.workspaceId }, }) } diff --git a/apps/sim/lib/internal/workflows/read-tool-enrichment.test.ts b/apps/sim/lib/internal/workflows/read-tool-enrichment.test.ts index 868714d5cc6..4f9e3ecc7bb 100644 --- a/apps/sim/lib/internal/workflows/read-tool-enrichment.test.ts +++ b/apps/sim/lib/internal/workflows/read-tool-enrichment.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const { mockReadWorkflowDefinitionAsExecutor } = vi.hoisted(() => ({ mockReadWorkflowDefinitionAsExecutor: vi.fn(), @@ -26,23 +27,23 @@ describe('workflow tool enrichment authority', () => { workflow: { name: 'Child workflow', description: 'Runs the child' }, state: { blocks: {} }, }) + const principal = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'actual-user', sessionId: 'session-1' }, + executionId: 'execution-1', + rootWorkflowId: 'parent-workflow', + }) await expect( readWorkflowMetadataForTool('child-workflow', { userId: 'billing-owner', workflowId: 'parent-workflow', executionId: 'execution-1', - executorDelegationOrigin: { - workflowId: 'parent-workflow', - executionId: 'execution-1', - principal: { kind: 'session', userId: 'actual-user', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, - }, + principal, }) ).resolves.toEqual({ name: 'Child workflow', description: 'Runs the child' }) expect(mockReadWorkflowDefinitionAsExecutor).toHaveBeenCalledWith({ - origin: { subjectUserId: 'actual-user', workflowId: 'child-workflow' }, + principal, workflowId: 'child-workflow', state: 'draft', }) @@ -53,39 +54,34 @@ describe('workflow tool enrichment authority', () => { workflow: { name: 'Child workflow', description: null }, state: { blocks: {} }, }) - const principal = { - kind: 'system' as const, - serviceId: 'schedule' as const, - workspaceId: 'workspace-1', - workflowId: 'parent-workflow', - } const currentWorkflow = { workflowId: 'parent-workflow', mode: 'deployment' as const, deploymentVersionId: 'deployment-1', } + const principal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'parent-workflow', + }, + executionId: 'execution-1', + rootWorkflowId: 'parent-workflow', + currentWorkflow, + }) await expect( readWorkflowInputFieldsForTool('child-workflow', { userId: 'billing-owner', workflowId: 'parent-workflow', executionId: 'execution-1', - executorDelegationOrigin: { - workflowId: 'parent-workflow', - executionId: 'execution-1', - principal, - currentWorkflow, - }, + principal, }) ).resolves.toEqual([]) expect(mockReadWorkflowDefinitionAsExecutor).toHaveBeenCalledWith({ - origin: { - workflowId: 'parent-workflow', - executionId: 'execution-1', - principal, - currentWorkflow, - }, + principal, workflowId: 'child-workflow', state: 'deployed', }) @@ -96,16 +92,15 @@ describe('workflow tool enrichment authority', () => { readWorkflowMetadataForTool('child-workflow', { userId: 'billing-owner', workflowId: 'parent-workflow', - executorDelegationOrigin: { - workflowId: 'parent-workflow', + principal: createTestRuntimePrincipal({ principal: { kind: 'system', serviceId: 'internal', workspaceId: 'workspace-1', workflowId: 'parent-workflow', }, - currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, - }, + rootWorkflowId: 'parent-workflow', + }), }) ).rejects.toThrow('Actorless workflow enrichment requires deployed execution authority') diff --git a/apps/sim/lib/internal/workflows/read-tool-enrichment.ts b/apps/sim/lib/internal/workflows/read-tool-enrichment.ts index 9ec04b735ff..fc22ff3c417 100644 --- a/apps/sim/lib/internal/workflows/read-tool-enrichment.ts +++ b/apps/sim/lib/internal/workflows/read-tool-enrichment.ts @@ -1,32 +1,33 @@ -import { resolveExecutorOriginSubject } from '@/lib/internal/principals/executor' +import { + type BoundWorkflowExecutionPrincipal, + requirePrincipalExecutionMetadata, + resolvePrincipalSubject, + type WorkflowExecutionPrincipal, +} from '@sim/auth/principal' import { readWorkflowDefinitionAsExecutor } from '@/lib/internal/workflows/read-definition' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' -import type { ExecutorDelegationOrigin } from '@/executor/types' export interface WorkflowToolEnrichmentContext { userId?: string workflowId?: string executionId?: string - executorDelegationOrigin?: ExecutorDelegationOrigin + principal?: WorkflowExecutionPrincipal } async function readWorkflowForTool(workflowId: string, context: WorkflowToolEnrichmentContext) { - const origin = context.executorDelegationOrigin - if (!origin) { - throw new Error('Workflow enrichment requires trusted execution authority') - } - const subjectUserId = resolveExecutorOriginSubject(origin) - if (subjectUserId) { - return readWorkflowDefinitionAsExecutor({ - origin: { subjectUserId, workflowId }, - workflowId, - state: 'draft', - }) - } - if (origin.currentWorkflow?.mode !== 'deployment') { + const principal = context.principal + if (!principal) throw new Error('Workflow enrichment requires trusted execution authority') + requirePrincipalExecutionMetadata(principal) + const runtimePrincipal = principal as BoundWorkflowExecutionPrincipal + const subject = resolvePrincipalSubject(runtimePrincipal) + if (!subject && runtimePrincipal.executionMetadata.currentWorkflow.mode !== 'deployment') { throw new Error('Actorless workflow enrichment requires deployed execution authority') } - return readWorkflowDefinitionAsExecutor({ origin, workflowId, state: 'deployed' }) + return readWorkflowDefinitionAsExecutor({ + principal: runtimePrincipal, + workflowId, + state: subject?.kind === 'sim_user' ? 'draft' : 'deployed', + }) } export async function readWorkflowMetadataForTool( diff --git a/apps/sim/lib/knowledge/api/internal-route.test.ts b/apps/sim/lib/knowledge/api/internal-route.test.ts index 4d69fea1e32..e2503ad8ccb 100644 --- a/apps/sim/lib/knowledge/api/internal-route.test.ts +++ b/apps/sim/lib/knowledge/api/internal-route.test.ts @@ -1,9 +1,10 @@ /** * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' import { NextRequest } from 'next/server' import { describe, expect, it } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { BILLING_ATTRIBUTION_HEADER, serializeBillingAttributionHeader, @@ -35,34 +36,16 @@ function request(): NextRequest { }) } -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', +function executorPrincipal(originalPrincipal: WorkflowExecutionPrincipal) { + return createTestRuntimePrincipal({ + principal: originalPrincipal, + currentWorkflow: { workflowId: 'workflow-1', - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, - compatibilityActor: { - kind: 'legacy_execution_user', - userId: 'execution-billing-actor-1', - }, - ...(originalPrincipal ? { principal: originalPrincipal } : {}), + mode: 'deployment', + deploymentVersionId: 'deployment-1', }, - } + compatibilityActorUserId: 'execution-billing-actor-1', + }) } describe('internal Knowledge execution attribution', () => { @@ -99,11 +82,11 @@ describe('internal Knowledge execution attribution', () => { const executor = executorPrincipal(principal) await expect( - resolveInternalKnowledgeBillingAttribution(request(), executor, 'workspace-1') + resolveInternalKnowledgeBillingAttribution(request(), executor, 'workspace-1', 'executor_jwt') ).resolves.toEqual(BILLING_ATTRIBUTION) - expect(internalKnowledgeProvenanceUserId(request().headers, executor, 'workspace-1')).toBe( - 'execution-billing-actor-1' - ) + expect( + internalKnowledgeProvenanceUserId(request().headers, executor, 'workspace-1', 'executor_jwt') + ).toBe('execution-billing-actor-1') expect( resolveKnowledgeAttributedUserId(executor, { workspaceId: 'workspace-1', @@ -123,7 +106,12 @@ describe('internal Knowledge execution attribution', () => { }) await expect( - resolveInternalKnowledgeBillingAttribution(request(), principal, 'workspace-2') + resolveInternalKnowledgeBillingAttribution( + request(), + principal, + 'workspace-2', + 'executor_jwt' + ) ).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 21fe9b0510f..9bf3b460dbc 100644 --- a/apps/sim/lib/knowledge/api/internal-route.ts +++ b/apps/sim/lib/knowledge/api/internal-route.ts @@ -15,6 +15,7 @@ import { } from '@/lib/api/contracts/knowledge/connectors' import { type DocumentData, documentDataSchema } from '@/lib/api/contracts/knowledge/documents' import { type TagDefinitionData, tagDefinitionDataSchema } from '@/lib/api/contracts/knowledge/tags' +import type { InternalAuthTransport } from '@/lib/api/server/routes' import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid' import { requireWorkspaceBillingAttributionHeader, @@ -43,32 +44,59 @@ export function internalKnowledgeActorUserId(principal: Principal): string { export function internalKnowledgeProvenanceUserId( headers: Headers, principal: Principal, - workspaceId: string | undefined + workspaceId: string | undefined, + authTransport: InternalAuthTransport | undefined ): string { - if (principal.kind !== 'delegated') return internalKnowledgeActorUserId(principal) - const subject = resolvePrincipalSubject(principal) - if (subject?.kind === 'sim_user') return subject.userId - return requireWorkspaceBillingAttributionHeader(headers, { - workspaceId: workspaceId ?? principal.workspaceId, - }).actorUserId + switch (authTransport) { + case 'session': + return internalKnowledgeActorUserId(principal) + case 'executor_jwt': { + const subject = resolvePrincipalSubject(principal) + if (subject?.kind === 'sim_user') return subject.userId + const canonicalWorkspaceId = + workspaceId ?? ('workspaceId' in principal ? principal.workspaceId : undefined) + if (!canonicalWorkspaceId) { + throw new Error('Executor knowledge operation is missing its canonical workspace') + } + return requireWorkspaceBillingAttributionHeader(headers, { + workspaceId: canonicalWorkspaceId, + }).actorUserId + } + case undefined: + throw new Error('Knowledge operation requires an authenticated transport') + } } -export function internalKnowledgeAuthType(principal: Principal): AuthTypeValue { - return principal.kind === 'delegated' ? AuthType.INTERNAL_JWT : AuthType.SESSION +export function internalKnowledgeAuthType( + authTransport: InternalAuthTransport | undefined +): AuthTypeValue { + switch (authTransport) { + case 'session': + return AuthType.SESSION + case 'executor_jwt': + return AuthType.INTERNAL_JWT + case undefined: + throw new Error('Knowledge operation requires an authenticated transport') + } } export async function resolveInternalKnowledgeBillingAttribution( request: NextRequest, principal: Principal, - workspaceId: string + workspaceId: string, + authTransport: InternalAuthTransport | undefined ) { - if (principal.kind === 'delegated') { - return requireWorkspaceBillingAttributionHeader(request.headers, { workspaceId }) + switch (authTransport) { + case 'executor_jwt': + return requireWorkspaceBillingAttributionHeader(request.headers, { workspaceId }) + case 'session': + return await resolveBillingAttribution({ + actorUserId: internalKnowledgeActorUserId(principal), + workspaceId, + }) + case undefined: + throw new Error('Knowledge operation requires an authenticated transport') } - return await resolveBillingAttribution({ - actorUserId: internalKnowledgeActorUserId(principal), - workspaceId, - }) } function internalKnowledgeAnalyticsUserId(principal: Principal): string | null { diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index 291585938dd..fc8f483c13b 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -10,7 +10,6 @@ import { v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' import { KnowledgeSearchProvenanceUnavailableError } from '@/lib/knowledge/application/search' @@ -51,9 +50,7 @@ const internalKnowledgeSearchErrorPolicy: InternalErrorPolicy = { unhandled: () => internalErrorResponse(500, { error: 'Failed to perform vector search' }), } -export const internalKnowledgeSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ - audience: KNOWLEDGE_DELEGATION_AUDIENCE, -}) +export const internalKnowledgeSessionOrExecutorAuth = createInternalSessionOrExecutorAuth() export const KNOWLEDGE_BASE_NOT_FOUND_MESSAGE = 'Knowledge base not found' diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 803a4223c3b..c67accb6883 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -153,7 +153,8 @@ describe('knowledge operation registry', () => { expect(knowledgeOperations.listFolders.principalKinds).not.toContain('delegated') expect(knowledgeOperations.uploadComplete.principalKinds).not.toContain('delegated') expect(knowledgeOperations.list.delegatedServices).toEqual(['copilot']) - expect(knowledgeOperations.search.delegatedServices).toEqual(['copilot', 'executor']) + expect(knowledgeOperations.search.delegatedServices).toEqual(['copilot']) + expect(knowledgeOperations.search.workflowExecution).toBe('allow') expect(knowledgeOperations.uploadComplete.delegatedServices).toBeUndefined() }) }) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index a4ffeb7567b..d0f7514df3f 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -11,7 +11,8 @@ const COPILOT_PRINCIPAL_POLICY = { const ALL_PRINCIPAL_WITH_EXECUTOR_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const const HTTP_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'workspace_api_key'] as const @@ -25,7 +26,8 @@ const HUMAN_AND_COPILOT_PRINCIPAL_POLICY = { const HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY = { principalKinds: HUMAN_AND_DELEGATED_PRINCIPAL_KINDS, - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const export const knowledgeOperations = { diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index c4d492820ac..c71c4f02c06 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -228,11 +228,7 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ ) } const userId = resolveKnowledgeAttributedUserId(principal, context) - const shouldMeter = !( - input.skipUsageBilling && - principal.kind === 'delegated' && - principal.serviceId === 'executor' - ) + const shouldMeter = !(input.skipUsageBilling && principal.executionMetadata !== undefined) const billingAttribution = hasQuery && context.workspaceId ? input.resolveBillingAttribution diff --git a/apps/sim/lib/logs/api/route-policies.test.ts b/apps/sim/lib/logs/api/route-policies.test.ts index 5c096657cd9..e709e2f237e 100644 --- a/apps/sim/lib/logs/api/route-policies.test.ts +++ b/apps/sim/lib/logs/api/route-policies.test.ts @@ -6,19 +6,24 @@ import { resetEnvMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockBindDelegation, mockGetSession } = vi.hoisted(() => ({ - mockBindDelegation: vi.fn(), - mockGetSession: vi.fn(), -})) +const { InvalidDelegationBindingError, mockBindDelegationAdmission, mockGetSession } = vi.hoisted( + () => ({ + InvalidDelegationBindingError: class InvalidDelegationBindingError extends Error {}, + mockBindDelegationAdmission: vi.fn(), + mockGetSession: vi.fn(), + }) +) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) vi.mock('@/lib/auth/internal-delegation', () => ({ - bindInternalExecutorDelegation: mockBindDelegation, - InvalidInternalDelegationBindingError: class InvalidInternalDelegationBindingError extends Error {}, + bindInternalExecutorDelegationAdmission: mockBindDelegationAdmission, + InvalidInternalDelegationBindingError: InvalidDelegationBindingError, })) vi.unmock('@/lib/auth/internal') +import { InternalUnauthenticatedError } from '@/lib/api/server/routes' import { generateInternalDelegationToken } from '@/lib/auth/internal' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { internalLogsSessionOrExecutorAuth } from '@/lib/logs/api/route-policies' afterAll(resetEnvMock) @@ -27,29 +32,15 @@ describe('internal logs route authentication', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue(null) - mockBindDelegation.mockImplementation(async (delegation, options) => ({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: delegation.subjectUserId, + mockBindDelegationAdmission.mockImplementation(async (delegation) => ({ + principal: delegation.principal, workspaceId: 'canonical-workspace', - delegationId: delegation.delegationId, - audience: options.audience, - issuedAt: delegation.issuedAt, - expiresAt: delegation.expiresAt, - resourceScope: options.resourceScope, - delegationContext: { - kind: 'workflow_execution', - workflowId: delegation.workflowId, - ...(delegation.executionId ? { executionId: delegation.executionId } : {}), - }, })) }) it('preserves the signed execution origin when the route names a log ID', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', + principal: createTestRuntimePrincipal(), }) const principal = await internalLogsSessionOrExecutorAuth.authenticate( @@ -60,17 +51,14 @@ describe('internal logs route authentication', () => { ) expect(principal).toMatchObject({ - kind: 'delegated', - workspaceId: 'canonical-workspace', - resourceScope: { executionId: 'execution-1' }, - delegationContext: { executionId: 'execution-1' }, + kind: 'session', + executionMetadata: { executionId: 'execution-1' }, }) }) it('keeps workflow-scoped executor tokens unscoped to one execution', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', + principal: createTestRuntimePrincipal(), }) const principal = await internalLogsSessionOrExecutorAuth.authenticate( @@ -80,24 +68,13 @@ describe('internal logs route authentication', () => { { id: 'log-1' } ) - expect(principal.resourceScope).toBeUndefined() + expect(principal.executionMetadata.executionId).toBe('execution-1') }) it('rejects an executor delegation without canonical workflow execution context', async () => { - mockBindDelegation.mockResolvedValueOnce({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'canonical-workspace', - delegationId: 'delegation-1', - audience: 'sim:logs', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2026-08-27T00:05:00.000Z'), - }) + mockBindDelegationAdmission.mockRejectedValueOnce(new InvalidDelegationBindingError()) const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', + principal: createTestRuntimePrincipal(), }) await expect( @@ -107,7 +84,7 @@ describe('internal logs route authentication', () => { }), { id: 'log-1' } ) - ).rejects.toThrow('Executor log delegation is missing its canonical workflow execution context') + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) }) it('preserves browser session principals', async () => { diff --git a/apps/sim/lib/logs/api/route-policies.ts b/apps/sim/lib/logs/api/route-policies.ts index 19c685eaa87..2b88c7ad1bf 100644 --- a/apps/sim/lib/logs/api/route-policies.ts +++ b/apps/sim/lib/logs/api/route-policies.ts @@ -3,29 +3,8 @@ import { createV2ResourceConcealmentPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' -import { LOGS_DELEGATION_AUDIENCE } from '@/lib/logs/application/authorization' -const internalLogsSessionOrExecutorAuthBase = createInternalSessionOrExecutorAuth({ - audience: LOGS_DELEGATION_AUDIENCE, -}) - -export const internalLogsSessionOrExecutorAuth = { - async authenticate( - ...args: Parameters - ) { - const principal = await internalLogsSessionOrExecutorAuthBase.authenticate(...args) - if (principal.kind !== 'delegated') return principal - - const { delegationContext } = principal - if (!delegationContext) { - throw new Error('Executor log delegation is missing its canonical workflow execution context') - } - const { executionId } = delegationContext - return executionId - ? { ...principal, resourceScope: { ...principal.resourceScope, executionId } } - : principal - }, -} +export const internalLogsSessionOrExecutorAuth = createInternalSessionOrExecutorAuth() /** * `GET /logs` and `GET /billing/logs` both take a caller-named `workspaceId` and diff --git a/apps/sim/lib/logs/application/authorization.ts b/apps/sim/lib/logs/application/authorization.ts index 81f587bb07e..a94879282ff 100644 --- a/apps/sim/lib/logs/application/authorization.ts +++ b/apps/sim/lib/logs/application/authorization.ts @@ -15,13 +15,10 @@ export interface LogAuthorizationContext extends WorkspaceAuthorizationContext { export const logDelegationPolicy: WorkspaceDelegationPolicy = { audience: LOGS_DELEGATION_AUDIENCE, isWithinScope( - principal: Extract, - context: LogAuthorizationContext + _principal: Extract, + _context: LogAuthorizationContext ) { - if (principal.serviceId !== 'executor') return true - return principal.resourceScope?.executionId === undefined - ? true - : principal.resourceScope.executionId === context.executionId + return true }, } diff --git a/apps/sim/lib/logs/application/operations.test.ts b/apps/sim/lib/logs/application/operations.test.ts index bf7b9bec922..d9f4b43474a 100644 --- a/apps/sim/lib/logs/application/operations.test.ts +++ b/apps/sim/lib/logs/application/operations.test.ts @@ -2,54 +2,18 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' -import { logDelegationPolicy } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' -const EXECUTOR_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:logs', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2026-08-27T00:05:00.000Z'), - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, - resourceScope: { executionId: 'execution-1' }, -} - describe('logs operation registry', () => { - it('admits executor delegation only to the three semantic read operations it needs', () => { - expect(logOperations.list.delegatedServices).toEqual(['copilot', 'executor']) - expect(logOperations.readDetail.delegatedServices).toEqual(['copilot', 'executor']) - expect(logOperations.readExecutionSnapshot.delegatedServices).toEqual(['executor']) - expect(logOperations.readStats.delegatedServices).toBeUndefined() + it('admits workflow execution only to the three semantic read operations it needs', () => { + expect(logOperations.list.workflowExecution).toBe('allow') + expect(logOperations.readDetail.workflowExecution).toBe('allow') + expect(logOperations.readExecutionSnapshot.workflowExecution).toBe('allow') + expect(logOperations.readStats.workflowExecution).toBeUndefined() for (const operation of Object.values(logOperations)) { expect(operation.minimumRole).toBe('read') } }) - - it('binds scoped executor reads to the canonical execution context', () => { - const workspaceContext = { - workspaceId: 'workspace-1', - workspaceOrganizationId: null, - allowPersonalApiKeys: true, - } - - expect( - logDelegationPolicy.isWithinScope(EXECUTOR_PRINCIPAL, { - ...workspaceContext, - executionId: 'execution-1', - }) - ).toBe(true) - expect( - logDelegationPolicy.isWithinScope(EXECUTOR_PRINCIPAL, { - ...workspaceContext, - executionId: 'execution-2', - }) - ).toBe(false) - }) }) diff --git a/apps/sim/lib/logs/application/operations.ts b/apps/sim/lib/logs/application/operations.ts index 2d84652e988..6db5ca06c6d 100644 --- a/apps/sim/lib/logs/application/operations.ts +++ b/apps/sim/lib/logs/application/operations.ts @@ -3,7 +3,8 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const PUBLIC_API_PRINCIPAL_KINDS = ['personal_api_key', 'workspace_api_key'] as const const LOG_READER_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const export const logOperations = { @@ -29,7 +30,7 @@ export const logOperations = { id: 'logs.read_execution_snapshot', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['session', 'delegated'], - delegatedServices: ['executor'], + principalKinds: ['session'], + workflowExecution: 'allow', }), } as const diff --git a/apps/sim/lib/logs/application/read-execution-snapshot.ts b/apps/sim/lib/logs/application/read-execution-snapshot.ts index c44f803852a..1b9913c65c3 100644 --- a/apps/sim/lib/logs/application/read-execution-snapshot.ts +++ b/apps/sim/lib/logs/application/read-execution-snapshot.ts @@ -54,6 +54,7 @@ interface ExecutionSnapshotContext extends ActiveWorkspaceApplicationContext { export interface ReadExecutionSnapshotInput { executionId: string + assertedWorkspaceId?: string signal?: AbortSignal } @@ -82,6 +83,12 @@ async function resolveExecutionSnapshotContext( input.signal?.throwIfAborted() if (workflowRecord) { + if ( + input.assertedWorkspaceId !== undefined && + workflowRecord.workspaceId !== input.assertedWorkspaceId + ) { + throw new OrchestrationError('not_found', 'Workflow execution not found') + } const workspace = await resolveActiveWorkspaceApplicationContext(workflowRecord.workspaceId) input.signal?.throwIfAborted() return { @@ -108,6 +115,12 @@ async function resolveExecutionSnapshotContext( input.signal?.throwIfAborted() if (!jobRecord) throw new OrchestrationError('not_found', 'Workflow execution not found') + if ( + input.assertedWorkspaceId !== undefined && + jobRecord.workspaceId !== input.assertedWorkspaceId + ) { + throw new OrchestrationError('not_found', 'Workflow execution not found') + } const workspace = await resolveActiveWorkspaceApplicationContext(jobRecord.workspaceId) input.signal?.throwIfAborted() return { ...workspace, executionId, record: { kind: 'job', ...jobRecord } } diff --git a/apps/sim/lib/logs/application/read-log-detail.test.ts b/apps/sim/lib/logs/application/read-log-detail.test.ts index ee291213706..82d2edff217 100644 --- a/apps/sim/lib/logs/application/read-log-detail.test.ts +++ b/apps/sim/lib/logs/application/read-log-detail.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ -import type { Principal } from '@sim/auth/principal' import { workflowExecutionLogs } from '@sim/db/schema' import { queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -27,56 +26,34 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { readLogDetailUseCase } from '@/lib/logs/application/read-log-detail' const WORKSPACE_ID = 'workspace-1' const EXECUTION_ID = 'execution-1' -/** - * What a scheduled run actually holds: a delegation whose workflow principal is the - * actorless `system:schedule`, so `subjectUserId` is absent. Its workspace reach comes - * from running a deployment, which is the branch `workspace-authorization.ts` admits - * without a subject — so this exercises the real authorization path, not a stub. - */ -const SCHEDULED_PRINCIPAL: Principal = { - kind: 'delegated', - serviceId: 'executor', - workspaceId: WORKSPACE_ID, - delegationId: 'delegation-1', - audience: 'sim:logs', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 5 * 60 * 1000), - delegationContext: { - kind: 'workflow_execution', +const SCHEDULED_PRINCIPAL = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: WORKSPACE_ID, workflowId: 'workflow-1', - principal: { - kind: 'system', - serviceId: 'schedule', - workspaceId: WORKSPACE_ID, - workflowId: 'workflow-1', - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'version-1', - }, }, -} + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, +}) -const HUMAN_PRINCIPAL: Principal = { - ...SCHEDULED_PRINCIPAL, - subjectUserId: 'user-1', - delegationContext: { - kind: 'workflow_execution', +const HUMAN_PRINCIPAL = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + currentWorkflow: { workflowId: 'workflow-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'version-1', - }, + mode: 'deployment', + deploymentVersionId: 'version-1', }, -} +}) function queueLogRow(): void { queueTableRows(workflowExecutionLogs, [{ workspaceId: WORKSPACE_ID, executionId: EXECUTION_ID }]) diff --git a/apps/sim/lib/mcp/application/execute-tool.test.ts b/apps/sim/lib/mcp/application/execute-tool.test.ts index 8406c18a812..6ae03fbcc47 100644 --- a/apps/sim/lib/mcp/application/execute-tool.test.ts +++ b/apps/sim/lib/mcp/application/execute-tool.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -37,6 +36,7 @@ vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { mcpToolExecuted: mocks.telemetry }, })) +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { executeMcpToolUseCase } from '@/lib/mcp/application/execute-tool' const WORKSPACE = { @@ -50,51 +50,34 @@ const SERVER = { workspaceId: WORKSPACE.workspaceId, enabled: true, } -const PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: WORKSPACE.workspaceId, - delegationId: 'delegation-1', - audience: 'sim:mcp-servers', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2099-08-27T00:05:00.000Z'), - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, -} -const ACTORLESS_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - workspaceId: WORKSPACE.workspaceId, - delegationId: 'delegation-system', - audience: 'sim:mcp-servers', - issuedAt: new Date('2026-08-27T00:00:00.000Z'), - expiresAt: new Date('2099-08-27T00:05:00.000Z'), - delegationContext: { - kind: 'workflow_execution', +const PRINCIPAL = createTestRuntimePrincipal() +const ACTORLESS_PRINCIPAL = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: WORKSPACE.workspaceId, workflowId: 'workflow-1', - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, - principal: { - kind: 'system', - serviceId: 'schedule', - workspaceId: WORKSPACE.workspaceId, - workflowId: 'workflow-1', - }, }, -} -const COMPATIBILITY_ACTOR_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - ...ACTORLESS_PRINCIPAL, - delegationContext: { - ...ACTORLESS_PRINCIPAL.delegationContext, - compatibilityActor: { - kind: 'legacy_execution_user', - userId: 'execution-actor', - }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', }, -} +}) +const COMPATIBILITY_ACTOR_PRINCIPAL = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: WORKSPACE.workspaceId, + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + compatibilityActorUserId: 'execution-actor', +}) describe('executeMcpToolUseCase', () => { beforeEach(() => { @@ -202,12 +185,9 @@ describe('executeMcpToolUseCase', () => { await executeMcpToolUseCase.execute({ principal: { ...PRINCIPAL, - delegationContext: { - ...PRINCIPAL.delegationContext, - compatibilityActor: { - kind: 'legacy_execution_user', - userId: 'someone-else', - }, + executionActor: { + kind: 'legacy_execution_user', + userId: 'someone-else', }, }, input: { @@ -228,26 +208,28 @@ describe('executeMcpToolUseCase', () => { // it has no Sim credentials of its own and these runs have always connected as // the actor. Refusing here would break workflows that worked before the tools // moved in-process, so the fallback deliberately covers this case. - const externalSubjectPrincipal = { - ...COMPATIBILITY_ACTOR_PRINCIPAL, - delegationContext: { - ...COMPATIBILITY_ACTOR_PRINCIPAL.delegationContext, - principal: { - kind: 'system' as const, - serviceId: 'webhook' as const, - workspaceId: WORKSPACE.workspaceId, - workflowId: 'workflow-1', - webhookId: 'webhook-1', + const externalSubjectPrincipal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'webhook', + workspaceId: WORKSPACE.workspaceId, + workflowId: 'workflow-1', + webhookId: 'webhook-1', + provider: 'slack', + subject: { + kind: 'external_user', provider: 'slack', - subject: { - kind: 'external_user' as const, - provider: 'slack', - tenantId: 'T1', - subjectId: 'U1', - }, + tenantId: 'T1', + subjectId: 'U1', }, }, - } + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + compatibilityActorUserId: 'execution-actor', + }) await executeMcpToolUseCase.execute({ principal: externalSubjectPrincipal, diff --git a/apps/sim/lib/mcp/application/operations.test.ts b/apps/sim/lib/mcp/application/operations.test.ts index 75218e8f402..feb988354fd 100644 --- a/apps/sim/lib/mcp/application/operations.test.ts +++ b/apps/sim/lib/mcp/application/operations.test.ts @@ -9,17 +9,18 @@ describe('MCP server operation registry', () => { expect(mcpServerOperations.discoverTools).toMatchObject({ workspaceApiKey: 'deny', principalKinds: ['session', 'personal_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', }) }) - it('admits only the executor delegation for tool execution', () => { + it('admits only workflow execution for tool execution', () => { expect(mcpServerOperations.executeTool).toMatchObject({ id: 'mcp_servers.tools.execute', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', }) }) diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index dc4ed4d2335..0981c539202 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -10,11 +10,12 @@ const HUMAN_PRINCIPAL_POLICY = { } as const const DISCOVERY_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const const EXECUTION_PRINCIPAL_POLICY = { - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', } as const export const mcpServerOperations = { diff --git a/apps/sim/lib/memory/application/operations.test.ts b/apps/sim/lib/memory/application/operations.test.ts index 7823931cfa2..9e5760e5a38 100644 --- a/apps/sim/lib/memory/application/operations.test.ts +++ b/apps/sim/lib/memory/application/operations.test.ts @@ -6,15 +6,15 @@ import { describe, expect, it } from 'vitest' import { memoryOperations } from '@/lib/memory/application/operations' describe('memory operation registry', () => { - it('admits only executor delegation with semantic read and write roles', () => { + it('admits only workflow execution with semantic read and write roles', () => { expect(memoryOperations.list.minimumRole).toBe('read') expect(memoryOperations.read.minimumRole).toBe('read') expect(memoryOperations.append.minimumRole).toBe('write') expect(memoryOperations.delete.minimumRole).toBe('write') for (const operation of Object.values(memoryOperations)) { - expect(operation.principalKinds).toEqual(['delegated']) - expect(operation.delegatedServices).toEqual(['executor']) + expect(operation.principalKinds).toEqual([]) + expect(operation.workflowExecution).toBe('allow') expect(operation.workspaceApiKey).toBe('deny') } }) diff --git a/apps/sim/lib/memory/application/operations.ts b/apps/sim/lib/memory/application/operations.ts index 253df541c3d..4bece9a46f4 100644 --- a/apps/sim/lib/memory/application/operations.ts +++ b/apps/sim/lib/memory/application/operations.ts @@ -1,8 +1,8 @@ import { defineWorkspaceOperation } from '@/lib/core/application' const MEMORY_EXECUTOR_PRINCIPAL_POLICY = { - principalKinds: ['delegated'], - delegatedServices: ['executor'], + principalKinds: [], + workflowExecution: 'allow', } as const function readOperation(id: Id) { diff --git a/apps/sim/lib/memory/application/use-cases.test.ts b/apps/sim/lib/memory/application/use-cases.test.ts index f30c95699ba..26843067fb1 100644 --- a/apps/sim/lib/memory/application/use-cases.test.ts +++ b/apps/sim/lib/memory/application/use-cases.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' const mocks = vi.hoisted(() => ({ @@ -61,31 +61,19 @@ const BILLING_ATTRIBUTION: BillingAttributionSnapshot = { payerSubscription: null, } -const ACTORLESS_DEPLOYED_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - workspaceId: WORKSPACE_ID, - delegationId: 'delegation-1', - audience: 'sim:memory', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution', +const ACTORLESS_DEPLOYED_PRINCIPAL = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: WORKSPACE_ID, workflowId: 'workflow-1', - executionId: 'execution-1', - principal: { - kind: 'system', - serviceId: 'schedule', - workspaceId: WORKSPACE_ID, - workflowId: 'workflow-1', - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, }, -} + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, +}) describe('Memory application use cases', () => { beforeEach(() => { diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index e06ee3c9c8d..4af3ba39f10 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -527,7 +527,11 @@ describe('resolveCredentialAccessToken', () => { it('resolves a managed credential through the use case and records analytics', async () => { mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) - mockExecuteManagedToken.mockResolvedValue({ accessToken: 'managed-token', idToken: 'id-1' }) + mockExecuteManagedToken.mockResolvedValue({ + accessToken: 'managed-token', + idToken: 'id-1', + workspaceId: 'ws-1', + }) const auditRequest = { headers: { get: () => null } } const result = await resolveCredentialAccessToken({ diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index ba903ef7eb5..70884d7d29f 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -1,8 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { - resolvePrincipalSubject, - type WorkflowExecutionDelegatedPrincipal, -} from '@sim/auth/principal' +import { type BoundWorkflowExecutionPrincipal, resolvePrincipalSubject } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { impersonateEmailSchema, @@ -332,7 +329,7 @@ export interface ResolveCredentialAccessTokenInput * `MANAGED_CREDENTIAL_DELEGATION_REQUIRED`. Must throw * {@link InvalidManagedOAuthDelegationError} on an invalid delegation. */ - resolveManagedPrincipal?: (credentialId: string) => Promise + resolveManagedPrincipal?: (credentialId: string) => Promise } /** @@ -376,7 +373,7 @@ export async function resolveCredentialAccessToken( } } - let principal: WorkflowExecutionDelegatedPrincipal + let principal: BoundWorkflowExecutionPrincipal try { principal = await input.resolveManagedPrincipal(resolved.credentialId) } catch (error) { @@ -443,9 +440,9 @@ export async function resolveCredentialAccessToken( { credential_type: 'managed_oauth', provider_id: toolMetadata.oauth.provider, - workspace_id: principal.workspaceId, + workspace_id: result.workspaceId, }, - { groups: { workspace: principal.workspaceId } } + { groups: { workspace: result.workspaceId } } ) } diff --git a/apps/sim/lib/table/api/route-policies.test.ts b/apps/sim/lib/table/api/route-policies.test.ts index f691f55db8e..7ee7f9a1840 100644 --- a/apps/sim/lib/table/api/route-policies.test.ts +++ b/apps/sim/lib/table/api/route-policies.test.ts @@ -6,18 +6,18 @@ import { resetEnvMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { MockInvalidBindingError, mockBindDelegation, mockGetSession } = vi.hoisted(() => { +const { MockInvalidBindingError, mockBindDelegationAdmission, mockGetSession } = vi.hoisted(() => { class MockInvalidBindingError extends Error {} return { MockInvalidBindingError, - mockBindDelegation: vi.fn(), + mockBindDelegationAdmission: vi.fn(), mockGetSession: vi.fn(), } }) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) vi.mock('@/lib/auth/internal-delegation', () => ({ - bindInternalExecutorDelegation: mockBindDelegation, + bindInternalExecutorDelegationAdmission: mockBindDelegationAdmission, InvalidInternalDelegationBindingError: MockInvalidBindingError, })) vi.unmock('@/lib/auth/internal') @@ -27,6 +27,7 @@ import { internalOrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { OrchestrationError } from '@/lib/core/orchestration/types' import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' @@ -37,64 +38,46 @@ describe('internal Table route authentication', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue(null) - mockBindDelegation.mockImplementation(async (delegation, options) => ({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: delegation.subjectUserId, + mockBindDelegationAdmission.mockImplementation(async (delegation) => ({ + principal: delegation.principal, workspaceId: 'canonical-workspace', - delegationId: delegation.delegationId, - audience: options.audience, - issuedAt: delegation.issuedAt, - expiresAt: delegation.expiresAt, - resourceScope: options.resourceScope, - delegationContext: { - kind: 'workflow_execution', - workflowId: delegation.workflowId, - executionId: delegation.executionId, - }, })) }) - it('binds table scope to the current workflow without trusting route workspace input', async () => { + it('restores the runtime principal and canonical workspace without deriving identity from route parameters', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', + principal: createTestRuntimePrincipal(), }) - const principal = await internalTableSessionOrExecutorAuth.authenticate( + const authentication = await internalTableSessionOrExecutorAuth.authenticateWithTransport?.( new NextRequest('http://localhost/api/table/table-1/groups?workspaceId=forged-workspace', { headers: { authorization: `Bearer ${token}` }, }), { tableId: 'table-1', workspaceId: 'forged-workspace' } ) - expect(principal).toMatchObject({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'canonical-workspace', - audience: 'sim:tables', - resourceScope: { tableId: 'table-1' }, - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - executionId: 'execution-1', + expect(authentication).toMatchObject({ + transport: 'executor_jwt', + executionWorkspaceId: 'canonical-workspace', + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + executionMetadata: { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, + }, }, }) - expect(mockBindDelegation).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'workflow-1', - executionId: 'execution-1', - }), - { audience: 'sim:tables', resourceScope: { tableId: 'table-1' } } + expect(mockBindDelegationAdmission).toHaveBeenCalledWith( + expect.objectContaining({ principal: expect.objectContaining({ kind: 'session' }) }) ) }) it('binds transfer resource routes as unscoped Table-domain principals', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', + principal: createTestRuntimePrincipal(), }) await internalTableSessionOrExecutorAuth.authenticate( @@ -104,10 +87,7 @@ describe('internal Table route authentication', () => { { importId: 'import-1' } ) - expect(mockBindDelegation).toHaveBeenCalledWith(expect.any(Object), { - audience: 'sim:tables', - resourceScope: undefined, - }) + expect(mockBindDelegationAdmission).toHaveBeenCalledOnce() }) it('rejects legacy actorless internal tokens before canonical binding', async () => { @@ -121,15 +101,14 @@ describe('internal Table route authentication', () => { { tableId: 'table-1' } ) ).rejects.toBeInstanceOf(InternalUnauthenticatedError) - expect(mockBindDelegation).not.toHaveBeenCalled() + expect(mockBindDelegationAdmission).not.toHaveBeenCalled() }) it('rejects a token whose current workflow binding no longer exists', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', + principal: createTestRuntimePrincipal(), }) - mockBindDelegation.mockRejectedValue(new MockInvalidBindingError()) + mockBindDelegationAdmission.mockRejectedValue(new MockInvalidBindingError()) await expect( internalTableSessionOrExecutorAuth.authenticate( @@ -143,11 +122,10 @@ describe('internal Table route authentication', () => { it('propagates canonical-binding infrastructure failures', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', + principal: createTestRuntimePrincipal(), }) const infrastructureError = new Error('database unavailable') - mockBindDelegation.mockRejectedValue(infrastructureError) + mockBindDelegationAdmission.mockRejectedValue(infrastructureError) await expect( internalTableSessionOrExecutorAuth.authenticate( diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index bd8402a0809..8b0c25e449b 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -7,7 +7,6 @@ import { internalOrchestrationErrorPolicy, type V2ErrorPolicy, } from '@/lib/api/server/routes' -import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' import { TableOperationError } from '@/lib/table/application/errors' import { TableLockedError } from '@/lib/table/mutation-locks' import { @@ -16,13 +15,7 @@ import { v2ErrorForOrchestration, } from '@/app/api/v2/lib/response' -export const internalTableSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ - audience: TABLE_DELEGATION_AUDIENCE, - resourceScope: (params) => { - const tableId = typeof params.tableId === 'string' ? params.tableId : undefined - return tableId ? { tableId } : undefined - }, -}) +export const internalTableSessionOrExecutorAuth = createInternalSessionOrExecutorAuth() function renderTableError(error: unknown) { if (error instanceof TableOperationError) { diff --git a/apps/sim/lib/table/application/authorization.test.ts b/apps/sim/lib/table/application/authorization.test.ts index 297359a3b42..8d04b748c7a 100644 --- a/apps/sim/lib/table/application/authorization.test.ts +++ b/apps/sim/lib/table/application/authorization.test.ts @@ -125,7 +125,7 @@ describe('table operation authorization', () => { it('requires delegated scope to match the context in both directions', async () => { const unscopedPrincipal = { kind: 'delegated' as const, - serviceId: 'executor' as const, + serviceId: 'copilot' as const, subjectUserId: 'user-1', workspaceId: 'workspace-1', delegationId: 'execution-1', @@ -135,17 +135,17 @@ describe('table operation authorization', () => { } const workspaceContext = { ...authorizationContext, tableId: undefined } - await authorizeTableOperation(unscopedPrincipal, tableOperations.readImport, workspaceContext) + await authorizeTableOperation(unscopedPrincipal, tableOperations.updateRow, workspaceContext) await expect( authorizeTableOperation( { ...unscopedPrincipal, resourceScope: { tableId: 'table-1' } }, - tableOperations.readImport, + tableOperations.updateRow, workspaceContext ) ).rejects.toMatchObject>({ code: 'forbidden' }) await expect( - authorizeTableOperation(unscopedPrincipal, tableOperations.read, authorizationContext) + authorizeTableOperation(unscopedPrincipal, tableOperations.updateRow, authorizationContext) ).rejects.toMatchObject>({ code: 'forbidden' }) }) diff --git a/apps/sim/lib/table/application/exports.test.ts b/apps/sim/lib/table/application/exports.test.ts index 736ecd7c0c2..63caff644b7 100644 --- a/apps/sim/lib/table/application/exports.test.ts +++ b/apps/sim/lib/table/application/exports.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import type { TableDefinition } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ @@ -82,18 +82,7 @@ const principal = { workspaceId: 'workspace-1', keyId: 'workspace-key-1', } -const executor: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-08-01T00:00:00.000Z'), - expiresAt: new Date('2099-08-01T00:00:00.000Z'), - resourceScope: { tableId: 'table-1' }, - delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, -} +const executor = createTestRuntimePrincipal() describe('table export application use cases', () => { beforeEach(() => { @@ -139,7 +128,7 @@ describe('table export application use cases', () => { ).resolves.toMatchObject({ export: { status: 'canceled', startedAt: now } }) }) - it('supports exact table-scoped executor create and unscoped resource reads', async () => { + it('supports runtime-principal create and resource reads', async () => { await expect( createTableExportUseCase.execute({ principal: executor, @@ -148,16 +137,30 @@ describe('table export application use cases', () => { ).resolves.toEqual({ export: record }) await expect( readTableExportUseCase.execute({ - principal: { ...executor, resourceScope: undefined }, + principal: executor, input: { exportId: 'export-1', workspaceId: 'workspace-1' }, }) ).resolves.toEqual({ export: record }) }) - it('rejects a mismatched executor table scope before export mutation', async () => { + it('rejects a runtime principal bound to another workspace before export mutation', async () => { + const crossWorkspaceExecutor = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-other', + workflowId: 'workflow-1', + }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + }) + await expect( createTableExportUseCase.execute({ - principal: { ...executor, resourceScope: { tableId: 'table-other' } }, + principal: crossWorkspaceExecutor, input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, }) ).rejects.toMatchObject({ code: 'forbidden' }) diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index c5226c24e5d..d4b434a0580 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ abortUpload: vi.fn(), @@ -107,21 +107,13 @@ const workspaceKey = { workspaceId: 'workspace-1', keyId: 'workspace-key-1', } -const executor: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'executor-user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-08-01T00:00:00.000Z'), - expiresAt: new Date('2099-08-01T00:00:00.000Z'), - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - executionId: 'execution-1', +const executor = createTestRuntimePrincipal({ + principal: { + kind: 'session', + userId: 'executor-user-1', + sessionId: 'session-1', }, -} +}) const upload = { id: 'import-1', workspaceId: 'workspace-1', diff --git a/apps/sim/lib/table/application/operations.test.ts b/apps/sim/lib/table/application/operations.test.ts index 3d92657571d..af9537badf1 100644 --- a/apps/sim/lib/table/application/operations.test.ts +++ b/apps/sim/lib/table/application/operations.test.ts @@ -14,9 +14,9 @@ describe('table operation registry', () => { expect(new Set(ids).size).toBe(ids.length) for (const operation of operations) { expect( - operation.principalKinds.length, + operation.principalKinds.length > 0 || operation.workflowExecution === 'allow', `${operation.id} has no allowed principals` - ).toBeGreaterThan(0) + ).toBe(true) expect( new Set(operation.principalKinds).size, `${operation.id} repeats a principal kind` @@ -57,7 +57,7 @@ describe('table operation registry', () => { expect(tableOperations.restore.minimumRole).toBe('write') }) - it('admits executor delegation only for the intentional internal route operations', () => { + it('admits workflow execution only for intentional table-tool operations', () => { const executorOnlyOperations = new Set([ tableOperations.createImport.id, tableOperations.readImport.id, @@ -92,20 +92,21 @@ describe('table operation registry', () => { ]) for (const operation of Object.values(tableOperations)) { + expect(operation.workflowExecution).toBe( + executorOnlyOperations.has(operation.id) || sharedToolOperations.has(operation.id) + ? 'allow' + : undefined + ) expect(operation.delegatedServices).toEqual( - executorOnlyOperations.has(operation.id) - ? ['executor'] - : sharedToolOperations.has(operation.id) - ? ['copilot', 'executor'] - : ['copilot'] + executorOnlyOperations.has(operation.id) ? undefined : ['copilot'] ) } }) it('separates Copilot workspace-file imports from the credential-bound upload lifecycle', () => { - expect(tableOperations.createImport.delegatedServices).toEqual(['executor']) - expect(tableOperations.createImportParts.delegatedServices).toEqual(['executor']) - expect(tableOperations.completeImport.delegatedServices).toEqual(['executor']) + expect(tableOperations.createImport.workflowExecution).toBe('allow') + expect(tableOperations.createImportParts.workflowExecution).toBe('allow') + expect(tableOperations.completeImport.workflowExecution).toBe('allow') expect(tableOperations.createFromWorkspaceFile.principalKinds).toEqual(['delegated']) expect(tableOperations.createFromWorkspaceFile.delegatedServices).toEqual(['copilot']) expect(tableOperations.importWorkspaceFile.principalKinds).toEqual(['delegated']) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 209d11979a7..6a12cc7dcee 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -11,12 +11,13 @@ const COPILOT_PRINCIPAL_POLICY = { const ALL_TABLE_TOOL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const const INTERNAL_EXECUTOR_PRINCIPAL_POLICY = { - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], - delegatedServices: ['executor'], + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + workflowExecution: 'allow', } as const function readOperation(id: Id) { diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index 9e9c9e0dc71..928dd353214 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import type { TableDefinition } from '@/lib/table/types' const { @@ -211,33 +212,21 @@ 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, +const GENERIC_WEBHOOK_EXECUTOR = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'webhook', + workspaceId: TABLE.workspaceId, 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', - }, + webhookId: 'webhook-1', + provider: 'generic', }, -} + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, +}) /** * The active-table context every row command resolves before it does any work. diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index d7746abdfaf..fc371fc5a5f 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import type { Principal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { type Principal, serializePrincipal } from '@sim/auth/principal' import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { eq, inArray, isNull } from 'drizzle-orm' @@ -68,6 +68,7 @@ vi.mock('@/lib/uploads/upload-session/provider', () => ({ uploadStorageProvider: mockUploadStorageProvider, })) +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { OrchestrationError } from '@/lib/core/orchestration/types' import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key' import { @@ -89,21 +90,7 @@ import { const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const FINAL_KEY = `workspace/${WORKSPACE_ID}/final-file.bin` -const executorPrincipal: WorkflowExecutionDelegatedPrincipal = { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: WORKSPACE_ID, - delegationId: 'delegation-1', - audience: 'sim:tables', - issuedAt: new Date('2026-08-01T00:00:00.000Z'), - expiresAt: new Date('2099-08-01T00:00:00.000Z'), - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - executionId: 'execution-1', - }, -} +const executorPrincipal = createTestRuntimePrincipal() describe('upload sessions', () => { beforeEach(() => { @@ -527,16 +514,9 @@ describe('upload sessions', () => { }) expect(dbChainMockFns.values.mock.calls[0][0].metadata.authBinding).toEqual({ - version: 1, + version: 2, workspaceId: WORKSPACE_ID, - principal: { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - audience: 'sim:tables', - workflowId: 'workflow-1', - executionId: 'execution-1', - }, + principal: serializePrincipal(executorPrincipal, 2), }) }) @@ -546,7 +526,7 @@ describe('upload sessions', () => { storageContext: 'table-import', metadata: { authBinding: createUploadSessionAuthBinding(executorPrincipal, WORKSPACE_ID, { - executorDelegationAudience: 'sim:tables', + workflowExecution: 'allow', }), }, }) @@ -554,37 +534,59 @@ describe('upload sessions', () => { expect(() => assertUploadSessionAuthBinding(session, { ...executorPrincipal, - delegationId: 'refreshed-token-jti', }) ).not.toThrow() expect(() => assertUploadSessionAuthBinding(session, { - ...executorPrincipal, - delegationId: 'other-execution-token', - delegationContext: { - kind: 'workflow_execution', - workflowId: 'workflow-1', - executionId: 'execution-2', - }, + ...createTestRuntimePrincipal({ executionId: 'execution-2' }), }) ).toThrow('Upload session not found') expect(() => assertUploadSessionAuthBinding(session, { - ...executorPrincipal, - workspaceId: 'different-workspace', + ...createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'user-2', sessionId: 'session-2' }, + }), }) ).toThrow('Upload session not found') }) - it('does not admit executor delegation outside the explicit Table upload policy', () => { + it('compares persisted executor bindings independently of JSON object key order', () => { + const session = sessionRecord({ + purpose: 'table_import', + storageContext: 'table-import', + metadata: { + authBinding: { + version: 2, + workspaceId: WORKSPACE_ID, + principal: { + executionMetadata: { + currentWorkflow: { mode: 'draft', workflowId: 'workflow-1' }, + rootWorkflowId: 'workflow-1', + executionId: 'execution-1', + }, + principal: { + sessionId: 'session-1', + userId: 'user-1', + kind: 'session', + }, + version: 2, + }, + }, + }, + }) + + expect(() => assertUploadSessionAuthBinding(session, executorPrincipal)).not.toThrow() + }) + + it('does not admit workflow execution outside an explicit upload policy', () => { expect(() => createUploadSessionAuthBinding(executorPrincipal, WORKSPACE_ID)).toThrow( - 'Delegated principal cannot create this upload' + 'Workflow execution cannot create this upload' ) expect(() => createUploadSessionAuthBinding(executorPrincipal, WORKSPACE_ID, { - executorDelegationAudience: 'sim:workspace-files', + workflowExecution: 'allow', }) - ).toThrow('Delegated principal cannot create this upload') + ).not.toThrow() }) it('fails closed for legacy table-import sessions without a binding', () => { diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index a766af1c680..f27b9026008 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,7 +1,12 @@ import { - type BoundWorkflowExecutionDelegatedPrincipal, + type BoundWorkflowExecutionPrincipal, type Principal, + parsePrincipal, + requirePrincipalExecutionMetadata, requirePrincipalSubjectUserId, + resolvePrincipalSubject, + type SerializedPrincipalV2, + serializePrincipal, } from '@sim/auth/principal' import { db, dbFor } from '@sim/db' import { uploadSession } from '@sim/db/schema' @@ -111,22 +116,28 @@ export interface UploadSessionRecord { * token only proves possession of the byte-plane capability and never grants * workspace access by itself. */ -export interface UploadSessionAuthBinding { - version: 1 - workspaceId: string - principal: - | { kind: 'session'; userId: string; sessionId: string } - | { kind: 'personal_api_key'; userId: string; keyId: string } - | { kind: 'workspace_api_key'; workspaceId: string; keyId: string } - | { - kind: 'delegated' - serviceId: 'executor' - subjectUserId: string - audience: string - workflowId: string - executionId?: string - } -} +export type UploadSessionAuthBinding = + | { + version: 1 + workspaceId: string + principal: + | { kind: 'session'; userId: string; sessionId: string } + | { kind: 'personal_api_key'; userId: string; keyId: string } + | { kind: 'workspace_api_key'; workspaceId: string; keyId: string } + | { + kind: 'delegated' + serviceId: 'executor' + subjectUserId: string + audience: string + workflowId: string + executionId?: string + } + } + | { + version: 2 + workspaceId: string + principal: SerializedPrincipalV2 + } export interface CreatedUploadSession extends UploadSessionRecord { transfer: UploadSessionTransfer @@ -144,26 +155,19 @@ export class UploadSessionError extends OrchestrationError { function isExecutorWorkflowExecutionPrincipal( principal: Principal -): principal is BoundWorkflowExecutionDelegatedPrincipal { +): principal is BoundWorkflowExecutionPrincipal { if ( - principal.kind !== 'delegated' || - principal.serviceId !== 'executor' || - !principal.delegationContext + principal.kind === 'credential_group_enrollment' || + principal.executionMetadata === undefined ) { return false } - const context = principal.delegationContext - return ( - typeof context === 'object' && - context !== null && - 'kind' in context && - context.kind === 'workflow_execution' && - 'workflowId' in context && - typeof context.workflowId === 'string' && - (!('executionId' in context) || - context.executionId === undefined || - typeof context.executionId === 'string') - ) + try { + requirePrincipalExecutionMetadata(principal) + } catch { + return false + } + return resolvePrincipalSubject(principal)?.kind === 'sim_user' } interface CreateUploadSessionBaseParams { @@ -215,7 +219,7 @@ export async function createUploadSession( } else if (params.purpose === 'table_import' && params.principal) { if (!workspaceId) throw new Error('table_import upload is missing workspaceId') metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId, { - executorDelegationAudience: 'sim:tables', + workflowExecution: 'allow', }) } const { storageContext, finalKey } = resolveUploadStorage(params, id) @@ -417,8 +421,26 @@ export async function getPrincipalKnowledgeDocumentUploadSession(params: { export function createUploadSessionAuthBinding( principal: Principal, workspaceId: string, - options: { executorDelegationAudience?: string } = {} + options: { workflowExecution?: 'allow' } = {} ): UploadSessionAuthBinding { + if (principal.executionMetadata !== undefined) { + if ( + options.workflowExecution !== 'allow' || + !isExecutorWorkflowExecutionPrincipal(principal) || + ((principal.kind === 'workspace_api_key' || + principal.kind === 'system' || + principal.kind === 'delegated') && + principal.workspaceId !== workspaceId) + ) { + throw new UploadSessionError('forbidden', 'Workflow execution cannot create this upload') + } + const serialized = serializePrincipal(principal, 2) + if (serialized.version !== 2) { + throw new Error('Workflow execution upload binding requires execution metadata') + } + return { version: 2, workspaceId, principal: serialized } + } + switch (principal.kind) { case 'session': return { @@ -446,28 +468,7 @@ export function createUploadSessionAuthBinding( principal: { kind: principal.kind, workspaceId, keyId: principal.keyId }, } case 'delegated': { - if ( - options.executorDelegationAudience === undefined || - !isExecutorWorkflowExecutionPrincipal(principal) || - principal.audience !== options.executorDelegationAudience || - principal.workspaceId !== workspaceId - ) { - throw new UploadSessionError('forbidden', 'Delegated principal cannot create this upload') - } - return { - version: 1, - workspaceId, - principal: { - kind: principal.kind, - serviceId: principal.serviceId, - subjectUserId: requirePrincipalSubjectUserId(principal), - audience: principal.audience, - workflowId: principal.delegationContext.workflowId, - ...(principal.delegationContext.executionId - ? { executionId: principal.delegationContext.executionId } - : {}), - }, - } + throw new UploadSessionError('forbidden', 'Delegated principal cannot create this upload') } case 'credential_group_enrollment': throw new UploadSessionError( @@ -493,6 +494,20 @@ export function assertUploadSessionAuthBinding( if (!isUploadSessionAuthBinding(candidate) || candidate.workspaceId !== session.workspaceId) { throw uploadNotFound() } + if (candidate.version === 2) { + if (!isExecutorWorkflowExecutionPrincipal(principal)) throw uploadNotFound() + const serialized = serializePrincipal(principal, 2) + let persisted: SerializedPrincipalV2 + try { + persisted = serializePrincipal(parsePrincipal(candidate.principal), 2) + } catch { + throw uploadNotFound() + } + if (serialized.version !== 2 || JSON.stringify(serialized) !== JSON.stringify(persisted)) { + throw uploadNotFound() + } + return + } const bound = candidate.principal const matches = bound.kind === principal.kind && @@ -509,11 +524,10 @@ export function assertUploadSessionAuthBinding( bound.workspaceId === principal.workspaceId && bound.keyId === principal.keyId : isExecutorWorkflowExecutionPrincipal(principal) && - principal.workspaceId === session.workspaceId && - principal.subjectUserId === bound.subjectUserId && - principal.audience === bound.audience && - principal.delegationContext.workflowId === bound.workflowId && - principal.delegationContext.executionId === bound.executionId) + resolvePrincipalSubject(principal)?.kind === 'sim_user' && + requirePrincipalSubjectUserId(principal) === bound.subjectUserId && + principal.executionMetadata.rootWorkflowId === bound.workflowId && + principal.executionMetadata.executionId === bound.executionId) if (!matches) throw uploadNotFound() } @@ -1279,8 +1293,18 @@ function isStorageContext(value: string): value is StorageContext { function isUploadSessionAuthBinding(value: unknown): value is UploadSessionAuthBinding { if (!value || typeof value !== 'object') return false const binding = value as Record - if (binding.version !== 1 || typeof binding.workspaceId !== 'string') return false + if (typeof binding.workspaceId !== 'string') return false if (!binding.principal || typeof binding.principal !== 'object') return false + if (binding.version === 2) { + try { + const principal = parsePrincipal(binding.principal) + requirePrincipalExecutionMetadata(principal) + return true + } catch { + return false + } + } + if (binding.version !== 1) return false const principal = binding.principal as Record if (principal.kind === 'session') { return typeof principal.userId === 'string' && typeof principal.sessionId === 'string' diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index e618d917621..ee9eb786d58 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -753,7 +753,8 @@ async function queueWebhookExecutionWithResult( workspaceId, provider: foundWebhook.provider, ...(options.subject ? { subject: options.subject } : {}), - }) + }), + 1 ), userId: actorUserId, billingAttribution, @@ -1056,7 +1057,8 @@ export async function processPolledWebhookEvent( workflowId: foundWorkflow.id, workspaceId, provider, - }) + }), + 1 ), userId: actorUserId, billingAttribution, diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index e445a2766ef..ac228d9751e 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -21,7 +21,6 @@ import { import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error' -import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error' import { @@ -90,9 +89,7 @@ export const v2WorkflowErrorPolicies = { }), } as const -export const internalWorkflowSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ - audience: WORKFLOW_DELEGATION_AUDIENCE, -}) +export const internalWorkflowSessionOrExecutorAuth = createInternalSessionOrExecutorAuth() type WorkflowApiKeyPrincipal = PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal diff --git a/apps/sim/lib/workflows/application/authorization.test.ts b/apps/sim/lib/workflows/application/authorization.test.ts index ac5f38dfb24..c5cae92d84b 100644 --- a/apps/sim/lib/workflows/application/authorization.test.ts +++ b/apps/sim/lib/workflows/application/authorization.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ -import type { DelegatedPrincipal } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { requireWorkflowExecutionUserId, WORKFLOW_DELEGATION_AUDIENCE, @@ -11,24 +11,22 @@ import { } from '@/lib/workflows/application/authorization' import { workflowOperations } from '@/lib/workflows/application/operations' -function createExecutorPrincipal(overrides: Partial = {}): DelegatedPrincipal { +function createCopilotPrincipal() { return { - kind: 'delegated', - serviceId: 'executor', + kind: 'delegated' as const, + serviceId: 'copilot' as const, subjectUserId: 'user-1', workspaceId: 'workspace-1', delegationId: 'execution-1', audience: WORKFLOW_DELEGATION_AUDIENCE, issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), - delegationContext: { kind: 'workflow_execution', workflowId: 'parent-workflow' }, - ...overrides, } } describe('workflow delegation policy', () => { it('allows an active execution to read a different workflow in the same workspace', () => { - const principal = createExecutorPrincipal() + const principal = createCopilotPrincipal() expect( workflowDelegationPolicy.isWithinScope(principal, { @@ -42,7 +40,7 @@ describe('workflow delegation policy', () => { }) it('rejects a child workflow in another workspace', () => { - const principal = createExecutorPrincipal() + const principal = createCopilotPrincipal() expect( workflowDelegationPolicy.isWithinScope(principal, { @@ -55,63 +53,40 @@ describe('workflow delegation policy', () => { ).toBe(false) }) - it('rejects executor delegation without a canonical workflow execution origin', () => { - const principal = createExecutorPrincipal({ delegationContext: undefined }) - - expect( - workflowDelegationPolicy.isWithinScope(principal, { - workspaceId: 'workspace-1', - workspaceOrganizationId: null, - allowPersonalApiKeys: true, - billedAccountUserId: 'user-1', - workflowId: 'child-workflow', - }) - ).toBe(false) - }) - - it('permits executor delegation only on workflow reads', () => { - expect(workflowOperations.read.delegatedServices).toContain('executor') - expect(workflowOperations.update.delegatedServices).not.toContain('executor') - expect(workflowOperations.delete.delegatedServices).not.toContain('executor') + it('permits workflow execution only on declared workflow operations', () => { + expect(workflowOperations.read.workflowExecution).toBe('allow') + expect(workflowOperations.update.workflowExecution).toBeUndefined() + expect(workflowOperations.delete.workflowExecution).toBeUndefined() }) }) describe('workflow execution actor', () => { it('uses the legacy execution actor when the principal is actorless', () => { - const principal = createExecutorPrincipal({ - subjectUserId: undefined, - delegationContext: { - kind: 'workflow_execution', + const principal = createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: 'workspace-1', + workflowId: 'parent-workflow', + }, + rootWorkflowId: 'parent-workflow', + currentWorkflow: { workflowId: 'parent-workflow', - currentWorkflow: { - workflowId: 'parent-workflow', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, - compatibilityActor: { - kind: 'legacy_execution_user', - userId: 'execution-actor', - }, + mode: 'deployment', + deploymentVersionId: 'deployment-1', }, + compatibilityActorUserId: 'execution-actor', }) expect(requireWorkflowExecutionUserId(principal)).toBe('execution-actor') }) it('prefers a real principal subject over the compatibility actor', () => { - const principal = createExecutorPrincipal({ - delegationContext: { - kind: 'workflow_execution', - workflowId: 'parent-workflow', - currentWorkflow: { - workflowId: 'parent-workflow', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, - compatibilityActor: { - kind: 'legacy_execution_user', - userId: 'someone-else', - }, + const principal = createTestRuntimePrincipal({ + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', }, }) diff --git a/apps/sim/lib/workflows/application/authorization.ts b/apps/sim/lib/workflows/application/authorization.ts index 1448e30a837..37a7d1f5069 100644 --- a/apps/sim/lib/workflows/application/authorization.ts +++ b/apps/sim/lib/workflows/application/authorization.ts @@ -20,18 +20,7 @@ export const workflowDelegationPolicy: WorkspaceDelegationPolicy 0 - ) + return principal.serviceId === 'copilot' }, } diff --git a/apps/sim/lib/workflows/application/deployments.ts b/apps/sim/lib/workflows/application/deployments.ts index 039d4d2b481..937169bd15d 100644 --- a/apps/sim/lib/workflows/application/deployments.ts +++ b/apps/sim/lib/workflows/application/deployments.ts @@ -113,7 +113,9 @@ export const deployWorkflow = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, actorId: attribution.attributedUserId, actor: toPrincipalActor(principal), - ...(principal.kind === 'delegated' ? { captureAnalytics: false as const } : {}), + ...(principal.kind === 'delegated' || principal.executionMetadata + ? { captureAnalytics: false as const } + : {}), versionName: input.name, versionDescription: input.description, requestId: input.requestId, @@ -198,7 +200,9 @@ export const activateWorkflowVersion = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, actorId: attribution.attributedUserId, actor: toPrincipalActor(principal), - ...(principal.kind === 'delegated' ? { captureAnalytics: false as const } : {}), + ...(principal.kind === 'delegated' || principal.executionMetadata + ? { captureAnalytics: false as const } + : {}), requestId: input.requestId, idempotencyKey: input.idempotencyKey, name: input.name, diff --git a/apps/sim/lib/workflows/application/operations.test.ts b/apps/sim/lib/workflows/application/operations.test.ts index b978a3e3bcd..f97779e1f25 100644 --- a/apps/sim/lib/workflows/application/operations.test.ts +++ b/apps/sim/lib/workflows/application/operations.test.ts @@ -56,7 +56,7 @@ describe('workflow operation registry', () => { expect(Object.isFrozen(workflowOperations.moveBulk)).toBe(true) }) - it('admits executor delegation only to workflow deployment operations', () => { + it('admits workflow execution only to workflow deployment operations', () => { for (const operation of [ workflowOperations.deploy, workflowOperations.undeploy, @@ -66,7 +66,8 @@ describe('workflow operation registry', () => { minimumRole: 'admin', workspaceApiKey: 'deny', principalKinds: ['session', 'personal_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', }) } @@ -75,7 +76,8 @@ describe('workflow operation registry', () => { minimumRole: 'read', workspaceApiKey: 'allow', principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', }) } diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 897c626b961..24f5a69d0bd 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -7,7 +7,8 @@ const ALL_WORKFLOW_PRINCIPAL_POLICY = { const WORKFLOW_READ_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const const HUMAN_WORKFLOW_PRINCIPAL_POLICY = { @@ -17,7 +18,8 @@ const HUMAN_WORKFLOW_PRINCIPAL_POLICY = { const WORKFLOW_DEPLOYMENT_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const const COPILOT_WORKFLOW_PRINCIPAL_POLICY = { diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 31ed03811fc..1c15bc82a35 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -230,7 +230,7 @@ async function executeCopilotRun(params: { }): Promise { if ( params.principal.kind === 'credential_group_enrollment' || - (params.principal.kind === 'delegated' && params.principal.serviceId === 'executor') + params.principal.executionMetadata !== undefined ) { throw new Error('The principal cannot start a Copilot workflow execution') } diff --git a/apps/sim/lib/workflows/application/workflow-crud.test.ts b/apps/sim/lib/workflows/application/workflow-crud.test.ts index 8c7168eab0d..14a5e884480 100644 --- a/apps/sim/lib/workflows/application/workflow-crud.test.ts +++ b/apps/sim/lib/workflows/application/workflow-crud.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ recordAudit: vi.fn(), @@ -145,21 +146,11 @@ const workspacePrincipal = { workspaceId: WORKSPACE_ID, keyId: 'workspace-key-1', } -const executorPrincipal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - subjectUserId: 'user-1', - workspaceId: WORKSPACE_ID, - delegationId: 'executor-1', - audience: 'sim:workflows', - issuedAt: new Date('2026-08-01T00:00:00Z'), - expiresAt: new Date('2999-08-01T00:00:00Z'), - delegationContext: { - kind: 'workflow_execution' as const, - workflowId: WORKFLOW_ID, - executionId: 'origin-run', - }, -} +const executorPrincipal = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + executionId: 'origin-run', + rootWorkflowId: WORKFLOW_ID, +}) describe('authorized workflow CRUD and version reads', () => { beforeEach(() => { @@ -294,21 +285,10 @@ describe('authorized workflow CRUD and version reads', () => { }) it('rejects executor workflow mutations before canonical resource loading', async () => { - const executor = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - subjectUserId: 'user-1', - workspaceId: WORKSPACE_ID, - delegationId: 'delegation-1', - audience: 'sim:workflows', - issuedAt: new Date('2026-08-01T00:00:00Z'), - expiresAt: new Date('2999-01-01T00:00:00Z'), - delegationContext: { - kind: 'workflow_execution' as const, - workflowId: WORKFLOW_ID, - executionId: 'execution-1', - }, - } + const executor = createTestRuntimePrincipal({ + executionId: 'execution-1', + rootWorkflowId: WORKFLOW_ID, + }) await expect( updateWorkflow.execute({ @@ -336,7 +316,7 @@ describe('authorized workflow CRUD and version reads', () => { expect(mocks.loadSnapshot).toHaveBeenCalledWith(WORKFLOW_ID, WORKSPACE_ID) }) - it('rejects executor reads whose canonical target is outside the signed origin workspace', async () => { + it('conceals runtime reads whose canonical target resolves outside the asserted workflow', async () => { mocks.resolveWorkflowContext.mockResolvedValueOnce({ ...workflowContext, workspaceId: 'workspace-other', @@ -348,8 +328,8 @@ describe('authorized workflow CRUD and version reads', () => { principal: executorPrincipal, input: { workflowId: WORKFLOW_ID }, }) - ).rejects.toMatchObject({ code: 'forbidden' }) - expect(mocks.loadSnapshot).not.toHaveBeenCalled() + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.loadSnapshot).toHaveBeenCalledWith(WORKFLOW_ID, 'workspace-other') }) it('rechecks current permission for every workflow mutation', async () => { diff --git a/apps/sim/lib/workflows/application/workflow-deployments.test.ts b/apps/sim/lib/workflows/application/workflow-deployments.test.ts index 440d8bc4f37..d7241fd6802 100644 --- a/apps/sim/lib/workflows/application/workflow-deployments.test.ts +++ b/apps/sim/lib/workflows/application/workflow-deployments.test.ts @@ -3,6 +3,7 @@ */ import type { Principal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const { MockWorkflowLockedError, mocks } = vi.hoisted(() => { class MockWorkflowLockedError extends Error {} @@ -189,23 +190,13 @@ describe('workflow deployment application use cases', () => { expect(mocks.deploy).not.toHaveBeenCalled() }) - it('admits executor deployment transitions through canonical workflow authorization', async () => { + it('admits runtime-principal deployment transitions through canonical authorization', async () => { await deployWorkflow.execute({ - principal: { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'executor-1', - audience: 'sim:workflows', - issuedAt: new Date('2026-08-08T00:00:00Z'), - expiresAt: new Date('2999-08-08T00:00:00Z'), - delegationContext: { - kind: 'workflow_execution', - workflowId: 'origin-workflow', - executionId: 'execution-1', - }, - }, + principal: createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + rootWorkflowId: 'origin-workflow', + executionId: 'execution-1', + }), input: { workflowId: 'workflow-1', requestId: 'request-1' }, }) @@ -223,10 +214,8 @@ describe('workflow deployment application use cases', () => { userId: 'user-1', actorId: 'user-1', actor: { - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - delegationId: 'executor-1', + kind: 'session', + userId: 'user-1', }, captureAnalytics: false, requestId: 'request-1', diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index a35f980b7e1..49d32bbd418 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -342,6 +342,10 @@ export async function getCustomBlockAuthority( * same lookup — read one value that no consumer input can influence. */ traceChildRuns: boolean + workflowName: string + workspaceId: string | null + variables: unknown + deploymentVersionId: string | null } | null> { // Scope resolution to the consumer's org: `(organizationId, type)` is the unique // key, so without the org filter a `custom_block_*` type smuggled in from another @@ -359,9 +363,20 @@ export async function getCustomBlockAuthority( inputs: customBlock.inputs, traceChildRuns: customBlock.traceChildRuns, ownerUserId: workflow.userId, + workflowName: workflow.name, + workspaceId: workflow.workspaceId, + variables: workflow.variables, + deploymentVersionId: workflowDeploymentVersion.id, }) .from(customBlock) .innerJoin(workflow, eq(workflow.id, customBlock.workflowId)) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) .where(and(eq(customBlock.type, type), eq(customBlock.organizationId, organizationId))) .limit(1) @@ -373,6 +388,10 @@ export async function getCustomBlockAuthority( exposedOutputs: row.outputs ?? [], requiredInputIds: (row.inputs ?? []).filter((i) => i.required).map((i) => i.id), traceChildRuns: row.traceChildRuns, + workflowName: row.workflowName, + workspaceId: row.workspaceId, + variables: row.variables, + deploymentVersionId: row.deploymentVersionId, } } diff --git a/apps/sim/lib/workflows/executor/enqueue-execution.ts b/apps/sim/lib/workflows/executor/enqueue-execution.ts index be3200d8ab6..2f12abfc36b 100644 --- a/apps/sim/lib/workflows/executor/enqueue-execution.ts +++ b/apps/sim/lib/workflows/executor/enqueue-execution.ts @@ -105,7 +105,7 @@ export async function enqueueWorkflowExecution( const payload: WorkflowExecutionPayload = { workflowId, - principal: serializePrincipal(principal), + principal: serializePrincipal(principal, 1), userId, billingAttribution, workspaceId, diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 666493bf95a..39022e5a9b2 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -494,7 +494,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { triggerType: 'api' | 'schedule' | 'webhook' isPublicApiAccess: boolean }>)( - 'preserves the exact $name principal and deployed workflow authority in executor delegation', + 'preserves the exact $name actor and deployed workflow authority on the runtime principal', async ({ principal, triggerType, isPublicApiAccess }) => { executorExecuteMock.mockResolvedValue({ success: true, @@ -522,18 +522,21 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { }) const contextExtensions = executorConstructorMock.mock.calls[0]?.[0]?.contextExtensions - expect(contextExtensions.principal).toBe(principal) - expect(contextExtensions.executorDelegationOrigin.principal).toBe(principal) - expect(contextExtensions.executorDelegationOrigin).toEqual({ - workflowId: 'workflow-1', - executionId: 'execution-1', - principal, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'dep-1', + expect(contextExtensions.principal).toEqual({ + ...principal, + executionMetadata: { + executionId: 'execution-1', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'dep-1', + }, }, }) + expect(contextExtensions.principal).not.toMatchObject({ + userId: 'billing-actor', + }) } ) @@ -942,11 +945,15 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { expect.objectContaining({ deploymentVersionId: 'dep-historical' }) ) expect(executorConstructorMock.mock.calls[0]?.[0]?.contextExtensions).toMatchObject({ - executorDelegationOrigin: { - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'dep-historical', + principal: { + executionMetadata: { + executionId: 'execution-resumed', + rootWorkflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'dep-historical', + }, }, }, }) diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 595fa7c35a0..ff686521eed 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -3,7 +3,11 @@ * This is the SINGLE source of truth for workflow execution */ -import { resolvePrincipalSubject } from '@sim/auth/principal' +import { + type BoundWorkflowExecutionPrincipal, + bindPrincipalExecutionMetadata, + requirePrincipalExecutionMetadata, +} from '@sim/auth/principal' import { db } from '@sim/db' import { organization, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -567,6 +571,34 @@ async function executeWorkflowCoreImpl( const { blocks, loops, parallels } = workflowState const edges: Edge[] = workflowState.edges deploymentVersionId = workflowState.deploymentVersionId + const currentWorkflow = deploymentVersionId + ? ({ workflowId, mode: 'deployment', deploymentVersionId } as const) + : ({ workflowId, mode: 'draft' } as const) + let runtimePrincipal: BoundWorkflowExecutionPrincipal + if (metadata.principal.executionMetadata === undefined) { + runtimePrincipal = bindPrincipalExecutionMetadata(metadata.principal, { + executionId, + rootWorkflowId: workflowId, + currentWorkflow, + }) + } else { + const executionMetadata = requirePrincipalExecutionMetadata(metadata.principal) + const matchesCurrentWorkflow = + executionMetadata.currentWorkflow.workflowId === currentWorkflow.workflowId && + executionMetadata.currentWorkflow.mode === currentWorkflow.mode && + (currentWorkflow.mode === 'draft' || + (executionMetadata.currentWorkflow.mode === 'deployment' && + executionMetadata.currentWorkflow.deploymentVersionId === + currentWorkflow.deploymentVersionId)) + if (executionMetadata.rootWorkflowId !== workflowId || !matchesCurrentWorkflow) { + throw new Error('Workflow execution principal does not match the canonical root authority') + } + if (!resumeFromSnapshot && executionMetadata.executionId !== executionId) { + throw new Error('Workflow execution principal does not match the canonical execution') + } + runtimePrincipal = metadata.principal as BoundWorkflowExecutionPrincipal + } + metadata.principal = runtimePrincipal const mergedStates = mergeSubblockStateWithValues(blocks) @@ -958,7 +990,6 @@ async function executeWorkflowCoreImpl( ? restoredWorkflowInputProvenance : resolvedSecretTraceRegistry.exportCommittedProvenanceForValue(processedInput) - const principalSubject = resolvePrincipalSubject(metadata.principal) const contextExtensions: ContextExtensions = { stream: !!onStream, selectedOutputs, @@ -969,18 +1000,7 @@ async function executeWorkflowCoreImpl( allowLargeValueWorkflowScope, workspaceId: providedWorkspaceId, userId, - principal: metadata.principal, - executorDelegationOrigin: { - ...(principalSubject?.kind === 'sim_user' - ? { subjectUserId: principalSubject.userId } - : {}), - workflowId, - ...(executionId ? { executionId } : {}), - principal: metadata.principal, - currentWorkflow: deploymentVersionId - ? { workflowId, mode: 'deployment', deploymentVersionId } - : { workflowId, mode: 'draft' }, - }, + principal: runtimePrincipal, isDeployedContext: metadata.useDraftState !== true, enforceCredentialAccess: metadata.enforceCredentialAccess ?? false, piiBlockOutputRedaction: piiRedaction.blockOutputs, diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index 33dd706d8a5..a90fc962bea 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -10,6 +10,7 @@ import { resetDbChainMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { createTimeoutAbortController, getExecutionDeadlineAt } from '@/lib/core/execution-limits' import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { terminalExecutionLogFields } from '@/lib/logs/execution/cancellation' @@ -31,6 +32,7 @@ vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({ })) import { + assertResumeExecutionPrincipalBinding, createResumeAttemptTimeoutController, extractResumeBillingAttributionFromSnapshot, PauseResumeManager, @@ -39,6 +41,7 @@ import { } from '@/lib/workflows/executor/human-in-the-loop-manager' import { getAutomaticResumeWaitingMetadata } from '@/lib/workflows/executor/paused-execution-metadata' import { AUTOMATIC_RESUME_WAITING_REASON_MAX_LENGTH } from '@/lib/workflows/executor/resume-policy' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { SerializableExecutionState } from '@/executor/execution/types' import type { PausePoint, SerializedSnapshot } from '@/executor/types' @@ -1878,6 +1881,37 @@ describe('PauseResumeManager resume log claims', () => { expect(requireResumeDeploymentVersion(true, null)).toBeUndefined() }) + it('keeps the root run stable while resuming a regular child workflow', () => { + const principal = createTestRuntimePrincipal({ + executionId: 'execution-1', + rootWorkflowId: 'root-workflow', + currentWorkflow: { workflowId: 'child-workflow', mode: 'draft' }, + }) + const snapshot = new ExecutionSnapshot( + { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'child-workflow', + workspaceId: 'workspace-1', + userId: 'user-1', + principal, + triggerType: 'manual', + useDraftState: true, + startTime: '2026-08-04T12:00:00.000Z', + }, + {}, + {}, + {} + ) + + expect(() => + assertResumeExecutionPrincipalBinding(snapshot, 'root-workflow', undefined) + ).not.toThrow() + expect(() => + assertResumeExecutionPrincipalBinding(snapshot, 'child-workflow', undefined) + ).toThrowError(expect.objectContaining({ name: 'ResumeAdmissionError', statusCode: 409 })) + }) + it.each([ { useDraftState: true, deploymentVersionId: 'deployment-version-1' }, { useDraftState: false, deploymentVersionId: null }, diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index b3b30634324..a89d3c4874a 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -1,3 +1,4 @@ +import { requirePrincipalExecutionMetadata } from '@sim/auth/principal' import { dbFor } from '@sim/db' import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -162,6 +163,37 @@ export function requireResumeDeploymentVersion( return deploymentVersionId } +/** Verifies that a durable pause retained the run root and current workflow authority. */ +export function assertResumeExecutionPrincipalBinding( + snapshot: ExecutionSnapshot, + rootWorkflowId: string, + deploymentVersionId: string | undefined +): void { + const { executionId, workflowId, principal } = snapshot.metadata + const currentWorkflow = deploymentVersionId + ? ({ workflowId, mode: 'deployment', deploymentVersionId } as const) + : ({ workflowId, mode: 'draft' } as const) + const executionMetadata = requirePrincipalExecutionMetadata(principal) + const matchesCurrentWorkflow = + executionMetadata.currentWorkflow.workflowId === workflowId && + executionMetadata.currentWorkflow.mode === currentWorkflow.mode && + (currentWorkflow.mode === 'draft' || + (executionMetadata.currentWorkflow.mode === 'deployment' && + executionMetadata.currentWorkflow.deploymentVersionId === + currentWorkflow.deploymentVersionId)) + if ( + executionMetadata.executionId !== executionId || + executionMetadata.rootWorkflowId !== rootWorkflowId || + !matchesCurrentWorkflow + ) { + throw new ResumeAdmissionError( + 'Paused execution principal does not match its durable workflow authority', + 409, + false + ) + } +} + function isPausedOutputForContext(output: unknown, contextId: string): boolean { if (!isRecordLike(output)) return false const metadata = output._pauseMetadata @@ -1108,6 +1140,11 @@ export class PauseResumeManager { baseSnapshot.metadata.useDraftState, claimedExecution.deploymentVersionId ) + assertResumeExecutionPrincipalBinding( + baseSnapshot, + pausedExecution.workflowId, + resumeDeploymentVersionId + ) const billingAttribution = assertBillingAttributionSnapshot( baseSnapshot.metadata.billingAttribution ) diff --git a/apps/sim/lib/workspace-files/api/route-policies.test.ts b/apps/sim/lib/workspace-files/api/route-policies.test.ts index 3ab9860d5f5..bddaa86169c 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.test.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.test.ts @@ -6,24 +6,25 @@ import { resetEnvMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { MockInvalidBindingError, mockBindDelegation, mockGetSession } = vi.hoisted(() => { +const { MockInvalidBindingError, mockBindDelegationAdmission, mockGetSession } = vi.hoisted(() => { class MockInvalidBindingError extends Error {} return { MockInvalidBindingError, - mockBindDelegation: vi.fn(), + mockBindDelegationAdmission: vi.fn(), mockGetSession: vi.fn(), } }) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) vi.mock('@/lib/auth/internal-delegation', () => ({ - bindInternalExecutorDelegation: mockBindDelegation, + bindInternalExecutorDelegationAdmission: mockBindDelegationAdmission, InvalidInternalDelegationBindingError: MockInvalidBindingError, })) vi.unmock('@/lib/auth/internal') import { InternalUnauthenticatedError } from '@/lib/api/server/routes' import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { internalSessionOrExecutorAuth } from '@/lib/workspace-files/api' afterAll(resetEnvMock) @@ -32,29 +33,15 @@ describe('internal file route authentication', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue(null) - mockBindDelegation.mockImplementation(async (delegation, options) => ({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: delegation.subjectUserId, + mockBindDelegationAdmission.mockImplementation(async (delegation) => ({ + principal: delegation.principal, workspaceId: 'canonical-workspace', - delegationId: delegation.delegationId, - audience: options.audience, - issuedAt: delegation.issuedAt, - expiresAt: delegation.expiresAt, - resourceScope: options.resourceScope, - delegationContext: { - kind: 'workflow_execution', - workflowId: delegation.workflowId, - executionId: delegation.executionId, - }, })) }) it('binds a scoped executor token without trusting the workspace route parameter', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', + principal: createTestRuntimePrincipal(), }) const principal = await internalSessionOrExecutorAuth.authenticate( @@ -65,22 +52,11 @@ describe('internal file route authentication', () => { ) expect(principal).toMatchObject({ - kind: 'delegated', - serviceId: 'executor', - subjectUserId: 'user-1', - workspaceId: 'canonical-workspace', - audience: 'sim:workspace-files', - resourceScope: { fileId: 'file-1' }, + kind: 'session', + executionMetadata: { executionId: 'execution-1' }, }) - expect(mockBindDelegation).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'workflow-1', - executionId: 'execution-1', - }), - { - audience: 'sim:workspace-files', - resourceScope: { fileId: 'file-1' }, - } + expect(mockBindDelegationAdmission).toHaveBeenCalledWith( + expect.objectContaining({ principal: expect.objectContaining({ kind: 'session' }) }) ) expect(mockGetSession).not.toHaveBeenCalled() }) @@ -96,15 +72,14 @@ describe('internal file route authentication', () => { { id: 'ws-1', fileId: 'file-1' } ) ).rejects.toBeInstanceOf(InternalUnauthenticatedError) - expect(mockBindDelegation).not.toHaveBeenCalled() + expect(mockBindDelegationAdmission).not.toHaveBeenCalled() }) it('rejects a scoped token whose canonical workflow binding no longer exists', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', + principal: createTestRuntimePrincipal(), }) - mockBindDelegation.mockRejectedValue(new MockInvalidBindingError()) + mockBindDelegationAdmission.mockRejectedValue(new MockInvalidBindingError()) await expect( internalSessionOrExecutorAuth.authenticate( @@ -118,11 +93,10 @@ describe('internal file route authentication', () => { it('does not render canonical-binding infrastructure failures as bad credentials', async () => { const token = await generateInternalDelegationToken({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', + principal: createTestRuntimePrincipal(), }) const infrastructureError = new Error('database unavailable') - mockBindDelegation.mockRejectedValue(infrastructureError) + mockBindDelegationAdmission.mockRejectedValue(infrastructureError) await expect( internalSessionOrExecutorAuth.authenticate( diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index c8b62bd6020..67b1bf0432f 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -5,25 +5,16 @@ import { v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { ArchiveError, statusForArchiveError } from '@/lib/uploads/archive' -import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' import { v2CaughtOrchestrationError, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' -export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ - audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, - resourceScope: (params) => { - const fileId = typeof params.fileId === 'string' ? params.fileId : undefined - return fileId ? { fileId } : undefined - }, -}) +export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth() /** * Generated-document serving authorizes the root file and every referenced input. Its executor * Principal is therefore intentionally workspace-scoped; a file-scoped Principal could authorize * the root or one dependency, but never the full dependency graph. */ -export const internalWorkspaceFileServeAuth = createInternalSessionOrExecutorAuth({ - audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, -}) +export const internalWorkspaceFileServeAuth = createInternalSessionOrExecutorAuth() export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, diff --git a/apps/sim/lib/workspace-files/application/authorization.test.ts b/apps/sim/lib/workspace-files/application/authorization.test.ts index 1ae557d8895..538ec4f1ce1 100644 --- a/apps/sim/lib/workspace-files/application/authorization.test.ts +++ b/apps/sim/lib/workspace-files/application/authorization.test.ts @@ -3,6 +3,7 @@ */ import type { Principal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const resolvePermission = vi.hoisted(() => vi.fn()) @@ -126,18 +127,8 @@ describe('file operation authorization', () => { ) }) - it('admits executor delegation only for explicitly declared file-tool operations', async () => { - const principal = { - kind: 'delegated' as const, - serviceId: 'executor' as const, - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'delegation-1', - audience: 'sim:workspace-files', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - resourceScope: { fileId: 'file-1', executionId: 'execution-1' }, - } + it('admits workflow execution only for explicitly declared file-tool operations', async () => { + const principal = createTestRuntimePrincipal() await authorizeWorkspaceFileAccess( principal, @@ -151,7 +142,7 @@ describe('file operation authorization', () => { authorizeWorkspaceFileAccess(principal, fileOperations.rename, authorizationContext) ).rejects.toMatchObject>({ code: 'forbidden', - message: 'Delegated service executor cannot perform operation files.rename', + message: 'Principal kind session cannot perform operation files.rename', }) expect(resolvePermission).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts index dd9135a101f..3baec64cad4 100644 --- a/apps/sim/lib/workspace-files/application/operations.test.ts +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -36,9 +36,9 @@ describe('file operation registry', () => { expect(new Set(ids).size).toBe(ids.length) }) - it('allows executor delegation only for operations used by the internal file tool', () => { + it('allows workflow execution only for operations used by the internal file tool', () => { const executorOperationIds = Object.values(fileOperations) - .filter((operation) => operation.delegatedServices?.includes('executor')) + .filter((operation) => operation.workflowExecution === 'allow') .map((operation) => operation.id) expect(executorOperationIds).toEqual([ @@ -62,7 +62,8 @@ describe('file operation registry', () => { 'personal_api_key', 'delegated', ]) - expect(fileOperations.updateShare.delegatedServices).toEqual(['copilot', 'executor']) + expect(fileOperations.updateShare.delegatedServices).toEqual(['copilot']) + expect(fileOperations.updateShare.workflowExecution).toBe('allow') }) it('keeps resumable workspace-file uploads on credential-bound principals', () => { diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index 9c4c6174435..ed89f8ab602 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -6,11 +6,13 @@ const ALL_COPILOT_PRINCIPAL_POLICY = { } as const const ALL_FILE_TOOL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const const HUMAN_FILE_TOOL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'delegated'], - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', } as const const UPLOAD_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts index d635047deb8..3f2db1d97df 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const mocks = vi.hoisted(() => ({ fetchContent: vi.fn(), @@ -117,34 +118,22 @@ describe('readWorkspaceFileContentByKey', () => { expect(mocks.fetchContent).not.toHaveBeenCalled() }) - it('authorizes an actorless deployment executor by its preserved workflow authority', async () => { + it('authorizes an actorless deployment by its preserved workflow authority', async () => { await expect( readWorkspaceFileRecordByKey.execute({ - principal: { - kind: 'delegated', - serviceId: 'executor', - workspaceId: file.workspaceId, - delegationId: 'execution-file-read:request-1', - audience: 'sim:workspace-files', - issuedAt: new Date(Date.now() - 1_000), - expiresAt: new Date(Date.now() + 60_000), - delegationContext: { - kind: 'workflow_execution', + principal: createTestRuntimePrincipal({ + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: file.workspaceId, workflowId: 'workflow-1', - executionId: 'execution-1', - principal: { - kind: 'system', - serviceId: 'schedule', - workspaceId: file.workspaceId, - workflowId: 'workflow-1', - }, - currentWorkflow: { - workflowId: 'workflow-1', - mode: 'deployment', - deploymentVersionId: 'deployment-1', - }, }, - }, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + }), input: { key: file.key, assertedWorkspaceId: file.workspaceId }, }) ).resolves.toEqual({ file }) diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index c7dc6067af7..edcbbe08585 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1,5 +1,6 @@ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const workflowMetadataMocks = vi.hoisted(() => ({ readWorkflowInputFieldsForTool: vi.fn(), @@ -1923,6 +1924,14 @@ describe('prepareToolExecution invoker identity hand-off', () => { }) describe('workflow executor metadata delegation', () => { + const parentPrincipal = createTestRuntimePrincipal({ + executionId: 'execution-1', + rootWorkflowId: 'parent-workflow', + }) + const currentPrincipal = createTestRuntimePrincipal({ + executionId: 'execution-1', + rootWorkflowId: 'current-workflow', + }) const workflowBlock = { type: 'workflow', name: 'Workflow', @@ -1967,13 +1976,7 @@ describe('workflow executor metadata delegation', () => { workspaceId: 'workspace-1', executionId: 'execution-1', userId: 'user-1', - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'parent-workflow', - executionId: 'execution-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, - }, + principal: parentPrincipal, }, readWorkflowMetadata: workflowMetadataMocks.readWorkflowMetadataForTool, } @@ -1986,13 +1989,7 @@ describe('workflow executor metadata delegation', () => { workflowId: 'parent-workflow', workspaceId: 'workspace-1', executionId: 'execution-1', - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'parent-workflow', - executionId: 'execution-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, - }, + principal: parentPrincipal, } ) expect(result).toMatchObject({ @@ -2017,13 +2014,7 @@ describe('workflow executor metadata delegation', () => { workspaceId: 'workspace-1', executionId: 'execution-1', userId: 'user-1', - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'current-workflow', - executionId: 'execution-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'current-workflow', mode: 'draft' }, - }, + principal: currentPrincipal, }, readWorkflowMetadata: workflowMetadataMocks.readWorkflowMetadataForTool, } @@ -2036,13 +2027,7 @@ describe('workflow executor metadata delegation', () => { workflowId: 'current-workflow', workspaceId: 'workspace-1', executionId: 'execution-1', - executorDelegationOrigin: { - subjectUserId: 'user-1', - workflowId: 'current-workflow', - executionId: 'execution-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'current-workflow', mode: 'draft' }, - }, + principal: currentPrincipal, } ) }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index d838e0ef217..d1e8edac5d2 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -89,7 +89,7 @@ async function fetchWorkflowMetadata( ) => Promise<{ name: string; description: string | null }> ): Promise<{ name: string; description: string | null } | null> { try { - if (!executionContext?.executorDelegationOrigin || !readWorkflowMetadata) { + if (!executionContext?.principal?.executionMetadata || !readWorkflowMetadata) { throw new Error('Workflow metadata enrichment requires trusted execution authority') } return await readWorkflowMetadata(workflowId, executionContext) diff --git a/apps/sim/tools/file/search.test.ts b/apps/sim/tools/file/search.test.ts index 5e8718a3e84..6835aaf6381 100644 --- a/apps/sim/tools/file/search.test.ts +++ b/apps/sim/tools/file/search.test.ts @@ -3,12 +3,13 @@ import { fileOperations } from '@/lib/workspace-files/application/operations' import { fileSearchTool } from '@/tools/file/search' describe('fileSearchTool', () => { - it('uses the shared protected read operation and admits executor delegation', () => { + it('uses the shared protected read operation and admits workflow execution', () => { expect(fileOperations.searchContent).toMatchObject({ id: 'files.search_content', minimumRole: 'read', workspaceApiKey: 'allow', - delegatedServices: ['copilot', 'executor'], + delegatedServices: ['copilot'], + workflowExecution: 'allow', }) }) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 4a820ca84c2..550bb004bb5 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -26,6 +26,7 @@ import { } from '@sim/testing' import { DrizzleQueryError } from 'drizzle-orm/errors' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { executeBitbucketTool } from '@/lib/internal/bitbucket/execute-tool' @@ -493,30 +494,12 @@ beforeEach(() => { } ) ) - mockCreateExecutorPrincipalFromExecutionContext.mockImplementation( - async ({ context, audience, resourceScope }) => { - const origin = context.executorDelegationOrigin - if (!origin) throw new Error('Executor delegation origin is required') - return { - kind: 'delegated' as const, - serviceId: 'executor', - ...(origin.subjectUserId ? { subjectUserId: origin.subjectUserId } : {}), - workspaceId: context.workspaceId, - delegationId: 'test-executor-delegation', - audience, - issuedAt: new Date('2026-01-01T00:00:00.000Z'), - expiresAt: new Date('2026-01-01T00:05:00.000Z'), - ...(resourceScope ? { resourceScope } : {}), - delegationContext: { - kind: 'workflow_execution' as const, - workflowId: origin.workflowId, - ...(origin.executionId ? { executionId: origin.executionId } : {}), - ...(origin.principal ? { principal: origin.principal } : {}), - ...(origin.currentWorkflow ? { currentWorkflow: origin.currentWorkflow } : {}), - }, - } + mockCreateExecutorPrincipalFromExecutionContext.mockImplementation(async ({ context }) => { + if (!context.principal?.executionMetadata) { + throw new Error('Workflow execution principal is required') } - ) + return context.principal + }) // Suites below call vi.resetAllMocks(), which wipes the shared env/urls mock // implementations — restore their defaults and re-pin the base URL each test. resetEnvMock() @@ -583,27 +566,25 @@ function createToolExecutionContext(overrides?: Partial): Exec metadata: overrides?.metadata, environmentVariables: overrides?.environmentVariables, }) - const principal = + const basePrincipal = overrides?.principal ?? (overrides?.userId ? { kind: 'session' as const, userId: overrides.userId, sessionId: 'test-session' } : undefined) - const executorDelegationOrigin = - overrides?.executorDelegationOrigin ?? - (principal - ? { - subjectUserId: overrides?.userId, - workflowId: overrides?.workflowId ?? ctx.workflowId, - executionId: overrides?.executionId ?? ctx.executionId, - principal, - } - : undefined) + const principal = basePrincipal + ? basePrincipal.executionMetadata + ? basePrincipal + : createTestRuntimePrincipal({ + principal: basePrincipal, + executionId: overrides?.executionId ?? ctx.executionId ?? 'execution-1', + rootWorkflowId: overrides?.workflowId ?? ctx.workflowId, + }) + : undefined return { ...ctx, workspaceId: 'workspace-456', - principal, - executorDelegationOrigin, ...overrides, + principal, metadata: { ...ctx.metadata, ...overrides?.metadata, @@ -1169,22 +1150,20 @@ describe('executeTool Function', () => { mockExecuteInternalToolOperation.mockResolvedValueOnce( Response.json({ success: true, output: { ok: true } }) ) - const principal = { - kind: 'system' as const, - serviceId: 'schedule' as const, - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - } - const executorDelegationOrigin = { - workflowId: 'workflow-1', + const principal = createTestRuntimePrincipal({ + principal: { + kind: 'system' as const, + serviceId: 'schedule' as const, + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + }, executionId: 'execution-1', currentWorkflow: { workflowId: 'workflow-1', mode: 'deployment' as const, deploymentVersionId: 'deployment-version-1', }, - principal, - } + }) try { const result = await executeTool( @@ -1197,7 +1176,6 @@ describe('executeTool Function', () => { workspaceId: 'workspace-1', executionId: 'execution-1', principal, - executorDelegationOrigin, }), } ) @@ -1206,7 +1184,7 @@ describe('executeTool Function', () => { expect(mockExecuteInternalToolOperation).toHaveBeenCalledWith( expect.objectContaining({ context: expect.objectContaining({ - executorDelegationOrigin, + principal, }), }) ) @@ -4500,21 +4478,21 @@ describe('Managed OAuth Credential Delegation', () => { ) global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch - const executorDelegationOrigin = { - subjectUserId: 'origin-user', - workflowId: 'origin-workflow', + const principal = createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'origin-user', sessionId: 'origin-session' }, executionId: 'origin-execution', + rootWorkflowId: 'origin-workflow', currentWorkflow: { workflowId: 'current-workflow', mode: 'deployment' as const, deploymentVersionId: 'deployment-version-1', }, - } + }) const context = createToolExecutionContext({ userId: 'current-user', workflowId: 'current-workflow', executionId: 'current-execution', - executorDelegationOrigin, + principal, }) await executeTool( @@ -4528,7 +4506,7 @@ describe('Managed OAuth Credential Delegation', () => { credentialId: 'managed-credential-id', toolId: 'gmail_read', scopes: ['https://www.googleapis.com/auth/gmail.readonly'], - executorDelegationOrigin, + principal, }) ) expect( @@ -4547,21 +4525,11 @@ describe('Managed OAuth Credential Delegation', () => { userId: 'current-user', workflowId: 'current-workflow', executionId: 'current-execution', - principal: { - kind: 'session', - userId: 'current-user', - sessionId: 'session-1', - }, - executorDelegationOrigin: { - subjectUserId: 'current-user', - workflowId: 'current-workflow', + principal: createTestRuntimePrincipal({ + principal: { kind: 'session', userId: 'current-user', sessionId: 'session-1' }, executionId: 'current-execution', - principal: { - kind: 'session', - userId: 'current-user', - sessionId: 'session-1', - }, - }, + rootWorkflowId: 'current-workflow', + }), }) const result = await executeTool( diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 1bdb25c257a..ca74f0b60c6 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -218,7 +218,7 @@ function createInternalToolOperationContext( workspaceId: context.workspaceId, executionId: context.executionId, userId: context.userId, - executorDelegationOrigin: context.executorDelegationOrigin, + principal: context.principal, copilotToolExecution: context.copilotToolExecution, billingAttribution: context.metadata.billingAttribution, callChain: context.callChain, @@ -1860,7 +1860,7 @@ async function executeToolImplementation( scopes: providerScopes, impersonateEmail, enforceCredentialAccess, - executorDelegationOrigin: executionContext?.executorDelegationOrigin, + principal: executionContext?.principal, }) } else { data = await fetchCredentialTokenFromRoute({ @@ -1964,7 +1964,6 @@ async function executeToolImplementation( { abortSignal: effectiveSignal, resolvedSecretTraceRegistry, - executorDelegationOrigin: executionContext?.executorDelegationOrigin, principal: executionContext?.principal, // Trusted `executionContext`, never `_context` — that bag spreads // model-reachable `contextParams._context` first, so a model could otherwise @@ -2505,7 +2504,7 @@ async function executeDeclaredInternalOperation({ }: ExecuteDeclaredInternalOperationInput): Promise { if ( !context?.workspaceId || - (!context.executorDelegationOrigin && !context.userId && !context.copilotToolExecution) + (!context.principal && !context.userId && !context.copilotToolExecution) ) { throw new Error('Internal tool execution requires trusted execution scope') } diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index 43b31ee325b..3c327c696dd 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutorDelegationOrigin } from '@/executor/types' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' import { mergeToolParameters } from '@/tools/merge-params' import * as toolMetadata from '@/tools/metadata' import { @@ -723,13 +723,10 @@ describe('Tool Parameters Utils', () => { describe('createLLMToolSchema - child workflow input enrichment', () => { const mockReadWorkflowInputFields = vi.fn() - const executorDelegationOrigin: ExecutorDelegationOrigin = { - subjectUserId: 'user-1', - workflowId: 'parent-workflow', + const principal = createTestRuntimePrincipal({ executionId: 'execution-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - currentWorkflow: { workflowId: 'parent-workflow', mode: 'draft' }, - } + rootWorkflowId: 'parent-workflow', + }) beforeEach(() => { mockReadWorkflowInputFields.mockReset() @@ -748,7 +745,7 @@ describe('Tool Parameters Utils', () => { workflowId: 'parent-workflow', executionId: 'execution-1', workspaceId: 'workspace-1', - executorDelegationOrigin, + principal, }, mockReadWorkflowInputFields ) @@ -758,7 +755,7 @@ describe('Tool Parameters Utils', () => { workflowId: 'parent-workflow', executionId: 'execution-1', workspaceId: 'workspace-1', - executorDelegationOrigin, + principal, }) expect(schema.properties.inputMapping.properties).toEqual({ email: { type: 'string', description: 'Recipient address' }, @@ -775,7 +772,7 @@ describe('Tool Parameters Utils', () => { userId: 'user-1', workflowId: 'parent-workflow', executionId: 'execution-1', - executorDelegationOrigin, + principal, }, mockReadWorkflowInputFields ) @@ -784,7 +781,7 @@ describe('Tool Parameters Utils', () => { userId: 'user-1', workflowId: 'parent-workflow', executionId: 'execution-1', - executorDelegationOrigin, + principal, }) }) @@ -809,7 +806,7 @@ describe('Tool Parameters Utils', () => { { userId: 'user-1', workflowId: 'parent-workflow', - executorDelegationOrigin, + principal, }, mockReadWorkflowInputFields ) @@ -817,7 +814,7 @@ describe('Tool Parameters Utils', () => { expect(mockReadWorkflowInputFields).toHaveBeenCalledWith('child-workflow', { userId: 'user-1', workflowId: 'parent-workflow', - executorDelegationOrigin, + principal, }) expect(schema.properties.inputMapping.properties).toBeUndefined() }) diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 6575f68749f..a904017aec7 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -841,7 +841,7 @@ async function fetchWorkflowInputFields( readWorkflowInputFields?: WorkflowInputFieldsReader ): Promise> { try { - if (!context.executorDelegationOrigin || !readWorkflowInputFields) { + if (!context.principal?.executionMetadata || !readWorkflowInputFields) { throw new Error('Workflow input enrichment requires trusted execution authority') } return await readWorkflowInputFields(workflowId, context) diff --git a/apps/sim/tools/schema-enrichers.test.ts b/apps/sim/tools/schema-enrichers.test.ts index a708f7dfd66..6eba2eb4993 100644 --- a/apps/sim/tools/schema-enrichers.test.ts +++ b/apps/sim/tools/schema-enrichers.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestRuntimePrincipal } from '@/lib/auth/runtime-principal.test-support' const { mockListKnowledgeTagsAsExecutor, mockReadTableSchemaAsExecutor } = vi.hoisted(() => ({ mockListKnowledgeTagsAsExecutor: vi.fn(), @@ -40,11 +41,10 @@ const V2_SCHEMA = { required: [], } -const EXECUTOR_ORIGIN = { - subjectUserId: 'user-1', - workflowId: 'workflow-1', +const EXECUTION_PRINCIPAL = createTestRuntimePrincipal({ executionId: 'execution-1', -} + rootWorkflowId: 'workflow-1', +}) describe('enrichTableToolSchema', () => { beforeEach(() => { @@ -69,7 +69,7 @@ describe('enrichTableToolSchema', () => { userId: 'user-1', workflowId: 'workflow-1', executionId: 'execution-1', - executorDelegationOrigin: EXECUTOR_ORIGIN, + principal: EXECUTION_PRINCIPAL, } ) @@ -80,7 +80,7 @@ describe('enrichTableToolSchema', () => { userId: 'user-1', workflowId: 'workflow-1', executionId: 'execution-1', - executorDelegationOrigin: EXECUTOR_ORIGIN, + principal: EXECUTION_PRINCIPAL, }, }) expect(result.description).toContain('Table "Customers" columns:') @@ -98,7 +98,7 @@ describe('enrichTableToolSchema', () => { workspaceId: 'workspace-1', userId: 'user-1', workflowId: 'workflow-1', - executorDelegationOrigin: EXECUTOR_ORIGIN, + principal: EXECUTION_PRINCIPAL, }) ).rejects.toThrow('Table not found') }) @@ -131,7 +131,7 @@ describe('enrichTableToolSchema', () => { userId: 'user-1', workflowId: 'workflow-1', executionId: 'execution-1', - executorDelegationOrigin: EXECUTOR_ORIGIN, + principal: EXECUTION_PRINCIPAL, } ) @@ -163,7 +163,7 @@ describe('enrichKBTagsSchema', () => { workspaceId: 'workspace-1', workflowId: 'workflow-1', executionId: 'execution-1', - executorDelegationOrigin: EXECUTOR_ORIGIN, + principal: EXECUTION_PRINCIPAL, }) expect(mockListKnowledgeTagsAsExecutor).toHaveBeenCalledWith({ @@ -174,7 +174,7 @@ describe('enrichKBTagsSchema', () => { userId: 'user-1', workflowId: 'workflow-1', executionId: 'execution-1', - executorDelegationOrigin: EXECUTOR_ORIGIN, + principal: EXECUTION_PRINCIPAL, }, }) expect(result?.properties).toEqual({ Client: { type: 'string', description: 'text tag' } }) @@ -187,7 +187,7 @@ describe('enrichKBTagsSchema', () => { userId: 'user-1', workspaceId: 'workspace-1', workflowId: 'workflow-1', - executorDelegationOrigin: EXECUTOR_ORIGIN, + principal: EXECUTION_PRINCIPAL, }) expect(mockListKnowledgeTagsAsExecutor).toHaveBeenCalledWith({ @@ -197,7 +197,7 @@ describe('enrichKBTagsSchema', () => { workspaceId: 'workspace-1', userId: 'user-1', workflowId: 'workflow-1', - executorDelegationOrigin: EXECUTOR_ORIGIN, + principal: EXECUTION_PRINCIPAL, }, }) }) @@ -206,14 +206,14 @@ describe('enrichKBTagsSchema', () => { ['no execution authority', { workspaceId: 'workspace-1', workflowId: 'workflow-1' }], [ 'no acting workflow to bind the delegation on', - { workspaceId: 'workspace-1', userId: 'user-1', executorDelegationOrigin: EXECUTOR_ORIGIN }, + { workspaceId: 'workspace-1', userId: 'user-1', principal: EXECUTION_PRINCIPAL }, ], [ 'no acting workspace', { userId: 'user-1', workflowId: 'workflow-1', - executorDelegationOrigin: EXECUTOR_ORIGIN, + principal: EXECUTION_PRINCIPAL, }, ], ])('skips enrichment with %s rather than issuing an unauthorized read', async (_, context) => { diff --git a/apps/sim/tools/schema-enrichers.ts b/apps/sim/tools/schema-enrichers.ts index a3cf05bb6e8..a74d6ee035d 100644 --- a/apps/sim/tools/schema-enrichers.ts +++ b/apps/sim/tools/schema-enrichers.ts @@ -13,7 +13,7 @@ async function fetchTableSchema( if (!context.workflowId) { throw new Error(`Workflow ID is required to enrich table tool schema for ${tableId}`) } - if (!context.executorDelegationOrigin) { + if (!context.principal?.executionMetadata) { throw new Error(`Execution authority is required to enrich table tool schema for ${tableId}`) } @@ -25,7 +25,7 @@ async function fetchTableSchema( workspaceId: context.workspaceId, executionId: context.executionId, userId: context.userId, - executorDelegationOrigin: context.executorDelegationOrigin, + principal: context.principal, }, }) } @@ -94,7 +94,7 @@ async function fetchTagDefinitions( knowledgeBaseId: string, context: WorkflowToolExecutionContext ): Promise { - if (!context.executorDelegationOrigin) { + if (!context.principal?.executionMetadata) { logger.warn( `Skipping tag definition enrichment for KB ${knowledgeBaseId}: no execution authority` ) @@ -119,7 +119,7 @@ async function fetchTagDefinitions( workspaceId: context.workspaceId, executionId: context.executionId, userId: context.userId, - executorDelegationOrigin: context.executorDelegationOrigin, + principal: context.principal, }, }) logger.info(`Found ${tagDefinitions.length} tag definitions for KB ${knowledgeBaseId}`) diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index d12694cefa7..62ab41f8ad4 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -3,7 +3,6 @@ import type { HostedKeyRateLimitConfig } from '@/lib/core/rate-limiter' import type { HttpRedirectPolicy } from '@/lib/core/security/http-redirect-policy' import type { PrivateSecretProvenanceSelection } from '@/lib/execution/model-input-provenance' import type { OAuthService } from '@/lib/oauth' -import type { ExecutorDelegationOrigin } from '@/executor/types' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' export type BYOKProviderId = @@ -56,7 +55,7 @@ export type WorkflowToolExecutionContext = { workflowId?: string executionId?: string userId?: string - executorDelegationOrigin?: ExecutorDelegationOrigin + principal?: WorkflowExecutionPrincipal } export type OutputType = @@ -481,3 +480,5 @@ export type ExecutableToolConfig

= ToolConfig | Internal export function isInternalToolConfig(tool: ExecutableToolConfig): tool is InternalToolConfig { return tool.operation !== undefined } + +import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' diff --git a/apps/sim/tools/utils.server.ts b/apps/sim/tools/utils.server.ts index d836a00e01d..e29c197117a 100644 --- a/apps/sim/tools/utils.server.ts +++ b/apps/sim/tools/utils.server.ts @@ -74,7 +74,7 @@ async function fetchCustomToolFromDB( if ( (!executionContext || - (!executionContext.userId && !executionContext.executorDelegationOrigin?.subjectUserId)) && + (!executionContext.userId && !executionContext.principal?.executionMetadata)) && !operationContext?.copilotToolExecution ) { throw new Error(`Cannot fetch custom tool without userId: ${identifier}`) diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index e47e9dd9262..f2d04193097 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -1,3 +1,22 @@ +export type WorkflowExecutionAuthority = + | { workflowId: string; mode: 'draft' } + | { workflowId: string; mode: 'deployment'; deploymentVersionId: string } + +export interface PrincipalExecutionMetadata { + executionId: string + rootWorkflowId: string + currentWorkflow: WorkflowExecutionAuthority +} + +interface PrincipalRuntimeMetadata { + executionMetadata?: PrincipalExecutionMetadata + /** Legacy user attribution for operations whose persisted model still requires one. */ + executionActor?: { + kind: 'legacy_execution_user' + userId: string + } +} + export type Principal = | SessionPrincipal | PersonalApiKeyPrincipal @@ -6,19 +25,19 @@ export type Principal = | SystemPrincipal | CredentialGroupEnrollmentPrincipal -export interface SessionPrincipal { +export interface SessionPrincipal extends PrincipalRuntimeMetadata { kind: 'session' userId: string sessionId: string } -export interface PersonalApiKeyPrincipal { +export interface PersonalApiKeyPrincipal extends PrincipalRuntimeMetadata { kind: 'personal_api_key' userId: string keyId: string } -export interface WorkspaceApiKeyPrincipal { +export interface WorkspaceApiKeyPrincipal extends PrincipalRuntimeMetadata { kind: 'workspace_api_key' workspaceId: string keyId: string @@ -31,14 +50,14 @@ export interface ExternalUserSubject { subjectId: string } -interface ActorlessSystemPrincipal { +interface ActorlessSystemPrincipal extends PrincipalRuntimeMetadata { kind: 'system' serviceId: 'public_api' | 'schedule' | 'internal' | 'table' | 'chat' workspaceId: string workflowId: string } -export interface WebhookSystemPrincipal { +export interface WebhookSystemPrincipal extends PrincipalRuntimeMetadata { kind: 'system' serviceId: 'webhook' workspaceId: string @@ -50,7 +69,7 @@ export interface WebhookSystemPrincipal { export type SystemPrincipal = ActorlessSystemPrincipal | WebhookSystemPrincipal -interface DelegatedPrincipalBase { +interface DelegatedPrincipalBase extends PrincipalRuntimeMetadata { kind: 'delegated' workspaceId: string delegationId: string @@ -72,42 +91,10 @@ export interface SubjectDelegatedPrincipal extends DelegatedPrincipalBase { subjectUserId: string } -export interface WorkflowExecutionDelegationContext { - kind: 'workflow_execution' - workflowId: string - executionId?: string - principal?: WorkflowExecutionPrincipal - currentWorkflow?: WorkflowExecutionAuthority - /** - * The trusted Sim user ID legacy executor routes ran as before principal wiring. - * - * This is compatibility policy, not the authenticated subject: workspace - * authorization and audit identity continue to use the principal itself. - */ - compatibilityActor?: { - kind: 'legacy_execution_user' - userId: string - } -} - -export type WorkflowExecutionAuthority = - | { workflowId: string; mode: 'draft' } - | { workflowId: string; mode: 'deployment'; deploymentVersionId: string } - -export interface WorkflowExecutionDelegatedPrincipal extends DelegatedPrincipalBase { - serviceId: 'executor' - subjectUserId?: string - delegationContext?: WorkflowExecutionDelegationContext -} - -export type BoundWorkflowExecutionDelegatedPrincipal = WorkflowExecutionDelegatedPrincipal & { - delegationContext: WorkflowExecutionDelegationContext -} - -export type DelegatedPrincipal = SubjectDelegatedPrincipal | WorkflowExecutionDelegatedPrincipal +export type DelegatedPrincipal = SubjectDelegatedPrincipal /** Bearer identity established by a currently valid Credential Group invitation. */ -export interface CredentialGroupEnrollmentPrincipal { +export interface CredentialGroupEnrollmentPrincipal extends PrincipalRuntimeMetadata { kind: 'credential_group_enrollment' workspaceId: string credentialGroupId: string @@ -118,6 +105,17 @@ export interface CredentialGroupEnrollmentPrincipal { export type DelegatedServiceId = DelegatedPrincipal['serviceId'] +export type WorkflowExecutionPrincipal = + | SessionPrincipal + | PersonalApiKeyPrincipal + | WorkspaceApiKeyPrincipal + | SubjectDelegatedPrincipal + | SystemPrincipal + +export type BoundWorkflowExecutionPrincipal = WorkflowExecutionPrincipal & { + executionMetadata: PrincipalExecutionMetadata +} + export class PrincipalSubjectUserRequiredError extends Error { constructor(principalKind: Principal['kind']) { super(`Principal kind ${principalKind} does not represent a human subject`) @@ -158,24 +156,23 @@ export function requirePrincipalSubjectUserId(principal: Principal): string { export function resolvePrincipalExecutionActorUserId(principal: Principal): string | undefined { const subjectUserId = resolvePrincipalSubjectUserId(principal) if (subjectUserId) return subjectUserId - if (principal.kind !== 'delegated' || principal.serviceId !== 'executor') return undefined - if (principal.delegationContext?.currentWorkflow?.mode !== 'deployment') return undefined - return principal.delegationContext?.compatibilityActor?.userId + if (principal.executionMetadata?.currentWorkflow.mode !== 'deployment') return undefined + return principal.executionActor?.userId } -export type WorkflowExecutionPrincipal = - | SessionPrincipal - | PersonalApiKeyPrincipal - | WorkspaceApiKeyPrincipal - | SubjectDelegatedPrincipal - | SystemPrincipal +type WithoutPrincipalRuntimeMetadata = T extends unknown + ? Omit + : never type SerializedWorkflowExecutionPrincipal = - | SessionPrincipal - | PersonalApiKeyPrincipal - | WorkspaceApiKeyPrincipal - | SystemPrincipal - | (Omit & { + | WithoutPrincipalRuntimeMetadata + | WithoutPrincipalRuntimeMetadata + | WithoutPrincipalRuntimeMetadata + | WithoutPrincipalRuntimeMetadata + | (Omit< + SubjectDelegatedPrincipal, + 'executionMetadata' | 'executionActor' | 'issuedAt' | 'expiresAt' + > & { issuedAt: string expiresAt: string }) @@ -185,6 +182,14 @@ export interface SerializedPrincipalV1 { principal: SerializedWorkflowExecutionPrincipal } +export interface SerializedPrincipalV2 { + version: 2 + principal: SerializedWorkflowExecutionPrincipal + executionMetadata: PrincipalExecutionMetadata +} + +export type SerializedPrincipal = SerializedPrincipalV1 | SerializedPrincipalV2 + function requireRecord(value: unknown, field: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${field} must be an object`) @@ -254,62 +259,214 @@ function parseExternalUserSubject(value: unknown): ExternalUserSubject { } } -/** Encodes a workflow caller without persisting bearer credentials or invitation proofs. */ -export function serializePrincipal(principal: WorkflowExecutionPrincipal): SerializedPrincipalV1 { +export function parseWorkflowExecutionAuthority(value: unknown): WorkflowExecutionAuthority { + const authority = requireRecord(value, 'Principal execution metadata currentWorkflow') + const workflowId = requireString(authority.workflowId, 'currentWorkflow.workflowId') + if (authority.mode === 'draft') { + requireExactKeys(authority, ['workflowId', 'mode']) + return { workflowId, mode: 'draft' } + } + if (authority.mode === 'deployment') { + requireExactKeys(authority, ['workflowId', 'mode', 'deploymentVersionId']) + return { + workflowId, + mode: 'deployment', + deploymentVersionId: requireString( + authority.deploymentVersionId, + 'currentWorkflow.deploymentVersionId' + ), + } + } + throw new Error(`Unsupported workflow execution mode ${String(authority.mode)}`) +} + +export function parsePrincipalExecutionMetadata(value: unknown): PrincipalExecutionMetadata { + const metadata = requireRecord(value, 'Principal execution metadata') + requireExactKeys(metadata, ['executionId', 'rootWorkflowId', 'currentWorkflow']) + return { + executionId: requireString(metadata.executionId, 'executionMetadata.executionId'), + rootWorkflowId: requireString(metadata.rootWorkflowId, 'executionMetadata.rootWorkflowId'), + currentWorkflow: parseWorkflowExecutionAuthority(metadata.currentWorkflow), + } +} + +/** Starts a root workflow run without changing the authenticated actor identity. */ +export function bindPrincipalExecutionMetadata( + principal: WorkflowExecutionPrincipal, + metadata: PrincipalExecutionMetadata +): BoundWorkflowExecutionPrincipal { + if (principal.executionMetadata !== undefined) { + throw new Error('Workflow execution principal is already bound to an execution') + } + const parsed = parsePrincipalExecutionMetadata(metadata) + if (parsed.currentWorkflow.workflowId !== parsed.rootWorkflowId) { + throw new Error('Root workflow execution authority must name the root workflow') + } + return { ...principal, executionMetadata: parsed } +} + +/** Enters a regular child workflow while preserving the root run and actor. */ +export function enterPrincipalWorkflowExecution( + principal: WorkflowExecutionPrincipal, + currentWorkflow: WorkflowExecutionAuthority +): BoundWorkflowExecutionPrincipal { + const executionMetadata = requirePrincipalExecutionMetadata(principal) + return { + ...principal, + executionMetadata: { + ...executionMetadata, + currentWorkflow: parseWorkflowExecutionAuthority(currentWorkflow), + }, + } +} + +/** Returns strict execution metadata or fails at the application boundary. */ +export function requirePrincipalExecutionMetadata( + principal: Principal +): PrincipalExecutionMetadata { + if (principal.executionMetadata === undefined) { + throw new Error('Workflow execution principal is missing execution metadata') + } + return parsePrincipalExecutionMetadata(principal.executionMetadata) +} + +/** Adds legacy user attribution without changing who the principal represents. */ +export function withPrincipalExecutionActor( + principal: BoundWorkflowExecutionPrincipal, + userId: string +): BoundWorkflowExecutionPrincipal { + if (!userId.trim()) throw new Error('Workflow execution actor must not be empty') + if (resolvePrincipalSubjectUserId(principal) !== undefined) { + throw new Error('Workflow execution actor is only valid without a Sim user subject') + } + return { + ...principal, + executionActor: { kind: 'legacy_execution_user', userId }, + } +} + +function serializeWorkflowExecutionPrincipal( + principal: WorkflowExecutionPrincipal +): SerializedWorkflowExecutionPrincipal { switch (principal.kind) { case 'session': + return { kind: principal.kind, userId: principal.userId, sessionId: principal.sessionId } case 'personal_api_key': + return { kind: principal.kind, userId: principal.userId, keyId: principal.keyId } case 'workspace_api_key': - return { version: 1, principal: { ...principal } } + return { kind: principal.kind, workspaceId: principal.workspaceId, keyId: principal.keyId } case 'system': if (principal.serviceId === 'webhook') { if (principal.subject && principal.subject.provider !== principal.provider) { throw new Error('Webhook system principal subject provider must match its provider') } + return { + kind: principal.kind, + serviceId: principal.serviceId, + workspaceId: principal.workspaceId, + workflowId: principal.workflowId, + webhookId: principal.webhookId, + provider: principal.provider, + ...(principal.subject ? { subject: principal.subject } : {}), + } + } + return { + kind: principal.kind, + serviceId: principal.serviceId, + workspaceId: principal.workspaceId, + workflowId: principal.workflowId, } - return { version: 1, principal: { ...principal } } case 'delegated': return { - version: 1, - principal: { - ...principal, - issuedAt: principal.issuedAt.toISOString(), - expiresAt: principal.expiresAt.toISOString(), - }, + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + workspaceId: principal.workspaceId, + delegationId: principal.delegationId, + audience: principal.audience, + issuedAt: principal.issuedAt.toISOString(), + expiresAt: principal.expiresAt.toISOString(), + ...(principal.resourceScope ? { resourceScope: { ...principal.resourceScope } } : {}), } } } +/** Encodes a workflow caller without persisting bearer credentials or invitation proofs. */ +export function serializePrincipal( + principal: WorkflowExecutionPrincipal, + expectedVersion: 1 +): SerializedPrincipalV1 +export function serializePrincipal( + principal: WorkflowExecutionPrincipal, + expectedVersion: 2 +): SerializedPrincipalV2 +export function serializePrincipal(principal: WorkflowExecutionPrincipal): SerializedPrincipal +export function serializePrincipal( + principal: WorkflowExecutionPrincipal, + expectedVersion?: 1 | 2 +): SerializedPrincipal { + if (principal.executionActor !== undefined) { + throw new Error('Workflow execution compatibility attribution cannot be serialized') + } + const serialized = serializeWorkflowExecutionPrincipal(principal) + if (principal.executionMetadata === undefined) { + if (expectedVersion === 2) { + throw new Error('Serialized principal version 2 requires execution metadata') + } + return { version: 1, principal: serialized } + } + if (expectedVersion === 1) { + throw new Error('Serialized principal version 1 cannot carry execution metadata') + } + return { + version: 2, + principal: serialized, + executionMetadata: parsePrincipalExecutionMetadata(principal.executionMetadata), + } +} + /** Strictly validates a persisted workflow caller and restores delegated-principal dates. */ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { const envelope = requireRecord(value, 'Serialized principal') - requireExactKeys(envelope, ['version', 'principal']) - if (envelope.version !== 1) throw new Error('Unsupported serialized principal version') + if (envelope.version === 1) { + requireExactKeys(envelope, ['version', 'principal']) + } else if (envelope.version === 2) { + requireExactKeys(envelope, ['version', 'principal', 'executionMetadata']) + } else { + throw new Error('Unsupported serialized principal version') + } + + const executionMetadata = + envelope.version === 2 ? parsePrincipalExecutionMetadata(envelope.executionMetadata) : undefined + const withExecutionMetadata = ( + principal: WorkflowExecutionPrincipal + ): WorkflowExecutionPrincipal => + executionMetadata === undefined ? principal : { ...principal, executionMetadata } const principal = requireRecord(envelope.principal, 'Serialized principal value') const kind = requireString(principal.kind, 'kind') switch (kind) { case 'session': requireExactKeys(principal, ['kind', 'userId', 'sessionId']) - return { + return withExecutionMetadata({ kind, userId: requireString(principal.userId, 'userId'), sessionId: requireString(principal.sessionId, 'sessionId'), - } + }) case 'personal_api_key': requireExactKeys(principal, ['kind', 'userId', 'keyId']) - return { + return withExecutionMetadata({ kind, userId: requireString(principal.userId, 'userId'), keyId: requireString(principal.keyId, 'keyId'), - } + }) case 'workspace_api_key': requireExactKeys(principal, ['kind', 'workspaceId', 'keyId']) - return { + return withExecutionMetadata({ kind, workspaceId: requireString(principal.workspaceId, 'workspaceId'), keyId: requireString(principal.keyId, 'keyId'), - } + }) case 'system': { requireExactKeys( principal, @@ -335,7 +492,7 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { if (subject && subject.provider !== provider) { throw new Error('Webhook system principal subject provider must match its provider') } - return { + return withExecutionMetadata({ kind, serviceId, workspaceId: requireString(principal.workspaceId, 'workspaceId'), @@ -343,17 +500,17 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { webhookId, provider, ...(subject ? { subject } : {}), - } + }) } if (webhookId || provider || subject) { throw new Error(`System principal service ${serviceId} cannot carry webhook identity`) } - return { + return withExecutionMetadata({ kind, serviceId: serviceId as ActorlessSystemPrincipal['serviceId'], workspaceId: requireString(principal.workspaceId, 'workspaceId'), workflowId: requireString(principal.workflowId, 'workflowId'), - } + }) } case 'delegated': { requireExactKeys( @@ -374,7 +531,7 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { if (!['copilot', 'realtime'].includes(serviceId)) { throw new Error(`Unsupported delegated principal service ${serviceId}`) } - return { + return withExecutionMetadata({ kind, serviceId: serviceId as SubjectDelegatedPrincipal['serviceId'], subjectUserId: requireString(principal.subjectUserId, 'subjectUserId'), @@ -386,7 +543,7 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { ...(principal.resourceScope === undefined ? {} : { resourceScope: parseResourceScope(principal.resourceScope) }), - } + }) } case 'credential_group_enrollment': throw new Error('Credential Group enrollment principals cannot be persisted for execution') @@ -410,7 +567,7 @@ export type PrincipalActor = } | { kind: 'delegated' - serviceId: DelegatedPrincipal['serviceId'] + serviceId: DelegatedPrincipal['serviceId'] | 'executor' subjectUserId?: string delegationId: string } @@ -454,13 +611,7 @@ export function resolvePrincipalSubject(principal: Principal): PrincipalSubject case 'personal_api_key': return { kind: 'sim_user', userId: principal.userId } case 'delegated': - if (principal.serviceId !== 'executor') { - return { kind: 'sim_user', userId: principal.subjectUserId } - } - if (principal.delegationContext?.principal) { - return resolvePrincipalSubject(principal.delegationContext.principal) - } - return principal.subjectUserId ? { kind: 'sim_user', userId: principal.subjectUserId } : null + return { kind: 'sim_user', userId: principal.subjectUserId } case 'system': return principal.serviceId === 'webhook' ? (principal.subject ?? null) : null case 'workspace_api_key': @@ -536,7 +687,7 @@ 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, + * A workspace billing owner may fill that field for actorless workflow execution, * but never changes the principal, audit actor, or authorization decision. */ export function resolvePrincipalAttribution( @@ -556,13 +707,21 @@ export function resolvePrincipalAttribution( } return { actor, attributedUserId } } - case 'system': - throw new Error('System principals do not support user attribution') - case 'delegated': { - if (actor.subjectUserId) return { actor, attributedUserId: actor.subjectUserId } - if (actor.serviceId !== 'executor') throw new PrincipalSubjectUserRequiredError(actor.kind) + case 'system': { + if (principal.executionMetadata === undefined) { + throw new Error('System principals do not support user attribution') + } const attributedUserId = context.workspaceBillingOwnerUserId - if (!attributedUserId) throw new PrincipalSubjectUserRequiredError(actor.kind) + if (!attributedUserId) { + throw new Error( + 'Actorless workflow execution attribution requires a workspace billing owner' + ) + } + return { actor, attributedUserId } + } + case 'delegated': { + const attributedUserId = resolvePrincipalSubjectUserId(principal) + if (!attributedUserId) throw new PrincipalSubjectUserRequiredError(principal.kind) return { actor, attributedUserId } } case 'credential_group_enrollment':