feat(core): Apply instance redaction floor to new workflows (#31532)

This commit is contained in:
Ilfat Mindubaev 2026-06-03 16:46:33 +03:00 committed by GitHub
parent 5feafdfafd
commit 21db4bcd6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 289 additions and 32 deletions

View File

@ -0,0 +1,31 @@
import { WorkflowEntity } from '@n8n/db';
import { dropRedactionPolicy } from '@/workflows/utils';
describe('dropRedactionPolicy', () => {
it('removes redactionPolicy when present', () => {
const workflow = new WorkflowEntity();
workflow.settings = { redactionPolicy: 'all', executionOrder: 'v1' };
dropRedactionPolicy(workflow);
expect(workflow.settings.redactionPolicy).toBeUndefined();
expect(workflow.settings.executionOrder).toBe('v1');
});
it('is a no-op when settings has no redactionPolicy', () => {
const workflow = new WorkflowEntity();
workflow.settings = { executionOrder: 'v1' };
dropRedactionPolicy(workflow);
expect(workflow.settings).toEqual({ executionOrder: 'v1' });
});
it('is a no-op when settings is undefined', () => {
const workflow = new WorkflowEntity();
expect(() => dropRedactionPolicy(workflow)).not.toThrow();
expect(workflow.settings).toBeUndefined();
});
});

View File

@ -1,5 +1,5 @@
import type { LicenseState } from '@n8n/backend-common';
import type { User, ProjectRepository } from '@n8n/db';
import type { ProjectRepository, User } from '@n8n/db';
import { WorkflowEntity } from '@n8n/db';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
@ -8,13 +8,14 @@ import type { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service';
import type { NodeTypes } from '@/node-types';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { ProjectService } from '@/services/project.service.ee';
import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowCreationService } from '@/workflows/workflow-creation.service';
import type { NodeTypes } from '@/node-types';
import type { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
import type { WorkflowValidationService } from '@/workflows/workflow-validation.service';
import type { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
jest.mock('@/permissions.ee/check-access');
jest.mock('@/workflow-helpers');
@ -30,6 +31,7 @@ describe('WorkflowCreationService', () => {
let projectServiceMock: MockProxy<ProjectService>;
let projectRepositoryMock: MockProxy<ProjectRepository>;
let workflowValidationServiceMock: MockProxy<WorkflowValidationService>;
let instanceRedactionEnforcementServiceMock: MockProxy<InstanceRedactionEnforcementService>;
beforeEach(() => {
jest.clearAllMocks();
@ -40,10 +42,14 @@ describe('WorkflowCreationService', () => {
projectServiceMock = mock<ProjectService>();
projectRepositoryMock = mock<ProjectRepository>();
workflowValidationServiceMock = mock<WorkflowValidationService>();
instanceRedactionEnforcementServiceMock = mock<InstanceRedactionEnforcementService>();
workflowValidationServiceMock.validateCredentialNodeRestrictions.mockReturnValue({
isValid: true,
});
// Default: no active floor. Tests opt into a floor explicitly.
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('off');
workflowCreationService = new WorkflowCreationService(
mock(), // logger
mock(), // sharedWorkflowRepository
@ -62,6 +68,7 @@ describe('WorkflowCreationService', () => {
enterpriseWorkflowServiceMock,
mock<NodeTypes>(),
workflowValidationServiceMock,
instanceRedactionEnforcementServiceMock,
);
});
@ -338,6 +345,181 @@ describe('WorkflowCreationService', () => {
});
});
describe('redaction policy floor enforcement on create', () => {
beforeEach(() => {
projectServiceMock.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never);
licenseStateMock.isSharingLicensed.mockReturnValue(false);
licenseStateMock.isDataRedactionLicensed.mockReturnValue(true);
});
it('seeds non-manual when floor is production-only and no policy is provided', async () => {
userHasScopesMock.mockResolvedValue(true);
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('production');
const { transactionManager } = setupTransactionMocks();
const newWorkflow = new WorkflowEntity();
newWorkflow.settings = { executionOrder: 'v1' };
await expect(
workflowCreationService.createWorkflow(mock<User>(), newWorkflow, {
projectId: 'project-1',
}),
).rejects.toThrow('Stopping for test');
const savedEntity = transactionManager.save.mock.calls[0][0] as WorkflowEntity;
expect(savedEntity.settings?.redactionPolicy).toBe('non-manual');
expect(savedEntity.settings?.executionOrder).toBe('v1');
});
it('seeds all when floor is production+manual and no policy is provided', async () => {
userHasScopesMock.mockResolvedValue(true);
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('all');
const { transactionManager } = setupTransactionMocks();
const newWorkflow = new WorkflowEntity();
newWorkflow.settings = { executionOrder: 'v1' };
await expect(
workflowCreationService.createWorkflow(mock<User>(), newWorkflow, {
projectId: 'project-1',
}),
).rejects.toThrow('Stopping for test');
const savedEntity = transactionManager.save.mock.calls[0][0] as WorkflowEntity;
expect(savedEntity.settings?.redactionPolicy).toBe('all');
expect(savedEntity.settings?.executionOrder).toBe('v1');
});
it('does not seed when floor is not enforced', async () => {
userHasScopesMock.mockResolvedValue(true);
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('off');
const { transactionManager } = setupTransactionMocks();
const newWorkflow = new WorkflowEntity();
newWorkflow.settings = { executionOrder: 'v1' };
await expect(
workflowCreationService.createWorkflow(mock<User>(), newWorkflow, {
projectId: 'project-1',
}),
).rejects.toThrow('Stopping for test');
const savedEntity = transactionManager.save.mock.calls[0][0] as WorkflowEntity;
expect(savedEntity.settings?.redactionPolicy).toBeUndefined();
expect(savedEntity.settings?.executionOrder).toBe('v1');
});
it('does not seed when user lacks workflow:enableRedaction', async () => {
userHasScopesMock.mockResolvedValue(false);
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('production');
const { transactionManager } = setupTransactionMocks();
const newWorkflow = new WorkflowEntity();
newWorkflow.settings = { executionOrder: 'v1' };
await expect(
workflowCreationService.createWorkflow(mock<User>(), newWorkflow, {
projectId: 'project-1',
}),
).rejects.toThrow('Stopping for test');
const savedEntity = transactionManager.save.mock.calls[0][0] as WorkflowEntity;
expect(savedEntity.settings?.redactionPolicy).toBeUndefined();
expect(savedEntity.settings?.executionOrder).toBe('v1');
});
it('does not seed when the effective floor is off', async () => {
userHasScopesMock.mockResolvedValue(true);
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('off');
const { transactionManager } = setupTransactionMocks();
const newWorkflow = new WorkflowEntity();
newWorkflow.settings = { executionOrder: 'v1' };
await expect(
workflowCreationService.createWorkflow(mock<User>(), newWorkflow, {
projectId: 'project-1',
}),
).rejects.toThrow('Stopping for test');
const savedEntity = transactionManager.save.mock.calls[0][0] as WorkflowEntity;
expect(savedEntity.settings?.redactionPolicy).toBeUndefined();
expect(savedEntity.settings?.executionOrder).toBe('v1');
});
it('clamps a none policy up to non-manual when the floor requires production redaction', async () => {
userHasScopesMock.mockResolvedValue(true);
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('production');
const { transactionManager } = setupTransactionMocks();
const newWorkflow = new WorkflowEntity();
newWorkflow.settings = { redactionPolicy: 'none' };
await expect(
workflowCreationService.createWorkflow(mock<User>(), newWorkflow, {
projectId: 'project-1',
}),
).rejects.toThrow('Stopping for test');
const savedEntity = transactionManager.save.mock.calls[0][0] as WorkflowEntity;
expect(savedEntity.settings?.redactionPolicy).toBe('non-manual');
});
it('replaces a manual-only policy with the floor seed when the floor requires production redaction', async () => {
userHasScopesMock.mockResolvedValue(true);
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('production');
const { transactionManager } = setupTransactionMocks();
const newWorkflow = new WorkflowEntity();
newWorkflow.settings = { redactionPolicy: 'manual-only' };
await expect(
workflowCreationService.createWorkflow(mock<User>(), newWorkflow, {
projectId: 'project-1',
}),
).rejects.toThrow('Stopping for test');
const savedEntity = transactionManager.save.mock.calls[0][0] as WorkflowEntity;
expect(savedEntity.settings?.redactionPolicy).toBe('non-manual');
});
it('accepts a stricter-than-floor policy unchanged', async () => {
userHasScopesMock.mockResolvedValue(true);
instanceRedactionEnforcementServiceMock.get.mockResolvedValue('production');
const { transactionManager } = setupTransactionMocks();
const newWorkflow = new WorkflowEntity();
newWorkflow.settings = { redactionPolicy: 'all' };
await expect(
workflowCreationService.createWorkflow(mock<User>(), newWorkflow, {
projectId: 'project-1',
}),
).rejects.toThrow('Stopping for test');
const savedEntity = transactionManager.save.mock.calls[0][0] as WorkflowEntity;
expect(savedEntity.settings?.redactionPolicy).toBe('all');
});
it('drops redactionPolicy when the instance lacks the data-redaction license', async () => {
licenseStateMock.isDataRedactionLicensed.mockReturnValue(false);
const { transactionManager } = setupTransactionMocks();
const newWorkflow = new WorkflowEntity();
newWorkflow.settings = { redactionPolicy: 'all' };
await expect(
workflowCreationService.createWorkflow(mock<User>(), newWorkflow, {
projectId: 'project-1',
}),
).rejects.toThrow('Stopping for test');
expect(instanceRedactionEnforcementServiceMock.get).not.toHaveBeenCalled();
const savedEntity = transactionManager.save.mock.calls[0][0] as WorkflowEntity;
expect(savedEntity.settings?.redactionPolicy).toBeUndefined();
});
});
describe('when user cannot create in the target project', () => {
it('throws NotFoundError when the target project does not exist', async () => {
projectServiceMock.getProjectWithScope.mockResolvedValue(null);

View File

@ -1,3 +1,4 @@
import type { WorkflowEntity } from '@n8n/db';
import type { Scope } from '@n8n/permissions';
import { NodeApiError, NodeError, WorkflowActivationError } from 'n8n-workflow';
import type { WorkflowSettings } from 'n8n-workflow';
@ -37,3 +38,9 @@ export function getErrorDescription(error: unknown): string | undefined {
if (error instanceof NodeApiError) return error.description ?? undefined;
return undefined;
}
export function dropRedactionPolicy(newWorkflow: WorkflowEntity): void {
if (newWorkflow.settings?.redactionPolicy !== undefined) {
delete newWorkflow.settings.redactionPolicy;
}
}

View File

@ -1,12 +1,13 @@
import type { RedactionFloor } from '@n8n/api-types';
import { LicenseState, Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { User, Project } from '@n8n/db';
import type { EntityManager, Project, User } from '@n8n/db';
import {
WorkflowEntity,
ProjectRepository,
SharedWorkflow,
SharedWorkflowRepository,
ProjectRepository,
TagRepository,
WorkflowEntity,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { v4 as uuid } from 'uuid';
@ -21,6 +22,8 @@ import { EventService } from '@/events/event.service';
import type { WorkflowActionSource } from '@/events/maps/relay.event-map';
import { ExternalHooks } from '@/external-hooks';
import { validateEntity } from '@/generic-helpers';
import { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service';
import { policyForFloor, policyMeetsFloor } from '@/modules/redaction/redaction-policy';
import { NodeTypes } from '@/node-types';
import { userHasScopes } from '@/permissions.ee/check-access';
import { FolderService } from '@/services/folder.service';
@ -28,10 +31,11 @@ import { ProjectService } from '@/services/project.service.ee';
import { TagService } from '@/services/tag.service';
import * as WorkflowHelpers from '@/workflow-helpers';
import { dropRedactionPolicy } from './utils';
import { WorkflowFinderService } from './workflow-finder.service';
import { WorkflowHistoryService } from './workflow-history/workflow-history.service';
import { EnterpriseWorkflowService } from './workflow.service.ee';
import { WorkflowValidationService } from './workflow-validation.service';
import { EnterpriseWorkflowService } from './workflow.service.ee';
@Service()
export class WorkflowCreationService {
@ -53,6 +57,7 @@ export class WorkflowCreationService {
private readonly enterpriseWorkflowService: EnterpriseWorkflowService,
private readonly nodeTypes: NodeTypes,
private readonly workflowValidationService: WorkflowValidationService,
private readonly instanceRedactionEnforcementService: InstanceRedactionEnforcementService,
) {}
async createWorkflow(
@ -156,6 +161,8 @@ export class WorkflowCreationService {
// Run external hook after all validation has passed, right before persisting
await this.externalHooks.run('workflow.create', [newWorkflow]);
const floor = await this.readActiveRedactionFloor();
const { manager: dbManager } = this.projectRepository;
const savedWorkflow = await dbManager.transaction(async (transactionManager) => {
@ -174,31 +181,13 @@ export class WorkflowCreationService {
throw new BadRequestError(message);
}
// Strip redactionPolicy if instance lacks data-redaction license
if (
newWorkflow.settings?.redactionPolicy !== undefined &&
!this.licenseState.isDataRedactionLicensed()
) {
delete newWorkflow.settings.redactionPolicy;
}
// Strip redactionPolicy if user lacks the enableRedaction scope.
if (
newWorkflow.settings?.redactionPolicy !== undefined &&
newWorkflow.settings.redactionPolicy !== 'none'
) {
const canUpdateRedaction = await userHasScopes(
user,
['workflow:enableRedaction'],
false,
{ projectId: effectiveProjectId },
transactionManager,
);
if (!canUpdateRedaction) {
delete newWorkflow.settings.redactionPolicy;
}
}
await this.resolveRedactionPolicyOnCreate(
newWorkflow,
user,
effectiveProjectId,
transactionManager,
floor,
);
const workflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
@ -266,4 +255,52 @@ export class WorkflowCreationService {
return savedWorkflow;
}
private async readActiveRedactionFloor(): Promise<RedactionFloor> {
if (!this.licenseState.isDataRedactionLicensed()) return 'off';
return await this.instanceRedactionEnforcementService.get();
}
private async resolveRedactionPolicyOnCreate(
newWorkflow: WorkflowEntity,
user: User,
effectiveProjectId: string,
transactionManager: EntityManager,
floor: RedactionFloor,
): Promise<void> {
// No license — the field is meaningless, drop any incoming value.
if (!this.licenseState.isDataRedactionLicensed()) {
dropRedactionPolicy(newWorkflow);
return;
}
const incomingPolicy = newWorkflow.settings?.redactionPolicy;
const hasIncoming = incomingPolicy !== undefined && incomingPolicy !== 'none';
// Nothing to validate, nothing to clamp — skip the scope check entirely.
if (!hasIncoming && floor === 'off') return;
const canUpdateRedaction = await userHasScopes(
user,
['workflow:enableRedaction'],
false,
{ projectId: effectiveProjectId },
transactionManager,
);
// User can't update the policy, drop any incoming value.
if (!canUpdateRedaction && hasIncoming) {
dropRedactionPolicy(newWorkflow);
}
if (floor === 'off' || !canUpdateRedaction) return;
const current = newWorkflow.settings?.redactionPolicy;
if (current !== undefined && policyMeetsFloor(current, floor)) return;
const seed = policyForFloor(floor);
if (seed === undefined) return;
newWorkflow.settings = { ...(newWorkflow.settings ?? {}), redactionPolicy: seed };
}
}