Merge pull request #17780 from XpiritBV/support-azdo-identities
feat(azure): support service principals and managed identities
This commit is contained in:
@@ -38,6 +38,9 @@ export type AwsS3IntegrationConfig = {
|
||||
externalId?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AzureCredential = ClientSecret | ManagedIdentity;
|
||||
|
||||
// @public
|
||||
export class AzureIntegration implements ScmIntegration {
|
||||
constructor(integrationConfig: AzureIntegrationConfig);
|
||||
@@ -63,6 +66,7 @@ export class AzureIntegration implements ScmIntegration {
|
||||
export type AzureIntegrationConfig = {
|
||||
host: string;
|
||||
token?: string;
|
||||
credential?: AzureCredential;
|
||||
};
|
||||
|
||||
// @public
|
||||
@@ -154,6 +158,13 @@ export type BitbucketServerIntegrationConfig = {
|
||||
password?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ClientSecret = {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export class DefaultGithubCredentialsProvider
|
||||
implements GithubCredentialsProvider
|
||||
@@ -216,9 +227,9 @@ export function getAzureFileFetchUrl(url: string): string;
|
||||
export function getAzureRequestOptions(
|
||||
config: AzureIntegrationConfig,
|
||||
additionalHeaders?: Record<string, string>,
|
||||
): {
|
||||
): Promise<{
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export function getBitbucketCloudDefaultBranch(
|
||||
@@ -535,6 +546,11 @@ export interface IntegrationsByType {
|
||||
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ManagedIdentity = {
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function parseGerritGitilesUrl(
|
||||
config: GerritIntegrationConfig,
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"clean": "backstage-cli package clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/identity": "^3.2.1",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@octokit/auth-app": "^4.0.0",
|
||||
|
||||
@@ -51,19 +51,42 @@ describe('readAzureIntegrationConfig', () => {
|
||||
return new ConfigReader((processed[0].data as any).integrations.azure[0]);
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
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', () => {
|
||||
const output = readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
host: 'dev.azure.com',
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
host: 'dev.azure.com',
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts the defaults if missing', () => {
|
||||
const output = readAzureIntegrationConfig(buildConfig({}));
|
||||
expect(output).toEqual({ host: 'dev.azure.com' });
|
||||
@@ -71,15 +94,86 @@ describe('readAzureIntegrationConfig', () => {
|
||||
|
||||
it('rejects funky configs', () => {
|
||||
const valid: any = {
|
||||
host: 'a.com',
|
||||
token: 't',
|
||||
host: 'dev.azure.com',
|
||||
};
|
||||
|
||||
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/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credential: { clientId: 'id', clientSecret: 'secret' },
|
||||
}),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
credential: { clientId: 'id', tenantId: 'tenant' },
|
||||
}),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
|
||||
expect(() =>
|
||||
readAzureIntegrationConfig(
|
||||
buildConfig({
|
||||
...valid,
|
||||
host: 'a.com',
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
tenantId: 'tenant',
|
||||
clientSecret: 'secret',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/credential/);
|
||||
});
|
||||
|
||||
it('works on the frontend', async () => {
|
||||
@@ -101,7 +195,7 @@ describe('readAzureIntegrationConfigs', () => {
|
||||
return data.map(item => new ConfigReader(item));
|
||||
}
|
||||
|
||||
it('reads all values', () => {
|
||||
it('reads all values when using a token', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
@@ -116,6 +210,29 @@ describe('readAzureIntegrationConfigs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all values when using a credential', () => {
|
||||
const output = readAzureIntegrationConfigs(
|
||||
buildConfig([
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(output).toContainEqual({
|
||||
host: 'dev.azure.com',
|
||||
credential: {
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
tenantId: 'tenant',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a default entry when missing', () => {
|
||||
const output = readAzureIntegrationConfigs(buildConfig([]));
|
||||
expect(output).toEqual([
|
||||
|
||||
@@ -38,6 +38,71 @@ export type AzureIntegrationConfig = {
|
||||
* If no token is specified, anonymous access is used.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* The credential to use for requests.
|
||||
*
|
||||
* If no credential is specified anonymous access is used.
|
||||
*/
|
||||
credential?: AzureCredential;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate using a client secret that was generated for an App Registration.
|
||||
* @public
|
||||
*/
|
||||
export type ClientSecret = {
|
||||
/**
|
||||
* The Azure Active Directory tenant
|
||||
*/
|
||||
tenantId: string;
|
||||
/**
|
||||
* The client id
|
||||
*/
|
||||
clientId: string;
|
||||
|
||||
/**
|
||||
* The client secret
|
||||
*/
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate using a managed identity available at the deployment environment.
|
||||
* @public
|
||||
*/
|
||||
export type ManagedIdentity = {
|
||||
/**
|
||||
* The clientId
|
||||
*/
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Credential used to authenticate to Azure Active Directory.
|
||||
* @public
|
||||
*/
|
||||
export type AzureCredential = ClientSecret | ManagedIdentity;
|
||||
export const isServicePrincipal = (
|
||||
credential: Partial<AzureCredential>,
|
||||
): credential is ClientSecret => {
|
||||
const clientSecretCredential = credential as ClientSecret;
|
||||
|
||||
return (
|
||||
Object.keys(credential).length === 3 &&
|
||||
clientSecretCredential.clientId !== undefined &&
|
||||
clientSecretCredential.clientSecret !== undefined &&
|
||||
clientSecretCredential.tenantId !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const isManagedIdentity = (
|
||||
credential: Partial<AzureCredential>,
|
||||
): credential is ManagedIdentity => {
|
||||
return (
|
||||
Object.keys(credential).length === 1 &&
|
||||
(credential as ManagedIdentity).clientId !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -52,13 +117,43 @@ export function readAzureIntegrationConfig(
|
||||
const host = config.getOptionalString('host') ?? AZURE_HOST;
|
||||
const token = config.getOptionalString('token');
|
||||
|
||||
const credential = config.getOptional<AzureCredential>('credential')
|
||||
? {
|
||||
tenantId: config.getOptionalString('credential.tenantId'),
|
||||
clientId: config.getOptionalString('credential.clientId'),
|
||||
clientSecret: config.getOptionalString('credential.clientSecret'),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (!isValidHost(host)) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config, '${host}' is not a valid host`,
|
||||
);
|
||||
}
|
||||
|
||||
return { host, token };
|
||||
if (
|
||||
credential &&
|
||||
!isServicePrincipal(credential) &&
|
||||
!isManagedIdentity(credential)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid Azure integration config, credential is not valid`,
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,21 +19,85 @@ import {
|
||||
getAzureDownloadUrl,
|
||||
getAzureRequestOptions,
|
||||
} from './core';
|
||||
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');
|
||||
|
||||
MockedClientSecretCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({ token: 'fake-client-secret-token' } as AccessToken),
|
||||
);
|
||||
MockedManagedIdentityCredential.prototype.getToken.mockImplementation(() =>
|
||||
Promise.resolve({ token: 'fake-managed-identity-token' } as AccessToken),
|
||||
);
|
||||
|
||||
describe('azure core', () => {
|
||||
describe('getAzureRequestOptions', () => {
|
||||
it('fills in the token if necessary', () => {
|
||||
expect(getAzureRequestOptions({ host: '', token: '0123456789' })).toEqual(
|
||||
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=',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(getAzureRequestOptions({ host: '' })).toEqual(
|
||||
});
|
||||
|
||||
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.not.objectContaining({
|
||||
Authorization: expect.anything(),
|
||||
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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -15,7 +15,15 @@
|
||||
*/
|
||||
|
||||
import { AzureUrl } from './AzureUrl';
|
||||
import { AzureIntegrationConfig } from './config';
|
||||
import {
|
||||
AzureIntegrationConfig,
|
||||
isManagedIdentity,
|
||||
isServicePrincipal,
|
||||
} from './config';
|
||||
import {
|
||||
ClientSecretCredential,
|
||||
ManagedIdentityCredential,
|
||||
} from '@azure/identity';
|
||||
|
||||
/**
|
||||
* Given a URL pointing to a file on a provider, returns a URL that is suitable
|
||||
@@ -59,17 +67,37 @@ export function getAzureCommitsUrl(url: string): string {
|
||||
* 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 function getAzureRequestOptions(
|
||||
export async function getAzureRequestOptions(
|
||||
config: AzureIntegrationConfig,
|
||||
additionalHeaders?: Record<string, string>,
|
||||
): { headers: Record<string, string> } {
|
||||
): Promise<{ headers: Record<string, string> }> {
|
||||
const azureDevOpsScope = '499b84ac-1321-427f-aa17-267ca6975798/.default';
|
||||
const headers: Record<string, string> = additionalHeaders
|
||||
? { ...additionalHeaders }
|
||||
: {};
|
||||
|
||||
if (config.token) {
|
||||
const { token, credential } = config;
|
||||
if (credential) {
|
||||
if (isServicePrincipal(credential)) {
|
||||
const servicePrincipal = new ClientSecretCredential(
|
||||
credential.tenantId,
|
||||
credential.clientId,
|
||||
credential.clientSecret,
|
||||
);
|
||||
|
||||
const accessToken = await servicePrincipal.getToken(azureDevOpsScope);
|
||||
headers.Authorization = `Bearer ${accessToken.token}`;
|
||||
} else if (isManagedIdentity(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')}`;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ export {
|
||||
readAzureIntegrationConfig,
|
||||
readAzureIntegrationConfigs,
|
||||
} from './config';
|
||||
export type { AzureIntegrationConfig } from './config';
|
||||
export type {
|
||||
AzureIntegrationConfig,
|
||||
AzureCredential,
|
||||
ManagedIdentity,
|
||||
ClientSecret,
|
||||
} from './config';
|
||||
export {
|
||||
getAzureCommitsUrl,
|
||||
getAzureDownloadUrl,
|
||||
|
||||
Reference in New Issue
Block a user