From ca96ffc8047150898dbe17b4baa69f0aa864a81b Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Wed, 17 Dec 2025 08:58:51 +0000 Subject: [PATCH] feat(core): Add log streaming events for 2FA enable/disable (#23302) --- .../cli/src/controllers/mfa.controller.ts | 23 ++++++ .../eventbus/event-message-classes/index.ts | 2 + .../log-streaming-event-relay.test.ts | 79 +++++++++++++++++++ .../cli/src/events/maps/relay.event-map.ts | 9 +++ .../relays/log-streaming.event-relay.ts | 18 +++++ 5 files changed, 131 insertions(+) diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts index 41790b8a709..dbc09d9744c 100644 --- a/packages/cli/src/controllers/mfa.controller.ts +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -4,6 +4,7 @@ import { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { MfaService } from '@/mfa/mfa.service'; import { MFA } from '@/requests'; @@ -15,6 +16,7 @@ export class MFAController { private externalHooks: ExternalHooks, private authService: AuthService, private userRepository: UserRepository, + private eventService: EventService, ) {} @Post('/enforce-mfa') @@ -107,6 +109,16 @@ export class MFAController { const updatedUser = await this.mfaService.enableMfa(id); + this.eventService.emit('user-mfa-enabled', { + user: { + id: req.user.id, + email: req.user.email, + firstName: req.user.firstName, + lastName: req.user.lastName, + role: req.user.role, + }, + }); + this.authService.issueCookie(res, updatedUser, verified, req.browserId); } @@ -132,6 +144,17 @@ export class MFAController { await this.mfaService.disableMfaWithRecoveryCode(userId, mfaRecoveryCode); } + this.eventService.emit('user-mfa-disabled', { + user: { + id: req.user.id, + email: req.user.email, + firstName: req.user.firstName, + lastName: req.user.lastName, + role: req.user.role, + }, + disableMethod: mfaCodeDefined ? 'mfaCode' : 'recoveryCode', + }); + const updatedUser = await this.userRepository.findOneOrFail({ where: { id: userId }, relations: ['role'], diff --git a/packages/cli/src/eventbus/event-message-classes/index.ts b/packages/cli/src/eventbus/event-message-classes/index.ts index f992df41124..e4bdfbca8f7 100644 --- a/packages/cli/src/eventbus/event-message-classes/index.ts +++ b/packages/cli/src/eventbus/event-message-classes/index.ts @@ -73,6 +73,8 @@ export const eventNamesAudit = [ 'n8n.audit.user.credentials.deleted', 'n8n.audit.user.api.created', 'n8n.audit.user.api.deleted', + 'n8n.audit.user.mfa.enabled', + 'n8n.audit.user.mfa.disabled', 'n8n.audit.package.installed', 'n8n.audit.package.updated', 'n8n.audit.package.deleted', 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 24fbd90c5bb..77e0933d148 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 @@ -657,6 +657,85 @@ describe('LogStreamingEventRelay', () => { }, }); }); + + it('should log on `user-mfa-enabled` event', () => { + const event: RelayEventMap['user-mfa-enabled'] = { + user: { + id: 'user505', + email: 'mfauser@example.com', + firstName: 'MFA', + lastName: 'User', + role: { slug: 'global:member' }, + }, + }; + + eventService.emit('user-mfa-enabled', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.mfa.enabled', + payload: { + userId: 'user505', + _email: 'mfauser@example.com', + _firstName: 'MFA', + _lastName: 'User', + globalRole: 'global:member', + }, + }); + }); + + it('should log on `user-mfa-disabled` event with mfaCode method', () => { + const event: RelayEventMap['user-mfa-disabled'] = { + user: { + id: 'user606', + email: 'mfadisable@example.com', + firstName: 'Disable', + lastName: 'MFA', + role: { slug: 'global:member' }, + }, + disableMethod: 'mfaCode', + }; + + eventService.emit('user-mfa-disabled', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.mfa.disabled', + payload: { + userId: 'user606', + _email: 'mfadisable@example.com', + _firstName: 'Disable', + _lastName: 'MFA', + globalRole: 'global:member', + disableMethod: 'mfaCode', + }, + }); + }); + + it('should log on `user-mfa-disabled` event with recoveryCode method', () => { + const event: RelayEventMap['user-mfa-disabled'] = { + user: { + id: 'user707', + email: 'recovery@example.com', + firstName: 'Recovery', + lastName: 'User', + role: { slug: GLOBAL_OWNER_ROLE.slug }, + }, + disableMethod: 'recoveryCode', + }; + + eventService.emit('user-mfa-disabled', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.mfa.disabled', + payload: { + userId: 'user707', + _email: 'recovery@example.com', + _firstName: 'Recovery', + _lastName: 'User', + globalRole: 'global:owner', + disableMethod: 'recoveryCode', + }, + }); + }); }); describe('click events', () => { diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index a3e5979cea7..5103da0cbfc 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -181,6 +181,15 @@ export type RelayEventMap = { fieldsChanged: string[]; }; + 'user-mfa-enabled': { + user: UserLike; + }; + + 'user-mfa-disabled': { + user: UserLike; + disableMethod: 'mfaCode' | 'recoveryCode'; + }; + 'user-signed-up': { user: UserLike; userType: AuthProviderType; 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 baefc3a4e39..fc2c75b1de9 100644 --- a/packages/cli/src/events/relays/log-streaming.event-relay.ts +++ b/packages/cli/src/events/relays/log-streaming.event-relay.ts @@ -35,6 +35,8 @@ export class LogStreamingEventRelay extends EventRelay { 'user-invited': (event) => this.userInvited(event), 'user-reinvited': (event) => this.userReinvited(event), 'user-updated': (event) => this.userUpdated(event), + 'user-mfa-enabled': (event) => this.userMfaEnabled(event), + 'user-mfa-disabled': (event) => this.userMfaDisabled(event), 'user-signed-up': (event) => this.userSignedUp(event), 'user-logged-in': (event) => this.userLoggedIn(event), 'user-login-failed': (event) => this.userLoginFailed(event), @@ -320,6 +322,22 @@ export class LogStreamingEventRelay extends EventRelay { }); } + @Redactable() + private userMfaEnabled({ user }: RelayEventMap['user-mfa-enabled']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.mfa.enabled', + payload: user, + }); + } + + @Redactable() + private userMfaDisabled({ user, disableMethod }: RelayEventMap['user-mfa-disabled']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.mfa.disabled', + payload: { ...user, disableMethod }, + }); + } + // #endregion // #region Auth