refactor(core): Decouple event bus from internal hooks (no-changelog) (#9724)

This commit is contained in:
Iván Ovejero
2024-06-20 12:32:22 +02:00
committed by GitHub
parent e4463c62b4
commit 199dff4fb3
29 changed files with 1028 additions and 664 deletions

View File

@@ -3,13 +3,11 @@ import { snakeCase } from 'change-case';
import os from 'node:os'; import os from 'node:os';
import { get as pslGet } from 'psl'; import { get as pslGet } from 'psl';
import type { import type {
AuthenticationMethod,
ExecutionStatus, ExecutionStatus,
INodesGraphResult, INodesGraphResult,
IRun, IRun,
ITelemetryTrackProperties, ITelemetryTrackProperties,
IWorkflowBase, IWorkflowBase,
WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { TelemetryHelpers } from 'n8n-workflow'; import { TelemetryHelpers } from 'n8n-workflow';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
@@ -18,17 +16,13 @@ import config from '@/config';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import type { AuthProviderType } from '@db/entities/AuthIdentity'; import type { AuthProviderType } from '@db/entities/AuthIdentity';
import type { GlobalRole, User } from '@db/entities/User'; import type { GlobalRole, User } from '@db/entities/User';
import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { WorkflowRepository } from '@db/repositories/workflow.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 { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions';
import type { import type {
ITelemetryUserDeletionData, ITelemetryUserDeletionData,
IWorkflowDb, IWorkflowDb,
IExecutionTrackProperties, IExecutionTrackProperties,
IWorkflowExecutionDataProcess,
} from '@/Interfaces'; } from '@/Interfaces';
import { License } from '@/License'; import { License } from '@/License';
import { EventsService } from '@/services/events.service'; 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 type { ProjectRole } from '@db/entities/ProjectRelation';
import { ProjectRelationRepository } from './databases/repositories/projectRelation.repository'; import { ProjectRelationRepository } from './databases/repositories/projectRelation.repository';
import { SharedCredentialsRepository } from './databases/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from './databases/repositories/sharedCredentials.repository';
import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus';
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,
};
}
@Service() @Service()
export class InternalHooks { export class InternalHooks {
@@ -64,10 +43,10 @@ export class InternalHooks {
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
eventsService: EventsService, eventsService: EventsService,
private readonly instanceSettings: InstanceSettings, private readonly instanceSettings: InstanceSettings,
private readonly eventBus: MessageEventBus,
private readonly license: License, private readonly license: License,
private readonly projectRelationRepository: ProjectRelationRepository, private readonly projectRelationRepository: ProjectRelationRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly _eventBus: MessageEventBus, // needed until we decouple telemetry
) { ) {
eventsService.on( eventsService.on(
'telemetry.onFirstProductionWorkflowSuccess', 'telemetry.onFirstProductionWorkflowSuccess',
@@ -177,41 +156,23 @@ export class InternalHooks {
publicApi: boolean, publicApi: boolean,
): Promise<void> { ): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
void Promise.all([
this.eventBus.sendAuditEvent({ void this.telemetry.track('User created workflow', {
eventName: 'n8n.audit.workflow.created', user_id: user.id,
payload: { workflow_id: workflow.id,
...userToPayload(user), node_graph_string: JSON.stringify(nodeGraph),
workflowId: workflow.id, public_api: publicApi,
workflowName: workflow.name, project_id: project.id,
}, project_type: project.type,
}), });
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> { async onWorkflowDeleted(user: User, workflowId: string, publicApi: boolean): Promise<void> {
void Promise.all([ void this.telemetry.track('User deleted workflow', {
this.eventBus.sendAuditEvent({ user_id: user.id,
eventName: 'n8n.audit.workflow.deleted', workflow_id: workflowId,
payload: { public_api: publicApi,
...userToPayload(user), });
workflowId,
},
}),
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<void> { async onWorkflowSaved(user: User, workflow: IWorkflowDb, publicApi: boolean): Promise<void> {
@@ -247,127 +208,22 @@ export class InternalHooks {
(note) => note.overlapping, (note) => note.overlapping,
).length; ).length;
void Promise.all([ void this.telemetry.track('User saved workflow', {
this.eventBus.sendAuditEvent({ user_id: user.id,
eventName: 'n8n.audit.workflow.updated', workflow_id: workflow.id,
payload: { node_graph_string: JSON.stringify(nodeGraph),
...userToPayload(user), notes_count_overlapping: overlappingCount,
workflowId: workflow.id, notes_count_non_overlapping: notesCount - overlappingCount,
workflowName: workflow.name, version_cli: N8N_VERSION,
}, num_tags: workflow.tags?.length ?? 0,
}), public_api: publicApi,
this.telemetry.track('User saved workflow', { sharing_role: userRole,
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<void> {
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<void> {
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<void> {
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,
});
}
async onWorkflowCrashed(
executionId: string,
executionMode: WorkflowExecuteMode,
workflowData?: IWorkflowBase,
executionMetadata?: ExecutionMetadata[],
): Promise<void> {
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 // eslint-disable-next-line complexity
async onWorkflowPostExecute( async onWorkflowPostExecute(
executionId: string, _executionId: string,
workflow: IWorkflowBase, workflow: IWorkflowBase,
runData?: IRun, runData?: IRun,
userId?: string, 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)]); void Promise.all([...promises, this.telemetry.trackWorkflowExecution(telemetryProperties)]);
} }
@@ -563,19 +389,11 @@ export class InternalHooks {
telemetryData: ITelemetryUserDeletionData; telemetryData: ITelemetryUserDeletionData;
publicApi: boolean; publicApi: boolean;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('User deleted user', {
this.eventBus.sendAuditEvent({ ...userDeletionData.telemetryData,
eventName: 'n8n.audit.user.deleted', user_id: userDeletionData.user.id,
payload: { public_api: userDeletionData.publicApi,
...userToPayload(userDeletionData.user), });
},
}),
this.telemetry.track('User deleted user', {
...userDeletionData.telemetryData,
user_id: userDeletionData.user.id,
public_api: userDeletionData.publicApi,
}),
]);
} }
async onUserInvite(userInviteData: { async onUserInvite(userInviteData: {
@@ -585,23 +403,13 @@ export class InternalHooks {
email_sent: boolean; email_sent: boolean;
invitee_role: string; invitee_role: string;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('User invited new user', {
this.eventBus.sendAuditEvent({ user_id: userInviteData.user.id,
eventName: 'n8n.audit.user.invited', target_user_id: userInviteData.target_user_id,
payload: { public_api: userInviteData.public_api,
...userToPayload(userInviteData.user), email_sent: userInviteData.email_sent,
targetUserId: userInviteData.target_user_id, invitee_role: userInviteData.invitee_role,
}, });
}),
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: { async onUserRoleChange(userRoleChangeData: {
@@ -615,27 +423,6 @@ export class InternalHooks {
void this.telemetry.track('User changed role', { user_id: user.id, ...rest }); 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> {
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: { async onUserRetrievedUser(userRetrievedData: {
user_id: string; user_id: string;
public_api: boolean; public_api: boolean;
@@ -679,55 +466,25 @@ export class InternalHooks {
} }
async onUserUpdate(userUpdateData: { user: User; fields_changed: string[] }): Promise<void> { async onUserUpdate(userUpdateData: { user: User; fields_changed: string[] }): Promise<void> {
void Promise.all([ void this.telemetry.track('User changed personal settings', {
this.eventBus.sendAuditEvent({ user_id: userUpdateData.user.id,
eventName: 'n8n.audit.user.updated', fields_changed: userUpdateData.fields_changed,
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,
}),
]);
} }
async onUserInviteEmailClick(userInviteClickData: { async onUserInviteEmailClick(userInviteClickData: {
inviter: User; inviter: User;
invitee: User; invitee: User;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('User clicked invite link from email', {
this.eventBus.sendAuditEvent({ user_id: userInviteClickData.invitee.id,
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,
}),
]);
} }
async onUserPasswordResetEmailClick(userPasswordResetData: { user: User }): Promise<void> { async onUserPasswordResetEmailClick(userPasswordResetData: { user: User }): Promise<void> {
void Promise.all([ void this.telemetry.track('User clicked password reset link from email', {
this.eventBus.sendAuditEvent({ user_id: userPasswordResetData.user.id,
eventName: 'n8n.audit.user.reset', });
payload: {
...userToPayload(userPasswordResetData.user),
},
}),
this.telemetry.track('User clicked password reset link from email', {
user_id: userPasswordResetData.user.id,
}),
]);
} }
async onUserTransactionalEmail(userTransactionalEmailData: { async onUserTransactionalEmail(userTransactionalEmailData: {
@@ -756,47 +513,23 @@ export class InternalHooks {
} }
async onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise<void> { async onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise<void> {
void Promise.all([ void this.telemetry.track('API key deleted', {
this.eventBus.sendAuditEvent({ user_id: apiKeyDeletedData.user.id,
eventName: 'n8n.audit.user.api.deleted', public_api: apiKeyDeletedData.public_api,
payload: { });
...userToPayload(apiKeyDeletedData.user),
},
}),
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> { async onApiKeyCreated(apiKeyCreatedData: { user: User; public_api: boolean }): Promise<void> {
void Promise.all([ void this.telemetry.track('API key created', {
this.eventBus.sendAuditEvent({ user_id: apiKeyCreatedData.user.id,
eventName: 'n8n.audit.user.api.created', public_api: apiKeyCreatedData.public_api,
payload: { });
...userToPayload(apiKeyCreatedData.user),
},
}),
this.telemetry.track('API key created', {
user_id: apiKeyCreatedData.user.id,
public_api: apiKeyCreatedData.public_api,
}),
]);
} }
async onUserPasswordResetRequestClick(userPasswordResetData: { user: User }): Promise<void> { async onUserPasswordResetRequestClick(userPasswordResetData: { user: User }): Promise<void> {
void Promise.all([ void this.telemetry.track('User requested password reset while logged out', {
this.eventBus.sendAuditEvent({ user_id: userPasswordResetData.user.id,
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,
}),
]);
} }
async onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }): Promise<void> { async onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }): Promise<void> {
@@ -810,18 +543,10 @@ export class InternalHooks {
was_disabled_ldap_user: boolean; was_disabled_ldap_user: boolean;
}, },
): Promise<void> { ): Promise<void> {
void Promise.all([ void this.telemetry.track('User signed up', {
this.eventBus.sendAuditEvent({ user_id: user.id,
eventName: 'n8n.audit.user.signedup', ...userSignupData,
payload: { });
...userToPayload(user),
},
}),
this.telemetry.track('User signed up', {
user_id: user.id,
...userSignupData,
}),
]);
} }
async onEmailFailed(failedEmailData: { async onEmailFailed(failedEmailData: {
@@ -834,50 +559,9 @@ export class InternalHooks {
| 'Credentials shared'; | 'Credentials shared';
public_api: boolean; public_api: boolean;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('Instance failed to send transactional email to user', {
this.eventBus.sendAuditEvent({ user_id: failedEmailData.user.id,
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> {
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> {
void Promise.all([
this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.user.login.failed',
payload: {
authenticationMethod: userLoginData.authenticationMethod,
user: userLoginData.user,
reason: userLoginData.reason,
},
}),
]);
} }
/** /**
@@ -894,25 +578,14 @@ export class InternalHooks {
const project = await this.sharedCredentialsRepository.findCredentialOwningProject( const project = await this.sharedCredentialsRepository.findCredentialOwningProject(
userCreatedCredentialsData.credential_id, userCreatedCredentialsData.credential_id,
); );
void Promise.all([ void this.telemetry.track('User created credentials', {
this.eventBus.sendAuditEvent({ user_id: userCreatedCredentialsData.user.id,
eventName: 'n8n.audit.user.credentials.created', credential_type: userCreatedCredentialsData.credential_type,
payload: { credential_id: userCreatedCredentialsData.credential_id,
...userToPayload(userCreatedCredentialsData.user), instance_id: this.instanceSettings.instanceId,
credentialName: userCreatedCredentialsData.credential_name, project_id: project?.id,
credentialType: userCreatedCredentialsData.credential_type, project_type: project?.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,
}),
]);
} }
async onUserSharedCredentials(userSharedCredentialsData: { async onUserSharedCredentials(userSharedCredentialsData: {
@@ -924,29 +597,15 @@ export class InternalHooks {
user_ids_sharees_added: string[]; user_ids_sharees_added: string[];
sharees_removed: number | null; sharees_removed: number | null;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('User updated cred sharing', {
this.eventBus.sendAuditEvent({ user_id: userSharedCredentialsData.user.id,
eventName: 'n8n.audit.user.credentials.shared', credential_type: userSharedCredentialsData.credential_type,
payload: { credential_id: userSharedCredentialsData.credential_id,
...userToPayload(userSharedCredentialsData.user), user_id_sharer: userSharedCredentialsData.user_id_sharer,
credentialName: userSharedCredentialsData.credential_name, user_ids_sharees_added: userSharedCredentialsData.user_ids_sharees_added,
credentialType: userSharedCredentialsData.credential_type, sharees_removed: userSharedCredentialsData.sharees_removed,
credentialId: userSharedCredentialsData.credential_id, instance_id: this.instanceSettings.instanceId,
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,
}),
]);
} }
async onUserUpdatedCredentials(userUpdatedCredentialsData: { async onUserUpdatedCredentials(userUpdatedCredentialsData: {
@@ -955,22 +614,11 @@ export class InternalHooks {
credential_type: string; credential_type: string;
credential_id: string; credential_id: string;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('User updated credentials', {
this.eventBus.sendAuditEvent({ user_id: userUpdatedCredentialsData.user.id,
eventName: 'n8n.audit.user.credentials.updated', credential_type: userUpdatedCredentialsData.credential_type,
payload: { credential_id: userUpdatedCredentialsData.credential_id,
...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,
}),
]);
} }
async onUserDeletedCredentials(userUpdatedCredentialsData: { async onUserDeletedCredentials(userUpdatedCredentialsData: {
@@ -979,23 +627,12 @@ export class InternalHooks {
credential_type: string; credential_type: string;
credential_id: string; credential_id: string;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('User deleted credentials', {
this.eventBus.sendAuditEvent({ user_id: userUpdatedCredentialsData.user.id,
eventName: 'n8n.audit.user.credentials.deleted', credential_type: userUpdatedCredentialsData.credential_type,
payload: { credential_id: userUpdatedCredentialsData.credential_id,
...userToPayload(userUpdatedCredentialsData.user), instance_id: this.instanceSettings.instanceId,
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,
}),
]);
} }
/** /**
@@ -1013,33 +650,17 @@ export class InternalHooks {
package_author_email?: string; package_author_email?: string;
failure_reason?: string; failure_reason?: string;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('cnr package install finished', {
this.eventBus.sendAuditEvent({ user_id: installationData.user.id,
eventName: 'n8n.audit.package.installed', input_string: installationData.input_string,
payload: { package_name: installationData.package_name,
...userToPayload(installationData.user), success: installationData.success,
inputString: installationData.input_string, package_version: installationData.package_version,
packageName: installationData.package_name, package_node_names: installationData.package_node_names,
success: installationData.success, package_author: installationData.package_author,
packageVersion: installationData.package_version, package_author_email: installationData.package_author_email,
packageNodeNames: installationData.package_node_names, failure_reason: installationData.failure_reason,
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,
}),
]);
} }
async onCommunityPackageUpdateFinished(updateData: { async onCommunityPackageUpdateFinished(updateData: {
@@ -1051,29 +672,15 @@ export class InternalHooks {
package_author?: string; package_author?: string;
package_author_email?: string; package_author_email?: string;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('cnr package updated', {
this.eventBus.sendAuditEvent({ user_id: updateData.user.id,
eventName: 'n8n.audit.package.updated', package_name: updateData.package_name,
payload: { package_version_current: updateData.package_version_current,
...userToPayload(updateData.user), package_version_new: updateData.package_version_new,
packageName: updateData.package_name, package_node_names: updateData.package_node_names,
packageVersionCurrent: updateData.package_version_current, package_author: updateData.package_author,
packageVersionNew: updateData.package_version_new, package_author_email: updateData.package_author_email,
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,
}),
]);
} }
async onCommunityPackageDeleteFinished(deleteData: { async onCommunityPackageDeleteFinished(deleteData: {
@@ -1084,27 +691,14 @@ export class InternalHooks {
package_author?: string; package_author?: string;
package_author_email?: string; package_author_email?: string;
}): Promise<void> { }): Promise<void> {
void Promise.all([ void this.telemetry.track('cnr package deleted', {
this.eventBus.sendAuditEvent({ user_id: deleteData.user.id,
eventName: 'n8n.audit.package.deleted', package_name: deleteData.package_name,
payload: { package_version: deleteData.package_version,
...userToPayload(deleteData.user), package_node_names: deleteData.package_node_names,
packageName: deleteData.package_name, package_author: deleteData.package_author,
packageVersion: deleteData.package_version, package_author_email: deleteData.package_author_email,
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,
}),
]);
} }
async onLdapSyncFinished(data: { async onLdapSyncFinished(data: {

View File

@@ -18,6 +18,7 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { EventRelay } from '@/eventbus/event-relay.service';
export async function getCredentials(credentialId: string): Promise<ICredentialsDb | null> { export async function getCredentials(credentialId: string): Promise<ICredentialsDb | null> {
return await Container.get(CredentialsRepository).findOneBy({ id: credentialId }); return await Container.get(CredentialsRepository).findOneBy({ id: credentialId });
@@ -59,6 +60,12 @@ export async function saveCredential(
credential_id: credential.id, credential_id: credential.id,
public_api: true, 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) => { return await Db.transaction(async (transactionManager) => {
const savedCredential = await transactionManager.save<CredentialsEntity>(credential); const savedCredential = await transactionManager.save<CredentialsEntity>(credential);
@@ -95,6 +102,12 @@ export async function removeCredential(
credential_type: credentials.type, credential_type: credentials.type,
credential_id: credentials.id, 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); return await Container.get(CredentialsRepository).remove(credentials);
} }

View File

@@ -30,6 +30,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflo
import { TagRepository } from '@/databases/repositories/tag.repository'; import { TagRepository } from '@/databases/repositories/tag.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { EventRelay } from '@/eventbus/event-relay.service';
export = { export = {
createWorkflow: [ createWorkflow: [
@@ -56,6 +57,10 @@ export = {
await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]);
void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, project, true); 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); return res.json(createdWorkflow);
}, },
@@ -233,6 +238,11 @@ export = {
await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]); await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]);
void Container.get(InternalHooks).onWorkflowSaved(req.user, updateData, true); 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); return res.json(updatedWorkflow);
}, },

View File

@@ -17,6 +17,7 @@ import { toError } from '@/utils';
import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces'; import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces';
import { NodeMailer } from './NodeMailer'; import { NodeMailer } from './NodeMailer';
import { EventRelay } from '@/eventbus/event-relay.service';
type Template = HandlebarsTemplateDelegate<unknown>; type Template = HandlebarsTemplateDelegate<unknown>;
type TemplateName = 'invite' | 'passwordReset' | 'workflowShared' | 'credentialsShared'; type TemplateName = 'invite' | 'passwordReset' | 'workflowShared' | 'credentialsShared';
@@ -144,6 +145,10 @@ export class UserManagementMailer {
message_type: 'Workflow shared', message_type: 'Workflow shared',
public_api: false, public_api: false,
}); });
Container.get(EventRelay).emit('email-failed', {
user: sharer,
messageType: 'Workflow shared',
});
const error = toError(e); const error = toError(e);
@@ -199,6 +204,10 @@ export class UserManagementMailer {
message_type: 'Credentials shared', message_type: 'Credentials shared',
public_api: false, public_api: false,
}); });
Container.get(EventRelay).emit('email-failed', {
user: sharer,
messageType: 'Credentials shared',
});
const error = toError(e); const error = toError(e);

View File

@@ -71,6 +71,7 @@ import { WorkflowRepository } from './databases/repositories/workflow.repository
import { UrlService } from './services/url.service'; import { UrlService } from './services/url.service';
import { WorkflowExecutionService } from './workflows/workflowExecution.service'; import { WorkflowExecutionService } from './workflows/workflowExecution.service';
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
import { EventRelay } from './eventbus/event-relay.service';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@@ -392,17 +393,21 @@ export function hookFunctionsPreExecute(): IWorkflowExecuteHooks {
*/ */
function hookFunctionsSave(): IWorkflowExecuteHooks { function hookFunctionsSave(): IWorkflowExecuteHooks {
const logger = Container.get(Logger); const logger = Container.get(Logger);
const internalHooks = Container.get(InternalHooks);
const eventsService = Container.get(EventsService); const eventsService = Container.get(EventsService);
const eventRelay = Container.get(EventRelay);
return { return {
nodeExecuteBefore: [ nodeExecuteBefore: [
async function (this: WorkflowHooks, nodeName: string): Promise<void> { async function (this: WorkflowHooks, nodeName: string): Promise<void> {
void internalHooks.onNodeBeforeExecute(this.executionId, this.workflowData, nodeName); const { executionId, workflowData: workflow } = this;
eventRelay.emit('node-pre-execute', { executionId, workflow, nodeName });
}, },
], ],
nodeExecuteAfter: [ nodeExecuteAfter: [
async function (this: WorkflowHooks, nodeName: string): Promise<void> { async function (this: WorkflowHooks, nodeName: string): Promise<void> {
void internalHooks.onNodePostExecute(this.executionId, this.workflowData, nodeName); const { executionId, workflowData: workflow } = this;
eventRelay.emit('node-post-execute', { executionId, workflow, nodeName });
}, },
], ],
workflowExecuteBefore: [], workflowExecuteBefore: [],
@@ -541,20 +546,27 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
const logger = Container.get(Logger); const logger = Container.get(Logger);
const internalHooks = Container.get(InternalHooks); const internalHooks = Container.get(InternalHooks);
const eventsService = Container.get(EventsService); const eventsService = Container.get(EventsService);
const eventRelay = Container.get(EventRelay);
return { return {
nodeExecuteBefore: [ nodeExecuteBefore: [
async function (this: WorkflowHooks, nodeName: string): Promise<void> { async function (this: WorkflowHooks, nodeName: string): Promise<void> {
void internalHooks.onNodeBeforeExecute(this.executionId, this.workflowData, nodeName); const { executionId, workflowData: workflow } = this;
eventRelay.emit('node-pre-execute', { executionId, workflow, nodeName });
}, },
], ],
nodeExecuteAfter: [ nodeExecuteAfter: [
async function (this: WorkflowHooks, nodeName: string): Promise<void> { async function (this: WorkflowHooks, nodeName: string): Promise<void> {
void internalHooks.onNodePostExecute(this.executionId, this.workflowData, nodeName); const { executionId, workflowData: workflow } = this;
eventRelay.emit('node-post-execute', { executionId, workflow, nodeName });
}, },
], ],
workflowExecuteBefore: [ workflowExecuteBefore: [
async function (): Promise<void> { async function (): Promise<void> {
void internalHooks.onWorkflowBeforeExecute(this.executionId, this.workflowData); const { executionId, workflowData } = this;
eventRelay.emit('workflow-pre-execute', { executionId, data: workflowData });
}, },
], ],
workflowExecuteAfter: [ workflowExecuteAfter: [
@@ -622,9 +634,17 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
eventsService.emit('workflowExecutionCompleted', this.workflowData, fullRunData); eventsService.emit('workflowExecutionCompleted', this.workflowData, fullRunData);
} }
}, },
async function (this: WorkflowHooks, fullRunData: IRun): Promise<void> { async function (this: WorkflowHooks, runData: IRun): Promise<void> {
// send tracking and event log events, but don't wait for them const { executionId, workflowData: workflow } = this;
void internalHooks.onWorkflowPostExecute(this.executionId, this.workflowData, fullRunData);
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) { async function (this: WorkflowHooks, fullRunData: IRun) {
const externalHooks = Container.get(ExternalHooks); const externalHooks = Container.get(ExternalHooks);
@@ -765,6 +785,7 @@ async function executeWorkflow(
const nodeTypes = Container.get(NodeTypes); const nodeTypes = Container.get(NodeTypes);
const activeExecutions = Container.get(ActiveExecutions); const activeExecutions = Container.get(ActiveExecutions);
const eventRelay = Container.get(EventRelay);
const workflowData = const workflowData =
options.loadedWorkflowData ?? options.loadedWorkflowData ??
@@ -792,7 +813,7 @@ async function executeWorkflow(
executionId = options.parentExecutionId ?? (await activeExecutions.add(runData)); executionId = options.parentExecutionId ?? (await activeExecutions.add(runData));
} }
void internalHooks.onWorkflowBeforeExecute(executionId || '', runData); Container.get(EventRelay).emit('workflow-pre-execute', { executionId, data: runData });
let data; let data;
try { try {
@@ -905,6 +926,14 @@ async function executeWorkflow(
await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]); await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]);
void internalHooks.onWorkflowPostExecute(executionId, workflowData, data, additionalData.userId); 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 // 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') { if (data.finished === true || data.status === 'waiting') {

View File

@@ -37,6 +37,7 @@ import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service';
import { EventRelay } from './eventbus/event-relay.service';
@Service() @Service()
export class WorkflowRunner { export class WorkflowRunner {
@@ -52,6 +53,7 @@ export class WorkflowRunner {
private readonly workflowStaticDataService: WorkflowStaticDataService, private readonly workflowStaticDataService: WorkflowStaticDataService,
private readonly nodeTypes: NodeTypes, private readonly nodeTypes: NodeTypes,
private readonly permissionChecker: PermissionChecker, private readonly permissionChecker: PermissionChecker,
private readonly eventRelay: EventRelay,
) { ) {
if (this.executionsMode === 'queue') { if (this.executionsMode === 'queue') {
this.jobQueue = Container.get(Queue); this.jobQueue = Container.get(Queue);
@@ -145,7 +147,7 @@ export class WorkflowRunner {
await this.enqueueExecution(executionId, data, loadStaticData, realtime); await this.enqueueExecution(executionId, data, loadStaticData, realtime);
} else { } else {
await this.runMainProcess(executionId, data, loadStaticData, executionId); 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, // only run these when not in queue mode or when the execution is manual,
@@ -164,6 +166,14 @@ export class WorkflowRunner {
executionData, executionData,
data.userId, 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')) { if (this.externalHooks.exists('workflow.postExecute')) {
try { try {
await this.externalHooks.run('workflow.postExecute', [ await this.externalHooks.run('workflow.postExecute', [

View File

@@ -12,6 +12,7 @@ import {
updateLdapUserOnLocalDb, updateLdapUserOnLocalDb,
} from '@/Ldap/helpers'; } from '@/Ldap/helpers';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { EventRelay } from '@/eventbus/event-relay.service';
export const handleLdapLogin = async ( export const handleLdapLogin = async (
loginId: string, loginId: string,
@@ -54,6 +55,7 @@ export const handleLdapLogin = async (
user_type: 'ldap', user_type: 'ldap',
was_disabled_ldap_user: false, was_disabled_ldap_user: false,
}); });
Container.get(EventRelay).emit('user-signed-up', { user });
return user; return user;
} }
} else { } else {

View File

@@ -24,6 +24,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { ApplicationError } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { EventRelay } from '@/eventbus/event-relay.service';
@RestController() @RestController()
export class AuthController { export class AuthController {
@@ -35,6 +36,7 @@ export class AuthController {
private readonly userService: UserService, private readonly userService: UserService,
private readonly license: License, private readonly license: License,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly eventRelay: EventRelay,
private readonly postHog?: PostHogClient, private readonly postHog?: PostHogClient,
) {} ) {}
@@ -90,16 +92,17 @@ export class AuthController {
} }
this.authService.issueCookie(res, user, req.browserId); this.authService.issueCookie(res, user, req.browserId);
void this.internalHooks.onUserLoginSuccess({
this.eventRelay.emit('user-logged-in', {
user, user,
authenticationMethod: usedAuthenticationMethod, authenticationMethod: usedAuthenticationMethod,
}); });
return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
} }
void this.internalHooks.onUserLoginFailed({ this.eventRelay.emit('user-login-failed', {
user: email,
authenticationMethod: usedAuthenticationMethod, authenticationMethod: usedAuthenticationMethod,
userEmail: email,
reason: 'wrong credentials', reason: 'wrong credentials',
}); });
throw new AuthError('Wrong username or password. Do you have caps lock on?'); 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 }); void this.internalHooks.onUserInviteEmailClick({ inviter, invitee });
this.eventRelay.emit('user-invite-email-click', { inviter, invitee });
const { firstName, lastName } = inviter; const { firstName, lastName } = inviter;
return { inviter: { firstName, lastName } }; return { inviter: { firstName, lastName } };

View File

@@ -14,6 +14,7 @@ import { Push } from '@/push';
import { CommunityPackagesService } from '@/services/communityPackages.service'; import { CommunityPackagesService } from '@/services/communityPackages.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { EventRelay } from '@/eventbus/event-relay.service';
const { const {
PACKAGE_NOT_INSTALLED, PACKAGE_NOT_INSTALLED,
@@ -38,6 +39,7 @@ export class CommunityPackagesController {
private readonly push: Push, private readonly push: Push,
private readonly internalHooks: InternalHooks, private readonly internalHooks: InternalHooks,
private readonly communityPackagesService: CommunityPackagesService, private readonly communityPackagesService: CommunityPackagesService,
private readonly eventRelay: EventRelay,
) {} ) {}
// TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')` // TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')`
@@ -114,6 +116,14 @@ export class CommunityPackagesController {
package_version: parsed.version, package_version: parsed.version,
failure_reason: errorMessage, 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(':'); let message = [`Error loading package "${name}" `, errorMessage].join(':');
if (error instanceof Error && error.cause instanceof Error) { if (error instanceof Error && error.cause instanceof Error) {
@@ -144,6 +154,16 @@ export class CommunityPackagesController {
package_author: installedPackage.authorName, package_author: installedPackage.authorName,
package_author_email: installedPackage.authorEmail, 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; return installedPackage;
} }
@@ -233,6 +253,14 @@ export class CommunityPackagesController {
package_author: installedPackage.authorName, package_author: installedPackage.authorName,
package_author_email: installedPackage.authorEmail, 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('/') @Patch('/')
@@ -281,6 +309,15 @@ export class CommunityPackagesController {
package_author: newInstalledPackage.authorName, package_author: newInstalledPackage.authorName,
package_author_email: newInstalledPackage.authorEmail, 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; return newInstalledPackage;
} catch (error) { } catch (error) {

View File

@@ -18,6 +18,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { ExternalHooks } from '@/ExternalHooks'; import { ExternalHooks } from '@/ExternalHooks';
import { EventRelay } from '@/eventbus/event-relay.service';
@RestController('/invitations') @RestController('/invitations')
export class InvitationController { export class InvitationController {
@@ -31,6 +32,7 @@ export class InvitationController {
private readonly passwordUtility: PasswordUtility, private readonly passwordUtility: PasswordUtility,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly postHog: PostHogClient, private readonly postHog: PostHogClient,
private readonly eventRelay: EventRelay,
) {} ) {}
/** /**
@@ -170,6 +172,7 @@ export class InvitationController {
user_type: 'email', user_type: 'email',
was_disabled_ldap_user: false, was_disabled_ldap_user: false,
}); });
this.eventRelay.emit('user-signed-up', { user: updatedUser });
const publicInvitee = await this.userService.toPublic(invitee); const publicInvitee = await this.userService.toPublic(invitee);

View File

@@ -23,6 +23,7 @@ import { InternalHooks } from '@/InternalHooks';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { isApiEnabled } from '@/PublicApi'; import { isApiEnabled } from '@/PublicApi';
import { EventRelay } from '@/eventbus/event-relay.service';
export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => { export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => {
if (isApiEnabled()) { if (isApiEnabled()) {
@@ -42,6 +43,7 @@ export class MeController {
private readonly userService: UserService, private readonly userService: UserService,
private readonly passwordUtility: PasswordUtility, private readonly passwordUtility: PasswordUtility,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly eventRelay: EventRelay,
) {} ) {}
/** /**
@@ -96,11 +98,9 @@ export class MeController {
this.authService.issueCookie(res, user, req.browserId); this.authService.issueCookie(res, user, req.browserId);
const updatedKeys = Object.keys(payload); const fieldsChanged = Object.keys(payload);
void this.internalHooks.onUserUpdate({ void this.internalHooks.onUserUpdate({ user, fields_changed: fieldsChanged });
user, this.eventRelay.emit('user-updated', { user, fieldsChanged });
fields_changed: updatedKeys,
});
const publicUser = await this.userService.toPublic(user); const publicUser = await this.userService.toPublic(user);
@@ -149,10 +149,8 @@ export class MeController {
this.authService.issueCookie(res, updatedUser, req.browserId); this.authService.issueCookie(res, updatedUser, req.browserId);
void this.internalHooks.onUserUpdate({ void this.internalHooks.onUserUpdate({ user: updatedUser, fields_changed: ['password'] });
user: updatedUser, this.eventRelay.emit('user-updated', { user: updatedUser, fieldsChanged: ['password'] });
fields_changed: ['password'],
});
await this.externalHooks.run('user.password.update', [updatedUser.email, updatedUser.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 }); await this.userService.update(req.user.id, { apiKey });
void this.internalHooks.onApiKeyCreated({ void this.internalHooks.onApiKeyCreated({ user: req.user, public_api: false });
user: req.user, this.eventRelay.emit('api-key-created', { user: req.user });
public_api: false,
});
return { apiKey }; return { apiKey };
} }
@@ -223,10 +219,8 @@ export class MeController {
async deleteAPIKey(req: AuthenticatedRequest) { async deleteAPIKey(req: AuthenticatedRequest) {
await this.userService.update(req.user.id, { apiKey: null }); await this.userService.update(req.user.id, { apiKey: null });
void this.internalHooks.onApiKeyDeleted({ void this.internalHooks.onApiKeyDeleted({ user: req.user, public_api: false });
user: req.user, this.eventRelay.emit('api-key-deleted', { user: req.user });
public_api: false,
});
return { success: true }; return { success: true };
} }

View File

@@ -21,6 +21,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { EventRelay } from '@/eventbus/event-relay.service';
@RestController() @RestController()
export class PasswordResetController { export class PasswordResetController {
@@ -36,6 +37,7 @@ export class PasswordResetController {
private readonly license: License, private readonly license: License,
private readonly passwordUtility: PasswordUtility, private readonly passwordUtility: PasswordUtility,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly eventRelay: EventRelay,
) {} ) {}
/** /**
@@ -123,6 +125,7 @@ export class PasswordResetController {
message_type: 'Reset password', message_type: 'Reset password',
public_api: false, public_api: false,
}); });
this.eventRelay.emit('email-failed', { user, messageType: 'Reset password' });
if (error instanceof Error) { if (error instanceof Error) {
throw new InternalServerError(`Please contact your administrator: ${error.message}`); throw new InternalServerError(`Please contact your administrator: ${error.message}`);
} }
@@ -136,6 +139,7 @@ export class PasswordResetController {
}); });
void this.internalHooks.onUserPasswordResetRequestClick({ user }); 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 }); this.logger.info('Reset-password token resolved successfully', { userId: user.id });
void this.internalHooks.onUserPasswordResetEmailClick({ user }); 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); this.authService.issueCookie(res, user, req.browserId);
void this.internalHooks.onUserUpdate({ void this.internalHooks.onUserUpdate({ user, fields_changed: ['password'] });
user, this.eventRelay.emit('user-updated', { user, fieldsChanged: ['password'] });
fields_changed: ['password'],
});
// if this user used to be an LDAP users // if this user used to be an LDAP users
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
@@ -222,6 +225,7 @@ export class PasswordResetController {
user_type: 'email', user_type: 'email',
was_disabled_ldap_user: true, was_disabled_ldap_user: true,
}); });
this.eventRelay.emit('user-signed-up', { user });
} }
await this.externalHooks.run('user.password.update', [user.email, passwordHash]); await this.externalHooks.run('user.password.update', [user.email, passwordHash]);

View File

@@ -28,6 +28,7 @@ import { Project } from '@/databases/entities/Project';
import { WorkflowService } from '@/workflows/workflow.service'; import { WorkflowService } from '@/workflows/workflow.service';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
import { ProjectService } from '@/services/project.service'; import { ProjectService } from '@/services/project.service';
import { EventRelay } from '@/eventbus/event-relay.service';
@RestController('/users') @RestController('/users')
export class UsersController { export class UsersController {
@@ -44,6 +45,7 @@ export class UsersController {
private readonly workflowService: WorkflowService, private readonly workflowService: WorkflowService,
private readonly credentialsService: CredentialsService, private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly eventRelay: EventRelay,
) {} ) {}
static ERROR_MESSAGES = { static ERROR_MESSAGES = {
@@ -256,6 +258,7 @@ export class UsersController {
telemetryData, telemetryData,
publicApi: false, publicApi: false,
}); });
this.eventRelay.emit('user-deleted', { user: req.user });
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);

View File

@@ -29,6 +29,7 @@ import { In } from '@n8n/typeorm';
import { SharedCredentials } from '@/databases/entities/SharedCredentials'; import { SharedCredentials } from '@/databases/entities/SharedCredentials';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
import { z } from 'zod'; import { z } from 'zod';
import { EventRelay } from '@/eventbus/event-relay.service';
@RestController('/credentials') @RestController('/credentials')
export class CredentialsController { export class CredentialsController {
@@ -42,6 +43,7 @@ export class CredentialsController {
private readonly userManagementMailer: UserManagementMailer, private readonly userManagementMailer: UserManagementMailer,
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly projectRelationRepository: ProjectRelationRepository, private readonly projectRelationRepository: ProjectRelationRepository,
private readonly eventRelay: EventRelay,
) {} ) {}
@Get('/', { middlewares: listQueryMiddleware }) @Get('/', { middlewares: listQueryMiddleware })
@@ -164,6 +166,12 @@ export class CredentialsController {
credential_id: credential.id, credential_id: credential.id,
public_api: false, 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); const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id);
@@ -218,6 +226,12 @@ export class CredentialsController {
credential_type: credential.type, credential_type: credential.type,
credential_id: credential.id, 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); const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id);
@@ -253,6 +267,12 @@ export class CredentialsController {
credential_type: credential.type, credential_type: credential.type,
credential_id: credential.id, credential_id: credential.id,
}); });
this.eventRelay.emit('credentials-deleted', {
user: req.user,
credentialName: credential.name,
credentialType: credential.type,
credentialId: credential.id,
});
return true; return true;
} }
@@ -321,6 +341,15 @@ export class CredentialsController {
user_ids_sharees_added: newShareeIds, user_ids_sharees_added: newShareeIds,
sharees_removed: amountRemoved, 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({ const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds), projectId: In(newShareeIds),

View File

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

View File

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

View File

@@ -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<MessageEventBus>();
const eventRelay = mock<EventRelay>();
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',
},
},
});
});
});

View File

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

View File

@@ -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<K extends keyof Event>(eventName: K, arg: Event[K]) {
super.emit(eventName, arg);
return true;
}
on<K extends keyof Event>(eventName: K, handler: (arg: Event[K]) => void) {
super.on(eventName, handler);
return this;
}
}

View File

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

View File

@@ -20,6 +20,8 @@ import { NodeCrashedError } from '@/errors/node-crashed.error';
import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; import { WorkflowCrashedError } from '@/errors/workflow-crashed.error';
import { EventMessageNode } from '@/eventbus/EventMessageClasses/EventMessageNode'; import { EventMessageNode } from '@/eventbus/EventMessageClasses/EventMessageNode';
import { EventMessageWorkflow } from '@/eventbus/EventMessageClasses/EventMessageWorkflow'; import { EventMessageWorkflow } from '@/eventbus/EventMessageClasses/EventMessageWorkflow';
import type { EventRelay } from '@/eventbus/event-relay.service';
import type { EventMessageTypes as EventMessage } from '@/eventbus/EventMessageClasses'; import type { EventMessageTypes as EventMessage } from '@/eventbus/EventMessageClasses';
import type { Logger } from '@/Logger'; import type { Logger } from '@/Logger';
@@ -191,6 +193,7 @@ describe('ExecutionRecoveryService', () => {
push, push,
executionRepository, executionRepository,
orchestrationService, orchestrationService,
mock<EventRelay>(),
); );
}); });

View File

@@ -16,6 +16,7 @@ import config from '@/config';
import { OnShutdown } from '@/decorators/OnShutdown'; import { OnShutdown } from '@/decorators/OnShutdown';
import type { QueueRecoverySettings } from './execution.types'; import type { QueueRecoverySettings } from './execution.types';
import { OrchestrationService } from '@/services/orchestration.service'; import { OrchestrationService } from '@/services/orchestration.service';
import { EventRelay } from '@/eventbus/event-relay.service';
/** /**
* Service for recovering key properties in executions. * Service for recovering key properties in executions.
@@ -27,6 +28,7 @@ export class ExecutionRecoveryService {
private readonly push: Push, private readonly push: Push,
private readonly executionRepository: ExecutionRepository, private readonly executionRepository: ExecutionRepository,
private readonly orchestrationService: OrchestrationService, private readonly orchestrationService: OrchestrationService,
private readonly eventRelay: EventRelay,
) {} ) {}
/** /**
@@ -284,6 +286,14 @@ export class ExecutionRecoveryService {
status: execution.status, 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( const externalHooks = getWorkflowHooksMain(
{ {
userId: '', userId: '',

View File

@@ -12,6 +12,7 @@ import { InternalHooks } from '@/InternalHooks';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
import type { UserRequest } from '@/requests'; import type { UserRequest } from '@/requests';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { EventRelay } from '@/eventbus/event-relay.service';
@Service() @Service()
export class UserService { export class UserService {
@@ -20,6 +21,7 @@ export class UserService {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly mailer: UserManagementMailer, private readonly mailer: UserManagementMailer,
private readonly urlService: UrlService, private readonly urlService: UrlService,
private readonly eventRelay: EventRelay,
) {} ) {}
async update(userId: string, data: Partial<User>) { async update(userId: string, data: Partial<User>) {
@@ -156,6 +158,10 @@ export class UserService {
email_sent: result.emailSent, email_sent: result.emailSent,
invitee_role: role, // same role for all invited users invitee_role: role, // same role for all invited users
}); });
this.eventRelay.emit('user-invited', {
user: owner,
targetUserId: Object.values(toInviteUsers),
});
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
void Container.get(InternalHooks).onEmailFailed({ void Container.get(InternalHooks).onEmailFailed({
@@ -163,6 +169,7 @@ export class UserService {
message_type: 'New user invite', message_type: 'New user invite',
public_api: false, public_api: false,
}); });
this.eventRelay.emit('email-failed', { user: owner, messageType: 'New user invite' });
this.logger.error('Failed to send email', { this.logger.error('Failed to send email', {
userId: owner.id, userId: owner.id,
inviteAcceptUrl, inviteAcceptUrl,

View File

@@ -6,7 +6,6 @@ import url from 'url';
import { Get, Post, RestController, GlobalScope } from '@/decorators'; import { Get, Post, RestController, GlobalScope } from '@/decorators';
import { AuthService } from '@/auth/auth.service'; import { AuthService } from '@/auth/auth.service';
import { AuthenticatedRequest } from '@/requests'; import { AuthenticatedRequest } from '@/requests';
import { InternalHooks } from '@/InternalHooks';
import querystring from 'querystring'; import querystring from 'querystring';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { AuthError } from '@/errors/response-errors/auth.error'; import { AuthError } from '@/errors/response-errors/auth.error';
@@ -28,6 +27,7 @@ import {
import { SamlService } from '../saml.service.ee'; import { SamlService } from '../saml.service.ee';
import { SamlConfiguration } from '../types/requests'; import { SamlConfiguration } from '../types/requests';
import { getInitSSOFormView } from '../views/initSsoPost'; import { getInitSSOFormView } from '../views/initSsoPost';
import { EventRelay } from '@/eventbus/event-relay.service';
@RestController('/sso/saml') @RestController('/sso/saml')
export class SamlController { export class SamlController {
@@ -35,7 +35,7 @@ export class SamlController {
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly samlService: SamlService, private readonly samlService: SamlService,
private readonly urlService: UrlService, private readonly urlService: UrlService,
private readonly internalHooks: InternalHooks, private readonly eventRelay: EventRelay,
) {} ) {}
@Get('/metadata', { skipAuth: true }) @Get('/metadata', { skipAuth: true })
@@ -126,10 +126,11 @@ export class SamlController {
} }
} }
if (loginResult.authenticatedUser) { if (loginResult.authenticatedUser) {
void this.internalHooks.onUserLoginSuccess({ this.eventRelay.emit('user-logged-in', {
user: loginResult.authenticatedUser, user: loginResult.authenticatedUser,
authenticationMethod: 'saml', authenticationMethod: 'saml',
}); });
// Only sign in user if SAML is enabled, otherwise treat as test connection // Only sign in user if SAML is enabled, otherwise treat as test connection
if (isSamlLicensedAndEnabled()) { if (isSamlLicensedAndEnabled()) {
this.authService.issueCookie(res, loginResult.authenticatedUser, req.browserId); this.authService.issueCookie(res, loginResult.authenticatedUser, req.browserId);
@@ -143,8 +144,8 @@ export class SamlController {
return res.status(202).send(loginResult.attributes); return res.status(202).send(loginResult.attributes);
} }
} }
void this.internalHooks.onUserLoginFailed({ this.eventRelay.emit('user-login-failed', {
user: loginResult.attributes.email ?? 'unknown', userEmail: loginResult.attributes.email ?? 'unknown',
authenticationMethod: 'saml', authenticationMethod: 'saml',
}); });
throw new AuthError('SAML Authentication failed'); throw new AuthError('SAML Authentication failed');
@@ -152,8 +153,8 @@ export class SamlController {
if (isConnectionTestRequest(req)) { if (isConnectionTestRequest(req)) {
return res.send(getSamlConnectionTestFailedView((error as Error).message)); return res.send(getSamlConnectionTestFailedView((error as Error).message));
} }
void this.internalHooks.onUserLoginFailed({ this.eventRelay.emit('user-login-failed', {
user: 'unknown', userEmail: 'unknown',
authenticationMethod: 'saml', authenticationMethod: 'saml',
}); });
throw new AuthError('SAML Authentication failed: ' + (error as Error).message); throw new AuthError('SAML Authentication failed: ' + (error as Error).message);

View File

@@ -32,6 +32,7 @@ import type { Scope } from '@n8n/permissions';
import type { EntityManager } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm';
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
import { EventRelay } from '@/eventbus/event-relay.service';
@Service() @Service()
export class WorkflowService { export class WorkflowService {
@@ -51,6 +52,7 @@ export class WorkflowService {
private readonly workflowSharingService: WorkflowSharingService, private readonly workflowSharingService: WorkflowSharingService,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly executionRepository: ExecutionRepository, private readonly executionRepository: ExecutionRepository,
private readonly eventRelay: EventRelay,
) {} ) {}
async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) { async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) {
@@ -216,6 +218,11 @@ export class WorkflowService {
await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
void Container.get(InternalHooks).onWorkflowSaved(user, updatedWorkflow, false); void Container.get(InternalHooks).onWorkflowSaved(user, updatedWorkflow, false);
this.eventRelay.emit('workflow-saved', {
user,
workflowId: updatedWorkflow.id,
workflowName: updatedWorkflow.name,
});
if (updatedWorkflow.active) { if (updatedWorkflow.active) {
// When the workflow is supposed to be active add it again // When the workflow is supposed to be active add it again
@@ -274,6 +281,7 @@ export class WorkflowService {
await this.binaryDataService.deleteMany(idsForDeletion); await this.binaryDataService.deleteMany(idsForDeletion);
void Container.get(InternalHooks).onWorkflowDeleted(user, workflowId, false); void Container.get(InternalHooks).onWorkflowDeleted(user, workflowId, false);
this.eventRelay.emit('workflow-deleted', { user, workflowId });
await this.externalHooks.run('workflow.afterDelete', [workflowId]); await this.externalHooks.run('workflow.afterDelete', [workflowId]);
return workflow; return workflow;

View File

@@ -41,6 +41,7 @@ import { In, type FindOptionsRelations } from '@n8n/typeorm';
import type { Project } from '@/databases/entities/Project'; import type { Project } from '@/databases/entities/Project';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
import { z } from 'zod'; import { z } from 'zod';
import { EventRelay } from '@/eventbus/event-relay.service';
@RestController('/workflows') @RestController('/workflows')
export class WorkflowsController { export class WorkflowsController {
@@ -64,6 +65,7 @@ export class WorkflowsController {
private readonly projectRepository: ProjectRepository, private readonly projectRepository: ProjectRepository,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly projectRelationRepository: ProjectRelationRepository, private readonly projectRelationRepository: ProjectRelationRepository,
private readonly eventRelay: EventRelay,
) {} ) {}
@Post('/') @Post('/')
@@ -175,6 +177,7 @@ export class WorkflowsController {
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void this.internalHooks.onWorkflowCreated(req.user, newWorkflow, project!, false); 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); const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id);

View File

@@ -1,12 +1,6 @@
import { Container } from 'typedi';
import type { AuthenticationMethod } from 'n8n-workflow';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers'; import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers';
import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; 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 { randomEmail, randomName, randomValidPassword } from '../shared/random';
import * as utils from '../shared/utils/'; 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();
});
});

View File

@@ -38,6 +38,7 @@ beforeAll(async () => {
mock(), mock(),
mock(), mock(),
mock(), mock(),
mock(),
); );
}); });

View File

@@ -23,7 +23,6 @@ describe('InternalHooks', () => {
mock(), mock(),
mock(), mock(),
mock(), mock(),
mock(),
license, license,
mock(), mock(),
mock(), mock(),