From bfbbf016f3d350f41e07fbb3ca756dbc0db2d8f9 Mon Sep 17 00:00:00 2001 From: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:14:27 +0100 Subject: [PATCH] chore: Add telemetry for user role provisioning (no changelog) (#22158) --- .../__tests__/telemetry-event-relay.test.ts | 32 ++++++++++++ .../cli/src/events/maps/relay.event-map.ts | 13 +++++ .../events/relays/telemetry.event-relay.ts | 25 ++++++++++ .../__tests__/provisioning.service.ee.test.ts | 49 +++++++++++++++++++ .../provisioning.service.ee.ts | 13 +++++ .../sso/components/OidcSettingsForm.vue | 12 ++++- .../sso/components/SamlSettingsForm.vue | 12 ++++- 7 files changed, 154 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 2566a1cd923..33de014b990 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -702,6 +702,38 @@ describe('TelemetryEventRelay', () => { }); }); + describe('SSO events', () => { + it('should track on `sso-user-project-access-updated` event', () => { + const event: RelayEventMap['sso-user-project-access-updated'] = { + userId: 'user123', + projectsRemoved: 2, + projectsAdded: 3, + }; + + eventService.emit('sso-user-project-access-updated', event); + + expect(telemetry.track).toHaveBeenCalledWith('Sso user project acess update', { + user_id: 'user123', + projects_removed: 2, + projects_added: 3, + }); + }); + + it('should track on `sso-user-instance-role-updated` event', () => { + const event: RelayEventMap['sso-user-instance-role-updated'] = { + userId: 'user123', + role: 'global:admin', + }; + + eventService.emit('sso-user-instance-role-updated', event); + + expect(telemetry.track).toHaveBeenCalledWith('Sso user instance role update', { + user_id: 'user123', + role: 'global:admin', + }); + }); + }); + describe('workflow events', () => { it('should track on `workflow-created` event', async () => { const event: RelayEventMap['workflow-created'] = { diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index b9ed1513952..e7c8d32542a 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -533,6 +533,19 @@ export type RelayEventMap = { // #endregion + // #region SSO + + 'sso-user-project-access-updated': { + projectsRemoved: number; + projectsAdded: number; + userId: string; + }; + + 'sso-user-instance-role-updated': { + role: string; + userId: string; + }; + // #region runner 'runner-task-requested': { diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 94852f76dd8..76c25fce676 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -81,6 +81,8 @@ export class TelemetryEventRelay extends EventRelay { 'ldap-settings-updated': (event) => this.ldapSettingsUpdated(event), 'ldap-login-sync-failed': (event) => this.ldapLoginSyncFailed(event), 'login-failed-due-to-ldap-disabled': (event) => this.loginFailedDueToLdapDisabled(event), + 'sso-user-project-access-updated': (event) => this.ssoUserProjectAccessUpdated(event), + 'sso-user-instance-role-updated': (event) => this.ssoUserInstanceRoleUpdated(event), 'workflow-created': (event) => this.workflowCreated(event), 'workflow-archived': (event) => this.workflowArchived(event), 'workflow-unarchived': (event) => this.workflowUnarchived(event), @@ -531,6 +533,29 @@ export class TelemetryEventRelay extends EventRelay { // #endregion + // #region SSO + + private ssoUserProjectAccessUpdated({ + projectsRemoved, + projectsAdded, + userId, + }: RelayEventMap['sso-user-project-access-updated']) { + this.telemetry.track('Sso user project acess update', { + user_id: userId, + projects_removed: projectsRemoved, + projects_added: projectsAdded, + }); + } + + private ssoUserInstanceRoleUpdated({ + userId, + role, + }: RelayEventMap['sso-user-instance-role-updated']) { + this.telemetry.track('Sso user instance role update', { user_id: userId, role }); + } + + // #endregion + // #region Workflow private workflowCreated({ diff --git a/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts b/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts index 9fa1bba6c31..b23f60691a2 100644 --- a/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts +++ b/packages/cli/src/modules/provisioning.ee/__tests__/provisioning.service.ee.test.ts @@ -19,6 +19,7 @@ import { type Publisher } from '@/scaling/pubsub/publisher.service'; import { type ProjectService } from '@/services/project.service.ee'; import type { EntityManager } from '@n8n/typeorm'; import { type InstanceSettings } from 'n8n-core'; +import { type EventService } from '@/events/event.service'; const globalConfig = mock(); const settingsRepository = mock(); @@ -26,6 +27,7 @@ const userRepository = mock(); const entityManager = mock(); const projectRepository = mock({ manager: entityManager }); const projectService = mock(); +const eventService = mock(); const logger = mock(); const publisher = mock(); @@ -33,6 +35,7 @@ const roleRepository = mock(); const instanceSettings = mock(); const provisioningService = new ProvisioningService( + eventService, globalConfig, settingsRepository, projectRepository, @@ -282,6 +285,25 @@ describe('ProvisioningService', () => { { userId: user.id, roleSlug: 'global:owner' }, ); }); + + it('sends telemetry event', async () => { + const user = mock({ id: 'user-123', role: { slug: 'global:member' } }); + const roleSlug = 'global:owner'; + + roleRepository.findOneOrFail.mockResolvedValue( + mock({ slug: 'global:owner', roleType: 'global' }), + ); + + provisioningService['isInstanceRoleProvisioningEnabled'] = jest.fn().mockResolvedValue(true); + + await provisioningService.provisionInstanceRoleForUser(user, roleSlug); + + expect(eventService.emit).toHaveBeenCalledTimes(1); + expect(eventService.emit).toHaveBeenCalledWith('sso-user-instance-role-updated', { + userId: user.id, + role: roleSlug, + }); + }); }); describe('provisionProjectRolesForUser', () => { @@ -492,6 +514,33 @@ describe('ProvisioningService', () => { entityManager, ); }); + + it('sends telemetry event', async () => { + const userId = 'user-id-123'; + const projectIdToRole = ['project-1:viewer', 'project-2:editor']; + projectRepository.find.mockResolvedValueOnce([ + mock({ id: 'project-1' }), + mock({ id: 'project-2' }), + ]); + projectRepository.find.mockResolvedValueOnce([ + mock({ id: 'project-3', type: 'team' }), + ]); + roleRepository.find.mockResolvedValue([ + mock({ displayName: 'viewer', slug: 'project:viewer' }), + mock({ displayName: 'editor', slug: 'project:editor' }), + ]); + + provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true); + + await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole); + + expect(eventService.emit).toHaveBeenCalledTimes(1); + expect(eventService.emit).toHaveBeenCalledWith('sso-user-project-access-updated', { + projectsAdded: 2, + projectsRemoved: 1, + userId, + }); + }); }); describe('handleReloadSsoProvisioningConfiguration', () => { diff --git a/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts b/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts index 7890427a64e..93cc8f18c88 100644 --- a/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts +++ b/packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts @@ -15,6 +15,7 @@ import { jsonParse } from 'n8n-workflow'; import { PROVISIONING_PREFERENCES_DB_KEY } from './constants'; import { Not, In } from '@n8n/typeorm'; import { OnPubSubEvent } from '@n8n/decorators'; +import { EventService } from '@/events/event.service'; import { type Publisher } from '@/scaling/pubsub/publisher.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ZodError } from 'zod'; @@ -26,6 +27,7 @@ export class ProvisioningService { private provisioningConfig: ProvisioningConfigDto; constructor( + private readonly eventService: EventService, private readonly globalConfig: GlobalConfig, private readonly settingsRepository: SettingsRepository, private readonly projectRepository: ProjectRepository, @@ -108,6 +110,11 @@ export class ProvisioningService { // No need to update record if the role hasn't changed if (user.role.slug !== dbRole.slug) { await this.userRepository.update(user.id, { role: { slug: dbRole.slug } }); + + this.eventService.emit('sso-user-instance-role-updated', { + userId: user.id, + role: dbRole.slug, + }); } } @@ -242,6 +249,12 @@ export class ProvisioningService { await this.projectService.addUser(projectId, { userId, role: roleSlug }, tx); } }); + + this.eventService.emit('sso-user-project-access-updated', { + projectsAdded: validProjectIds.size, + projectsRemoved: projectsToRemoveAccessFrom.length, + userId, + }); } async patchConfig(rawConfig: unknown): Promise { diff --git a/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue index bbb69c10249..b803bafc0c9 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue +++ b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue @@ -25,6 +25,7 @@ const telemetry = useTelemetry(); const toast = useToast(); const message = useMessage(); const pageRedirectionHelper = usePageRedirectionHelper(); +const instanceId = useRootStore().instanceId; const savingForm = ref(false); @@ -151,6 +152,7 @@ async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false) if (isUserRoleProvisioningChanged()) { await saveProvisioningConfig(); + sendTrackingEventForUserProvisioning(); showUserRoleProvisioningDialog.value = false; } @@ -171,7 +173,7 @@ async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false) function sendTrackingEvent(config: OidcConfigDto) { const trackingMetadata = { - instance_id: useRootStore().instanceId, + instance_id: instanceId, authentication_method: SupportedProtocols.OIDC, discovery_endpoint: config.discoveryEndpoint, is_active: config.loginEnabled, @@ -179,6 +181,14 @@ function sendTrackingEvent(config: OidcConfigDto) { telemetry.track('User updated single sign on settings', trackingMetadata); } +function sendTrackingEventForUserProvisioning() { + telemetry.track('User updated provisioning settings', { + instance_id: instanceId, + authentication_method: SupportedProtocols.OIDC, + updated_setting: userRoleProvisioning.value, + }); +} + const goToUpgrade = () => { void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso'); }; diff --git a/packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue b/packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue index 925131aa41a..bece4b880c4 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue +++ b/packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue @@ -26,6 +26,7 @@ const telemetry = useTelemetry(); const toast = useToast(); const message = useMessage(); const pageRedirectionHelper = usePageRedirectionHelper(); +const instanceId = useRootStore().instanceId; const savingForm = ref(false); @@ -122,7 +123,7 @@ const sendTrackingEvent = (config?: SamlPreferences) => { return; } const trackingMetadata = { - instance_id: useRootStore().instanceId, + instance_id: instanceId, authentication_method: SupportedProtocols.SAML, identity_provider: config.metadataUrl ? 'metadata' : 'xml', is_active: config.loginEnabled ?? false, @@ -130,6 +131,14 @@ const sendTrackingEvent = (config?: SamlPreferences) => { telemetry.track('User updated single sign on settings', trackingMetadata); }; +const sendTrackingEventForUserProvisioning = () => { + telemetry.track('User updated provisioning settings', { + instance_id: instanceId, + authentication_method: SupportedProtocols.SAML, + updated_setting: userRoleProvisioning.value, + }); +}; + const promptConfirmDisablingSamlLogin = async () => { const confirmAction = await message.confirm( i18n.baseText('settings.sso.confirmMessage.beforeSaveForm.message', { @@ -221,6 +230,7 @@ const onSave = async (provisioningChangesConfirmed: boolean = false) => { if (isUserRoleProvisioningChanged()) { await saveProvisioningConfig(); + sendTrackingEventForUserProvisioning(); showUserRoleProvisioningDialog.value = false; }