feat(catalog-backend-module-github): add config flag for verified email behavior (#33262)

Add a `defaultUserTransformer.useVerifiedEmails` config option to the
githubOrg provider, making the verified domain email behavior from #32997
opt-in rather than the default.

Signed-off-by: Jonathan Roebuck <jroebuck@spotify.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jonathan Roebuck
2026-03-10 16:55:53 +00:00
committed by GitHub
parent bed58405ab
commit 106d1b2a0e
8 changed files with 264 additions and 75 deletions
@@ -0,0 +1,20 @@
---
'@backstage/plugin-catalog-backend-module-github': patch
'@backstage/plugin-catalog-backend-module-github-org': patch
---
Added a `defaultUserTransformer.useVerifiedEmails` config option for the `githubOrg` provider. When set to `true`, the default user transformer prefers organization verified domain emails over the user's public GitHub email. Defaults to `false`, which uses only the public GitHub email.
This option has no effect when a custom user transformer is set via the `githubOrgEntityProviderTransformsExtensionPoint`.
```yaml
catalog:
providers:
githubOrg:
production:
githubUrl: https://github.com
orgs:
- my-org
defaultUserTransformer:
useVerifiedEmails: true
```
@@ -23,6 +23,7 @@ import {
} from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import {
buildDefaultUserTransformer,
GithubMultiOrgEntityProvider,
TeamTransformer,
UserTransformer,
@@ -116,7 +117,11 @@ export const catalogModuleGithubOrgEntityProvider = createBackendModule({
definition.schedule,
),
logger,
userTransformer,
userTransformer:
userTransformer ??
buildDefaultUserTransformer({
useVerifiedEmails: definition.useVerifiedEmails,
}),
teamTransformer,
alwaysUseDefaultNamespace:
definitions.length === 1 && definition.orgs?.length === 1,
@@ -141,6 +146,7 @@ function readDefinitionsFromConfig(rootConfig: Config): Array<{
organizationMembers?: number;
};
excludeSuspendedUsers?: boolean;
useVerifiedEmails?: boolean;
}> {
const baseKey = 'catalog.providers.githubOrg';
const baseConfig = rootConfig.getOptional(baseKey);
@@ -170,5 +176,7 @@ function readDefinitionsFromConfig(rootConfig: Config): Array<{
: undefined,
excludeSuspendedUsers:
c.getOptionalBoolean('excludeSuspendedUsers') ?? false,
useVerifiedEmails:
c.getOptionalBoolean('defaultUserTransformer.useVerifiedEmails') ?? false,
}));
}
+36
View File
@@ -270,6 +270,24 @@ export interface Config {
*/
excludeSuspendedUsers?: boolean;
/**
* (Optional) Configuration for the default user transformer.
* These options only apply when using the built-in transformer;
* they have no effect if a custom transformer is set via the
* extension point.
*/
defaultUserTransformer?: {
/**
* (Optional) Whether to prefer organization verified domain emails
* over the user's public GitHub email when populating user entity profiles.
* When enabled, the transformer uses the first verified domain email
* (with plus-addressed routing tags stripped) and falls back to the
* public email if none are available.
* Default: `false`.
*/
useVerifiedEmails?: boolean;
};
/**
* The refresh schedule to use.
*/
@@ -327,6 +345,24 @@ export interface Config {
*/
excludeSuspendedUsers?: boolean;
/**
* (Optional) Configuration for the default user transformer.
* These options only apply when using the built-in transformer;
* they have no effect if a custom transformer is set via the
* extension point.
*/
defaultUserTransformer?: {
/**
* (Optional) Whether to prefer organization verified domain emails
* over the user's public GitHub email when populating user entity profiles.
* When enabled, the transformer uses the first verified domain email
* (with plus-addressed routing tags stripped) and falls back to the
* public email if none are available.
* Default: `false`.
*/
useVerifiedEmails?: boolean;
};
/**
* The refresh schedule to use.
*/
@@ -25,16 +25,22 @@ import { SchedulerService } from '@backstage/backend-plugin-api';
import { SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { ScmLocationAnalyzer } from '@backstage/plugin-catalog-node';
import { UserEntity } from '@backstage/catalog-model';
// @public
export function buildDefaultUserTransformer(
options?: DefaultUserTransformerOptions,
): UserTransformer;
// @public
export const defaultOrganizationTeamTransformer: TeamTransformer;
// @public
export const defaultUserTransformer: (
item: GithubUser,
_ctx: TransformerContext,
) => Promise<UserEntity | undefined>;
export const defaultUserTransformer: UserTransformer;
// @public
export interface DefaultUserTransformerOptions {
useVerifiedEmails?: boolean;
}
// @public
const githubCatalogModule: BackendFeature;
@@ -37,6 +37,8 @@ export {
type GithubUser,
type UserTransformer,
defaultUserTransformer,
buildDefaultUserTransformer,
type DefaultUserTransformerOptions,
type TeamTransformer,
defaultOrganizationTeamTransformer,
type TransformerContext,
@@ -17,6 +17,7 @@
import { UserEntity } from '@backstage/catalog-model';
import { graphql } from '@octokit/graphql';
import {
buildDefaultUserTransformer,
defaultUserTransformer,
TransformerContext,
} from './defaultTransformers';
@@ -63,7 +64,7 @@ describe('defaultUserTransformer', () => {
expect(result.spec.memberOf).toEqual([]);
});
it('prefers verified domain email over regular email', async () => {
it('uses public email and ignores verified domain emails', async () => {
const result = (await defaultUserTransformer(
makeUser({
email: 'public@gmail.com',
@@ -72,21 +73,10 @@ describe('defaultUserTransformer', () => {
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('corp@company.com');
expect(result.spec.profile!.email).toBe('public@gmail.com');
});
it('strips plus-addressed tag from verified domain email', async () => {
const result = (await defaultUserTransformer(
makeUser({
organizationVerifiedDomainEmails: ['amckay+2jc29kv2@spotify.com'],
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('amckay@spotify.com');
});
it('uses verified domain email when regular email is absent', async () => {
it('sets no email when public email is absent even if verified emails exist', async () => {
const result = (await defaultUserTransformer(
makeUser({
organizationVerifiedDomainEmails: ['corp@company.com'],
@@ -94,30 +84,7 @@ describe('defaultUserTransformer', () => {
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('corp@company.com');
});
it('falls back to regular email when verified array is empty', async () => {
const result = (await defaultUserTransformer(
makeUser({
email: 'public@gmail.com',
organizationVerifiedDomainEmails: [],
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('public@gmail.com');
});
it('falls back to regular email when verified array is undefined', async () => {
const result = (await defaultUserTransformer(
makeUser({
email: 'public@gmail.com',
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('public@gmail.com');
expect(result.spec.profile!.email).toBeUndefined();
});
it('sets no email when both are absent', async () => {
@@ -129,3 +96,119 @@ describe('defaultUserTransformer', () => {
expect(result.spec.profile!.email).toBeUndefined();
});
});
describe('buildDefaultUserTransformer', () => {
describe('with useVerifiedEmails: false', () => {
const transformer = buildDefaultUserTransformer({
useVerifiedEmails: false,
});
it('uses public email and ignores verified domain emails', async () => {
const result = (await transformer(
makeUser({
email: 'public@gmail.com',
organizationVerifiedDomainEmails: ['corp@company.com'],
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('public@gmail.com');
});
it('sets no email when public email is absent', async () => {
const result = (await transformer(
makeUser({
organizationVerifiedDomainEmails: ['corp@company.com'],
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBeUndefined();
});
});
describe('with useVerifiedEmails: true', () => {
const transformer = buildDefaultUserTransformer({
useVerifiedEmails: true,
});
it('prefers verified domain email over public email', async () => {
const result = (await transformer(
makeUser({
email: 'public@gmail.com',
organizationVerifiedDomainEmails: ['corp@company.com'],
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('corp@company.com');
});
it('strips plus-addressed tag from verified domain email', async () => {
const result = (await transformer(
makeUser({
organizationVerifiedDomainEmails: ['amckay+2jc29kv2@spotify.com'],
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('amckay@spotify.com');
});
it('uses verified domain email when public email is absent', async () => {
const result = (await transformer(
makeUser({
organizationVerifiedDomainEmails: ['corp@company.com'],
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('corp@company.com');
});
it('falls back to public email when verified array is empty', async () => {
const result = (await transformer(
makeUser({
email: 'public@gmail.com',
organizationVerifiedDomainEmails: [],
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('public@gmail.com');
});
it('falls back to public email when verified array is undefined', async () => {
const result = (await transformer(
makeUser({
email: 'public@gmail.com',
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('public@gmail.com');
});
it('sets no email when both are absent', async () => {
const result = (await transformer(makeUser(), ctx)) as UserEntity;
expect(result.spec.profile!.email).toBeUndefined();
});
});
describe('with no options', () => {
const transformer = buildDefaultUserTransformer();
it('behaves the same as useVerifiedEmails: false', async () => {
const result = (await transformer(
makeUser({
email: 'public@gmail.com',
organizationVerifiedDomainEmails: ['corp@company.com'],
}),
ctx,
)) as UserEntity;
expect(result.spec.profile!.email).toBe('public@gmail.com');
});
});
});
@@ -54,42 +54,74 @@ export type TeamTransformer = (
ctx: TransformerContext,
) => Promise<Entity | undefined>;
/**
* Options for {@link buildDefaultUserTransformer}.
*
* @public
*/
export interface DefaultUserTransformerOptions {
/**
* Whether to prefer organization verified domain emails over the user's
* public GitHub email. When enabled, the transformer uses the first
* verified domain email (with plus-addressed routing tags stripped) and
* falls back to the public email if none are available.
*
* @defaultValue false
*/
useVerifiedEmails?: boolean;
}
/**
* Builds a user transformer with configurable email behavior.
*
* @public
*/
export function buildDefaultUserTransformer(
options?: DefaultUserTransformerOptions,
): UserTransformer {
return async (item, _ctx) => {
const entity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: item.login,
annotations: {
[ANNOTATION_GITHUB_USER_LOGIN]: item.login,
...(item.id && { [ANNOTATION_GITHUB_USER_ID]: item.id }),
},
},
spec: {
profile: {},
memberOf: [],
},
};
if (item.bio) entity.metadata.description = item.bio;
if (item.name) entity.spec.profile!.displayName = item.name;
if (options?.useVerifiedEmails) {
// GitHub returns verified domain emails as plus-addressed routing aliases
// (e.g. user+abc123@example.com). Strip the tag to get the real address.
const email = item.organizationVerifiedDomainEmails?.length
? item.organizationVerifiedDomainEmails[0].replace(/\+[^@]*/, '')
: item.email;
if (email) entity.spec.profile!.email = email;
} else {
if (item.email) entity.spec.profile!.email = item.email;
}
if (item.avatarUrl) entity.spec.profile!.picture = item.avatarUrl;
return entity;
};
}
/**
* Default transformer for GitHub users to UserEntity
*
* @public
*/
export const defaultUserTransformer = async (
item: GithubUser,
_ctx: TransformerContext,
): Promise<UserEntity | undefined> => {
const entity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: item.login,
annotations: {
[ANNOTATION_GITHUB_USER_LOGIN]: item.login,
...(item.id && { [ANNOTATION_GITHUB_USER_ID]: item.id }),
},
},
spec: {
profile: {},
memberOf: [],
},
};
if (item.bio) entity.metadata.description = item.bio;
if (item.name) entity.spec.profile!.displayName = item.name;
// GitHub returns verified domain emails as plus-addressed routing aliases
// (e.g. user+abc123@example.com). Strip the tag to get the real address.
const email = item.organizationVerifiedDomainEmails?.length
? item.organizationVerifiedDomainEmails[0].replace(/\+[^@]*/, '')
: item.email;
if (email) entity.spec.profile!.email = email;
if (item.avatarUrl) entity.spec.profile!.picture = item.avatarUrl;
return entity;
};
export const defaultUserTransformer: UserTransformer =
buildDefaultUserTransformer();
/**
* Default transformer for GitHub Team to GroupEntity
@@ -28,6 +28,8 @@ export {
export {
type UserTransformer,
defaultUserTransformer,
buildDefaultUserTransformer,
type DefaultUserTransformerOptions,
type TeamTransformer,
defaultOrganizationTeamTransformer,
type TransformerContext,