mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 17:27:14 +02:00
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:
parent
04ce09878b
commit
93c009aaeb
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user