Merge pull request #17780 from XpiritBV/support-azdo-identities

feat(azure): support service principals and managed identities
This commit is contained in:
Ben Lambert
2023-05-29 12:44:15 +02:00
committed by GitHub
12 changed files with 436 additions and 37 deletions
+18 -2
View File
@@ -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,
+1
View File
@@ -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",
+121 -4
View File
@@ -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([
+96 -1
View File
@@ -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 };
}
/**
+69 -5
View File
@@ -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',
}),
}),
);
+32 -4
View File
@@ -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')}`;
}
+6 -1
View File
@@ -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,