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 0b39e8ac1bc..f1bdeaa44d5 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 @@ -8,8 +8,7 @@ import type { ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - buildHeaders: vi.fn(), + createPrincipal: vi.fn(), createInviteLink: vi.fn(), enforceInviteRateLimit: vi.fn(), listCredentials: vi.fn(), @@ -22,10 +21,6 @@ vi.mock('@/lib/credential-groups/application/create-invite-link', () => ({ createCredentialGroupInviteLink: { execute: mocks.createInviteLink }, })) -vi.mock('@/lib/credential-groups/application/delegation', () => ({ - authenticateCredentialGroupDelegation: mocks.authenticate, -})) - vi.mock('@/lib/credential-groups/application/list-credentials', () => ({ listCredentialGroupCredentials: { execute: mocks.listCredentials }, })) @@ -53,8 +48,8 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({ enforceCredentialGroupInvitationExecutionRateLimit: mocks.enforceInviteRateLimit, })) -vi.mock('@/executor/utils/http', () => ({ - buildExecutorDelegationHeaders: mocks.buildHeaders, +vi.mock('@/lib/internal/principals/executor', () => ({ + createExecutorPrincipalFromExecutionContext: mocks.createPrincipal, })) import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler' @@ -92,8 +87,7 @@ const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBloc describe('CredentialGroupBlockHandler', () => { beforeEach(() => { vi.clearAllMocks() - mocks.buildHeaders.mockResolvedValue({ Authorization: 'Bearer executor-token' }) - mocks.authenticate.mockResolvedValue(principal) + mocks.createPrincipal.mockResolvedValue(principal) }) it('recognizes only Credential Group blocks', () => { @@ -122,7 +116,11 @@ describe('CredentialGroupBlockHandler', () => { cursor: ' credential-1 ', }) - expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1') + expect(mocks.createPrincipal).toHaveBeenCalledWith({ + context, + audience: 'sim:credential-groups', + resourceScope: { credentialGroupId: 'group-1' }, + }) expect(mocks.listCredentials).toHaveBeenCalledWith({ principal, input: { @@ -136,6 +134,73 @@ describe('CredentialGroupBlockHandler', () => { expect(result).toEqual({ credentials: [], count: 0, hasMore: false, nextCursor: null }) }) + 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', + workflowId: 'workflow-1', + principal: executionPrincipal, + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'deployment-version-1', + }, + }, + } + const actorlessContext = { + ...context, + userId: undefined, + principal: executionPrincipal, + executorDelegationOrigin: { + workflowId: 'workflow-1', + principal: executionPrincipal, + currentWorkflow: actorlessPrincipal.delegationContext.currentWorkflow, + }, + } as ExecutionContext + mocks.createPrincipal.mockResolvedValueOnce(actorlessPrincipal) + mocks.listCredentials.mockResolvedValue({ + credentials: [], + count: 0, + hasMore: false, + nextCursor: null, + }) + + await new CredentialGroupBlockHandler().execute(actorlessContext, block, { + operation: 'list_credentials', + credentialGroupId: 'group-1', + }) + + expect(mocks.createPrincipal).toHaveBeenCalledWith({ + context: actorlessContext, + audience: 'sim:credential-groups', + resourceScope: { credentialGroupId: 'group-1' }, + }) + expect(mocks.listCredentials).toHaveBeenCalledWith({ + principal: actorlessPrincipal, + input: { + credentialGroupId: 'group-1', + limit: 100, + cursor: undefined, + email: undefined, + credentialProviderIds: undefined, + }, + }) + }) + it('lists groups under workspace-scoped delegation', async () => { mocks.listGroups.mockResolvedValue({ credentialGroups: [], @@ -149,7 +214,10 @@ describe('CredentialGroupBlockHandler', () => { limit: 10, }) - expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', undefined) + expect(mocks.createPrincipal).toHaveBeenCalledWith({ + context, + audience: 'sim:credential-groups', + }) expect(mocks.listGroups).toHaveBeenCalledWith({ principal, input: { workspaceId: 'workspace-1', limit: 10, cursor: undefined }, @@ -201,7 +269,11 @@ describe('CredentialGroupBlockHandler', () => { email: ' person@example.com ', }) - expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1') + 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( mocks.createInviteLink.mock.invocationCallOrder[0]! @@ -236,7 +308,6 @@ describe('CredentialGroupBlockHandler', () => { await expect( new CredentialGroupBlockHandler().execute(context, block, { operation: 'unknown' }) ).rejects.toThrow('Unsupported Credential Group operation: unknown') - expect(mocks.buildHeaders).not.toHaveBeenCalled() - expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.createPrincipal).not.toHaveBeenCalled() }) }) 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 a7710731a7f..0bc0c24169a 100644 --- a/apps/sim/executor/handlers/credential-group/credential-group-handler.ts +++ b/apps/sim/executor/handlers/credential-group/credential-group-handler.ts @@ -1,6 +1,6 @@ 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 { authenticateCredentialGroupDelegation } from '@/lib/credential-groups/application/delegation' import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials' import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups' import { @@ -11,10 +11,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 type { BlockOutput } from '@/blocks/types' import { BlockType } from '@/executor/constants' -import type { BlockHandler, ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types' -import { buildExecutorDelegationHeaders } from '@/executor/utils/http' +import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('CredentialGroupBlockHandler') @@ -84,13 +84,6 @@ function requireString(value: unknown, label: string): string { return parsed } -function delegationOrigin(ctx: ExecutionContext): ExecutorDelegationOrigin { - if (!ctx.executorDelegationOrigin) { - throw new Error('Credential Group operations require an authenticated workflow execution') - } - return ctx.executorDelegationOrigin -} - export class CredentialGroupBlockHandler implements BlockHandler { canHandle(block: SerializedBlock): boolean { return block.metadata?.id === BlockType.CREDENTIAL_GROUP @@ -103,14 +96,18 @@ export class CredentialGroupBlockHandler implements BlockHandler { ): Promise { if (!ctx.workspaceId) throw new Error('workspaceId is required for Credential Group operations') const operation = parseOperation(inputs.operation) + if (!ctx.executorDelegationOrigin) { + throw new Error('Credential Group operations require an authenticated workflow execution') + } const credentialGroupId = operation === 'list_groups' ? undefined : requireString(inputs.credentialGroupId, 'Credential Group') - const headers = await buildExecutorDelegationHeaders(delegationOrigin(ctx)) - const authorization = headers.Authorization - if (!authorization) throw new Error('Executor delegation authorization is missing') - const principal = await authenticateCredentialGroupDelegation(authorization, credentialGroupId) + const principal = await createExecutorPrincipalFromExecutionContext({ + context: ctx, + audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, + ...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}), + }) switch (operation) { case 'list_credentials': { diff --git a/apps/sim/lib/auth/internal-delegation.test.ts b/apps/sim/lib/auth/internal-delegation.test.ts index f7bd611db2a..2450d21bdb6 100644 --- a/apps/sim/lib/auth/internal-delegation.test.ts +++ b/apps/sim/lib/auth/internal-delegation.test.ts @@ -102,6 +102,41 @@ describe('bindInternalExecutorDelegation', () => { }) }) + 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', + }, + }, + { + audience: 'sim:workspace-files', + compatibilityActorUserId: 'execution-actor', + } + ) + + expect(principal.subjectUserId).toBeUndefined() + expect(principal.delegationContext.compatibilityActor).toEqual({ + kind: 'legacy_execution_user', + userId: 'execution-actor', + }) + }) + + 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() + }) + it('binds deployed child authority to its exact historical deployment version', async () => { const currentWorkflow = { workflowId: 'child-workflow', @@ -260,6 +295,16 @@ describe('bindInternalExecutorDelegation', () => { 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')) diff --git a/apps/sim/lib/auth/internal-delegation.ts b/apps/sim/lib/auth/internal-delegation.ts index 7f03930ae03..502ab3859b8 100644 --- a/apps/sim/lib/auth/internal-delegation.ts +++ b/apps/sim/lib/auth/internal-delegation.ts @@ -15,6 +15,7 @@ import { export interface BindInternalExecutorDelegationOptions { audience: string resourceScope?: DelegatedPrincipal['resourceScope'] + compatibilityActorUserId?: string } export class InvalidInternalDelegationBindingError extends Error { @@ -30,6 +31,12 @@ export async function bindInternalExecutorDelegation( options: BindInternalExecutorDelegationOptions ): Promise { if (!options.audience.trim()) throw new Error('Internal delegation audience must not be empty') + 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') + } let context: ActiveWorkflowApplicationContext let rootDeploymentVersionId: string | null | undefined @@ -107,6 +114,14 @@ export async function bindInternalExecutorDelegation( ...(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, + } + : {}), }, } } diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index e938f062a58..8e5e4c108c9 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -7,6 +7,7 @@ import { requirePrincipalSubjectUserId, resolvePrincipalAttribution, resolvePrincipalAuditAttribution, + resolvePrincipalExecutionActorUserId, resolvePrincipalSubject, resolvePrincipalSubjectUserId, serializePrincipal, @@ -108,6 +109,49 @@ describe('principal subject users', () => { ).toBeUndefined() }) + 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: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + }, + compatibilityActor: { + kind: 'legacy_execution_user' as const, + userId: 'execution-actor', + }, + }, + } + + expect(resolvePrincipalSubjectUserId(principal)).toBeUndefined() + expect(resolvePrincipalExecutionActorUserId(principal)).toBe('execution-actor') + expect( + resolvePrincipalExecutionActorUserId({ + ...principal, + subjectUserId: 'authenticated-user', + }) + ).toBe('authenticated-user') + expect( + resolvePrincipalExecutionActorUserId({ + ...principal, + delegationContext: { + ...principal.delegationContext, + currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' }, + }, + }) + ).toBeUndefined() + }) + it('fails fast instead of fabricating a workspace-key subject', () => { expect(() => requirePrincipalSubjectUserId({ diff --git a/apps/sim/lib/core/orchestration/types.ts b/apps/sim/lib/core/orchestration/types.ts index f46c0a4ebbd..dabdcfb23bc 100644 --- a/apps/sim/lib/core/orchestration/types.ts +++ b/apps/sim/lib/core/orchestration/types.ts @@ -109,11 +109,7 @@ export function asOrchestrationError(error: unknown): OrchestrationError | null return null } -/** - * The slice of an HTTP request the audit log reads for client IP and user-agent - * capture. Optional on every orchestration function so the non-HTTP callers — - * copilot tools, background jobs — can omit what they do not have. - */ +/** Transport metadata available to an application operation for audit capture. */ export interface OrchestrationRequestContext { headers: { get(name: string): string | null } } diff --git a/apps/sim/lib/credential-groups/application/delegation.ts b/apps/sim/lib/credential-groups/application/delegation.ts deleted file mode 100644 index 56ad6895c0a..00000000000 --- a/apps/sim/lib/credential-groups/application/delegation.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' -import { - InvalidInternalDelegationTokenError, - verifyInternalDelegationToken, -} from '@/lib/auth/internal' -import { - bindInternalExecutorDelegation, - InvalidInternalDelegationBindingError, -} from '@/lib/auth/internal-delegation' -import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization' - -export class InvalidCredentialGroupDelegationError extends Error { - constructor() { - super('Credential Group execution requires valid workflow delegation') - this.name = 'InvalidCredentialGroupDelegationError' - } -} - -/** Authenticates and binds executor claims to Credential Group application scope. */ -export async function authenticateCredentialGroupDelegation( - authorization: string, - credentialGroupId?: string -): Promise { - if (!authorization.startsWith('Bearer ')) throw new InvalidCredentialGroupDelegationError() - - try { - const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) - return await bindInternalExecutorDelegation(claims, { - audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, - ...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}), - }) - } catch (error) { - if ( - error instanceof InvalidInternalDelegationTokenError || - error instanceof InvalidInternalDelegationBindingError - ) { - throw new InvalidCredentialGroupDelegationError() - } - throw error - } -} diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts index fdcca435bb6..ef384c6b346 100644 --- a/apps/sim/lib/credentials/application/authorization.ts +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -1,5 +1,6 @@ -import type { Principal } from '@sim/auth/principal' +import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth' export const CREDENTIAL_DELEGATION_AUDIENCE = 'sim:credentials' @@ -21,3 +22,21 @@ export const managedOAuthCredentialDelegationPolicy = { context: ManagedOAuthCredentialApplicationContext ) => principal.resourceScope?.credentialId === context.credentialId, } satisfies WorkspaceDelegationPolicy + +/** + * Resolves the user whose credential grants an operation evaluates. + * + * Actorless execution uses only the compatibility actor bound into the executor + * principal by the trusted runtime. Workspace authorization remains + * principal-based, and a principal subject always takes precedence. + */ +export function requireCredentialExecutionUserId(principal: Principal): string { + const userId = resolvePrincipalExecutionActorUserId(principal) + if (!userId) { + throw new OrchestrationError( + 'forbidden', + 'Credential access requires a user subject or execution actor' + ) + } + return userId +} diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts index 6fe719b99a8..3231a94063f 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -1,5 +1,4 @@ import type { Principal } from '@sim/auth/principal' -import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { type AuthorizedWorkspaceUseCaseDefinition, defineAuthorizedWorkspaceUseCase, @@ -9,7 +8,10 @@ import { import { OrchestrationError } from '@/lib/core/orchestration/types' import type { CredentialActorContext } from '@/lib/credentials/access' import { getCredentialActorContext } from '@/lib/credentials/access' -import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { + credentialDelegationPolicy, + requireCredentialExecutionUserId, +} from '@/lib/credentials/application/authorization' import type { CredentialOperation } from '@/lib/credentials/application/operations' import type { CredentialRow } from '@/lib/credentials/queries' @@ -88,8 +90,7 @@ export function defineAuthorizedCredentialUseCase< async authorizeResource({ principal, context }) { const actor = await getCredentialActorContext( context.credential.id, - // actorless-unsupported: credential access is decided per person; an actorless run has no credential grants - requirePrincipalSubjectUserId(principal) + requireCredentialExecutionUserId(principal) ) if ( !actor.credential || diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts index 143a068961e..de2a9cd71c9 100644 --- a/apps/sim/lib/credentials/application/connection-target.test.ts +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -137,6 +137,39 @@ 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: { + workflowId: 'workflow-1', + mode: 'deployment' as const, + deploymentVersionId: 'deployment-1', + }, + compatibilityActor: { + kind: 'legacy_execution_user' as const, + userId: 'execution-actor', + }, + }, + } + + await resolveCredentialConnectionTarget({ + principal: actorlessPrincipal, + context, + credentialId: 'credential-1', + }) + + expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'execution-actor') + }) + it('rejects providers whose custom flow cannot reconnect', async () => { mocks.listCatalog.mockResolvedValue([{ ...salesforceProvider, supportsReconnect: false }]) diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts index 38dfcd482b7..6c98ac540ce 100644 --- a/apps/sim/lib/credentials/application/connection-target.ts +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -1,7 +1,8 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { Principal } from '@sim/auth/principal' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getCredentialActorContext } from '@/lib/credentials/access' +import { requireCredentialExecutionUserId } from '@/lib/credentials/application/authorization' import { listCredentialProviderCatalog, type OAuthCredentialProviderCatalogEntry, @@ -46,8 +47,7 @@ export async function resolveCredentialConnectionTarget(params: { } if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID') - // actorless-unsupported: reconnecting rebinds a person's own OAuth grant - const userId = requirePrincipalSubjectUserId(principal) + const userId = requireCredentialExecutionUserId(principal) const targetCredentialId = credentialId const credential = await getWorkspaceCredential({ workspaceId: context.workspaceId, diff --git a/apps/sim/lib/custom-tools/application/authorization.ts b/apps/sim/lib/custom-tools/application/authorization.ts index 4dd7b31f2ce..f97e6fd0168 100644 --- a/apps/sim/lib/custom-tools/application/authorization.ts +++ b/apps/sim/lib/custom-tools/application/authorization.ts @@ -1,4 +1,6 @@ +import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' export const CUSTOM_TOOL_DELEGATION_AUDIENCE = 'sim:custom-tools' @@ -10,3 +12,22 @@ export const customToolDelegationPolicy = { workspaceOrganizationId: string | null allowPersonalApiKeys: boolean }> + +/** + * Resolves the user whose custom-tool library an operation reads or mutates. + * + * Actorless execution keeps the pre-application-boundary behavior through the + * compatibility actor bound into the executor principal. A real principal + * subject always wins, and this value is never involved in workspace + * authorization. + */ +export function requireCustomToolUserId(principal: Principal): string { + const userId = resolvePrincipalExecutionActorUserId(principal) + if (!userId) { + throw new OrchestrationError( + 'forbidden', + 'Custom tools are resolved from a user library, and this run has no execution actor' + ) + } + return userId +} 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 98acac58fe4..8b4bb2fac7e 100644 --- a/apps/sim/lib/custom-tools/application/use-cases.test.ts +++ b/apps/sim/lib/custom-tools/application/use-cases.test.ts @@ -131,7 +131,7 @@ describe('custom tool application use cases', () => { expect(mocks.audit).not.toHaveBeenCalled() }) - it('authorizes an actorless deployment without enabling personal fallback', async () => { + it('preserves the legacy execution actor for an actorless deployment', async () => { const result = await readAvailableCustomToolByIdOrTitleUseCase.execute({ principal: executorPrincipal({ subjectUserId: undefined, @@ -150,6 +150,10 @@ describe('custom tool application use cases', () => { mode: 'deployment', deploymentVersionId: 'version-1', }, + compatibilityActor: { + kind: 'legacy_execution_user', + userId: 'execution-actor', + }, }, }), input: { @@ -163,11 +167,41 @@ describe('custom tool application use cases', () => { expect(mocks.resolvePermission).not.toHaveBeenCalled() expect(mocks.getAvailableTool).toHaveBeenCalledWith({ identifier: tool.id, + userId: 'execution-actor', workspaceId: workspace.workspaceId, lookup: 'id_or_title', }) }) + it('refuses actorless lookup when the legacy execution actor is missing', async () => { + await expect( + readAvailableCustomToolByIdOrTitleUseCase.execute({ + principal: executorPrincipal({ + subjectUserId: undefined, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + currentWorkflow: { + workflowId: 'workflow-1', + mode: 'deployment', + deploymentVersionId: 'version-1', + }, + }, + }), + input: { + workspaceId: workspace.workspaceId, + identifier: tool.id, + lookup: 'id', + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: + 'Custom tools are resolved from a user library, and this run has no execution actor', + }) + expect(mocks.getAvailableTool).not.toHaveBeenCalled() + }) + it('conceals a workspace assertion outside the delegated workspace before lookup', async () => { mocks.loadContext.mockResolvedValueOnce({ ...workspace, workspaceId: 'workspace-2' }) diff --git a/apps/sim/lib/custom-tools/application/use-cases.ts b/apps/sim/lib/custom-tools/application/use-cases.ts index a0a20abaebe..4d9643837ba 100644 --- a/apps/sim/lib/custom-tools/application/use-cases.ts +++ b/apps/sim/lib/custom-tools/application/use-cases.ts @@ -1,16 +1,14 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { - type Principal, - requirePrincipalSubjectUserId, - resolvePrincipalAttribution, - resolvePrincipalSubject, -} from '@sim/auth/principal' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import type { customTools } from '@sim/db/schema' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { customToolDelegationPolicy } from '@/lib/custom-tools/application/authorization' +import { + customToolDelegationPolicy, + requireCustomToolUserId, +} from '@/lib/custom-tools/application/authorization' import { customToolOperations } from '@/lib/custom-tools/application/operations' import { assertStorableCustomToolSchema, @@ -70,8 +68,7 @@ async function resolveAvailableToolContext(args: { const workspace = await resolveWorkspaceContext(args.workspaceId) const tool = await getCustomToolById({ toolId: args.toolId, - // actorless-unsupported: a custom tool is owned by one user; an actorless run has no library to look in - userId: requirePrincipalSubjectUserId(args.principal), + userId: requireCustomToolUserId(args.principal), workspaceId: workspace.workspaceId, }) if (!tool) throw new OrchestrationError('not_found', 'Custom tool not found') @@ -128,10 +125,9 @@ export const listAvailableCustomToolsUseCase = defineAuthorizedWorkspaceUseCase( resolveContext: ({ input }: { input: ListAvailableCustomToolsInput }) => resolveWorkspaceContext(input.workspaceId), authorizationOptions, - async execute({ principal, context }) { + async execute({ principal, input, context }) { const tools = await listCustomTools({ - // actorless-unsupported: the listing is the acting user's own tool library, which an actorless run does not have - userId: requirePrincipalSubjectUserId(principal), + userId: requireCustomToolUserId(principal), workspaceId: context.workspaceId, }) return { tools } @@ -165,10 +161,9 @@ export const readAvailableCustomToolByIdOrTitleUseCase = defineAuthorizedWorkspa resolveWorkspaceContext(input.workspaceId), authorizationOptions, async execute({ principal, input, context }) { - const subject = resolvePrincipalSubject(principal) const tool = await getAvailableCustomTool({ identifier: input.identifier, - ...(subject?.kind === 'sim_user' ? { userId: subject.userId } : {}), + userId: requireCustomToolUserId(principal), workspaceId: context.workspaceId, lookup: input.lookup, }) @@ -355,8 +350,7 @@ export const updateAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase const tool = await updateCustomTool({ workspaceId: context.workspaceId, toolId: context.tool.id, - // actorless-unsupported: editing a tool is scoped to its owner; an actorless run owns none - userId: requirePrincipalSubjectUserId(principal), + userId: requireCustomToolUserId(principal), title, schema: input.schema ?? context.tool.schema, code: input.code ?? context.tool.code, @@ -421,12 +415,11 @@ export const deleteAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase toolId: input.toolId, }), authorizationOptions, - async execute({ principal, context }) { + async execute({ principal, input, context }) { const deleted = await deleteCustomTool({ workspaceId: context.workspaceId, toolId: context.tool.id, - // actorless-unsupported: deleting a tool is scoped to its owner; an actorless run owns none - userId: requirePrincipalSubjectUserId(principal), + userId: requireCustomToolUserId(principal), }) if (!deleted) throw new OrchestrationError('not_found', 'Custom tool not found') return { tool: context.tool } 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 8b459221de2..dc2f03e8796 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 @@ -102,6 +102,38 @@ describe('readAvailableCustomToolByIdOrTitleAsExecutor', () => { }) }) + it('forwards the principal-bound legacy execution actor for an actorless principal', async () => { + const context = executionContext() + const actorlessPrincipal = { + ...principal, + subjectUserId: undefined, + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'workflow-1', + compatibilityActor: { + kind: 'legacy_execution_user' as const, + userId: 'user-1', + }, + }, + } + mocks.createPrincipal.mockResolvedValueOnce(actorlessPrincipal) + + await readAvailableCustomToolByIdOrTitleAsExecutor({ + context, + identifier: tool.id, + lookup: 'id', + }) + + expect(mocks.readUseCase.execute).toHaveBeenCalledWith({ + principal: actorlessPrincipal, + input: { + workspaceId: principal.workspaceId, + identifier: tool.id, + lookup: 'id', + }, + }) + }) + it('stops before principal construction when execution is already cancelled', async () => { const controller = new AbortController() controller.abort(new Error('cancelled')) diff --git a/apps/sim/lib/internal/file/execute-tool.test.ts b/apps/sim/lib/internal/file/execute-tool.test.ts index 9079b2b8aaa..cadc082eacf 100644 --- a/apps/sim/lib/internal/file/execute-tool.test.ts +++ b/apps/sim/lib/internal/file/execute-tool.test.ts @@ -196,6 +196,10 @@ describe('executeFileTool', () => { mode: 'deployment' as const, deploymentVersionId: 'deployment-1', }, + compatibilityActor: { + kind: 'legacy_execution_user' as const, + userId: 'legacy-actor', + }, }, } mocks.createPrincipal.mockResolvedValueOnce(principal) diff --git a/apps/sim/lib/internal/knowledge/execute-tool.ts b/apps/sim/lib/internal/knowledge/execute-tool.ts index 16f5535f5fc..03b177ab4c9 100644 --- a/apps/sim/lib/internal/knowledge/execute-tool.ts +++ b/apps/sim/lib/internal/knowledge/execute-tool.ts @@ -139,7 +139,11 @@ export const executeKnowledgeTool: InternalToolOperationHandler = async (request throw error } signal?.throwIfAborted() - const context = { principal, headers: request.headers, signal } + const context = { + principal, + headers: request.headers, + signal, + } const input = normalizeKnowledgeInput(request.input) switch (toolId) { diff --git a/apps/sim/lib/internal/mcp/discover-tools.ts b/apps/sim/lib/internal/mcp/discover-tools.ts index fcf1f709a46..78050f11e9c 100644 --- a/apps/sim/lib/internal/mcp/discover-tools.ts +++ b/apps/sim/lib/internal/mcp/discover-tools.ts @@ -25,8 +25,7 @@ export async function discoverMcpServerToolsAsExecutor({ signal?.throwIfAborted() const result = await discoverMcpServerToolsUseCase.execute({ principal, - // See `executionActorUserId`: preserves the pre-in-process behavior for unattended runs. - input: { workspaceId, serverId, executionActorUserId: context.userId }, + input: { workspaceId, serverId }, }) signal?.throwIfAborted() return result.tools diff --git a/apps/sim/lib/internal/mcp/execute-tool.ts b/apps/sim/lib/internal/mcp/execute-tool.ts index e271ed3657a..3e900d81af2 100644 --- a/apps/sim/lib/internal/mcp/execute-tool.ts +++ b/apps/sim/lib/internal/mcp/execute-tool.ts @@ -149,9 +149,6 @@ export const executeMcpTool: InternalToolOperationHandler = async (request) => { input: { workspaceId: request.context.workspaceId, serverId, - // The run's execution actor, exactly what the pre-in-process path minted its - // internal token from. Keeps unattended MCP workflows working as before. - executionActorUserId: request.context.userId, toolName, arguments: args, callChain: request.context.callChain, diff --git a/apps/sim/lib/internal/principals/executor.test.ts b/apps/sim/lib/internal/principals/executor.test.ts index 064b418b34c..3409852c2e0 100644 --- a/apps/sim/lib/internal/principals/executor.test.ts +++ b/apps/sim/lib/internal/principals/executor.test.ts @@ -43,6 +43,14 @@ describe('createExecutorPrincipalFromExecutionContext', () => { ...(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, + }, + } + : {}), }, })) }) @@ -66,7 +74,10 @@ describe('createExecutorPrincipalFromExecutionContext', () => { workflowId: 'workflow-origin', executionId: 'execution-origin', }), - { audience: 'sim:tables', resourceScope: { tableId: 'table-1' } } + { + audience: 'sim:tables', + resourceScope: { tableId: 'table-1' }, + } ) }) @@ -152,7 +163,7 @@ describe('createExecutorPrincipalFromExecutionContext', () => { principal, currentWorkflow, }), - { audience: 'sim:tables' } + { audience: 'sim:tables', compatibilityActorUserId: 'user-current' } ) expect(mockBindInternalExecutorDelegation.mock.calls[0]?.[0]).not.toHaveProperty( 'subjectUserId' diff --git a/apps/sim/lib/internal/principals/executor.ts b/apps/sim/lib/internal/principals/executor.ts index 51c5069d736..4aeeb57e5a8 100644 --- a/apps/sim/lib/internal/principals/executor.ts +++ b/apps/sim/lib/internal/principals/executor.ts @@ -33,7 +33,8 @@ async function bindExecutorPrincipal( origin: ExecutorDelegationOrigin, audience: string, resourceScope?: DelegatedPrincipal['resourceScope'], - expiresAt?: Date + expiresAt?: Date, + compatibilityActorUserId?: string ) { if (!origin.workflowId.trim()) throw new Error('Authentication required') const subjectUserId = resolveExecutorOriginSubject(origin) @@ -53,6 +54,7 @@ async function bindExecutorPrincipal( { audience, ...(resourceScope ? { resourceScope } : {}), + ...(!subjectUserId && compatibilityActorUserId ? { compatibilityActorUserId } : {}), } ) } @@ -72,5 +74,5 @@ export async function createExecutorPrincipalFromExecutionContext({ }: CreateExecutorPrincipalFromExecutionContextInput) { const origin = context.executorDelegationOrigin if (!origin) throw new ExecutorDelegationOriginRequiredError() - return bindExecutorPrincipal(origin, audience, resourceScope, expiresAt) + return bindExecutorPrincipal(origin, audience, resourceScope, expiresAt, context.userId) } diff --git a/apps/sim/lib/internal/windchill/execute-tool.ts b/apps/sim/lib/internal/windchill/execute-tool.ts index 35a0141ed5f..b49cf727672 100644 --- a/apps/sim/lib/internal/windchill/execute-tool.ts +++ b/apps/sim/lib/internal/windchill/execute-tool.ts @@ -89,7 +89,11 @@ export const executeWindchillTool: InternalToolOperationHandler = async (request return failureResponse('Windchill request operation does not match the selected tool', 400) } - const output = await executeWindchillOperation(input, { principal, requestId, signal }) + const output = await executeWindchillOperation(input, { + principal, + requestId, + signal, + }) signal?.throwIfAborted() return Response.json({ success: true, output } satisfies WindchillOperationResponse) } catch (error) { diff --git a/apps/sim/lib/internal/windchill/operations.test.ts b/apps/sim/lib/internal/windchill/operations.test.ts index 95b2b1bff95..badf46d0ba1 100644 --- a/apps/sim/lib/internal/windchill/operations.test.ts +++ b/apps/sim/lib/internal/windchill/operations.test.ts @@ -331,6 +331,55 @@ describe('Windchill operations', () => { }) }) + it('uses the legacy execution actor for actorless file access', async () => { + const rawFile = { + key: 'workspace/specification.pdf', + name: 'specification.pdf', + size: 3, + type: 'application/pdf', + } + mocks.processFilesToUserFiles.mockReturnValue([rawFile]) + mocks.downloadServableFileFromStorage.mockResolvedValue({ + buffer: Buffer.from('pdf'), + contentType: 'application/pdf', + }) + + await executeWindchillOperation( + { + ...BASE, + operation: 'windchill_upload_primary_content', + documentOid: DOCUMENT_OID, + 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', + }, + }, + }, + requestId: 'request-1', + } + ) + + expect(mocks.assertToolFileAccess).toHaveBeenCalledWith( + rawFile.key, + 'execution-actor', + 'request-1', + expect.anything() + ) + }) + it('fails closed before storage or provider work when file access is denied', async () => { const rawFile = { key: 'other/file.pdf', name: 'file.pdf', size: 3, type: 'application/pdf' } mocks.processFilesToUserFiles.mockReturnValue([rawFile]) @@ -413,4 +462,41 @@ describe('Windchill operations', () => { }) expect(result).not.toHaveProperty('content') }) + + it('attributes actorless provider downloads to the legacy execution actor', async () => { + await executeWindchillOperation( + { + ...BASE, + operation: 'windchill_download_primary_content', + 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', + }, + }, + }, + requestId: 'request-1', + } + ) + + expect(mocks.uploadExecutionFile).toHaveBeenCalledWith( + expect.anything(), + Buffer.from('pdf'), + 'specification.pdf', + 'application/pdf', + 'execution-actor' + ) + }) }) diff --git a/apps/sim/lib/internal/windchill/operations.ts b/apps/sim/lib/internal/windchill/operations.ts index f8bc8c30e2f..c9967e51bbb 100644 --- a/apps/sim/lib/internal/windchill/operations.ts +++ b/apps/sim/lib/internal/windchill/operations.ts @@ -1,6 +1,6 @@ import { type BoundWorkflowExecutionDelegatedPrincipal, - requirePrincipalSubjectUserId, + resolvePrincipalExecutionActorUserId, } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -440,6 +440,16 @@ async function loadUploadFiles( return files } +function requireWindchillExecutionUserId( + principal: BoundWorkflowExecutionDelegatedPrincipal +): string { + const userId = resolvePrincipalExecutionActorUserId(principal) + if (!userId) { + throw new WindchillOperationError('Windchill file operations require an execution actor', 403) + } + return userId +} + function contentDispositionFileName(value: string | null): string | null { if (!value) return null const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1] @@ -472,6 +482,7 @@ async function storeDownloadedFile({ }): Promise { signal?.throwIfAborted() const { workflowId, executionId } = principal.delegationContext + const userId = requireWindchillExecutionUserId(principal) if (executionId) { const file = await uploadExecutionFile( { @@ -482,8 +493,7 @@ async function storeDownloadedFile({ buffer, fileName, contentType, - // actorless-unsupported: the uploaded file needs an owning user row; attributing it to the workflow's user is a follow-up - requirePrincipalSubjectUserId(principal) + userId ) signal?.throwIfAborted() return file @@ -492,8 +502,7 @@ async function storeDownloadedFile({ buffer, fileName, contentType, - // actorless-unsupported: the uploaded file needs an owning user row; attributing it to the workflow's user is a follow-up - userId: requirePrincipalSubjectUserId(principal), + userId, }) signal?.throwIfAborted() return file @@ -577,8 +586,7 @@ export async function executeWindchillOperation( : body.attachmentFiles const files = await loadUploadFiles( inputs, - // actorless-unsupported: reads the acting user's own files; attributing it to the workflow's user is a follow-up - requirePrincipalSubjectUserId(principal), + requireWindchillExecutionUserId(principal), requestId, signal ) diff --git a/apps/sim/lib/knowledge/api/internal-route.test.ts b/apps/sim/lib/knowledge/api/internal-route.test.ts index 3b04901a13c..4d69fea1e32 100644 --- a/apps/sim/lib/knowledge/api/internal-route.test.ts +++ b/apps/sim/lib/knowledge/api/internal-route.test.ts @@ -56,6 +56,10 @@ function executorPrincipal( mode: 'deployment', deploymentVersionId: 'deployment-1', }, + compatibilityActor: { + kind: 'legacy_execution_user', + userId: 'execution-billing-actor-1', + }, ...(originalPrincipal ? { principal: originalPrincipal } : {}), }, } @@ -98,7 +102,7 @@ describe('internal Knowledge execution attribution', () => { resolveInternalKnowledgeBillingAttribution(request(), executor, 'workspace-1') ).resolves.toEqual(BILLING_ATTRIBUTION) expect(internalKnowledgeProvenanceUserId(request().headers, executor, 'workspace-1')).toBe( - 'billing-owner-1' + 'execution-billing-actor-1' ) expect( resolveKnowledgeAttributedUserId(executor, { @@ -107,7 +111,7 @@ describe('internal Knowledge execution attribution', () => { allowPersonalApiKeys: true, billedAccountUserId: 'billing-owner-1', }) - ).toBe('billing-owner-1') + ).toBe('execution-billing-actor-1') }) it('rejects a billing snapshot from another workspace', async () => { diff --git a/apps/sim/lib/knowledge/api/internal-route.ts b/apps/sim/lib/knowledge/api/internal-route.ts index 5608a476923..21fe9b0510f 100644 --- a/apps/sim/lib/knowledge/api/internal-route.ts +++ b/apps/sim/lib/knowledge/api/internal-route.ts @@ -1,7 +1,7 @@ import { type Principal, - requirePrincipalSubjectUserId, resolvePrincipalSubject, + resolvePrincipalSubjectUserId, type SessionPrincipal, } from '@sim/auth/principal' import type { NextRequest } from 'next/server' @@ -20,6 +20,7 @@ import { requireWorkspaceBillingAttributionHeader, resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import type { CreateKnowledgeBaseInput, @@ -32,8 +33,11 @@ import { captureServerEvent } from '@/lib/posthog/server' import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' export function internalKnowledgeActorUserId(principal: Principal): string { - // actorless-unsupported: knowledge writes are attributed to a person; the actorless path is a follow-up - return requirePrincipalSubjectUserId(principal) + const userId = resolvePrincipalSubjectUserId(principal) + if (!userId) { + throw new OrchestrationError('forbidden', 'Knowledge operation requires a user subject') + } + return userId } export function internalKnowledgeProvenanceUserId( @@ -44,10 +48,9 @@ export function internalKnowledgeProvenanceUserId( if (principal.kind !== 'delegated') return internalKnowledgeActorUserId(principal) const subject = resolvePrincipalSubject(principal) if (subject?.kind === 'sim_user') return subject.userId - if (!workspaceId) { - throw new Error('Delegated Knowledge provenance requires a workspace scope') - } - return requireWorkspaceBillingAttributionHeader(headers, { workspaceId }).billedAccountUserId + return requireWorkspaceBillingAttributionHeader(headers, { + workspaceId: workspaceId ?? principal.workspaceId, + }).actorUserId } export function internalKnowledgeAuthType(principal: Principal): AuthTypeValue { diff --git a/apps/sim/lib/knowledge/application/authorized-knowledge-use-case.ts b/apps/sim/lib/knowledge/application/authorized-knowledge-use-case.ts index 6deb81fe26c..33cd7b143d3 100644 --- a/apps/sim/lib/knowledge/application/authorized-knowledge-use-case.ts +++ b/apps/sim/lib/knowledge/application/authorized-knowledge-use-case.ts @@ -1,4 +1,3 @@ -import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase, type OperationUseCase, @@ -18,6 +17,7 @@ import { knowledgeDelegationPolicy, type LegacyPersonalKnowledgeAuthorizationContext, } from '@/lib/knowledge/application/authorization' +import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' interface AuthorizedKnowledgeUseCaseContext< O extends WorkspaceOperation, @@ -126,8 +126,7 @@ export function defineAuthorizedKnowledgeUseCase< if (isLegacyPersonalKnowledgeContext(context)) { if ( principal.kind === 'workspace_api_key' || - // actorless-unsupported: a legacy personal knowledge base has exactly one owner, so an actorless caller is never it - requirePrincipalSubjectUserId(principal) !== context.legacyPersonalOwnerUserId + resolveKnowledgeAttributedUserId(principal, context) !== context.legacyPersonalOwnerUserId ) { throw new OrchestrationError('not_found', 'Knowledge base not found') } diff --git a/apps/sim/lib/knowledge/application/billing.ts b/apps/sim/lib/knowledge/application/billing.ts index ccf4af4a8f0..2d372a5da1b 100644 --- a/apps/sim/lib/knowledge/application/billing.ts +++ b/apps/sim/lib/knowledge/application/billing.ts @@ -1,5 +1,8 @@ -import type { Principal } from '@sim/auth/principal' -import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal' +import { + type Principal, + resolvePrincipalAttribution, + resolvePrincipalExecutionActorUserId, +} from '@sim/auth/principal' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { type BillingAttributionSnapshot, @@ -7,6 +10,7 @@ import { resolveBillingAttribution, resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { KnowledgeResourceContext } from '@/lib/knowledge/application/contexts' export class KnowledgeUsageLimitExceededError extends Error { @@ -20,8 +24,14 @@ export function resolveKnowledgeAttributedUserId( principal: Principal, context: KnowledgeResourceContext ): string { - // actorless-unsupported: a workspace-less knowledge base bills its owner directly, with no workspace to attribute to - if (context.workspaceId === undefined) return requirePrincipalSubjectUserId(principal) + const executionUserId = resolvePrincipalExecutionActorUserId(principal) + if (executionUserId) return executionUserId + if (context.workspaceId === undefined) { + throw new OrchestrationError( + 'forbidden', + 'Knowledge operations require a user subject or execution actor' + ) + } return resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId diff --git a/apps/sim/lib/mcp/application/authorization.ts b/apps/sim/lib/mcp/application/authorization.ts index 7bd10b74492..38a67e73f09 100644 --- a/apps/sim/lib/mcp/application/authorization.ts +++ b/apps/sim/lib/mcp/application/authorization.ts @@ -1,4 +1,4 @@ -import { type Principal, resolvePrincipalSubjectUserId } from '@sim/auth/principal' +import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -20,15 +20,15 @@ export const mcpServerDelegationPolicy = { * credentials and is gated by that person's permission group, so unlike an * attribution-only read it cannot proceed with nobody named. * - * `executionActorUserId` preserves the behavior that existed before the Logs and - * MCP tools moved in-process. That path minted an internal token from - * `ExecutionContext.userId` and the MCP route ran as that user, so an unattended - * run has always reached MCP as the execution actor. For a schedule, webhook, or - * anonymous public-API run that actor is the workspace system actor resolved - * during preprocessing — the billing payer — not the workflow's author. Keeping - * it is what stops every unattended MCP workflow from breaking; changing it is a - * product decision, not a refactor, and a workspace-level MCP identity is the - * real fix. + * The principal-bound compatibility actor preserves the behavior that existed + * before the Logs and MCP tools moved in-process. That path minted an internal + * token from `ExecutionContext.userId` and the MCP route ran as that user, so an + * unattended run has always reached MCP as the execution actor. For a schedule, + * webhook, or anonymous public-API run that actor is the workspace system actor + * resolved during preprocessing — the billing payer — not the workflow's + * author. Keeping it is what stops every unattended MCP workflow from breaking; + * changing it is a product decision, not a refactor, and a workspace-level MCP + * identity is the real fix. * * The fallback deliberately covers a webhook carrying an `external_user` subject * too. That subject is a real identity but never a Sim user, so it has no Sim @@ -36,15 +36,12 @@ export const mcpServerDelegationPolicy = { * Refusing them here would break working workflows in the name of a boundary the * old path never drew. * - * It is NOT an authorization input: workspace reach is decided by the principal - * before this is read, and a principal that names its own subject always wins, so - * a caller cannot use this to nominate someone else's credentials. + * It is not a separate authorization input: the trusted executor binds it into + * the principal before the use case runs, workspace reach is decided before it + * is read, and a principal that names its own subject always wins. */ -export function requireMcpCredentialUserId( - principal: Principal, - executionActorUserId?: string -): string { - const userId = resolvePrincipalSubjectUserId(principal) ?? executionActorUserId +export function requireMcpCredentialUserId(principal: Principal): string { + const userId = resolvePrincipalExecutionActorUserId(principal) if (!userId) { throw new OrchestrationError( 'forbidden', diff --git a/apps/sim/lib/mcp/application/execute-tool.test.ts b/apps/sim/lib/mcp/application/execute-tool.test.ts index b0711c22960..8406c18a812 100644 --- a/apps/sim/lib/mcp/application/execute-tool.test.ts +++ b/apps/sim/lib/mcp/application/execute-tool.test.ts @@ -85,6 +85,16 @@ const ACTORLESS_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { }, }, } +const COMPATIBILITY_ACTOR_PRINCIPAL: WorkflowExecutionDelegatedPrincipal = { + ...ACTORLESS_PRINCIPAL, + delegationContext: { + ...ACTORLESS_PRINCIPAL.delegationContext, + compatibilityActor: { + kind: 'legacy_execution_user', + userId: 'execution-actor', + }, + }, +} describe('executeMcpToolUseCase', () => { beforeEach(() => { @@ -173,13 +183,12 @@ describe('executeMcpToolUseCase', () => { // ExecutionContext.userId and MCP ran as that user. Preserved deliberately — // see requireMcpCredentialUserId for why that actor is the payer, not the author. await executeMcpToolUseCase.execute({ - principal: ACTORLESS_PRINCIPAL, + principal: COMPATIBILITY_ACTOR_PRINCIPAL, input: { workspaceId: WORKSPACE.workspaceId, serverId: SERVER.id, toolName: 'lookup', arguments: { count: '2', enabled: 'true', tags: 'a,b' }, - executionActorUserId: 'execution-actor', }, }) @@ -189,17 +198,23 @@ describe('executeMcpToolUseCase', () => { expect(mocks.discoverServerTools.mock.calls[0][0]).toBe('execution-actor') }) - it('lets an authenticated subject win over the execution actor', async () => { - // The property that keeps the fallback from becoming an impersonation handle: - // it is consulted only when the principal names nobody. + it('lets an authenticated subject win over a principal-bound compatibility actor', async () => { await executeMcpToolUseCase.execute({ - principal: PRINCIPAL, + principal: { + ...PRINCIPAL, + delegationContext: { + ...PRINCIPAL.delegationContext, + compatibilityActor: { + kind: 'legacy_execution_user', + userId: 'someone-else', + }, + }, + }, input: { workspaceId: WORKSPACE.workspaceId, serverId: SERVER.id, toolName: 'lookup', arguments: { count: '2', enabled: 'true', tags: 'a,b' }, - executionActorUserId: 'someone-else', }, }) @@ -214,9 +229,9 @@ describe('executeMcpToolUseCase', () => { // the actor. Refusing here would break workflows that worked before the tools // moved in-process, so the fallback deliberately covers this case. const externalSubjectPrincipal = { - ...ACTORLESS_PRINCIPAL, + ...COMPATIBILITY_ACTOR_PRINCIPAL, delegationContext: { - ...ACTORLESS_PRINCIPAL.delegationContext, + ...COMPATIBILITY_ACTOR_PRINCIPAL.delegationContext, principal: { kind: 'system' as const, serviceId: 'webhook' as const, @@ -241,7 +256,6 @@ describe('executeMcpToolUseCase', () => { serverId: SERVER.id, toolName: 'lookup', arguments: { count: '2', enabled: 'true', tags: 'a,b' }, - executionActorUserId: 'execution-actor', }, }) diff --git a/apps/sim/lib/mcp/application/execute-tool.ts b/apps/sim/lib/mcp/application/execute-tool.ts index 8f71be4582d..60dba14a253 100644 --- a/apps/sim/lib/mcp/application/execute-tool.ts +++ b/apps/sim/lib/mcp/application/execute-tool.ts @@ -26,11 +26,6 @@ interface SchemaProperty { export interface ExecuteMcpToolInput { workspaceId: string serverId: string - /** - * The run's execution actor. See {@link requireMcpCredentialUserId}: preserves - * the pre-in-process behavior for runs whose principal names no Sim user. - */ - executionActorUserId?: string toolName: string arguments?: Record callChain?: string[] @@ -139,7 +134,7 @@ export const executeMcpToolUseCase = defineAuthorizedWorkspaceUseCase({ authorizationOptions: { delegation: mcpServerDelegationPolicy }, async execute({ principal, input, context }): Promise { input.signal?.throwIfAborted() - const userId = requireMcpCredentialUserId(principal, input.executionActorUserId) + const userId = requireMcpCredentialUserId(principal) await assertPermissionsAllowed({ userId, workspaceId: context.workspaceId, diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index 18085334775..60bdb4840a7 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -91,11 +91,6 @@ export const listMcpServersUseCase = defineAuthorizedWorkspaceUseCase({ export interface DiscoverMcpToolsInput { workspaceId: string - /** - * The run's execution actor. See {@link requireMcpCredentialUserId}: preserves - * the pre-in-process behavior for runs whose principal names no Sim user. - */ - executionActorUserId?: string refresh?: boolean } @@ -106,7 +101,7 @@ export const discoverMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ authorizationOptions, async execute({ principal, input, context }) { const tools = await mcpService.discoverTools( - requireMcpCredentialUserId(principal, input.executionActorUserId), + requireMcpCredentialUserId(principal), context.workspaceId, /** * A public `refresh` skips the positive cache but keeps the failure @@ -122,11 +117,6 @@ export const discoverMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ export interface DiscoverMcpServerToolsInput { workspaceId: string serverId: string - /** - * The run's execution actor. See {@link requireMcpCredentialUserId}: preserves - * the pre-in-process behavior for runs whose principal names no Sim user. - */ - executionActorUserId?: string refresh?: boolean } @@ -164,7 +154,7 @@ export const discoverMcpServerToolsUseCase = defineAuthorizedWorkspaceUseCase({ } const tools = await mcpService.discoverServerTools( - requireMcpCredentialUserId(principal, input.executionActorUserId), + requireMcpCredentialUserId(principal), context.server.id, context.workspaceId, /** diff --git a/apps/sim/lib/workflows/application/authorization.test.ts b/apps/sim/lib/workflows/application/authorization.test.ts index 2ee430dca05..ac5f38dfb24 100644 --- a/apps/sim/lib/workflows/application/authorization.test.ts +++ b/apps/sim/lib/workflows/application/authorization.test.ts @@ -5,6 +5,7 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' import { + requireWorkflowExecutionUserId, WORKFLOW_DELEGATION_AUDIENCE, workflowDelegationPolicy, } from '@/lib/workflows/application/authorization' @@ -74,3 +75,46 @@ describe('workflow delegation policy', () => { expect(workflowOperations.delete.delegatedServices).not.toContain('executor') }) }) + +describe('workflow execution actor', () => { + it('uses the legacy execution actor when the principal is actorless', () => { + const principal = createExecutorPrincipal({ + subjectUserId: undefined, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'parent-workflow', + currentWorkflow: { + workflowId: 'parent-workflow', + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + compatibilityActor: { + kind: 'legacy_execution_user', + userId: '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', + }, + }, + }) + + expect(requireWorkflowExecutionUserId(principal)).toBe('user-1') + }) +}) diff --git a/apps/sim/lib/workflows/application/authorization.ts b/apps/sim/lib/workflows/application/authorization.ts index 87e574fb6c9..1448e30a837 100644 --- a/apps/sim/lib/workflows/application/authorization.ts +++ b/apps/sim/lib/workflows/application/authorization.ts @@ -1,8 +1,9 @@ -import type { Principal } from '@sim/auth/principal' +import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import type { WorkspaceAuthorizationContext, WorkspaceDelegationPolicy, } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' export const WORKFLOW_DELEGATION_AUDIENCE = 'sim:workflows' @@ -33,3 +34,15 @@ export const workflowDelegationPolicy: WorkspaceDelegationPolicy, async execute({ principal, input, context }) { - // actorless-unsupported: reverting a version is an authored edit and records who made it; no tool exposes it to a run - const userId = requirePrincipalSubjectUserId(principal) + const userId = requireWorkflowExecutionUserId(principal) await requireMutableWorkflow(context.workflowId) const result = await performRevertToVersion({ workflowId: context.workflowId, diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.ts index 247af5d1198..b1584f0f977 100644 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' @@ -72,8 +72,13 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ return { ...canonical, file } }, async execute({ principal, input, context }): Promise { - // actorless-unsupported: a share link records the person who published it - const subjectUserId = requirePrincipalSubjectUserId(principal) + const userId = resolvePrincipalExecutionActorUserId(principal) + if (!userId) { + throw new OrchestrationError( + 'forbidden', + 'File sharing requires a user subject or execution actor' + ) + } const existingShare = await getShareForResource('file', context.fileId) if (input.noOpIfInactive && !input.isActive && !existingShare?.isActive) { @@ -83,7 +88,7 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ if (input.isActive) { const effectiveAuthType = input.authType ?? existingShare?.authType ?? 'public' try { - await validatePublicFileSharing(subjectUserId, context.workspaceId, effectiveAuthType) + await validatePublicFileSharing(userId, context.workspaceId, effectiveAuthType) } catch (error) { if (error instanceof PublicFileSharingNotAllowedError) throw new ForbiddenOperationError('PUBLIC_SHARING_NOT_ALLOWED', error.message) @@ -96,7 +101,7 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ share = await upsertFileShare({ workspaceId: context.workspaceId, fileId: context.fileId, - userId: subjectUserId, + userId, isActive: input.isActive, authType: input.authType, password: input.password, diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 3336e5bb624..e47e9dd9262 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -78,6 +78,16 @@ export interface WorkflowExecutionDelegationContext { 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 = @@ -137,6 +147,22 @@ export function requirePrincipalSubjectUserId(principal: Principal): string { throw new PrincipalSubjectUserRequiredError(principal.kind) } +/** + * Resolves the principal's Sim user subject or its principal-bound legacy + * execution actor. + * + * Only operations that deliberately preserve pre-principal executor behavior + * should use this helper. It never changes the principal subject, workspace + * authorization, or audit actor. + */ +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 +} + export type WorkflowExecutionPrincipal = | SessionPrincipal | PersonalApiKeyPrincipal