feat(core): Add audit event for redaction enforcement policy changes (#31078)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thanasis G 2026-05-29 10:15:42 +03:00 committed by GitHub
parent 04ce09878b
commit 93c009aaeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 223 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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