From 8e3168e09db162cfaf4b46cf6b5c6bf89de4b71e Mon Sep 17 00:00:00 2001 From: Brett de Beer Date: Wed, 15 May 2024 13:33:18 +0800 Subject: [PATCH] Support workload identity federation service connection (#147) (#153) Add support for [Workload identity federation for Azure service connections](https://devblogs.microsoft.com/devops/workload-identity-federation-for-azure-deployments-is-now-generally-available/). This change is based on the implementation in the [Azure CLI Task V2](https://github.com/microsoft/azure-pipelines-tasks/blob/5278dc64cd07ce067e40f3e4a2bf5e15edf12b57/Tasks/AzureCLIV2/azureclitask.ts). Added [azure-pipelines-tasks-artifacts-common@2.230.0](https://www.npmjs.com/package/azure-pipelines-tasks-artifacts-common/v/2.230.0) so the [getSystemAccessToken](https://github.com/microsoft/azure-pipelines-tasks/blob/68caa90dd430a9f2a1cb2cacc1d8b6fcc48fbb71/Tasks/GradleV3/Modules/environment.ts#L10-L27) function could be used to get the system token to auth the request for the creation of the OIDC token. Tested in my Azure DevOps organisation with a new Pulumi project created using the Azure C# template which successfully deployed in pipeline run [20240505.12](https://dev.azure.com/brdbr/public-playground/_build/results?buildId=438&view=logs&j=12f1170f-54f2-53f3-20dd-22fc7dff55f9&t=eb980582-45a7-5e52-4a39-69f73379d8d6). --- buildAndReleaseTask/package-lock.json | 156 ++++++++++++++++++++++++- buildAndReleaseTask/package.json | 1 + buildAndReleaseTask/pulumi.ts | 20 +++- buildAndReleaseTask/serviceEndpoint.ts | 96 ++++++++++++++- 4 files changed, 260 insertions(+), 13 deletions(-) diff --git a/buildAndReleaseTask/package-lock.json b/buildAndReleaseTask/package-lock.json index 50d465f..19713cf 100644 --- a/buildAndReleaseTask/package-lock.json +++ b/buildAndReleaseTask/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.6.2", "azure-devops-node-api": "^12.1.0", "azure-pipelines-task-lib": "^4.7.0", + "azure-pipelines-tasks-artifacts-common": "^2.230.0", "azure-pipelines-tool-lib": "^2.0.7", "semver": "^7.5.4", "typed-rest-client": "^1.8.11" @@ -71,6 +72,14 @@ "@types/node": "*" } }, + "node_modules/@types/fs-extra": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.0.0.tgz", + "integrity": "sha512-bCtL5v9zdbQW86yexOlXWTEGvLNqWxMFyi7gQA7Gcthbezr2cPSOb8SkESVKA937QD5cIwOFLDFt0MQoXOEr9Q==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mocha": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-8.0.3.tgz", @@ -80,8 +89,7 @@ "node_modules/@types/node": { "version": "16.18.68", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.68.tgz", - "integrity": "sha512-sG3hPIQwJLoewrN7cr0dwEy+yF5nD4D/4FxtQpFciRD/xwUzgD+G05uxZHv5mhfXo4F9Jkp13jjn0CC2q325sg==", - "dev": true + "integrity": "sha512-sG3hPIQwJLoewrN7cr0dwEy+yF5nD4D/4FxtQpFciRD/xwUzgD+G05uxZHv5mhfXo4F9Jkp13jjn0CC2q325sg==" }, "node_modules/@types/q": { "version": "1.5.4", @@ -233,6 +241,42 @@ "semver": "bin/semver" } }, + "node_modules/azure-pipelines-tasks-artifacts-common": { + "version": "2.230.0", + "resolved": "https://registry.npmjs.org/azure-pipelines-tasks-artifacts-common/-/azure-pipelines-tasks-artifacts-common-2.230.0.tgz", + "integrity": "sha512-FWyRjR+eqNjjVvXwiIJVcfLN+DTmbS3icRgrL6zAGx7iIKJOJn+sjlKOuCIR36TaWU4KOVfDGJujDK6Z+WPY8g==", + "dependencies": { + "@types/fs-extra": "8.0.0", + "@types/mocha": "^5.2.6", + "@types/node": "^16.11.39", + "azure-devops-node-api": "12.0.0", + "azure-pipelines-task-lib": "^4.2.0", + "fs-extra": "8.1.0", + "semver": "6.3.0" + } + }, + "node_modules/azure-pipelines-tasks-artifacts-common/node_modules/@types/mocha": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", + "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==" + }, + "node_modules/azure-pipelines-tasks-artifacts-common/node_modules/azure-devops-node-api": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.0.0.tgz", + "integrity": "sha512-S6Il++7dQeMlZDokBDWw7YVoPeb90tWF10pYxnoauRMnkuL91jq9M7SOYRVhtO3FUC5URPkB/qzGa7jTLft0Xw==", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/azure-pipelines-tasks-artifacts-common/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/azure-pipelines-tool-lib": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/azure-pipelines-tool-lib/-/azure-pipelines-tool-lib-2.0.7.tgz", @@ -517,6 +561,19 @@ "node": ">= 0.12" } }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -568,6 +625,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -699,6 +761,14 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -1161,6 +1231,14 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", @@ -1268,6 +1346,14 @@ "@types/node": "*" } }, + "@types/fs-extra": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.0.0.tgz", + "integrity": "sha512-bCtL5v9zdbQW86yexOlXWTEGvLNqWxMFyi7gQA7Gcthbezr2cPSOb8SkESVKA937QD5cIwOFLDFt0MQoXOEr9Q==", + "requires": { + "@types/node": "*" + } + }, "@types/mocha": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-8.0.3.tgz", @@ -1277,8 +1363,7 @@ "@types/node": { "version": "16.18.68", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.68.tgz", - "integrity": "sha512-sG3hPIQwJLoewrN7cr0dwEy+yF5nD4D/4FxtQpFciRD/xwUzgD+G05uxZHv5mhfXo4F9Jkp13jjn0CC2q325sg==", - "dev": true + "integrity": "sha512-sG3hPIQwJLoewrN7cr0dwEy+yF5nD4D/4FxtQpFciRD/xwUzgD+G05uxZHv5mhfXo4F9Jkp13jjn0CC2q325sg==" }, "@types/q": { "version": "1.5.4", @@ -1413,6 +1498,41 @@ } } }, + "azure-pipelines-tasks-artifacts-common": { + "version": "2.230.0", + "resolved": "https://registry.npmjs.org/azure-pipelines-tasks-artifacts-common/-/azure-pipelines-tasks-artifacts-common-2.230.0.tgz", + "integrity": "sha512-FWyRjR+eqNjjVvXwiIJVcfLN+DTmbS3icRgrL6zAGx7iIKJOJn+sjlKOuCIR36TaWU4KOVfDGJujDK6Z+WPY8g==", + "requires": { + "@types/fs-extra": "8.0.0", + "@types/mocha": "^5.2.6", + "@types/node": "^16.11.39", + "azure-devops-node-api": "12.0.0", + "azure-pipelines-task-lib": "^4.2.0", + "fs-extra": "8.1.0", + "semver": "6.3.0" + }, + "dependencies": { + "@types/mocha": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", + "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==" + }, + "azure-devops-node-api": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.0.0.tgz", + "integrity": "sha512-S6Il++7dQeMlZDokBDWw7YVoPeb90tWF10pYxnoauRMnkuL91jq9M7SOYRVhtO3FUC5URPkB/qzGa7jTLft0Xw==", + "requires": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, "azure-pipelines-tool-lib": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/azure-pipelines-tool-lib/-/azure-pipelines-tool-lib-2.0.7.tgz", @@ -1630,6 +1750,16 @@ "mime-types": "^2.1.12" } }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1669,6 +1799,11 @@ "path-is-absolute": "^1.0.0" } }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -1775,6 +1910,14 @@ "esprima": "^4.0.0" } }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "requires": { + "graceful-fs": "^4.1.6" + } + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -2148,6 +2291,11 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==" }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, "utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", diff --git a/buildAndReleaseTask/package.json b/buildAndReleaseTask/package.json index fbd74c7..11c613e 100644 --- a/buildAndReleaseTask/package.json +++ b/buildAndReleaseTask/package.json @@ -15,6 +15,7 @@ "axios": "^1.6.2", "azure-devops-node-api": "^12.1.0", "azure-pipelines-task-lib": "^4.7.0", + "azure-pipelines-tasks-artifacts-common": "^2.230.0", "azure-pipelines-tool-lib": "^2.0.7", "semver": "^7.5.4", "typed-rest-client": "^1.8.11" diff --git a/buildAndReleaseTask/pulumi.ts b/buildAndReleaseTask/pulumi.ts index d2cf655..a38eb87 100644 --- a/buildAndReleaseTask/pulumi.ts +++ b/buildAndReleaseTask/pulumi.ts @@ -211,14 +211,14 @@ function getExecOptions( * this function returns an env var map with the `ARM_*` * env vars. */ -function tryGetAzureEnvVarsFromServiceEndpoint(): IEnvMap { +async function tryGetAzureEnvVarsFromServiceEndpoint(): Promise { const connectedServiceName = tl.getInput("azureSubscription", false); if (!connectedServiceName) { return {}; } tl.debug(tl.loc("Debug_ServiceEndpointName", connectedServiceName)); - const serviceEndpoint = getServiceEndpoint(connectedServiceName); + const serviceEndpoint = await getServiceEndpoint(connectedServiceName); if (serviceEndpoint) { tl.debug( `Service endpoint retrieved with client ID ${serviceEndpoint.clientId}` @@ -228,9 +228,8 @@ function tryGetAzureEnvVarsFromServiceEndpoint(): IEnvMap { return {}; } - return { + const envMap: IEnvMap = { ARM_CLIENT_ID: serviceEndpoint.clientId, - ARM_CLIENT_SECRET: serviceEndpoint.servicePrincipalKey, ARM_SUBSCRIPTION_ID: serviceEndpoint.subscriptionId, ARM_TENANT_ID: serviceEndpoint.tenantId, @@ -239,9 +238,17 @@ function tryGetAzureEnvVarsFromServiceEndpoint(): IEnvMap { // Azure Storage-based backend for state persistence. // https://docs.microsoft.com/en-us/azure/developer/go/azure-sdk-authorization#use-environment-based-authentication AZURE_CLIENT_ID: serviceEndpoint.clientId, - AZURE_CLIENT_SECRET: serviceEndpoint.servicePrincipalKey, AZURE_TENANT_ID: serviceEndpoint.tenantId, }; + if (serviceEndpoint.servicePrincipalKey) { + envMap["ARM_CLIENT_SECRET"] = serviceEndpoint.servicePrincipalKey; + envMap["AZURE_CLIENT_SECRET"] = serviceEndpoint.servicePrincipalKey; + } + if (serviceEndpoint.oidcToken) { + envMap["ARM_OIDC_TOKEN"] = serviceEndpoint.oidcToken; + envMap["ARM_USE_OIDC"] = "true"; + } + return envMap; } /** @@ -281,7 +288,8 @@ export async function runPulumi(taskConfig: TaskConfig) { try { const toolPath = tl.which("pulumi"); const agentEnvVars = tryGetEnvVars(); - const azureServiceEndpointEnvVars = tryGetAzureEnvVarsFromServiceEndpoint(); + const azureServiceEndpointEnvVars = + await tryGetAzureEnvVarsFromServiceEndpoint(); const loginEnvVars = { ...azureServiceEndpointEnvVars, ...agentEnvVars, diff --git a/buildAndReleaseTask/serviceEndpoint.ts b/buildAndReleaseTask/serviceEndpoint.ts index cd2d5d3..f1abfde 100644 --- a/buildAndReleaseTask/serviceEndpoint.ts +++ b/buildAndReleaseTask/serviceEndpoint.ts @@ -1,17 +1,21 @@ // Copyright 2016-2019, Pulumi Corporation. All rights reserved. +import { getHandlerFromToken, WebApi } from "azure-devops-node-api"; +import { ITaskApi } from "azure-devops-node-api/TaskApi"; import * as tl from "azure-pipelines-task-lib/task"; +import { getSystemAccessToken } from "azure-pipelines-tasks-artifacts-common/webapi"; export interface IServiceEndpoint { subscriptionId: string; - servicePrincipalKey: string; + oidcToken?: string; + servicePrincipalKey?: string; tenantId: string; clientId: string; } -export function getServiceEndpoint( +export async function getServiceEndpoint( connectedServiceName: string -): IServiceEndpoint | undefined { +): Promise { const endpointAuthorization = tl.getEndpointAuthorization( connectedServiceName, true @@ -20,6 +24,11 @@ export function getServiceEndpoint( return undefined; } + const oidcEndpoint = await tryGetOidcServiceEndpoint(connectedServiceName); + if (oidcEndpoint) { + return oidcEndpoint; + } + const endpoint = { clientId: tl.getEndpointAuthorizationParameter( connectedServiceName, @@ -47,4 +56,85 @@ export function getServiceEndpoint( return endpoint; } + +/** + * Tries to get the endpoint details for a workload identity federation + * service connection as per V2 of the Azure CLI task + * https://github.com/microsoft/azure-pipelines-tasks/blob/master/Tasks/AzureCLIV2/azureclitask.ts#L143-L152 + */ +async function tryGetOidcServiceEndpoint( + connectedServiceName: string +): Promise { + const authScheme = tl.getEndpointAuthorizationScheme( + connectedServiceName, + true + ); + tl.debug(`Service endpoint authorization scheme ${authScheme ?? ""}`); + if (authScheme?.toLowerCase() !== "workloadidentityfederation") { + return undefined; + } + + const federatedToken = await tryGetIdToken(connectedServiceName); + + if (!federatedToken) { + return undefined; + } + + tl.setSecret(federatedToken); + + const endpoint = { + clientId: tl.getEndpointAuthorizationParameter( + connectedServiceName, + "serviceprincipalid", + false + ), + oidcToken: federatedToken, + // It is entirely possible subscriptionId is not present, so setting subscription as optional stops an unintended failure. + // eg- a ManagementGroup scoped SP has access to multiple subscriptions; the subscription that is the target of a pulumi up will be enforced in the pulumi program config/env. + subscriptionId: tl.getEndpointDataParameter( + connectedServiceName, + "subscriptionid", + true + ), + tenantId: tl.getEndpointAuthorizationParameter( + connectedServiceName, + "tenantid", + false + ), + } as IServiceEndpoint; + + return endpoint; +} + +/** + * Tries to get the ID token as per V2 of the Azure CLI task + * https://github.com/microsoft/azure-pipelines-tasks/blob/master/Tasks/AzureCLIV2/azureclitask.ts#L251-L268 + */ +async function tryGetIdToken( + connectedService: string +): Promise { + const jobId = tl.getVariable("System.JobId")!; + const planId = tl.getVariable("System.PlanId")!; + const projectId = tl.getVariable("System.TeamProjectId")!; + const hub = tl.getVariable("System.HostType")!; + const uri = tl.getVariable("System.CollectionUri")!; + const token = getSystemAccessToken(); + + const authHandler = getHandlerFromToken(token); + const connection = new WebApi(uri, authHandler); + const api: ITaskApi = await connection.getTaskApi(); + const response = await api.createOidcToken( + {}, + projectId, + hub, + planId, + jobId, + connectedService + ); + if (response == null) { + return null; + } + + return response.oidcToken; +} // 500-1000 per tenant