From 21db4bcd6c9b02f338babdca95b8c8729524eb5b Mon Sep 17 00:00:00 2001 From: Ilfat Mindubaev Date: Wed, 3 Jun 2026 16:46:33 +0300 Subject: [PATCH] feat(core): Apply instance redaction floor to new workflows (#31532) --- .../cli/src/workflows/__tests__/utils.test.ts | 31 +++ .../workflow-creation.service.test.ts | 188 +++++++++++++++++- packages/cli/src/workflows/utils.ts | 7 + .../workflows/workflow-creation.service.ts | 95 ++++++--- 4 files changed, 289 insertions(+), 32 deletions(-) create mode 100644 packages/cli/src/workflows/__tests__/utils.test.ts diff --git a/packages/cli/src/workflows/__tests__/utils.test.ts b/packages/cli/src/workflows/__tests__/utils.test.ts new file mode 100644 index 00000000000..e880eacfe1b --- /dev/null +++ b/packages/cli/src/workflows/__tests__/utils.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts index c741a7ca3e9..96b7b47084e 100644 --- a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts @@ -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; let projectRepositoryMock: MockProxy; let workflowValidationServiceMock: MockProxy; + let instanceRedactionEnforcementServiceMock: MockProxy; beforeEach(() => { jest.clearAllMocks(); @@ -40,10 +42,14 @@ describe('WorkflowCreationService', () => { projectServiceMock = mock(); projectRepositoryMock = mock(); workflowValidationServiceMock = mock(); + instanceRedactionEnforcementServiceMock = mock(); 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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); diff --git a/packages/cli/src/workflows/utils.ts b/packages/cli/src/workflows/utils.ts index 9a97a24be47..18fb5aad6ab 100644 --- a/packages/cli/src/workflows/utils.ts +++ b/packages/cli/src/workflows/utils.ts @@ -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; + } +} diff --git a/packages/cli/src/workflows/workflow-creation.service.ts b/packages/cli/src/workflows/workflow-creation.service.ts index 2242d83336f..33daf175fab 100644 --- a/packages/cli/src/workflows/workflow-creation.service.ts +++ b/packages/cli/src/workflows/workflow-creation.service.ts @@ -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(newWorkflow); @@ -266,4 +255,52 @@ export class WorkflowCreationService { return savedWorkflow; } + + private async readActiveRedactionFloor(): Promise { + 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 { + // 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 }; + } }