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:
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user