diff --git a/packages/cli/src/controllers/security-settings.controller.ts b/packages/cli/src/controllers/security-settings.controller.ts index e47ed6d0892..dae31ffb6cb 100644 --- a/packages/cli/src/controllers/security-settings.controller.ts +++ b/packages/cli/src/controllers/security-settings.controller.ts @@ -95,14 +95,28 @@ export class SecuritySettingsController { this.emitInstancePolicyUpdated(req, 'workflow_sharing', dto.personalSpaceSharing); } - // TODO(IAM-622): emit a dedicated audit event with before/after state for - // redaction enforcement changes. The existing `instance-policies-updated` - // event carries a single boolean and can't represent the `floor` enum. if (dto.redactionEnforcement !== undefined && isRedactionEnforcementEnabled()) { - await this.instanceRedactionEnforcementService.set( - floorToSettings(dto.redactionEnforcement.floor), - ); + const before = await this.instanceRedactionEnforcementService.get(); + const after = floorToSettings(dto.redactionEnforcement.floor); updatedSettings.redactionEnforcement = { floor: dto.redactionEnforcement.floor }; + if ( + before.enforced !== after.enforced || + before.manual !== after.manual || + before.production !== after.production + ) { + await this.instanceRedactionEnforcementService.set(after); + this.eventService.emit('redaction-enforcement-updated', { + user: { + id: req.user.id, + email: req.user.email, + firstName: req.user.firstName, + lastName: req.user.lastName, + role: req.user.role, + }, + before, + after, + }); + } } return updatedSettings; diff --git a/packages/cli/src/eventbus/event-message-classes/index.ts b/packages/cli/src/eventbus/event-message-classes/index.ts index ce13f3c64b7..d1cb0d45ee1 100644 --- a/packages/cli/src/eventbus/event-message-classes/index.ts +++ b/packages/cli/src/eventbus/event-message-classes/index.ts @@ -107,6 +107,7 @@ export const eventNamesAudit = [ 'n8n.audit.personal-sharing-restricted.disabled', 'n8n.audit.2fa-enforcement.enabled', 'n8n.audit.2fa-enforcement.disabled', + 'n8n.audit.redaction-enforcement.updated', 'n8n.audit.execution.data.revealed', 'n8n.audit.execution.data.reveal_failure', 'n8n.audit.token-exchange.succeeded', diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index c88dbf1babe..aeb670fc4c9 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -2657,4 +2657,93 @@ describe('LogStreamingEventRelay', () => { }); }); }); + + describe('redaction enforcement events', () => { + it('should log `redaction-enforcement.updated` with redacted user and before/after payload', () => { + const event: RelayEventMap['redaction-enforcement-updated'] = { + user: { + id: 'user404', + email: 'admin7@example.com', + firstName: 'Seventh', + lastName: 'Admin', + role: { slug: 'global:owner' }, + }, + before: { enforced: false, manual: false, production: false }, + after: { enforced: true, manual: false, production: true }, + }; + + eventService.emit('redaction-enforcement-updated', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.redaction-enforcement.updated', + payload: { + userId: 'user404', + _email: 'admin7@example.com', + _firstName: 'Seventh', + _lastName: 'Admin', + globalRole: 'global:owner', + before: { enforced: false, manual: false, production: false }, + after: { enforced: true, manual: false, production: true }, + }, + }); + }); + + it('should log `redaction-enforcement.updated` for downgrade (all -> production)', () => { + const event: RelayEventMap['redaction-enforcement-updated'] = { + user: { + id: 'user404', + email: 'admin7@example.com', + firstName: 'Seventh', + lastName: 'Admin', + role: { slug: 'global:owner' }, + }, + before: { enforced: true, manual: true, production: true }, + after: { enforced: true, manual: false, production: true }, + }; + + eventService.emit('redaction-enforcement-updated', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.redaction-enforcement.updated', + payload: { + userId: 'user404', + _email: 'admin7@example.com', + _firstName: 'Seventh', + _lastName: 'Admin', + globalRole: 'global:owner', + before: { enforced: true, manual: true, production: true }, + after: { enforced: true, manual: false, production: true }, + }, + }); + }); + + it('should log `redaction-enforcement.updated` for upgrade to `all` (full-tuple passthrough)', () => { + const event: RelayEventMap['redaction-enforcement-updated'] = { + user: { + id: 'user404', + email: 'admin7@example.com', + firstName: 'Seventh', + lastName: 'Admin', + role: { slug: 'global:owner' }, + }, + before: { enforced: true, manual: false, production: true }, + after: { enforced: true, manual: true, production: true }, + }; + + eventService.emit('redaction-enforcement-updated', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.redaction-enforcement.updated', + payload: { + userId: 'user404', + _email: 'admin7@example.com', + _firstName: 'Seventh', + _lastName: 'Admin', + globalRole: 'global:owner', + before: { enforced: true, manual: false, production: true }, + after: { enforced: true, manual: true, production: true }, + }, + }); + }); + }); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 47290ded94e..9fd72d11a55 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -1,4 +1,8 @@ -import type { AuthenticationMethod, ProjectRelation } from '@n8n/api-types'; +import type { + AuthenticationMethod, + ProjectRelation, + RedactionEnforcementSettings, +} from '@n8n/api-types'; import type { AuthProviderType, User, IWorkflowDb } from '@n8n/db'; import type { CancellationReason, @@ -943,6 +947,12 @@ export type RelayEventMap = { value: boolean; }; + 'redaction-enforcement-updated': { + user: UserLike; + before: RedactionEnforcementSettings; + after: RedactionEnforcementSettings; + }; + // #endregion // #region Custom Roles diff --git a/packages/cli/src/events/relays/log-streaming.event-relay.ts b/packages/cli/src/events/relays/log-streaming.event-relay.ts index 293c77cb852..23ac0b20854 100644 --- a/packages/cli/src/events/relays/log-streaming.event-relay.ts +++ b/packages/cli/src/events/relays/log-streaming.event-relay.ts @@ -107,6 +107,7 @@ export class LogStreamingEventRelay extends EventRelay { 'job-dequeued': (event) => this.jobDequeued(event), 'job-stalled': (event) => this.jobStalled(event), 'instance-policies-updated': (event) => this.instancePoliciesUpdated(event), + 'redaction-enforcement-updated': (event) => this.redactionEnforcementUpdated(event), 'token-exchange-succeeded': (event) => this.tokenExchangeSucceeded(event), 'token-exchange-failed': (event) => this.tokenExchangeFailed(event), 'token-exchange-identity-linked': (event) => this.tokenExchangeIdentityLinked(event), @@ -1036,6 +1037,18 @@ export class LogStreamingEventRelay extends EventRelay { } } + @Redactable() + private redactionEnforcementUpdated({ + user, + before, + after, + }: RelayEventMap['redaction-enforcement-updated']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.redaction-enforcement.updated', + payload: { ...user, before, after }, + }); + } + // #endregion // #region Token exchange diff --git a/packages/cli/test/integration/controllers/security-settings.controller.test.ts b/packages/cli/test/integration/controllers/security-settings.controller.test.ts index f839841447b..90056c6209f 100644 --- a/packages/cli/test/integration/controllers/security-settings.controller.test.ts +++ b/packages/cli/test/integration/controllers/security-settings.controller.test.ts @@ -5,6 +5,7 @@ import { PERSONAL_SPACE_SHARING_SETTING, } from '@n8n/permissions'; +import { EventService } from '@/events/event.service'; import { InstanceRedactionEnforcementService } from '@/modules/redaction/instance-redaction-enforcement.service'; import { N8N_ENV_FEAT_REDACTION_ENFORCEMENT } from '@/modules/redaction/redaction-enforcement.feature-flag'; import { SecuritySettingsService } from '@/services/security-settings.service'; @@ -16,6 +17,7 @@ import { setupTestServer } from '../shared/utils'; describe('SecuritySettingsController', () => { const securitySettingsService = mockInstance(SecuritySettingsService); const instanceRedactionEnforcementService = mockInstance(InstanceRedactionEnforcementService); + const eventService = mockInstance(EventService); const instanceSettingsLoaderConfig = mockInstance(InstanceSettingsLoaderConfig, { securityPolicyManagedByEnv: false, }); @@ -294,6 +296,14 @@ describe('SecuritySettingsController', () => { }); describe('POST /settings/security', () => { + beforeEach(() => { + instanceRedactionEnforcementService.get.mockResolvedValue({ + enforced: false, + manual: false, + production: false, + }); + }); + it('should ignore redactionEnforcement when feature flag is off', async () => { const response = await ownerAgent .post('/settings/security') @@ -302,6 +312,10 @@ describe('SecuritySettingsController', () => { expect(response.body).toEqual({ data: {} }); expect(instanceRedactionEnforcementService.set).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalledWith( + 'redaction-enforcement-updated', + expect.anything(), + ); }); it('should persist redactionEnforcement.floor = "production" when flag is on', async () => { @@ -342,6 +356,11 @@ describe('SecuritySettingsController', () => { it('should persist redactionEnforcement.floor = "off" when flag is on', async () => { enableRedactionFlag(); + instanceRedactionEnforcementService.get.mockResolvedValue({ + enforced: true, + manual: false, + production: true, + }); instanceRedactionEnforcementService.set.mockResolvedValue(undefined); await ownerAgent @@ -389,6 +408,76 @@ describe('SecuritySettingsController', () => { expect(instanceRedactionEnforcementService.set).not.toHaveBeenCalled(); }); + + describe('audit event emission', () => { + it('should emit `redaction-enforcement-updated` with before/after when settings change', async () => { + enableRedactionFlag(); + instanceRedactionEnforcementService.get.mockResolvedValue({ + enforced: false, + manual: false, + production: false, + }); + instanceRedactionEnforcementService.set.mockResolvedValue(undefined); + + await ownerAgent + .post('/settings/security') + .send({ redactionEnforcement: { floor: 'production' } }) + .expect(200); + + expect(eventService.emit).toHaveBeenCalledWith( + 'redaction-enforcement-updated', + expect.objectContaining({ + user: expect.objectContaining({ id: expect.any(String) }), + before: { enforced: false, manual: false, production: false }, + after: { enforced: true, manual: false, production: true }, + }), + ); + }); + + it('should emit `redaction-enforcement-updated` with before/after when settings are disabled', async () => { + enableRedactionFlag(); + instanceRedactionEnforcementService.get.mockResolvedValue({ + enforced: true, + manual: false, + production: true, + }); + instanceRedactionEnforcementService.set.mockResolvedValue(undefined); + + await ownerAgent + .post('/settings/security') + .send({ redactionEnforcement: { floor: 'off' } }) + .expect(200); + + expect(eventService.emit).toHaveBeenCalledWith( + 'redaction-enforcement-updated', + expect.objectContaining({ + user: expect.objectContaining({ id: expect.any(String) }), + before: { enforced: true, manual: false, production: true }, + after: { enforced: false, manual: false, production: false }, + }), + ); + }); + + it('should not emit when save is idempotent', async () => { + enableRedactionFlag(); + instanceRedactionEnforcementService.get.mockResolvedValue({ + enforced: true, + manual: false, + production: true, + }); + instanceRedactionEnforcementService.set.mockResolvedValue(undefined); + + await ownerAgent + .post('/settings/security') + .send({ redactionEnforcement: { floor: 'production' } }) + .expect(200); + + expect(eventService.emit).not.toHaveBeenCalledWith( + 'redaction-enforcement-updated', + expect.anything(), + ); + }); + }); }); }); });