mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 02:37:46 +02:00
feat(core): Apply instance redaction floor to new workflows (#31532)
This commit is contained in:
parent
5feafdfafd
commit
21db4bcd6c
31
packages/cli/src/workflows/__tests__/utils.test.ts
Normal file
31
packages/cli/src/workflows/__tests__/utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user