chore: Add telemetry for user role provisioning (no changelog) (#22158)

This commit is contained in:
Konstantin Tieber 2025-11-24 12:14:27 +01:00 committed by GitHub
parent 6d281c68f3
commit bfbbf016f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 154 additions and 2 deletions

View File

@ -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'] = {

View File

@ -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': {

View File

@ -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({

View File

@ -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<GlobalConfig>();
const settingsRepository = mock<SettingsRepository>();
@ -26,6 +27,7 @@ const userRepository = mock<UserRepository>();
const entityManager = mock<EntityManager>();
const projectRepository = mock<ProjectRepository>({ manager: entityManager });
const projectService = mock<ProjectService>();
const eventService = mock<EventService>();
const logger = mock<Logger>();
const publisher = mock<Publisher>();
@ -33,6 +35,7 @@ const roleRepository = mock<RoleRepository>();
const instanceSettings = mock<InstanceSettings>();
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<User>({ id: 'user-123', role: { slug: 'global:member' } });
const roleSlug = 'global:owner';
roleRepository.findOneOrFail.mockResolvedValue(
mock<Role>({ 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<Project>({ id: 'project-1' }),
mock<Project>({ id: 'project-2' }),
]);
projectRepository.find.mockResolvedValueOnce([
mock<Project>({ id: 'project-3', type: 'team' }),
]);
roleRepository.find.mockResolvedValue([
mock<Role>({ displayName: 'viewer', slug: 'project:viewer' }),
mock<Role>({ 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', () => {

View File

@ -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<ProvisioningConfigDto> {

View File

@ -25,6 +25,7 @@ const telemetry = useTelemetry();
const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const instanceId = useRootStore().instanceId;
const savingForm = ref<boolean>(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');
};

View File

@ -26,6 +26,7 @@ const telemetry = useTelemetry();
const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const instanceId = useRootStore().instanceId;
const savingForm = ref<boolean>(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;
}