diff --git a/.changeset/serious-kiwis-wonder.md b/.changeset/serious-kiwis-wonder.md new file mode 100644 index 0000000000..0deba49616 --- /dev/null +++ b/.changeset/serious-kiwis-wonder.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Remove repo restriction from GitHub credentials provider diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts index f698c01bdb..9450d96ab7 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -133,7 +133,7 @@ describe('GithubCredentialsProvider tests', () => { expect(token).toEqual('secret_token'); }); - it('should fail to issue tokens for an organization when the app is installed for a single repo', async () => { + it('should not fail to issue tokens for an organization when the app is installed for a single repo', async () => { octokit.apps.listInstallations.mockResolvedValue({ headers: { etag: '123', @@ -156,13 +156,12 @@ describe('GithubCredentialsProvider tests', () => { }, } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); - await expect( - github.getCredentials({ - url: 'https://github.com/backstage', - }), - ).rejects.toThrow( - 'The Backstage GitHub application used in the backstage organization must be installed for the entire organization to be able to issue credentials without a specified repository.', - ); + const { token, headers } = await github.getCredentials({ + url: 'https://github.com/backstage', + }); + const expectedToken = 'secret_token'; + expect(headers).toEqual({ Authorization: `Bearer ${expectedToken}` }); + expect(token).toEqual('secret_token'); }); it('should throw if the app is suspended', async () => { diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index 1de19693b5..d945f6260e 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -23,7 +23,6 @@ import { DateTime } from 'luxon'; type InstallationData = { installationId: number; suspended: boolean; - repositorySelection: 'selected' | 'all'; }; class Cache { @@ -85,31 +84,34 @@ class GithubAppManager { owner: string, repo?: string, ): Promise<{ accessToken: string }> { - const { installationId, suspended, repositorySelection } = - await this.getInstallationData(owner); + const { installationId, suspended } = await this.getInstallationData(owner); if (suspended) { - throw new Error( - `The GitHub application for ${[owner, repo] - .filter(Boolean) - .join('/')} is suspended`, - ); - } - if (repositorySelection !== 'all' && !repo) { - throw new Error( - `The Backstage GitHub application used in the ${owner} organization must be installed for the entire organization to be able to issue credentials without a specified repository.`, - ); + throw new Error(`The GitHub application for ${owner} is suspended`); } - const cacheKey = !repo ? owner : `${owner}/${repo}`; - const repositories = repositorySelection !== 'all' ? [repo!] : undefined; + const cacheKey = repo ? `${owner}/${repo}` : owner; // Go and grab an access token for the app scoped to a repository if provided, if not use the organisation installation. return this.cache.getOrCreateToken(cacheKey, async () => { const result = await this.appClient.apps.createInstallationAccessToken({ installation_id: installationId, headers: HEADERS, - repositories, }); + if (repo && result.data.repository_selection === 'selected') { + const installationClient = new Octokit({ + auth: result.data.token, + }); + const repos = + await installationClient.apps.listReposAccessibleToInstallation(); + const hasRepo = repos.data.repositories.some(repository => { + return repository.name === repo; + }); + if (!hasRepo) { + throw new Error( + `The Backstage GitHub application used in the ${owner} organization does not have access to a repository with the name ${repo}`, + ); + } + } return { token: result.data.token, expiresAt: DateTime.fromISO(result.data.expires_at), @@ -132,7 +134,6 @@ class GithubAppManager { return { installationId: installation.id, suspended: Boolean(installation.suspended_by), - repositorySelection: installation.repository_selection, }; } const notFoundError = new Error( diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts index 711c6fb27d..ca8c702c8f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.test.ts @@ -33,6 +33,7 @@ describe('GithubDiscoveryProcessor', () => { parseUrl('https://github.com/foo/proj/blob/master/catalog.yaml'), ).toEqual({ org: 'foo', + host: 'github.com', repoSearchPath: /^proj$/, catalogPath: '/blob/master/catalog.yaml', }); @@ -40,6 +41,7 @@ describe('GithubDiscoveryProcessor', () => { parseUrl('https://github.com/foo/proj*/blob/master/catalog.yaml'), ).toEqual({ org: 'foo', + host: 'github.com', repoSearchPath: /^proj.*$/, catalogPath: '/blob/master/catalog.yaml', }); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts index d3064f9c39..f7069a9f64 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts @@ -64,10 +64,18 @@ export class GithubDiscoveryProcessor implements CatalogProcessor { `There is no GitHub integration that matches ${location.target}. Please add a configuration entry for it under integrations.github`, ); } + + const { org, repoSearchPath, catalogPath, host } = parseUrl( + location.target, + ); + + // Building the org url here so that the github creds provider doesn't need to know + // about how to handle the wild card which is special for this processor. + const orgUrl = `https://${host}/${org}`; + const { headers } = await GithubCredentialsProvider.create( gitHubConfig, - ).getCredentials({ url: location.target }); - const { org, repoSearchPath, catalogPath } = parseUrl(location.target); + ).getCredentials({ url: orgUrl }); const client = graphql.defaults({ baseUrl: gitHubConfig.apiBaseUrl, @@ -115,6 +123,7 @@ export function parseUrl(urlString: string): { org: string; repoSearchPath: RegExp; catalogPath: string; + host: string; } { const url = new URL(urlString); const path = url.pathname.substr(1).split('/'); @@ -125,6 +134,7 @@ export function parseUrl(urlString: string): { org: decodeURIComponent(path[0]), repoSearchPath: escapeRegExp(decodeURIComponent(path[1])), catalogPath: `/${decodeURIComponent(path.slice(2).join('/'))}`, + host: url.host, }; }