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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import { InternalHooks } from '@/InternalHooks';
import { Logger } from '@/Logger';
import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service';
import { EventRelay } from './eventbus/event-relay.service';
@Service()
export class WorkflowRunner {
@@ -52,6 +53,7 @@ export class WorkflowRunner {
private readonly workflowStaticDataService: WorkflowStaticDataService,
private readonly nodeTypes: NodeTypes,
private readonly permissionChecker: PermissionChecker,
private readonly eventRelay: EventRelay,
) {
if (this.executionsMode === 'queue') {
this.jobQueue = Container.get(Queue);
@@ -145,7 +147,7 @@ export class WorkflowRunner {
await this.enqueueExecution(executionId, data, loadStaticData, realtime);
} else {
await this.runMainProcess(executionId, data, loadStaticData, executionId);
void Container.get(InternalHooks).onWorkflowBeforeExecute(executionId, data);
this.eventRelay.emit('workflow-pre-execute', { executionId, data });
}
// only run these when not in queue mode or when the execution is manual,
@@ -164,6 +166,14 @@ export class WorkflowRunner {
executionData,
data.userId,
);
this.eventRelay.emit('workflow-post-execute', {
workflowId: data.workflowData.id,
workflowName: data.workflowData.name,
executionId,
success: executionData?.status === 'success',
isManual: data.executionMode === 'manual',
userId: data.userId,
});
if (this.externalHooks.exists('workflow.postExecute')) {
try {
await this.externalHooks.run('workflow.postExecute', [

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import { Push } from '@/push';
import { CommunityPackagesService } from '@/services/communityPackages.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { EventRelay } from '@/eventbus/event-relay.service';
const {
PACKAGE_NOT_INSTALLED,
@@ -38,6 +39,7 @@ export class CommunityPackagesController {
private readonly push: Push,
private readonly internalHooks: InternalHooks,
private readonly communityPackagesService: CommunityPackagesService,
private readonly eventRelay: EventRelay,
) {}
// TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')`
@@ -114,6 +116,14 @@ export class CommunityPackagesController {
package_version: parsed.version,
failure_reason: errorMessage,
});
this.eventRelay.emit('community-package-installed', {
user: req.user,
inputString: name,
packageName: parsed.packageName,
success: false,
packageVersion: parsed.version,
failureReason: errorMessage,
});
let message = [`Error loading package "${name}" `, errorMessage].join(':');
if (error instanceof Error && error.cause instanceof Error) {
@@ -144,6 +154,16 @@ export class CommunityPackagesController {
package_author: installedPackage.authorName,
package_author_email: installedPackage.authorEmail,
});
this.eventRelay.emit('community-package-installed', {
user: req.user,
inputString: name,
packageName: parsed.packageName,
success: true,
packageVersion: parsed.version,
packageNodeNames: installedPackage.installedNodes.map((node) => node.name),
packageAuthor: installedPackage.authorName,
packageAuthorEmail: installedPackage.authorEmail,
});
return installedPackage;
}
@@ -233,6 +253,14 @@ export class CommunityPackagesController {
package_author: installedPackage.authorName,
package_author_email: installedPackage.authorEmail,
});
this.eventRelay.emit('community-package-deleted', {
user: req.user,
packageName: name,
packageVersion: installedPackage.installedVersion,
packageNodeNames: installedPackage.installedNodes.map((node) => node.name),
packageAuthor: installedPackage.authorName,
packageAuthorEmail: installedPackage.authorEmail,
});
}
@Patch('/')
@@ -281,6 +309,15 @@ export class CommunityPackagesController {
package_author: newInstalledPackage.authorName,
package_author_email: newInstalledPackage.authorEmail,
});
this.eventRelay.emit('community-package-updated', {
user: req.user,
packageName: name,
packageVersionCurrent: previouslyInstalledPackage.installedVersion,
packageVersionNew: newInstalledPackage.installedVersion,
packageNodeNames: newInstalledPackage.installedNodes.map((n) => n.name),
packageAuthor: newInstalledPackage.authorName,
packageAuthorEmail: newInstalledPackage.authorEmail,
});
return newInstalledPackage;
} catch (error) {

View File

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

View File

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

View File

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

View File

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

View File

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

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 { EventMessageNode } from '@/eventbus/EventMessageClasses/EventMessageNode';
import { EventMessageWorkflow } from '@/eventbus/EventMessageClasses/EventMessageWorkflow';
import type { EventRelay } from '@/eventbus/event-relay.service';
import type { EventMessageTypes as EventMessage } from '@/eventbus/EventMessageClasses';
import type { Logger } from '@/Logger';
@@ -191,6 +193,7 @@ describe('ExecutionRecoveryService', () => {
push,
executionRepository,
orchestrationService,
mock<EventRelay>(),
);
});

View File

@@ -16,6 +16,7 @@ import config from '@/config';
import { OnShutdown } from '@/decorators/OnShutdown';
import type { QueueRecoverySettings } from './execution.types';
import { OrchestrationService } from '@/services/orchestration.service';
import { EventRelay } from '@/eventbus/event-relay.service';
/**
* Service for recovering key properties in executions.
@@ -27,6 +28,7 @@ export class ExecutionRecoveryService {
private readonly push: Push,
private readonly executionRepository: ExecutionRepository,
private readonly orchestrationService: OrchestrationService,
private readonly eventRelay: EventRelay,
) {}
/**
@@ -284,6 +286,14 @@ export class ExecutionRecoveryService {
status: execution.status,
});
this.eventRelay.emit('workflow-post-execute', {
workflowId: execution.workflowData.id,
workflowName: execution.workflowData.name,
executionId: execution.id,
success: execution.status === 'success',
isManual: execution.mode === 'manual',
});
const externalHooks = getWorkflowHooksMain(
{
userId: '',

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,6 @@
import { Container } from 'typedi';
import type { AuthenticationMethod } from 'n8n-workflow';
import type { User } from '@db/entities/User';
import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers';
import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
import { InternalHooks } from '@/InternalHooks';
import { SamlService } from '@/sso/saml/saml.service.ee';
import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes';
import { randomEmail, randomName, randomValidPassword } from '../shared/random';
import * as utils from '../shared/utils/';
@@ -266,89 +260,3 @@ describe('Check endpoint permissions', () => {
});
});
});
describe('SAML login flow', () => {
beforeEach(async () => {
await enableSaml(true);
});
test('should trigger onUserLoginSuccess hook', async () => {
const mockedHandleSamlLogin = jest.spyOn(Container.get(SamlService), 'handleSamlLogin');
mockedHandleSamlLogin.mockImplementation(
async (): Promise<{
authenticatedUser: User;
attributes: SamlUserAttributes;
onboardingRequired: false;
}> => {
return {
authenticatedUser: someUser,
attributes: {
email: someUser.email,
firstName: someUser.firstName,
lastName: someUser.lastName,
userPrincipalName: someUser.email,
},
onboardingRequired: false,
};
},
);
const mockedHookOnUserLoginSuccess = jest.spyOn(
Container.get(InternalHooks),
'onUserLoginSuccess',
);
mockedHookOnUserLoginSuccess.mockImplementation(
async (userLoginData: { user: User; authenticationMethod: AuthenticationMethod }) => {
expect(userLoginData.authenticationMethod).toEqual('saml');
return;
},
);
await authOwnerAgent.post('/sso/saml/acs').expect(302);
expect(mockedHookOnUserLoginSuccess).toBeCalled();
mockedHookOnUserLoginSuccess.mockRestore();
mockedHandleSamlLogin.mockRestore();
});
test('should trigger onUserLoginFailed hook', async () => {
const mockedHandleSamlLogin = jest.spyOn(Container.get(SamlService), 'handleSamlLogin');
mockedHandleSamlLogin.mockImplementation(
async (): Promise<{
authenticatedUser: User | undefined;
attributes: SamlUserAttributes;
onboardingRequired: false;
}> => {
return {
authenticatedUser: undefined,
attributes: {
email: someUser.email,
firstName: someUser.firstName,
lastName: someUser.lastName,
userPrincipalName: someUser.email,
},
onboardingRequired: false,
};
},
);
const mockedHookOnUserLoginFailed = jest.spyOn(
Container.get(InternalHooks),
'onUserLoginFailed',
);
mockedHookOnUserLoginFailed.mockImplementation(
async (userLoginData: {
user: string;
authenticationMethod: AuthenticationMethod;
reason?: string;
}) => {
expect(userLoginData.authenticationMethod).toEqual('saml');
return;
},
);
await authOwnerAgent.post('/sso/saml/acs').expect(401);
expect(mockedHookOnUserLoginFailed).toBeCalled();
mockedHookOnUserLoginFailed.mockRestore();
mockedHandleSamlLogin.mockRestore();
});
});

View File

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

View File

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