diff --git a/.changeset/ninety-worms-kick.md b/.changeset/ninety-worms-kick.md new file mode 100644 index 0000000000..f350c6eb31 --- /dev/null +++ b/.changeset/ninety-worms-kick.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Adds an allow list of GitHub installations diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 805e321093..f9f7590e09 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -89,6 +89,26 @@ integrations: - $include: example-backstage-app-credentials.yaml ``` +### Limiting the GitHub App installations + +If you want to limit the GitHub app installations visible to backstage you may +optionally include the `allowedInstallationOwners` option. + +```yaml +appId: 1 +allowedInstallationOwners: ['GlobexCorp'] +clientId: client id +clientSecret: client secret +webhookSecret: webhook secret +privateKey: | + -----BEGIN RSA PRIVATE KEY----- + ...Key content... + -----END RSA PRIVATE KEY----- +``` + +This will result in backstage preventing the use of any installation that is not +within the allow list. + ### Permissions for pull requests These are the minimum permissions required for creating a pull request with diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index d945f6260e..a3938b7ba3 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -66,8 +66,10 @@ class GithubAppManager { private readonly appClient: Octokit; private readonly baseAuthConfig: { appId: number; privateKey: string }; private readonly cache = new Cache(); + private readonly allowedInstallationOwners: string[] | undefined; // undefined allows all installations constructor(config: GithubAppConfig, baseUrl?: string) { + this.allowedInstallationOwners = config.allowedInstallationOwners; this.baseAuthConfig = { appId: config.appId, privateKey: config.privateKey, @@ -85,6 +87,13 @@ class GithubAppManager { repo?: string, ): Promise<{ accessToken: string }> { const { installationId, suspended } = await this.getInstallationData(owner); + if (this.allowedInstallationOwners) { + if (!this.allowedInstallationOwners?.includes(owner)) { + throw new Error( + `The GitHub application for ${owner} is not included in the allowed installation list (${installationId}).`, + ); + } + } if (suspended) { throw new Error(`The GitHub application for ${owner} is suspended`); } diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index d03b45e424..4ecac99295 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -93,6 +93,14 @@ export type GithubAppConfig = { * Client secrets can be generated at https://github.com/organizations/$org/settings/apps/$AppName */ clientSecret: string; + /** + * List of installation owners allowed to be used by this GitHub app. The GitHub UI does not provide a way to list the installations. + * However you can list the installations with the GitHub API. You can find the list of installations here: + * https://api.github.com/app/installations + * The relevant documentation for this is here. + * https://docs.github.com/en/rest/reference/apps#list-installations-for-the-authenticated-app--code-samples + */ + allowedInstallationOwners?: string[]; }; /** @@ -113,6 +121,9 @@ export function readGitHubIntegrationConfig( clientSecret: c.getString('clientSecret'), webhookSecret: c.getString('webhookSecret'), privateKey: c.getString('privateKey'), + allowedInstallationOwners: c.getOptionalStringArray( + 'allowedInstallationOwners', + ), })); if (!isValidHost(host)) { diff --git a/plugins/scaffolder/src/components/fields/EntityNamePicker/validation.test.ts b/plugins/scaffolder/src/components/fields/EntityNamePicker/validation.test.ts index 2006a87520..290650387e 100644 --- a/plugins/scaffolder/src/components/fields/EntityNamePicker/validation.test.ts +++ b/plugins/scaffolder/src/components/fields/EntityNamePicker/validation.test.ts @@ -24,17 +24,18 @@ jest.mock('@backstage/catalog-model', () => ({ }, })); -const mockIsValidObjectName = KubernetesValidatorFunctions.isValidObjectName as jest.MockedFunction< - typeof KubernetesValidatorFunctions.isValidObjectName ->; +const mockIsValidObjectName = + KubernetesValidatorFunctions.isValidObjectName as jest.MockedFunction< + typeof KubernetesValidatorFunctions.isValidObjectName + >; describe('EntityNamePicker Validation', () => { let mockFieldValidation: FieldValidation; beforeEach(() => { - mockFieldValidation = ({ + mockFieldValidation = { addError: jest.fn(), - } as unknown) as FieldValidation; + } as unknown as FieldValidation; }); it('calls isValidObjectName to validate value', () => {