feat(core): Send a log streaming audit event when a policy blocks an action (#39581)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart 2026-09-29 14:02:09 +00:00 committed by GitHub
parent 53b84d2baa
commit 55fd9daa08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 1396 additions and 502 deletions

View File

@ -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 () => {

View File

@ -3049,7 +3049,11 @@ describe('CredentialsHelper', () => {
source: null,
} as IExecuteData;
const additionalData = mock<IWorkflowExecuteAdditionalData>({ projectId: 'proj-1' });
const additionalData = mock<IWorkflowExecuteAdditionalData>({
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<IWorkflowExecuteAdditionalData>({
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<IWorkflowExecuteAdditionalData>({
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<IWorkflowExecuteAdditionalData>({ projectId: undefined });
const additionalData = mock<IWorkflowExecuteAdditionalData>({
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' },
);
});

View File

@ -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,

View File

@ -273,11 +273,14 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
let cleared: PolicyCleared<'contentImport'>;
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;

View File

@ -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<ICredentialDataDecryptedObject> {
// 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 },

View File

@ -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 });
});

View File

@ -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<User>({ id: 'owner-id', role: GLOBAL_OWNER_ROLE });
const ownerActor: PolicyActor = { kind: 'user', user: ownerUser };
const memberUser = mock<User>({ id: 'member-id', role: GLOBAL_MEMBER_ROLE });
/** A custom instance role that can see every credential but not use one. */
const viewOnlyUser = mock<User>({
@ -239,13 +241,18 @@ describe('CredentialsService', () => {
.mockResolvedValue(mock<ICredentialsDb>());
const updateSpy = vi.spyOn(service, 'update').mockResolvedValue(mock<CredentialsEntity>());
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<ICredentialsDb>({ 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<ICredentialsDb>({ id: 'missing' })),
service.update('missing', mock<ICredentialsDb>({ id: 'missing' }), ownerActor),
).resolves.toBeNull();
expect(policyEnforcementService.enforceCredentialSave).not.toHaveBeenCalled();
@ -1623,7 +1635,11 @@ describe('CredentialsService', () => {
);
await expect(
service.update('project-credential', mock<ICredentialsDb>({ id: 'project-credential' })),
service.update(
'project-credential',
mock<ICredentialsDb>({ 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<ICredentialsDb>({ 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',
]);

View File

@ -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 });

View File

@ -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<void> {
async clearOauthTokenData(credential: CredentialsEntity, actor: PolicyActor): Promise<void> {
// 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<PolicyCleared<'credentialSave'> | 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 },

View File

@ -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;

View File

@ -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

View File

@ -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<InstanceSettings>({ hostId });
new LogStreamingEventRelay(eventService, eventBus, instanceSettings).init();
const userRepository = mock<UserRepository>();
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<User>({
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',

View File

@ -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;

View File

@ -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<UserLike> {
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(

View File

@ -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({

View File

@ -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;
}

View File

@ -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(),

View File

@ -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 },

View File

@ -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),

View File

@ -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,

View File

@ -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<User>(),
);
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<User>(),
);
await expect(restore).rejects.toThrow(PolicyViolationError);

View File

@ -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);

View File

@ -356,6 +356,7 @@ export class EvalThreadRestoreService {
async restoreWorkflows(
workflows: InstanceAiEvalSeedWorkflow[],
projectId: string,
user: User,
dataTableIdMap: Map<string, string> = new Map(),
allowedCredentialIds?: Set<string>,
folderIdMap: Map<string, string> = 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<string, string>,
allowedCredentialIds: Set<string> | undefined,
folderIdMap: Map<string, string>,
@ -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<PolicyCleared<'workflowSave'>> {
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(

View File

@ -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 },

View File

@ -1300,6 +1300,7 @@ export class InstanceAiController {
createdWorkflowIds = await this.evalThreadRestore.restoreWorkflows(
workflows,
projectId,
req.user,
idMap,
allowedCredentialIds ? new Set(allowedCredentialIds) : undefined,
folderIdMap,

View File

@ -156,6 +156,8 @@ describe('MessageEventBusDestinationWebhook', () => {
'internal',
undefined,
false,
undefined,
{ actor: { kind: 'system', reason: 'log-streaming' } },
);
expect(credentialsHelper.authenticate).toHaveBeenCalledWith(
decrypted,

View File

@ -179,6 +179,8 @@ export class MessageEventBusDestinationWebhook
'internal',
undefined,
raw,
undefined,
{ actor: { kind: 'system', reason: 'log-streaming' } },
);
return credentialsDecrypted;
}

View File

@ -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<PolicyEnforcementService>();
const gate = new ContentImportPolicyGate(policyEnforcementService, mock<Logger>());
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);
});
});

View File

@ -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<BlockingIssue[]> {
// 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

View File

@ -257,6 +257,7 @@ export class ImportOrchestrator {
workflowPlan.items,
context.projectId,
contentImportTransport(input.importSource),
{ kind: 'user', user: context.user },
);
const blockingIssues = this.collectBlockingIssues({

View File

@ -33,7 +33,7 @@ flowchart LR
end
subgraph module["policy-infrastructure module (default, disable to opt out)"]
pds["PolicyDecisionService<br/>deadline per check · all checks must pass<br/>crash or timeout = fail closed<br/>one audit line per veto"]
pds["PolicyDecisionService<br/>deadline per check · all checks must pass<br/>crash or timeout = fail closed<br/>one audit line + one log streaming event per veto"]
registry["PolicyCheckMetadata<br/>registry in @n8n/decorators"]
checks["@PolicyCheck() classes<br/>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 |

View File

@ -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<PolicyCheckMetadata>({ getClasses: () => classes });
return new PolicyDecisionService(mockLogger(), metadata);
return new PolicyDecisionService(mockLogger(), metadata, mock<EventService>());
};
/**
@ -208,23 +210,33 @@ const serviceWith = (...classes: PolicyCheckClass[]) => {
const auditedServiceWith = (...classes: PolicyCheckClass[]) => {
const logger = mockLogger();
const metadata = mock<PolicyCheckMetadata>({ getClasses: () => classes });
const eventService = mock<EventService>();
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<PolicyCheckMetadata>({ getClasses: () => classes });
const service = new PolicyDecisionService(mockLogger(), metadata);
const service = new PolicyDecisionService(mockLogger(), metadata, mock<EventService>());
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);

View File

@ -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',
});
});

View File

@ -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<Point>` narrows against none. */
type AnyPolicyContext = PolicyContext<EnforcementPoint>;
/** 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<PolicyViolation, 'checkId' | 'kind'> & {
[Key in OptionalAuditedKey]-?: Exclude<PolicyViolation[Key], undefined> | 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 }),
};
}

View File

@ -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 extends EnforcementPoint>(
point: Point,
context: PolicyContext<Point>,
actor: PolicyActor,
): Promise<PolicyDecision> {
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<Point extends EnforcementPoint>(

View File

@ -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 },
);
}
}

View File

@ -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

View File

@ -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;

View File

@ -632,8 +632,8 @@ describe('auto-close on source-control pull', () => {
mock<PolicyEnforcementService>({
// 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),

View File

@ -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(

View File

@ -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<T>(credential: CredentialsEntity): Promise<T> {
const additionalData = await this.getAdditionalData();
async getOAuthCredentials<T>(credential: CredentialsEntity, userId?: string): Promise<T> {
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<OAuth2CredentialData>(credential);
await this.getOAuthCredentials<OAuth2CredentialData>(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<OAuth1CredentialData>(credential);
await this.getOAuthCredentials<OAuth1CredentialData>(credential, csrfUserId(csrfData));
this.validateOAuthUrlOrThrow(oauthCredentials.authUrl ?? '');
this.validateOAuthUrlOrThrow(oauthCredentials.requestTokenUrl ?? '');

View File

@ -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<PolicyEnforcementService>();
const ownershipService = mock<OwnershipService>();
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();

View File

@ -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<PolicedWorkflow['nodes'][number]>({ 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' });
});

View File

@ -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<void> {
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);
}

View File

@ -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<Point extends EnforcementPoint> = 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 extends EnforcementPoint>(
point: Point,
context: PolicyContext<Point>,
actor: PolicyActor,
): Promise<PolicyDecision>;
evaluate<Point extends EnforcementPoint>(

View File

@ -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<PolicyCleared<'workflowSave'>> {
async enforceWorkflowSave(
context: WorkflowSaveContext,
actor: PolicyActor,
): Promise<PolicyCleared<'workflowSave'>> {
// 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<PolicyDecision> {
@ -73,8 +80,9 @@ export class PolicyEnforcementService {
async enforceWorkflowPublish(
context: WorkflowPublishContext,
actor: PolicyActor,
): Promise<PolicyCleared<'workflowPublish'>> {
return await this.enforce('workflowPublish', context, workflowSubject(context.workflow));
return await this.enforce('workflowPublish', context, actor, workflowSubject(context.workflow));
}
async evaluateWorkflowPublish(context: WorkflowPublishContext): Promise<PolicyDecision> {
@ -83,8 +91,9 @@ export class PolicyEnforcementService {
async enforceWorkflowStart(
context: WorkflowStartContext,
actor: PolicyActor,
): Promise<PolicyCleared<'workflowStart'>> {
return await this.enforce('workflowStart', context, workflowSubject(context.workflow));
return await this.enforce('workflowStart', context, actor, workflowSubject(context.workflow));
}
async evaluateWorkflowStart(context: WorkflowStartContext): Promise<PolicyDecision> {
@ -93,8 +102,14 @@ export class PolicyEnforcementService {
async enforceWorkflowTransfer(
context: WorkflowTransferContext,
actor: PolicyActor,
): Promise<PolicyCleared<'workflowTransfer'>> {
return await this.enforce('workflowTransfer', context, workflowSubject(context.workflow));
return await this.enforce(
'workflowTransfer',
context,
actor,
workflowSubject(context.workflow),
);
}
async evaluateWorkflowTransfer(context: WorkflowTransferContext): Promise<PolicyDecision> {
@ -103,13 +118,14 @@ export class PolicyEnforcementService {
async enforceCredentialSave(
context: CredentialSaveContext,
actor: PolicyActor,
): Promise<PolicyCleared<'credentialSave'>> {
// 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<PolicyDecision> {
@ -118,8 +134,9 @@ export class PolicyEnforcementService {
async enforceCredentialDecrypt(
context: CredentialDecryptContext,
actor: PolicyActor,
): Promise<PolicyCleared<'credentialDecrypt'>> {
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<PolicyCleared<'contentImport'>> {
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<PolicyDecision> {
@ -146,10 +164,11 @@ export class PolicyEnforcementService {
private async enforce<Point extends EnforcementPoint>(
point: Point,
context: PolicyContext<Point>,
actor: PolicyActor,
subject: PolicySubject,
): Promise<PolicyCleared<Point>> {
const decision = this.implementation
? await this.implementation.enforce(point, context)
? await this.implementation.enforce(point, context, actor)
: emptyDecision();
if (hasViolations(decision.violations)) {

View File

@ -325,6 +325,7 @@ export class CredentialsPublicController {
const updatedCredential = await this.credentialsService.update(
credentialId,
updatePayload,
{ kind: 'user', user: req.user },
decryptedDataForDeps,
);

View File

@ -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

View File

@ -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;
};
};

View File

@ -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(),
);
});

View File

@ -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 () => {

View File

@ -1883,7 +1883,7 @@ describe('WorkflowService', () => {
arrangeSuccessfulActivation(workflow);
workflowHistoryServiceMock.getVersion.mockResolvedValue(versionToActivate);
await workflowService.activateWorkflow(mock<User>(), WORKFLOW_ID, {
await workflowService.activateWorkflow(mock<User>({ 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<User>(), WORKFLOW_ID, {
await workflowService.activateWorkflow(mock<User>({ 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<User>(), WORKFLOW_ID, {
await workflowService.activateWorkflow(mock<User>({ 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 () => {

View File

@ -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

View File

@ -443,11 +443,12 @@ export class WorkflowPublicationApplier {
newVersion: WorkflowHistory,
): Promise<PublicationResult | null> {
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) {

View File

@ -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());

View File

@ -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) {

View File

@ -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<void> {
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.

View File

@ -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);
});
});

View File

@ -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 () => {

View File

@ -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);

View File

@ -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) => {

View File

@ -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 () => {

View File

@ -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.

View File

@ -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,

View File

@ -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<string, unknown> };
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');
});

View File

@ -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);
});

View File

@ -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);

View File

@ -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,