From cc6206e436fb3def6d0e950c34ba64bedcee69a5 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Fri, 20 Jun 2025 14:37:01 -0500 Subject: [PATCH] Added support for `{org}.visualstudio.com` domains used by Azure DevOps Signed-off-by: Andre Wanlin Address CodeQL comments Signed-off-by: Andre Wanlin Another correction Signed-off-by: Andre Wanlin Fixed casing Signed-off-by: Andre Wanlin Adjusted to be more secure based on feedback Signed-off-by: Andre Wanlin Tighten up endsWith Signed-off-by: Andre Wanlin Corrections to TSDoc comment Signed-off-by: Andre Wanlin Changes based on feedback Signed-off-by: Andre Wanlin Correct URL for discovery Signed-off-by: Andre Wanlin Updated docs Signed-off-by: Andre Wanlin Updated changeset Signed-off-by: Andre Wanlin --- .changeset/calm-knives-glow.md | 6 + docs/integrations/azure/discovery.md | 12 +- docs/integrations/azure/locations.md | 41 +++++++ .../integration/src/azure/AzureUrl.test.ts | 49 ++++++++ packages/integration/src/azure/AzureUrl.ts | 106 +++++++++++++----- ...aultAzureDevOpsCredentialsProvider.test.ts | 20 ++++ .../DefaultAzureDevOpsCredentialsProvider.ts | 13 +++ packages/integration/src/azure/core.ts | 11 ++ .../src/lib/azure.ts | 6 +- 9 files changed, 225 insertions(+), 39 deletions(-) create mode 100644 .changeset/calm-knives-glow.md diff --git a/.changeset/calm-knives-glow.md b/.changeset/calm-knives-glow.md new file mode 100644 index 0000000000..f2e1d5805c --- /dev/null +++ b/.changeset/calm-knives-glow.md @@ -0,0 +1,6 @@ +--- +'@backstage/integration': patch +'@backstage/plugin-catalog-backend-module-azure': patch +--- + +Added support for `{org}.visualstudio.com` domains used by Azure DevOps diff --git a/docs/integrations/azure/discovery.md b/docs/integrations/azure/discovery.md index a4584ef338..7e4d376a7d 100644 --- a/docs/integrations/azure/discovery.md +++ b/docs/integrations/azure/discovery.md @@ -9,11 +9,7 @@ description: Automatically discovering catalog entities from repositories in an This documentation is written for [the new backend system](../../backend-system/index.md) which is the default since Backstage [version 1.24](../../releases/v1.24.0.md). If you are still on the old backend system, you may want to read [its own article](https://github.com/backstage/backstage/blob/v1.37.0/docs/integrations/azure/discovery--old.md) instead, and [consider migrating](../../backend-system/building-backends/08-migrating.md)! ::: -The Azure DevOps integration has a special entity provider for discovering -catalog entities within an Azure DevOps. The provider will crawl your Azure -DevOps organization and register entities matching the configured path. This can -be useful as an alternative to static locations or manually adding things to the -catalog. +The Azure DevOps integration has a special entity provider for discovering catalog entities within an Azure DevOps. The provider will crawl your Azure DevOps organization and register entities matching the configured path. This can be useful as an alternative to static locations or manually adding things to the catalog. This guide explains how to install and configure the Azure DevOps Entity Provider (recommended) or the Azure DevOps Processor. @@ -21,9 +17,7 @@ This guide explains how to install and configure the Azure DevOps Entity Provide ### Code Search Feature -Azure discovery is driven by the Code Search feature in Azure DevOps, this may not be enabled by default. For Azure -DevOps Services you can confirm this by looking at the installed extensions in your Organization Settings. For Azure -DevOps Server you'll find this information in your Collection Settings. +Azure discovery is driven by the Code Search feature in Azure DevOps, this may not be enabled by default. For Azure DevOps Services you can confirm this by looking at the installed extensions in your Organization Settings. For Azure DevOps Server you'll find this information in your Collection Settings. If the Code Search extension is not listed then you can install it from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=ms.vss-code-search&targetId=f9352dac-ba6e-434e-9241-a848a510ce3f&utm_source=vstsproduct&utm_medium=SearchExtStatus). @@ -68,7 +62,7 @@ catalog: The parameters available are: -- **`host:`** _(optional)_ Leave empty for Cloud hosted, otherwise set to your self-hosted instance host. +- **`host:`** _(optional)_ The default value is `dev.azure.com`, it is required for legacy `{org}.visualstudio.com` domains or for on-premise installations. - **`organization:`** Your Organization slug (or Collection for on-premise users). Required. - **`project:`** _(required)_ Your project slug. Wildcards are supported as shown on the examples above. Using '\*' will search all projects. For a project name containing spaces, use both single and double quotes as in `project: '"My Project Name"'`. - **`repository:`** _(optional)_ The repository name. Wildcards are supported as show on the examples above. If not set, all repositories will be searched. diff --git a/docs/integrations/azure/locations.md b/docs/integrations/azure/locations.md index 3ea180f5bd..8dd115662e 100644 --- a/docs/integrations/azure/locations.md +++ b/docs/integrations/azure/locations.md @@ -200,6 +200,47 @@ However a system-assigned managed identity is the most secure option because: ::: +### Legacy `{org}.visualstudio.com` Domains + +Backstage supports the legacy `{org}.visualstudio.com` domains along with all the previously mentioned authentication options, the caveat is that each Azure DevOps Organization will need to be defined in your configuration along with a single credential. + +For example, this will work: + +```yaml +integrations: + azure: + - host: my-org.visualstudio.com + credentials: + - clientId: ${AZURE_CLIENT_ID} + clientSecret: ${AZURE_CLIENT_SECRET} + tenantId: ${AZURE_TENANT_ID} +``` + +As will this: + +```yaml +integrations: + azure: + - host: my-other-org.visualstudio.com + credentials: + - personalAccessToken: ${PERSONAL_ACCESS_TOKEN} +``` + +But this will NOT work: + +```yaml +integrations: + azure: + - host: my-org.visualstudio.com + credentials: + - organizations: + - my-org + - my-other-org + clientId: ${AZURE_CLIENT_ID} + clientSecret: ${AZURE_CLIENT_SECRET} + tenantId: ${AZURE_TENANT_ID} +``` + ## Configuration schema The configuration is a structure with these elements: diff --git a/packages/integration/src/azure/AzureUrl.test.ts b/packages/integration/src/azure/AzureUrl.test.ts index 7382476da4..e1285fc38e 100644 --- a/packages/integration/src/azure/AzureUrl.test.ts +++ b/packages/integration/src/azure/AzureUrl.test.ts @@ -270,4 +270,53 @@ describe('AzureUrl', () => { ), ).toThrow('Azure URL must point to a git repository'); }); + + it('should work with the old visualstudio long URL', () => { + const url = AzureUrl.fromRepoUrl( + 'https://my-org.visualstudio.com/my-project/_git/my-repo', + ); + + expect(url.getOwner()).toBe('my-org'); + expect(url.getProject()).toBe('my-project'); + expect(url.getRepo()).toBe('my-repo'); + expect(url.getRef()).toBeUndefined(); + expect(url.getPath()).toBeUndefined(); + }); + + it('should work with the old visualstudio long URL form with a path', () => { + const url = AzureUrl.fromRepoUrl( + 'https://my-org.visualstudio.com/my-project/_git/my-repo?path=%2Ftest.yaml', + ); + + expect(url.getOwner()).toBe('my-org'); + expect(url.getProject()).toBe('my-project'); + expect(url.getRepo()).toBe('my-repo'); + expect(url.getRef()).toBeUndefined(); + expect(url.getPath()).toBe('/test.yaml'); + + expect(url.toRepoUrl()).toBe( + 'https://my-org.visualstudio.com/my-project/_git/my-repo?path=%2Ftest.yaml', + ); + expect(url.toFileUrl()).toBe( + 'https://my-org.visualstudio.com/my-project/_apis/git/repositories/my-repo/items?api-version=6.0&path=%2Ftest.yaml', + ); + expect(url.toArchiveUrl()).toBe( + 'https://my-org.visualstudio.com/my-project/_apis/git/repositories/my-repo/items?recursionLevel=full&download=true&api-version=6.0&scopePath=%2Ftest.yaml', + ); + expect(url.toCommitsUrl()).toBe( + 'https://my-org.visualstudio.com/my-project/_apis/git/repositories/my-repo/commits?api-version=6.0', + ); + }); + + it('should work with the old visualstudio long URL form with a path and ref', () => { + const url = AzureUrl.fromRepoUrl( + 'https://my-org.visualstudio.com/my-project/_git/my-repo?path=%2Ffolder&version=GBtest-branch', + ); + + expect(url.getOwner()).toBe('my-org'); + expect(url.getProject()).toBe('my-project'); + expect(url.getRepo()).toBe('my-repo'); + expect(url.getRef()).toBe('test-branch'); + expect(url.getPath()).toBe('/folder'); + }); }); diff --git a/packages/integration/src/azure/AzureUrl.ts b/packages/integration/src/azure/AzureUrl.ts index a271403554..58ccff24a4 100644 --- a/packages/integration/src/azure/AzureUrl.ts +++ b/packages/integration/src/azure/AzureUrl.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { isVisualStudioDomain } from './core'; + export class AzureUrl { /** * Parses an azure URL as copied from the browser address bar. @@ -28,7 +30,12 @@ export class AzureUrl { let repo; const parts = url.pathname.split('/').map(part => decodeURIComponent(part)); - if (parts[2] === '_git') { + + if (isVisualStudioDomain(url.origin) && parts[2] === '_git') { + owner = url.host.split('.')[0]; + project = parts[1]; + repo = parts[3]; + } else if (parts[2] === '_git') { owner = parts[1]; project = repo = parts[3]; } else if (parts[3] === '_git') { @@ -102,7 +109,9 @@ export class AzureUrl { */ toRepoUrl(): string { let url; - if (this.#project === this.#repo) { + if (isVisualStudioDomain(this.#origin)) { + url = this.#baseUrl(this.#project, '_git', this.#repo); + } else if (this.#project === this.#repo) { url = this.#baseUrl(this.#owner, '_git', this.#repo); } else { url = this.#baseUrl(this.#owner, this.#project, '_git', this.#repo); @@ -130,15 +139,28 @@ export class AzureUrl { ); } - const url = this.#baseUrl( - this.#owner, - this.#project, - '_apis', - 'git', - 'repositories', - this.#repo, - 'items', - ); + let url; + if (isVisualStudioDomain(this.#origin)) { + url = this.#baseUrl( + this.#project, + '_apis', + 'git', + 'repositories', + this.#repo, + 'items', + ); + } else { + url = this.#baseUrl( + this.#owner, + this.#project, + '_apis', + 'git', + 'repositories', + this.#repo, + 'items', + ); + } + url.searchParams.set('api-version', '6.0'); url.searchParams.set('path', this.#path); @@ -155,15 +177,28 @@ export class AzureUrl { * Throws an error if the URL does not point to a repo. */ toArchiveUrl(): string { - const url = this.#baseUrl( - this.#owner, - this.#project, - '_apis', - 'git', - 'repositories', - this.#repo, - 'items', - ); + let url; + if (isVisualStudioDomain(this.#origin)) { + url = this.#baseUrl( + this.#project, + '_apis', + 'git', + 'repositories', + this.#repo, + 'items', + ); + } else { + url = this.#baseUrl( + this.#owner, + this.#project, + '_apis', + 'git', + 'repositories', + this.#repo, + 'items', + ); + } + url.searchParams.set('recursionLevel', 'full'); url.searchParams.set('download', 'true'); url.searchParams.set('api-version', '6.0'); @@ -184,15 +219,28 @@ export class AzureUrl { * Throws an error if the URL does not point to a commit. */ toCommitsUrl(): string { - const url = this.#baseUrl( - this.#owner, - this.#project, - '_apis', - 'git', - 'repositories', - this.#repo, - 'commits', - ); + let url; + if (isVisualStudioDomain(this.#origin)) { + url = this.#baseUrl( + this.#project, + '_apis', + 'git', + 'repositories', + this.#repo, + 'commits', + ); + } else { + url = this.#baseUrl( + this.#owner, + this.#project, + '_apis', + 'git', + 'repositories', + this.#repo, + 'commits', + ); + } + url.searchParams.set('api-version', '6.0'); if (this.#ref) { diff --git a/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts index 6e171eff77..4b86e9869e 100644 --- a/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts +++ b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.test.ts @@ -445,5 +445,25 @@ describe('DefaultAzureDevOpsCredentialProvider', () => { }); }); }); + describe('Azure DevOps (visualstudio.com)', () => { + it('Should return a token when a credential with the same organization is specified', async () => { + const provider = buildProvider([ + { + host: 'org1.visualstudio.com', + credentials: [ + { + personalAccessToken: 'pat', + }, + ], + }, + ]); + + const credentials = provider.getCredentials({ + url: 'https://org1.visualstudio.com/project1', + }); + + expect(credentials).toBeDefined(); + }); + }); }); }); diff --git a/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.ts b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.ts index 1ff35b0612..29abddb0e9 100644 --- a/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.ts +++ b/packages/integration/src/azure/DefaultAzureDevOpsCredentialsProvider.ts @@ -20,6 +20,7 @@ import { import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider'; import { ScmIntegrationRegistry } from '../registry'; import { DefaultAzureCredential } from '@azure/identity'; +import { isVisualStudioDomain } from './core'; /** * Default implementation of AzureDevOpsCredentialsProvider. @@ -110,6 +111,17 @@ export class DefaultAzureDevOpsCredentialsProvider return undefined; } + private forVisualStudioOrganization( + url: URL, + ): AzureDevOpsCredentialsProvider | undefined { + const parts = url.host.split('.'); + if (isVisualStudioDomain(url.origin) && parts.length > 0) { + return this.providers.get(url.host); + } + + return undefined; + } + private forHost(url: URL): AzureDevOpsCredentialsProvider | undefined { return this.providers.get(url.host); } @@ -121,6 +133,7 @@ export class DefaultAzureDevOpsCredentialsProvider const provider = this.forAzureDevOpsOrganization(url) ?? this.forAzureDevOpsServerOrganization(url) ?? + this.forVisualStudioOrganization(url) ?? this.forHost(url); if (provider === undefined) { diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts index 1c25373962..a004400ba4 100644 --- a/packages/integration/src/azure/core.ts +++ b/packages/integration/src/azure/core.ts @@ -53,3 +53,14 @@ export function getAzureDownloadUrl(url: string): string { export function getAzureCommitsUrl(url: string): string { return AzureUrl.fromRepoUrl(url).toCommitsUrl(); } + +/** + * Given a URL, return true if it contains `.visualstudio.com` and false if it does not + * URLs can be in these two formats: `dev.azure.com/{org}` or the legacy `{org}.visualstudio.com` + * + * @param origin - A URL origin string pointing to an Azure DevOps instance + * @public + */ +export function isVisualStudioDomain(origin: string): boolean { + return origin.endsWith('.visualstudio.com'); +} diff --git a/plugins/catalog-backend-module-azure/src/lib/azure.ts b/plugins/catalog-backend-module-azure/src/lib/azure.ts index 7e9bf203ea..0fea509fa4 100644 --- a/plugins/catalog-backend-module-azure/src/lib/azure.ts +++ b/plugins/catalog-backend-module-azure/src/lib/azure.ts @@ -75,12 +75,16 @@ export async function codeSearch( : `https://${azureConfig.host}`; const searchUrl = `${searchBaseUrl}/${org}/_apis/search/codesearchresults?api-version=6.0-preview.1`; + const url = azureConfig.host.endsWith('.visualstudio.com') + ? `https://${azureConfig.host}` + : `https://${azureConfig.host}/${org}`; + let items: CodeSearchResultItem[] = []; let hasMorePages = true; do { const credentials = await credentialsProvider.getCredentials({ - url: `https://${azureConfig.host}/${org}`, + url, }); const searchRequestBody: CodeSearchRequest = {