feature(azure devops): support multiple organisations

Signed-off-by: Sander Aernouts <sander.aernouts@gmail.com>
This commit is contained in:
Sander Aernouts
2023-06-12 16:37:22 +02:00
parent b3d14f8112
commit 5f1a92b9f1
28 changed files with 2387 additions and 363 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-backend-module-azure': patch
'@backstage/plugin-scaffolder-backend': patch
'@backstage/backend-common': patch
---
Use `DefaultAzureDevOpsCredentialsProvider` to retrieve credentials for Azure DevOps.
+23
View File
@@ -0,0 +1,23 @@
---
'@backstage/integration': minor
---
Added `AzureDevOpsCredentialsProvider` to support multiple Azure DevOps organizations and **deprecated** `AzureIntegrationConfig.credential` and `AzureIntegrationConfig.token` in favour of `AzureIntegrationConfig.credentials`. You can now use specific credentials for different Azure DevOps (Server) organizations by specifying the `organizations` field on a credential:
```yaml
integrations:
azure:
- host: dev.azure.com
credentials:
- organizations:
- my-org
- my-other-org
clientId: ${AZURE_CLIENT_ID}
clientSecret: ${AZURE_CLIENT_SECRET}
tenantId: ${AZURE_TENANT_ID}
- organizations:
- yet-another-org
personalAccessToken: ${PERSONAL_ACCESS_TOKEN}
```
See the [Azure integration documentation](https://backstage.io/docs/integrations/azure/locations) for more information.
+43 -13
View File
@@ -19,10 +19,10 @@ Using a service principal:
integrations:
azure:
- host: dev.azure.com
credential:
clientId: ${CLIENT_ID}
clientSecret: ${CLIENT_SECRET}
tenantId: ${TENANT_ID}
credentials:
- clientId: ${AZURE_CLIENT_ID}
clientSecret: ${AZURE_CLIENT_SECRET}
tenantId: ${AZURE_TENANT_ID}
```
Using a managed identity:
@@ -31,8 +31,8 @@ Using a managed identity:
integrations:
azure:
- host: dev.azure.com
credential:
clientId: ${CLIENT_ID}
credentials:
- clientId: ${AZURE_CLIENT_ID}
```
Using a personal access token (PAT):
@@ -41,24 +41,54 @@ Using a personal access token (PAT):
integrations:
azure:
- host: dev.azure.com
token: ${AZURE_TOKEN}
credentials:
- personalAccessToken: ${PERSONAL_ACCESS_TOKEN}
```
You can use specific credentials for different Azure DevOps organizations by specifying the `organizations` field on the credential:
```yaml
integrations:
azure:
- host: dev.azure.com
credentials:
- organizations:
- my-org
- my-other-org
clientId: ${AZURE_CLIENT_ID}
clientSecret: ${AZURE_CLIENT_SECRET}
tenantId: ${AZURE_TENANT_ID}
- organizations:
- another-org
clientId: ${AZURE_CLIENT_ID}
- organizations:
- yet-another-org
personalAccessToken: ${PERSONAL_ACCESS_TOKEN}
```
If you do not specify the `organizations` field the credential will be used for all organizations for which no other credential is configured.
> Note: An Azure DevOps provider is added automatically at startup for
> convenience, so you only need to list it if you want to supply a
> [token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate),
> [personalAccessToken](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate),
> a [service principal](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity),
> or a [managed identity](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity)
The configuration is a structure with these elements:
- `host`: The DevOps host; only `dev.azure.com` is supported.
- `token` (optional): A personal access token as expected by Azure DevOps.
- `credential`: (optional): A service principal or managed identity
- `credentials`: (optional): A service principal, managed identity, or personal access token
The `credentials` element is a structure with these elements:
- `organizations`: (optional): A list of organizations for which this credential should be used. If not specified the credential will be used for all organizations for which no other credential is configured.
- `clientId`: The client ID of the service principal or managed identity (required for service principal and managed identities)
- `clientSecret`: The client secret of the service principal (required for service principal)
- `tenantId`: The tenant ID of the service principal (required for service principal)
- `personalAccessToken`: The personal access token (required for personal access token)
> Note:
>
> - `token` and `credential` are mutually exclusive.
> - You cannot use a service principal or managed identity for Azure DevOps Server (on-premises) organizations
> - You can only use a service principal or managed identity for Azure AD backed Azure DevOps organizations
> - The token should just be provided as the raw token generated by Azure DevOps using the format `raw_token` with no base64 encoding. Formatting and base64'ing is handled by dependent libraries handling the Azure DevOps API
> - You can only specify one credential per host without any organizations specified
> - The personal access token should just be provided as the raw token generated by Azure DevOps using the format `raw_token` with no base64 encoding. Formatting and base64'ing is handled by dependent libraries handling the Azure DevOps API
+2
View File
@@ -9,6 +9,7 @@
import { AppConfig } from '@backstage/config';
import { AwsCredentialsManager } from '@backstage/integration-aws-node';
import { AwsS3Integration } from '@backstage/integration';
import { AzureDevOpsCredentialsProvider } from '@backstage/integration';
import { AzureIntegration } from '@backstage/integration';
import { BackendFeature } from '@backstage/backend-plugin-api';
import { BitbucketCloudIntegration } from '@backstage/integration';
@@ -94,6 +95,7 @@ export class AzureUrlReader implements UrlReader {
integration: AzureIntegration,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
credentialsProvider: AzureDevOpsCredentialsProvider;
},
);
// (undocumented)
@@ -17,7 +17,11 @@
import { ConfigReader } from '@backstage/config';
import {
AzureIntegration,
DefaultAzureDevOpsCredentialsProvider,
readAzureIntegrationConfig,
ScmIntegrations,
AzureDevOpsCredentialLike,
AzureIntegrationConfig,
} from '@backstage/integration';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import fs from 'fs-extra';
@@ -31,12 +35,40 @@ import { getVoidLogger } from '../logging';
import { AzureUrlReader } from './AzureUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
type AzureIntegrationConfigLike = Partial<
Omit<AzureIntegrationConfig, 'credential' | 'credentials'>
> & {
credentials?: Partial<AzureDevOpsCredentialLike>[];
};
const logger = getVoidLogger();
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const urlReaderFactory = (azureIntegration: AzureIntegrationConfigLike) => {
const credentialsProvider =
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(
ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
azure: [azureIntegration],
},
}),
),
);
return new AzureUrlReader(
new AzureIntegration(
readAzureIntegrationConfig(new ConfigReader(azureIntegration)),
),
{
treeResponseFactory,
credentialsProvider,
},
);
};
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
describe('AzureUrlReader', () => {
@@ -68,13 +100,29 @@ describe('AzureUrlReader', () => {
);
});
const createConfig = (token?: string) =>
new ConfigReader(
const createConfig = (token?: string) => {
let credentials: AzureDevOpsCredentialLike[] | undefined = undefined;
if (token !== undefined) {
credentials = [
{
personalAccessToken: token,
},
];
}
return new ConfigReader(
{
integrations: { azure: [{ host: 'dev.azure.com', token }] },
integrations: {
azure: [
{
host: 'dev.azure.com',
credentials: credentials,
},
],
},
},
'test-config',
);
};
it.each([
{
@@ -104,8 +152,8 @@ describe('AzureUrlReader', () => {
url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
config: createConfig(undefined),
response: expect.objectContaining({
headers: expect.not.objectContaining({
authorization: expect.anything(),
headers: expect.objectContaining({
authorization: expect.stringMatching(/^Bearer /),
}),
}),
},
@@ -137,7 +185,7 @@ describe('AzureUrlReader', () => {
url: '',
config: createConfig(''),
error:
"Invalid type in config for key 'integrations.azure[0].token' in 'test-config', got empty-string, wanted string",
"Invalid type in config for key 'integrations.azure[0].credentials[0].personalAccessToken' in 'test-config', got empty-string, wanted string",
},
])('should handle error path %#', async ({ url, config, error }) => {
await expect(async () => {
@@ -156,16 +204,14 @@ describe('AzureUrlReader', () => {
path.resolve(__dirname, '__fixtures__/mock-main.zip'),
);
const processor = new AzureUrlReader(
new AzureIntegration(
readAzureIntegrationConfig(
new ConfigReader({
host: 'dev.azure.com',
}),
),
),
{ treeResponseFactory },
);
const processor = urlReaderFactory({
host: 'dev.azure.com',
credentials: [
{
personalAccessToken: 'my-pat',
},
],
});
beforeEach(() => {
worker.use(
@@ -268,16 +314,14 @@ describe('AzureUrlReader', () => {
path.resolve(__dirname, '__fixtures__/mock-main.zip'),
);
const processor = new AzureUrlReader(
new AzureIntegration(
readAzureIntegrationConfig(
new ConfigReader({
host: 'dev.azure.com',
}),
),
),
{ treeResponseFactory },
);
const processor = urlReaderFactory({
host: 'dev.azure.com',
credentials: [
{
personalAccessToken: 'my-pat',
},
],
});
beforeEach(() => {
worker.use(
@@ -15,12 +15,13 @@
*/
import {
AzureIntegration,
getAzureCommitsUrl,
getAzureDownloadUrl,
getAzureFileFetchUrl,
getAzureRequestOptions,
AzureDevOpsCredentialsProvider,
DefaultAzureDevOpsCredentialsProvider,
ScmIntegrations,
AzureIntegration,
} from '@backstage/integration';
import fetch, { Response } from 'node-fetch';
import { Minimatch } from 'minimatch';
@@ -47,8 +48,13 @@ import { ReadUrlResponseFactory } from './ReadUrlResponseFactory';
export class AzureUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const integrations = ScmIntegrations.fromConfig(config);
const credentialProvider =
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(integrations);
return integrations.azure.list().map(integration => {
const reader = new AzureUrlReader(integration, { treeResponseFactory });
const reader = new AzureUrlReader(integration, {
treeResponseFactory,
credentialsProvider: credentialProvider,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
@@ -56,7 +62,10 @@ export class AzureUrlReader implements UrlReader {
constructor(
private readonly integration: AzureIntegration,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
private readonly deps: {
treeResponseFactory: ReadTreeResponseFactory;
credentialsProvider: AzureDevOpsCredentialsProvider;
},
) {}
async read(url: string): Promise<Buffer> {
@@ -72,11 +81,13 @@ export class AzureUrlReader implements UrlReader {
const { signal } = options ?? {};
const builtUrl = getAzureFileFetchUrl(url);
let response: Response;
try {
const credentials = await this.deps.credentialsProvider.getCredentials({
url: builtUrl,
});
response = await fetch(builtUrl, {
...(await getAzureRequestOptions(this.integration.config)),
headers: credentials?.headers,
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
@@ -111,10 +122,13 @@ export class AzureUrlReader implements UrlReader {
// Get latest commit SHA
const commitsAzureResponse = await fetch(
getAzureCommitsUrl(url),
await getAzureRequestOptions(this.integration.config),
);
const credentials = await this.deps.credentialsProvider.getCredentials({
url: url,
});
const commitsAzureResponse = await fetch(getAzureCommitsUrl(url), {
headers: credentials?.headers,
});
if (!commitsAzureResponse.ok) {
const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`;
if (commitsAzureResponse.status === 404) {
@@ -129,9 +143,10 @@ export class AzureUrlReader implements UrlReader {
}
const archiveAzureResponse = await fetch(getAzureDownloadUrl(url), {
...(await getAzureRequestOptions(this.integration.config, {
headers: {
...credentials?.headers,
Accept: 'application/zip',
})),
},
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can be
@@ -198,7 +213,9 @@ export class AzureUrlReader implements UrlReader {
}
toString() {
const { host, token } = this.integration.config;
return `azure{host=${host},authed=${Boolean(token)}}`;
const { host, credentials } = this.integration.config;
return `azure{host=${host},authed=${Boolean(
credentials !== undefined && credentials.length > 0,
)}}`;
}
}
+70 -6
View File
@@ -39,16 +39,58 @@ export type AwsS3IntegrationConfig = {
};
// @public
export type AzureClientSecretCredential = {
export type AzureClientSecretCredential = AzureCredentialBase & {
kind: 'ClientSecret';
tenantId: string;
clientId: string;
clientSecret: string;
};
// @public
export type AzureCredential =
export type AzureCredentialBase = {
kind: AzureDevOpsCredentialKind;
organizations?: string[];
};
// @public
export type AzureDevOpsCredential =
| AzureClientSecretCredential
| AzureManagedIdentityCredential;
| AzureManagedIdentityCredential
| PersonalAccessTokenCredential;
// @public
export type AzureDevOpsCredentialKind =
| 'PersonalAccessToken'
| 'ClientSecret'
| 'ManagedIdentity';
// @public
export type AzureDevOpsCredentialLike = Omit<
Partial<AzureClientSecretCredential> &
Partial<AzureManagedIdentityCredential> &
Partial<PersonalAccessTokenCredential>,
'kind'
>;
// @public
export type AzureDevOpsCredentials = {
headers: {
[name: string]: string;
};
token: string;
type: AzureDevOpsCredentialType;
};
// @public
export interface AzureDevOpsCredentialsProvider {
// (undocumented)
getCredentials(opts: {
url: string;
}): Promise<AzureDevOpsCredentials | undefined>;
}
// @public
export type AzureDevOpsCredentialType = 'bearer' | 'pat';
// @public
export class AzureIntegration implements ScmIntegration {
@@ -75,11 +117,13 @@ export class AzureIntegration implements ScmIntegration {
export type AzureIntegrationConfig = {
host: string;
token?: string;
credential?: AzureCredential;
credential?: AzureDevOpsCredential;
credentials?: AzureDevOpsCredential[];
};
// @public
export type AzureManagedIdentityCredential = {
export type AzureManagedIdentityCredential = AzureCredentialBase & {
kind: 'ManagedIdentity';
clientId: string;
};
@@ -180,6 +224,20 @@ export function buildGerritGitilesArchiveUrl(
filePath: string,
): string;
// @public
export class DefaultAzureDevOpsCredentialsProvider
implements AzureDevOpsCredentialsProvider
{
// (undocumented)
static fromIntegrations(
integrations: ScmIntegrationRegistry,
): DefaultAzureDevOpsCredentialsProvider;
// (undocumented)
getCredentials(opts: {
url: string;
}): Promise<AzureDevOpsCredentials | undefined>;
}
// @public
export class DefaultGithubCredentialsProvider
implements GithubCredentialsProvider
@@ -250,7 +308,7 @@ export function getAzureDownloadUrl(url: string): string;
// @public
export function getAzureFileFetchUrl(url: string): string;
// @public
// @public @deprecated
export function getAzureRequestOptions(
config: AzureIntegrationConfig,
additionalHeaders?: Record<string, string>,
@@ -600,6 +658,12 @@ export function parseGerritGitilesUrl(
// @public
export function parseGerritJsonResponse(response: Response): Promise<unknown>;
// @public
export type PersonalAccessTokenCredential = AzureCredentialBase & {
kind: 'PersonalAccessToken';
personalAccessToken: string;
};
// @public
export function readAwsS3IntegrationConfig(
config: Config,
+30
View File
@@ -30,8 +30,38 @@ export interface Config {
/**
* Token used to authenticate requests.
* @visibility secret
* @deprecated Use `credentials` instead.
*/
token?: string;
/**
* The credential to use for requests.
*
* If no credential is specified anonymous access is used.
*
* @visibility secret
* @deprecated Use `credentials` instead.
*/
credential?: {
clientId?: string;
clientSecret?: string;
tenantId?: string;
personalAccessToken?: string;
};
/**
* The credentials to use for requests. If multiple credentials are specified the first one that matches the organization is used.
* If not organization matches the first credential without an organization is used.
*
* If no credentials are specified at all, either a default credential (for Azure DevOps) or anonymous access (for Azure DevOps Server) is used.
* @visibility secret
*/
credentials?: {
clientId?: string;
clientSecret?: string;
tenantId?: string;
personalAccessToken?: string;
}[];
}>;
/**
@@ -29,6 +29,7 @@ export class AzureIntegration implements ScmIntegration {
const configs = readAzureIntegrationConfigs(
config.getOptionalConfigArray('integrations.azure') ?? [],
);
return basicIntegrations(
configs.map(c => new AzureIntegration(c)),
i => i.config.host,
@@ -0,0 +1,188 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
import {
AccessToken,
ClientSecretCredential,
ManagedIdentityCredential,
} from '@azure/identity';
const MockedClientSecretCredential = ClientSecretCredential as jest.MockedClass<
typeof ClientSecretCredential
>;
const MockedManagedIdentityCredential =
ManagedIdentityCredential as jest.MockedClass<
typeof ManagedIdentityCredential
>;
jest.mock('@azure/identity');
const seconds = (s: number) => s * 1000;
const minutes = (m: number) => seconds(60) * m;
const hours = (h: number) => minutes(60) * h;
describe('CachedAzureDevOpsCredentialsProvider', () => {
beforeEach(() => {
jest.resetAllMocks();
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: Date.now() + hours(8),
token: 'fake-client-secret-token',
} as AccessToken),
);
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: Date.now() + hours(8),
token: 'fake-managed-identity-token',
} as AccessToken),
);
});
it('Should return a pat credential when a personal access token is configured', async () => {
const manager =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
kind: 'PersonalAccessToken',
personalAccessToken: 'token',
});
const { headers, type, token } = await manager.getCredentials();
expect(headers).toStrictEqual({
Authorization: `Basic ${btoa(`:${token}`)}`,
});
expect(type).toBe('pat');
expect(token).toBe('token');
});
it('Should return a bearer credential when a client secret is configured', async () => {
const manager =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
kind: 'ClientSecret',
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenantId',
});
const { headers, type, token } = await manager.getCredentials();
expect(headers).toStrictEqual({
Authorization: `Bearer fake-client-secret-token`,
});
expect(type).toBe('bearer');
expect(token).toBe('fake-client-secret-token');
});
it('Should return a bearer credential when a managed identity is configured', async () => {
const manager =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
kind: 'ManagedIdentity',
clientId: 'id',
});
const { headers, type, token } = await manager.getCredentials();
expect(headers).toStrictEqual({
Authorization: `Bearer fake-managed-identity-token`,
});
expect(type).toBe('bearer');
expect(token).toBe('fake-managed-identity-token');
});
it('Should not refresh the client secret token when it does not expire within 10 minutes', async () => {
const manager =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
kind: 'ClientSecret',
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenantId',
});
await manager.getCredentials();
await manager.getCredentials();
await manager.getCredentials();
expect(
MockedClientSecretCredential.prototype.getToken,
).toHaveBeenCalledTimes(1);
});
it('Should return the managed identity token when it does not expire within 10 minutes', async () => {
const manager =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
kind: 'ManagedIdentity',
clientId: 'id',
});
await manager.getCredentials();
await manager.getCredentials();
await manager.getCredentials();
expect(
MockedManagedIdentityCredential.prototype.getToken,
).toHaveBeenCalledTimes(1);
});
it('Should refresh the client secret token when it expires within 10 minutes', async () => {
const manager =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
kind: 'ClientSecret',
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenantId',
});
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: Date.now() + minutes(9) + seconds(59),
token: 'fake-client-secret-token',
} as AccessToken),
);
await manager.getCredentials();
await manager.getCredentials();
expect(
MockedClientSecretCredential.prototype.getToken,
).toHaveBeenCalledTimes(2);
});
it('Should refresh the managed identity token when it expires within 10 minutes', async () => {
const manager =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential({
kind: 'ManagedIdentity',
clientId: 'id',
});
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: Date.now() + minutes(9) + seconds(59),
token: 'fake-managed-identity-token',
} as AccessToken),
);
await manager.getCredentials();
await manager.getCredentials();
expect(
MockedManagedIdentityCredential.prototype.getToken,
).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,127 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AzureDevOpsCredential, PersonalAccessTokenCredential } from './config';
import {
ClientSecretCredential,
ManagedIdentityCredential,
TokenCredential,
} from '@azure/identity';
import {
AzureDevOpsCredentials,
AzureDevOpsCredentialsProvider,
} from './types';
type CachedAzureDevOpsCredentials = AzureDevOpsCredentials & {
expiresAt?: number;
};
function exhaustiveCheck(_param: never) {}
const tenMinutes = 1000 * 60 * 10;
/**
* A credentials provider that caches the credentials for as long as it is valid.
*
* @public
*/
export class CachedAzureDevOpsCredentialsProvider
implements AzureDevOpsCredentialsProvider
{
azureDevOpsScope = '499b84ac-1321-427f-aa17-267ca6975798/.default';
cached: CachedAzureDevOpsCredentials | undefined;
static fromAzureDevOpsCredential(
credential: AzureDevOpsCredential,
): CachedAzureDevOpsCredentialsProvider {
switch (credential.kind) {
case 'PersonalAccessToken':
return CachedAzureDevOpsCredentialsProvider.fromPersonalAccessTokenCredential(
credential,
);
case 'ClientSecret':
return CachedAzureDevOpsCredentialsProvider.fromTokenCredential(
new ClientSecretCredential(
credential.tenantId,
credential.clientId,
credential.clientSecret,
),
);
case 'ManagedIdentity':
return CachedAzureDevOpsCredentialsProvider.fromTokenCredential(
new ManagedIdentityCredential(credential.clientId),
);
default:
exhaustiveCheck(credential);
throw new Error(
`Credential kind '${(credential as any).kind}' not supported`,
);
}
}
static fromTokenCredential(
credential: TokenCredential,
): CachedAzureDevOpsCredentialsProvider {
return new CachedAzureDevOpsCredentialsProvider(credential);
}
static fromPersonalAccessTokenCredential(
credential: PersonalAccessTokenCredential,
) {
return new CachedAzureDevOpsCredentialsProvider(
credential.personalAccessToken,
);
}
private constructor(private readonly credential: TokenCredential | string) {}
async getCredentials(): Promise<AzureDevOpsCredentials> {
if (
this.cached === undefined ||
(this.cached.expiresAt !== undefined &&
Date.now() > this.cached.expiresAt)
) {
if (typeof this.credential === 'string') {
this.cached = {
headers: {
Authorization: `Basic ${btoa(`:${this.credential}`)}`,
},
type: 'pat',
token: this.credential,
};
} else {
const accessToken = await this.credential.getToken(
this.azureDevOpsScope,
);
if (!accessToken) {
throw new Error('Failed to retrieve access token');
}
this.cached = {
expiresAt: accessToken.expiresOnTimestamp - tenMinutes,
headers: {
Authorization: `Bearer ${accessToken.token}`,
},
type: 'bearer',
token: accessToken.token,
};
}
}
return this.cached;
}
}
@@ -0,0 +1,465 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ScmIntegrations } from '../ScmIntegrations';
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
import { AzureDevOpsCredentialLike, AzureIntegrationConfig } from './config';
import { ConfigReader } from '@backstage/config';
import { DefaultAzureDevOpsCredentialsProvider } from './DefaultAzureDevOpsCredentialsProvider';
import {
AccessToken,
ClientSecretCredential,
DefaultAzureCredential,
ManagedIdentityCredential,
} from '@azure/identity';
import { DateTime } from 'luxon';
type AzureIntegrationConfigLike = Partial<
Omit<AzureIntegrationConfig, 'credential' | 'credentials'>
> & {
credential?: Partial<AzureDevOpsCredentialLike>;
credentials?: Partial<AzureDevOpsCredentialLike>[];
};
const MockedClientSecretCredential = ClientSecretCredential as jest.MockedClass<
typeof ClientSecretCredential
>;
const MockedManagedIdentityCredential =
ManagedIdentityCredential as jest.MockedClass<
typeof ManagedIdentityCredential
>;
const MockedDefaultAzureCredential = DefaultAzureCredential as jest.MockedClass<
typeof DefaultAzureCredential
>;
jest.mock('@azure/identity');
describe('DefaultAzureDevOpsCredentialProvider', () => {
const buildProvider = (azureIntegrations: AzureIntegrationConfigLike[]) =>
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(
ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
azure: azureIntegrations,
},
}),
),
);
beforeEach(() => {
jest.resetAllMocks();
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
token: 'fake-client-secret-token',
} as AccessToken),
);
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
token: 'fake-managed-identity-token',
} as AccessToken),
);
MockedDefaultAzureCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
token: 'fake-default-azure-credential-token',
} as AccessToken),
);
jest.spyOn(
CachedAzureDevOpsCredentialsProvider,
'fromAzureDevOpsCredential',
);
jest.spyOn(CachedAzureDevOpsCredentialsProvider, 'fromTokenCredential');
jest.spyOn(
CachedAzureDevOpsCredentialsProvider,
'fromPersonalAccessTokenCredential',
);
});
describe('fromIntegrations', () => {
it('Should create a credential provider when a credential is specified', async () => {
const provider = buildProvider([
{
host: 'dev.azure.com',
credentials: [
{
personalAccessToken: 'pat',
},
],
},
]);
expect(provider).toBeDefined();
expect(
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential,
).toHaveBeenCalledTimes(1);
});
it('Should create a single credential provider per credential', async () => {
const provider = buildProvider([
{
host: 'dev.azure.com',
credentials: [
{
organizations: ['org1', 'org2'],
clientId: 'client-id-1',
},
{
organizations: ['org3', 'org4'],
clientId: 'client-id-2',
},
],
},
]);
expect(provider).toBeDefined();
expect(
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential,
).toHaveBeenCalledTimes(2);
});
it('Should create a default credential provider for Azure DevOps when no credential is specified', async () => {
const provider = buildProvider([
{
host: 'dev.azure.com',
credentials: [],
},
]);
expect(provider).toBeDefined();
expect(
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
).toHaveBeenCalledTimes(1);
expect(
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
).toHaveBeenCalledWith(new DefaultAzureCredential());
});
it('Should create a default credential provider for Azure DevOps when no default credential is specified', async () => {
const provider = buildProvider([
{
host: 'dev.azure.com',
credentials: [
{
organizations: ['org1', 'org2'],
clientId: 'client-id',
tenantId: 'tenant-id',
clientSecret: 'client-secret',
},
],
},
]);
expect(provider).toBeDefined();
expect(
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
).toHaveBeenCalledTimes(2);
expect(
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
).toHaveBeenCalledWith(new DefaultAzureCredential());
});
it('Should not create a default credential provider for Azure DevOps when another default credential is specified', async () => {
const provider = buildProvider([
{
host: 'dev.azure.com',
credentials: [
{
clientId: 'client-id',
tenantId: 'tenant-id',
clientSecret: 'client-secret',
},
],
},
]);
expect(provider).toBeDefined();
expect(
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
).toHaveBeenCalledTimes(1);
expect(
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
).toHaveBeenCalledWith(expect.any(ClientSecretCredential));
});
it('Should not create a default credential provider for on-premise Azure DevOps server when no credential is specified', async () => {
const provider = buildProvider([
{
host: 'my.devops.server',
credentials: [],
},
]);
expect(provider).toBeDefined();
expect(
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential,
).toHaveBeenCalledTimes(0);
// expect 1 call because the Azure integration adds a default integration for dev.azure.com when it is not configured
expect(
CachedAzureDevOpsCredentialsProvider.fromTokenCredential,
).toHaveBeenCalledTimes(1);
expect(
CachedAzureDevOpsCredentialsProvider.fromPersonalAccessTokenCredential,
).toHaveBeenCalledTimes(0);
});
});
describe('getCredentials', () => {
describe('Azure DevOps (dev.azure.com)', () => {
it('Should return a token when a credential with the same organization is specified', async () => {
const provider = buildProvider([
{
host: 'dev.azure.com',
credentials: [
{
organizations: ['org1'],
personalAccessToken: 'pat',
},
],
},
]);
const credentials = provider.getCredentials({
url: 'https://dev.azure.com/org1/project1',
});
expect(credentials).toBeDefined();
});
it('Should return DefaultAzureCredential when no credential with the same organization is specified and host is dev.azure.com', async () => {
const provider = buildProvider([
{
host: 'dev.azure.com',
credentials: [
{
organizations: ['org1'],
personalAccessToken: 'pat',
},
],
},
]);
const credentials = await provider.getCredentials({
url: 'https://dev.azure.com/org2/project1',
});
expect(credentials).toMatchObject({
token: 'fake-default-azure-credential-token',
});
});
it('Should return undefined when no credential is specified and host is Azure DevOps server', async () => {
const provider = buildProvider([
{
host: 'my.devops.server',
credentials: [],
},
]);
const credentials = await provider.getCredentials({
url: 'https://my.devops.server/org2/project1',
});
expect(credentials).toBeUndefined();
});
it('Should prefer organization credential when a credential with the same organization is specified', async () => {
const provider = buildProvider([
{
host: 'dev.azure.com',
credentials: [
{
personalAccessToken: 'fallback-pat',
},
{
organizations: ['org1'],
personalAccessToken: 'org1-pat',
},
],
},
]);
const credentials = await provider.getCredentials({
url: 'https://dev.azure.com/org1/project1',
});
expect(credentials).toMatchObject({
token: 'org1-pat',
});
});
it('Should fallback to host credential when no credential with the same organization is specified', async () => {
const provider = buildProvider([
{
host: 'dev.azure.com',
credentials: [
{
personalAccessToken: 'fallback-pat',
},
{
organizations: ['org1'],
personalAccessToken: 'org1-pat',
},
],
},
]);
const credentials = await provider.getCredentials({
url: 'https://dev.azure.com/org2/project1',
});
expect(credentials).toMatchObject({
token: 'fallback-pat',
});
});
});
describe('Azure DevOps Server', () => {
[
'https://{host}/{organization}/{project}',
'https://{host}/tfs/{organization}/{project}',
].map(format => {
describe(`With url format ${format}`, () => {
const formatUrl = (opts: {
host: string;
organization: string;
project: string;
}) =>
format
.replace('{host}', opts.host)
.replace('{organization}', opts.organization)
.replace('{project}', opts.project);
it('Should return a token when a credential with the same host is specified', async () => {
const provider = buildProvider([
{
host: 'my.devops.server',
credentials: [
{
personalAccessToken: 'pat',
},
],
},
]);
const credentials = await provider.getCredentials({
url: formatUrl({
host: 'my.devops.server',
organization: 'org1',
project: 'project1',
}),
});
expect(credentials).toBeDefined();
});
it('Should prefer organization credential when a credential with the same organization is specified', async () => {
const provider = buildProvider([
{
host: 'my.devops.server',
credentials: [
{
personalAccessToken: 'fallback-pat',
},
{
organizations: ['org1'],
personalAccessToken: 'org1-pat',
},
],
},
]);
const credentials = await provider.getCredentials({
url: formatUrl({
host: 'my.devops.server',
organization: 'org1',
project: 'project1',
}),
});
expect(credentials).toMatchObject({
token: 'org1-pat',
});
});
it('Should fallback to host credential when no credential with the same organization is specified', async () => {
const provider = buildProvider([
{
host: 'my.devops.server',
credentials: [
{
personalAccessToken: 'fallback-pat',
},
{
organizations: ['org1'],
personalAccessToken: 'org1-pat',
},
],
},
]);
const credentials = await provider.getCredentials({
url: formatUrl({
host: 'my.devops.server',
organization: 'org2',
project: 'project1',
}),
});
expect(credentials).toMatchObject({
token: 'fallback-pat',
});
});
it('Should return a undefined when no credential with the same host is specified', async () => {
const provider = buildProvider([
{
host: 'my.devops.server',
credentials: [
{
personalAccessToken: 'pat',
},
],
},
]);
const credentials = await provider.getCredentials({
url: formatUrl({
host: 'my.other.devops.server',
organization: 'org1',
project: 'project1',
}),
});
expect(credentials).toBeUndefined();
});
});
});
});
});
});
@@ -0,0 +1,132 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AzureDevOpsCredentials,
AzureDevOpsCredentialsProvider,
} from './types';
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
import { ScmIntegrationRegistry } from '../registry';
import { DefaultAzureCredential } from '@azure/identity';
/**
* Default implementation of AzureDevOpsCredentialsProvider.
* @public
*/
export class DefaultAzureDevOpsCredentialsProvider
implements AzureDevOpsCredentialsProvider
{
static fromIntegrations(
integrations: ScmIntegrationRegistry,
): DefaultAzureDevOpsCredentialsProvider {
const providers = integrations.azure.list().reduce((acc, integration) => {
integration.config.credentials?.forEach(credential => {
if (
credential.organizations === undefined ||
credential.organizations.length === 0
) {
if (acc.get(integration.config.host) === undefined) {
acc.set(
integration.config.host,
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential(
credential,
),
);
}
} else {
const provider =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential(
credential,
);
credential.organizations?.forEach(organization => {
acc.set(`${integration.config.host}/${organization}`, provider);
});
}
});
if (
integration.config.host === 'dev.azure.com' &&
acc.get(integration.config.host) === undefined
) {
acc.set(
integration.config.host,
CachedAzureDevOpsCredentialsProvider.fromTokenCredential(
new DefaultAzureCredential(),
),
);
}
return acc;
}, new Map<string, CachedAzureDevOpsCredentialsProvider>());
return new DefaultAzureDevOpsCredentialsProvider(providers);
}
private constructor(
private readonly providers: Map<
string,
CachedAzureDevOpsCredentialsProvider
>,
) {}
private forAzureDevOpsServerOrganization(
url: URL,
): AzureDevOpsCredentialsProvider | undefined {
const parts = url.pathname.split('/').filter(part => part !== '');
if (url.host !== 'dev.azure.com' && parts.length > 0) {
if (parts[0] !== 'tfs') {
// url format: https://{host}/{organization}
return this.providers.get(`${url.host}/${parts[0]}`);
} else if (parts[0] === 'tfs' && parts.length > 1) {
// url format: https://{host}/tfs/{organization}
return this.providers.get(`${url.host}/${parts[1]}`);
}
}
return undefined;
}
private forAzureDevOpsOrganization(
url: URL,
): AzureDevOpsCredentialsProvider | undefined {
const parts = url.pathname.split('/').filter(part => part !== '');
if (url.host === 'dev.azure.com' && parts.length > 0) {
// url format: https://{host}/{organization}
return this.providers.get(`${url.host}/${parts[0]}`);
}
return undefined;
}
private forHost(url: URL): AzureDevOpsCredentialsProvider | undefined {
return this.providers.get(url.host);
}
async getCredentials(opts: {
url: string;
}): Promise<AzureDevOpsCredentials | undefined> {
const url = new URL(opts.url);
const provider =
this.forAzureDevOpsOrganization(url) ??
this.forAzureDevOpsServerOrganization(url) ??
this.forHost(url);
if (provider === undefined) {
return undefined;
}
return provider.getCredentials(opts);
}
}
+571 -105
View File
@@ -17,18 +17,30 @@
import { Config, ConfigReader } from '@backstage/config';
import { loadConfigSchema } from '@backstage/config-loader';
import {
AzureDevOpsCredentialLike,
AzureIntegrationConfig,
readAzureIntegrationConfig,
readAzureIntegrationConfigs,
} from './config';
type AzureIntegrationConfigLike = Partial<
Omit<AzureIntegrationConfig, 'credential' | 'credentials'>
> & {
credential?: Partial<AzureDevOpsCredentialLike>;
credentials?: Partial<AzureDevOpsCredentialLike>[];
};
describe('readAzureIntegrationConfig', () => {
function buildConfig(data: Partial<AzureIntegrationConfig>): Config {
const valid: any = {
host: 'dev.azure.com',
};
function buildConfig(data: AzureIntegrationConfigLike): Config {
return new ConfigReader(data);
}
async function buildFrontendConfig(
data: Partial<AzureIntegrationConfig>,
data: AzureIntegrationConfigLike,
): Promise<Config> {
const fullSchema = await loadConfigSchema({
dependencies: ['@backstage/integration'],
@@ -51,39 +63,155 @@ describe('readAzureIntegrationConfig', () => {
return new ConfigReader((processed[0].data as any).integrations.azure[0]);
}
it('reads all values when using a token', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'a.com',
token: 't',
}),
);
expect(output).toEqual({
host: 'a.com',
token: 't',
});
});
it('reads all values when using a credential', () => {
it('reads all values when using a personal access token credential', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'dev.azure.com',
credential: {
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
credentials: [
{
organizations: ['org1'],
personalAccessToken: 't',
},
],
}),
);
expect(output).toEqual({
host: 'dev.azure.com',
credential: {
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
credentials: [
{
kind: 'PersonalAccessToken',
organizations: ['org1'],
personalAccessToken: 't',
},
],
});
});
it('reads all values when using a personal access token credential (without organizations)', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'a.com',
credentials: [
{
personalAccessToken: 't',
},
],
}),
);
expect(output).toEqual({
host: 'a.com',
credentials: [
{
kind: 'PersonalAccessToken',
personalAccessToken: 't',
},
],
});
});
it('reads all values when using a client secret credential', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'dev.azure.com',
credentials: [
{
organizations: ['org1', 'org2'],
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
],
}),
);
expect(output).toEqual({
host: 'dev.azure.com',
credentials: [
{
kind: 'ClientSecret',
organizations: ['org1', 'org2'],
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
],
});
});
it('reads all values when using a client secret credential (without organizations)', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'dev.azure.com',
credentials: [
{
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
],
}),
);
expect(output).toEqual({
host: 'dev.azure.com',
credentials: [
{
kind: 'ClientSecret',
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
],
});
});
it('reads all values when using a managed identity credential', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'dev.azure.com',
credentials: [
{
organizations: ['org1', 'org2'],
clientId: 'id',
},
],
}),
);
expect(output).toEqual({
host: 'dev.azure.com',
credentials: [
{
kind: 'ManagedIdentity',
organizations: ['org1', 'org2'],
clientId: 'id',
},
],
});
});
it('reads all values when using a managed identity credential (without organizations)', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'dev.azure.com',
credentials: [
{
clientId: 'id',
},
],
}),
);
expect(output).toEqual({
host: 'dev.azure.com',
credentials: [
{
kind: 'ManagedIdentity',
clientId: 'id',
},
],
});
});
@@ -92,96 +220,296 @@ describe('readAzureIntegrationConfig', () => {
expect(output).toEqual({ host: 'dev.azure.com' });
});
it('rejects funky configs', () => {
const valid: any = {
host: 'dev.azure.com',
};
it('maps deprecated token to credentials', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'dev.azure.com',
token: 't',
}),
);
expect(output).toEqual({
host: 'dev.azure.com',
credentials: [
{
kind: 'PersonalAccessToken',
personalAccessToken: 't',
},
],
});
});
it('maps deprecated credential to credentials', () => {
const output = readAzureIntegrationConfig(
buildConfig({
host: 'dev.azure.com',
credential: {
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenantId',
},
}),
);
expect(output).toEqual({
host: 'dev.azure.com',
credentials: [
{
kind: 'ClientSecret',
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenantId',
},
],
});
});
it('rejects config when host is not valid', () => {
expect(() =>
readAzureIntegrationConfig(buildConfig({ ...valid, host: 7 })),
).toThrow(/host/);
});
expect(() =>
readAzureIntegrationConfig(buildConfig({ ...valid, token: 7 })),
).toThrow(/token/);
expect(() =>
readAzureIntegrationConfig(buildConfig({ ...valid, credential: 7 })),
).toThrow(/credential/);
expect(() =>
readAzureIntegrationConfig(
buildConfig({ ...valid, credential: { clientId: 7 } }),
),
).toThrow(/credential/);
expect(() =>
readAzureIntegrationConfig(
buildConfig({ ...valid, credential: { clientSecret: 7 } }),
),
).toThrow(/credential/);
expect(() =>
readAzureIntegrationConfig(
buildConfig({ ...valid, credential: { tenantId: 7 } }),
),
).toThrow(/credential/);
expect(() =>
readAzureIntegrationConfig(buildConfig({ ...valid, credential: {} })),
).toThrow(/credential/);
expect(() =>
readAzureIntegrationConfig(
buildConfig({ ...valid, credential: { clientSecret: 'secret' } }),
),
).toThrow(/credential/);
expect(() =>
readAzureIntegrationConfig(
buildConfig({ ...valid, credential: { tenantId: 'tenant' } }),
),
).toThrow(/credential/);
it('rejects config when organizations is not valid', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
credential: { clientId: 'id', clientSecret: 'secret' },
credentials: [
{
organizations: [1, 2, 'org'],
},
],
}),
),
).toThrow(/credential/);
).toThrow(/credentials/);
});
it('rejects config when token is not valid', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
credential: { clientId: 'id', tenantId: 'tenant' },
credentials: [
{
personalAccessToken: 7,
},
],
}),
),
).toThrow(/credential/);
).toThrow(/credentials/);
});
it('rejects config when clientId is not valid', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
credentials: [
{
clientId: 7,
},
],
}),
),
).toThrow(/credentials/);
});
it('rejects config when clientSecret is not valid', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
credentials: [
{
clientId: 'id',
clientSecret: 7,
tenantId: 'tenant',
},
],
}),
),
).toThrow(/credentials/);
});
it('rejects config when tenantId is not valid', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
credentials: [
{
clientId: 'id',
clientSecret: 'secret',
tenantId: 7,
},
],
}),
),
).toThrow(/credentials/);
});
it('rejects config when at least one credential not valid', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
credentials: [
{
personalAccessToken: 'token',
},
{
clientId: 'id',
},
{
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
{
clientId: 'id',
personalAccessToken: 'token',
},
],
}),
),
).toThrow(/not a valid credential/);
});
it('rejects config when using a client secret credential for Azure DevOps server', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
host: 'a.com',
credentials: [
{
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
],
}),
),
).toThrow(/hosts/);
});
it('rejects config when using a managed identity for Azure DevOps server', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
host: 'a.com',
credentials: [
{
organizations: ['org1', 'org2'],
clientId: 'id',
},
],
}),
),
).toThrow(/personal access tokens/);
});
it('rejects config when using organizations for Azure DevOps server', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
host: 'a.com',
credentials: [
{
clientId: 'id',
},
],
}),
),
).toThrow(/hosts/);
});
it('rejects config when both the credential and credentials field are specified', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
credential: {
clientId: 'id',
tenantId: 'tenant',
clientSecret: 'secret',
},
credentials: [
{
clientId: 'id',
},
],
}),
),
).toThrow(/credential/);
});
it('rejects config when both the token and credentials field are specified', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
token: 'token',
credentials: [
{
clientId: 'id',
},
],
}),
),
).toThrow(/token/);
});
it('rejects config when more than one credential does not specify an organization', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
credentials: [
{
clientId: 'id',
},
{
organizations: [],
personalAccessToken: 'pat',
},
],
}),
),
).toThrow(/organizations/);
});
it('rejects config when multiple credentials specify the same organization', () => {
expect(() =>
readAzureIntegrationConfig(
buildConfig({
...valid,
credentials: [
{
organizations: ['org1', 'org2'],
clientId: 'id',
},
{
organizations: ['org2', 'org3'],
personalAccessToken: 'pat',
},
],
}),
),
).toThrow(/organization org2/);
});
it('works on the frontend', async () => {
expect(
readAzureIntegrationConfig(
await buildFrontendConfig({
host: 'a.com',
token: 't',
credentials: [
{
personalAccessToken: 't',
},
],
}),
),
).toEqual({
@@ -191,46 +519,184 @@ describe('readAzureIntegrationConfig', () => {
});
describe('readAzureIntegrationConfigs', () => {
function buildConfig(data: Partial<AzureIntegrationConfig>[]): Config[] {
function buildConfig(data: AzureIntegrationConfigLike[]): Config[] {
return data.map(item => new ConfigReader(item));
}
it('reads all values when using a token', () => {
const output = readAzureIntegrationConfigs(
buildConfig([
{
host: 'a.com',
token: 't',
},
]),
);
expect(output).toContainEqual({
host: 'a.com',
token: 't',
});
});
it('reads all values when using a credential', () => {
it('reads all values when using a personal access token credential', () => {
const output = readAzureIntegrationConfigs(
buildConfig([
{
host: 'dev.azure.com',
credential: {
credentials: [
{
organizations: ['org1'],
personalAccessToken: 't',
},
],
},
]),
);
expect(output).toEqual([
{
host: 'dev.azure.com',
credentials: [
{
kind: 'PersonalAccessToken',
organizations: ['org1'],
personalAccessToken: 't',
},
],
},
]);
});
it('reads all values when using a personal access token credential (without organizations)', () => {
const output = readAzureIntegrationConfigs(
buildConfig([
{
host: 'dev.azure.com',
credentials: [
{
personalAccessToken: 't',
},
],
},
]),
);
expect(output).toEqual([
{
host: 'dev.azure.com',
credentials: [
{
kind: 'PersonalAccessToken',
personalAccessToken: 't',
},
],
},
]);
});
it('reads all values when using a client secret credential', () => {
const output = readAzureIntegrationConfigs(
buildConfig([
{
host: 'dev.azure.com',
credentials: [
{
organizations: ['org1', 'org2'],
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
],
},
]),
);
expect(output).toEqual([
{
host: 'dev.azure.com',
credentials: [
{
kind: 'ClientSecret',
organizations: ['org1', 'org2'],
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
],
},
]);
});
it('reads all values when using a client secret credential (without organizations)', () => {
const output = readAzureIntegrationConfigs(
buildConfig([
{
host: 'dev.azure.com',
credentials: [
{
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
],
},
]),
);
expect(output).toContainEqual({
host: 'dev.azure.com',
credential: {
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
expect(output).toEqual([
{
host: 'dev.azure.com',
credentials: [
{
kind: 'ClientSecret',
clientId: 'id',
clientSecret: 'secret',
tenantId: 'tenant',
},
],
},
});
]);
});
it('reads all values when using a managed identity credential', () => {
const output = readAzureIntegrationConfigs(
buildConfig([
{
host: 'dev.azure.com',
credentials: [
{
organizations: ['org1', 'org2'],
clientId: 'id',
},
],
},
]),
);
expect(output).toEqual([
{
host: 'dev.azure.com',
credentials: [
{
kind: 'ManagedIdentity',
organizations: ['org1', 'org2'],
clientId: 'id',
},
],
},
]);
});
it('reads all values when using a managed identity credential (without organizations)', () => {
const output = readAzureIntegrationConfigs(
buildConfig([
{
host: 'dev.azure.com',
credentials: [
{
clientId: 'id',
},
],
},
]),
);
expect(output).toEqual([
{
host: 'dev.azure.com',
credentials: [
{
kind: 'ManagedIdentity',
clientId: 'id',
},
],
},
]);
});
it('adds a default entry when missing', () => {
+243 -51
View File
@@ -36,6 +36,8 @@ export type AzureIntegrationConfig = {
* The authorization token to use for requests.
*
* If no token is specified, anonymous access is used.
*
* @deprecated Use `credentials` instead.
*/
token?: string;
@@ -43,15 +45,50 @@ export type AzureIntegrationConfig = {
* The credential to use for requests.
*
* If no credential is specified anonymous access is used.
*
* @deprecated Use `credentials` instead.
*/
credential?: AzureCredential;
credential?: AzureDevOpsCredential;
/**
* The credentials to use for requests. If multiple credentials are specified the first one that matches the organization is used.
* If not organization matches the first credential without an organization is used.
*
* If no credentials are specified at all, either a default credential (for Azure DevOps) or anonymous access (for Azure DevOps Server) is used.
*/
credentials?: AzureDevOpsCredential[];
};
/**
* Authenticate using a client secret that was generated for an App Registration.
* The kind of Azure DevOps credential.
* @public
*/
export type AzureClientSecretCredential = {
export type AzureDevOpsCredentialKind =
| 'PersonalAccessToken'
| 'ClientSecret'
| 'ManagedIdentity';
/**
* Common fields for the Azure DevOps credentials.
* @public
*/
export type AzureCredentialBase = {
/**
* The kind of credential.
*/
kind: AzureDevOpsCredentialKind;
/**
* The Azure DevOps organizations for which to use this credential.
*/
organizations?: string[];
};
/**
* A client secret credential that was generated for an App Registration.
* @public
*/
export type AzureClientSecretCredential = AzureCredentialBase & {
kind: 'ClientSecret';
/**
* The Azure Active Directory tenant
*/
@@ -68,10 +105,11 @@ export type AzureClientSecretCredential = {
};
/**
* Authenticate using a managed identity available at the deployment environment.
* A managed identity credential.
* @public
*/
export type AzureManagedIdentityCredential = {
export type AzureManagedIdentityCredential = AzureCredentialBase & {
kind: 'ManagedIdentity';
/**
* The clientId
*/
@@ -79,33 +117,77 @@ export type AzureManagedIdentityCredential = {
};
/**
* Credential used to authenticate to Azure Active Directory.
* A personal access token credential.
* @public
*/
export type AzureCredential =
export type PersonalAccessTokenCredential = AzureCredentialBase & {
kind: 'PersonalAccessToken';
personalAccessToken: string;
};
/**
* The general shape of a credential that can be used to authenticate to Azure DevOps.
* @public
*/
export type AzureDevOpsCredentialLike = Omit<
Partial<AzureClientSecretCredential> &
Partial<AzureManagedIdentityCredential> &
Partial<PersonalAccessTokenCredential>,
'kind'
>;
/**
* Credential used to authenticate to Azure DevOps.
* @public
*/
export type AzureDevOpsCredential =
| AzureClientSecretCredential
| AzureManagedIdentityCredential;
export const isAzureClientSecretCredential = (
credential: Partial<AzureCredential>,
): credential is AzureClientSecretCredential => {
const clientSecretCredential = credential as AzureClientSecretCredential;
| AzureManagedIdentityCredential
| PersonalAccessTokenCredential;
return (
Object.keys(credential).length === 3 &&
clientSecretCredential.clientId !== undefined &&
clientSecretCredential.clientSecret !== undefined &&
clientSecretCredential.tenantId !== undefined
);
};
const AzureDevOpsCredentialFields = [
'clientId',
'clientSecret',
'tenantId',
'personalAccessToken',
] as const;
type AzureDevOpsCredentialField = (typeof AzureDevOpsCredentialFields)[number];
export const isAzureManagedIdentityCredential = (
credential: Partial<AzureCredential>,
): credential is AzureManagedIdentityCredential => {
return (
Object.keys(credential).length === 1 &&
(credential as AzureManagedIdentityCredential).clientId !== undefined
);
};
const AzureDevopsCredentialFieldMap = new Map<
AzureDevOpsCredentialKind,
AzureDevOpsCredentialField[]
>([
['ClientSecret', ['clientId', 'clientSecret', 'tenantId']],
['ManagedIdentity', ['clientId']],
['PersonalAccessToken', ['personalAccessToken']],
]);
function asAzureDevOpsCredential(
credential: AzureDevOpsCredentialLike,
): AzureDevOpsCredential {
for (const entry of AzureDevopsCredentialFieldMap.entries()) {
const [kind, requiredFields] = entry;
const forbiddenFields = AzureDevOpsCredentialFields.filter(
field => !requiredFields.includes(field as AzureDevOpsCredentialField),
);
if (
requiredFields.every(field => credential[field] !== undefined) &&
forbiddenFields.every(field => credential[field] === undefined)
) {
return {
kind,
organizations: credential.organizations,
...requiredFields.reduce((acc, field) => {
acc[field] = credential[field];
return acc;
}, {} as Record<string, any>),
} as AzureDevOpsCredential;
}
}
throw new Error('is not a valid credential');
}
/**
* Reads a single Azure integration config.
@@ -117,15 +199,62 @@ export function readAzureIntegrationConfig(
config: Config,
): AzureIntegrationConfig {
const host = config.getOptionalString('host') ?? AZURE_HOST;
let credentialConfigs = config
.getOptionalConfigArray('credentials')
?.map(credential => {
const result: Partial<AzureDevOpsCredentialLike> = {
organizations: credential.getOptionalStringArray('organizations'),
personalAccessToken: credential.getOptionalString(
'personalAccessToken',
),
tenantId: credential.getOptionalString('tenantId'),
clientId: credential.getOptionalString('clientId'),
clientSecret: credential.getOptionalString('clientSecret'),
};
return result;
});
const token = config.getOptionalString('token');
const credential = config.getOptional<AzureCredential>('credential')
? {
if (
config.getOptional('credential') !== undefined &&
config.getOptional('credentials') !== undefined
) {
throw new Error(
`Invalid Azure integration config, 'credential' and 'credentials' cannot be used together. Use 'credentials' instead.`,
);
}
if (
config.getOptional('token') !== undefined &&
config.getOptional('credentials') !== undefined
) {
throw new Error(
`Invalid Azure integration config, 'token' and 'credentials' cannot be used together. Use 'credentials' instead.`,
);
}
if (token !== undefined) {
const mapped = [{ personalAccessToken: token }];
credentialConfigs = credentialConfigs?.concat(mapped) ?? mapped;
}
if (config.getOptional('credential') !== undefined) {
const mapped = [
{
organizations: config.getOptionalStringArray(
'credential.organizations',
),
token: config.getOptionalString('credential.token'),
tenantId: config.getOptionalString('credential.tenantId'),
clientId: config.getOptionalString('credential.clientId'),
clientSecret: config.getOptionalString('credential.clientSecret'),
}
: undefined;
},
];
credentialConfigs = credentialConfigs?.concat(mapped) ?? mapped;
}
if (!isValidHost(host)) {
throw new Error(
@@ -133,29 +262,92 @@ export function readAzureIntegrationConfig(
);
}
if (
credential &&
!isAzureClientSecretCredential(credential) &&
!isAzureManagedIdentityCredential(credential)
) {
throw new Error(
`Invalid Azure integration config, credential is not valid`,
let credentials: AzureDevOpsCredential[] | undefined = undefined;
if (credentialConfigs !== undefined) {
const errors = credentialConfigs
?.reduce((acc, credentialConfig, index) => {
let error: string | undefined = undefined;
try {
asAzureDevOpsCredential(credentialConfig);
} catch (e) {
error = e.message;
}
if (error !== undefined) {
acc.push(`credential at position ${index + 1} ${error}`);
}
return acc;
}, Array.of<string>())
.concat(
Object.entries(
credentialConfigs
.filter(
credential =>
credential.organizations !== undefined &&
credential.organizations.length > 0,
)
.reduce((acc, credential, index) => {
credential.organizations?.forEach(organization => {
if (!acc[organization]) {
acc[organization] = [];
}
acc[organization].push(index + 1);
});
return acc;
}, {} as Record<string, number[]>),
)
.filter(([_, indexes]) => indexes.length > 1)
.reduce((acc, [org, indexes]) => {
acc.push(
`organization ${org} is specified multiple times in credentials at positions ${indexes
.slice(0, indexes.length - 1)
.join(', ')} and ${indexes[indexes.length - 1]}`,
);
return acc;
}, Array.of<string>()),
);
if (errors?.length > 0) {
throw new Error(
`Invalid Azure integration config for ${host}: ${errors.join('; ')}`,
);
}
credentials = credentialConfigs.map(credentialConfig =>
asAzureDevOpsCredential(credentialConfig),
);
if (
credentials.some(
credential => credential.kind !== 'PersonalAccessToken',
) &&
host !== AZURE_HOST
) {
throw new Error(
`Invalid Azure integration config for ${host}, only personal access tokens can be used with hosts other than ${AZURE_HOST}`,
);
}
if (
credentials.filter(
credential =>
credential.organizations === undefined ||
credential.organizations.length === 0,
).length > 1
) {
throw new Error(
`Invalid Azure integration config for ${host}, you cannot specify multiple credentials without organizations`,
);
}
}
if (credential && host !== AZURE_HOST) {
throw new Error(
`Invalid Azure integration config, credential can only be used with ${AZURE_HOST}`,
);
}
if (credential && token) {
throw new Error(
`Invalid Azure integration config, specify either a token or a credential but not both`,
);
}
return { host, token, credential };
return {
host,
credentials,
};
}
/**
+1 -65
View File
@@ -14,11 +14,7 @@
* limitations under the License.
*/
import {
getAzureFileFetchUrl,
getAzureDownloadUrl,
getAzureRequestOptions,
} from './core';
import { getAzureFileFetchUrl, getAzureDownloadUrl } from './core';
import {
AccessToken,
ClientSecretCredential,
@@ -44,66 +40,6 @@ MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
);
describe('azure core', () => {
describe('getAzureRequestOptions', () => {
it('should not add authorization header when not using token or credential', async () => {
expect(await getAzureRequestOptions({ host: '' })).toEqual(
expect.objectContaining({
headers: expect.not.objectContaining({
Authorization: expect.anything(),
}),
}),
);
});
it('should add authorization header when using a personal access token', async () => {
expect(
await getAzureRequestOptions({ host: '', token: '0123456789' }),
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Basic OjAxMjM0NTY3ODk=',
}),
}),
);
});
it('should add authorization header when using a client secret', async () => {
expect(
await getAzureRequestOptions({
host: '',
credential: {
clientId: 'fake-id',
clientSecret: 'fake-secret',
tenantId: 'fake-tenant',
},
}),
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fake-client-secret-token',
}),
}),
);
});
it('should add authorization header when using a managed identity', async () => {
expect(
await getAzureRequestOptions({
host: '',
credential: {
clientId: 'fake-id',
},
}),
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fake-managed-identity-token',
}),
}),
);
});
});
describe('getAzureFileFetchUrl', () => {
it.each([
{
-51
View File
@@ -15,15 +15,6 @@
*/
import { AzureUrl } from './AzureUrl';
import {
AzureIntegrationConfig,
isAzureManagedIdentityCredential,
isAzureClientSecretCredential,
} from './config';
import {
ClientSecretCredential,
ManagedIdentityCredential,
} from '@azure/identity';
/**
* Given a URL pointing to a file on a provider, returns a URL that is suitable
@@ -62,45 +53,3 @@ export function getAzureDownloadUrl(url: string): string {
export function getAzureCommitsUrl(url: string): string {
return AzureUrl.fromRepoUrl(url).toCommitsUrl();
}
/**
* Gets the request options necessary to make requests to a given provider.
*
* @param config - The relevant provider config
* @param additionalHeaders - Additional headers for the request
* @public
*/
export async function getAzureRequestOptions(
config: AzureIntegrationConfig,
additionalHeaders?: Record<string, string>,
): Promise<{ headers: Record<string, string> }> {
const azureDevOpsScope = '499b84ac-1321-427f-aa17-267ca6975798/.default';
const headers: Record<string, string> = additionalHeaders
? { ...additionalHeaders }
: {};
const { token, credential } = config;
if (credential) {
if (isAzureClientSecretCredential(credential)) {
const servicePrincipal = new ClientSecretCredential(
credential.tenantId,
credential.clientId,
credential.clientSecret,
);
const accessToken = await servicePrincipal.getToken(azureDevOpsScope);
headers.Authorization = `Bearer ${accessToken.token}`;
} else if (isAzureManagedIdentityCredential(credential)) {
const managedIdentity = new ManagedIdentityCredential(
credential.clientId,
);
const accessToken = await managedIdentity.getToken(azureDevOpsScope);
headers.Authorization = `Bearer ${accessToken.token}`;
}
} else if (token) {
const buffer = Buffer.from(`:${config.token}`, 'utf8');
headers.Authorization = `Basic ${buffer.toString('base64')}`;
}
return { headers };
}
@@ -0,0 +1,128 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getAzureRequestOptions } from './deprecated';
import { DateTime } from 'luxon';
import {
AccessToken,
ClientSecretCredential,
ManagedIdentityCredential,
} from '@azure/identity';
jest.mock('@azure/identity');
const MockedClientSecretCredential = ClientSecretCredential as jest.MockedClass<
typeof ClientSecretCredential
>;
const MockedManagedIdentityCredential =
ManagedIdentityCredential as jest.MockedClass<
typeof ManagedIdentityCredential
>;
describe('azure core', () => {
beforeEach(() => {
jest.resetAllMocks();
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
token: 'fake-client-secret-token',
} as AccessToken),
);
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
Promise.resolve({
expiresOnTimestamp: DateTime.local().plus({ days: 1 }).toSeconds(),
token: 'fake-managed-identity-token',
} as AccessToken),
);
});
describe('getAzureRequestOptions', () => {
it('should not add authorization header when not using token or credential', async () => {
expect(await getAzureRequestOptions({ host: '' })).toEqual(
expect.objectContaining({
headers: expect.not.objectContaining({
Authorization: expect.anything(),
}),
}),
);
});
it('should add authorization header when using a personal access token', async () => {
const pat = '0123456789';
const encoded = Buffer.from(`:${pat}`).toString('base64');
expect(
await getAzureRequestOptions({
host: '',
credentials: [
{
kind: 'PersonalAccessToken',
personalAccessToken: pat,
},
],
}),
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: `Basic ${encoded}`,
}),
}),
);
});
it('should add authorization header when using a client secret', async () => {
expect(
await getAzureRequestOptions({
host: '',
credentials: [
{
kind: 'ClientSecret',
clientId: 'fake-id',
clientSecret: 'fake-secret',
tenantId: 'fake-tenant',
},
],
}),
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fake-client-secret-token',
}),
}),
);
});
it('should add authorization header when using a managed identity', async () => {
expect(
await getAzureRequestOptions({
host: '',
credentials: [
{
kind: 'ManagedIdentity',
clientId: 'fake-id',
},
],
}),
).toEqual(
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fake-managed-identity-token',
}),
}),
);
});
});
});
@@ -0,0 +1,61 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AzureIntegrationConfig } from './config';
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
/**
* Gets the request options necessary to make requests to a given provider.
*
* @param config - The relevant provider config
* @param additionalHeaders - Additional headers for the request
* @public
* @deprecated Use {@link AzureDevOpsCredentialsProvider} instead.
*/
export async function getAzureRequestOptions(
config: AzureIntegrationConfig,
additionalHeaders?: Record<string, string>,
): Promise<{ headers: Record<string, string> }> {
const headers: Record<string, string> = additionalHeaders
? { ...additionalHeaders }
: {};
/*
* Since we do not have a way to determine which organization the request is for,
* we will use the first credential that does not have an organization specified.
*/
const credentialConfig = config.credentials?.filter(
credential =>
credential.organizations === undefined ||
credential.organizations.length === 0,
)[0];
if (credentialConfig) {
const credentialsProvider =
CachedAzureDevOpsCredentialsProvider.fromAzureDevOpsCredential(
credentialConfig,
);
const credentials = await credentialsProvider.getCredentials();
return {
headers: {
...credentials?.headers,
...headers,
},
};
}
return { headers };
}
+11 -3
View File
@@ -21,13 +21,21 @@ export {
} from './config';
export type {
AzureIntegrationConfig,
AzureCredential,
AzureManagedIdentityCredential,
AzureDevOpsCredentialKind,
AzureCredentialBase,
AzureClientSecretCredential,
AzureManagedIdentityCredential,
PersonalAccessTokenCredential,
AzureDevOpsCredentialLike,
AzureDevOpsCredential,
} from './config';
export {
getAzureCommitsUrl,
getAzureDownloadUrl,
getAzureFileFetchUrl,
getAzureRequestOptions,
} from './core';
export * from './types';
export { DefaultAzureDevOpsCredentialsProvider } from './DefaultAzureDevOpsCredentialsProvider';
export * from './deprecated';
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The type of Azure DevOps credential, either bearer or pat.
* @public
*/
export type AzureDevOpsCredentialType = 'bearer' | 'pat';
/**
* A set of credentials for Azure DevOps.
*
* @public
*/
export type AzureDevOpsCredentials = {
headers: { [name: string]: string };
token: string;
type: AzureDevOpsCredentialType;
};
/**
* This allows implementations to be provided to retrieve Azure DevOps credentials.
*
* @public
*
*/
export interface AzureDevOpsCredentialsProvider {
getCredentials(opts: {
url: string;
}): Promise<AzureDevOpsCredentials | undefined>;
}
@@ -18,11 +18,40 @@ import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { codeSearch, CodeSearchResponse } from './azure';
import {
DefaultAzureDevOpsCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
describe('azure', () => {
const server = setupServer();
setupRequestMockHandlers(server);
const createFixture = (host: string, token: string) => {
const azureConfig = {
host: host,
credentials: [
{
personalAccessToken: token,
},
],
};
const scmIntegrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
azure: [azureConfig],
},
}),
);
return {
azureConfig: scmIntegrations.azure.byHost(host)?.config!,
credentialsProvider:
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations),
};
};
describe('codeSearch', () => {
it('returns empty when nothing is found', async () => {
const response: CodeSearchResponse = { count: 0, results: [] };
@@ -42,9 +71,14 @@ describe('azure', () => {
),
);
const { credentialsProvider, azureConfig } = createFixture(
'dev.azure.com',
'ABC',
);
await expect(
codeSearch(
{ host: 'dev.azure.com', token: 'ABC' },
credentialsProvider,
azureConfig,
'shopify',
'engineering',
'',
@@ -96,9 +130,14 @@ describe('azure', () => {
),
);
const { credentialsProvider, azureConfig } = createFixture(
'dev.azure.com',
'ABC',
);
await expect(
codeSearch(
{ host: 'dev.azure.com', token: 'ABC' },
credentialsProvider,
azureConfig,
'shopify',
'engineering',
'',
@@ -140,9 +179,15 @@ describe('azure', () => {
),
);
const { credentialsProvider, azureConfig } = createFixture(
'dev.azure.com',
'ABC',
);
await expect(
codeSearch(
{ host: 'dev.azure.com', token: 'ABC' },
credentialsProvider,
azureConfig,
'shopify',
'engineering',
'backstage',
@@ -183,9 +228,15 @@ describe('azure', () => {
),
);
const { credentialsProvider, azureConfig } = createFixture(
'azuredevops.mycompany.com',
'ABC',
);
await expect(
codeSearch(
{ host: 'azuredevops.mycompany.com', token: 'ABC' },
credentialsProvider,
azureConfig,
'shopify',
'engineering',
'',
@@ -236,9 +287,15 @@ describe('azure', () => {
),
);
const { credentialsProvider, azureConfig } = createFixture(
'dev.azure.com',
'ABC',
);
await expect(
codeSearch(
{ host: 'dev.azure.com', token: 'ABC' },
credentialsProvider,
azureConfig,
'shopify',
'engineering',
'backstage',
@@ -16,8 +16,8 @@
import fetch from 'node-fetch';
import {
AzureDevOpsCredentialsProvider,
AzureIntegrationConfig,
getAzureRequestOptions,
} from '@backstage/integration';
export interface CodeSearchResponse {
@@ -42,6 +42,7 @@ const PAGE_SIZE = 1000;
// codeSearch returns all files that matches the given search path.
export async function codeSearch(
credentialsProvider: AzureDevOpsCredentialsProvider,
azureConfig: AzureIntegrationConfig,
org: string,
project: string,
@@ -57,10 +58,15 @@ export async function codeSearch(
let hasMorePages = true;
do {
const credentials = await credentialsProvider.getCredentials({
url: `https://${azureConfig.host}/${org}`,
});
const response = await fetch(searchUrl, {
...(await getAzureRequestOptions(azureConfig, {
headers: {
...credentials?.headers,
'Content-Type': 'application/json',
})),
},
method: 'POST',
body: JSON.stringify({
searchText: `path:${path} repo:${repo || '*'} proj:${project || '*'}`,
@@ -158,6 +158,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
await processor.readLocation(location, false, emitter);
expect(mockCodeSearch).toHaveBeenCalledWith(
expect.anything(),
{ host: 'dev.azure.com' },
'shopify',
'engineering',
@@ -207,6 +208,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
await processor.readLocation(location, false, emitter);
expect(mockCodeSearch).toHaveBeenCalledWith(
expect.anything(),
{ host: 'dev.azure.com' },
'shopify',
'engineering',
@@ -248,6 +250,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
await processor.readLocation(location, false, emitter);
expect(mockCodeSearch).toHaveBeenCalledWith(
expect.anything(),
{ host: 'dev.azure.com' },
'shopify',
'engineering',
@@ -277,6 +280,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
await processor.readLocation(location, false, emitter);
expect(mockCodeSearch).toHaveBeenCalledWith(
expect.anything(),
{ host: 'dev.azure.com' },
'shopify',
'engineering',
@@ -16,6 +16,8 @@
import { Config } from '@backstage/config';
import {
AzureDevOpsCredentialsProvider,
DefaultAzureDevOpsCredentialsProvider,
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
@@ -46,6 +48,7 @@ import { codeSearch } from '../lib';
*/
export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
private readonly integrations: ScmIntegrationRegistry;
private readonly credentialsProvider: AzureDevOpsCredentialsProvider;
private readonly logger: Logger;
static fromConfig(config: Config, options: { logger: Logger }) {
@@ -63,6 +66,10 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
}) {
this.integrations = options.integrations;
this.logger = options.logger;
this.credentialsProvider =
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(
options.integrations,
);
}
getProcessorName(): string {
@@ -93,6 +100,7 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
);
const files = await codeSearch(
this.credentialsProvider,
azureConfig,
org,
project,
@@ -16,7 +16,12 @@
import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks';
import { Config } from '@backstage/config';
import { AzureIntegration, ScmIntegrations } from '@backstage/integration';
import {
AzureDevOpsCredentialsProvider,
AzureIntegration,
DefaultAzureDevOpsCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import {
EntityProvider,
EntityProviderConnection,
@@ -50,6 +55,9 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
},
): AzureDevOpsEntityProvider[] {
const providerConfigs = readAzureDevOpsConfigs(configRoot);
const scmIntegrations = ScmIntegrations.fromConfig(configRoot);
const credentialsProvider =
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations);
if (!options.schedule && !options.scheduler) {
throw new Error('Either schedule or scheduler must be provided.');
@@ -79,6 +87,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
return new AzureDevOpsEntityProvider(
providerConfig,
integration,
credentialsProvider,
options.logger,
taskRunner,
);
@@ -88,6 +97,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
private constructor(
private readonly config: AzureDevOpsConfig,
private readonly integration: AzureIntegration,
private readonly credentialsProvider: AzureDevOpsCredentialsProvider,
logger: Logger,
taskRunner: TaskRunner,
) {
@@ -139,6 +149,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
logger.info('Discovering Azure DevOps catalog files');
const files = await codeSearch(
this.credentialsProvider,
this.integration.config,
this.config.organization,
this.config.project,
@@ -42,7 +42,10 @@ describe('publish:azure', () => {
const config = new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{
host: 'dev.azure.com',
credentials: [{ personalAccessToken: 'tokenlols' }],
},
{ host: 'myazurehostnotoken.com' },
],
},
@@ -115,7 +118,9 @@ describe('publish:azure', () => {
'myazurehostnotoken.com?repo=bob&owner=owner&organization=org',
},
}),
).rejects.toThrow(/No token provided for Azure Integration/);
).rejects.toThrow(
/No credentials provided https:\/\/myazurehostnotoken.com\/org, please check your integrations config/,
);
});
it('should throw when no repo is returned', async () => {
@@ -260,7 +265,10 @@ describe('publish:azure', () => {
const customAuthorConfig = new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{
host: 'dev.azure.com',
credentials: [{ personalAccessToken: 'tokenlols' }],
},
{ host: 'myazurehostnotoken.com' },
],
},
@@ -301,7 +309,10 @@ describe('publish:azure', () => {
const customAuthorConfig = new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{
host: 'dev.azure.com',
credentials: [{ personalAccessToken: 'tokenlols' }],
},
{ host: 'myazurehostnotoken.com' },
],
},
@@ -15,10 +15,17 @@
*/
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import {
DefaultAzureDevOpsCredentialsProvider,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { initRepoAndPush } from '../helpers';
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
import {
getBearerHandler,
getPersonalAccessTokenHandler,
WebApi,
} from 'azure-devops-node-api';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { Config } from '@backstage/config';
@@ -135,22 +142,23 @@ export function createPublishAzureAction(options: {
);
}
const integrationConfig = integrations.azure.byHost(host);
const url = `https://${host}/${organization}`;
const credentialProvider =
DefaultAzureDevOpsCredentialsProvider.fromIntegrations(integrations);
const credentials = await credentialProvider.getCredentials({ url: url });
if (!integrationConfig) {
if (credentials === undefined && ctx.input.token === undefined) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
`No credentials provided ${url}, please check your integrations config`,
);
}
if (!integrationConfig.config.token && !ctx.input.token) {
throw new InputError(`No token provided for Azure Integration ${host}`);
}
const authHandler =
ctx.input.token || credentials?.type === 'pat'
? getPersonalAccessTokenHandler(ctx.input.token ?? credentials!.token)
: getBearerHandler(credentials!.token);
const token = ctx.input.token ?? integrationConfig.config.token!;
const authHandler = getPersonalAccessTokenHandler(token);
const webApi = new WebApi(`https://${host}/${organization}`, authHandler);
const webApi = new WebApi(url, authHandler);
const client = await webApi.getGitApi();
const createOptions: GitRepositoryCreateOptions = { name: repo };
const returnedRepo = await client.createRepository(createOptions, owner);
@@ -187,14 +195,19 @@ export function createPublishAzureAction(options: {
: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
const auth =
ctx.input.token || credentials?.type === 'pat'
? {
username: 'notempty',
password: ctx.input.token ?? credentials!.token,
}
: { token: credentials!.token };
const commitResult = await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
defaultBranch,
auth: {
username: 'notempty',
password: token,
},
auth: auth,
logger: ctx.logger,
commitMessage: gitCommitMessage
? gitCommitMessage