From 55fd9daa089b3af5f387f968644a6cc10e57c451 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 29 Sep 2026 14:02:09 +0000 Subject: [PATCH] feat(core): Send a log streaming audit event when a policy blocks an action (#39581) Co-authored-by: Claude Opus 5.5 (1M context) --- .../__tests__/active-workflow-manager.test.ts | 11 +- .../src/__tests__/credentials-helper.test.ts | 72 ++++++- packages/cli/src/active-workflow-manager.ts | 17 +- .../cli/src/commands/import/credentials.ts | 13 +- packages/cli/src/credentials-helper.ts | 25 ++- .../__tests__/credentials.controller.test.ts | 50 +++-- .../__tests__/credentials.service.test.ts | 98 ++++++---- .../src/credentials/credentials.controller.ts | 3 +- .../src/credentials/credentials.service.ts | 52 ++--- .../event-message-audit.ts | 8 +- .../eventbus/event-message-classes/index.ts | 1 + .../log-streaming-event-relay.test.ts | 163 +++++++++++++++- .../cli/src/events/maps/relay.event-map.ts | 21 +- .../relays/log-streaming.event-relay.ts | 47 +++++ .../__tests__/slack-app-setup.service.test.ts | 2 + .../slack/slack-managed-setup.service.ts | 14 +- .../chat-hub-workflow.service.test.ts | 3 + .../chat-hub/chat-hub-workflow.service.ts | 26 ++- .../instance-ai.adapter.service.test.ts | 13 +- .../__tests__/instance-ai.controller.test.ts | 4 + ...thread-restore.service.integration.test.ts | 9 +- .../__tests__/thread-restore.service.test.ts | 45 ++++- .../eval/thread-restore.service.ts | 19 +- .../instance-ai.adapter.service.ts | 13 +- .../instance-ai/instance-ai.controller.ts | 1 + ...e-event-bus-destination-webhook.ee.test.ts | 2 + ...essage-event-bus-destination-webhook.ee.ts | 2 + .../__tests__/content-import-policy.test.ts | 52 +++-- .../engine/content-import-policy.ts | 21 +- .../engine/import-orchestrator.ts | 1 + .../modules/policy-infrastructure/README.md | 33 +++- .../__tests__/policy-decision.service.test.ts | 170 ++++++++++++---- .../policy-lifecycle-handler.test.ts | 16 +- .../policy-decision-audit.ts | 61 ++++-- .../policy-decision.service.ts | 25 ++- .../policy-lifecycle-handler.ts | 8 +- .../source-control-import.service.ee.test.ts | 32 ++-- .../source-control-import.service.ee.ts | 26 ++- ...kflow-review-lifecycle.integration.test.ts | 4 +- .../src/oauth/__tests__/oauth.service.test.ts | 5 + packages/cli/src/oauth/oauth.service.ts | 20 +- .../enforce-workflow-publish.test.ts | 21 +- .../policy-enforcement.service.test.ts | 181 +++++++++++------- .../src/policy/enforce-workflow-publish.ts | 4 +- .../src/policy/policy-enforcement-backend.ts | 26 +++ .../src/policy/policy-enforcement.service.ts | 39 +++- .../credentials.public.controller.ts | 1 + packages/cli/src/services/import.service.ts | 13 +- packages/cli/src/types/user-like.types.ts | 10 + .../workflow-creation.service.test.ts | 14 +- .../__tests__/workflow.service.ee.test.ts | 11 +- .../__tests__/workflow.service.test.ts | 35 ++-- .../workflow-publication-applier.test.ts | 17 +- .../workflow-publication-applier.ts | 11 +- .../workflows/workflow-creation.service.ts | 13 +- .../cli/src/workflows/workflow.service.ee.ts | 12 +- .../cli/src/workflows/workflow.service.ts | 34 ++-- .../active-workflow-manager.test.ts | 4 + .../commands/credentials.cmd.test.ts | 30 +-- .../integration/commands/import.cmd.test.ts | 8 +- .../database/services/db-lock.service.test.ts | 26 ++- .../source-control-import.service.test.ts | 17 +- .../test/integration/import.service.test.ts | 54 ++++-- .../instance-ai-settings.service.test.ts | 13 +- .../workflows/workflow-save-policy.test.ts | 43 +++++ .../workflows/workflow.service.test.ts | 17 +- .../workflows/workflows.controller.ee.test.ts | 8 +- .../workflows/workflows.controller.test.ts | 28 ++- 68 files changed, 1396 insertions(+), 502 deletions(-) create mode 100644 packages/cli/src/types/user-like.types.ts diff --git a/packages/cli/src/__tests__/active-workflow-manager.test.ts b/packages/cli/src/__tests__/active-workflow-manager.test.ts index e2c60327f29..4a8b91b140b 100644 --- a/packages/cli/src/__tests__/active-workflow-manager.test.ts +++ b/packages/cli/src/__tests__/active-workflow-manager.test.ts @@ -262,10 +262,13 @@ describe('ActiveWorkflowManager', () => { // Registration fails here (no real node types); the check runs before it. await activeWorkflowManager.add('wf-1', 'activate').catch(() => {}); - expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: 'wf-1', name: 'My workflow', nodes: VERSION_NODES }, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: 'wf-1', name: 'My workflow', nodes: VERSION_NODES }, + projectId: 'project-1', + }, + { kind: 'system', reason: 'activation' }, + ); }); test('registers nothing and records an activation error when policy blocks', async () => { diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index 46da2ed569c..5b173415856 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -3049,7 +3049,11 @@ describe('CredentialsHelper', () => { source: null, } as IExecuteData; - const additionalData = mock({ projectId: 'proj-1' }); + const additionalData = mock({ + projectId: 'proj-1', + executionId: 'exec-1', + userId: 'user-1', + }); await helper.getDecrypted( additionalData, @@ -3060,16 +3064,69 @@ describe('CredentialsHelper', () => { true, ); - expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith({ - credentialType: 'testApi', - credentialId: 'cred-policy', - consumer: { nodeType: 'n8n-nodes-base.slack' }, - projectId: 'proj-1', + expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith( + { + credentialType: 'testApi', + credentialId: 'cred-policy', + consumer: { nodeType: 'n8n-nodes-base.slack' }, + projectId: 'proj-1', + }, + { kind: 'system', reason: 'execution', executionId: 'exec-1' }, + ); + }); + + test('names the user when the decrypt is outside a run, e.g. an OAuth flow', async () => { + const additionalData = mock({ + projectId: undefined, + executionId: undefined, + userId: 'user-1', }); + + await helper.getDecrypted( + additionalData, + nodeCredentials, + 'testApi', + 'internal', + undefined, + true, + ); + + expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + { kind: 'user', user: { id: 'user-1' } }, + ); + }); + + test('uses the actor the caller names over the derived one', async () => { + const additionalData = mock({ + projectId: undefined, + executionId: undefined, + userId: undefined, + }); + + await helper.getDecrypted( + additionalData, + nodeCredentials, + 'testApi', + 'internal', + undefined, + true, + undefined, + { actor: { kind: 'system', reason: 'log-streaming' } }, + ); + + expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith( + expect.anything(), + { kind: 'system', reason: 'log-streaming' }, + ); }); test('passes a null consumer when no node is asking, e.g. a credential test', async () => { - const additionalData = mock({ projectId: undefined }); + const additionalData = mock({ + projectId: undefined, + executionId: undefined, + userId: undefined, + }); await helper.getDecrypted( additionalData, @@ -3082,6 +3139,7 @@ describe('CredentialsHelper', () => { expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ consumer: null, projectId: null }), + { kind: 'system', reason: 'execution' }, ); }); diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 36713b0f107..95cd31b0dc1 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -45,6 +45,7 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import { ExternalHooks } from '@/external-hooks'; import { NodeTypes } from '@/node-types'; import { enforceWorkflowPublishPolicy } from '@/policy/enforce-workflow-publish'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { isPolicyRefusal } from '@/policy/policy-violation.error'; import { Publisher } from '@/scaling/pubsub/publisher.service'; @@ -540,7 +541,10 @@ export class ActiveWorkflowManager { workflowId: WorkflowId, activationMode: WorkflowActivateMode, existingWorkflow?: WorkflowEntity, - { shouldPublish } = { shouldPublish: true }, + { + shouldPublish = true, + actor = { kind: 'system', reason: 'activation' }, + }: { shouldPublish?: boolean; actor?: PolicyActor } = {}, ) { const added = { webhooks: false, triggersAndPollers: false }; @@ -601,11 +605,12 @@ export class ActiveWorkflowManager { // Trigger and poller nodes run code at registration, so this gates startup // and leadership change too, not just the activate button. - await enforceWorkflowPublishPolicy(this.policyEnforcementService, this.ownershipService, { - id: dbWorkflow.id, - name: dbWorkflow.name, - nodes, - }); + await enforceWorkflowPublishPolicy( + this.policyEnforcementService, + this.ownershipService, + { id: dbWorkflow.id, name: dbWorkflow.name, nodes }, + actor, + ); workflow = new Workflow({ id: dbWorkflow.id, diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 8f5843c0675..3b3706addbf 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -273,11 +273,14 @@ export class ImportCredentialsCommand extends BaseCommand; try { - cleared = await Container.get(PolicyEnforcementService).enforceContentImport({ - credential: { id: credential.id ?? null, type }, - projectId: credential.usageScope === 'instance' ? null : landingProjectId, - transport: 'cli', - }); + cleared = await Container.get(PolicyEnforcementService).enforceContentImport( + { + credential: { id: credential.id ?? null, type }, + projectId: credential.usageScope === 'instance' ? null : landingProjectId, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); } catch (error) { if (!(error instanceof PolicyViolationError)) throw error; diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index b673a0753e5..e73a7547a91 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -43,6 +43,7 @@ import { CredentialTypes } from '@/credential-types'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { DCR_MANAGED_CREDENTIAL_FIELDS, OAUTH_PINNED_FIELDS } from '@/oauth/dcr-managed-fields'; import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { AiGatewayService } from '@/services/ai-gateway.service'; @@ -100,6 +101,13 @@ const { nodeTypes: mockNodeTypes } = createMockNodeTypes(); const INVALID_JSON_VALUE = Symbol('invalidJsonValue'); +/** A run names no user: the starter is not known reliably on every path, so a run never guesses. */ +function decryptActor({ executionId, userId }: IWorkflowExecuteAdditionalData): PolicyActor { + if (executionId) return { kind: 'system', reason: 'execution', executionId }; + if (userId) return { kind: 'user', user: { id: userId } }; + return { kind: 'system', reason: 'execution' }; +} + @Service() export class CredentialsHelper extends ICredentialsHelper { constructor( @@ -556,7 +564,7 @@ export class CredentialsHelper extends ICredentialsHelper { executeData?: IExecuteData, raw?: boolean, expressionResolveValues?: ICredentialsExpressionResolveValues, - options?: IGetDecryptedCredentialsOptions, + options?: IGetDecryptedCredentialsOptions & { actor?: PolicyActor }, ): Promise { // Sub-nodes, such as a chat model connected to a chain or agent, inherit executeData.node // from their parent. Prefer expressionResolveValues.node when present: it is always @@ -578,12 +586,15 @@ export class CredentialsHelper extends ICredentialsHelper { const credentialsEntity = await this.getCredentialsEntity(nodeCredentials, type); // Validate against the executing project's policy before any decryption happens. - await this.policyEnforcementService.enforceCredentialDecrypt({ - credentialType: type, - credentialId: credentialsEntity.id, - consumer: consumerNode ? { nodeType: consumerNode.type } : null, - projectId: additionalData.projectId ?? null, - }); + await this.policyEnforcementService.enforceCredentialDecrypt( + { + credentialType: type, + credentialId: credentialsEntity.id, + consumer: consumerNode ? { nodeType: consumerNode.type } : null, + projectId: additionalData.projectId ?? null, + }, + options?.actor ?? decryptActor(additionalData), + ); const credentials = new Credentials( { id: credentialsEntity.id, name: credentialsEntity.name }, diff --git a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts index 1f3da0e8313..55df6566cd4 100644 --- a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts @@ -387,6 +387,7 @@ describe('CredentialsController', () => { expect.objectContaining({ isGlobal: true, }), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -570,6 +571,7 @@ describe('CredentialsController', () => { expect(updateSpy).toHaveBeenCalledWith( credentialId, expect.objectContaining({ description: prepared }), + { kind: 'user', user: req.user }, expect.anything(), expect.any(Object), ); @@ -686,6 +688,7 @@ describe('CredentialsController', () => { expect.objectContaining({ isGlobal: false, }), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -773,6 +776,7 @@ describe('CredentialsController', () => { expect(updateSpy).toHaveBeenCalledWith( credentialId, expect.any(Object), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -820,6 +824,7 @@ describe('CredentialsController', () => { expect.objectContaining({ isResolvable: true, }), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -897,6 +902,7 @@ describe('CredentialsController', () => { expect.objectContaining({ isResolvable: true, // Should keep the existing value }), + { kind: 'user', user: ownerReq.user }, expect.any(Object), expect.any(Object), ); @@ -1040,10 +1046,16 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); expect(getChangedSharedFieldsSpy).toHaveBeenCalled(); - expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object), { - deleteUserEntries: true, - user: ownerReq.user, - }); + expect(updateSpy).toHaveBeenCalledWith( + credentialId, + expect.any(Object), + { kind: 'user', user: ownerReq.user }, + expect.any(Object), + { + deleteUserEntries: true, + user: ownerReq.user, + }, + ); expect(emitSpy).toHaveBeenCalledWith('private-credential-connections-cleared', { user: ownerReq.user, credentialType: privateCredential.type, @@ -1070,10 +1082,16 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); - expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object), { - deleteUserEntries: false, - user: ownerReq.user, - }); + expect(updateSpy).toHaveBeenCalledWith( + credentialId, + expect.any(Object), + { kind: 'user', user: ownerReq.user }, + expect.any(Object), + { + deleteUserEntries: false, + user: ownerReq.user, + }, + ); const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]); expect(emittedEventNames).not.toContain('private-credential-connections-cleared'); }); @@ -1098,10 +1116,16 @@ describe('CredentialsController', () => { await credentialsController.updateCredentials(ownerReq); expect(getChangedSharedFieldsSpy).not.toHaveBeenCalled(); - expect(updateSpy).toHaveBeenCalledWith(credentialId, expect.any(Object), expect.any(Object), { - deleteUserEntries: true, - user: ownerReq.user, - }); + expect(updateSpy).toHaveBeenCalledWith( + credentialId, + expect.any(Object), + { kind: 'user', user: ownerReq.user }, + expect.any(Object), + { + deleteUserEntries: true, + user: ownerReq.user, + }, + ); }); it('should not emit toggle events when resolvable state is unchanged', async () => { @@ -1175,7 +1199,7 @@ describe('CredentialsController', () => { req.user, ['credential:update'], ); - expect(clearSpy).toHaveBeenCalledWith(credential); + expect(clearSpy).toHaveBeenCalledWith(credential, { kind: 'user', user: req.user }); expect(result).toEqual({ success: true }); }); diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 653bf3bc178..dc43d7bc674 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -49,6 +49,7 @@ import { CredentialsService } from '@/credentials/credentials.service'; import type { InstanceCredentialUseRegistry } from '@/credentials/instance-credential-use.registry'; import * as validation from '@/credentials/validation'; import type { CredentialsHelper } from '@/credentials-helper'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; @@ -69,6 +70,7 @@ import type { RoleService } from '@/services/role.service'; import { mockExistingCredential } from './credentials.test-data'; const ownerUser = mock({ id: 'owner-id', role: GLOBAL_OWNER_ROLE }); +const ownerActor: PolicyActor = { kind: 'user', user: ownerUser }; const memberUser = mock({ id: 'member-id', role: GLOBAL_MEMBER_ROLE }); /** A custom instance role that can see every credential but not use one. */ const viewOnlyUser = mock({ @@ -239,13 +241,18 @@ describe('CredentialsService', () => { .mockResolvedValue(mock()); const updateSpy = vi.spyOn(service, 'update').mockResolvedValue(mock()); - await service.clearOauthTokenData(credential); + await service.clearOauthTokenData(credential, ownerActor); const passedData = createEncryptedDataSpy.mock.calls[0][0].data; expect(passedData).not.toHaveProperty('oauthTokenData'); expect(passedData).not.toHaveProperty('accountIdentifier'); expect(passedData).toHaveProperty('clientId', 'abc'); - expect(updateSpy).toHaveBeenCalledWith(credential.id, expect.anything(), passedData); + expect(updateSpy).toHaveBeenCalledWith( + credential.id, + expect.anything(), + ownerActor, + passedData, + ); }); it('aborts without persisting when the credential cannot be decrypted', async () => { @@ -256,7 +263,9 @@ describe('CredentialsService', () => { const updateSpy = vi.spyOn(service, 'update'); // Must propagate the failure rather than overwrite the credential with empty data. - await expect(service.clearOauthTokenData(credential)).rejects.toThrow(decryptionError); + await expect(service.clearOauthTokenData(credential, ownerActor)).rejects.toThrow( + decryptionError, + ); expect(updateSpy).not.toHaveBeenCalled(); }); }); @@ -1540,7 +1549,7 @@ describe('CredentialsService', () => { return []; }); - const update = service.update(credential.id, encrypted, undefined, { + const update = service.update(credential.id, encrypted, ownerActor, undefined, { instanceCredential: credential, }); await vi.waitFor(() => expect(dbLockService.withLock).toHaveBeenCalledOnce()); @@ -1569,7 +1578,7 @@ describe('CredentialsService', () => { data: 'encrypted', }); - await expect(service.update(credential.id, encrypted)).resolves.toBe(credential); + await expect(service.update(credential.id, encrypted, ownerActor)).resolves.toBe(credential); expect(repositoryTransaction).toHaveBeenCalledExactlyOnceWith( { policyCleared: cleared }, @@ -1594,20 +1603,23 @@ describe('CredentialsService', () => { setRepositoryTransaction(transactionManager); const encrypted = mock({ id: credential.id, type: 'slackApi' }); - await service.update(credential.id, encrypted); + await service.update(credential.id, encrypted, ownerActor); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: credential.id, type: 'slackApi' }, - storedCredential: { id: credential.id, type: 'githubApi' }, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: credential.id, type: 'slackApi' }, + storedCredential: { id: credential.id, type: 'githubApi' }, + projectId: 'project-1', + }, + ownerActor, + ); }); it('returns null without checking policy when the credential does not exist', async () => { credentialsRepository.findOneBy.mockResolvedValue(null); await expect( - service.update('missing', mock({ id: 'missing' })), + service.update('missing', mock({ id: 'missing' }), ownerActor), ).resolves.toBeNull(); expect(policyEnforcementService.enforceCredentialSave).not.toHaveBeenCalled(); @@ -1623,7 +1635,11 @@ describe('CredentialsService', () => { ); await expect( - service.update('project-credential', mock({ id: 'project-credential' })), + service.update( + 'project-credential', + mock({ id: 'project-credential' }), + ownerActor, + ), ).rejects.toThrow(PolicyViolationError); expect(credentialsRepository.runInTransaction).not.toHaveBeenCalled(); @@ -1644,7 +1660,7 @@ describe('CredentialsService', () => { new Map([[credential.id, { accountIdentifier: 'me@gmail.com' }]]), ); - const result = await service.update(credential.id, encrypted, undefined, { + const result = await service.update(credential.id, encrypted, ownerActor, undefined, { user: ownerUser, }); @@ -1668,7 +1684,7 @@ describe('CredentialsService', () => { setRepositoryTransaction(transactionManager); const encrypted = mock({ id: credential.id }); - await service.update(credential.id, encrypted); + await service.update(credential.id, encrypted, ownerActor); expect(connectionStatusProxy.findMyConnections).not.toHaveBeenCalled(); }); @@ -1725,11 +1741,14 @@ describe('CredentialsService', () => { await service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: 'instance-credential', type: 'openAiApi' }, - storedCredential: { id: 'instance-credential', type: 'openAiApi' }, - projectId: null, - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: 'instance-credential', type: 'openAiApi' }, + storedCredential: { id: 'instance-credential', type: 'openAiApi' }, + projectId: null, + }, + { kind: 'user', user: ownerUser }, + ); }); it('writes nothing when policy refuses the update', async () => { @@ -1873,11 +1892,14 @@ describe('CredentialsService', () => { it('gates the create on a policy clearance for its type, with no project', async () => { await service.createInstanceCredential(dto, ownerUser, {}); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: null, type: 'apiKey' }, - storedCredential: null, - projectId: null, - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: null, type: 'apiKey' }, + storedCredential: null, + projectId: null, + }, + { kind: 'user', user: ownerUser }, + ); expect(credentialsRepository.saveInstanceCredential).toHaveBeenCalledWith( expect.objectContaining({ type: 'apiKey', usageScope: 'instance' }), { policyCleared: cleared }, @@ -3479,11 +3501,14 @@ describe('CredentialsService', () => { ownerUser, ); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: null, type: credentialData.type }, - storedCredential: null, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: null, type: credentialData.type }, + storedCredential: null, + projectId: 'project-1', + }, + { kind: 'user', user: ownerUser }, + ); expect(credentialsRepository.runInTransaction).toHaveBeenCalledWith( { policyCleared: cleared }, expect.any(Function), @@ -3773,11 +3798,14 @@ describe('CredentialsService', () => { [], { policyCleared: cleared }, ); - expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith({ - credential: { id: null, type: 'githubApi' }, - storedCredential: null, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceCredentialSave).toHaveBeenCalledExactlyOnceWith( + { + credential: { id: null, type: 'githubApi' }, + storedCredential: null, + projectId: 'project-1', + }, + { kind: 'user', user: ownerUser }, + ); expect(projectService.getProjectWithScope).toHaveBeenCalledWith(ownerUser, 'project-1', [ 'credential:create', ]); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 415eb041937..6d2a47ada44 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -378,6 +378,7 @@ export class CredentialsController { const responseData = await this.credentialsService.update( credentialId, newCredentialData, + { kind: 'user', user: req.user }, body.data ? (preparedCredentialData.data as unknown as ICredentialDataDecryptedObject) : undefined, @@ -468,7 +469,7 @@ export class CredentialsController { throw new BadRequestError('Only OAuth credentials can be disconnected'); } - await this.credentialsService.clearOauthTokenData(credential); + await this.credentialsService.clearOauthTokenData(credential, { kind: 'user', user: req.user }); this.logger.debug('Credential OAuth token cleared', { credentialId }); diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 6770f42253c..728b123f380 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -70,6 +70,7 @@ import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee import { DCR_MANAGED_CREDENTIAL_FIELDS } from '@/oauth/dcr-managed-fields'; import { validateOAuthUrl } from '@/oauth/validate-oauth-url'; import { userHasScopes } from '@/permissions.ee/check-access'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import type { CredentialRequest, ListQuery } from '@/requests'; import { CredentialsTester } from '@/services/credentials-tester.service'; @@ -949,7 +950,7 @@ export class CredentialsService { * deliberately carries `oauthTokenData` forward, so a "Disconnect" action on a * fixed OAuth credential needs this dedicated path. */ - async clearOauthTokenData(credential: CredentialsEntity): Promise { + async clearOauthTokenData(credential: CredentialsEntity, actor: PolicyActor): Promise { // Decrypt via the core credential so a decryption failure aborts the // disconnect. `this.decrypt` swallows CredentialDataError and returns `{}`, // which would otherwise overwrite the whole credential with empty data. @@ -964,7 +965,7 @@ export class CredentialsService { data: decryptedData, }); - await this.update(credential.id, newCredentialData, decryptedData); + await this.update(credential.id, newCredentialData, actor, decryptedData); } /** @@ -996,6 +997,7 @@ export class CredentialsService { async update( credentialId: string, newCredentialData: ICredentialsDb, + actor: PolicyActor, decryptedCredentialData?: ICredentialDataDecryptedObject, options?: UpdateOptions, ) { @@ -1003,7 +1005,7 @@ export class CredentialsService { await this.externalHooks.run('credentials.update', [newCredentialData]); await this.credentialDescriptions.stripIfDisabled(newCredentialData); - const cleared = await this.enforceCredentialUpdate(credentialId, newCredentialData.type); + const cleared = await this.enforceCredentialUpdate(credentialId, newCredentialData.type, actor); if (cleared === null) return null; const persist = async (transactionManager: EntityManager, ctx: OperationContext) => { @@ -1134,11 +1136,14 @@ export class CredentialsService { ); await this.validateInstanceCredentialUpdate(credential, hookedData, undefined, ctx); // The type cannot change here, so the check sees the same stored and new type. - const cleared = await this.policyEnforcementService.enforceCredentialSave({ - credential: { id: credential.id, type: credential.type }, - storedCredential: { id: credential.id, type: credential.type }, - projectId: null, - }); + const cleared = await this.policyEnforcementService.enforceCredentialSave( + { + credential: { id: credential.id, type: credential.type }, + storedCredential: { id: credential.id, type: credential.type }, + projectId: null, + }, + { kind: 'user', user }, + ); const updated = await this.credentialsRepository.updateInstanceCredential( credential.id, encrypted, @@ -1198,7 +1203,7 @@ export class CredentialsService { // Gate the save on policy before persisting, so the author learns about a blocked type // while setting the credential up rather than at run time. No stored credential: this one is new. - const cleared = await this.enforceCredentialCreate(newCredential.type, project.id); + const cleared = await this.enforceCredentialCreate(newCredential.type, project.id, user); const result = await this.credentialsRepository.runInTransaction( { policyCleared: cleared }, @@ -1245,26 +1250,29 @@ export class CredentialsService { private async enforceCredentialUpdate( credentialId: string, newType: string | undefined, + actor: PolicyActor, ): Promise | null> { const stored = await this.credentialsRepository.findOneBy({ id: credentialId }); if (!stored) return null; const owningProject = await this.sharedCredentialsRepository.findCredentialOwningProject(credentialId); - return await this.policyEnforcementService.enforceCredentialSave({ - credential: { id: credentialId, type: newType ?? stored.type }, - storedCredential: { id: credentialId, type: stored.type }, - projectId: owningProject?.id ?? null, - }); + return await this.policyEnforcementService.enforceCredentialSave( + { + credential: { id: credentialId, type: newType ?? stored.type }, + storedCredential: { id: credentialId, type: stored.type }, + projectId: owningProject?.id ?? null, + }, + actor, + ); } /** A create binds to its type: the row has no committed id yet, whatever the payload claims. */ - private async enforceCredentialCreate(type: string, projectId: string | null) { - return await this.policyEnforcementService.enforceCredentialSave({ - credential: { id: null, type }, - storedCredential: null, - projectId, - }); + private async enforceCredentialCreate(type: string, projectId: string | null, user: User) { + return await this.policyEnforcementService.enforceCredentialSave( + { credential: { id: null, type }, storedCredential: null, projectId }, + { kind: 'user', user }, + ); } private async insertProjectCredential( @@ -1293,7 +1301,7 @@ export class CredentialsService { "You don't have the permissions to save the credential in this project.", ); } - const cleared = await this.enforceCredentialCreate(newCredential.type, project.id); + const cleared = await this.enforceCredentialCreate(newCredential.type, project.id, user); const saved = await this.transactionRunner.run({ policyCleared: cleared }, async (ctx) => { return await this.credentialsRepository.insertProjectCredentialWithOwner( newCredential, @@ -2239,7 +2247,7 @@ export class CredentialsService { }); // After the hooks ran: the type checked has to be the type written. - const cleared = await this.enforceCredentialCreate(credentialEntity.type, null); + const cleared = await this.enforceCredentialCreate(credentialEntity.type, null, user); const savedCredential = await this.credentialsRepository.saveInstanceCredential( credentialEntity, { ...ctx, policyCleared: cleared }, diff --git a/packages/cli/src/eventbus/event-message-classes/event-message-audit.ts b/packages/cli/src/eventbus/event-message-classes/event-message-audit.ts index 120dfc30f1a..58d9b80c453 100644 --- a/packages/cli/src/eventbus/event-message-classes/event-message-audit.ts +++ b/packages/cli/src/eventbus/event-message-classes/event-message-audit.ts @@ -11,14 +11,16 @@ import type { AbstractEventPayload } from './abstract-event-payload'; // -------------------------------------- export interface EventPayloadAudit extends AbstractEventPayload { msg?: JsonValue; - userId?: string; + /** `null` when the event names no user. */ + userId?: string | null; userEmail?: string; firstName?: string; lastName?: string; credentialName?: string; credentialType?: string; - credentialId?: string; - workflowId?: string; + credentialId?: string | null; + /** `null` when the event has no workflow row, such as a create that was blocked. */ + workflowId?: string | null; workflowName?: string; projectId?: string | null; projectName?: string; diff --git a/packages/cli/src/eventbus/event-message-classes/index.ts b/packages/cli/src/eventbus/event-message-classes/index.ts index 7e84edb3ef3..b4f410da1c6 100644 --- a/packages/cli/src/eventbus/event-message-classes/index.ts +++ b/packages/cli/src/eventbus/event-message-classes/index.ts @@ -170,6 +170,7 @@ export const eventNamesAudit = [ 'n8n.audit.node-type-policy.document.updated', 'n8n.audit.node-type-policy.document.deleted', 'n8n.audit.node-type-policy.attachments.updated', + 'n8n.audit.policy.decision.blocked', ] as const; // Instance MCP server events. Kept as their own list and message class because the payload diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 5e448875a57..8db6db60e5f 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -1,5 +1,5 @@ import { EventService } from '@n8n/backend-services'; -import { GLOBAL_OWNER_ROLE, type IWorkflowDb } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, type IWorkflowDb, type User, type UserRepository } from '@n8n/db'; import type { InstanceSettings } from 'n8n-core'; import type { INode, IRun, IWorkflowBase, IWorkflowExecutionDataProcess } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; @@ -13,7 +13,8 @@ describe('LogStreamingEventRelay', () => { const eventService = new EventService(); const hostId = 'host-xyz'; const instanceSettings = mock({ hostId }); - new LogStreamingEventRelay(eventService, eventBus, instanceSettings).init(); + const userRepository = mock(); + new LogStreamingEventRelay(eventService, eventBus, instanceSettings, userRepository).init(); afterEach(() => { vi.clearAllMocks(); @@ -3027,6 +3028,164 @@ describe('LogStreamingEventRelay', () => { }); }); + describe('policy enforcement events', () => { + const blocked: RelayEventMap['policy-decision-blocked'] = { + point: 'workflowSave', + outcome: 'violation', + durationMs: 12, + checkIds: ['node-types'], + violations: [ + { + checkId: 'node-types', + kind: 'node-type-unavailable', + subject: 'n8n-nodes-base.slack', + subjectType: null, + scope: null, + matchedRuleId: null, + }, + ], + policyVersions: [{ scope: 'instance', version: 4 }], + workflowId: 'wf-1', + workflowName: 'My workflow', + projectId: 'proj-1', + actorType: 'user', + user: { + id: 'user-1', + email: 'alice@example.com', + firstName: 'Alice', + lastName: 'Admin', + role: { slug: 'global:admin' }, + }, + }; + + it('should log on `policy-decision-blocked` event, with the user redactable', () => { + eventService.emit('policy-decision-blocked', { ...blocked }); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: { + userId: 'user-1', + _email: 'alice@example.com', + _firstName: 'Alice', + _lastName: 'Admin', + globalRole: 'global:admin', + point: 'workflowSave', + outcome: 'violation', + durationMs: 12, + checkIds: ['node-types'], + violations: [ + { + checkId: 'node-types', + kind: 'node-type-unavailable', + subject: 'n8n-nodes-base.slack', + subjectType: null, + scope: null, + matchedRuleId: null, + }, + ], + policyVersions: [{ scope: 'instance', version: 4 }], + workflowId: 'wf-1', + workflowName: 'My workflow', + projectId: 'proj-1', + actorType: 'user', + }, + }); + }); + + it('should send a null user id and the reason when no user asked for the action', () => { + eventService.emit('policy-decision-blocked', { + ...blocked, + point: 'workflowStart', + outcome: 'checkFailure', + correlationIds: ['corr-1'], + policyVersions: undefined, + actorType: 'system', + user: null, + systemReason: 'execution', + executionId: 'exec-1', + }); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: expect.objectContaining({ + userId: null, + actorType: 'system', + systemReason: 'execution', + executionId: 'exec-1', + point: 'workflowStart', + outcome: 'checkFailure', + correlationIds: ['corr-1'], + policyVersions: [], + }), + }); + }); + + it('should not send an event when a destination credential was blocked', () => { + eventService.emit('policy-decision-blocked', { + ...blocked, + point: 'credentialDecrypt', + actorType: 'system', + user: null, + systemReason: 'log-streaming', + }); + + expect(eventBus.sendAuditEvent).not.toHaveBeenCalled(); + }); + + it('should read the user fields when the host passed only the user id', async () => { + userRepository.findByIdWithRole.mockResolvedValue( + mock({ + id: 'user-1', + email: 'alice@example.com', + firstName: 'Alice', + lastName: 'Admin', + role: { slug: 'global:admin' }, + }), + ); + + eventService.emit('policy-decision-blocked', { ...blocked, user: { id: 'user-1' } }); + + await vi.waitFor(() => + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: expect.objectContaining({ + userId: 'user-1', + _email: 'alice@example.com', + _firstName: 'Alice', + _lastName: 'Admin', + globalRole: 'global:admin', + }), + }), + ); + expect(userRepository.findByIdWithRole).toHaveBeenCalledWith('user-1'); + }); + + it.each([ + ['no longer exists', () => userRepository.findByIdWithRole.mockResolvedValue(null)], + [ + 'cannot be read', + () => userRepository.findByIdWithRole.mockRejectedValue(new Error('db down')), + ], + ])('should still send the event with the user id when the user %s', async (_, arrange) => { + arrange(); + + eventService.emit('policy-decision-blocked', { ...blocked, user: { id: 'user-1' } }); + + await vi.waitFor(() => + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: expect.objectContaining({ userId: 'user-1', actorType: 'user' }), + }), + ); + }); + + it('should not read the user when the host passed a full user', () => { + eventService.emit('policy-decision-blocked', { ...blocked }); + + expect(userRepository.findByIdWithRole).not.toHaveBeenCalled(); + }); + }); + describe('workflow review events', () => { const reviewer = { id: 'user123', diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index bb26886efb9..3770b87887b 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -25,6 +25,10 @@ import type { } from '@/modules/n8n-packages/n8n-packages.types'; import type { TokenExchangeFailureReason } from '@/modules/token-exchange/token-exchange.types'; import type { AdminCredentialSelection as InstanceAiCredentialSelection } from '@/modules/instance-ai/instance-ai-settings.service'; +import type { + AuditedActor, + PolicyDecisionAudit, +} from '@/modules/policy-infrastructure/policy-decision-audit'; import type { PolicyAction, PolicyAttachment, @@ -32,6 +36,7 @@ import type { } from '@/modules/type-availability-policies/policy-rule.types'; import type { TracingContext } from '@/modules/otel/tracing-context'; import type { McpCallerAuth } from '@/services/oauth-token-verifier-proxy.service'; +import type { UserLike } from '@/types/user-like.types'; import type { AiEventMap } from './ai.event-map'; @@ -43,15 +48,7 @@ export type WorkflowActionSource = | 'import' | 'review-approval'; -export type UserLike = { - id: string; - email?: string; - firstName?: string; - lastName?: string; - role?: { - slug: string; - }; -}; +export type { UserLike }; /** * Which write path produced a policy document event. A composed save emits a document event @@ -1402,4 +1399,10 @@ export type RelayEventMap = { }; // #endregion + + // #region Policy enforcement + + 'policy-decision-blocked': PolicyDecisionAudit & AuditedActor; + + // #endregion } & AiEventMap; diff --git a/packages/cli/src/events/relays/log-streaming.event-relay.ts b/packages/cli/src/events/relays/log-streaming.event-relay.ts index 8ff004f47ed..0f0015f5c5e 100644 --- a/packages/cli/src/events/relays/log-streaming.event-relay.ts +++ b/packages/cli/src/events/relays/log-streaming.event-relay.ts @@ -1,4 +1,5 @@ import { EventService } from '@n8n/backend-services'; +import { UserRepository } from '@n8n/db'; import { Redactable } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; @@ -68,6 +69,7 @@ export class LogStreamingEventRelay extends EventRelay { readonly eventService: EventService, private readonly eventBus: MessageEventBus, private readonly instanceSettings: InstanceSettings, + private readonly userRepository: UserRepository, ) { super(eventService); } @@ -124,6 +126,7 @@ export class LogStreamingEventRelay extends EventRelay { 'node-type-policy-document-deleted': (event) => this.nodeTypePolicyDocumentDeleted(event), 'node-type-policy-attachments-updated': (event) => this.nodeTypePolicyAttachmentsUpdated(event), + 'policy-decision-blocked': (event) => this.policyDecisionBlocked(event), 'external-secrets-provider-settings-saved': (event) => this.externalSecretsProviderSettingsSaved(event), 'external-secrets-provider-reloaded': (event) => this.externalSecretsProviderReloaded(event), @@ -909,6 +912,50 @@ export class LogStreamingEventRelay extends EventRelay { // #endregion + // #region Policy enforcement + + /** Hosts that know only the user id pass `{ id }`, so read the rest to keep the fields uniform. */ + private policyDecisionBlocked(event: RelayEventMap['policy-decision-blocked']) { + // The event would go to the destination whose credential was blocked, and block again. + if (event.actorType === 'system' && event.systemReason === 'log-streaming') return; + + if (event.actorType === 'system' || event.user.email !== undefined) { + this.sendPolicyDecisionBlocked(event); + return; + } + + void this.findAuditedUser(event.user).then((user) => + this.sendPolicyDecisionBlocked({ ...event, user }), + ); + } + + /** Falls back to the id alone, so a failed lookup never drops the event. */ + private async findAuditedUser(user: UserLike): Promise { + try { + return (await this.userRepository.findByIdWithRole(user.id)) ?? user; + } catch { + return user; + } + } + + @Redactable() + private sendPolicyDecisionBlocked({ + user, + policyVersions, + ...rest + }: RelayEventMap['policy-decision-blocked']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.policy.decision.blocked', + payload: { + ...(user ?? { userId: null }), + ...rest, + policyVersions: policyVersions?.map((version) => ({ ...version })) ?? [], + }, + }); + } + + // #endregion + // #region External Secrets Provider private externalSecretsProviderSettingsSaved( diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts index 307fc7b28d6..9f63d737cb9 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts @@ -677,6 +677,7 @@ describe('Slack setup services', () => { type: 'slackManagerOAuth2Api', data: 're-encrypted', }, + { kind: 'user', user }, rawData, ); } else { @@ -854,6 +855,7 @@ describe('Slack setup services', () => { expect(credentialsService.update).toHaveBeenCalledWith( 'manager', { data: 'encrypted' }, + { kind: 'system', reason: 'integration' }, expect.objectContaining({ oauthTokenData: expect.objectContaining({ authed_user: expect.objectContaining({ diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts index 892086efef0..ab18b15013d 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts @@ -251,7 +251,12 @@ export class SlackManagedSetupService { type: manager.credential.type, data: manager.rawData, }); - await this.credentialsService.update(options.credentialId, encrypted, manager.rawData); + await this.credentialsService.update( + options.credentialId, + encrypted, + { kind: 'user', user: options.user }, + manager.rawData, + ); } async installApp( @@ -980,7 +985,12 @@ export class SlackManagedSetupService { type: manager.credential.type, data: updatedData, }); - await this.credentialsService.update(manager.credential.id, encrypted, updatedData); + await this.credentialsService.update( + manager.credential.id, + encrypted, + { kind: 'system', reason: 'integration' }, + updatedData, + ); manager.oauthTokenData = oauthTokenData; manager.accessToken = refreshedAccessToken; } diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts index 738d6ddb903..1adef174d59 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts @@ -130,6 +130,7 @@ describe('ChatHubWorkflowService', () => { expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith( expect.objectContaining({ storedWorkflow: null, projectId: 'project-789' }), + { kind: 'user', user: { id: 'user-123' } }, ); expect(workflowRepository.createContent).toHaveBeenCalledWith( expect.anything(), @@ -174,6 +175,7 @@ describe('ChatHubWorkflowService', () => { expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'project-789' }), + { kind: 'user', user: { id: 'user-123' } }, ); expect(workflowRepository.createContent).toHaveBeenCalledWith( expect.anything(), @@ -207,6 +209,7 @@ describe('ChatHubWorkflowService', () => { workflow: expect.objectContaining({ id: 'workflow-1' }), projectId: 'project-1', }), + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, ); expect(workflowRepository.createContent).toHaveBeenCalledWith( expect.anything(), diff --git a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts index ce6b4938f99..0e774e22ad3 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts @@ -50,6 +50,7 @@ import { import { v4 as uuidv4 } from 'uuid'; import { BadRequestError } from '@n8n/errors'; +import type { UserLike } from '@/types/user-like.types'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; @@ -161,7 +162,7 @@ export class ChatHubWorkflowService { executionOrder: 'v1', }; - const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId); + const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId, { id: userId }); return await this.workflowRepository.runInTransaction( { ...ctx, policyCleared: cleared }, @@ -189,12 +190,19 @@ export class ChatHubWorkflowService { * Chat workflows are system-generated but policed like any other: the nodes are real, so a * blocked node type has to block the run rather than reach the engine. */ - private async enforceChatWorkflowSave(workflow: WorkflowEntity, projectId: string) { - return await this.policyEnforcementService.enforceWorkflowSave({ - workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes }, - storedWorkflow: null, - projectId, - }); + private async enforceChatWorkflowSave( + workflow: WorkflowEntity, + projectId: string, + user: UserLike, + ) { + return await this.policyEnforcementService.enforceWorkflowSave( + { + workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes }, + storedWorkflow: null, + projectId, + }, + { kind: 'user', user }, + ); } async createTitleGenerationWorkflow( @@ -241,7 +249,7 @@ export class ChatHubWorkflowService { saveDataSuccessExecution: 'all', }; - const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId); + const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId, { id: userId }); return await this.workflowRepository.runInTransaction( { ...ctx, policyCleared: cleared }, @@ -1889,7 +1897,7 @@ You can update the most recent document using the commands described above, or c executionOrder: 'v1', }; - const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId); + const cleared = await this.enforceChatWorkflowSave(newWorkflow, projectId, user); return await this.workflowRepository.runInTransaction( { ...ctx, policyCleared: cleared }, diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index cc1c5ac8c32..1831bcffa22 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -3778,11 +3778,14 @@ describe('createWorkflowAdapter', () => { await adapter.createFromWorkflowJSON(minimalWorkflowJSON); - expect(mockPolicyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith({ - workflow: { id: null, name: minimalWorkflowJSON.name, nodes: [] }, - storedWorkflow: null, - projectId: 'team-project-id', - }); + expect(mockPolicyEnforcementService.enforceWorkflowSave).toHaveBeenCalledWith( + { + workflow: { id: null, name: minimalWorkflowJSON.name, nodes: [] }, + storedWorkflow: null, + projectId: 'team-project-id', + }, + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, + ); expect(mockWorkflowRepository.runInTransaction).toHaveBeenCalledWith( { policyCleared: cleared }, expect.any(Function), diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index b53fc1832c1..a99ca558b65 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -1123,6 +1123,7 @@ describe('InstanceAiController', () => { expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith( [seedWorkflow], 'project-1', + expect.objectContaining({ id: USER_ID }), expect.any(Map), undefined, expect.any(Map), @@ -1166,6 +1167,7 @@ describe('InstanceAiController', () => { expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith( [seedWorkflow], 'project-1', + expect.objectContaining({ id: USER_ID }), idMap, undefined, expect.any(Map), @@ -1185,6 +1187,7 @@ describe('InstanceAiController', () => { expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith( [seedWorkflow], 'project-1', + expect.objectContaining({ id: USER_ID }), expect.any(Map), new Set(['cred-1', 'cred-2']), expect.any(Map), @@ -1295,6 +1298,7 @@ describe('InstanceAiController', () => { expect(evalThreadRestore.restoreWorkflows).toHaveBeenCalledWith( [placedWorkflow], 'project-1', + expect.objectContaining({ id: USER_ID }), expect.any(Map), undefined, folderIdMap, diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.integration.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.integration.test.ts index 2544c370831..0edf564abd4 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.integration.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.integration.test.ts @@ -1,6 +1,6 @@ import type { LicenseState } from '@n8n/backend-common'; import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; -import type { Project } from '@n8n/db'; +import type { Project, User } from '@n8n/db'; import { CredentialsRepository, SharedWorkflowRepository, @@ -250,7 +250,11 @@ describe('EvalThreadRestoreService.restoreWorkflows (policy seal)', () => { }); it('persists the seed workflow at its id and makes the project its owner', async () => { - const created = await service.restoreWorkflows([seed('wf-seeded-1', 'Allowed')], project.id); + const created = await service.restoreWorkflows( + [seed('wf-seeded-1', 'Allowed')], + project.id, + mock(), + ); expect(created).toEqual(['wf-seeded-1']); const stored = await workflowRepository.findById('wf-seeded-1'); @@ -264,6 +268,7 @@ describe('EvalThreadRestoreService.restoreWorkflows (policy seal)', () => { const restore = service.restoreWorkflows( [seed('wf-seeded-ok', 'Allowed'), seed('wf-seeded-blocked', 'Blocked')], project.id, + mock(), ); await expect(restore).rejects.toThrow(PolicyViolationError); diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts index ed34ea19ec5..70e007ae97e 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/thread-restore.service.test.ts @@ -83,6 +83,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-original', name: 'Daily digest', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ); expect(workflowRepo.createContent).toHaveBeenCalledTimes(1); @@ -104,14 +105,18 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'Daily digest', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ); const saved = workflowRepo.create.mock.calls[0][0]; - expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: null, name: 'Daily digest', nodes: saved.nodes }, - storedWorkflow: null, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: null, name: 'Daily digest', nodes: saved.nodes }, + storedWorkflow: null, + projectId: 'project-1', + }, + { kind: 'user', user: evalUser }, + ); expect(workflowRepo.runInTransaction).toHaveBeenCalledExactlyOnceWith( { policyCleared: cleared }, expect.any(Function), @@ -131,6 +136,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, ); expect(credentialsRepo.findByNameAndTypeInProject).toHaveBeenCalledExactlyOnceWith( @@ -160,6 +166,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, ); const saved = workflowRepo.create.mock.calls[0][0]; @@ -178,6 +185,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, new Map(), new Set(['cred-this-thread']), ); @@ -198,6 +206,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, ); expect(workflowRepo.create.mock.calls[0][0].nodes?.[0]).not.toHaveProperty('credentials'); @@ -210,6 +219,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-live', name: 'wf', nodes: [node], connections: {}, published: true }], 'project-1', + evalUser, ), ).rejects.toThrow( 'Seed workflow wf-live is published, but its slackApi credential "Slak" matched 0 project credentials (need exactly 1)', @@ -228,6 +238,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-live', name: 'wf', nodes: [node], connections: {}, published: true }], 'project-1', + evalUser, ), ).rejects.toThrow( 'Seed workflow wf-live is published, but its slackApi credential "Slack" matched 2 project credentials (need exactly 1)', @@ -243,6 +254,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-live', name: 'wf', nodes: [node], connections: {}, published: true }], 'project-1', + evalUser, ), ).rejects.toThrow( 'Seed workflow wf-live is published, but its slackApi credential reference has no name', @@ -339,16 +351,20 @@ describe('EvalThreadRestoreService', () => { const created = await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ); expect(workflowRepo.findByIds).toHaveBeenCalledWith(['wf-1'], { fields: ['id', 'name', 'nodes'], }); - expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: 'wf-1', name: 'wf', nodes: expect.any(Array) }, - storedWorkflow: { id: 'wf-1', name: 'Old name', nodes: storedNodes }, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: 'wf-1', name: 'wf', nodes: expect.any(Array) }, + storedWorkflow: { id: 'wf-1', name: 'Old name', nodes: storedNodes }, + projectId: 'project-1', + }, + { kind: 'user', user: evalUser }, + ); expect(workflowRepo.updateContent).toHaveBeenCalledExactlyOnceWith( 'wf-1', expect.objectContaining({ name: 'wf', active: false, versionId: expect.any(String) }), @@ -368,6 +384,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ), ).rejects.toThrow(BadRequestError); expect(policyEnforcementService.enforceWorkflowSave).not.toHaveBeenCalled(); @@ -379,6 +396,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [{ name: 'no type' }], connections: {} }], 'project-1', + evalUser, ), ).rejects.toThrow(BadRequestError); expect(policyEnforcementService.enforceWorkflowSave).not.toHaveBeenCalled(); @@ -405,6 +423,7 @@ describe('EvalThreadRestoreService', () => { { id: 'wf-blocked', name: 'Blocked', nodes: [makeNode()], connections: {} }, ], 'project-1', + evalUser, ); await expect(restore).rejects.toThrow(PolicyViolationError); @@ -427,6 +446,7 @@ describe('EvalThreadRestoreService', () => { service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, ), ).rejects.toBe(failure); expect(workflowRepo.createContent).not.toHaveBeenCalled(); @@ -574,6 +594,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'wf', nodes: [node], connections: {} }], 'project-1', + evalUser, new Map([['dt-old', 'dt-new']]), ); @@ -847,6 +868,7 @@ describe('EvalThreadRestoreService', () => { { id: 'wf-1', name: 'Rooted', nodes: [makeNode()], connections: {} }, ], 'project-1', + evalUser, new Map(), undefined, new Map([['odwFolder0001', 'real-odw']]), @@ -981,6 +1003,7 @@ describe('EvalThreadRestoreService', () => { }, ], 'project-1', + evalUser, new Map(), undefined, new Map([['odwFolder0001', 'real-odw']]), @@ -994,6 +1017,7 @@ describe('EvalThreadRestoreService', () => { await service.restoreWorkflows( [{ id: 'wf-1', name: 'Root', nodes: [makeNode()], connections: {} }], 'project-1', + evalUser, new Map(), undefined, new Map([['odwFolder0001', 'real-odw']]), @@ -1016,6 +1040,7 @@ describe('EvalThreadRestoreService', () => { }, ], 'project-1', + evalUser, ), ).rejects.toThrow(BadRequestError); diff --git a/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts b/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts index 677fe6e4480..c9beebbc92c 100644 --- a/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts +++ b/packages/cli/src/modules/instance-ai/eval/thread-restore.service.ts @@ -356,6 +356,7 @@ export class EvalThreadRestoreService { async restoreWorkflows( workflows: InstanceAiEvalSeedWorkflow[], projectId: string, + user: User, dataTableIdMap: Map = new Map(), allowedCredentialIds?: Set, folderIdMap: Map = new Map(), @@ -366,6 +367,7 @@ export class EvalThreadRestoreService { const isNew = await this.createWorkflowPinnedToId( workflow, projectId, + user, dataTableIdMap, allowedCredentialIds, folderIdMap, @@ -469,6 +471,7 @@ export class EvalThreadRestoreService { private async createWorkflowPinnedToId( workflow: InstanceAiEvalSeedWorkflow, projectId: string, + user: User, dataTableIdMap: Map, allowedCredentialIds: Set | undefined, folderIdMap: Map, @@ -538,7 +541,7 @@ export class EvalThreadRestoreService { versionId: randomUUID(), parentFolder: parentFolderId === null ? null : { id: parentFolderId }, }); - const cleared = await this.enforceSeedWorkflowSave(workflow, entity, stored, projectId); + const cleared = await this.enforceSeedWorkflowSave(workflow, entity, stored, projectId, user); await this.workflowRepo.runInTransaction({ policyCleared: cleared }, async (em, ctx) => { if (stored) { @@ -614,13 +617,17 @@ export class EvalThreadRestoreService { entity: WorkflowEntity, stored: PolicedWorkflow | null, projectId: string, + user: User, ): Promise> { try { - return await this.policyEnforcementService.enforceWorkflowSave({ - workflow: { id: stored?.id ?? null, name: entity.name, nodes: entity.nodes }, - storedWorkflow: stored, - projectId, - }); + return await this.policyEnforcementService.enforceWorkflowSave( + { + workflow: { id: stored?.id ?? null, name: entity.name, nodes: entity.nodes }, + storedWorkflow: stored, + projectId, + }, + { kind: 'user', user }, + ); } catch (error) { if (error instanceof PolicyViolationError && hasViolations(error.violations)) { throw new PolicyViolationError( diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index 3e13ac034a2..f764ea90fe6 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -1670,11 +1670,14 @@ export class InstanceAiAdapterService { // The shell has no nodes, so the real content check is the `update()` below. // This call is still needed: `createContent` refuses to write without a clearance. - const cleared = await policyEnforcementService.enforceWorkflowSave({ - workflow: { id: null, name: newWorkflow.name, nodes: newWorkflow.nodes }, - storedWorkflow: null, - projectId, - }); + const cleared = await policyEnforcementService.enforceWorkflowSave( + { + workflow: { id: null, name: newWorkflow.name, nodes: newWorkflow.nodes }, + storedWorkflow: null, + projectId, + }, + { kind: 'user', user }, + ); const saved = await workflowRepository.runInTransaction( { policyCleared: cleared }, diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index f1310fdcc54..904734a4353 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -1300,6 +1300,7 @@ export class InstanceAiController { createdWorkflowIds = await this.evalThreadRestore.restoreWorkflows( workflows, projectId, + req.user, idMap, allowedCredentialIds ? new Set(allowedCredentialIds) : undefined, folderIdMap, diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts index 5d5431e7279..31df5c08f23 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/message-event-bus-destination-webhook.ee.test.ts @@ -156,6 +156,8 @@ describe('MessageEventBusDestinationWebhook', () => { 'internal', undefined, false, + undefined, + { actor: { kind: 'system', reason: 'log-streaming' } }, ); expect(credentialsHelper.authenticate).toHaveBeenCalledWith( decrypted, diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts b/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts index 23337a9bbb1..ac71a531392 100644 --- a/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts +++ b/packages/cli/src/modules/log-streaming.ee/destinations/message-event-bus-destination-webhook.ee.ts @@ -179,6 +179,8 @@ export class MessageEventBusDestinationWebhook 'internal', undefined, raw, + undefined, + { actor: { kind: 'system', reason: 'log-streaming' } }, ); return credentialsDecrypted; } diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/content-import-policy.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/content-import-policy.test.ts index 3aa393f15eb..9907f889efd 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/content-import-policy.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/content-import-policy.test.ts @@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended'; import type { PolicyCleared } from '@n8n/decorators'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; @@ -58,6 +59,7 @@ describe('contentImportTransport', () => { describe('ContentImportPolicyGate', () => { const policyEnforcementService = mock(); const gate = new ContentImportPolicyGate(policyEnforcementService, mock()); + const actor: PolicyActor = { kind: 'user', user: { id: 'user-1' } }; beforeEach(() => { vi.clearAllMocks(); @@ -66,7 +68,9 @@ describe('ContentImportPolicyGate', () => { it('admits nothing when no check is registered', async () => { policyEnforcementService.hasChecksFor.mockReturnValue(false); - expect(await gate.refusedWorkflows([createItem('W1')], 'project-1', 'package')).toEqual([]); + expect(await gate.refusedWorkflows([createItem('W1')], 'project-1', 'package', actor)).toEqual( + [], + ); expect(policyEnforcementService.hasChecksFor).toHaveBeenCalledTimes(1); expect(policyEnforcementService.enforceContentImport).not.toHaveBeenCalled(); }); @@ -76,7 +80,12 @@ describe('ContentImportPolicyGate', () => { policyEnforcementService.enforceContentImport.mockResolvedValue(cleared); expect( - await gate.refusedWorkflows([createItem('W1'), createItem('W2')], 'project-1', 'package'), + await gate.refusedWorkflows( + [createItem('W1'), createItem('W2')], + 'project-1', + 'package', + actor, + ), ).toEqual([]); expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(2); }); @@ -85,7 +94,7 @@ describe('ContentImportPolicyGate', () => { policyEnforcementService.hasChecksFor.mockReturnValue(true); policyEnforcementService.enforceContentImport.mockResolvedValue(cleared); - await gate.refusedWorkflows([skipItem('W1'), createItem('W2')], 'project-1', 'package'); + await gate.refusedWorkflows([skipItem('W1'), createItem('W2')], 'project-1', 'package', actor); expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(1); }); @@ -98,18 +107,27 @@ describe('ContentImportPolicyGate', () => { [createItem('W1', 'new-id'), updateItem('W2', 'existing-id')], 'project-1', 'git-connection', + actor, ); - expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith(1, { - workflow: { id: 'new-id', name: 'W1', nodes: [] }, - projectId: 'project-1', - transport: 'git-connection', - }); - expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith(2, { - workflow: { id: 'existing-id', name: 'W2', nodes: [] }, - projectId: 'project-1', - transport: 'git-connection', - }); + expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith( + 1, + { + workflow: { id: 'new-id', name: 'W1', nodes: [] }, + projectId: 'project-1', + transport: 'git-connection', + }, + actor, + ); + expect(policyEnforcementService.enforceContentImport).toHaveBeenNthCalledWith( + 2, + { + workflow: { id: 'existing-id', name: 'W2', nodes: [] }, + projectId: 'project-1', + transport: 'git-connection', + }, + actor, + ); }); it('reports every refusal, not just the first', async () => { @@ -122,6 +140,7 @@ describe('ContentImportPolicyGate', () => { [createItem('W1'), createItem('W2')], 'project-1', 'package', + actor, ); expect(refused).toEqual([ @@ -140,6 +159,7 @@ describe('ContentImportPolicyGate', () => { [createItem('W1'), createItem('W2')], 'project-1', 'package', + actor, ); expect(refused).toHaveLength(1); @@ -151,8 +171,8 @@ describe('ContentImportPolicyGate', () => { const checkFailure = new Error('check exploded'); policyEnforcementService.enforceContentImport.mockRejectedValue(checkFailure); - await expect(gate.refusedWorkflows([createItem('W1')], 'project-1', 'package')).rejects.toBe( - checkFailure, - ); + await expect( + gate.refusedWorkflows([createItem('W1')], 'project-1', 'package', actor), + ).rejects.toBe(checkFailure); }); }); diff --git a/packages/cli/src/modules/n8n-packages/engine/content-import-policy.ts b/packages/cli/src/modules/n8n-packages/engine/content-import-policy.ts index bc48f06ac16..08fa784cae1 100644 --- a/packages/cli/src/modules/n8n-packages/engine/content-import-policy.ts +++ b/packages/cli/src/modules/n8n-packages/engine/content-import-policy.ts @@ -2,6 +2,7 @@ import { Logger } from '@n8n/backend-common'; import type { ContentImportTransport } from '@n8n/decorators'; import { Service } from '@n8n/di'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; @@ -36,6 +37,7 @@ export class ContentImportPolicyGate { items: WorkflowPlanItem[], projectId: string, transport: ContentImportTransport, + actor: PolicyActor, ): Promise { // Skips the whole loop rather than paying a per-workflow guard: a package can hold // hundreds, and having no policy at all is the common case. @@ -50,15 +52,18 @@ export class ContentImportPolicyGate { // The clearance is discarded: these writes still go through `WorkflowCreationService` // and `WorkflowService`, which mint their own `workflowSave` one. Threading this token // into them instead is its own change. - await this.policyEnforcementService.enforceContentImport({ - workflow: { - id: item.action === 'create' ? item.decidedId : item.existing.id, - name: item.entity.name, - nodes: item.entity.nodes, + await this.policyEnforcementService.enforceContentImport( + { + workflow: { + id: item.action === 'create' ? item.decidedId : item.existing.id, + name: item.entity.name, + nodes: item.entity.nodes, + }, + projectId, + transport, }, - projectId, - transport, - }); + actor, + ); } catch (error) { // Every refusal is collected, so the caller reports the whole package's worth at once. // A check that broke is not scoped to one workflow, so it fails the import outright diff --git a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts index 325b2ca5765..0a056a7c34d 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts @@ -257,6 +257,7 @@ export class ImportOrchestrator { workflowPlan.items, context.projectId, contentImportTransport(input.importSource), + { kind: 'user', user: context.user }, ); const blockingIssues = this.collectBlockingIssues({ diff --git a/packages/cli/src/modules/policy-infrastructure/README.md b/packages/cli/src/modules/policy-infrastructure/README.md index 645775f3336..7be6e0d08e1 100644 --- a/packages/cli/src/modules/policy-infrastructure/README.md +++ b/packages/cli/src/modules/policy-infrastructure/README.md @@ -33,7 +33,7 @@ flowchart LR end subgraph module["policy-infrastructure module (default, disable to opt out)"] - pds["PolicyDecisionService
deadline per check · all checks must pass
crash or timeout = fail closed
one audit line per veto"] + pds["PolicyDecisionService
deadline per check · all checks must pass
crash or timeout = fail closed
one audit line + one log streaming event per veto"] registry["PolicyCheckMetadata
registry in @n8n/decorators"] checks["@PolicyCheck() classes
onWorkflowSave · onWorkflowPublish · …"] end @@ -199,6 +199,35 @@ Two logging facts to know before relying on this: Policy _mutation_ audit — who changed a policy — is a different surface, owned by the feature that has a policy to mutate, on the existing audit-event infrastructure. +## The log streaming event + +The same emit site also sends `n8n.audit.policy.decision.blocked` to log streaming, so a +SIEM sees every block but one kind (see below). The payload is the audit line plus the actor. It does not depend on +the log format or on `N8N_LOG_SCOPES`. + +- **The host names the actor.** Every `enforce*` call takes a `PolicyActor` as its second + argument. Checks never see it. It is a user when the host is a request with an + authenticated user. Otherwise it is the system with a reason: `execution`, `cli-import`, + `activation`, `publication`, `integration` or `log-streaming`. +- **A run never names a user.** Not every execution path knows reliably who started the + run, so a block inside a run is `execution` and carries the `executionId`. Use the + execution to find who started it. This includes manual runs and sub-workflows. +- **The payload says which.** `actorType` is `user` or `system`. A system actor adds + `systemReason` and has `userId: null`. +- **`userId` always means the accountable human.** A future agent actor adds its own + fields and fills `userId` with the user it acts for, so SIEM rules on `userId` keep + their meaning. +- **The user fields are the same on every block.** A host that knows only the user id + passes `{ id }`, and the relay reads the rest of the user before it sends the event. The + fields are redactable, the same as on every other `n8n.audit.*` event. +- **A block on a destination credential sends no event.** A webhook destination decrypts + its credential on every delivery. If a policy blocks it, the event would go to the same + destination and block again. So the relay drops `log-streaming` blocks. The audit line + still records them. +- **Violations have fixed keys.** A field a check left out is `null`, not missing. +- **Workers and webhook processes send it too.** The log streaming module runs on + every instance type, so an event from a worker goes straight to the destinations. + ## The seal A cleared write needs a `PolicyCleared` token minted by the enforcement point. @@ -250,7 +279,7 @@ registry is read on every decision, so load order cannot hide a check. | File | Role | | -------------------------------------------- | -------------------------------------------------------------------------------------------- | | `policy-infrastructure.module.ts` | Registers `PolicyDecisionService` into the enforcement point and loads the lifecycle handler | -| `policy-decision.service.ts` | Runs the checks with deadlines, combines their results, and emits the audit line | +| `policy-decision.service.ts` | Runs the checks with deadlines, combines their results, and emits the audit line and event | | `policy-decision-audit.ts` | The audit line's shape and how it reads a target off each context | | `policy-lifecycle-handler.ts` | The `workflowStart` host, one hook for every way an execution starts | | `policy-check-failed.error.ts` | The 503 for a check that did not answer | diff --git a/packages/cli/src/modules/policy-infrastructure/__tests__/policy-decision.service.test.ts b/packages/cli/src/modules/policy-infrastructure/__tests__/policy-decision.service.test.ts index 32adb3aae6f..78f8050fa3c 100644 --- a/packages/cli/src/modules/policy-infrastructure/__tests__/policy-decision.service.test.ts +++ b/packages/cli/src/modules/policy-infrastructure/__tests__/policy-decision.service.test.ts @@ -1,3 +1,4 @@ +import type { EventService } from '@n8n/backend-services'; import { mockLogger } from '@n8n/backend-test-utils'; import { type CredentialDecryptContext, @@ -16,6 +17,7 @@ import { OperationalError } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import { classifyHttpError } from '@/errors/http-error-classifier'; +import type { PolicyActor } from '@/policy/policy-enforcement-backend'; import { serializeInternalRestError } from '@/errors/http-error-serializers'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; @@ -198,7 +200,7 @@ class StartOnlyCheck implements RegisteredPolicyCheck { const serviceWith = (...classes: PolicyCheckClass[]) => { const metadata = mock({ getClasses: () => classes }); - return new PolicyDecisionService(mockLogger(), metadata); + return new PolicyDecisionService(mockLogger(), metadata, mock()); }; /** @@ -208,23 +210,33 @@ const serviceWith = (...classes: PolicyCheckClass[]) => { const auditedServiceWith = (...classes: PolicyCheckClass[]) => { const logger = mockLogger(); const metadata = mock({ getClasses: () => classes }); + const eventService = mock(); return { - service: new PolicyDecisionService(logger, metadata), + service: new PolicyDecisionService(logger, metadata, eventService), audit: vi.mocked(logger.scoped('policy').warn), + emit: eventService.emit, }; }; +const alice = { id: 'user-1', email: 'alice@example.com', role: { slug: 'global:member' } }; +const asAlice: PolicyActor = { kind: 'user', user: alice }; +const unattended: PolicyActor = { kind: 'system', reason: 'execution' }; + describe('PolicyDecisionService', () => { describe('enforce', () => { it('allows when no checks are registered', async () => { - const decision = await serviceWith().enforce('workflowSave', saveContext); + const decision = await serviceWith().enforce('workflowSave', saveContext, unattended); expect(decision).toEqual({ violations: [] }); }); it('allows when every registered check is silent', async () => { - const decision = await serviceWith(SilentCheck).enforce('workflowSave', saveContext); + const decision = await serviceWith(SilentCheck).enforce( + 'workflowSave', + saveContext, + unattended, + ); expect(decision).toEqual({ violations: [] }); }); @@ -233,6 +245,7 @@ describe('PolicyDecisionService', () => { const decision = await serviceWith(SlackCheck, CodeCheck).enforce( 'workflowSave', saveContext, + unattended, ); expect(decision.violations).toEqual([slackBlocked, codeBlocked]); @@ -242,6 +255,7 @@ describe('PolicyDecisionService', () => { const decision = await serviceWith(SlackCheck, CodeCheck).enforce( 'workflowSave', saveContext, + unattended, ); expect(decision.policyVersions).toEqual([{ scope: 'instance', version: 4 }]); @@ -249,7 +263,7 @@ describe('PolicyDecisionService', () => { it('blocks when a check throws, without leaking why', async () => { const error = await serviceWith(SilentCheck, BrokenCheck) - .enforce('workflowSave', saveContext) + .enforce('workflowSave', saveContext, unattended) .catch((e: unknown) => e); expect(error).toBeInstanceOf(PolicyCheckFailedError); @@ -265,7 +279,7 @@ describe('PolicyDecisionService', () => { try { const pending = serviceWith(HangingCheck) - .enforce('workflowStart', startContext) + .enforce('workflowStart', startContext, unattended) .catch((e: unknown) => e); await vi.advanceTimersByTimeAsync(250); @@ -281,7 +295,7 @@ describe('PolicyDecisionService', () => { try { const pending = serviceWith(AbortAwareCheck) - .enforce('workflowStart', startContext) + .enforce('workflowStart', startContext, unattended) .catch((e: unknown) => e); await vi.advanceTimersByTimeAsync(250); @@ -296,7 +310,7 @@ describe('PolicyDecisionService', () => { it('only runs checks that implement the point', async () => { const service = serviceWith(StartOnlyCheck); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(Container.get(StartOnlyCheck).onWorkflowStart).not.toHaveBeenCalled(); }); @@ -319,7 +333,9 @@ describe('PolicyDecisionService', () => { const service = serviceWith(SilentCheck); expect(service.hasChecksFor('workflowSave')).toBe(true); - expect((await service.enforce('workflowSave', saveContext)).violations).toEqual([]); + expect((await service.enforce('workflowSave', saveContext, unattended)).violations).toEqual( + [], + ); expect(service.hasChecksFor('workflowStart')).toBe(false); }); }); @@ -355,7 +371,7 @@ describe('PolicyDecisionService', () => { it('writes one line naming the point, the objecting check and the violation', async () => { const { service, audit } = auditedServiceWith(SlackCheck); - await service.enforce('workflowSave', updateContext); + await service.enforce('workflowSave', updateContext, unattended); expect(audit).toHaveBeenCalledTimes(1); expect(audit).toHaveBeenCalledWith('Policy blocked workflowSave', { @@ -374,7 +390,7 @@ describe('PolicyDecisionService', () => { it('keeps the violation message out of the line', async () => { const { service, audit } = auditedServiceWith(SlackCheck); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(JSON.stringify(audit.mock.calls[0][1])).not.toContain('is not available'); }); @@ -382,7 +398,7 @@ describe('PolicyDecisionService', () => { it('writes one line for the decision, not one per objecting check', async () => { const { service, audit } = auditedServiceWith(SilentCheck, SlackCheck, CodeCheck); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(audit).toHaveBeenCalledTimes(1); expect(audit.mock.calls[0][1]).toMatchObject({ @@ -394,7 +410,7 @@ describe('PolicyDecisionService', () => { it('names the workflow when a create has no id to name', async () => { const { service, audit } = auditedServiceWith(SlackCheck); - await service.enforce('workflowSave', createContext); + await service.enforce('workflowSave', createContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ workflowId: null, @@ -405,7 +421,7 @@ describe('PolicyDecisionService', () => { it('does not record a create id the client supplied, which the seal discards too', async () => { const { service, audit } = auditedServiceWith(SlackCheck); - await service.enforce('workflowSave', createWithClaimedIdContext); + await service.enforce('workflowSave', createWithClaimedIdContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ workflowId: null, @@ -417,7 +433,7 @@ describe('PolicyDecisionService', () => { it('records the project a transfer moves into', async () => { const { service, audit } = auditedServiceWith(OtherPointsCheck); - await service.enforce('workflowTransfer', transferContext); + await service.enforce('workflowTransfer', transferContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ projectId: 'proj-2' }); }); @@ -425,7 +441,7 @@ describe('PolicyDecisionService', () => { it('records a credential create by type, with no id', async () => { const { service, audit } = auditedServiceWith(OtherPointsCheck); - await service.enforce('credentialSave', credentialCreateContext); + await service.enforce('credentialSave', credentialCreateContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ credentialId: null, @@ -438,7 +454,7 @@ describe('PolicyDecisionService', () => { it('records a credential update by its stored id', async () => { const { service, audit } = auditedServiceWith(OtherPointsCheck); - await service.enforce('credentialSave', credentialUpdateContext); + await service.enforce('credentialSave', credentialUpdateContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ credentialId: 'cred-1', @@ -450,7 +466,7 @@ describe('PolicyDecisionService', () => { it('records the credential and the node asking for it', async () => { const { service, audit } = auditedServiceWith(OtherPointsCheck); - await service.enforce('credentialDecrypt', decryptContext); + await service.enforce('credentialDecrypt', decryptContext, unattended); expect(audit.mock.calls[0][1]).toMatchObject({ credentialId: 'cred-1', @@ -465,7 +481,7 @@ describe('PolicyDecisionService', () => { const { service, audit } = auditedServiceWith(SilentCheck, BrokenCheck); const error = (await service - .enforce('workflowSave', saveContext) + .enforce('workflowSave', saveContext, unattended) .catch((e: unknown) => e)) as PolicyCheckFailedError; expect(audit).toHaveBeenCalledTimes(1); @@ -483,7 +499,7 @@ describe('PolicyDecisionService', () => { it('keeps what the answered checks said on a check failure line', async () => { const { service, audit } = auditedServiceWith(SlackCheck, BrokenCheck); - await service.enforce('workflowSave', saveContext).catch(() => {}); + await service.enforce('workflowSave', saveContext, unattended).catch(() => {}); expect(audit.mock.calls[0][1]).toMatchObject({ outcome: 'checkFailure', @@ -495,7 +511,7 @@ describe('PolicyDecisionService', () => { it('stays silent when every check is silent', async () => { const { service, audit } = auditedServiceWith(SilentCheck); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(audit).not.toHaveBeenCalled(); }); @@ -503,7 +519,7 @@ describe('PolicyDecisionService', () => { it('stays silent when no check is registered', async () => { const { service, audit } = auditedServiceWith(); - await service.enforce('workflowSave', saveContext); + await service.enforce('workflowSave', saveContext, unattended); expect(audit).not.toHaveBeenCalled(); }); @@ -530,7 +546,7 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(service); - await expect(proxy.enforceWorkflowSave(saveContext)).rejects.toBeInstanceOf( + await expect(proxy.enforceWorkflowSave(saveContext, unattended)).rejects.toBeInstanceOf( PolicyViolationError, ); expect(audit).toHaveBeenCalledTimes(1); @@ -541,17 +557,99 @@ describe('PolicyDecisionService', () => { }); }); + describe('log streaming event', () => { + it('emits one event with the point, checks, violations, versions and user', async () => { + const { service, emit } = auditedServiceWith(SlackCheck); + const proxy = new PolicyEnforcementService(); + proxy.setImplementation(service); + + await expect(proxy.enforceWorkflowSave(updateContext, asAlice)).rejects.toBeInstanceOf( + PolicyViolationError, + ); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith('policy-decision-blocked', { + point: 'workflowSave', + outcome: 'violation', + durationMs: expect.any(Number), + checkIds: ['node-types'], + violations: [slackAudited], + policyVersions: [{ scope: 'instance', version: 4 }], + workflowId: 'wf-1', + workflowName: 'My workflow', + projectId: 'proj-1', + actorType: 'user', + user: alice, + }); + }); + + it('emits a check failure with its correlation ids and no check internals', async () => { + const { service, emit } = auditedServiceWith(BrokenCheck); + + const error = (await service + .enforce('workflowSave', saveContext, asAlice) + .catch((e: unknown) => e)) as PolicyCheckFailedError; + + expect(emit).toHaveBeenCalledTimes(1); + const [, payload] = emit.mock.calls[0]; + expect(payload).toMatchObject({ + outcome: 'checkFailure', + correlationIds: error.meta.correlationIds, + }); + expect(JSON.stringify(payload)).not.toContain('policy store'); + }); + + it('sends a null user and the reason for a system actor', async () => { + const { service, emit } = auditedServiceWith(SlackCheck); + + await service.enforce('workflowSave', saveContext, unattended); + + expect(emit).toHaveBeenCalledWith( + 'policy-decision-blocked', + expect.objectContaining({ actorType: 'system', user: null, systemReason: 'execution' }), + ); + }); + + it('still writes the decision line alongside the event', async () => { + const { service, audit, emit } = auditedServiceWith(SlackCheck); + + await service.enforce('workflowSave', saveContext, asAlice); + + expect(audit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledTimes(1); + }); + + it('emits nothing for an allowed action', async () => { + const { service, emit } = auditedServiceWith(SilentCheck); + + await service.enforce('workflowSave', saveContext, asAlice); + + expect(emit).not.toHaveBeenCalled(); + }); + + it('emits nothing for an evaluate, even one that finds violations or breaks', async () => { + const { service, emit } = auditedServiceWith(SlackCheck, BrokenCheck); + + const decision = await service.evaluate('workflowSave', saveContext); + + expect(decision.violations).toEqual([slackBlocked]); + expect(emit).not.toHaveBeenCalled(); + }); + }); + describe('check resolution', () => { it('reads the registry per decision, so a check registered later still runs', async () => { const classes: PolicyCheckClass[] = []; const metadata = mock({ getClasses: () => classes }); - const service = new PolicyDecisionService(mockLogger(), metadata); + const service = new PolicyDecisionService(mockLogger(), metadata, mock()); - expect(await service.enforce('workflowSave', saveContext)).toEqual({ violations: [] }); + expect(await service.enforce('workflowSave', saveContext, unattended)).toEqual({ + violations: [], + }); classes.push(SlackCheck); - const decision = await service.enforce('workflowSave', saveContext); + const decision = await service.enforce('workflowSave', saveContext, unattended); expect(decision.violations).toEqual([slackBlocked]); }); }); @@ -561,7 +659,9 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(SlackCheck)); - const error = await proxy.enforceWorkflowSave(saveContext).catch((e: unknown) => e); + const error = await proxy + .enforceWorkflowSave(saveContext, unattended) + .catch((e: unknown) => e); expect(error).toBeInstanceOf(PolicyViolationError); expect((error as PolicyViolationError).violations).toEqual([slackBlocked]); @@ -571,7 +671,7 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(SilentCheck)); - const token = await proxy.enforceWorkflowSave(saveContext); + const token = await proxy.enforceWorkflowSave(saveContext, unattended); expect(token.subject).toEqual(workflowContentSubject(saveContext.workflow)); expect(token.subject.id).not.toBe('wf-1'); @@ -581,10 +681,10 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(SilentCheck)); - const token = await proxy.enforceWorkflowSave({ - ...saveContext, - storedWorkflow: saveContext.workflow, - }); + const token = await proxy.enforceWorkflowSave( + { ...saveContext, storedWorkflow: saveContext.workflow }, + unattended, + ); expect(token.subject).toEqual({ type: 'workflow', id: 'wf-1' }); }); @@ -593,7 +693,7 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(BrokenCheck)); - await expect(proxy.enforceWorkflowSave(saveContext)).rejects.toBeInstanceOf( + await expect(proxy.enforceWorkflowSave(saveContext, unattended)).rejects.toBeInstanceOf( PolicyCheckFailedError, ); }); @@ -602,7 +702,9 @@ describe('PolicyDecisionService', () => { const proxy = new PolicyEnforcementService(); proxy.setImplementation(serviceWith(BrokenCheck)); - const error = await proxy.enforceWorkflowSave(saveContext).catch((e: unknown) => e); + const error = await proxy + .enforceWorkflowSave(saveContext, unattended) + .catch((e: unknown) => e); const { status, body } = serializeInternalRestError(classifyHttpError(error as Error)); expect(status).toBe(503); diff --git a/packages/cli/src/modules/policy-infrastructure/__tests__/policy-lifecycle-handler.test.ts b/packages/cli/src/modules/policy-infrastructure/__tests__/policy-lifecycle-handler.test.ts index f8ef863e9f2..d0be9620307 100644 --- a/packages/cli/src/modules/policy-infrastructure/__tests__/policy-lifecycle-handler.test.ts +++ b/packages/cli/src/modules/policy-infrastructure/__tests__/policy-lifecycle-handler.test.ts @@ -55,9 +55,19 @@ describe('PolicyLifecycleHandler', () => { it('enforces the workflow start point with the workflow and its owning project', async () => { await handler.onWorkflowExecuteBefore(beforeContext()); - expect(policyEnforcementService.enforceWorkflowStart).toHaveBeenCalledExactlyOnceWith({ - workflow, - projectId: 'proj-1', + expect(policyEnforcementService.enforceWorkflowStart).toHaveBeenCalledExactlyOnceWith( + { workflow, projectId: 'proj-1' }, + { kind: 'system', reason: 'execution', executionId: 'exec-1' }, + ); + }); + + it('names the run, not a user, even for a manual run', async () => { + await handler.onWorkflowExecuteBefore(beforeContext({ mode: 'manual' })); + + expect(policyEnforcementService.enforceWorkflowStart).toHaveBeenCalledWith(expect.anything(), { + kind: 'system', + reason: 'execution', + executionId: 'exec-1', }); }); diff --git a/packages/cli/src/modules/policy-infrastructure/policy-decision-audit.ts b/packages/cli/src/modules/policy-infrastructure/policy-decision-audit.ts index 6aada425930..821f99de3f1 100644 --- a/packages/cli/src/modules/policy-infrastructure/policy-decision-audit.ts +++ b/packages/cli/src/modules/policy-infrastructure/policy-decision-audit.ts @@ -7,16 +7,25 @@ import type { PolicyVersionRef, } from '@n8n/decorators'; -import type { PolicyContext } from '@/policy/policy-enforcement-backend'; +import type { UserLike } from '@/types/user-like.types'; +import type { + PolicyActor, + PolicyContext, + PolicySystemReason, +} from '@/policy/policy-enforcement-backend'; /** Every context, as one union — a generic `PolicyContext` narrows against none. */ type AnyPolicyContext = PolicyContext; -/** The violation as the audit line records it. No `message`: free text saying what the fields say. */ -type AuditedViolation = Pick< - PolicyViolation, - 'checkId' | 'kind' | 'subject' | 'subjectType' | 'scope' | 'matchedRuleId' ->; +type OptionalAuditedKey = 'subject' | 'subjectType' | 'scope' | 'matchedRuleId'; + +/** + * The violation as the audit line records it. Every key is present, `null` when the check left + * it out, so a SIEM can map fixed fields. No `message`: free text saying what the fields say. + */ +type AuditedViolation = Pick & { + [Key in OptionalAuditedKey]-?: Exclude | null; +}; /** * One decision-audit line. @@ -53,20 +62,13 @@ export type PolicyDecisionAudit = { projectId: string | null; }; -const auditedViolation = ({ - checkId, - kind, - subject, - subjectType, - scope, - matchedRuleId, -}: PolicyViolation): AuditedViolation => ({ - checkId, - kind, - subject, - subjectType, - scope, - matchedRuleId, +const auditedViolation = (violation: PolicyViolation): AuditedViolation => ({ + checkId: violation.checkId, + kind: violation.kind, + subject: violation.subject ?? null, + subjectType: violation.subjectType ?? null, + scope: violation.scope ?? null, + matchedRuleId: violation.matchedRuleId ?? null, }); /** @@ -157,3 +159,22 @@ export function decisionAudit({ ...targetOf(context), }; } + +/** + * The actor as the log streaming event records it. `user` is always the accountable human, or + * `null` when there is none, and stays a field of its own so the relay can redact it. + */ +export type AuditedActor = + | { actorType: 'user'; user: UserLike } + | { actorType: 'system'; user: null; systemReason: PolicySystemReason; executionId?: string }; + +export function auditedActor(actor: PolicyActor): AuditedActor { + if (actor.kind === 'user') return { actorType: 'user', user: actor.user }; + + return { + actorType: 'system', + user: null, + systemReason: actor.reason, + ...(actor.executionId !== undefined && { executionId: actor.executionId }), + }; +} diff --git a/packages/cli/src/modules/policy-infrastructure/policy-decision.service.ts b/packages/cli/src/modules/policy-infrastructure/policy-decision.service.ts index f92f8ab410a..980adf4268d 100644 --- a/packages/cli/src/modules/policy-infrastructure/policy-decision.service.ts +++ b/packages/cli/src/modules/policy-infrastructure/policy-decision.service.ts @@ -1,4 +1,5 @@ import { Logger } from '@n8n/backend-common'; +import { EventService } from '@n8n/backend-services'; import type { EnforcementPoint, PolicyCheckFailure, @@ -11,11 +12,15 @@ import { Container, Service } from '@n8n/di'; import { OperationalError } from 'n8n-workflow'; import { randomUUID } from 'node:crypto'; -import type { PolicyContext, PolicyEnforcementBackend } from '@/policy/policy-enforcement-backend'; +import type { + PolicyActor, + PolicyContext, + PolicyEnforcementBackend, +} from '@/policy/policy-enforcement-backend'; import { PolicyCheckFailedError } from './policy-check-failed.error'; import type { DecisionAuditInput } from './policy-decision-audit'; -import { decisionAudit } from './policy-decision-audit'; +import { auditedActor, decisionAudit } from './policy-decision-audit'; /** * How long one check gets. Tight on the two points that sit inside a running execution: a wedged @@ -106,14 +111,16 @@ function decisionFrom(results: PolicyCheckResult[]): PolicyDecision { * Checks are combined by conjunction: all of them have to pass and every violation is reported, * so a user fixing a workflow sees the whole list rather than one at a time. * - * Either way of blocking writes one audit line from here, the single emit site for enforcement - * observability. `evaluate` stays silent, so previews never pollute the trail. + * Either way of blocking writes one audit line and one log streaming event from here, the single + * emit site for enforcement observability. `evaluate` stays silent, so previews never pollute + * the trail. */ @Service() export class PolicyDecisionService implements PolicyEnforcementBackend { constructor( private readonly logger: Logger, private readonly checkMetadata: PolicyCheckMetadata, + private readonly eventService: EventService, ) { this.logger = this.logger.scoped('policy'); } @@ -121,6 +128,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { async enforce( point: Point, context: PolicyContext, + actor: PolicyActor, ): Promise { const startedAt = Date.now(); const { checkIds, results, failures } = await this.runChecks(point, context); @@ -129,7 +137,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { const decision = decisionFrom(results); if (failures.length > 0) { - this.audit({ point, context, decision, durationMs, checkIds, failures }); + this.audit({ point, context, decision, durationMs, checkIds, failures }, actor); throw new PolicyCheckFailedError( point, @@ -139,7 +147,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { // The proxy throws on any violation, so this is the veto — and the only place it is logged. if (decision.violations.length > 0) { - this.audit({ point, context, decision, durationMs, checkIds }); + this.audit({ point, context, decision, durationMs, checkIds }, actor); } return decision; @@ -162,13 +170,13 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { /** * The one emit site for enforcement observability, so every policy feature gets the same - * line without building one. + * line and log streaming event without building them. * * `warn`, not `info`: a blocked action must survive an operator quietening logs. The * structured half only reaches the console under `N8N_LOG_FORMAT=json` — the text format * prints the message alone — so the message names the point on its own. */ - private audit(input: DecisionAuditInput) { + private audit(input: DecisionAuditInput, actor: PolicyActor) { const line = decisionAudit(input); const message = line.outcome === 'violation' @@ -176,6 +184,7 @@ export class PolicyDecisionService implements PolicyEnforcementBackend { : `Policy could not be verified for ${line.point}, so it was blocked`; this.logger.warn(message, line); + this.eventService.emit('policy-decision-blocked', { ...line, ...auditedActor(actor) }); } private async runChecks( diff --git a/packages/cli/src/modules/policy-infrastructure/policy-lifecycle-handler.ts b/packages/cli/src/modules/policy-infrastructure/policy-lifecycle-handler.ts index 74b8f8f5b62..f48a2856108 100644 --- a/packages/cli/src/modules/policy-infrastructure/policy-lifecycle-handler.ts +++ b/packages/cli/src/modules/policy-infrastructure/policy-lifecycle-handler.ts @@ -33,9 +33,9 @@ export class PolicyLifecycleHandler { // lookup fails the run. The other pre-execution gates let this throw too. const project = await this.ownershipService.getWorkflowProjectCached(ctx.workflow.id); - await this.policyEnforcementService.enforceWorkflowStart({ - workflow: ctx.workflow, - projectId: project.id, - }); + await this.policyEnforcementService.enforceWorkflowStart( + { workflow: ctx.workflow, projectId: project.id }, + { kind: 'system', reason: 'execution', executionId: ctx.executionId }, + ); } } diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts index f41d30cc537..a1894b6893b 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts @@ -1663,15 +1663,18 @@ describe('SourceControlImportService', () => { await service.importWorkflowFromWorkFolder(candidates, mockUserId); - expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { - id: mockWorkflowData.id, - name: mockWorkflowData.name, - nodes: mockWorkflowData.nodes, + expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { + id: mockWorkflowData.id, + name: mockWorkflowData.name, + nodes: mockWorkflowData.nodes, + }, + projectId: 'personal-project-id-123', + transport: 'source-control', }, - projectId: 'personal-project-id-123', - transport: 'source-control', - }); + { kind: 'user', user: { id: mockUserId } }, + ); }); // The clearance is checked at the write, so enforcing after the upsert would seal @@ -2830,11 +2833,14 @@ describe('SourceControlImportService', () => { await service.importCredentialsFromWorkFolder(candidates, mockUserId); - expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - credential: { id: mockCredentialData.id, type: mockCredentialData.type }, - projectId: mockPersonalProject.id, - transport: 'source-control', - }); + expect(policyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + credential: { id: mockCredentialData.id, type: mockCredentialData.type }, + projectId: mockPersonalProject.id, + transport: 'source-control', + }, + { kind: 'user', user: { id: mockUserId } }, + ); }); // The clearance is checked at the write, so enforcing after the upsert would seal diff --git a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts index b63b7c4f67c..01f45ee0161 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts @@ -863,11 +863,14 @@ export class SourceControlImportService { // skip after that point would leave it stopped with nothing imported in its place. let cleared: PolicyCleared<'contentImport'>; try { - cleared = await this.policyEnforcementService.enforceContentImport({ - workflow: { id, name: importedWorkflow.name, nodes }, - projectId: targetOwnerProject.id, - transport: 'source-control', - }); + cleared = await this.policyEnforcementService.enforceContentImport( + { + workflow: { id, name: importedWorkflow.name, nodes }, + projectId: targetOwnerProject.id, + transport: 'source-control', + }, + { kind: 'user', user: { id: userId } }, + ); } catch (error) { // A blocked workflow is skipped, not fatal — the rest of the pull still lands. A check // that broke is not scoped to one workflow, so it fails the pull rather than silently @@ -1116,11 +1119,14 @@ export class SourceControlImportService { // its stored data can't be read. let cleared: PolicyCleared<'contentImport'>; try { - cleared = await this.policyEnforcementService.enforceContentImport({ - credential: { id: credential.id ?? null, type }, - projectId: targetOwnerProject.id, - transport: 'source-control', - }); + cleared = await this.policyEnforcementService.enforceContentImport( + { + credential: { id: credential.id ?? null, type }, + projectId: targetOwnerProject.id, + transport: 'source-control', + }, + { kind: 'user', user: { id: userId } }, + ); } catch (error) { if (!(error instanceof PolicyViolationError)) throw error; diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts index af71596c33b..e40bafebe27 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts @@ -632,8 +632,8 @@ describe('auto-close on source-control pull', () => { mock({ // The repository verifies the token, so it has to be a real one. With no backend // registered the real service clears everything, which is what a default pull does. - enforceContentImport: async (context) => - await Container.get(PolicyEnforcementService).enforceContentImport(context), + enforceContentImport: async (context, actor) => + await Container.get(PolicyEnforcementService).enforceContentImport(context, actor), }), // policyEnforcementService mock(), // dataTableSizeValidator Container.get(WorkflowPublishedVersionRepository), diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts index 231b45e2d24..67c3ad4063d 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -1223,6 +1223,8 @@ describe('OauthService', () => { // Flow state read from cache and consumed (replay protection) expect(result[4]).toEqual({ csrfSecret: 'csrf-secret', codeVerifier: 'code-verifier' }); expect(cacheService.delete).toHaveBeenCalledWith(`oauth:flow:${stateToken}`); + // Names the user on a policy block while the credential is decrypted. + expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalledWith({ userId: 'user-id' }); }); it('should reject the callback when the flow state is missing (replay / unknown state)', async () => { @@ -1437,6 +1439,8 @@ describe('OauthService', () => { // Should succeed despite no user because origin is dynamic-credential expect(result[0]).toEqual(mockCredential); + // The starter comes from the state, since the callback carries no user. + expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalledWith({ userId: 'user-id' }); expect(result[1]).toEqual(mockDecryptedData); expect(result[2]).toEqual(mockOAuthCredentials); expect(result[3]).toMatchObject({ @@ -2035,6 +2039,7 @@ describe('OauthService', () => { }); expect(authUri).toContain('https://example.domain/oauth2/auth'); + expect(service.getOAuthCredentials).toHaveBeenCalledWith(credential, 'user-id'); // CSRF/PKCE state must not be persisted to the credential; it lives in the cache. expect(service.encryptAndSaveData).not.toHaveBeenCalled(); expect(cacheService.set).toHaveBeenCalledWith( diff --git a/packages/cli/src/oauth/oauth.service.ts b/packages/cli/src/oauth/oauth.service.ts index b1e7bdcfaad..42d81fc0224 100644 --- a/packages/cli/src/oauth/oauth.service.ts +++ b/packages/cli/src/oauth/oauth.service.ts @@ -111,6 +111,10 @@ export const skipAuthOnOAuthCallback = shouldSkipAuthOnOAuthCallback(); export { OauthVersion, type OAuth1CredentialData, type CreateCsrfStateData, type CsrfState }; +/** The user who started the flow. An externally started dynamic-credential flow has none. */ +const csrfUserId = (csrfData: CreateCsrfStateData) => + typeof csrfData.userId === 'string' ? csrfData.userId : undefined; + export class InvalidTargetError extends BadRequestError { constructor(message: string) { super(message, undefined, 'invalid_target'); @@ -345,8 +349,9 @@ export class OauthService { }; } - protected async getAdditionalData() { - return await WorkflowExecuteAdditionalData.getBase(); + /** `userId` names the user on a policy block while the credential is decrypted. */ + protected async getAdditionalData(userId?: string) { + return await WorkflowExecuteAdditionalData.getBase({ userId }); } /** @@ -609,7 +614,8 @@ export class OauthService { throw new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL); } - const additionalData = await this.getAdditionalData(); + // The state holds the verified starter; the callback request may carry no user at all. + const additionalData = await this.getAdditionalData(csrfUserId(state)); const decryptedDataOriginal = await this.getDecryptedDataForCallback( credential, additionalData, @@ -661,8 +667,8 @@ export class OauthService { return causes.length ? causes.join(': ') : undefined; } - async getOAuthCredentials(credential: CredentialsEntity): Promise { - const additionalData = await this.getAdditionalData(); + async getOAuthCredentials(credential: CredentialsEntity, userId?: string): Promise { + const additionalData = await this.getAdditionalData(userId); const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(credential, additionalData); // At some point in the past we saved hidden scopes to credentials (but shouldn't) @@ -1013,7 +1019,7 @@ export class OauthService { this.applyBrowserBindingIfEnabled(csrfData, req, res); const oauthCredentials: OAuth2CredentialData = - await this.getOAuthCredentials(credential); + await this.getOAuthCredentials(credential, csrfUserId(csrfData)); const toUpdate: ICredentialDataDecryptedObject = {}; const toDelete: string[] = []; @@ -1252,7 +1258,7 @@ export class OauthService { this.applyBrowserBindingIfEnabled(csrfData, req, res); const oauthCredentials: OAuth1CredentialData = - await this.getOAuthCredentials(credential); + await this.getOAuthCredentials(credential, csrfUserId(csrfData)); this.validateOAuthUrlOrThrow(oauthCredentials.authUrl ?? ''); this.validateOAuthUrlOrThrow(oauthCredentials.requestTokenUrl ?? ''); diff --git a/packages/cli/src/policy/__tests__/enforce-workflow-publish.test.ts b/packages/cli/src/policy/__tests__/enforce-workflow-publish.test.ts index 8f7e44bd654..12d46f329b4 100644 --- a/packages/cli/src/policy/__tests__/enforce-workflow-publish.test.ts +++ b/packages/cli/src/policy/__tests__/enforce-workflow-publish.test.ts @@ -4,12 +4,14 @@ import { mock } from 'vitest-mock-extended'; import type { OwnershipService } from '@/services/ownership.service'; import { enforceWorkflowPublishPolicy } from '../enforce-workflow-publish'; +import type { PolicyActor } from '../policy-enforcement-backend'; import type { PolicyEnforcementService } from '../policy-enforcement.service'; describe('enforceWorkflowPublishPolicy', () => { const policyEnforcementService = mock(); const ownershipService = mock(); const workflow = { id: 'workflow-1', name: 'Test Workflow', nodes: [] }; + const actor: PolicyActor = { kind: 'user', user: { id: 'user-1' } }; beforeEach(() => { vi.clearAllMocks(); @@ -20,23 +22,26 @@ describe('enforceWorkflowPublishPolicy', () => { it('skips the project lookup and the check when nothing is registered for workflowPublish', async () => { policyEnforcementService.hasChecksFor.mockReturnValue(false); - await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow); + await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow, actor); expect(policyEnforcementService.hasChecksFor).toHaveBeenCalledWith('workflowPublish'); expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled(); expect(policyEnforcementService.enforceWorkflowPublish).not.toHaveBeenCalled(); }); - it('enforces with the owning project when a check is registered', async () => { + it('enforces with the owning project and the actor when a check is registered', async () => { policyEnforcementService.hasChecksFor.mockReturnValue(true); - await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow); + await enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow, actor); expect(ownershipService.getWorkflowProjectCached).toHaveBeenCalledWith('workflow-1'); - expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledWith({ - workflow, - projectId: 'project-1', - }); + expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledWith( + { + workflow, + projectId: 'project-1', + }, + actor, + ); }); // An unevaluated project rule is not a passed one, so the lookup is unguarded. @@ -45,7 +50,7 @@ describe('enforceWorkflowPublishPolicy', () => { ownershipService.getWorkflowProjectCached.mockRejectedValue(new Error('no owner row')); await expect( - enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow), + enforceWorkflowPublishPolicy(policyEnforcementService, ownershipService, workflow, actor), ).rejects.toThrow('no owner row'); expect(policyEnforcementService.enforceWorkflowPublish).not.toHaveBeenCalled(); diff --git a/packages/cli/src/policy/__tests__/policy-enforcement.service.test.ts b/packages/cli/src/policy/__tests__/policy-enforcement.service.test.ts index 65bb1093c17..eba72d59fd1 100644 --- a/packages/cli/src/policy/__tests__/policy-enforcement.service.test.ts +++ b/packages/cli/src/policy/__tests__/policy-enforcement.service.test.ts @@ -2,7 +2,7 @@ import type { PolicedWorkflow, PolicyDecision, PolicyViolation } from '@n8n/deco import { UnexpectedError } from 'n8n-workflow'; import { mock, type MockProxy } from 'vitest-mock-extended'; -import type { PolicyEnforcementBackend } from '../policy-enforcement-backend'; +import type { PolicyActor, PolicyEnforcementBackend } from '../policy-enforcement-backend'; import { PolicyEnforcementService } from '../policy-enforcement.service'; import { PolicyViolationError } from '../policy-violation.error'; @@ -19,39 +19,60 @@ const cleared: PolicyDecision = { violations: [] }; /** One call per point, so a new point can't be left out of the no-op guarantee. */ const enforceCalls = (service: PolicyEnforcementService) => ({ workflowSave: async () => - await service.enforceWorkflowSave({ - workflow: savedWorkflow, - storedWorkflow: null, - projectId: 'proj-1', - }), + await service.enforceWorkflowSave( + { + workflow: savedWorkflow, + storedWorkflow: null, + projectId: 'proj-1', + }, + { kind: 'system', reason: 'execution' }, + ), workflowPublish: async () => - await service.enforceWorkflowPublish({ workflow: savedWorkflow, projectId: 'proj-1' }), + await service.enforceWorkflowPublish( + { workflow: savedWorkflow, projectId: 'proj-1' }, + { kind: 'system', reason: 'execution' }, + ), workflowStart: async () => - await service.enforceWorkflowStart({ workflow: savedWorkflow, projectId: 'proj-1' }), + await service.enforceWorkflowStart( + { workflow: savedWorkflow, projectId: 'proj-1' }, + { kind: 'system', reason: 'execution' }, + ), workflowTransfer: async () => - await service.enforceWorkflowTransfer({ - workflow: savedWorkflow, - targetProjectId: 'proj-2', - }), + await service.enforceWorkflowTransfer( + { + workflow: savedWorkflow, + targetProjectId: 'proj-2', + }, + { kind: 'system', reason: 'execution' }, + ), credentialSave: async () => - await service.enforceCredentialSave({ - credential: { id: null, type: 'slackApi' }, - storedCredential: null, - projectId: 'proj-1', - }), + await service.enforceCredentialSave( + { + credential: { id: null, type: 'slackApi' }, + storedCredential: null, + projectId: 'proj-1', + }, + { kind: 'system', reason: 'execution' }, + ), credentialDecrypt: async () => - await service.enforceCredentialDecrypt({ - credentialType: 'slackApi', - credentialId: 'cred-1', - consumer: null, - projectId: 'proj-1', - }), + await service.enforceCredentialDecrypt( + { + credentialType: 'slackApi', + credentialId: 'cred-1', + consumer: null, + projectId: 'proj-1', + }, + { kind: 'system', reason: 'execution' }, + ), contentImport: async () => - await service.enforceContentImport({ - workflow: savedWorkflow, - projectId: 'proj-1', - transport: 'cli', - }), + await service.enforceContentImport( + { + workflow: savedWorkflow, + projectId: 'proj-1', + transport: 'cli', + }, + { kind: 'system', reason: 'execution' }, + ), }); const evaluateCalls = (service: PolicyEnforcementService) => ({ @@ -166,14 +187,17 @@ describe('PolicyEnforcementService', () => { backend.enforce.mockResolvedValue({ violations: [violation, second] }); const error = await service - .enforceWorkflowSave({ workflow: savedWorkflow, storedWorkflow: null, projectId: null }) + .enforceWorkflowSave( + { workflow: savedWorkflow, storedWorkflow: null, projectId: null }, + { kind: 'system', reason: 'execution' }, + ) .catch((e: unknown) => e); expect(error).toBeInstanceOf(PolicyViolationError); expect((error as PolicyViolationError).violations).toEqual([violation, second]); }); - it('passes the point and context through and mints the decision it got back', async () => { + it('passes the point, context and actor through and mints the decision it got back', async () => { const decision: PolicyDecision = { violations: [], policyVersions: [{ scope: 'instance', version: 7 }], @@ -181,9 +205,11 @@ describe('PolicyEnforcementService', () => { backend.enforce.mockResolvedValue(decision); const context = { workflow: savedWorkflow, storedWorkflow: null, projectId: 'proj-1' }; - const token = await service.enforceWorkflowSave(context); + const actor: PolicyActor = { kind: 'user', user: { id: 'user-1' } }; - expect(backend.enforce).toHaveBeenCalledWith('workflowSave', context); + const token = await service.enforceWorkflowSave(context, actor); + + expect(backend.enforce).toHaveBeenCalledWith('workflowSave', context, actor); expect(token.decision).toBe(decision); expect(token.policyVersions).toEqual([{ scope: 'instance', version: 7 }]); }); @@ -205,10 +231,13 @@ describe('PolicyEnforcementService', () => { const service = new PolicyEnforcementService(); it('binds a saved workflow to its id', async () => { - const token = await service.enforceWorkflowStart({ - workflow: savedWorkflow, - projectId: null, - }); + const token = await service.enforceWorkflowStart( + { + workflow: savedWorkflow, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject).toEqual({ type: 'workflow', id: 'wf-1' }); }); @@ -216,11 +245,14 @@ describe('PolicyEnforcementService', () => { it('binds an unsaved workflow to a hash of its nodes', async () => { const unsaved: PolicedWorkflow = { id: null, name: 'New', nodes: [] }; - const token = await service.enforceWorkflowSave({ - workflow: unsaved, - storedWorkflow: null, - projectId: null, - }); + const token = await service.enforceWorkflowSave( + { + workflow: unsaved, + storedWorkflow: null, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject.type).toBe('workflow'); expect(token.subject.id).toMatch(/^[0-9a-f]{64}$/); @@ -231,11 +263,14 @@ describe('PolicyEnforcementService', () => { it('binds a create with a supplied id to a hash of its nodes', async () => { const withClientId: PolicedWorkflow = { id: 'wf-client', name: 'New', nodes: [] }; - const token = await service.enforceWorkflowSave({ - workflow: withClientId, - storedWorkflow: null, - projectId: null, - }); + const token = await service.enforceWorkflowSave( + { + workflow: withClientId, + storedWorkflow: null, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject.id).toMatch(/^[0-9a-f]{64}$/); expect(token.subject.id).not.toBe('wf-client'); @@ -243,11 +278,14 @@ describe('PolicyEnforcementService', () => { it('gives two unsaved workflows with different nodes different subjects', async () => { const enforce = async (nodes: PolicedWorkflow['nodes']) => - await service.enforceWorkflowSave({ - workflow: { id: null, name: 'New', nodes }, - storedWorkflow: null, - projectId: null, - }); + await service.enforceWorkflowSave( + { + workflow: { id: null, name: 'New', nodes }, + storedWorkflow: null, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); const empty = await enforce([]); const withNode = await enforce([mock({ type: 'slack' })]); @@ -256,33 +294,42 @@ describe('PolicyEnforcementService', () => { }); it('binds a credential create to a hash of its type', async () => { - const token = await service.enforceCredentialSave({ - credential: { id: null, type: 'slackApi' }, - storedCredential: null, - projectId: null, - }); + const token = await service.enforceCredentialSave( + { + credential: { id: null, type: 'slackApi' }, + storedCredential: null, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject.type).toBe('credential'); expect(token.subject.id).toMatch(/^[0-9a-f]{64}$/); }); it('binds a credential update to the row id', async () => { - const token = await service.enforceCredentialSave({ - credential: { id: 'cred-1', type: 'slackApi' }, - storedCredential: { id: 'cred-1', type: 'slackApi' }, - projectId: null, - }); + const token = await service.enforceCredentialSave( + { + credential: { id: 'cred-1', type: 'slackApi' }, + storedCredential: { id: 'cred-1', type: 'slackApi' }, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject).toEqual({ type: 'credential', id: 'cred-1' }); }); it('binds a credential decrypt to the credential', async () => { - const token = await service.enforceCredentialDecrypt({ - credentialType: 'slackApi', - credentialId: 'cred-1', - consumer: { nodeType: 'n8n-nodes-base.slack' }, - projectId: null, - }); + const token = await service.enforceCredentialDecrypt( + { + credentialType: 'slackApi', + credentialId: 'cred-1', + consumer: { nodeType: 'n8n-nodes-base.slack' }, + projectId: null, + }, + { kind: 'system', reason: 'execution' }, + ); expect(token.subject).toEqual({ type: 'credential', id: 'cred-1' }); }); diff --git a/packages/cli/src/policy/enforce-workflow-publish.ts b/packages/cli/src/policy/enforce-workflow-publish.ts index db0c25c2372..a513a0785a3 100644 --- a/packages/cli/src/policy/enforce-workflow-publish.ts +++ b/packages/cli/src/policy/enforce-workflow-publish.ts @@ -2,6 +2,7 @@ import type { PolicedWorkflow } from '@n8n/decorators'; import type { OwnershipService } from '@/services/ownership.service'; +import type { PolicyActor } from './policy-enforcement-backend'; import type { PolicyEnforcementService } from './policy-enforcement.service'; /** @@ -20,10 +21,11 @@ export async function enforceWorkflowPublishPolicy( policyEnforcementService: PolicyEnforcementService, ownershipService: OwnershipService, workflow: PolicedWorkflow & { id: string }, + actor: PolicyActor, ): Promise { if (!policyEnforcementService.hasChecksFor('workflowPublish')) return; const project = await ownershipService.getWorkflowProjectCached(workflow.id); - await policyEnforcementService.enforceWorkflowPublish({ workflow, projectId: project.id }); + await policyEnforcementService.enforceWorkflowPublish({ workflow, projectId: project.id }, actor); } diff --git a/packages/cli/src/policy/policy-enforcement-backend.ts b/packages/cli/src/policy/policy-enforcement-backend.ts index a8b2d0d89e9..46b2401eb28 100644 --- a/packages/cli/src/policy/policy-enforcement-backend.ts +++ b/packages/cli/src/policy/policy-enforcement-backend.ts @@ -10,6 +10,8 @@ import type { WorkflowTransferContext, } from '@n8n/decorators'; +import type { UserLike } from '@/types/user-like.types'; + /** Which context each point is called with, mirroring `RegisteredPolicyCheck`. */ type PolicyContexts = { workflowSave: WorkflowSaveContext; @@ -23,6 +25,29 @@ type PolicyContexts = { export type PolicyContext = PolicyContexts[Point]; +/** Why the actor is not a user. */ +export type PolicySystemReason = + /** Inside a run. Runs are not attributed to the user who started them. */ + | 'execution' + | 'cli-import' + /** Trigger registration that no request asked for directly, such as startup or a retry. */ + | 'activation' + /** The publication outbox. The publish request itself was checked with its user. */ + | 'publication' + /** An integration that refreshes its own credential. */ + | 'integration' + /** A log streaming destination that reads its own credential. */ + | 'log-streaming'; + +/** + * Who asked for the policed action, named on the block audit event and never shown to checks. + * An agent lands later as an additive `{ kind: 'agent'; agentId: string; onBehalfOf: UserLike | + * null }`, with `onBehalfOf` as the audited user, so `userId` keeps meaning the accountable human. + */ +export type PolicyActor = + | { kind: 'user'; user: UserLike } + | { kind: 'system'; reason: PolicySystemReason; executionId?: string }; + /** * What the policy infrastructure module registers into the proxy. * @@ -34,6 +59,7 @@ export interface PolicyEnforcementBackend { enforce( point: Point, context: PolicyContext, + actor: PolicyActor, ): Promise; evaluate( diff --git a/packages/cli/src/policy/policy-enforcement.service.ts b/packages/cli/src/policy/policy-enforcement.service.ts index 2c7fec44589..89b94500121 100644 --- a/packages/cli/src/policy/policy-enforcement.service.ts +++ b/packages/cli/src/policy/policy-enforcement.service.ts @@ -21,7 +21,11 @@ import { mintPolicyCleared } from '@n8n/decorators/policy-internal'; import { Service } from '@n8n/di'; import { UnexpectedError } from 'n8n-workflow'; -import type { PolicyContext, PolicyEnforcementBackend } from './policy-enforcement-backend'; +import type { + PolicyActor, + PolicyContext, + PolicyEnforcementBackend, +} from './policy-enforcement-backend'; import { hasViolations, PolicyViolationError } from './policy-violation.error'; /** Fresh each time — `violations` is mutable. */ @@ -57,14 +61,17 @@ export class PolicyEnforcementService { return this.implementation?.hasChecksFor(point) ?? false; } - async enforceWorkflowSave(context: WorkflowSaveContext): Promise> { + async enforceWorkflowSave( + context: WorkflowSaveContext, + actor: PolicyActor, + ): Promise> { // A create has no committed id to bind to, so it binds to its content — even when a client // supplied an id, which is no proof of what was checked. An update binds to the row id. const subject = context.storedWorkflow === null ? workflowContentSubject(context.workflow) : workflowSubject(context.workflow); - return await this.enforce('workflowSave', context, subject); + return await this.enforce('workflowSave', context, actor, subject); } async evaluateWorkflowSave(context: WorkflowSaveContext): Promise { @@ -73,8 +80,9 @@ export class PolicyEnforcementService { async enforceWorkflowPublish( context: WorkflowPublishContext, + actor: PolicyActor, ): Promise> { - return await this.enforce('workflowPublish', context, workflowSubject(context.workflow)); + return await this.enforce('workflowPublish', context, actor, workflowSubject(context.workflow)); } async evaluateWorkflowPublish(context: WorkflowPublishContext): Promise { @@ -83,8 +91,9 @@ export class PolicyEnforcementService { async enforceWorkflowStart( context: WorkflowStartContext, + actor: PolicyActor, ): Promise> { - return await this.enforce('workflowStart', context, workflowSubject(context.workflow)); + return await this.enforce('workflowStart', context, actor, workflowSubject(context.workflow)); } async evaluateWorkflowStart(context: WorkflowStartContext): Promise { @@ -93,8 +102,14 @@ export class PolicyEnforcementService { async enforceWorkflowTransfer( context: WorkflowTransferContext, + actor: PolicyActor, ): Promise> { - return await this.enforce('workflowTransfer', context, workflowSubject(context.workflow)); + return await this.enforce( + 'workflowTransfer', + context, + actor, + workflowSubject(context.workflow), + ); } async evaluateWorkflowTransfer(context: WorkflowTransferContext): Promise { @@ -103,13 +118,14 @@ export class PolicyEnforcementService { async enforceCredentialSave( context: CredentialSaveContext, + actor: PolicyActor, ): Promise> { // Same rule as a workflow save: a create binds to its content, an update to the row id. const subject = context.storedCredential === null ? credentialContentSubject(context.credential) : credentialSubject(context.credential); - return await this.enforce('credentialSave', context, subject); + return await this.enforce('credentialSave', context, actor, subject); } async evaluateCredentialSave(context: CredentialSaveContext): Promise { @@ -118,8 +134,9 @@ export class PolicyEnforcementService { async enforceCredentialDecrypt( context: CredentialDecryptContext, + actor: PolicyActor, ): Promise> { - return await this.enforce('credentialDecrypt', context, { + return await this.enforce('credentialDecrypt', context, actor, { type: 'credential', id: context.credentialId, }); @@ -131,12 +148,13 @@ export class PolicyEnforcementService { async enforceContentImport( context: ContentImportContext, + actor: PolicyActor, ): Promise> { const subject = 'workflow' in context ? workflowSubject(context.workflow) : credentialSubject(context.credential); - return await this.enforce('contentImport', context, subject); + return await this.enforce('contentImport', context, actor, subject); } async evaluateContentImport(context: ContentImportContext): Promise { @@ -146,10 +164,11 @@ export class PolicyEnforcementService { private async enforce( point: Point, context: PolicyContext, + actor: PolicyActor, subject: PolicySubject, ): Promise> { const decision = this.implementation - ? await this.implementation.enforce(point, context) + ? await this.implementation.enforce(point, context, actor) : emptyDecision(); if (hasViolations(decision.violations)) { diff --git a/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts b/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts index 6db34ae79fa..27c9a25d786 100644 --- a/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/credentials.public.controller.ts @@ -325,6 +325,7 @@ export class CredentialsPublicController { const updatedCredential = await this.credentialsService.update( credentialId, updatePayload, + { kind: 'user', user: req.user }, decryptedDataForDeps, ); diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index 7599ba17485..25df754f004 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -173,11 +173,14 @@ export class ImportService { let cleared: PolicyCleared<'contentImport'>; try { - cleared = await this.policyEnforcementService.enforceContentImport({ - workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes }, - projectId: policyProjectId, - transport: 'cli', - }); + cleared = await this.policyEnforcementService.enforceContentImport( + { + workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes }, + projectId: policyProjectId, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); } catch (error) { // A blocked workflow is skipped, not fatal — the operator gets the rest of the batch. // A check that broke is not scoped to one workflow, so it fails the whole import diff --git a/packages/cli/src/types/user-like.types.ts b/packages/cli/src/types/user-like.types.ts new file mode 100644 index 00000000000..172f3a639fc --- /dev/null +++ b/packages/cli/src/types/user-like.types.ts @@ -0,0 +1,10 @@ +/** The user fields an event or a policy actor carries. Only `id` is guaranteed. */ +export type UserLike = { + id: string; + email?: string; + firstName?: string; + lastName?: string; + role?: { + slug: string; + }; +}; diff --git a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts index 7ace3e9f09b..1d79ae091f2 100644 --- a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts @@ -526,11 +526,14 @@ describe('WorkflowCreationService', () => { projectId: 'project-1', }); - expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: null, name: 'My workflow', nodes: [] }, - storedWorkflow: null, - projectId: 'project-1', - }); + expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: null, name: 'My workflow', nodes: [] }, + storedWorkflow: null, + projectId: 'project-1', + }, + expect.anything(), + ); }); it("falls back to the user's personal project when no project is given", async () => { @@ -543,6 +546,7 @@ describe('WorkflowCreationService', () => { expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'personal-project' }), + expect.anything(), ); }); diff --git a/packages/cli/src/workflows/__tests__/workflow.service.ee.test.ts b/packages/cli/src/workflows/__tests__/workflow.service.ee.test.ts index 4a81026fb7c..50bad5222ef 100644 --- a/packages/cli/src/workflows/__tests__/workflow.service.ee.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow.service.ee.test.ts @@ -492,10 +492,13 @@ describe('EnterpriseWorkflowService', () => { await service.transferWorkflow(user, 'wf-1', 'proj-dest'); - expect(policyEnforcementService.enforceWorkflowTransfer).toHaveBeenCalledExactlyOnceWith({ - workflow, - targetProjectId: destinationProject.id, - }); + expect(policyEnforcementService.enforceWorkflowTransfer).toHaveBeenCalledExactlyOnceWith( + { + workflow, + targetProjectId: destinationProject.id, + }, + { kind: 'user', user }, + ); }); it('proceeds with the transfer unchanged when the policy check clears', async () => { diff --git a/packages/cli/src/workflows/__tests__/workflow.service.test.ts b/packages/cli/src/workflows/__tests__/workflow.service.test.ts index f8d8ba01091..0b316a53735 100644 --- a/packages/cli/src/workflows/__tests__/workflow.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow.service.test.ts @@ -1883,7 +1883,7 @@ describe('WorkflowService', () => { arrangeSuccessfulActivation(workflow); workflowHistoryServiceMock.getVersion.mockResolvedValue(versionToActivate); - await workflowService.activateWorkflow(mock(), WORKFLOW_ID, { + await workflowService.activateWorkflow(mock({ id: 'user-1' }), WORKFLOW_ID, { versionId: TARGET_VERSION_ID, }); @@ -1896,6 +1896,7 @@ describe('WorkflowService', () => { }, projectId: 'project-1', }, + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, ); }); @@ -1913,7 +1914,7 @@ describe('WorkflowService', () => { candidate.nodes.push({ name: 'Injected by hook' } as INode); }); - await workflowService.activateWorkflow(mock(), WORKFLOW_ID, { + await workflowService.activateWorkflow(mock({ id: 'user-1' }), WORKFLOW_ID, { versionId: TARGET_VERSION_ID, }); @@ -1921,6 +1922,7 @@ describe('WorkflowService', () => { expect.objectContaining({ workflow: expect.objectContaining({ nodes: originalNodes }), }), + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, ); }); @@ -1937,7 +1939,7 @@ describe('WorkflowService', () => { candidate.nodes = [{ name: 'Rewritten by hook' } as INode]; }); - await workflowService.activateWorkflow(mock(), WORKFLOW_ID, { + await workflowService.activateWorkflow(mock({ id: 'user-1' }), WORKFLOW_ID, { versionId: TARGET_VERSION_ID, }); @@ -1945,6 +1947,7 @@ describe('WorkflowService', () => { expect.objectContaining({ workflow: expect.objectContaining({ nodes: versionToActivate.nodes }), }), + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, ); }); @@ -2740,11 +2743,14 @@ describe('WorkflowService', () => { WORKFLOW_ID, ); - expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: WORKFLOW_ID, name: 'New name', nodes: submittedNodes }, - storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, - projectId: 'project-1', - }); + expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: WORKFLOW_ID, name: 'New name', nodes: submittedNodes }, + storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, + projectId: 'project-1', + }, + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, + ); }); // A partial update (e.g. renaming only) omits `nodes` entirely. The check still needs @@ -2756,11 +2762,14 @@ describe('WorkflowService', () => { WORKFLOW_ID, ); - expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith({ - workflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, - storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, - projectId: 'project-1', - }); + expect(policyEnforcementServiceMock.enforceWorkflowSave).toHaveBeenCalledExactlyOnceWith( + { + workflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, + storedWorkflow: { id: WORKFLOW_ID, name: 'Stored name', nodes: storedNodes }, + projectId: 'project-1', + }, + { kind: 'user', user: expect.objectContaining({ id: 'user-1' }) }, + ); }); it('updates the workflow unchanged when the check clears', async () => { diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts index 540c9c56532..01341cc0995 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts @@ -301,14 +301,17 @@ describe('WorkflowPublicationApplier', () => { await applier.apply(makeRecord(), abort); - expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith({ - workflow: { - id: 'wf-1', - name: 'My workflow', - nodes: versionWithNodes.nodes, + expect(policyEnforcementService.enforceWorkflowPublish).toHaveBeenCalledExactlyOnceWith( + { + workflow: { + id: 'wf-1', + name: 'My workflow', + nodes: versionWithNodes.nodes, + }, + projectId: 'project-1', }, - projectId: 'project-1', - }); + { kind: 'system', reason: 'publication' }, + ); }); // The no-change branch still advances the published version, which running diff --git a/packages/cli/src/workflows/publication/workflow-publication-applier.ts b/packages/cli/src/workflows/publication/workflow-publication-applier.ts index a9875af419f..b3d1000edc6 100644 --- a/packages/cli/src/workflows/publication/workflow-publication-applier.ts +++ b/packages/cli/src/workflows/publication/workflow-publication-applier.ts @@ -443,11 +443,12 @@ export class WorkflowPublicationApplier { newVersion: WorkflowHistory, ): Promise { try { - await enforceWorkflowPublishPolicy(this.policyEnforcementService, this.ownershipService, { - id: workflow.id, - name: workflow.name, - nodes: newVersion.nodes, - }); + await enforceWorkflowPublishPolicy( + this.policyEnforcementService, + this.ownershipService, + { id: workflow.id, name: workflow.name, nodes: newVersion.nodes }, + { kind: 'system', reason: 'publication' }, + ); return null; } catch (e) { diff --git a/packages/cli/src/workflows/workflow-creation.service.ts b/packages/cli/src/workflows/workflow-creation.service.ts index 6e87285de8c..2ab4c0214fd 100644 --- a/packages/cli/src/workflows/workflow-creation.service.ts +++ b/packages/cli/src/workflows/workflow-creation.service.ts @@ -296,11 +296,14 @@ export class WorkflowCreationService { // while editing rather than at runtime. No stored workflow: this one is new. // The clearance binds to the node hash while the row has no id, so nothing below // may touch `newWorkflow.nodes` — the sealed write would reject it. - const cleared = await this.policyEnforcementService.enforceWorkflowSave({ - workflow: { id: newWorkflow.id ?? null, name: newWorkflow.name, nodes: newWorkflow.nodes }, - storedWorkflow: null, - projectId: effectiveProjectId, - }); + const cleared = await this.policyEnforcementService.enforceWorkflowSave( + { + workflow: { id: newWorkflow.id ?? null, name: newWorkflow.name, nodes: newWorkflow.nodes }, + storedWorkflow: null, + projectId: effectiveProjectId, + }, + { kind: 'user', user }, + ); const floor = batchContext?.redactionFloor ?? (await this.readActiveRedactionFloor()); diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index f2e9fc96cb2..5dda862cd80 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -520,10 +520,10 @@ export class EnterpriseWorkflowService { } // 6. validate against the destination project's policy - await this.policyEnforcementService.enforceWorkflowTransfer({ - workflow, - targetProjectId: destinationProject.id, - }); + await this.policyEnforcementService.enforceWorkflowTransfer( + { workflow, targetProjectId: destinationProject.id }, + { kind: 'user', user }, + ); const wasActive = this.isActiveWorkflow(workflow); @@ -679,7 +679,9 @@ export class EnterpriseWorkflowService { private async attemptWorkflowReactivation(workflowId: string, versionId: string, userId: string) { try { - await this.activeWorkflowManager.add(workflowId, 'update'); + await this.activeWorkflowManager.add(workflowId, 'update', undefined, { + actor: { kind: 'user', user: { id: userId } }, + }); return; } catch (error) { diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 1819144db9c..5acd1fa2b40 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -612,15 +612,18 @@ export class WorkflowService { // Gate the save on policy before persisting, so the author learns about a violation // while editing rather than at runtime. Carries the stored workflow alongside the // submitted one so a check can restrict its verdict to what this save adds. - const cleared = await this.policyEnforcementService.enforceWorkflowSave({ - workflow: { - id: workflow.id, - name: workflowUpdateData.name ?? workflow.name, - nodes: workflowUpdateData.nodes ?? workflow.nodes, + const cleared = await this.policyEnforcementService.enforceWorkflowSave( + { + workflow: { + id: workflow.id, + name: workflowUpdateData.name ?? workflow.name, + nodes: workflowUpdateData.nodes ?? workflow.nodes, + }, + storedWorkflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes }, + projectId: ownerProject.id, }, - storedWorkflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes }, - projectId: ownerProject.id, - }); + { kind: 'user', user }, + ); const fieldsToUpdate = [ 'name', @@ -793,7 +796,9 @@ export class WorkflowService { ): Promise { let didPublish = false; try { - await this.activeWorkflowManager.add(workflowId, mode); + await this.activeWorkflowManager.add(workflowId, mode, undefined, { + actor: { kind: 'user', user }, + }); didPublish = true; } catch (error) { // Activation failed partway through. It may already have registered triggers @@ -1030,11 +1035,12 @@ export class WorkflowService { // Polices what gets registered — the version row, not the hook's candidate. // Enforced on a same-version republish too. - await enforceWorkflowPublishPolicy(this.policyEnforcementService, this.ownershipService, { - id: workflowId, - name: workflow.name, - nodes: nodesToPublish, - }); + await enforceWorkflowPublishPolicy( + this.policyEnforcementService, + this.ownershipService, + { id: workflowId, name: workflow.name, nodes: nodesToPublish }, + { kind: 'user', user }, + ); // re-applying the already-published version (e.g. a settings-only update) // publishes no new version, so the review gate must not block it. diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index 450bc664825..29b21f85df3 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -150,6 +150,10 @@ describe('init()', () => { await activeWorkflowManager.init(); expect(enforceSpy).toHaveBeenCalledTimes(2); + expect(enforceSpy).toHaveBeenCalledWith(expect.anything(), { + kind: 'system', + reason: 'activation', + }); expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(2); }); }); diff --git a/packages/cli/test/integration/commands/credentials.cmd.test.ts b/packages/cli/test/integration/commands/credentials.cmd.test.ts index 1023757b5d7..b132b74d3f7 100644 --- a/packages/cli/test/integration/commands/credentials.cmd.test.ts +++ b/packages/cli/test/integration/commands/credentials.cmd.test.ts @@ -855,11 +855,11 @@ describe('content-import policy', () => { const service = Container.get(PolicyEnforcementService); // The prototype method, not `service.enforceContentImport`: that is the spy itself. const clearance = PolicyEnforcementService.prototype.enforceContentImport; - enforceContentImport.mockImplementation(async (context: ContentImportContext) => { + enforceContentImport.mockImplementation(async (context: ContentImportContext, actor) => { if ('credential' in context && context.credential.type === blockedType) { throw new PolicyViolationError([violation]); } - return await clearance.call(service, context); + return await clearance.call(service, context, actor); }); }; @@ -893,11 +893,14 @@ describe('content-import policy', () => { await command.run([`--input=${inputPath}`]); - expect(enforceContentImport).toHaveBeenCalledWith({ - credential: { id: 'blocked', type: 'slackApi' }, - projectId: ownerProject.id, - transport: 'cli', - }); + expect(enforceContentImport).toHaveBeenCalledWith( + { + credential: { id: 'blocked', type: 'slackApi' }, + projectId: ownerProject.id, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); const after = { credentials: await getAllCredentials(), sharings: await getAllSharedCredentials(), @@ -921,11 +924,14 @@ describe('content-import policy', () => { // Flagless re-import: the batch target is the owner's project, but the row stays the member's. await command.run([`--input=${inputPath}`]); - expect(enforceContentImport).toHaveBeenCalledWith({ - credential: { id: 'existing', type: 'aws' }, - projectId: memberProject.id, - transport: 'cli', - }); + expect(enforceContentImport).toHaveBeenCalledWith( + { + credential: { id: 'existing', type: 'aws' }, + projectId: memberProject.id, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); }); test('fails the import when the policy layer errors', async () => { diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index 750bcd3f7ab..f863adb5b28 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -525,7 +525,9 @@ describe('--activeState flag', () => { expect(inactiveWorkflow).toMatchObject({ active: false, activeVersionId: null }); const activeWorkflowManager = Container.get(ActiveWorkflowManager); - expect(activeWorkflowManager.add).toHaveBeenCalledWith('998', 'activate'); + expect(activeWorkflowManager.add).toHaveBeenCalledWith('998', 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); expect(activeWorkflowManager.add).not.toHaveBeenCalledWith('999', expect.anything()); }); @@ -558,7 +560,9 @@ describe('--activeState flag', () => { const activeWorkflowManager = Container.get(ActiveWorkflowManager); expect(activeWorkflowManager.remove).toHaveBeenCalledWith('998'); - expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate'); + expect(activeWorkflowManager.add).toHaveBeenLastCalledWith('998', 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const publishHistoryRepo = Container.get(WorkflowPublishHistoryRepository); expect(publishHistoryRepo.addRecord).toHaveBeenCalledTimes(3); diff --git a/packages/cli/test/integration/database/services/db-lock.service.test.ts b/packages/cli/test/integration/database/services/db-lock.service.test.ts index fd17ad93f29..ab0a43ef7b6 100644 --- a/packages/cli/test/integration/database/services/db-lock.service.test.ts +++ b/packages/cli/test/integration/database/services/db-lock.service.test.ts @@ -88,11 +88,14 @@ describe('DbLockService', () => { const settingsKey = `test.atomic-settings.${randomUUID()}`; // The credential writes are sealed; with no check registered, enforcement clears. - const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave({ - credential: { id: null, type: 'openAiApi' }, - storedCredential: null, - projectId: null, - }); + const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave( + { + credential: { id: null, type: 'openAiApi' }, + storedCredential: null, + projectId: null, + }, + { kind: 'system', reason: 'integration' }, + ); await expect( dbLockService.withLockContext(DbLock.TEST, async (ctx) => { @@ -130,11 +133,14 @@ describe('DbLockService', () => { }), ); const settingsKey = `test.atomic-settings.${randomUUID()}`; - const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave({ - credential: { id: credential.id, type: credential.type }, - storedCredential: { id: credential.id, type: credential.type }, - projectId: null, - }); + const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave( + { + credential: { id: credential.id, type: credential.type }, + storedCredential: { id: credential.id, type: credential.type }, + projectId: null, + }, + { kind: 'system', reason: 'integration' }, + ); await expect( dbLockService.withLockContext(DbLock.TEST, async (ctx) => { diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index c502afcfb35..e205514566e 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -112,8 +112,8 @@ describe('SourceControlImportService', () => { // The repository verifies the token, so it has to be a real one. With no backend // registered the real service clears everything, which is what a default pull does. mockPolicyEnforcementService.enforceContentImport.mockImplementation( - async (context) => - await Container.get(PolicyEnforcementService).enforceContentImport(context), + async (context, actor) => + await Container.get(PolicyEnforcementService).enforceContentImport(context, actor), ); service = new SourceControlImportService( mock(), @@ -2097,11 +2097,14 @@ describe('SourceControlImportService', () => { ); expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(1); - expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes }, - projectId: importingUserProject.id, - transport: 'source-control', - }); + expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { id: workflow.id, name: workflow.name, nodes: workflow.nodes }, + projectId: importingUserProject.id, + transport: 'source-control', + }, + { kind: 'user', user: { id: importingUser.id } }, + ); }); it('skips a blocked workflow, attaches the reason, and persists nothing', async () => { diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index 31b30797673..f488aa3f057 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -68,8 +68,8 @@ describe('ImportService', () => { // The repository verifies the token, so it has to be a real one. With no backend // registered the real service clears everything, which is what a default import does. mockPolicyEnforcementService.enforceContentImport.mockImplementation( - async (context) => - await Container.get(PolicyEnforcementService).enforceContentImport(context), + async (context, actor) => + await Container.get(PolicyEnforcementService).enforceContentImport(context, actor), ); importService = new ImportService( @@ -495,7 +495,10 @@ describe('ImportService', () => { describe('content-import policy', () => { const clearance = async (context: ContentImportContext) => - await Container.get(PolicyEnforcementService).enforceContentImport(context); + await Container.get(PolicyEnforcementService).enforceContentImport(context, { + kind: 'system', + reason: 'cli-import', + }); beforeEach(() => { mockPolicyEnforcementService.enforceContentImport.mockClear(); @@ -510,16 +513,22 @@ describe('ImportService', () => { await importService.importWorkflows([first, second], ownerPersonalProject.id, owner.id, {}); expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledTimes(2); - expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { id: first.id, name: first.name, nodes: first.nodes }, - projectId: ownerPersonalProject.id, - transport: 'cli', - }); - expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { id: second.id, name: second.name, nodes: second.nodes }, - projectId: ownerPersonalProject.id, - transport: 'cli', - }); + expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { id: first.id, name: first.name, nodes: first.nodes }, + projectId: ownerPersonalProject.id, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); + expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { id: second.id, name: second.name, nodes: second.nodes }, + projectId: ownerPersonalProject.id, + transport: 'cli', + }, + { kind: 'system', reason: 'cli-import' }, + ); }); test('skips a blocked workflow, reports it, and still imports the rest of the batch', async () => { @@ -591,15 +600,18 @@ describe('ImportService', () => { {}, ); - expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith({ - workflow: { - id: workflowToReimport.id, - name: workflowToReimport.name, - nodes: workflowToReimport.nodes, + expect(mockPolicyEnforcementService.enforceContentImport).toHaveBeenCalledWith( + { + workflow: { + id: workflowToReimport.id, + name: workflowToReimport.name, + nodes: workflowToReimport.nodes, + }, + projectId: memberPersonalProject.id, + transport: 'cli', }, - projectId: memberPersonalProject.id, - transport: 'cli', - }); + { kind: 'system', reason: 'cli-import' }, + ); }); // A check that cannot answer is an infrastructure fault, not a property of one workflow. diff --git a/packages/cli/test/integration/instance-ai-settings.service.test.ts b/packages/cli/test/integration/instance-ai-settings.service.test.ts index 29a92c4bd7f..6e54b0ba70c 100644 --- a/packages/cli/test/integration/instance-ai-settings.service.test.ts +++ b/packages/cli/test/integration/instance-ai-settings.service.test.ts @@ -173,11 +173,14 @@ describe('InstanceAiSettingsService (integration)', () => { ); // A real clearance, so the row is protected by its usage scope alone, not by the seal. - const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave({ - credential: { id: workflowCredential.id, type: workflowCredential.type }, - storedCredential: { id: workflowCredential.id, type: workflowCredential.type }, - projectId: null, - }); + const policyCleared = await Container.get(PolicyEnforcementService).enforceCredentialSave( + { + credential: { id: workflowCredential.id, type: workflowCredential.type }, + storedCredential: { id: workflowCredential.id, type: workflowCredential.type }, + projectId: null, + }, + { kind: 'system', reason: 'integration' }, + ); const updated = await credentialsRepository.updateInstanceCredential( workflowCredential.id, diff --git a/packages/cli/test/integration/workflows/workflow-save-policy.test.ts b/packages/cli/test/integration/workflows/workflow-save-policy.test.ts index d07ba670f32..cb10d9469d1 100644 --- a/packages/cli/test/integration/workflows/workflow-save-policy.test.ts +++ b/packages/cli/test/integration/workflows/workflow-save-policy.test.ts @@ -6,6 +6,8 @@ import { PolicyCheck, PolicyCheckMetadata } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; +import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; import { cleanupRolesAndScopes } from '../shared/db/roles'; import { createOwnerWithApiKey } from '../shared/db/users'; @@ -68,6 +70,12 @@ let owner: User; let editorAgent: SuperAgentTest; let publicApiAgent: SuperAgentTest; let workflowRepository: WorkflowRepository; +const eventBus = mockInstance(MessageEventBus); + +const blockEvents = () => + eventBus.sendAuditEvent.mock.calls + .map(([options]) => options) + .filter((options) => options.eventName === 'n8n.audit.policy.decision.blocked'); const truncate = async () => await testDb.truncate([ @@ -86,6 +94,7 @@ const truncate = async () => beforeAll(async () => { await utils.initNodeTypes(); workflowRepository = Container.get(WorkflowRepository); + Container.get(LogStreamingEventRelay).init(); // The public API's OpenAPI validator compiles the whole spec on the first authenticated // request that reaches it (~3s). Spend it here, under the 30s hook timeout, so it can't @@ -99,6 +108,7 @@ beforeAll(async () => { beforeEach(async () => { mode = 'allow'; + eventBus.sendAuditEvent.mockClear(); await truncate(); await cleanupRolesAndScopes(); @@ -128,6 +138,31 @@ describe('with a check that denies every save', () => { await expect(workflowRepository.count()).resolves.toBe(0); }); + test('editor create sends one audit event naming the point, violation and user', async () => { + await editorAgent.post('/workflows').send(editorPayload); + + expect(blockEvents()).toHaveLength(1); + expect(blockEvents()[0].payload).toMatchObject({ + point: 'workflowSave', + outcome: 'violation', + checkIds: expect.arrayContaining([CHECK_ID]), + violations: [ + { + checkId: CHECK_ID, + kind: DENIAL.kind, + subject: DENIAL.subject, + subjectType: DENIAL.subjectType, + scope: null, + matchedRuleId: null, + }, + ], + policyVersions: [], + actorType: 'user', + userId: owner.id, + _email: owner.email, + }); + }); + test('editor update fails with 403 and the violations', async () => { const workflow = await createWorkflow({ name: 'Stored name' }, owner); @@ -219,4 +254,12 @@ test('a check that breaks blocks the save without leaking violations', async () const { meta } = response.body as { meta: Record }; expect(meta.violations).toBeUndefined(); await expect(workflowRepository.count()).resolves.toBe(0); + + expect(blockEvents()).toHaveLength(1); + expect(blockEvents()[0].payload).toMatchObject({ + outcome: 'checkFailure', + correlationIds: meta.correlationIds, + userId: owner.id, + }); + expect(JSON.stringify(blockEvents()[0].payload)).not.toContain('failed on purpose'); }); diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index d075be1e03d..b8c6dcf2096 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -372,14 +372,17 @@ describe('activateWorkflow()', () => { const updatedWorkflow = await workflowService.activateWorkflow(owner, workflow.id); - expect(enforceSpy).toHaveBeenCalledExactlyOnceWith({ - workflow: { - id: workflow.id, - name: workflow.name, - nodes: expect.any(Array), + expect(enforceSpy).toHaveBeenCalledExactlyOnceWith( + { + workflow: { + id: workflow.id, + name: workflow.name, + nodes: expect.any(Array), + }, + projectId: expect.any(String), }, - projectId: expect.any(String), - }); + { kind: 'user', user: expect.objectContaining({ id: owner.id }) }, + ); expect(updatedWorkflow.activeVersionId).toBe(workflow.versionId); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 5a076866529..df4a46841b7 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -1859,7 +1859,9 @@ describe('PUT /:workflowId/transfer', () => { expect(response.body).toEqual({}); expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id); - expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update'); + expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update', undefined, { + actor: { kind: 'user', user: { id: member.id } }, + }); }); test('should move workflow to project root if `destinationParentFolderId` is not provided', async () => { @@ -1989,7 +1991,9 @@ describe('PUT /:workflowId/transfer', () => { }); expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id); - expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update'); + expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update', undefined, { + actor: { kind: 'user', user: { id: member.id } }, + }); const workflowFromDB = await workflowRepository.findOneByOrFail({ id: workflow.id }); expect(workflowFromDB.active).toBe(false); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index f326a88b47d..0fa9c344035 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -3914,7 +3914,9 @@ describe('PATCH /workflows/:workflowId', () => { expect(response.statusCode).toBe(200); expect(activeWorkflowManagerLike.remove).toHaveBeenCalledWith(workflow.id); - expect(activeWorkflowManagerLike.add).toHaveBeenCalledWith(workflow.id, 'update'); + expect(activeWorkflowManagerLike.add).toHaveBeenCalledWith(workflow.id, 'update', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); }); test('should not reactivate when settings unchanged', async () => { @@ -4287,7 +4289,9 @@ describe('POST /workflows/:workflowId/activate', () => { .send({ versionId: newVersionId }); expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const { data } = response.body; expect(data.id).toBe(workflow.id); @@ -4422,7 +4426,9 @@ describe('POST /workflows/:workflowId/activate', () => { .send({ versionId: workflow.versionId, name: newVersionName }); expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const { data } = response.body; expect(data.activeVersionId).toBe(workflow.versionId); @@ -4443,7 +4449,9 @@ describe('POST /workflows/:workflowId/activate', () => { .send({ versionId: workflow.versionId, description: newDescription }); expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const { data } = response.body; expect(data.activeVersionId).toBe(workflow.versionId); @@ -4467,7 +4475,9 @@ describe('POST /workflows/:workflowId/activate', () => { }); expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); const { data } = response.body; expect(data.activeVersionId).toBe(workflow.versionId); @@ -4592,7 +4602,9 @@ describe('POST /workflows/:workflowId/activate', () => { // First remove active version expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); }); test('should call active workflow manager with activate mode if workflow is not active', async () => { @@ -4604,7 +4616,9 @@ describe('POST /workflows/:workflowId/activate', () => { .send({ versionId: workflow.versionId }); expect(activeWorkflowManagerLike.remove).not.toBeCalledWith(workflow.id); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate', undefined, { + actor: expect.objectContaining({ kind: 'user' }), + }); expect(addRecordSpy).toBeCalledWith({ event: 'activated', userId: owner.id,