Merge pull request #31357 from VDVsx/excludeSuspendUsers-GHEnterprise

Exclude suspend users gh enterprise
This commit is contained in:
Ben Lambert
2025-12-02 11:31:16 +01:00
committed by GitHub
9 changed files with 236 additions and 50 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-catalog-backend-module-github-org': patch
'@backstage/plugin-catalog-backend-module-github': patch
---
Introduce new configuration option to exclude suspended users from GitHub Enterprise instances.
When its set to true, suspended users wont be returned when querying the organization users for GitHub Enterprise instances.
Note that this option should be used only against GitHub Enterprise instances, the property does not exist in the github.com GraphQL schema, setting it will cause a schema validation error and the syncing of users will fail.
+3
View File
@@ -90,6 +90,7 @@ catalog:
initialDelay: { seconds: 30 }
frequency: { hours: 1 }
timeout: { minutes: 50 }
excludeSuspendedUsers: true
```
Directly under the `githubOrg` is a list of configurations, each entry is a structure with the following elements:
@@ -106,6 +107,8 @@ Directly under the `githubOrg` is a list of configurations, each entry is a stru
Reducing page sizes will result in more API calls and slightly longer sync times, but will prevent API resource limits for organizations with large number of teams and members.
- `excludeSuspendedUsers` (optional): Whether to exclude suspended users when querying organization users. Only for GitHub Enterprise instances. Will error if used against GitHub.com API.
### Events Support
The catalog module for GitHub Org comes with events support enabled.
@@ -121,6 +121,7 @@ export const catalogModuleGithubOrgEntityProvider = createBackendModule({
alwaysUseDefaultNamespace:
definitions.length === 1 && definition.orgs?.length === 1,
pageSizes: definition.pageSizes,
excludeSuspendedUsers: definition.excludeSuspendedUsers,
}),
);
}
@@ -139,6 +140,7 @@ function readDefinitionsFromConfig(rootConfig: Config): Array<{
teamMembers?: number;
organizationMembers?: number;
};
excludeSuspendedUsers?: boolean;
}> {
const baseKey = 'catalog.providers.githubOrg';
const baseConfig = rootConfig.getOptional(baseKey);
@@ -166,5 +168,7 @@ function readDefinitionsFromConfig(rootConfig: Config): Array<{
),
}
: undefined,
excludeSuspendedUsers:
c.getOptionalBoolean('excludeSuspendedUsers') ?? false,
}));
}
+12
View File
@@ -264,6 +264,12 @@ export interface Config {
*/
orgs?: string[];
/**
* (Optional) Only for GitHub Enterprise. Whether to exclude suspended users when querying organization users.
* Default: `false`.
*/
excludeSuspendedUsers?: boolean;
/**
* The refresh schedule to use.
*/
@@ -315,6 +321,12 @@ export interface Config {
*/
orgs?: string[];
/**
* (Optional) Only for GitHub Enterprise. Whether to exclude suspended users when querying organization users.
* Default: `false`.
*/
excludeSuspendedUsers?: boolean;
/**
* The refresh schedule to use.
*/
@@ -151,6 +151,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
teamTransformer?: TeamTransformer;
alwaysUseDefaultNamespace?: boolean;
pageSizes?: Partial<GithubPageSizes>;
excludeSuspendedUsers?: boolean;
});
connect(connection: EntityProviderConnection): Promise<void>;
// (undocumented)
@@ -166,6 +167,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
export interface GithubMultiOrgEntityProviderOptions {
alwaysUseDefaultNamespace?: boolean;
events?: EventsService;
excludeSuspendedUsers?: boolean;
githubCredentialsProvider?: GithubCredentialsProvider;
githubUrl: string;
id: string;
@@ -227,6 +229,7 @@ export class GithubOrgEntityProvider implements EntityProvider {
githubCredentialsProvider?: GithubCredentialsProvider;
userTransformer?: UserTransformer;
teamTransformer?: TeamTransformer;
excludeSuspendedUsers?: boolean;
});
connect(connection: EntityProviderConnection): Promise<void>;
// (undocumented)
@@ -244,6 +247,7 @@ export type GitHubOrgEntityProviderOptions = GithubOrgEntityProviderOptions;
// @public
export interface GithubOrgEntityProviderOptions {
events?: EventsService;
excludeSuspendedUsers?: boolean;
githubCredentialsProvider?: GithubCredentialsProvider;
id: string;
logger: LoggerService;
@@ -306,6 +310,7 @@ export type GithubUser = {
email?: string;
name?: string;
organizationVerifiedDomainEmails?: string[];
suspendedAt?: string;
};
// @public
@@ -166,6 +166,54 @@ describe('github', () => {
getOrganizationUsers(graphql, 'a', 'token'),
).resolves.toEqual(output);
});
it('reads members excluding suspended users', async () => {
const input: QueryResponse = {
organization: {
membersWithRole: {
pageInfo: { hasNextPage: false },
nodes: [
{
login: 'a',
name: 'b',
bio: 'c',
email: 'd',
avatarUrl: 'e',
suspendedAt: '2025-01-01',
},
{
login: 'a',
name: 'b',
bio: 'c',
email: 'd',
avatarUrl: 'e',
suspendedAt: undefined,
},
],
},
},
};
const output = {
users: [
expect.objectContaining({
metadata: expect.objectContaining({ name: 'a', description: 'c' }),
spec: {
profile: { displayName: 'b', email: 'd', picture: 'e' },
memberOf: [],
},
}),
],
};
server.use(
graphqlMsw.query('users', () => HttpResponse.json({ data: input })),
);
await expect(
getOrganizationUsers(graphql, 'a', 'token', undefined, undefined, true),
).resolves.toEqual(output);
});
});
describe('getOrganizationUsers using custom UserTransformer', () => {
@@ -279,6 +327,63 @@ describe('github', () => {
expect(users.users).toHaveLength(1);
expect(users).toEqual(output);
});
it('reads members including suspended users', async () => {
const input: QueryResponse = {
organization: {
membersWithRole: {
pageInfo: { hasNextPage: false },
nodes: [
{
login: 'a',
name: 'b',
bio: 'c',
email: 'd',
avatarUrl: 'e',
},
{
login: 'ab',
name: 'bb',
bio: 'cc',
email: 'dd',
avatarUrl: 'ee',
suspendedAt: '2025-01-01',
},
],
},
},
};
const output = {
users: [
expect.objectContaining({
metadata: expect.objectContaining({
name: 'a-custom',
}),
}),
expect.objectContaining({
metadata: expect.objectContaining({
name: 'ab-custom',
}),
}),
],
};
server.use(
graphqlMsw.query('users', () => HttpResponse.json({ data: input })),
);
await expect(
getOrganizationUsers(
graphql,
'a',
'token',
customUserTransformer,
undefined,
false,
),
).resolves.toEqual(output);
});
});
describe('getOrganizationTeams using default TeamTransformer', () => {
@@ -117,6 +117,7 @@ export type GithubUser = {
email?: string;
name?: string;
organizationVerifiedDomainEmails?: string[];
suspendedAt?: string;
};
/**
@@ -181,6 +182,7 @@ export type Connection<T> = {
* @param tokenType - The type of GitHub credential
* @param userTransformer - Optional transformer for user entities
* @param pageSizes - Optional page sizes configuration
* @param excludeSuspendedUsers - Optional flag to exclude suspended users (only for GitHub Enterprise instances)
*/
export async function getOrganizationUsers(
client: typeof graphql,
@@ -188,7 +190,9 @@ export async function getOrganizationUsers(
tokenType: GithubCredentialType,
userTransformer: UserTransformer = defaultUserTransformer,
pageSizes: GithubPageSizes = DEFAULT_PAGE_SIZES,
excludeSuspendedUsers: boolean = false,
): Promise<{ users: Entity[] }> {
const suspendedAtField = excludeSuspendedUsers ? 'suspendedAt,' : '';
const query = `
query users($org: String!, $email: Boolean!, $cursor: String, $organizationMembersPageSize: Int!) {
organization(login: $org) {
@@ -200,6 +204,7 @@ export async function getOrganizationUsers(
email @include(if: $email),
login,
name,
${suspendedAtField}
organizationVerifiedDomainEmails(login: $org)
}
}
@@ -209,18 +214,19 @@ export async function getOrganizationUsers(
// There is no user -> teams edge, so we leave the memberships empty for
// now and let the team iteration handle it instead
const users = await queryWithPaging(
const users = await queryWithPaging({
client,
query,
org,
r => r.organization?.membersWithRole,
userTransformer,
{
connection: r => r.organization?.membersWithRole,
transformer: userTransformer,
variables: {
org,
email: tokenType === 'token',
organizationMembersPageSize: pageSizes.organizationMembers,
},
);
filter: u => (excludeSuspendedUsers ? !u.suspendedAt : true),
});
return { users };
}
@@ -305,18 +311,18 @@ export async function getOrganizationTeams(
return await teamTransformer(team, ctx);
};
const teams = await queryWithPaging(
const teams = await queryWithPaging({
client,
query,
org,
r => r.organization?.teams,
materialisedTeams,
{
connection: r => r.organization?.teams,
transformer: materialisedTeams,
variables: {
org,
teamsPageSize: pageSizes.teams,
membersPageSize: pageSizes.teamMembers,
},
);
});
return { teams };
}
@@ -399,19 +405,19 @@ export async function getOrganizationTeamsFromUsers(
return await teamTransformer(team, ctx);
};
const teams = await queryWithPaging(
const teams = await queryWithPaging({
client,
query,
org,
r => r.organization?.teams,
materialisedTeams,
{
connection: r => r.organization?.teams,
transformer: materialisedTeams,
variables: {
org,
userLogins,
teamsPageSize: pageSizes.teams,
membersPageSize: pageSizes.teamMembers,
},
);
});
return { teams };
}
@@ -458,14 +464,14 @@ export async function getOrganizationTeamsForUser(
return await teamTransformer(team, ctx);
};
const teams = await queryWithPaging(
const teams = await queryWithPaging({
client,
query,
org,
r => r.organization?.teams,
materialisedTeams,
{ org, userLogins: [userLogin], teamsPageSize: pageSizes.teams },
);
connection: r => r.organization?.teams,
transformer: materialisedTeams,
variables: { org, userLogins: [userLogin], teamsPageSize: pageSizes.teams },
});
return { teams };
}
@@ -486,14 +492,14 @@ export async function getOrganizationsFromUser(
}
}`;
const orgs = await queryWithPaging(
const orgs = await queryWithPaging({
client,
query,
'',
r => r.user?.organizations,
async o => o.login,
{ user },
);
org: '',
connection: r => r.user?.organizations,
transformer: async o => o.login,
variables: { user },
});
return { orgs };
}
@@ -632,14 +638,18 @@ export async function getOrganizationRepositories(
}
}`;
const repositories = await queryWithPaging(
const repositories = await queryWithPaging({
client,
query,
org,
r => r.repositoryOwner?.repositories,
async x => x,
{ org, catalogPathRef, repositoriesPageSize: pageSizes.repositories },
);
connection: r => r.repositoryOwner?.repositories,
transformer: async x => x,
variables: {
org,
catalogPathRef,
repositoriesPageSize: pageSizes.repositories,
},
});
return { repositories };
}
@@ -727,14 +737,14 @@ export async function getTeamMembers(
}
}`;
const members = await queryWithPaging(
const members = await queryWithPaging({
client,
query,
org,
r => r.organization?.team?.members,
async user => user,
{ org, teamSlug, membersPageSize: pageSizes.teamMembers },
);
connection: r => r.organization?.team?.members,
transformer: async user => user,
variables: { org, teamSlug, membersPageSize: pageSizes.teamMembers },
});
return { members };
}
@@ -748,31 +758,36 @@ export async function getTeamMembers(
*
* Requires that the query accepts a $cursor variable.
*
* @param client - The octokit client
* @param query - The query to execute
* @param org - The slug of the org to read
* @param connection - A function that, given the response, picks out the actual
* @param params - Object containing all parameters
* @param params.client - The octokit client
* @param params.query - The query to execute
* @param params.org - The slug of the org to read
* @param params.connection - A function that, given the response, picks out the actual
* Connection object that's being iterated
* @param transformer - A function that, given one of the nodes in the Connection,
* @param params.transformer - A function that, given one of the nodes in the Connection,
* returns the model mapped form of it
* @param variables - The variable values that the query needs, minus the cursor
* @param params.variables - The variable values that the query needs, minus the cursor
* @param params.filter - An optional filter function to filter the nodes before transforming them
*/
export async function queryWithPaging<
GraphqlType,
OutputType,
Variables extends {},
Response = QueryResponse,
>(
client: typeof graphql,
query: string,
org: string,
connection: (response: Response) => Connection<GraphqlType> | undefined,
>(params: {
client: typeof graphql;
query: string;
org: string;
connection: (response: Response) => Connection<GraphqlType> | undefined;
transformer: (
item: GraphqlType,
ctx: TransformerContext,
) => Promise<OutputType | undefined>,
variables: Variables,
): Promise<OutputType[]> {
) => Promise<OutputType | undefined>;
variables: Variables;
filter?: (item: GraphqlType) => boolean;
}): Promise<OutputType[]> {
const { client, query, org, connection, transformer, variables, filter } =
params;
const result: OutputType[] = [];
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
@@ -789,6 +804,9 @@ export async function queryWithPaging<
}
for (const node of conn.nodes) {
if (filter && !filter(node)) {
continue;
}
const transformedNode = await transformer(node, {
client,
query,
@@ -174,6 +174,14 @@ export interface GithubMultiOrgEntityProviderOptions {
* Reduce these values if hitting RESOURCE_LIMITS_EXCEEDED errors.
*/
pageSizes?: Partial<GithubPageSizes>;
/**
* Optionally exclude suspended users when querying organization users.
* @defaultValue false
* @remarks
* Only for GitHub Enterprise instances. Will error if used against GitHub.com API.
*/
excludeSuspendedUsers?: boolean;
}
type CreateDeltaOperation = (entities: Entity[]) => {
@@ -221,6 +229,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
events: options.events,
alwaysUseDefaultNamespace: options.alwaysUseDefaultNamespace,
pageSizes: options.pageSizes,
excludeSuspendedUsers: options.excludeSuspendedUsers,
});
provider.schedule(options.schedule);
@@ -241,6 +250,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
teamTransformer?: TeamTransformer;
alwaysUseDefaultNamespace?: boolean;
pageSizes?: Partial<GithubPageSizes>;
excludeSuspendedUsers?: boolean;
},
) {}
@@ -306,6 +316,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
tokenType,
this.options.userTransformer,
pageSizes,
this.options.excludeSuspendedUsers,
);
const { teams } = await getOrganizationTeams(
@@ -458,6 +469,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
tokenType,
this.options.userTransformer,
pageSizes,
this.options.excludeSuspendedUsers,
);
const { teams } = await getOrganizationTeams(
@@ -692,6 +704,7 @@ export class GithubMultiOrgEntityProvider implements EntityProvider {
tokenType,
this.options.userTransformer,
pageSizes,
this.options.excludeSuspendedUsers,
);
const usersFromChangedGroup = isGroupEntity(team)
@@ -53,6 +53,7 @@ import {
createGraphqlClient,
createRemoveEntitiesOperation,
createReplaceEntitiesOperation,
DEFAULT_PAGE_SIZES,
DeferredEntitiesBuilder,
getOrganizationTeam,
getOrganizationTeams,
@@ -130,6 +131,14 @@ export interface GithubOrgEntityProviderOptions {
* Optionally include a team transformer for transforming from GitHub teams to Group Entities
*/
teamTransformer?: TeamTransformer;
/**
* Optionally exclude suspended users when querying organization users.
* @defaultValue false
* @remarks
* Only for GitHub Enterprise instances. Will error if used against GitHub.com API.
*/
excludeSuspendedUsers?: boolean;
}
/**
@@ -167,6 +176,7 @@ export class GithubOrgEntityProvider implements EntityProvider {
userTransformer: options.userTransformer,
teamTransformer: options.teamTransformer,
events: options.events,
excludeSuspendedUsers: options.excludeSuspendedUsers,
});
provider.schedule(options.schedule);
@@ -184,6 +194,7 @@ export class GithubOrgEntityProvider implements EntityProvider {
githubCredentialsProvider?: GithubCredentialsProvider;
userTransformer?: UserTransformer;
teamTransformer?: TeamTransformer;
excludeSuspendedUsers?: boolean;
},
) {
this.credentialsProvider =
@@ -236,6 +247,8 @@ export class GithubOrgEntityProvider implements EntityProvider {
org,
tokenType,
this.options.userTransformer,
DEFAULT_PAGE_SIZES,
this.options.excludeSuspendedUsers,
);
const { teams } = await getOrganizationTeams(
client,
@@ -364,6 +377,8 @@ export class GithubOrgEntityProvider implements EntityProvider {
org,
tokenType,
this.options.userTransformer,
DEFAULT_PAGE_SIZES,
this.options.excludeSuspendedUsers,
);
if (!isGroupEntity(team)) {
@@ -455,6 +470,8 @@ export class GithubOrgEntityProvider implements EntityProvider {
org,
tokenType,
this.options.userTransformer,
DEFAULT_PAGE_SIZES,
this.options.excludeSuspendedUsers,
);
const usersToRebuild = users.filter(u => u.metadata.name === userLogin);