Added support for {org}.visualstudio.com domains used by Azure DevOps

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Address CodeQL comments

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Another correction

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Fixed casing

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Adjusted to be more secure based on feedback

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Tighten up endsWith

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Corrections to TSDoc comment

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Changes based on feedback

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Correct URL for discovery

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Updated docs

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Updated changeset

Signed-off-by: Andre Wanlin <awanlin@spotify.com>
This commit is contained in:
Andre Wanlin
2025-06-20 14:37:01 -05:00
parent 3581e7d2c9
commit cc6206e436
9 changed files with 225 additions and 39 deletions
+6
View File
@@ -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
+3 -9
View File
@@ -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.
+41
View File
@@ -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:
@@ -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');
});
});
+77 -29
View File
@@ -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) {
@@ -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();
});
});
});
});
@@ -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) {
+11
View File
@@ -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');
}
@@ -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 = {