feat(core): Add telemetry events for private credentials (no-changelog) (#31343)

This commit is contained in:
Andreas Fitzek 2026-06-01 10:29:14 +02:00 committed by GitHub
parent 80a97bdcf3
commit 3dfca93a37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 504 additions and 2 deletions

View File

@ -6,6 +6,7 @@ import { mock } from 'jest-mock-extended';
import axios from 'axios';
import type { Response } from 'express';
import { OAuth1CredentialController } from '@/controllers/oauth/oauth1-credential.controller';
import { EventService } from '@/events/event.service';
import type { OAuthRequest } from '@/requests';
import { OauthService } from '@/oauth/oauth.service';
import { ExternalHooks } from '@/external-hooks';
@ -14,6 +15,7 @@ jest.mock('axios');
describe('OAuth1CredentialController', () => {
const oauthService = mockInstance(OauthService);
const eventService = mockInstance(EventService);
mockInstance(Logger);
mockInstance(ExternalHooks);
@ -168,10 +170,58 @@ describe('OAuth1CredentialController', () => {
'resolver-id',
{},
);
expect(eventService.emit).not.toHaveBeenCalledWith(
'private-credential-user-connected',
expect.anything(),
);
expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled();
expect(res.render).toHaveBeenCalledWith('oauth-callback');
});
it('should emit "private-credential-user-connected" when state.userId is a string', async () => {
const mockResolvedCredential = mock<CredentialsEntity>({
id: 'cred-1',
type: 'twitterOAuth1Api',
});
const mockState = {
token: 'token',
cid: '1',
userId: 'user-42',
origin: 'dynamic-credential' as const,
credentialResolverId: 'resolver-id',
authorizationHeader: 'Bearer token123',
createdAt: timestamp,
data: 'encrypted-data',
};
const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64');
const dynamicReq = mock<OAuthRequest.OAuth1Credential.Callback>({
query: {
oauth_verifier: 'verifier',
oauth_token: 'token',
state: dynamicState,
},
});
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'invalid' },
{ accessTokenUrl: 'https://example.domain/oauth/access_token' },
mockState,
]);
jest
.mocked(axios.post)
.mockResolvedValueOnce({ data: 'oauth_token=token&oauth_token_secret=secret' } as any);
oauthService.saveDynamicCredential.mockResolvedValueOnce(undefined);
await controller.handleCallback(dynamicReq, res);
expect(eventService.emit).toHaveBeenCalledWith('private-credential-user-connected', {
user: { id: 'user-42' },
credentialType: 'twitterOAuth1Api',
credentialId: 'cred-1',
});
});
it('should render error when credentialResolverId is missing for dynamic credential', async () => {
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockState = {

View File

@ -6,6 +6,7 @@ import { mock } from 'jest-mock-extended';
import type { Response } from 'express';
import { UserError } from 'n8n-workflow';
import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller';
import { EventService } from '@/events/event.service';
import type { OAuthRequest } from '@/requests';
import { OauthService } from '@/oauth/oauth.service';
import { ExternalHooks } from '@/external-hooks';
@ -19,6 +20,7 @@ describe('OAuth2CredentialController', () => {
const oauthService = mockInstance(OauthService);
const externalHooks = mockInstance(ExternalHooks);
const oauthJweServiceProxy = mockInstance(OAuthJweServiceProxy);
const eventService = mockInstance(EventService);
mockInstance(Logger);
@ -158,7 +160,7 @@ describe('OAuth2CredentialController', () => {
}) as any,
);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1', type: 'genericOAuth2' });
const mockDecryptedData = { csrfSecret: 'csrf-secret', existing: 'data' };
const mockState = {
token: 'token',
@ -209,6 +211,11 @@ describe('OAuth2CredentialController', () => {
'resolver-id',
{},
);
expect(eventService.emit).toHaveBeenCalledWith('private-credential-user-connected', {
user: { id: '123' },
credentialType: 'genericOAuth2',
credentialId: '1',
});
expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled();
expect(res.render).toHaveBeenCalledWith('oauth-callback');
});
@ -830,7 +837,7 @@ describe('OAuth2CredentialController', () => {
jest
.mocked(ClientOAuth2)
.mockImplementation(() => ({ code: { getToken: mockGetToken } }) as any);
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1' });
const mockResolvedCredential = mock<CredentialsEntity>({ id: '1', type: 'genericOAuth2' });
oauthService.resolveCredential.mockResolvedValueOnce([
mockResolvedCredential,
{ csrfSecret: 'csrf-secret' },
@ -872,6 +879,11 @@ describe('OAuth2CredentialController', () => {
'resolver-1',
{ tenant: 'acme' },
);
expect(eventService.emit).toHaveBeenCalledWith('private-credential-user-connected', {
user: { id: '123' },
credentialType: 'genericOAuth2',
credentialId: '1',
});
expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled();
});
});

View File

@ -4,6 +4,7 @@ import axios from 'axios';
import { Response } from 'express';
import { ensureError, jsonStringify } from 'n8n-workflow';
import { EventService } from '@/events/event.service';
import { OAuthRequest } from '@/requests';
import { OauthService, type OAuth1CredentialData } from '@/oauth/oauth.service';
@ -13,6 +14,7 @@ export class OAuth1CredentialController {
constructor(
private readonly oauthService: OauthService,
private readonly logger: Logger,
private readonly eventService: EventService,
) {}
/** Get Authorization url */
@ -89,6 +91,15 @@ export class OAuth1CredentialController {
state.credentialResolverId,
(state.authMetadata as Record<string, unknown>) ?? {},
);
if (typeof state.userId === 'string') {
this.eventService.emit('private-credential-user-connected', {
user: { id: state.userId },
credentialType: credential.type,
credentialId: credential.id,
});
}
return res.render('oauth-callback');
}
} catch (e) {

View File

@ -9,6 +9,7 @@ import split from 'lodash/split';
import type { ICredentialDataDecryptedObject, IDataObject } from 'n8n-workflow';
import { ensureError, jsonParse, jsonStringify } from 'n8n-workflow';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy';
import { OauthService, OauthVersion } from '@/oauth/oauth.service';
@ -21,6 +22,7 @@ export class OAuth2CredentialController {
private readonly logger: Logger,
private readonly externalHooks: ExternalHooks,
private readonly oauthJweServiceProxy: OAuthJweServiceProxy,
private readonly eventService: EventService,
) {}
/** Get Authorization url */
@ -133,6 +135,15 @@ export class OAuth2CredentialController {
state.credentialResolverId,
(state.authMetadata as Record<string, unknown>) ?? {},
);
if (typeof state.userId === 'string') {
this.eventService.emit('private-credential-user-connected', {
user: { id: state.userId },
credentialType: credential.type,
credentialId: credential.id,
});
}
return res.render('oauth-callback');
}
} catch (e) {

View File

@ -175,6 +175,45 @@ describe('CredentialsController', () => {
expect(eventName).toBe('credentials-created');
expect(eventPayload).toMatchObject({ jweEnabled: true });
});
it('should emit "private-credential-created" when credential is created as resolvable', async () => {
const newCredentialsPayload = createNewCredentialsPayload();
req.body = newCredentialsPayload;
const { data, ...payloadWithoutData } = newCredentialsPayload;
const createdCredentials = createdCredentialsWithScopes({
...payloadWithoutData,
isResolvable: true,
});
const project = mock<Project>({ id: 'p1', type: 'team' });
createUnmanagedCredentialSpy.mockResolvedValue(createdCredentials);
findCredentialOwningProjectSpy.mockResolvedValue(project);
await credentialsController.createCredentials(req, res, newCredentialsPayload);
expect(emitSpy).toHaveBeenCalledWith('private-credential-created', {
user: req.user,
credentialType: createdCredentials.type,
credentialId: createdCredentials.id,
projectId: project.id,
projectType: project.type,
});
});
it('should not emit "private-credential-created" when credential is not resolvable', async () => {
const newCredentialsPayload = createNewCredentialsPayload();
req.body = newCredentialsPayload;
const { data, ...payloadWithoutData } = newCredentialsPayload;
const createdCredentials = createdCredentialsWithScopes(payloadWithoutData);
createUnmanagedCredentialSpy.mockResolvedValue(createdCredentials);
findCredentialOwningProjectSpy.mockResolvedValue(mock<Project>());
await credentialsController.createCredentials(req, res, newCredentialsPayload);
const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]);
expect(emittedEventNames).not.toContain('private-credential-created');
});
});
describe('updateCredentials', () => {
@ -584,5 +623,129 @@ describe('CredentialsController', () => {
});
expect(updateSpy).not.toHaveBeenCalled();
});
it('should emit "private-credential-toggled-to-private" when toggling static to private', async () => {
const ownerReq = {
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
params: { credentialId },
body: { data: { apiKey: 'k' }, isResolvable: true },
} as unknown as CredentialRequest.Update;
const staticCredential = mock<CredentialsEntity>({
...existingCredential,
isResolvable: false,
});
credentialsFinderService.findCredentialForUser.mockResolvedValue(staticCredential);
createEncryptedDataSpy.mockResolvedValue(getEncryptedCredential(true));
updateSpy.mockResolvedValue({ ...staticCredential, isResolvable: true });
await credentialsController.updateCredentials(ownerReq);
expect(emitSpy).toHaveBeenCalledWith('private-credential-toggled-to-private', {
user: ownerReq.user,
credentialType: staticCredential.type,
credentialId: staticCredential.id,
});
const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]);
expect(emittedEventNames).not.toContain('private-credential-toggled-to-static');
});
it('should emit "private-credential-toggled-to-static" when toggling private to static', async () => {
const ownerReq = {
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
params: { credentialId },
body: { data: { apiKey: 'k' }, isResolvable: false },
} as unknown as CredentialRequest.Update;
const privateCredential = mock<CredentialsEntity>({
...existingCredential,
isResolvable: true,
});
credentialsFinderService.findCredentialForUser.mockResolvedValue(privateCredential);
createEncryptedDataSpy.mockResolvedValue(getEncryptedCredential(false));
updateSpy.mockResolvedValue({ ...privateCredential, isResolvable: false });
await credentialsController.updateCredentials(ownerReq);
expect(emitSpy).toHaveBeenCalledWith('private-credential-toggled-to-static', {
user: ownerReq.user,
credentialType: privateCredential.type,
credentialId: privateCredential.id,
});
const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]);
expect(emittedEventNames).not.toContain('private-credential-toggled-to-private');
});
it('should not emit toggle events when resolvable state is unchanged', async () => {
const ownerReq = {
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
params: { credentialId },
body: { data: { apiKey: 'k' }, isResolvable: true },
} as unknown as CredentialRequest.Update;
const privateCredential = mock<CredentialsEntity>({
...existingCredential,
isResolvable: true,
});
credentialsFinderService.findCredentialForUser.mockResolvedValue(privateCredential);
createEncryptedDataSpy.mockResolvedValue(getEncryptedCredential(true));
updateSpy.mockResolvedValue({ ...privateCredential, isResolvable: true });
await credentialsController.updateCredentials(ownerReq);
const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]);
expect(emittedEventNames).not.toContain('private-credential-toggled-to-private');
expect(emittedEventNames).not.toContain('private-credential-toggled-to-static');
});
});
describe('deleteCredentials', () => {
const credentialId = 'cred-del-1';
it('should emit "private-credential-deleted" when deleting a resolvable credential', async () => {
const privateCredential = mock<CredentialsEntity>({
id: credentialId,
type: 'gmailOAuth2',
isResolvable: true,
});
credentialsFinderService.findCredentialForUser.mockResolvedValue(privateCredential);
jest.spyOn(credentialsService, 'delete').mockResolvedValue(undefined);
const deleteReq = {
user: { id: 'u1' },
params: { credentialId },
} as unknown as CredentialRequest.Delete;
await credentialsController.deleteCredentials(deleteReq);
expect(emitSpy).toHaveBeenCalledWith('private-credential-deleted', {
user: deleteReq.user,
credentialType: privateCredential.type,
credentialId: privateCredential.id,
});
});
it('should not emit "private-credential-deleted" when deleting a static credential', async () => {
const staticCredential = mock<CredentialsEntity>({
id: credentialId,
type: 'gmailOAuth2',
isResolvable: false,
});
credentialsFinderService.findCredentialForUser.mockResolvedValue(staticCredential);
jest.spyOn(credentialsService, 'delete').mockResolvedValue(undefined);
const deleteReq = {
user: { id: 'u1' },
params: { credentialId },
} as unknown as CredentialRequest.Delete;
await credentialsController.deleteCredentials(deleteReq);
const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]);
expect(emittedEventNames).not.toContain('private-credential-deleted');
});
});
});

View File

@ -179,6 +179,16 @@ export class CredentialsController {
jweEnabled: payload.data.jweEnabled === true,
});
if (newCredential.isResolvable) {
this.eventService.emit('private-credential-created', {
user: req.user,
credentialType: newCredential.type,
credentialId: newCredential.id,
projectId: project?.id,
projectType: project?.type,
});
}
return newCredential;
}
@ -279,6 +289,22 @@ export class CredentialsController {
true,
});
const wasResolvable = Boolean(credential.isResolvable);
const willBeResolvable = Boolean(newCredentialData.isResolvable);
if (!wasResolvable && willBeResolvable) {
this.eventService.emit('private-credential-toggled-to-private', {
user: req.user,
credentialType: credential.type,
credentialId: credential.id,
});
} else if (wasResolvable && !willBeResolvable) {
this.eventService.emit('private-credential-toggled-to-static', {
user: req.user,
credentialType: credential.type,
credentialId: credential.id,
});
}
const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id);
return { ...rest, scopes };
@ -313,6 +339,14 @@ export class CredentialsController {
credentialId: credential.id,
});
if (credential.isResolvable) {
this.eventService.emit('private-credential-deleted', {
user: req.user,
credentialType: credential.type,
credentialId: credential.id,
});
}
return true;
}

View File

@ -993,6 +993,119 @@ describe('TelemetryEventRelay', () => {
credential_id: 'cred123',
});
});
it('should track on `private-credential-created` event', () => {
const event: RelayEventMap['private-credential-created'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: { slug: GLOBAL_OWNER_ROLE.slug },
},
credentialId: 'cred123',
credentialType: 'gmailOAuth2',
projectId: 'project456',
projectType: 'personal',
};
eventService.emit('private-credential-created', event);
expect(telemetry.track).toHaveBeenCalledWith('User created private credential', {
user_id: 'user123',
user_role: GLOBAL_OWNER_ROLE.slug,
credential_type: 'gmailOAuth2',
credential_id: 'cred123',
project_id: 'project456',
project_type: 'personal',
});
});
it('should track on `private-credential-toggled-to-private` event', () => {
const event: RelayEventMap['private-credential-toggled-to-private'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: { slug: GLOBAL_OWNER_ROLE.slug },
},
credentialId: 'cred123',
credentialType: 'gmailOAuth2',
};
eventService.emit('private-credential-toggled-to-private', event);
expect(telemetry.track).toHaveBeenCalledWith('User made credential private', {
user_id: 'user123',
user_role: GLOBAL_OWNER_ROLE.slug,
credential_type: 'gmailOAuth2',
credential_id: 'cred123',
});
});
it('should track on `private-credential-toggled-to-static` event', () => {
const event: RelayEventMap['private-credential-toggled-to-static'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: { slug: GLOBAL_OWNER_ROLE.slug },
},
credentialId: 'cred123',
credentialType: 'gmailOAuth2',
};
eventService.emit('private-credential-toggled-to-static', event);
expect(telemetry.track).toHaveBeenCalledWith('User made credential static', {
user_id: 'user123',
user_role: GLOBAL_OWNER_ROLE.slug,
credential_type: 'gmailOAuth2',
credential_id: 'cred123',
});
});
it('should track on `private-credential-deleted` event', () => {
const event: RelayEventMap['private-credential-deleted'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: { slug: GLOBAL_OWNER_ROLE.slug },
},
credentialId: 'cred123',
credentialType: 'gmailOAuth2',
};
eventService.emit('private-credential-deleted', event);
expect(telemetry.track).toHaveBeenCalledWith('User deleted private credential', {
user_id: 'user123',
user_role: GLOBAL_OWNER_ROLE.slug,
credential_type: 'gmailOAuth2',
credential_id: 'cred123',
});
});
it('should track on `private-credential-user-connected` event', () => {
const event: RelayEventMap['private-credential-user-connected'] = {
user: { id: 'user123' },
credentialId: 'cred123',
credentialType: 'gmailOAuth2',
};
eventService.emit('private-credential-user-connected', event);
expect(telemetry.track).toHaveBeenCalledWith('User connected to private credential', {
user_id: 'user123',
user_role: undefined,
credential_type: 'gmailOAuth2',
credential_id: 'cred123',
});
});
});
describe('LDAP events', () => {

View File

@ -436,6 +436,38 @@ export type RelayEventMap = {
credentialId: string;
};
'private-credential-created': {
user: UserLike;
credentialType: string;
credentialId: string;
projectId?: string;
projectType?: string;
};
'private-credential-toggled-to-private': {
user: UserLike;
credentialType: string;
credentialId: string;
};
'private-credential-toggled-to-static': {
user: UserLike;
credentialType: string;
credentialId: string;
};
'private-credential-deleted': {
user: UserLike;
credentialType: string;
credentialId: string;
};
'private-credential-user-connected': {
user: UserLike;
credentialType: string;
credentialId: string;
};
// #endregion
// #region Community package

View File

@ -128,6 +128,13 @@ export class TelemetryEventRelay extends EventRelay {
'credentials-updated': (event) => this.credentialsUpdated(event),
'credentials-deleted': (event) => this.credentialsDeleted(event),
'credentials-user-disconnected': (event) => this.credentialsUserDisconnected(event),
'private-credential-created': (event) => this.privateCredentialCreated(event),
'private-credential-toggled-to-private': (event) =>
this.privateCredentialToggledToPrivate(event),
'private-credential-toggled-to-static': (event) =>
this.privateCredentialToggledToStatic(event),
'private-credential-deleted': (event) => this.privateCredentialDeleted(event),
'private-credential-user-connected': (event) => this.privateCredentialUserConnected(event),
'ldap-general-sync-finished': (event) => this.ldapGeneralSyncFinished(event),
'ldap-settings-updated': (event) => this.ldapSettingsUpdated(event),
'ldap-login-sync-failed': (event) => this.ldapLoginSyncFailed(event),
@ -637,6 +644,75 @@ export class TelemetryEventRelay extends EventRelay {
});
}
private privateCredentialCreated({
user,
credentialId,
credentialType,
projectId,
projectType,
}: RelayEventMap['private-credential-created']) {
this.telemetry.track('User created private credential', {
user_id: user.id,
user_role: user.role?.slug,
credential_type: credentialType,
credential_id: credentialId,
project_id: projectId,
project_type: projectType,
});
}
private privateCredentialToggledToPrivate({
user,
credentialId,
credentialType,
}: RelayEventMap['private-credential-toggled-to-private']) {
this.telemetry.track('User made credential private', {
user_id: user.id,
user_role: user.role?.slug,
credential_type: credentialType,
credential_id: credentialId,
});
}
private privateCredentialToggledToStatic({
user,
credentialId,
credentialType,
}: RelayEventMap['private-credential-toggled-to-static']) {
this.telemetry.track('User made credential static', {
user_id: user.id,
user_role: user.role?.slug,
credential_type: credentialType,
credential_id: credentialId,
});
}
private privateCredentialDeleted({
user,
credentialId,
credentialType,
}: RelayEventMap['private-credential-deleted']) {
this.telemetry.track('User deleted private credential', {
user_id: user.id,
user_role: user.role?.slug,
credential_type: credentialType,
credential_id: credentialId,
});
}
private privateCredentialUserConnected({
user,
credentialId,
credentialType,
}: RelayEventMap['private-credential-user-connected']) {
this.telemetry.track('User connected to private credential', {
user_id: user.id,
user_role: user.role?.slug,
credential_type: credentialType,
credential_id: credentialId,
});
}
// #endregion
// #region LDAP