diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index f0c063c2a6e7b..f8c56be0d43bf 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -3,13 +3,11 @@ import { snakeCase } from 'change-case'; import os from 'node:os'; import { get as pslGet } from 'psl'; import type { - AuthenticationMethod, ExecutionStatus, INodesGraphResult, IRun, ITelemetryTrackProperties, IWorkflowBase, - WorkflowExecuteMode, } from 'n8n-workflow'; import { TelemetryHelpers } from 'n8n-workflow'; import { InstanceSettings } from 'n8n-core'; @@ -18,17 +16,13 @@ import config from '@/config'; import { N8N_VERSION } from '@/constants'; import type { AuthProviderType } from '@db/entities/AuthIdentity'; import type { GlobalRole, User } from '@db/entities/User'; -import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; -import type { EventPayloadWorkflow } from '@/eventbus'; -import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions'; import type { ITelemetryUserDeletionData, IWorkflowDb, IExecutionTrackProperties, - IWorkflowExecutionDataProcess, } from '@/Interfaces'; import { License } from '@/License'; import { EventsService } from '@/services/events.service'; @@ -38,22 +32,7 @@ import type { Project } from '@db/entities/Project'; import type { ProjectRole } from '@db/entities/ProjectRelation'; import { ProjectRelationRepository } from './databases/repositories/projectRelation.repository'; import { SharedCredentialsRepository } from './databases/repositories/sharedCredentials.repository'; - -function userToPayload(user: User): { - userId: string; - _email: string; - _firstName: string; - _lastName: string; - globalRole: GlobalRole; -} { - return { - userId: user.id, - _email: user.email, - _firstName: user.firstName, - _lastName: user.lastName, - globalRole: user.role, - }; -} +import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus'; @Service() export class InternalHooks { @@ -64,10 +43,10 @@ export class InternalHooks { private readonly workflowRepository: WorkflowRepository, eventsService: EventsService, private readonly instanceSettings: InstanceSettings, - private readonly eventBus: MessageEventBus, private readonly license: License, private readonly projectRelationRepository: ProjectRelationRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly _eventBus: MessageEventBus, // needed until we decouple telemetry ) { eventsService.on( 'telemetry.onFirstProductionWorkflowSuccess', @@ -177,41 +156,23 @@ export class InternalHooks { publicApi: boolean, ): Promise { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.workflow.created', - payload: { - ...userToPayload(user), - workflowId: workflow.id, - workflowName: workflow.name, - }, - }), - this.telemetry.track('User created workflow', { - user_id: user.id, - workflow_id: workflow.id, - node_graph_string: JSON.stringify(nodeGraph), - public_api: publicApi, - project_id: project.id, - project_type: project.type, - }), - ]); + + void this.telemetry.track('User created workflow', { + user_id: user.id, + workflow_id: workflow.id, + node_graph_string: JSON.stringify(nodeGraph), + public_api: publicApi, + project_id: project.id, + project_type: project.type, + }); } async onWorkflowDeleted(user: User, workflowId: string, publicApi: boolean): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.workflow.deleted', - payload: { - ...userToPayload(user), - workflowId, - }, - }), - this.telemetry.track('User deleted workflow', { - user_id: user.id, - workflow_id: workflowId, - public_api: publicApi, - }), - ]); + void this.telemetry.track('User deleted workflow', { + user_id: user.id, + workflow_id: workflowId, + public_api: publicApi, + }); } async onWorkflowSaved(user: User, workflow: IWorkflowDb, publicApi: boolean): Promise { @@ -247,127 +208,22 @@ export class InternalHooks { (note) => note.overlapping, ).length; - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.workflow.updated', - payload: { - ...userToPayload(user), - workflowId: workflow.id, - workflowName: workflow.name, - }, - }), - this.telemetry.track('User saved workflow', { - user_id: user.id, - workflow_id: workflow.id, - node_graph_string: JSON.stringify(nodeGraph), - notes_count_overlapping: overlappingCount, - notes_count_non_overlapping: notesCount - overlappingCount, - version_cli: N8N_VERSION, - num_tags: workflow.tags?.length ?? 0, - public_api: publicApi, - sharing_role: userRole, - }), - ]); - } - - async onNodeBeforeExecute( - executionId: string, - workflow: IWorkflowBase, - nodeName: string, - ): Promise { - const nodeInWorkflow = workflow.nodes.find((node) => node.name === nodeName); - void this.eventBus.sendNodeEvent({ - eventName: 'n8n.node.started', - payload: { - executionId, - nodeName, - workflowId: workflow.id?.toString(), - workflowName: workflow.name, - nodeType: nodeInWorkflow?.type, - }, - }); - } - - async onNodePostExecute( - executionId: string, - workflow: IWorkflowBase, - nodeName: string, - ): Promise { - const nodeInWorkflow = workflow.nodes.find((node) => node.name === nodeName); - void this.eventBus.sendNodeEvent({ - eventName: 'n8n.node.finished', - payload: { - executionId, - nodeName, - workflowId: workflow.id?.toString(), - workflowName: workflow.name, - nodeType: nodeInWorkflow?.type, - }, - }); - } - - async onWorkflowBeforeExecute( - executionId: string, - data: IWorkflowExecutionDataProcess | IWorkflowBase, - ): Promise { - let payload: EventPayloadWorkflow; - // this hook is called slightly differently depending on whether it's from a worker or the main instance - // in the worker context, meaning in queue mode, only IWorkflowBase is available - if ('executionData' in data) { - payload = { - executionId, - userId: data.userId ?? undefined, - workflowId: data.workflowData.id?.toString(), - isManual: data.executionMode === 'manual', - workflowName: data.workflowData.name, - }; - } else { - payload = { - executionId, - userId: undefined, - workflowId: (data as IWorkflowBase).id?.toString(), - isManual: false, - workflowName: (data as IWorkflowBase).name, - }; - } - void this.eventBus.sendWorkflowEvent({ - eventName: 'n8n.workflow.started', - payload, + void this.telemetry.track('User saved workflow', { + user_id: user.id, + workflow_id: workflow.id, + node_graph_string: JSON.stringify(nodeGraph), + notes_count_overlapping: overlappingCount, + notes_count_non_overlapping: notesCount - overlappingCount, + version_cli: N8N_VERSION, + num_tags: workflow.tags?.length ?? 0, + public_api: publicApi, + sharing_role: userRole, }); } - async onWorkflowCrashed( - executionId: string, - executionMode: WorkflowExecuteMode, - workflowData?: IWorkflowBase, - executionMetadata?: ExecutionMetadata[], - ): Promise { - let metaData; - try { - if (executionMetadata) { - metaData = executionMetadata.reduce((acc, meta) => { - return { ...acc, [meta.key]: meta.value }; - }, {}); - } - } catch {} - - void Promise.all([ - this.eventBus.sendWorkflowEvent({ - eventName: 'n8n.workflow.crashed', - payload: { - executionId, - isManual: executionMode === 'manual', - workflowId: workflowData?.id?.toString(), - workflowName: workflowData?.name, - metaData, - }, - }), - ]); - } - // eslint-disable-next-line complexity async onWorkflowPostExecute( - executionId: string, + _executionId: string, workflow: IWorkflowBase, runData?: IRun, userId?: string, @@ -505,36 +361,6 @@ export class InternalHooks { } } - const sharedEventPayload: EventPayloadWorkflow = { - executionId, - success: telemetryProperties.success, - userId: telemetryProperties.user_id, - workflowId: workflow.id, - isManual: telemetryProperties.is_manual, - workflowName: workflow.name, - metaData: runData?.data?.resultData?.metadata, - }; - let event; - if (telemetryProperties.success) { - event = this.eventBus.sendWorkflowEvent({ - eventName: 'n8n.workflow.success', - payload: sharedEventPayload, - }); - } else { - event = this.eventBus.sendWorkflowEvent({ - eventName: 'n8n.workflow.failed', - payload: { - ...sharedEventPayload, - lastNodeExecuted: runData?.data.resultData.lastNodeExecuted, - errorNodeType: telemetryProperties.error_node_type, - errorNodeId: telemetryProperties.error_node_id?.toString(), - errorMessage: telemetryProperties.error_message?.toString(), - }, - }); - } - - promises.push(event); - void Promise.all([...promises, this.telemetry.trackWorkflowExecution(telemetryProperties)]); } @@ -563,19 +389,11 @@ export class InternalHooks { telemetryData: ITelemetryUserDeletionData; publicApi: boolean; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.deleted', - payload: { - ...userToPayload(userDeletionData.user), - }, - }), - this.telemetry.track('User deleted user', { - ...userDeletionData.telemetryData, - user_id: userDeletionData.user.id, - public_api: userDeletionData.publicApi, - }), - ]); + void this.telemetry.track('User deleted user', { + ...userDeletionData.telemetryData, + user_id: userDeletionData.user.id, + public_api: userDeletionData.publicApi, + }); } async onUserInvite(userInviteData: { @@ -585,23 +403,13 @@ export class InternalHooks { email_sent: boolean; invitee_role: string; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.invited', - payload: { - ...userToPayload(userInviteData.user), - targetUserId: userInviteData.target_user_id, - }, - }), - - this.telemetry.track('User invited new user', { - user_id: userInviteData.user.id, - target_user_id: userInviteData.target_user_id, - public_api: userInviteData.public_api, - email_sent: userInviteData.email_sent, - invitee_role: userInviteData.invitee_role, - }), - ]); + void this.telemetry.track('User invited new user', { + user_id: userInviteData.user.id, + target_user_id: userInviteData.target_user_id, + public_api: userInviteData.public_api, + email_sent: userInviteData.email_sent, + invitee_role: userInviteData.invitee_role, + }); } async onUserRoleChange(userRoleChangeData: { @@ -615,27 +423,6 @@ export class InternalHooks { void this.telemetry.track('User changed role', { user_id: user.id, ...rest }); } - async onUserReinvite(userReinviteData: { - user: User; - target_user_id: string; - public_api: boolean; - }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.reinvited', - payload: { - ...userToPayload(userReinviteData.user), - targetUserId: userReinviteData.target_user_id, - }, - }), - this.telemetry.track('User resent new user invite email', { - user_id: userReinviteData.user.id, - target_user_id: userReinviteData.target_user_id, - public_api: userReinviteData.public_api, - }), - ]); - } - async onUserRetrievedUser(userRetrievedData: { user_id: string; public_api: boolean; @@ -679,55 +466,25 @@ export class InternalHooks { } async onUserUpdate(userUpdateData: { user: User; fields_changed: string[] }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.updated', - payload: { - ...userToPayload(userUpdateData.user), - fieldsChanged: userUpdateData.fields_changed, - }, - }), - this.telemetry.track('User changed personal settings', { - user_id: userUpdateData.user.id, - fields_changed: userUpdateData.fields_changed, - }), - ]); + void this.telemetry.track('User changed personal settings', { + user_id: userUpdateData.user.id, + fields_changed: userUpdateData.fields_changed, + }); } async onUserInviteEmailClick(userInviteClickData: { inviter: User; invitee: User; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.invitation.accepted', - payload: { - invitee: { - ...userToPayload(userInviteClickData.invitee), - }, - inviter: { - ...userToPayload(userInviteClickData.inviter), - }, - }, - }), - this.telemetry.track('User clicked invite link from email', { - user_id: userInviteClickData.invitee.id, - }), - ]); + void this.telemetry.track('User clicked invite link from email', { + user_id: userInviteClickData.invitee.id, + }); } async onUserPasswordResetEmailClick(userPasswordResetData: { user: User }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.reset', - payload: { - ...userToPayload(userPasswordResetData.user), - }, - }), - this.telemetry.track('User clicked password reset link from email', { - user_id: userPasswordResetData.user.id, - }), - ]); + void this.telemetry.track('User clicked password reset link from email', { + user_id: userPasswordResetData.user.id, + }); } async onUserTransactionalEmail(userTransactionalEmailData: { @@ -756,47 +513,23 @@ export class InternalHooks { } async onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.api.deleted', - payload: { - ...userToPayload(apiKeyDeletedData.user), - }, - }), - this.telemetry.track('API key deleted', { - user_id: apiKeyDeletedData.user.id, - public_api: apiKeyDeletedData.public_api, - }), - ]); + void this.telemetry.track('API key deleted', { + user_id: apiKeyDeletedData.user.id, + public_api: apiKeyDeletedData.public_api, + }); } async onApiKeyCreated(apiKeyCreatedData: { user: User; public_api: boolean }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.api.created', - payload: { - ...userToPayload(apiKeyCreatedData.user), - }, - }), - this.telemetry.track('API key created', { - user_id: apiKeyCreatedData.user.id, - public_api: apiKeyCreatedData.public_api, - }), - ]); + void this.telemetry.track('API key created', { + user_id: apiKeyCreatedData.user.id, + public_api: apiKeyCreatedData.public_api, + }); } async onUserPasswordResetRequestClick(userPasswordResetData: { user: User }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.reset.requested', - payload: { - ...userToPayload(userPasswordResetData.user), - }, - }), - this.telemetry.track('User requested password reset while logged out', { - user_id: userPasswordResetData.user.id, - }), - ]); + void this.telemetry.track('User requested password reset while logged out', { + user_id: userPasswordResetData.user.id, + }); } async onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }): Promise { @@ -810,18 +543,10 @@ export class InternalHooks { was_disabled_ldap_user: boolean; }, ): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.signedup', - payload: { - ...userToPayload(user), - }, - }), - this.telemetry.track('User signed up', { - user_id: user.id, - ...userSignupData, - }), - ]); + void this.telemetry.track('User signed up', { + user_id: user.id, + ...userSignupData, + }); } async onEmailFailed(failedEmailData: { @@ -834,50 +559,9 @@ export class InternalHooks { | 'Credentials shared'; public_api: boolean; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.email.failed', - payload: { - messageType: failedEmailData.message_type, - ...userToPayload(failedEmailData.user), - }, - }), - this.telemetry.track('Instance failed to send transactional email to user', { - user_id: failedEmailData.user.id, - }), - ]); - } - - async onUserLoginSuccess(userLoginData: { - user: User; - authenticationMethod: AuthenticationMethod; - }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.login.success', - payload: { - authenticationMethod: userLoginData.authenticationMethod, - ...userToPayload(userLoginData.user), - }, - }), - ]); - } - - async onUserLoginFailed(userLoginData: { - user: string; - authenticationMethod: AuthenticationMethod; - reason?: string; - }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.login.failed', - payload: { - authenticationMethod: userLoginData.authenticationMethod, - user: userLoginData.user, - reason: userLoginData.reason, - }, - }), - ]); + void this.telemetry.track('Instance failed to send transactional email to user', { + user_id: failedEmailData.user.id, + }); } /** @@ -894,25 +578,14 @@ export class InternalHooks { const project = await this.sharedCredentialsRepository.findCredentialOwningProject( userCreatedCredentialsData.credential_id, ); - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.credentials.created', - payload: { - ...userToPayload(userCreatedCredentialsData.user), - credentialName: userCreatedCredentialsData.credential_name, - credentialType: userCreatedCredentialsData.credential_type, - credentialId: userCreatedCredentialsData.credential_id, - }, - }), - this.telemetry.track('User created credentials', { - user_id: userCreatedCredentialsData.user.id, - credential_type: userCreatedCredentialsData.credential_type, - credential_id: userCreatedCredentialsData.credential_id, - instance_id: this.instanceSettings.instanceId, - project_id: project?.id, - project_type: project?.type, - }), - ]); + void this.telemetry.track('User created credentials', { + user_id: userCreatedCredentialsData.user.id, + credential_type: userCreatedCredentialsData.credential_type, + credential_id: userCreatedCredentialsData.credential_id, + instance_id: this.instanceSettings.instanceId, + project_id: project?.id, + project_type: project?.type, + }); } async onUserSharedCredentials(userSharedCredentialsData: { @@ -924,29 +597,15 @@ export class InternalHooks { user_ids_sharees_added: string[]; sharees_removed: number | null; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.credentials.shared', - payload: { - ...userToPayload(userSharedCredentialsData.user), - credentialName: userSharedCredentialsData.credential_name, - credentialType: userSharedCredentialsData.credential_type, - credentialId: userSharedCredentialsData.credential_id, - userIdSharer: userSharedCredentialsData.user_id_sharer, - userIdsShareesAdded: userSharedCredentialsData.user_ids_sharees_added, - shareesRemoved: userSharedCredentialsData.sharees_removed, - }, - }), - this.telemetry.track('User updated cred sharing', { - user_id: userSharedCredentialsData.user.id, - credential_type: userSharedCredentialsData.credential_type, - credential_id: userSharedCredentialsData.credential_id, - user_id_sharer: userSharedCredentialsData.user_id_sharer, - user_ids_sharees_added: userSharedCredentialsData.user_ids_sharees_added, - sharees_removed: userSharedCredentialsData.sharees_removed, - instance_id: this.instanceSettings.instanceId, - }), - ]); + void this.telemetry.track('User updated cred sharing', { + user_id: userSharedCredentialsData.user.id, + credential_type: userSharedCredentialsData.credential_type, + credential_id: userSharedCredentialsData.credential_id, + user_id_sharer: userSharedCredentialsData.user_id_sharer, + user_ids_sharees_added: userSharedCredentialsData.user_ids_sharees_added, + sharees_removed: userSharedCredentialsData.sharees_removed, + instance_id: this.instanceSettings.instanceId, + }); } async onUserUpdatedCredentials(userUpdatedCredentialsData: { @@ -955,22 +614,11 @@ export class InternalHooks { credential_type: string; credential_id: string; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.credentials.updated', - payload: { - ...userToPayload(userUpdatedCredentialsData.user), - credentialName: userUpdatedCredentialsData.credential_name, - credentialType: userUpdatedCredentialsData.credential_type, - credentialId: userUpdatedCredentialsData.credential_id, - }, - }), - this.telemetry.track('User updated credentials', { - user_id: userUpdatedCredentialsData.user.id, - credential_type: userUpdatedCredentialsData.credential_type, - credential_id: userUpdatedCredentialsData.credential_id, - }), - ]); + void this.telemetry.track('User updated credentials', { + user_id: userUpdatedCredentialsData.user.id, + credential_type: userUpdatedCredentialsData.credential_type, + credential_id: userUpdatedCredentialsData.credential_id, + }); } async onUserDeletedCredentials(userUpdatedCredentialsData: { @@ -979,23 +627,12 @@ export class InternalHooks { credential_type: string; credential_id: string; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.credentials.deleted', - payload: { - ...userToPayload(userUpdatedCredentialsData.user), - credentialName: userUpdatedCredentialsData.credential_name, - credentialType: userUpdatedCredentialsData.credential_type, - credentialId: userUpdatedCredentialsData.credential_id, - }, - }), - this.telemetry.track('User deleted credentials', { - user_id: userUpdatedCredentialsData.user.id, - credential_type: userUpdatedCredentialsData.credential_type, - credential_id: userUpdatedCredentialsData.credential_id, - instance_id: this.instanceSettings.instanceId, - }), - ]); + void this.telemetry.track('User deleted credentials', { + user_id: userUpdatedCredentialsData.user.id, + credential_type: userUpdatedCredentialsData.credential_type, + credential_id: userUpdatedCredentialsData.credential_id, + instance_id: this.instanceSettings.instanceId, + }); } /** @@ -1013,33 +650,17 @@ export class InternalHooks { package_author_email?: string; failure_reason?: string; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.package.installed', - payload: { - ...userToPayload(installationData.user), - inputString: installationData.input_string, - packageName: installationData.package_name, - success: installationData.success, - packageVersion: installationData.package_version, - packageNodeNames: installationData.package_node_names, - packageAuthor: installationData.package_author, - packageAuthorEmail: installationData.package_author_email, - failureReason: installationData.failure_reason, - }, - }), - this.telemetry.track('cnr package install finished', { - user_id: installationData.user.id, - input_string: installationData.input_string, - package_name: installationData.package_name, - success: installationData.success, - package_version: installationData.package_version, - package_node_names: installationData.package_node_names, - package_author: installationData.package_author, - package_author_email: installationData.package_author_email, - failure_reason: installationData.failure_reason, - }), - ]); + void this.telemetry.track('cnr package install finished', { + user_id: installationData.user.id, + input_string: installationData.input_string, + package_name: installationData.package_name, + success: installationData.success, + package_version: installationData.package_version, + package_node_names: installationData.package_node_names, + package_author: installationData.package_author, + package_author_email: installationData.package_author_email, + failure_reason: installationData.failure_reason, + }); } async onCommunityPackageUpdateFinished(updateData: { @@ -1051,29 +672,15 @@ export class InternalHooks { package_author?: string; package_author_email?: string; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.package.updated', - payload: { - ...userToPayload(updateData.user), - packageName: updateData.package_name, - packageVersionCurrent: updateData.package_version_current, - packageVersionNew: updateData.package_version_new, - packageNodeNames: updateData.package_node_names, - packageAuthor: updateData.package_author, - packageAuthorEmail: updateData.package_author_email, - }, - }), - this.telemetry.track('cnr package updated', { - user_id: updateData.user.id, - package_name: updateData.package_name, - package_version_current: updateData.package_version_current, - package_version_new: updateData.package_version_new, - package_node_names: updateData.package_node_names, - package_author: updateData.package_author, - package_author_email: updateData.package_author_email, - }), - ]); + void this.telemetry.track('cnr package updated', { + user_id: updateData.user.id, + package_name: updateData.package_name, + package_version_current: updateData.package_version_current, + package_version_new: updateData.package_version_new, + package_node_names: updateData.package_node_names, + package_author: updateData.package_author, + package_author_email: updateData.package_author_email, + }); } async onCommunityPackageDeleteFinished(deleteData: { @@ -1084,27 +691,14 @@ export class InternalHooks { package_author?: string; package_author_email?: string; }): Promise { - void Promise.all([ - this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.package.deleted', - payload: { - ...userToPayload(deleteData.user), - packageName: deleteData.package_name, - packageVersion: deleteData.package_version, - packageNodeNames: deleteData.package_node_names, - packageAuthor: deleteData.package_author, - packageAuthorEmail: deleteData.package_author_email, - }, - }), - this.telemetry.track('cnr package deleted', { - user_id: deleteData.user.id, - package_name: deleteData.package_name, - package_version: deleteData.package_version, - package_node_names: deleteData.package_node_names, - package_author: deleteData.package_author, - package_author_email: deleteData.package_author_email, - }), - ]); + void this.telemetry.track('cnr package deleted', { + user_id: deleteData.user.id, + package_name: deleteData.package_name, + package_version: deleteData.package_version, + package_node_names: deleteData.package_node_names, + package_author: deleteData.package_author, + package_author_email: deleteData.package_author_email, + }); } async onLdapSyncFinished(data: { diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index 20b7d5f949488..adb7d370e6861 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -18,6 +18,7 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { InternalHooks } from '@/InternalHooks'; +import { EventRelay } from '@/eventbus/event-relay.service'; export async function getCredentials(credentialId: string): Promise { return await Container.get(CredentialsRepository).findOneBy({ id: credentialId }); @@ -59,6 +60,12 @@ export async function saveCredential( credential_id: credential.id, public_api: true, }); + Container.get(EventRelay).emit('credentials-created', { + user, + credentialName: credential.name, + credentialType: credential.type, + credentialId: credential.id, + }); return await Db.transaction(async (transactionManager) => { const savedCredential = await transactionManager.save(credential); @@ -95,6 +102,12 @@ export async function removeCredential( credential_type: credentials.type, credential_id: credentials.id, }); + Container.get(EventRelay).emit('credentials-deleted', { + user, + credentialName: credentials.name, + credentialType: credentials.type, + credentialId: credentials.id, + }); return await Container.get(CredentialsRepository).remove(credentials); } diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index 6daab565c97a1..d0f69cd80d583 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -30,6 +30,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflo import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { EventRelay } from '@/eventbus/event-relay.service'; export = { createWorkflow: [ @@ -56,6 +57,10 @@ export = { await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, project, true); + Container.get(EventRelay).emit('workflow-created', { + workflow: createdWorkflow, + user: req.user, + }); return res.json(createdWorkflow); }, @@ -233,6 +238,11 @@ export = { await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]); void Container.get(InternalHooks).onWorkflowSaved(req.user, updateData, true); + Container.get(EventRelay).emit('workflow-saved', { + user: req.user, + workflowId: updateData.id, + workflowName: updateData.name, + }); return res.json(updatedWorkflow); }, diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts index d864ab7b2ee88..c6adec6e4d8d5 100644 --- a/packages/cli/src/UserManagement/email/UserManagementMailer.ts +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -17,6 +17,7 @@ import { toError } from '@/utils'; import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces'; import { NodeMailer } from './NodeMailer'; +import { EventRelay } from '@/eventbus/event-relay.service'; type Template = HandlebarsTemplateDelegate; type TemplateName = 'invite' | 'passwordReset' | 'workflowShared' | 'credentialsShared'; @@ -144,6 +145,10 @@ export class UserManagementMailer { message_type: 'Workflow shared', public_api: false, }); + Container.get(EventRelay).emit('email-failed', { + user: sharer, + messageType: 'Workflow shared', + }); const error = toError(e); @@ -199,6 +204,10 @@ export class UserManagementMailer { message_type: 'Credentials shared', public_api: false, }); + Container.get(EventRelay).emit('email-failed', { + user: sharer, + messageType: 'Credentials shared', + }); const error = toError(e); diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 5fb6a3de06c79..5b1804daac165 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -71,6 +71,7 @@ import { WorkflowRepository } from './databases/repositories/workflow.repository import { UrlService } from './services/url.service'; import { WorkflowExecutionService } from './workflows/workflowExecution.service'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; +import { EventRelay } from './eventbus/event-relay.service'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -392,17 +393,21 @@ export function hookFunctionsPreExecute(): IWorkflowExecuteHooks { */ function hookFunctionsSave(): IWorkflowExecuteHooks { const logger = Container.get(Logger); - const internalHooks = Container.get(InternalHooks); const eventsService = Container.get(EventsService); + const eventRelay = Container.get(EventRelay); return { nodeExecuteBefore: [ async function (this: WorkflowHooks, nodeName: string): Promise { - void internalHooks.onNodeBeforeExecute(this.executionId, this.workflowData, nodeName); + const { executionId, workflowData: workflow } = this; + + eventRelay.emit('node-pre-execute', { executionId, workflow, nodeName }); }, ], nodeExecuteAfter: [ async function (this: WorkflowHooks, nodeName: string): Promise { - void internalHooks.onNodePostExecute(this.executionId, this.workflowData, nodeName); + const { executionId, workflowData: workflow } = this; + + eventRelay.emit('node-post-execute', { executionId, workflow, nodeName }); }, ], workflowExecuteBefore: [], @@ -541,20 +546,27 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { const logger = Container.get(Logger); const internalHooks = Container.get(InternalHooks); const eventsService = Container.get(EventsService); + const eventRelay = Container.get(EventRelay); return { nodeExecuteBefore: [ async function (this: WorkflowHooks, nodeName: string): Promise { - void internalHooks.onNodeBeforeExecute(this.executionId, this.workflowData, nodeName); + const { executionId, workflowData: workflow } = this; + + eventRelay.emit('node-pre-execute', { executionId, workflow, nodeName }); }, ], nodeExecuteAfter: [ async function (this: WorkflowHooks, nodeName: string): Promise { - void internalHooks.onNodePostExecute(this.executionId, this.workflowData, nodeName); + const { executionId, workflowData: workflow } = this; + + eventRelay.emit('node-post-execute', { executionId, workflow, nodeName }); }, ], workflowExecuteBefore: [ async function (): Promise { - void internalHooks.onWorkflowBeforeExecute(this.executionId, this.workflowData); + const { executionId, workflowData } = this; + + eventRelay.emit('workflow-pre-execute', { executionId, data: workflowData }); }, ], workflowExecuteAfter: [ @@ -622,9 +634,17 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { eventsService.emit('workflowExecutionCompleted', this.workflowData, fullRunData); } }, - async function (this: WorkflowHooks, fullRunData: IRun): Promise { - // send tracking and event log events, but don't wait for them - void internalHooks.onWorkflowPostExecute(this.executionId, this.workflowData, fullRunData); + async function (this: WorkflowHooks, runData: IRun): Promise { + const { executionId, workflowData: workflow } = this; + + void internalHooks.onWorkflowPostExecute(executionId, workflow, runData); + eventRelay.emit('workflow-post-execute', { + workflowId: workflow.id, + workflowName: workflow.name, + executionId, + success: runData.status === 'success', + isManual: runData.mode === 'manual', + }); }, async function (this: WorkflowHooks, fullRunData: IRun) { const externalHooks = Container.get(ExternalHooks); @@ -765,6 +785,7 @@ async function executeWorkflow( const nodeTypes = Container.get(NodeTypes); const activeExecutions = Container.get(ActiveExecutions); + const eventRelay = Container.get(EventRelay); const workflowData = options.loadedWorkflowData ?? @@ -792,7 +813,7 @@ async function executeWorkflow( executionId = options.parentExecutionId ?? (await activeExecutions.add(runData)); } - void internalHooks.onWorkflowBeforeExecute(executionId || '', runData); + Container.get(EventRelay).emit('workflow-pre-execute', { executionId, data: runData }); let data; try { @@ -905,6 +926,14 @@ async function executeWorkflow( await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]); void internalHooks.onWorkflowPostExecute(executionId, workflowData, data, additionalData.userId); + eventRelay.emit('workflow-post-execute', { + workflowId: workflowData.id, + workflowName: workflowData.name, + executionId, + success: data.status === 'success', + isManual: data.mode === 'manual', + userId: additionalData.userId, + }); // subworkflow either finished, or is in status waiting due to a wait node, both cases are considered successes here if (data.finished === true || data.status === 'waiting') { diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 3ba06671c89b6..8430d95327d60 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -37,6 +37,7 @@ import { PermissionChecker } from '@/UserManagement/PermissionChecker'; import { InternalHooks } from '@/InternalHooks'; import { Logger } from '@/Logger'; import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; +import { EventRelay } from './eventbus/event-relay.service'; @Service() export class WorkflowRunner { @@ -52,6 +53,7 @@ export class WorkflowRunner { private readonly workflowStaticDataService: WorkflowStaticDataService, private readonly nodeTypes: NodeTypes, private readonly permissionChecker: PermissionChecker, + private readonly eventRelay: EventRelay, ) { if (this.executionsMode === 'queue') { this.jobQueue = Container.get(Queue); @@ -145,7 +147,7 @@ export class WorkflowRunner { await this.enqueueExecution(executionId, data, loadStaticData, realtime); } else { await this.runMainProcess(executionId, data, loadStaticData, executionId); - void Container.get(InternalHooks).onWorkflowBeforeExecute(executionId, data); + this.eventRelay.emit('workflow-pre-execute', { executionId, data }); } // only run these when not in queue mode or when the execution is manual, @@ -164,6 +166,14 @@ export class WorkflowRunner { executionData, data.userId, ); + this.eventRelay.emit('workflow-post-execute', { + workflowId: data.workflowData.id, + workflowName: data.workflowData.name, + executionId, + success: executionData?.status === 'success', + isManual: data.executionMode === 'manual', + userId: data.userId, + }); if (this.externalHooks.exists('workflow.postExecute')) { try { await this.externalHooks.run('workflow.postExecute', [ diff --git a/packages/cli/src/auth/methods/ldap.ts b/packages/cli/src/auth/methods/ldap.ts index 964fc9f485f56..d7a254889e8f3 100644 --- a/packages/cli/src/auth/methods/ldap.ts +++ b/packages/cli/src/auth/methods/ldap.ts @@ -12,6 +12,7 @@ import { updateLdapUserOnLocalDb, } from '@/Ldap/helpers'; import type { User } from '@db/entities/User'; +import { EventRelay } from '@/eventbus/event-relay.service'; export const handleLdapLogin = async ( loginId: string, @@ -54,6 +55,7 @@ export const handleLdapLogin = async ( user_type: 'ldap', was_disabled_ldap_user: false, }); + Container.get(EventRelay).emit('user-signed-up', { user }); return user; } } else { diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 39285b797e746..7cfa7a7f43236 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -24,6 +24,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ApplicationError } from 'n8n-workflow'; import { UserRepository } from '@/databases/repositories/user.repository'; +import { EventRelay } from '@/eventbus/event-relay.service'; @RestController() export class AuthController { @@ -35,6 +36,7 @@ export class AuthController { private readonly userService: UserService, private readonly license: License, private readonly userRepository: UserRepository, + private readonly eventRelay: EventRelay, private readonly postHog?: PostHogClient, ) {} @@ -90,16 +92,17 @@ export class AuthController { } this.authService.issueCookie(res, user, req.browserId); - void this.internalHooks.onUserLoginSuccess({ + + this.eventRelay.emit('user-logged-in', { user, authenticationMethod: usedAuthenticationMethod, }); return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); } - void this.internalHooks.onUserLoginFailed({ - user: email, + this.eventRelay.emit('user-login-failed', { authenticationMethod: usedAuthenticationMethod, + userEmail: email, reason: 'wrong credentials', }); throw new AuthError('Wrong username or password. Do you have caps lock on?'); @@ -177,6 +180,7 @@ export class AuthController { } void this.internalHooks.onUserInviteEmailClick({ inviter, invitee }); + this.eventRelay.emit('user-invite-email-click', { inviter, invitee }); const { firstName, lastName } = inviter; return { inviter: { firstName, lastName } }; diff --git a/packages/cli/src/controllers/communityPackages.controller.ts b/packages/cli/src/controllers/communityPackages.controller.ts index 681acd9d234cf..11914e5d7a5cd 100644 --- a/packages/cli/src/controllers/communityPackages.controller.ts +++ b/packages/cli/src/controllers/communityPackages.controller.ts @@ -14,6 +14,7 @@ import { Push } from '@/push'; import { CommunityPackagesService } from '@/services/communityPackages.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { EventRelay } from '@/eventbus/event-relay.service'; const { PACKAGE_NOT_INSTALLED, @@ -38,6 +39,7 @@ export class CommunityPackagesController { private readonly push: Push, private readonly internalHooks: InternalHooks, private readonly communityPackagesService: CommunityPackagesService, + private readonly eventRelay: EventRelay, ) {} // TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')` @@ -114,6 +116,14 @@ export class CommunityPackagesController { package_version: parsed.version, failure_reason: errorMessage, }); + this.eventRelay.emit('community-package-installed', { + user: req.user, + inputString: name, + packageName: parsed.packageName, + success: false, + packageVersion: parsed.version, + failureReason: errorMessage, + }); let message = [`Error loading package "${name}" `, errorMessage].join(':'); if (error instanceof Error && error.cause instanceof Error) { @@ -144,6 +154,16 @@ export class CommunityPackagesController { package_author: installedPackage.authorName, package_author_email: installedPackage.authorEmail, }); + this.eventRelay.emit('community-package-installed', { + user: req.user, + inputString: name, + packageName: parsed.packageName, + success: true, + packageVersion: parsed.version, + packageNodeNames: installedPackage.installedNodes.map((node) => node.name), + packageAuthor: installedPackage.authorName, + packageAuthorEmail: installedPackage.authorEmail, + }); return installedPackage; } @@ -233,6 +253,14 @@ export class CommunityPackagesController { package_author: installedPackage.authorName, package_author_email: installedPackage.authorEmail, }); + this.eventRelay.emit('community-package-deleted', { + user: req.user, + packageName: name, + packageVersion: installedPackage.installedVersion, + packageNodeNames: installedPackage.installedNodes.map((node) => node.name), + packageAuthor: installedPackage.authorName, + packageAuthorEmail: installedPackage.authorEmail, + }); } @Patch('/') @@ -281,6 +309,15 @@ export class CommunityPackagesController { package_author: newInstalledPackage.authorName, package_author_email: newInstalledPackage.authorEmail, }); + this.eventRelay.emit('community-package-updated', { + user: req.user, + packageName: name, + packageVersionCurrent: previouslyInstalledPackage.installedVersion, + packageVersionNew: newInstalledPackage.installedVersion, + packageNodeNames: newInstalledPackage.installedNodes.map((n) => n.name), + packageAuthor: newInstalledPackage.authorName, + packageAuthorEmail: newInstalledPackage.authorEmail, + }); return newInstalledPackage; } catch (error) { diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index 5e75db0250811..9f1013b29cd0f 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -18,6 +18,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalHooks } from '@/InternalHooks'; import { ExternalHooks } from '@/ExternalHooks'; +import { EventRelay } from '@/eventbus/event-relay.service'; @RestController('/invitations') export class InvitationController { @@ -31,6 +32,7 @@ export class InvitationController { private readonly passwordUtility: PasswordUtility, private readonly userRepository: UserRepository, private readonly postHog: PostHogClient, + private readonly eventRelay: EventRelay, ) {} /** @@ -170,6 +172,7 @@ export class InvitationController { user_type: 'email', was_disabled_ldap_user: false, }); + this.eventRelay.emit('user-signed-up', { user: updatedUser }); const publicInvitee = await this.userService.toPublic(invitee); diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 9afe583d56eaa..57eafe4a75f49 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -23,6 +23,7 @@ import { InternalHooks } from '@/InternalHooks'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UserRepository } from '@/databases/repositories/user.repository'; import { isApiEnabled } from '@/PublicApi'; +import { EventRelay } from '@/eventbus/event-relay.service'; export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => { if (isApiEnabled()) { @@ -42,6 +43,7 @@ export class MeController { private readonly userService: UserService, private readonly passwordUtility: PasswordUtility, private readonly userRepository: UserRepository, + private readonly eventRelay: EventRelay, ) {} /** @@ -96,11 +98,9 @@ export class MeController { this.authService.issueCookie(res, user, req.browserId); - const updatedKeys = Object.keys(payload); - void this.internalHooks.onUserUpdate({ - user, - fields_changed: updatedKeys, - }); + const fieldsChanged = Object.keys(payload); + void this.internalHooks.onUserUpdate({ user, fields_changed: fieldsChanged }); + this.eventRelay.emit('user-updated', { user, fieldsChanged }); const publicUser = await this.userService.toPublic(user); @@ -149,10 +149,8 @@ export class MeController { this.authService.issueCookie(res, updatedUser, req.browserId); - void this.internalHooks.onUserUpdate({ - user: updatedUser, - fields_changed: ['password'], - }); + void this.internalHooks.onUserUpdate({ user: updatedUser, fields_changed: ['password'] }); + this.eventRelay.emit('user-updated', { user: updatedUser, fieldsChanged: ['password'] }); await this.externalHooks.run('user.password.update', [updatedUser.email, updatedUser.password]); @@ -200,10 +198,8 @@ export class MeController { await this.userService.update(req.user.id, { apiKey }); - void this.internalHooks.onApiKeyCreated({ - user: req.user, - public_api: false, - }); + void this.internalHooks.onApiKeyCreated({ user: req.user, public_api: false }); + this.eventRelay.emit('api-key-created', { user: req.user }); return { apiKey }; } @@ -223,10 +219,8 @@ export class MeController { async deleteAPIKey(req: AuthenticatedRequest) { await this.userService.update(req.user.id, { apiKey: null }); - void this.internalHooks.onApiKeyDeleted({ - user: req.user, - public_api: false, - }); + void this.internalHooks.onApiKeyDeleted({ user: req.user, public_api: false }); + this.eventRelay.emit('api-key-deleted', { user: req.user }); return { success: true }; } diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index 03d19b4deca64..aa053d0f525b8 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -21,6 +21,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { UserRepository } from '@/databases/repositories/user.repository'; +import { EventRelay } from '@/eventbus/event-relay.service'; @RestController() export class PasswordResetController { @@ -36,6 +37,7 @@ export class PasswordResetController { private readonly license: License, private readonly passwordUtility: PasswordUtility, private readonly userRepository: UserRepository, + private readonly eventRelay: EventRelay, ) {} /** @@ -123,6 +125,7 @@ export class PasswordResetController { message_type: 'Reset password', public_api: false, }); + this.eventRelay.emit('email-failed', { user, messageType: 'Reset password' }); if (error instanceof Error) { throw new InternalServerError(`Please contact your administrator: ${error.message}`); } @@ -136,6 +139,7 @@ export class PasswordResetController { }); void this.internalHooks.onUserPasswordResetRequestClick({ user }); + this.eventRelay.emit('user-password-reset-request-click', { user }); } /** @@ -168,6 +172,7 @@ export class PasswordResetController { this.logger.info('Reset-password token resolved successfully', { userId: user.id }); void this.internalHooks.onUserPasswordResetEmailClick({ user }); + this.eventRelay.emit('user-password-reset-email-click', { user }); } /** @@ -210,10 +215,8 @@ export class PasswordResetController { this.authService.issueCookie(res, user, req.browserId); - void this.internalHooks.onUserUpdate({ - user, - fields_changed: ['password'], - }); + void this.internalHooks.onUserUpdate({ user, fields_changed: ['password'] }); + this.eventRelay.emit('user-updated', { user, fieldsChanged: ['password'] }); // if this user used to be an LDAP users const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); @@ -222,6 +225,7 @@ export class PasswordResetController { user_type: 'email', was_disabled_ldap_user: true, }); + this.eventRelay.emit('user-signed-up', { user }); } await this.externalHooks.run('user.password.update', [user.email, passwordHash]); diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index ec7df4ff9584b..76206330dbf82 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -28,6 +28,7 @@ import { Project } from '@/databases/entities/Project'; import { WorkflowService } from '@/workflows/workflow.service'; import { CredentialsService } from '@/credentials/credentials.service'; import { ProjectService } from '@/services/project.service'; +import { EventRelay } from '@/eventbus/event-relay.service'; @RestController('/users') export class UsersController { @@ -44,6 +45,7 @@ export class UsersController { private readonly workflowService: WorkflowService, private readonly credentialsService: CredentialsService, private readonly projectService: ProjectService, + private readonly eventRelay: EventRelay, ) {} static ERROR_MESSAGES = { @@ -256,6 +258,7 @@ export class UsersController { telemetryData, publicApi: false, }); + this.eventRelay.emit('user-deleted', { user: req.user }); await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index d75e6436b10cf..c5f6ba96c6888 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -29,6 +29,7 @@ import { In } from '@n8n/typeorm'; import { SharedCredentials } from '@/databases/entities/SharedCredentials'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import { z } from 'zod'; +import { EventRelay } from '@/eventbus/event-relay.service'; @RestController('/credentials') export class CredentialsController { @@ -42,6 +43,7 @@ export class CredentialsController { private readonly userManagementMailer: UserManagementMailer, private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly projectRelationRepository: ProjectRelationRepository, + private readonly eventRelay: EventRelay, ) {} @Get('/', { middlewares: listQueryMiddleware }) @@ -164,6 +166,12 @@ export class CredentialsController { credential_id: credential.id, public_api: false, }); + this.eventRelay.emit('credentials-created', { + user: req.user, + credentialName: newCredential.name, + credentialType: credential.type, + credentialId: credential.id, + }); const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); @@ -218,6 +226,12 @@ export class CredentialsController { credential_type: credential.type, credential_id: credential.id, }); + this.eventRelay.emit('credentials-updated', { + user: req.user, + credentialName: credential.name, + credentialType: credential.type, + credentialId: credential.id, + }); const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); @@ -253,6 +267,12 @@ export class CredentialsController { credential_type: credential.type, credential_id: credential.id, }); + this.eventRelay.emit('credentials-deleted', { + user: req.user, + credentialName: credential.name, + credentialType: credential.type, + credentialId: credential.id, + }); return true; } @@ -321,6 +341,15 @@ export class CredentialsController { user_ids_sharees_added: newShareeIds, sharees_removed: amountRemoved, }); + this.eventRelay.emit('credentials-shared', { + user: req.user, + credentialName: credential.name, + credentialType: credential.type, + credentialId: credential.id, + userIdSharer: req.user.id, + userIdsShareesRemoved: newShareeIds, + shareesRemoved: amountRemoved, + }); const projectsRelations = await this.projectRelationRepository.findBy({ projectId: In(newShareeIds), diff --git a/packages/cli/src/decorators/Redactable.ts b/packages/cli/src/decorators/Redactable.ts new file mode 100644 index 0000000000000..e5debeb7a1507 --- /dev/null +++ b/packages/cli/src/decorators/Redactable.ts @@ -0,0 +1,50 @@ +import { RedactableError } from '@/errors/redactable.error'; +import type { UserLike } from '@/eventbus/event.types'; + +function toRedactable(userLike: UserLike) { + return { + userId: userLike.id, + _email: userLike.email, + _firstName: userLike.firstName, + _lastName: userLike.lastName, + globalRole: userLike.role, + }; +} + +type FieldName = 'user' | 'inviter' | 'invitee'; + +/** + * Mark redactable properties in a `{ user: UserLike }` field in an `AuditEventRelay` + * method arg. These properties will be later redacted by the log streaming + * destination based on user prefs. Only for `n8n.audit.*` logs. + * + * Also transform `id` to `userId` and `role` to `globalRole`. + * + * @example + * + * { id: '123'; email: 'test@example.com', role: 'some-role' } -> + * { userId: '123'; _email: 'test@example.com', globalRole: 'some-role' } + */ +export const Redactable = + (fieldName: FieldName = 'user'): MethodDecorator => + (_target, _propertyName, propertyDescriptor: PropertyDescriptor) => { + const originalMethod = propertyDescriptor.value as Function; + + type MethodArgs = Array<{ [fieldName: string]: UserLike }>; + + propertyDescriptor.value = function (...args: MethodArgs) { + const index = args.findIndex((arg) => arg[fieldName] !== undefined); + + if (index === -1) throw new RedactableError(fieldName, args.toString()); + + const userLike = args[index]?.[fieldName]; + + // @ts-expect-error Transformation + if (userLike) args[index][fieldName] = toRedactable(userLike); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return originalMethod.apply(this, args); + }; + + return propertyDescriptor; + }; diff --git a/packages/cli/src/errors/redactable.error.ts b/packages/cli/src/errors/redactable.error.ts new file mode 100644 index 0000000000000..0f6697a0652d6 --- /dev/null +++ b/packages/cli/src/errors/redactable.error.ts @@ -0,0 +1,9 @@ +import { ApplicationError } from 'n8n-workflow'; + +export class RedactableError extends ApplicationError { + constructor(fieldName: string, args: string) { + super( + `Failed to find "${fieldName}" property in argument "${args.toString()}". Please set the decorator \`@Redactable()\` only on \`AuditEventRelay\` methods where the argument contains a "${fieldName}" property.`, + ); + } +} diff --git a/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts b/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts new file mode 100644 index 0000000000000..84408e56a711c --- /dev/null +++ b/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts @@ -0,0 +1,83 @@ +import { mock } from 'jest-mock-extended'; +import { AuditEventRelay } from '../audit-event-relay.service'; +import type { MessageEventBus } from '../MessageEventBus/MessageEventBus'; +import type { Event } from '../event.types'; +import type { EventRelay } from '../event-relay.service'; + +describe('AuditorService', () => { + const eventBus = mock(); + const eventRelay = mock(); + const auditor = new AuditEventRelay(eventRelay, eventBus); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should handle `user-deleted` event', () => { + const arg: Event['user-deleted'] = { + user: { + id: '123', + email: 'john@n8n.io', + firstName: 'John', + lastName: 'Doe', + role: 'some-role', + }, + }; + + // @ts-expect-error Private method + auditor.userDeleted(arg); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.deleted', + payload: { + userId: '123', + _email: 'john@n8n.io', + _firstName: 'John', + _lastName: 'Doe', + globalRole: 'some-role', + }, + }); + }); + + it('should handle `user-invite-email-click` event', () => { + const arg: Event['user-invite-email-click'] = { + inviter: { + id: '123', + email: 'john@n8n.io', + firstName: 'John', + lastName: 'Doe', + role: 'some-role', + }, + invitee: { + id: '456', + email: 'jane@n8n.io', + firstName: 'Jane', + lastName: 'Doe', + role: 'some-other-role', + }, + }; + + // @ts-expect-error Private method + auditor.userInviteEmailClick(arg); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.invitation.accepted', + payload: { + inviter: { + userId: '123', + _email: 'john@n8n.io', + _firstName: 'John', + _lastName: 'Doe', + globalRole: 'some-role', + }, + invitee: { + userId: '456', + _email: 'jane@n8n.io', + _firstName: 'Jane', + _lastName: 'Doe', + globalRole: 'some-other-role', + }, + }, + }); + }); +}); diff --git a/packages/cli/src/eventbus/audit-event-relay.service.ts b/packages/cli/src/eventbus/audit-event-relay.service.ts new file mode 100644 index 0000000000000..e4a9632a6cc3f --- /dev/null +++ b/packages/cli/src/eventbus/audit-event-relay.service.ts @@ -0,0 +1,340 @@ +import { Service } from 'typedi'; +import { MessageEventBus } from './MessageEventBus/MessageEventBus'; +import { Redactable } from '@/decorators/Redactable'; +import { EventRelay } from './event-relay.service'; +import type { Event } from './event.types'; +import type { IWorkflowBase } from 'n8n-workflow'; + +@Service() +export class AuditEventRelay { + constructor( + private readonly eventRelay: EventRelay, + private readonly eventBus: MessageEventBus, + ) { + this.setupHandlers(); + } + + private setupHandlers() { + this.eventRelay.on('workflow-created', (event) => this.workflowCreated(event)); + this.eventRelay.on('workflow-deleted', (event) => this.workflowDeleted(event)); + this.eventRelay.on('workflow-saved', (event) => this.workflowSaved(event)); + this.eventRelay.on('workflow-pre-execute', (event) => this.workflowPreExecute(event)); + this.eventRelay.on('workflow-post-execute', (event) => this.workflowPostExecute(event)); + this.eventRelay.on('node-pre-execute', (event) => this.nodePreExecute(event)); + this.eventRelay.on('node-post-execute', (event) => this.nodePostExecute(event)); + this.eventRelay.on('user-deleted', (event) => this.userDeleted(event)); + this.eventRelay.on('user-invited', (event) => this.userInvited(event)); + this.eventRelay.on('user-reinvited', (event) => this.userReinvited(event)); + this.eventRelay.on('user-updated', (event) => this.userUpdated(event)); + this.eventRelay.on('user-signed-up', (event) => this.userSignedUp(event)); + this.eventRelay.on('user-logged-in', (event) => this.userLoggedIn(event)); + this.eventRelay.on('user-login-failed', (event) => this.userLoginFailed(event)); + this.eventRelay.on('user-invite-email-click', (event) => this.userInviteEmailClick(event)); + this.eventRelay.on('user-password-reset-email-click', (event) => + this.userPasswordResetEmailClick(event), + ); + this.eventRelay.on('user-password-reset-request-click', (event) => + this.userPasswordResetRequestClick(event), + ); + this.eventRelay.on('api-key-created', (event) => this.apiKeyCreated(event)); + this.eventRelay.on('api-key-deleted', (event) => this.apiKeyDeleted(event)); + this.eventRelay.on('email-failed', (event) => this.emailFailed(event)); + this.eventRelay.on('credentials-created', (event) => this.credentialsCreated(event)); + this.eventRelay.on('credentials-deleted', (event) => this.credentialsDeleted(event)); + this.eventRelay.on('credentials-shared', (event) => this.credentialsShared(event)); + this.eventRelay.on('credentials-updated', (event) => this.credentialsUpdated(event)); + this.eventRelay.on('credentials-deleted', (event) => this.credentialsDeleted(event)); + this.eventRelay.on('community-package-installed', (event) => + this.communityPackageInstalled(event), + ); + this.eventRelay.on('community-package-updated', (event) => this.communityPackageUpdated(event)); + this.eventRelay.on('community-package-deleted', (event) => this.communityPackageDeleted(event)); + } + + /** + * Workflow + */ + + @Redactable() + private workflowCreated({ user, workflow }: Event['workflow-created']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.workflow.created', + payload: { + ...user, + workflowId: workflow.id, + workflowName: workflow.name, + }, + }); + } + + @Redactable() + private workflowDeleted({ user, workflowId }: Event['workflow-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.workflow.deleted', + payload: { ...user, workflowId }, + }); + } + + @Redactable() + private workflowSaved({ user, workflowId, workflowName }: Event['workflow-saved']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.workflow.updated', + payload: { + ...user, + workflowId, + workflowName, + }, + }); + } + + private workflowPreExecute({ data, executionId }: Event['workflow-pre-execute']) { + const payload = + 'executionData' in data + ? { + executionId, + userId: data.userId, + workflowId: data.workflowData.id, + isManual: data.executionMode === 'manual', + workflowName: data.workflowData.name, + } + : { + executionId, + userId: undefined, + workflowId: (data as IWorkflowBase).id, + isManual: false, + workflowName: (data as IWorkflowBase).name, + }; + + void this.eventBus.sendWorkflowEvent({ + eventName: 'n8n.workflow.started', + payload, + }); + } + + private workflowPostExecute(event: Event['workflow-post-execute']) { + void this.eventBus.sendWorkflowEvent({ + eventName: 'n8n.workflow.success', + payload: event, + }); + } + + /** + * Node + */ + + private nodePreExecute({ workflow, executionId, nodeName }: Event['node-pre-execute']) { + void this.eventBus.sendNodeEvent({ + eventName: 'n8n.node.started', + payload: { + workflowId: workflow.id, + workflowName: workflow.name, + executionId, + nodeType: workflow.nodes.find((n) => n.name === nodeName)?.type, + nodeName, + }, + }); + } + + private nodePostExecute({ workflow, executionId, nodeName }: Event['node-post-execute']) { + void this.eventBus.sendNodeEvent({ + eventName: 'n8n.node.finished', + payload: { + workflowId: workflow.id, + workflowName: workflow.name, + executionId, + nodeType: workflow.nodes.find((n) => n.name === nodeName)?.type, + nodeName, + }, + }); + } + + /** + * User + */ + + @Redactable() + private userDeleted({ user }: Event['user-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.deleted', + payload: user, + }); + } + + @Redactable() + private userInvited({ user, targetUserId }: Event['user-invited']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.invited', + payload: { ...user, targetUserId }, + }); + } + + @Redactable() + private userReinvited({ user, targetUserId }: Event['user-reinvited']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.reinvited', + payload: { ...user, targetUserId }, + }); + } + + @Redactable() + private userUpdated({ user, fieldsChanged }: Event['user-updated']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.updated', + payload: { ...user, fieldsChanged }, + }); + } + + /** + * Auth + */ + + @Redactable() + private userSignedUp({ user }: Event['user-signed-up']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.signedup', + payload: user, + }); + } + + @Redactable() + private userLoggedIn({ user, authenticationMethod }: Event['user-logged-in']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.login.success', + payload: { ...user, authenticationMethod }, + }); + } + + private userLoginFailed( + event: Event['user-login-failed'] /* exception: no `UserLike` to redact */, + ) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.login.failed', + payload: event, + }); + } + + /** + * Click + */ + + @Redactable('inviter') + @Redactable('invitee') + private userInviteEmailClick(event: Event['user-invite-email-click']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.invitation.accepted', + payload: event, + }); + } + + @Redactable() + private userPasswordResetEmailClick({ user }: Event['user-password-reset-email-click']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.reset', + payload: user, + }); + } + + @Redactable() + private userPasswordResetRequestClick({ user }: Event['user-password-reset-request-click']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.reset.requested', + payload: user, + }); + } + + /** + * API key + */ + + @Redactable() + private apiKeyCreated({ user }: Event['api-key-created']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.api.created', + payload: user, + }); + } + + @Redactable() + private apiKeyDeleted({ user }: Event['api-key-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.api.deleted', + payload: user, + }); + } + + /** + * Emailing + */ + + @Redactable() + private emailFailed({ user, messageType }: Event['email-failed']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.email.failed', + payload: { ...user, messageType }, + }); + } + + /** + * Credentials + */ + + @Redactable() + private credentialsCreated({ user, ...rest }: Event['credentials-created']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.credentials.created', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private credentialsDeleted({ user, ...rest }: Event['credentials-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.credentials.deleted', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private credentialsShared({ user, ...rest }: Event['credentials-shared']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.credentials.shared', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private credentialsUpdated({ user, ...rest }: Event['credentials-updated']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.credentials.updated', + payload: { ...user, ...rest }, + }); + } + + /** + * Community package + */ + + @Redactable() + private communityPackageInstalled({ user, ...rest }: Event['community-package-installed']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.package.installed', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private communityPackageUpdated({ user, ...rest }: Event['community-package-updated']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.package.updated', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private communityPackageDeleted({ user, ...rest }: Event['community-package-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.package.deleted', + payload: { ...user, ...rest }, + }); + } +} diff --git a/packages/cli/src/eventbus/event-relay.service.ts b/packages/cli/src/eventbus/event-relay.service.ts new file mode 100644 index 0000000000000..8f6bb4c5c1420 --- /dev/null +++ b/packages/cli/src/eventbus/event-relay.service.ts @@ -0,0 +1,16 @@ +import { EventEmitter } from 'node:events'; +import { Service } from 'typedi'; +import type { Event } from './event.types'; + +@Service() +export class EventRelay extends EventEmitter { + emit(eventName: K, arg: Event[K]) { + super.emit(eventName, arg); + return true; + } + + on(eventName: K, handler: (arg: Event[K]) => void) { + super.on(eventName, handler); + return this; + } +} diff --git a/packages/cli/src/eventbus/event.types.ts b/packages/cli/src/eventbus/event.types.ts new file mode 100644 index 0000000000000..b4162f0333736 --- /dev/null +++ b/packages/cli/src/eventbus/event.types.ts @@ -0,0 +1,185 @@ +import type { AuthenticationMethod, IWorkflowBase } from 'n8n-workflow'; +import type { IWorkflowExecutionDataProcess } from '@/Interfaces'; + +export type UserLike = { + id: string; + email?: string; + firstName?: string; + lastName?: string; + role: string; +}; + +/** + * Events sent by services and consumed by relays, e.g. `AuditEventRelay`. + */ +export type Event = { + 'workflow-created': { + user: UserLike; + workflow: IWorkflowBase; + }; + + 'workflow-deleted': { + user: UserLike; + workflowId: string; + }; + + 'workflow-saved': { + user: UserLike; + workflowId: string; + workflowName: string; + }; + + 'workflow-pre-execute': { + executionId: string; + data: IWorkflowExecutionDataProcess /* main process */ | IWorkflowBase /* worker */; + }; + + 'workflow-post-execute': { + executionId: string; + success: boolean; + userId?: string; + workflowId: string; + isManual: boolean; + workflowName: string; + metadata?: Record; + }; + + 'node-pre-execute': { + executionId: string; + workflow: IWorkflowBase; + nodeName: string; + }; + + 'node-post-execute': { + executionId: string; + workflow: IWorkflowBase; + nodeName: string; + }; + + 'user-deleted': { + user: UserLike; + }; + + 'user-invited': { + user: UserLike; + targetUserId: string[]; + }; + + 'user-reinvited': { + user: UserLike; + targetUserId: string[]; + }; + + 'user-updated': { + user: UserLike; + fieldsChanged: string[]; + }; + + 'user-signed-up': { + user: UserLike; + }; + + 'user-logged-in': { + user: UserLike; + authenticationMethod: AuthenticationMethod; + }; + + 'user-login-failed': { + userEmail: string; + authenticationMethod: AuthenticationMethod; + reason?: string; + }; + + 'user-invite-email-click': { + inviter: UserLike; + invitee: UserLike; + }; + + 'user-password-reset-email-click': { + user: UserLike; + }; + + 'user-password-reset-request-click': { + user: UserLike; + }; + + 'api-key-created': { + user: UserLike; + }; + + 'api-key-deleted': { + user: UserLike; + }; + + 'email-failed': { + user: UserLike; + messageType: + | 'Reset password' + | 'New user invite' + | 'Resend invite' + | 'Workflow shared' + | 'Credentials shared'; + }; + + 'credentials-created': { + user: UserLike; + credentialName: string; + credentialType: string; + credentialId: string; + }; + + 'credentials-shared': { + user: UserLike; + credentialName: string; + credentialType: string; + credentialId: string; + userIdSharer: string; + userIdsShareesRemoved: string[]; + shareesRemoved: number | null; + }; + + 'credentials-updated': { + user: UserLike; + credentialName: string; + credentialType: string; + credentialId: string; + }; + + 'credentials-deleted': { + user: UserLike; + credentialName: string; + credentialType: string; + credentialId: string; + }; + + 'community-package-installed': { + user: UserLike; + inputString: string; + packageName: string; + success: boolean; + packageVersion?: string; + packageNodeNames?: string[]; + packageAuthor?: string; + packageAuthorEmail?: string; + failureReason?: string; + }; + + 'community-package-updated': { + user: UserLike; + packageName: string; + packageVersionCurrent: string; + packageVersionNew: string; + packageNodeNames: string[]; + packageAuthor?: string; + packageAuthorEmail?: string; + }; + + 'community-package-deleted': { + user: UserLike; + packageName: string; + packageVersion: string; + packageNodeNames: string[]; + packageAuthor?: string; + packageAuthorEmail?: string; + }; +}; diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts index b20f74edbe3a9..fb8bdd2e929f7 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts @@ -20,6 +20,8 @@ import { NodeCrashedError } from '@/errors/node-crashed.error'; import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; import { EventMessageNode } from '@/eventbus/EventMessageClasses/EventMessageNode'; import { EventMessageWorkflow } from '@/eventbus/EventMessageClasses/EventMessageWorkflow'; + +import type { EventRelay } from '@/eventbus/event-relay.service'; import type { EventMessageTypes as EventMessage } from '@/eventbus/EventMessageClasses'; import type { Logger } from '@/Logger'; @@ -191,6 +193,7 @@ describe('ExecutionRecoveryService', () => { push, executionRepository, orchestrationService, + mock(), ); }); diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 26441bd4e1eba..5abfe2c2e0509 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -16,6 +16,7 @@ import config from '@/config'; import { OnShutdown } from '@/decorators/OnShutdown'; import type { QueueRecoverySettings } from './execution.types'; import { OrchestrationService } from '@/services/orchestration.service'; +import { EventRelay } from '@/eventbus/event-relay.service'; /** * Service for recovering key properties in executions. @@ -27,6 +28,7 @@ export class ExecutionRecoveryService { private readonly push: Push, private readonly executionRepository: ExecutionRepository, private readonly orchestrationService: OrchestrationService, + private readonly eventRelay: EventRelay, ) {} /** @@ -284,6 +286,14 @@ export class ExecutionRecoveryService { status: execution.status, }); + this.eventRelay.emit('workflow-post-execute', { + workflowId: execution.workflowData.id, + workflowName: execution.workflowData.name, + executionId: execution.id, + success: execution.status === 'success', + isManual: execution.mode === 'manual', + }); + const externalHooks = getWorkflowHooksMain( { userId: '', diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index e9681d68da818..18b45d8b6e4df 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -12,6 +12,7 @@ import { InternalHooks } from '@/InternalHooks'; import { UrlService } from '@/services/url.service'; import type { UserRequest } from '@/requests'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { EventRelay } from '@/eventbus/event-relay.service'; @Service() export class UserService { @@ -20,6 +21,7 @@ export class UserService { private readonly userRepository: UserRepository, private readonly mailer: UserManagementMailer, private readonly urlService: UrlService, + private readonly eventRelay: EventRelay, ) {} async update(userId: string, data: Partial) { @@ -156,6 +158,10 @@ export class UserService { email_sent: result.emailSent, invitee_role: role, // same role for all invited users }); + this.eventRelay.emit('user-invited', { + user: owner, + targetUserId: Object.values(toInviteUsers), + }); } catch (e) { if (e instanceof Error) { void Container.get(InternalHooks).onEmailFailed({ @@ -163,6 +169,7 @@ export class UserService { message_type: 'New user invite', public_api: false, }); + this.eventRelay.emit('email-failed', { user: owner, messageType: 'New user invite' }); this.logger.error('Failed to send email', { userId: owner.id, inviteAcceptUrl, diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 38de44c230b70..f08189ffafb74 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -6,7 +6,6 @@ import url from 'url'; import { Get, Post, RestController, GlobalScope } from '@/decorators'; import { AuthService } from '@/auth/auth.service'; import { AuthenticatedRequest } from '@/requests'; -import { InternalHooks } from '@/InternalHooks'; import querystring from 'querystring'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { AuthError } from '@/errors/response-errors/auth.error'; @@ -28,6 +27,7 @@ import { import { SamlService } from '../saml.service.ee'; import { SamlConfiguration } from '../types/requests'; import { getInitSSOFormView } from '../views/initSsoPost'; +import { EventRelay } from '@/eventbus/event-relay.service'; @RestController('/sso/saml') export class SamlController { @@ -35,7 +35,7 @@ export class SamlController { private readonly authService: AuthService, private readonly samlService: SamlService, private readonly urlService: UrlService, - private readonly internalHooks: InternalHooks, + private readonly eventRelay: EventRelay, ) {} @Get('/metadata', { skipAuth: true }) @@ -126,10 +126,11 @@ export class SamlController { } } if (loginResult.authenticatedUser) { - void this.internalHooks.onUserLoginSuccess({ + this.eventRelay.emit('user-logged-in', { user: loginResult.authenticatedUser, authenticationMethod: 'saml', }); + // Only sign in user if SAML is enabled, otherwise treat as test connection if (isSamlLicensedAndEnabled()) { this.authService.issueCookie(res, loginResult.authenticatedUser, req.browserId); @@ -143,8 +144,8 @@ export class SamlController { return res.status(202).send(loginResult.attributes); } } - void this.internalHooks.onUserLoginFailed({ - user: loginResult.attributes.email ?? 'unknown', + this.eventRelay.emit('user-login-failed', { + userEmail: loginResult.attributes.email ?? 'unknown', authenticationMethod: 'saml', }); throw new AuthError('SAML Authentication failed'); @@ -152,8 +153,8 @@ export class SamlController { if (isConnectionTestRequest(req)) { return res.send(getSamlConnectionTestFailedView((error as Error).message)); } - void this.internalHooks.onUserLoginFailed({ - user: 'unknown', + this.eventRelay.emit('user-login-failed', { + userEmail: 'unknown', authenticationMethod: 'saml', }); throw new AuthError('SAML Authentication failed: ' + (error as Error).message); diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index d03fd65646378..d6ceb070da2de 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -32,6 +32,7 @@ import type { Scope } from '@n8n/permissions'; import type { EntityManager } from '@n8n/typeorm'; import { In } from '@n8n/typeorm'; import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; +import { EventRelay } from '@/eventbus/event-relay.service'; @Service() export class WorkflowService { @@ -51,6 +52,7 @@ export class WorkflowService { private readonly workflowSharingService: WorkflowSharingService, private readonly projectService: ProjectService, private readonly executionRepository: ExecutionRepository, + private readonly eventRelay: EventRelay, ) {} async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) { @@ -216,6 +218,11 @@ export class WorkflowService { await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); void Container.get(InternalHooks).onWorkflowSaved(user, updatedWorkflow, false); + this.eventRelay.emit('workflow-saved', { + user, + workflowId: updatedWorkflow.id, + workflowName: updatedWorkflow.name, + }); if (updatedWorkflow.active) { // When the workflow is supposed to be active add it again @@ -274,6 +281,7 @@ export class WorkflowService { await this.binaryDataService.deleteMany(idsForDeletion); void Container.get(InternalHooks).onWorkflowDeleted(user, workflowId, false); + this.eventRelay.emit('workflow-deleted', { user, workflowId }); await this.externalHooks.run('workflow.afterDelete', [workflowId]); return workflow; diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index a33194a31e545..1d551ea03c575 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -41,6 +41,7 @@ import { In, type FindOptionsRelations } from '@n8n/typeorm'; import type { Project } from '@/databases/entities/Project'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import { z } from 'zod'; +import { EventRelay } from '@/eventbus/event-relay.service'; @RestController('/workflows') export class WorkflowsController { @@ -64,6 +65,7 @@ export class WorkflowsController { private readonly projectRepository: ProjectRepository, private readonly projectService: ProjectService, private readonly projectRelationRepository: ProjectRelationRepository, + private readonly eventRelay: EventRelay, ) {} @Post('/') @@ -175,6 +177,7 @@ export class WorkflowsController { await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); void this.internalHooks.onWorkflowCreated(req.user, newWorkflow, project!, false); + this.eventRelay.emit('workflow-created', { user: req.user, workflow: newWorkflow }); const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id); diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index b9dbe7cb0fa90..23dc49607f709 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -1,12 +1,6 @@ -import { Container } from 'typedi'; -import type { AuthenticationMethod } from 'n8n-workflow'; - import type { User } from '@db/entities/User'; import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers'; import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; -import { InternalHooks } from '@/InternalHooks'; -import { SamlService } from '@/sso/saml/saml.service.ee'; -import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes'; import { randomEmail, randomName, randomValidPassword } from '../shared/random'; import * as utils from '../shared/utils/'; @@ -266,89 +260,3 @@ describe('Check endpoint permissions', () => { }); }); }); - -describe('SAML login flow', () => { - beforeEach(async () => { - await enableSaml(true); - }); - - test('should trigger onUserLoginSuccess hook', async () => { - const mockedHandleSamlLogin = jest.spyOn(Container.get(SamlService), 'handleSamlLogin'); - - mockedHandleSamlLogin.mockImplementation( - async (): Promise<{ - authenticatedUser: User; - attributes: SamlUserAttributes; - onboardingRequired: false; - }> => { - return { - authenticatedUser: someUser, - attributes: { - email: someUser.email, - firstName: someUser.firstName, - lastName: someUser.lastName, - userPrincipalName: someUser.email, - }, - onboardingRequired: false, - }; - }, - ); - - const mockedHookOnUserLoginSuccess = jest.spyOn( - Container.get(InternalHooks), - 'onUserLoginSuccess', - ); - mockedHookOnUserLoginSuccess.mockImplementation( - async (userLoginData: { user: User; authenticationMethod: AuthenticationMethod }) => { - expect(userLoginData.authenticationMethod).toEqual('saml'); - return; - }, - ); - await authOwnerAgent.post('/sso/saml/acs').expect(302); - expect(mockedHookOnUserLoginSuccess).toBeCalled(); - mockedHookOnUserLoginSuccess.mockRestore(); - mockedHandleSamlLogin.mockRestore(); - }); - - test('should trigger onUserLoginFailed hook', async () => { - const mockedHandleSamlLogin = jest.spyOn(Container.get(SamlService), 'handleSamlLogin'); - - mockedHandleSamlLogin.mockImplementation( - async (): Promise<{ - authenticatedUser: User | undefined; - attributes: SamlUserAttributes; - onboardingRequired: false; - }> => { - return { - authenticatedUser: undefined, - attributes: { - email: someUser.email, - firstName: someUser.firstName, - lastName: someUser.lastName, - userPrincipalName: someUser.email, - }, - onboardingRequired: false, - }; - }, - ); - - const mockedHookOnUserLoginFailed = jest.spyOn( - Container.get(InternalHooks), - 'onUserLoginFailed', - ); - mockedHookOnUserLoginFailed.mockImplementation( - async (userLoginData: { - user: string; - authenticationMethod: AuthenticationMethod; - reason?: string; - }) => { - expect(userLoginData.authenticationMethod).toEqual('saml'); - return; - }, - ); - await authOwnerAgent.post('/sso/saml/acs').expect(401); - expect(mockedHookOnUserLoginFailed).toBeCalled(); - mockedHookOnUserLoginFailed.mockRestore(); - mockedHandleSamlLogin.mockRestore(); - }); -}); diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 8c9e35983e47b..b09cf30b0d1dd 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -38,6 +38,7 @@ beforeAll(async () => { mock(), mock(), mock(), + mock(), ); }); diff --git a/packages/cli/test/unit/InternalHooks.test.ts b/packages/cli/test/unit/InternalHooks.test.ts index 6dbb4ab5f2def..f56b45d1b8b34 100644 --- a/packages/cli/test/unit/InternalHooks.test.ts +++ b/packages/cli/test/unit/InternalHooks.test.ts @@ -23,7 +23,6 @@ describe('InternalHooks', () => { mock(), mock(), mock(), - mock(), license, mock(), mock(),