mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
refactor(core): Decouple event bus from internal hooks (no-changelog) (#9724)
This commit is contained in:
@@ -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', {
|
||||
|
||||
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', {
|
||||
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,16 +208,7 @@ 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', {
|
||||
void this.telemetry.track('User saved workflow', {
|
||||
user_id: user.id,
|
||||
workflow_id: workflow.id,
|
||||
node_graph_string: JSON.stringify(nodeGraph),
|
||||
@@ -266,108 +218,12 @@ export class InternalHooks {
|
||||
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
|
||||
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', {
|
||||
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', {
|
||||
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', {
|
||||
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', {
|
||||
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', {
|
||||
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', {
|
||||
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', {
|
||||
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', {
|
||||
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', {
|
||||
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', {
|
||||
void 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(
|
||||
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', {
|
||||
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,20 +597,7 @@ 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', {
|
||||
void this.telemetry.track('User updated cred sharing', {
|
||||
user_id: userSharedCredentialsData.user.id,
|
||||
credential_type: userSharedCredentialsData.credential_type,
|
||||
credential_id: userSharedCredentialsData.credential_id,
|
||||
@@ -945,8 +605,7 @@ export class InternalHooks {
|
||||
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', {
|
||||
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', {
|
||||
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,22 +650,7 @@ 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', {
|
||||
void this.telemetry.track('cnr package install finished', {
|
||||
user_id: installationData.user.id,
|
||||
input_string: installationData.input_string,
|
||||
package_name: installationData.package_name,
|
||||
@@ -1038,8 +660,7 @@ export class InternalHooks {
|
||||
package_author: installationData.package_author,
|
||||
package_author_email: installationData.package_author_email,
|
||||
failure_reason: installationData.failure_reason,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async onCommunityPackageUpdateFinished(updateData: {
|
||||
@@ -1051,20 +672,7 @@ 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', {
|
||||
void this.telemetry.track('cnr package updated', {
|
||||
user_id: updateData.user.id,
|
||||
package_name: updateData.package_name,
|
||||
package_version_current: updateData.package_version_current,
|
||||
@@ -1072,8 +680,7 @@ export class InternalHooks {
|
||||
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', {
|
||||
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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
50
packages/cli/src/decorators/Redactable.ts
Normal file
50
packages/cli/src/decorators/Redactable.ts
Normal 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;
|
||||
};
|
||||
9
packages/cli/src/errors/redactable.error.ts
Normal file
9
packages/cli/src/errors/redactable.error.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
340
packages/cli/src/eventbus/audit-event-relay.service.ts
Normal file
340
packages/cli/src/eventbus/audit-event-relay.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
16
packages/cli/src/eventbus/event-relay.service.ts
Normal file
16
packages/cli/src/eventbus/event-relay.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
185
packages/cli/src/eventbus/event.types.ts
Normal file
185
packages/cli/src/eventbus/event.types.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -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>(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ beforeAll(async () => {
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ describe('InternalHooks', () => {
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
license,
|
||||
mock(),
|
||||
mock(),
|
||||
|
||||
Reference in New Issue
Block a user