From ae6c07c30307ce0e5aba89340061ded64e135a0e Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Tue, 4 Jun 2024 19:02:40 +0200 Subject: [PATCH 01/12] Generalize script --- ...eate_azure_msi_oidc_service_connection.ps1 | 351 ++++++++++++++++++ .../manualServiceEndpointRequest.json | 4 +- 2 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 diff --git a/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 b/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 new file mode 100644 index 0000000..6b2293e --- /dev/null +++ b/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 @@ -0,0 +1,351 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Create a Service Connection in Azure DevOps that uses a Managed Identity and Workload Identity federation to authenticate to Azure. + +.DESCRIPTION + Creates a Managed Identiy, sets up a federation subject on the Managed Identity for a Service Connection, creates the Service Connection, and grants the Managed Identity the Contributor role on the subscription. + +.LINK + https://aka.ms/azdo-rm-workload-identity + +.EXAMPLE + ./create_azurerm_msi_oidc_service_connection.ps1 -Project MyProject -OrganizationUrl https://dev.azure.com/MyOrg -SubscriptionId 00000000-0000-0000-0000-000000000000 +#> +#Requires -Version 7.2 + +param ( + [parameter(Mandatory=$false,HelpMessage="Name of the Managed Identity")] + [string] + $IdentityName, + + [parameter(Mandatory=$false,HelpMessage="Name of the Azure Resource Group where the Managed Identity will be created")] + [string] + $IdentityResourceGroupName, + + [parameter(Mandatory=$false,HelpMessage="Id of the Azure Subscription where the Managed Identity will be created")] + [guid] + $IdentitySubscriptionId=($env:AZURE_SUBSCRIPTION_ID || $env:ARM_SUBSCRIPTION_ID), + + [parameter(Mandatory=$false,HelpMessage="Azure region of the Managed Identity")] + [string] + $IdentityLocation, + + [parameter(Mandatory=$false,HelpMessage="Name of the Service Connection")] + [string] + $ServiceConnectionName, + + [parameter(Mandatory=$false,HelpMessage="Role to grant the Service Connection on the selected scope")] + [string] + [ValidateNotNullOrEmpty()] + $ServiceConnectionRole="Contributor", + + [parameter(Mandatory=$false,HelpMessage="Scope of the Service Connection (e.g. /subscriptions/00000000-0000-0000-0000-000000000000)")] + [string] + [ValidatePattern("^$|(?i)/subscriptions/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}(/resourcegroups/(.+?))?")] + $ServiceConnectionScope, + + [parameter(Mandatory=$false)] + [string] + [ValidateSet("AzureRM","dockerregistry")] + $ServiceConnectionType="AzureRM", + + [parameter(Mandatory=$false,HelpMessage="Name of the Azure DevOps Project")] + [string] + [ValidateNotNullOrEmpty()] + $Project=$env:SYSTEM_TEAMPROJECT, + + [parameter(Mandatory=$false,HelpMessage="Url of the Azure DevOps Organization")] + [uri] + [ValidateNotNullOrEmpty()] + $OrganizationUrl=($env:AZDO_ORG_SERVICE_URL ?? $env:SYSTEM_COLLECTIONURI) +) +Write-Verbose $MyInvocation.line +. (Join-Path $PSScriptRoot .. functions.ps1) +# $apiVersion = "7.1-preview.4" +$apiVersion = "7.2-preview" + +#----------------------------------------------------------- +# Validate parameters +switch ($ServiceConnectionType) +{ + AzureRM { + # Nothing to do + } + dockerregistry { + if (!$ServiceConnectionScope) { + Write-Error "Parameter ServiceConnectionScope is required for ServiceConnectionType 'dockerregistry'" + exit 1 + } + if ($ServiceConnectionScope -notmatch "/Microsoft.ContainerRegistry/registries/") { + Write-Error "Parameter ServiceConnectionScope must be a container registry scope for ServiceConnectionType 'dockerregistry'" + exit 1 + } + "{0}.azurecr.io" -f $ServiceConnectionScope.Split('/')[8] | Set-Variable acrLoginServer + } +} + +#----------------------------------------------------------- +# Log in to Azure +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Error "Azure CLI is not installed. You can get it here: https://docs.microsoft.com/cli/azure/install-azure-cli" + exit 1 +} +az account show -o json 2>$null | ConvertFrom-Json | Set-Variable subscription +if (!$subscription) { + az login -o json | ConvertFrom-Json | Set-Variable subscription +} +$subscription | Format-List | Out-String | Write-Debug +if ($IdentitySubscriptionId) { + az account set --subscription $IdentitySubscriptionId -o none + az account show -o json 2>$null | ConvertFrom-Json | Set-Variable subscription +} else { + # Prompt for subscription + az account list --query "sort_by([].{id:id, name:name},&name)" ` + -o json ` + | ConvertFrom-Json ` + | Set-Variable subscriptions + + if ($subscriptions.Length -eq 1) { + $occurrence = 0 + } else { + # Active subscription may not be the desired one, prompt the user to select one + $index = 0 + $subscriptions | Format-Table -Property @{name="index";expression={$script:index;$script:index+=1}}, id, name + Write-Host "Set `$env:ARM_SUBSCRIPTION_ID to the id of the subscription you want to use to prevent this prompt" -NoNewline + + do { + Write-Host "`nEnter the index # of the subscription you want to use: " -ForegroundColor Cyan -NoNewline + [int]$occurrence = Read-Host + Write-Debug "User entered index '$occurrence'" + } while (($occurrence -notmatch "^\d+$") -or ($occurrence -lt 1) -or ($occurrence -gt $subscriptions.Length)) + } + + $subscription = $subscriptions[$occurrence-1] + $IdentitySubscriptionId = $subscription.id + + Write-Host "Using subscription '$($subscription.name)'" -ForegroundColor Yellow + Start-Sleep -Milliseconds 250 +} + +# Log in to Azure & Azure DevOps +$OrganizationUrl = $OrganizationUrl.ToString().Trim('/') +az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 ` + --query "accessToken" ` + --output tsv ` + | Set-Variable accessToken +if (!$accessToken) { + Write-Error "$(subscription.user.name) failed to get access token for Azure DevOps" + exit 1 +} +if (!(az extension list --query "[?name=='azure-devops'].version" -o tsv)) { + Write-Host "Adding Azure CLI extension 'azure-devops'..." + az extension add -n azure-devops -y -o none +} +$accessToken | az devops login --organization $OrganizationUrl +if ($lastexitcode -ne 0) { + Write-Error "$($subscription.user.name) failed to log in to Azure DevOps organization '${OrganizationUrl}'" + exit $lastexitcode +} + +#----------------------------------------------------------- +# Process parameters, making sure they're not empty +$organizationName = $OrganizationUrl.ToString().Split('/')[3] +if (!$IdentityResourceGroupName) { + $IdentityResourceGroupName = (az config get defaults.group --query value -o tsv) +} +if (!$IdentityResourceGroupName) { + $IdentityResourceGroupName = "VS-${organizationName}-Group" +} +az group show -g $IdentityResourceGroupName -o json 2>$null | ConvertFrom-Json | Set-Variable resourceGroup +if ($resourceGroup) { + if (!$IdentityLocation) { + $IdentityLocation = $resourceGroup.location + } +} else { + if (!$IdentityLocation) { + $IdentityLocation = (az config get defaults.location --query value -o tsv) + } + if (!$IdentityLocation) { + # Azure location doesn't really matter for MI; the object is in AAD which is a global service + $IdentityLocation = "southcentralus" + } + az group create -g $IdentityResourceGroupName -l $IdentityLocation -o json | ConvertFrom-Json | Set-Variable resourceGroup +} +if (!$ServiceConnectionScope) { + $ServiceConnectionScope = "/subscriptions/${IdentitySubscriptionId}" + Write-Verbose "Parameter ServiceConnectionScope not provided, using '${ServiceConnectionScope}'." +} +$serviceConnectionSubscriptionId = $ServiceConnectionScope.Split('/')[2] + +# Check whether project exists +az devops project show --project $Project --organization $OrganizationUrl --query id -o tsv | Set-Variable projectId +if (!$projectId) { + Write-Error "Project '${Project}' not found in organization '${OrganizationUrl}" + exit 1 +} + +# Test whether Service Connection already exists +$serviceConnectionSubscriptionName = $(az account show --subscription $serviceConnectionSubscriptionId --query name -o tsv) +if (!$ServiceConnectionName) { + $ServiceConnectionName = $serviceConnectionSubscriptionName + $serviceConnectionResourceGroupName = $ServiceConnectionScope.Split('/')[4] + if ($serviceConnectionResourceGroupName) { + $ServiceConnectionName += "-${serviceConnectionResourceGroupName}" + } + $ServiceConnectionName += "-oidc-msi" +} +do { + az devops service-endpoint list -p $Project ` + --organization $OrganizationUrl ` + --query "[?name=='${ServiceConnectionName}'].id" ` + -o tsv ` + | Set-Variable serviceEndpointId + + $ServiceConnectionNameBefore = $ServiceConnectionName + if ($serviceEndpointId) { + Write-Warning "Service connection '${ServiceConnectionName}' already exists. Provide a different name to create a new service connection or press enter to overwrite '${ServiceConnectionName}'." + $ServiceConnectionName = Read-Host -Prompt "Provide the name of the service connection ('${ServiceConnectionName}')" + if (!$ServiceConnectionName) { + $ServiceConnectionName = $ServiceConnectionNameBefore + } + if ($ServiceConnectionName -ieq $ServiceConnectionNameBefore) { + Write-Verbose "Service connection '${ServiceConnectionName}' (${serviceEndpointId}) wil be updated" + break + } + } else { + Write-Verbose "Service connection '${ServiceConnectionName}' (${serviceEndpointId}) wil be created" + } +} while ($serviceEndpointId) + +#----------------------------------------------------------- +# Create Managed Identity +if (!$IdentityName) { + $IdentityName = "${organizationName}-${Project}-${ServiceConnectionName}" +} +Write-Verbose "Creating Managed Identity '${IdentityName}' in resource group '${IdentityResourceGroupName}'..." +Write-Debug "az identity create -n $IdentityName -g $IdentityResourceGroupName -l $IdentityLocation --subscription $IdentitySubscriptionId" +az identity create -n $IdentityName ` + -g $IdentityResourceGroupName ` + -l $IdentityLocation ` + --subscription $IdentitySubscriptionId ` + -o json ` + | Tee-Object -Variable identityJson ` + | ConvertFrom-Json ` + | Set-Variable identity +$identityJson | Write-Debug +Write-Verbose "Created Managed Identity $($identity.id)" +$identity | Format-List | Out-String | Write-Debug + +Write-Verbose "Creating role assignment for Managed Identity '${IdentityName}' on subscription '$($subscription.name)'..." +az role assignment create --assignee-object-id $identity.principalId ` + --assignee-principal-type ServicePrincipal ` + --role $ServiceConnectionRole ` + --scope $ServiceConnectionScope ` + --subscription $serviceConnectionSubscriptionId ` + -o json ` + | Tee-Object -Variable roleAssignmentJson ` + | ConvertFrom-Json ` + | Set-Variable roleAssignment +$roleAssignmentJson | Write-Debug +Write-Verbose "Created role assignment $($roleAssignment.id)" +Write-Host "`nManaged Identity '$($identity.name)':" +$identity | Format-List -Property id, clientId, federatedSubject, role, scope, subscriptionId, tenantId + +# Prepare service connection REST API request body +Write-Verbose "Creating / updating service connection '${ServiceConnectionName}'..." +Get-Content -Path (Join-Path $PSScriptRoot manualServiceEndpointRequest.json) ` + | ConvertFrom-Json ` + | Set-Variable serviceEndpointRequest + +$serviceEndpointDescription = "Created by $($MyInvocation.MyCommand.Name). Configured Managed Identity ${IdentityName} (clientId $($identity.clientId)) federated on ${federatedSubject} as ${ServiceConnectionRole} on scope ${ServiceConnectionScope}." +if ($ServiceConnectionType -ieq "dockerregistry") { + Add-Member -InputObject $serviceEndpointRequest.authorization.parameters -NotePropertyName loginServer -NotePropertyValue $acrLoginServer +} +$serviceEndpointRequest.authorization.parameters.role = $roleAssignment.roleDefinitionId.Split('/')[-1] +$serviceEndpointRequest.authorization.parameters.servicePrincipalId = $identity.clientId +$serviceEndpointRequest.authorization.parameters.scope = $ServiceConnectionScope +$serviceEndpointRequest.authorization.parameters.tenantId = $identity.tenantId +if ($ServiceConnectionType -ieq "dockerregistry") { + Add-Member -InputObject $serviceEndpointRequest.data -NotePropertyName registryId -NotePropertyValue $ServiceConnectionScope + Add-Member -InputObject $serviceEndpointRequest.data -NotePropertyName registryType -NotePropertyValue "ACR" +} +if ($ServiceConnectionType -ieq "AzureRM") { + Add-Member -InputObject $serviceEndpointRequest.data -NotePropertyName environment -NotePropertyValue "AzureCloud" + Add-Member -InputObject $serviceEndpointRequest.data -NotePropertyName scopeLevel -NotePropertyValue "Subscription" +} +$serviceEndpointRequest.data.subscriptionId = $serviceConnectionSubscriptionId +$serviceEndpointRequest.data.subscriptionName = $serviceConnectionSubscriptionName +$serviceEndpointRequest.description = $serviceEndpointDescription +$serviceEndpointRequest.name = $ServiceConnectionName +$serviceEndpointRequest.serviceEndpointProjectReferences[0].description = $serviceEndpointDescription +$serviceEndpointRequest.serviceEndpointProjectReferences[0].name = $ServiceConnectionName +$serviceEndpointRequest.serviceEndpointProjectReferences[0].projectReference.id = $projectId +$serviceEndpointRequest.serviceEndpointProjectReferences[0].projectReference.name = $Project +$serviceEndpointRequest.type = $ServiceConnectionType +$serviceEndpointRequest | ConvertTo-Json -Depth 4 | Set-Variable serviceEndpointRequestBody +Write-Debug "Service connection request body: `n${serviceEndpointRequestBody}" + +$apiUri = "${OrganizationUrl}/_apis/serviceendpoint/endpoints" +if ($serviceEndpointId) { + $apiUri += "/${serviceEndpointId}" +} +$apiUri += "?api-version=${apiVersion}" +Invoke-RestMethod -Uri $apiUri ` + -Method ($serviceEndpointId ? 'PUT' : 'POST') ` + -Body $serviceEndpointRequestBody ` + -ContentType 'application/json' ` + -Authentication Bearer ` + -Token (ConvertTo-SecureString $accessToken -AsPlainText) ` + | Set-Variable serviceEndpoint + +$serviceEndpoint | ConvertTo-Json -Depth 4 | Write-Debug +if (!$serviceEndpoint) { + Write-Error "Failed to create / update service connection '${ServiceConnectionName}'" + exit 1 +} +if ($serviceEndpointId) { + Write-Host "Service connection '${ServiceConnectionName}' updated:" +} else { + Write-Host "Service connection '${ServiceConnectionName}' created:" +} +Write-Debug "Service connection data:" +$serviceEndpoint.data | Format-List | Out-String | Write-Debug +Write-Debug "Service connection authorization parameters:" +$serviceEndpoint.authorization.parameters | Format-List | Out-String | Write-Debug + +# Create Federated Credential +Write-Verbose "Configuring Managed Identity '${IdentityName}' with federated subject '$($serviceEndpoint.authorization.parameters.workloadIdentityFederationSubject)'..." +az identity federated-credential create --name $IdentityName ` + --identity-name $IdentityName ` + --resource-group $IdentityResourceGroupName ` + --issuer $serviceEndpoint.authorization.parameters.workloadIdentityFederationIssuer ` + --subject $serviceEndpoint.authorization.parameters.workloadIdentityFederationSubject ` + --subscription $IdentitySubscriptionId ` + -o json ` + | Tee-Object -Variable federatedCredentialJson ` + | ConvertFrom-Json ` + | Set-Variable federatedCredential +$federatedCredentialJson | Write-Debug +Write-Verbose "Created federated credential $($federatedCredential.id)" +$identity | Add-Member -NotePropertyName federatedSubject -NotePropertyValue $serviceEndpoint.authorization.parameters.workloadIdentityFederationSubject +$identity | Add-Member -NotePropertyName role -NotePropertyValue $ServiceConnectionRole +$identity | Add-Member -NotePropertyName scope -NotePropertyValue $ServiceConnectionScope +$identity | Add-Member -NotePropertyName subscriptionId -NotePropertyValue $IdentitySubscriptionId +$identity | Format-List | Out-String | Write-Debug + +$serviceEndpoint | Select-Object -Property authorization, data, id, name, description, type, createdBy ` + | ForEach-Object { + $_.createdBy = $_.createdBy.uniqueName + $_ | Add-Member -NotePropertyName clientId -NotePropertyValue $_.authorization.parameters.serviceprincipalid + $_ | Add-Member -NotePropertyName creationMode -NotePropertyValue $_.data.creationMode + $_ | Add-Member -NotePropertyName scheme -NotePropertyValue $_.authorization.scheme + $_ | Add-Member -NotePropertyName scopeLevel -NotePropertyValue $_.data.scopeLevel + $_ | Add-Member -NotePropertyName subscriptionName -NotePropertyValue $_.data.subscriptionName + $_ | Add-Member -NotePropertyName subscriptionId -NotePropertyValue $_.data.subscriptionId + $_ | Add-Member -NotePropertyName tenantid -NotePropertyValue $_.authorization.parameters.tenantid + $_ | Add-Member -NotePropertyName workloadIdentityFederationSubject -NotePropertyValue $_.authorization.parameters.workloadIdentityFederationSubject + $_ + } ` + | Select-Object -ExcludeProperty authorization, data + | Format-List diff --git a/scripts/azure-devops/manualServiceEndpointRequest.json b/scripts/azure-devops/manualServiceEndpointRequest.json index 3158e75..22a0ec5 100644 --- a/scripts/azure-devops/manualServiceEndpointRequest.json +++ b/scripts/azure-devops/manualServiceEndpointRequest.json @@ -2,8 +2,6 @@ "data": { "subscriptionId": "", "subscriptionName": "", - "environment": "AzureCloud", - "scopeLevel": "Subscription", "creationMode": "Manual" }, "description": "", @@ -13,6 +11,8 @@ "authorization": { "parameters": { "tenantid": "", + "role": "", + "scope": "", "serviceprincipalid": "" }, "scheme": "WorkloadIdentityFederation" From f6f48954aad06c218395f6c895e3cc66712fbaf3 Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Tue, 4 Jun 2024 19:14:27 +0200 Subject: [PATCH 02/12] Use az rest --- ...eate_azure_msi_oidc_service_connection.ps1 | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 b/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 index 6b2293e..d76d325 100644 --- a/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 +++ b/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 @@ -284,22 +284,26 @@ $serviceEndpointRequest.serviceEndpointProjectReferences[0].projectReference.id $serviceEndpointRequest.serviceEndpointProjectReferences[0].projectReference.name = $Project $serviceEndpointRequest.type = $ServiceConnectionType $serviceEndpointRequest | ConvertTo-Json -Depth 4 | Set-Variable serviceEndpointRequestBody -Write-Debug "Service connection request body: `n${serviceEndpointRequestBody}" +# Write-Debug "Service connection request body: `n${serviceEndpointRequestBody}" $apiUri = "${OrganizationUrl}/_apis/serviceendpoint/endpoints" if ($serviceEndpointId) { $apiUri += "/${serviceEndpointId}" } $apiUri += "?api-version=${apiVersion}" -Invoke-RestMethod -Uri $apiUri ` - -Method ($serviceEndpointId ? 'PUT' : 'POST') ` - -Body $serviceEndpointRequestBody ` - -ContentType 'application/json' ` - -Authentication Bearer ` - -Token (ConvertTo-SecureString $accessToken -AsPlainText) ` - | Set-Variable serviceEndpoint - -$serviceEndpoint | ConvertTo-Json -Depth 4 | Write-Debug +$serviceEndpointRequestBodyFile = (New-TemporaryFile).FullName +$serviceEndpointRequest | ConvertTo-Json -Depth 4 | Out-File $serviceEndpointRequestBodyFile +Write-Debug "Service connection request body file: ${serviceEndpointRequestBodyFile}" +Get-Content $serviceEndpointRequestBodyFile | Write-Debug +az rest --method ($serviceEndpointId ? 'PUT' : 'POST') ` + --uri $apiUri ` + --body "@$serviceEndpointRequestBodyFile" ` + --resource 499b84ac-1321-427f-aa17-267ca6975798 ` + --output json ` + | Tee-Object -Variable serviceEndpointResponseJson ` + | ConvertFrom-Json -Depth 4 ` + | Set-Variable serviceEndpoint +$serviceEndpointResponseJson | Write-Debug if (!$serviceEndpoint) { Write-Error "Failed to create / update service connection '${ServiceConnectionName}'" exit 1 From 08a32216dae7e93fd276ba525009f738bffb9a97 Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Tue, 4 Jun 2024 19:16:17 +0200 Subject: [PATCH 03/12] Update README link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5eaac4c..ebd3a77 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This repo contains a few [PowerShell](https://github.com/PowerShell/PowerShell) - Configure Terraform [azuread](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs#authenticating-to-azure-active-directory)/[azurerm](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs#authenticating-to-azure) provider `ARM_*` environment variables to use the [AzureCLI](https://learn.microsoft.com/azure/devops/pipelines/tasks/reference/azure-cli-v2?view=azure-pipelines) task [Service Connection](https://learn.microsoft.com/azure/devops/pipelines/library/connect-to-azure?view=azure-devops): [set_terraform_azurerm_vars.ps1](scripts/azure-devops/set_terraform_azurerm_vars.ps1) -- Create Managed Identity for Service Connection with Workload identity federation: [create_azurerm_msi_oidc_service_connection.ps1](scripts/azure-devops/create_azurerm_msi_oidc_service_connection.ps1) +- Create Managed Identity for Service Connection with Workload identity federation: [create_azure_msi_oidc_service_connection.ps1](scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1) - List identities for Azure DevOps Service Connections in Entra ID pertaining to Azure DevOps organization and (optionally) project: [list_service_connection_identities.ps1](scripts/azure-devops/list_service_connection_identities.ps1) - List Azure DevOps Service Connections in an Azure DevOps organization and project: [list_service_connections.ps1](scripts/azure-devops/list_service_connections.ps1) - 'Pretty-name' Entra ID applications created for Service Connections, so the Service Connection name is included in the application display name: [rename_service_connection_applications.ps1](scripts/azure-devops/rename_service_connection_applications.ps1) From b35f9324c7fa6d7c3bf2a605767e4267f2702971 Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Tue, 4 Jun 2024 19:59:00 +0200 Subject: [PATCH 04/12] Build JSON in script --- ...eate_azure_msi_oidc_service_connection.ps1 | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 b/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 index d76d325..a003119 100644 --- a/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 +++ b/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 @@ -254,19 +254,42 @@ $identity | Format-List -Property id, clientId, federatedSubject, role, scope, s # Prepare service connection REST API request body Write-Verbose "Creating / updating service connection '${ServiceConnectionName}'..." -Get-Content -Path (Join-Path $PSScriptRoot manualServiceEndpointRequest.json) ` - | ConvertFrom-Json ` - | Set-Variable serviceEndpointRequest - $serviceEndpointDescription = "Created by $($MyInvocation.MyCommand.Name). Configured Managed Identity ${IdentityName} (clientId $($identity.clientId)) federated on ${federatedSubject} as ${ServiceConnectionRole} on scope ${ServiceConnectionScope}." -if ($ServiceConnectionType -ieq "dockerregistry") { - Add-Member -InputObject $serviceEndpointRequest.authorization.parameters -NotePropertyName loginServer -NotePropertyValue $acrLoginServer +$serviceEndpointRequest = @{ + data = @{ + subscriptionId = $serviceConnectionSubscriptionId + subscriptionName = $serviceConnectionSubscriptionName + creationMode = 'Manual' + } + description = $serviceEndpointDescription + name = $ServiceConnectionName + type = $ServiceConnectionType + url = "https://management.azure.com/" + authorization = @{ + parameters = @{ + tenantid = $identity.tenantId + role = $roleAssignment.roleDefinitionId.Split('/')[-1] + scope = $ServiceConnectionScope + serviceprincipalid = $identity.clientId + } + scheme = "WorkloadIdentityFederation" + } + isShared = $false + isReady = $false + serviceEndpointProjectReferences = @( + @{ + projectReference = @{ + id = $projectId + name = $Project + } + name = $ServiceConnectionName + description = $serviceEndpointDescription + } + ) } -$serviceEndpointRequest.authorization.parameters.role = $roleAssignment.roleDefinitionId.Split('/')[-1] -$serviceEndpointRequest.authorization.parameters.servicePrincipalId = $identity.clientId -$serviceEndpointRequest.authorization.parameters.scope = $ServiceConnectionScope -$serviceEndpointRequest.authorization.parameters.tenantId = $identity.tenantId + if ($ServiceConnectionType -ieq "dockerregistry") { + Add-Member -InputObject $serviceEndpointRequest.authorization.parameters -NotePropertyName loginServer -NotePropertyValue $acrLoginServer Add-Member -InputObject $serviceEndpointRequest.data -NotePropertyName registryId -NotePropertyValue $ServiceConnectionScope Add-Member -InputObject $serviceEndpointRequest.data -NotePropertyName registryType -NotePropertyValue "ACR" } @@ -274,17 +297,6 @@ if ($ServiceConnectionType -ieq "AzureRM") { Add-Member -InputObject $serviceEndpointRequest.data -NotePropertyName environment -NotePropertyValue "AzureCloud" Add-Member -InputObject $serviceEndpointRequest.data -NotePropertyName scopeLevel -NotePropertyValue "Subscription" } -$serviceEndpointRequest.data.subscriptionId = $serviceConnectionSubscriptionId -$serviceEndpointRequest.data.subscriptionName = $serviceConnectionSubscriptionName -$serviceEndpointRequest.description = $serviceEndpointDescription -$serviceEndpointRequest.name = $ServiceConnectionName -$serviceEndpointRequest.serviceEndpointProjectReferences[0].description = $serviceEndpointDescription -$serviceEndpointRequest.serviceEndpointProjectReferences[0].name = $ServiceConnectionName -$serviceEndpointRequest.serviceEndpointProjectReferences[0].projectReference.id = $projectId -$serviceEndpointRequest.serviceEndpointProjectReferences[0].projectReference.name = $Project -$serviceEndpointRequest.type = $ServiceConnectionType -$serviceEndpointRequest | ConvertTo-Json -Depth 4 | Set-Variable serviceEndpointRequestBody -# Write-Debug "Service connection request body: `n${serviceEndpointRequestBody}" $apiUri = "${OrganizationUrl}/_apis/serviceendpoint/endpoints" if ($serviceEndpointId) { From 089bf7eab47fd95a5b0c9d29ab8852a8e8968bcd Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Wed, 5 Jun 2024 16:02:42 +0200 Subject: [PATCH 05/12] Add CI --- scripts/azure-devops/azure-pipelines.yml | 81 ++++++++++++++----- ...eate_azure_msi_oidc_service_connection.ps1 | 30 +++---- 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/scripts/azure-devops/azure-pipelines.yml b/scripts/azure-devops/azure-pipelines.yml index e22eee0..c026cb0 100644 --- a/scripts/azure-devops/azure-pipelines.yml +++ b/scripts/azure-devops/azure-pipelines.yml @@ -48,13 +48,14 @@ variables: value: true - name: AZURE_EXTENSION_USE_DYNAMIC_INSTALL value: yes_without_prompt +- name: acrServiceConnectionToCreate + value: oidc-msi-test-acr-$(Build.BuildId) +- name: azureServiceConnectionToCreate + value: oidc-msi-test-$(Build.BuildId) - name: scriptDirectory value: $(Build.SourcesDirectory)/scripts/azure-devops - name: organizationName value: ${{ split(variables['System.CollectionUri'],'/')[3] }} -- name: serviceConnectionToCreate - value: oidc-msi-test-$(Build.BuildId) - # TODO: Convert multiple service connections - name: serviceConnectionToConvert value: oidc-convert-test-$(Build.BuildId) @@ -178,7 +179,7 @@ jobs: Write-Host "##vso[task.setvariable variable=scopeResourceGroupName;isOutput=true]${scopeResourceGroupName}" - task: AzureCLI@2 - displayName: 'Create Managed Identity and Service Connection' + displayName: 'Create Managed Identity and Azure Service Connection' name: identity inputs: azureSubscription: '$(azureConnection)' @@ -195,11 +196,55 @@ jobs: Get-ChildItem -Path Env: -Force -Recurse -Include * | Sort-Object -Property Name | Format-Table -AutoSize | Out-String } - ./create_azurerm_msi_oidc_service_connection.ps1 -IdentityName ${{ variables['organizationName'] }}-service-connection-test-$(Build.BuildId) ` - -IdentityResourceGroupName $(resourceGroup.managedIdentityResourceGroupName) ` - -IdentitySubscriptionId $(az account show --query id -o tsv) ` - -ServiceConnectionName $(serviceConnectionToCreate) ` - -ServiceConnectionScope $(resourceGroup.scopeResourceGroupId) + ./create_azure_msi_oidc_service_connection.ps1 -IdentityName ${{ variables['organizationName'] }}-service-connection-test-$(Build.BuildId) ` + -IdentityResourceGroupName $(resourceGroup.managedIdentityResourceGroupName) ` + -IdentitySubscriptionId $(az account show --query id -o tsv) ` + -ServiceConnectionName $(azureServiceConnectionToCreate) ` + -ServiceConnectionRole Reader ` + -ServiceConnectionScope $(resourceGroup.scopeResourceGroupId) ` + -ServiceConnectionType AzureRM + + az identity list -g $(resourceGroup.managedIdentityResourceGroupName) ` + --query [0].clientId ` + -o tsv ` + | Set-Variable -Name clientId + Write-Host "##vso[task.setvariable variable=clientId;isOutput=true]${clientId}" + + workingDirectory: '$(scriptDirectory)' + + - task: AzureCLI@2 + displayName: 'Create ACR, Managed Identity and Docker Registry Service Connection' + name: acrIdentity + inputs: + azureSubscription: '$(azureConnection)' + failOnStandardError: true + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + if ($env:SYSTEM_DEBUG -eq "true") { + $InformationPreference = "Continue" + $VerbosePreference = "Continue" + $DebugPreference = "Continue" + + Set-PSDebug -Trace 1 + + Get-ChildItem -Path Env: -Force -Recurse -Include * | Sort-Object -Property Name | Format-Table -AutoSize | Out-String + } + Write-Host "Create Azure Container Registry.." + az acr create --resource-group myResourceGroup ` + --name $(resourceGroup.scopeResourceGroupName) ` + --sku Basic ` + -o json ` + | ConvertFrom-Json ` + | Set-Variable -Name acr + + ./create_azure_msi_oidc_service_connection.ps1 -IdentityName ${{ variables['organizationName'] }}-service-connection-test-$(Build.BuildId) ` + -IdentityResourceGroupName $(resourceGroup.managedIdentityResourceGroupName) ` + -IdentitySubscriptionId $(az account show --query id -o tsv) ` + -ServiceConnectionName $(acrServiceConnectionToCreate) ` + -ServiceConnectionRole AcrPull ` + -ServiceConnectionScope $(acr.id) ` + -ServiceConnectionType dockerregistry az identity list -g $(resourceGroup.managedIdentityResourceGroupName) ` --query [0].clientId ` @@ -220,7 +265,7 @@ jobs: steps: - task: AzureCLI@2 - displayName: 'Test Service Connection $(serviceConnectionToCreate)' + displayName: 'Test Service Connection $(azureServiceConnectionToCreate)' timeoutInMinutes: 5 inputs: azureSubscription: '$(azureConnection)' @@ -229,7 +274,7 @@ jobs: scriptLocation: inlineScript workingDirectory: '$(scriptDirectory)' inlineScript: | - ./test_service_connection.ps1 -ServiceConnectionName $(serviceConnectionToCreate) ` + ./test_service_connection.ps1 -ServiceConnectionName $(azureServiceConnectionToCreate) ` -ServiceConnectionTestPipelineId $(serviceConnectionTestPipelineId) - ${{ if or(eq(parameters.jobsToRun, 'Non-modifying tests'),eq(parameters.jobsToRun, 'Both')) }}: @@ -338,7 +383,8 @@ jobs: # convertedServiceConnectionId1: $[ dependencies.convertServiceConnection.outputs['job1.convert.serviceConnectionId'] ] # convertedServiceConnectionId2: $[ dependencies.convertServiceConnection.outputs['job2.convert.serviceConnectionId'] ] convertedServiceConnectionId: $[ dependencies.convertServiceConnection.outputs['convert.serviceConnectionId'] ] - createdClientId: $[ dependencies.createServiceConnection.outputs['identity.clientId'] ] + createdAzureClientId: $[ dependencies.createServiceConnection.outputs['identity.clientId'] ] + createdACRClientId: $[ dependencies.createServiceConnection.outputs['acrIdentity.clientId'] ] steps: - task: AzureCLI@2 name: teardownAzure @@ -390,14 +436,11 @@ jobs: $ErrorActionPreference = "Continue" # Continue to remove resources if remove by resource group fails az devops configure --defaults organization="$(System.CollectionUri)" project="$(System.TeamProject)" - az devops service-endpoint list --query "[?authorization.parameters.serviceprincipalid=='$(createdClientId)'].id" ` + az devops service-endpoint list --query "[?authorization.parameters.serviceprincipalid=='$(createdAzureClientId)' || authorization.parameters.serviceprincipalid=='$(createdACRClientId)'].id" ` -o tsv ` - | Set-Variable -Name serviceConnectionId - if (!$serviceConnectionId) { - Write-Host "No created service connections to remove" - exit 0 - } else { - Write-Host "Removing created service connection ${serviceConnectionId}..." + | Set-Variable -Name serviceConnectionIds + foreach ($serviceConnectionId in $serviceConnectionIds) { + Write-Host "Removing service connection ${serviceConnectionId}..." &{ # az writes information to stderr $ErrorActionPreference = 'SilentlyContinue' az devops service-endpoint delete --id $serviceConnectionId --yes 2>&1 diff --git a/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 b/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 index a003119..bce1e94 100644 --- a/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 +++ b/scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1 @@ -62,7 +62,6 @@ param ( ) Write-Verbose $MyInvocation.line . (Join-Path $PSScriptRoot .. functions.ps1) -# $apiVersion = "7.1-preview.4" $apiVersion = "7.2-preview" #----------------------------------------------------------- @@ -167,7 +166,7 @@ if ($resourceGroup) { $IdentityLocation = (az config get defaults.location --query value -o tsv) } if (!$IdentityLocation) { - # Azure location doesn't really matter for MI; the object is in AAD which is a global service + # Azure location doesn't really matter for MI; the actual object is in Entra ID which is a global service $IdentityLocation = "southcentralus" } az group create -g $IdentityResourceGroupName -l $IdentityLocation -o json | ConvertFrom-Json | Set-Variable resourceGroup @@ -210,11 +209,11 @@ do { $ServiceConnectionName = $ServiceConnectionNameBefore } if ($ServiceConnectionName -ieq $ServiceConnectionNameBefore) { - Write-Verbose "Service connection '${ServiceConnectionName}' (${serviceEndpointId}) wil be updated" + Write-Verbose "Service connection '${ServiceConnectionName}' (${serviceEndpointId}) will be updated" break } } else { - Write-Verbose "Service connection '${ServiceConnectionName}' (${serviceEndpointId}) wil be created" + Write-Verbose "Service connection '${ServiceConnectionName}' (${serviceEndpointId}) will be created" } } while ($serviceEndpointId) @@ -223,7 +222,7 @@ do { if (!$IdentityName) { $IdentityName = "${organizationName}-${Project}-${ServiceConnectionName}" } -Write-Verbose "Creating Managed Identity '${IdentityName}' in resource group '${IdentityResourceGroupName}'..." +Write-Host "Creating Managed Identity '${IdentityName}' in resource group '${IdentityResourceGroupName}'..." Write-Debug "az identity create -n $IdentityName -g $IdentityResourceGroupName -l $IdentityLocation --subscription $IdentitySubscriptionId" az identity create -n $IdentityName ` -g $IdentityResourceGroupName ` @@ -237,7 +236,7 @@ $identityJson | Write-Debug Write-Verbose "Created Managed Identity $($identity.id)" $identity | Format-List | Out-String | Write-Debug -Write-Verbose "Creating role assignment for Managed Identity '${IdentityName}' on subscription '$($subscription.name)'..." +Write-Host "Creating role assignment for Managed Identity '${IdentityName}' on scope '${ServiceConnectionScope}'..." az role assignment create --assignee-object-id $identity.principalId ` --assignee-principal-type ServicePrincipal ` --role $ServiceConnectionRole ` @@ -249,12 +248,10 @@ az role assignment create --assignee-object-id $identity.principalId ` | Set-Variable roleAssignment $roleAssignmentJson | Write-Debug Write-Verbose "Created role assignment $($roleAssignment.id)" -Write-Host "`nManaged Identity '$($identity.name)':" -$identity | Format-List -Property id, clientId, federatedSubject, role, scope, subscriptionId, tenantId # Prepare service connection REST API request body -Write-Verbose "Creating / updating service connection '${ServiceConnectionName}'..." -$serviceEndpointDescription = "Created by $($MyInvocation.MyCommand.Name). Configured Managed Identity ${IdentityName} (clientId $($identity.clientId)) federated on ${federatedSubject} as ${ServiceConnectionRole} on scope ${ServiceConnectionScope}." +Write-Host "Creating / updating service connection '${ServiceConnectionName}'..." +$serviceEndpointDescription = "Created by $($MyInvocation.MyCommand.Name). Configured Managed Identity ${IdentityName} (clientId $($identity.clientId)) as ${ServiceConnectionRole} on scope ${ServiceConnectionScope}." $serviceEndpointRequest = @{ data = @{ subscriptionId = $serviceConnectionSubscriptionId @@ -287,7 +284,6 @@ $serviceEndpointRequest = @{ } ) } - if ($ServiceConnectionType -ieq "dockerregistry") { Add-Member -InputObject $serviceEndpointRequest.authorization.parameters -NotePropertyName loginServer -NotePropertyValue $acrLoginServer Add-Member -InputObject $serviceEndpointRequest.data -NotePropertyName registryId -NotePropertyValue $ServiceConnectionScope @@ -321,9 +317,9 @@ if (!$serviceEndpoint) { exit 1 } if ($serviceEndpointId) { - Write-Host "Service connection '${ServiceConnectionName}' updated:" + Write-Host "Service connection '${ServiceConnectionName}' updated." } else { - Write-Host "Service connection '${ServiceConnectionName}' created:" + Write-Host "Service connection '${ServiceConnectionName}' created." } Write-Debug "Service connection data:" $serviceEndpoint.data | Format-List | Out-String | Write-Debug @@ -331,7 +327,7 @@ Write-Debug "Service connection authorization parameters:" $serviceEndpoint.authorization.parameters | Format-List | Out-String | Write-Debug # Create Federated Credential -Write-Verbose "Configuring Managed Identity '${IdentityName}' with federated subject '$($serviceEndpoint.authorization.parameters.workloadIdentityFederationSubject)'..." +Write-Host "Configuring Managed Identity '${IdentityName}' with federated subject '$($serviceEndpoint.authorization.parameters.workloadIdentityFederationSubject)'..." az identity federated-credential create --name $IdentityName ` --identity-name $IdentityName ` --resource-group $IdentityResourceGroupName ` @@ -350,13 +346,17 @@ $identity | Add-Member -NotePropertyName scope -NotePropertyValue $ServiceConnec $identity | Add-Member -NotePropertyName subscriptionId -NotePropertyValue $IdentitySubscriptionId $identity | Format-List | Out-String | Write-Debug +Write-Host "`nService Connection '$($serviceEndpoint.name)':" + $serviceEndpoint | Select-Object -Property authorization, data, id, name, description, type, createdBy ` | ForEach-Object { $_.createdBy = $_.createdBy.uniqueName $_ | Add-Member -NotePropertyName clientId -NotePropertyValue $_.authorization.parameters.serviceprincipalid $_ | Add-Member -NotePropertyName creationMode -NotePropertyValue $_.data.creationMode + $_ | Add-Member -NotePropertyName managedIdentityPortalLink -NotePropertyValue ("https://portal.azure.com/#@{0}/resource{1}" -f $identity.tenantId, $identity.id) $_ | Add-Member -NotePropertyName scheme -NotePropertyValue $_.authorization.scheme - $_ | Add-Member -NotePropertyName scopeLevel -NotePropertyValue $_.data.scopeLevel + $_ | Add-Member -NotePropertyName scopePortalLink -NotePropertyValue ("https://portal.azure.com/#@{0}/resource{1}" -f $identity.tenantId, $ServiceConnectionScope) + $_ | Add-Member -NotePropertyName serviceConnectionPortalLink -NotePropertyValue ("{0}/{1}/_settings/adminservices?resourceId={2}" -f $OrganizationUrl, $Project, $serviceEndpoint.id) $_ | Add-Member -NotePropertyName subscriptionName -NotePropertyValue $_.data.subscriptionName $_ | Add-Member -NotePropertyName subscriptionId -NotePropertyValue $_.data.subscriptionId $_ | Add-Member -NotePropertyName tenantid -NotePropertyValue $_.authorization.parameters.tenantid From 350e5df928e729e87620810263faf0d54c74d9e8 Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Wed, 5 Jun 2024 16:14:06 +0200 Subject: [PATCH 06/12] --- scripts/azure-devops/azure-pipelines.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/azure-devops/azure-pipelines.yml b/scripts/azure-devops/azure-pipelines.yml index c026cb0..3381557 100644 --- a/scripts/azure-devops/azure-pipelines.yml +++ b/scripts/azure-devops/azure-pipelines.yml @@ -231,8 +231,8 @@ jobs: Get-ChildItem -Path Env: -Force -Recurse -Include * | Sort-Object -Property Name | Format-Table -AutoSize | Out-String } Write-Host "Create Azure Container Registry.." - az acr create --resource-group myResourceGroup ` - --name $(resourceGroup.scopeResourceGroupName) ` + az acr create --resource-group $(resourceGroup.scopeResourceGroupName) ` + --name ciacr ` --sku Basic ` -o json ` | ConvertFrom-Json ` From 1ab1c49eaa63274d147f880ef35582b57d2650a3 Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Wed, 5 Jun 2024 16:18:06 +0200 Subject: [PATCH 07/12] acrName --- scripts/azure-devops/azure-pipelines.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/azure-devops/azure-pipelines.yml b/scripts/azure-devops/azure-pipelines.yml index 3381557..58b9236 100644 --- a/scripts/azure-devops/azure-pipelines.yml +++ b/scripts/azure-devops/azure-pipelines.yml @@ -48,6 +48,8 @@ variables: value: true - name: AZURE_EXTENSION_USE_DYNAMIC_INSTALL value: yes_without_prompt +- name: acrName + value: ciacr$(Build.BuildId) - name: acrServiceConnectionToCreate value: oidc-msi-test-acr-$(Build.BuildId) - name: azureServiceConnectionToCreate @@ -232,7 +234,7 @@ jobs: } Write-Host "Create Azure Container Registry.." az acr create --resource-group $(resourceGroup.scopeResourceGroupName) ` - --name ciacr ` + --name $(acrName) ` --sku Basic ` -o json ` | ConvertFrom-Json ` From cd1eccffab03b5c786eeea51bf2061939e306735 Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Wed, 5 Jun 2024 16:24:49 +0200 Subject: [PATCH 08/12] $acr.id --- scripts/azure-devops/azure-pipelines.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/azure-devops/azure-pipelines.yml b/scripts/azure-devops/azure-pipelines.yml index 58b9236..b62801f 100644 --- a/scripts/azure-devops/azure-pipelines.yml +++ b/scripts/azure-devops/azure-pipelines.yml @@ -232,20 +232,20 @@ jobs: Get-ChildItem -Path Env: -Force -Recurse -Include * | Sort-Object -Property Name | Format-Table -AutoSize | Out-String } - Write-Host "Create Azure Container Registry.." + Write-Host "Creating Azure Container Registry.." az acr create --resource-group $(resourceGroup.scopeResourceGroupName) ` --name $(acrName) ` --sku Basic ` -o json ` | ConvertFrom-Json ` - | Set-Variable -Name acr + | Tee-Object -Variable acr ./create_azure_msi_oidc_service_connection.ps1 -IdentityName ${{ variables['organizationName'] }}-service-connection-test-$(Build.BuildId) ` -IdentityResourceGroupName $(resourceGroup.managedIdentityResourceGroupName) ` -IdentitySubscriptionId $(az account show --query id -o tsv) ` -ServiceConnectionName $(acrServiceConnectionToCreate) ` -ServiceConnectionRole AcrPull ` - -ServiceConnectionScope $(acr.id) ` + -ServiceConnectionScope $acr.id ` -ServiceConnectionType dockerregistry az identity list -g $(resourceGroup.managedIdentityResourceGroupName) ` From 37ddbd5fccea456658b62e6bdf2c8cf07df60323 Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Wed, 5 Jun 2024 16:30:02 +0200 Subject: [PATCH 09/12] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ebd3a77..f3b6622 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This repo contains a few [PowerShell](https://github.com/PowerShell/PowerShell) - Configure Terraform [azuread](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs#authenticating-to-azure-active-directory)/[azurerm](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs#authenticating-to-azure) provider `ARM_*` environment variables to use the [AzureCLI](https://learn.microsoft.com/azure/devops/pipelines/tasks/reference/azure-cli-v2?view=azure-pipelines) task [Service Connection](https://learn.microsoft.com/azure/devops/pipelines/library/connect-to-azure?view=azure-devops): [set_terraform_azurerm_vars.ps1](scripts/azure-devops/set_terraform_azurerm_vars.ps1) -- Create Managed Identity for Service Connection with Workload identity federation: [create_azure_msi_oidc_service_connection.ps1](scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1) +- Create Managed Identity and (Azure or Docker Registry) Service Connection with Workload identity federation: [create_azure_msi_oidc_service_connection.ps1](scripts/azure-devops/create_azure_msi_oidc_service_connection.ps1) - List identities for Azure DevOps Service Connections in Entra ID pertaining to Azure DevOps organization and (optionally) project: [list_service_connection_identities.ps1](scripts/azure-devops/list_service_connection_identities.ps1) - List Azure DevOps Service Connections in an Azure DevOps organization and project: [list_service_connections.ps1](scripts/azure-devops/list_service_connections.ps1) - 'Pretty-name' Entra ID applications created for Service Connections, so the Service Connection name is included in the application display name: [rename_service_connection_applications.ps1](scripts/azure-devops/rename_service_connection_applications.ps1) From e0254c6f246c5eff6a509bc78992710b98caa77a Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Wed, 5 Jun 2024 16:30:04 +0200 Subject: [PATCH 10/12] --- scripts/azure-devops/azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/azure-devops/azure-pipelines.yml b/scripts/azure-devops/azure-pipelines.yml index b62801f..4260849 100644 --- a/scripts/azure-devops/azure-pipelines.yml +++ b/scripts/azure-devops/azure-pipelines.yml @@ -267,7 +267,7 @@ jobs: steps: - task: AzureCLI@2 - displayName: 'Test Service Connection $(azureServiceConnectionToCreate)' + displayName: 'Test Azure Service Connection $(azureServiceConnectionToCreate)' timeoutInMinutes: 5 inputs: azureSubscription: '$(azureConnection)' From e8931f8ee545041ee2dd584b5f29c05b62a3ade6 Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Wed, 5 Jun 2024 16:35:46 +0200 Subject: [PATCH 11/12] Delete azurerm specific script --- ...te_azurerm_msi_oidc_service_connection.ps1 | 316 ------------------ 1 file changed, 316 deletions(-) delete mode 100644 scripts/azure-devops/create_azurerm_msi_oidc_service_connection.ps1 diff --git a/scripts/azure-devops/create_azurerm_msi_oidc_service_connection.ps1 b/scripts/azure-devops/create_azurerm_msi_oidc_service_connection.ps1 deleted file mode 100644 index f17034d..0000000 --- a/scripts/azure-devops/create_azurerm_msi_oidc_service_connection.ps1 +++ /dev/null @@ -1,316 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Create a Service Connection in Azure DevOps that uses a Managed Identity and Workload Identity federation to authenticate to Azure. - -.DESCRIPTION - Creates a Managed Identiy, sets up a federation subject on the Managed Identity for a Service Connection, creates the Service Connection, and grants the Managed Identity the Contributor role on the subscription. - -.LINK - https://aka.ms/azdo-rm-workload-identity - -.EXAMPLE - ./create_azurerm_msi_oidc_service_connection.ps1 -Project MyProject -OrganizationUrl https://dev.azure.com/MyOrg -SubscriptionId 00000000-0000-0000-0000-000000000000 -#> -#Requires -Version 7.2 - -param ( - [parameter(Mandatory=$false,HelpMessage="Name of the Managed Identity")] - [string] - $IdentityName, - - [parameter(Mandatory=$false,HelpMessage="Name of the Azure Resource Group where the Managed Identity will be created")] - [string] - $IdentityResourceGroupName, - - [parameter(Mandatory=$false,HelpMessage="Id of the Azure Subscription where the Managed Identity will be created")] - [guid] - $IdentitySubscriptionId=($env:AZURE_SUBSCRIPTION_ID || $env:ARM_SUBSCRIPTION_ID), - - [parameter(Mandatory=$false,HelpMessage="Azure region of the Managed Identity")] - [string] - $IdentityLocation, - - [parameter(Mandatory=$false,HelpMessage="Name of the Service Connection")] - [string] - $ServiceConnectionName, - - [parameter(Mandatory=$false,HelpMessage="Role to grant the Service Connection on the selected scope")] - [string] - [ValidateNotNullOrEmpty()] - $ServiceConnectionRole="Contributor", - - [parameter(Mandatory=$false,HelpMessage="Scope of the Service Connection (e.g. /subscriptions/00000000-0000-0000-0000-000000000000)")] - [string] - [ValidatePattern("^$|(?i)/subscriptions/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}(/resourcegroups/(.+?))?")] - $ServiceConnectionScope, - - [parameter(Mandatory=$false,HelpMessage="Name of the Azure DevOps Project")] - [string] - [ValidateNotNullOrEmpty()] - $Project=$env:SYSTEM_TEAMPROJECT, - - [parameter(Mandatory=$false,HelpMessage="Url of the Azure DevOps Organization")] - [uri] - [ValidateNotNullOrEmpty()] - $OrganizationUrl=($env:AZDO_ORG_SERVICE_URL ?? $env:SYSTEM_COLLECTIONURI) -) -Write-Verbose $MyInvocation.line -. (Join-Path $PSScriptRoot .. functions.ps1) -$apiVersion = "7.1-preview.4" - -#----------------------------------------------------------- -# Log in to Azure -if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - Write-Error "Azure CLI is not installed. You can get it here: https://docs.microsoft.com/cli/azure/install-azure-cli" - exit 1 -} -az account show -o json 2>$null | ConvertFrom-Json | Set-Variable subscription -if (!$subscription) { - az login -o json | ConvertFrom-Json | Set-Variable subscription -} -$subscription | Format-List | Out-String | Write-Debug -if ($IdentitySubscriptionId) { - az account set --subscription $IdentitySubscriptionId -o none - az account show -o json 2>$null | ConvertFrom-Json | Set-Variable subscription -} else { - # Prompt for subscription - az account list --query "sort_by([].{id:id, name:name},&name)" ` - -o json ` - | ConvertFrom-Json ` - | Set-Variable subscriptions - - if ($subscriptions.Length -eq 1) { - $occurrence = 0 - } else { - # Active subscription may not be the desired one, prompt the user to select one - $index = 0 - $subscriptions | Format-Table -Property @{name="index";expression={$script:index;$script:index+=1}}, id, name - Write-Host "Set `$env:ARM_SUBSCRIPTION_ID to the id of the subscription you want to use to prevent this prompt" -NoNewline - - do { - Write-Host "`nEnter the index # of the subscription you want to use: " -ForegroundColor Cyan -NoNewline - [int]$occurrence = Read-Host - Write-Debug "User entered index '$occurrence'" - } while (($occurrence -notmatch "^\d+$") -or ($occurrence -lt 1) -or ($occurrence -gt $subscriptions.Length)) - } - - $subscription = $subscriptions[$occurrence-1] - $IdentitySubscriptionId = $subscription.id - - Write-Host "Using subscription '$($subscription.name)'" -ForegroundColor Yellow - Start-Sleep -Milliseconds 250 -} - -# Log in to Azure & Azure DevOps -$OrganizationUrl = $OrganizationUrl.ToString().Trim('/') -az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 ` - --query "accessToken" ` - --output tsv ` - | Set-Variable accessToken -if (!$accessToken) { - Write-Error "$(subscription.user.name) failed to get access token for Azure DevOps" - exit 1 -} -if (!(az extension list --query "[?name=='azure-devops'].version" -o tsv)) { - Write-Host "Adding Azure CLI extension 'azure-devops'..." - az extension add -n azure-devops -y -o none -} -$accessToken | az devops login --organization $OrganizationUrl -if ($lastexitcode -ne 0) { - Write-Error "$($subscription.user.name) failed to log in to Azure DevOps organization '${OrganizationUrl}'" - exit $lastexitcode -} - -#----------------------------------------------------------- -# Process parameters, making sure they're not empty -$organizationName = $OrganizationUrl.ToString().Split('/')[3] -if (!$IdentityResourceGroupName) { - $IdentityResourceGroupName = (az config get defaults.group --query value -o tsv) -} -if (!$IdentityResourceGroupName) { - $IdentityResourceGroupName = "VS-${organizationName}-Group" -} -az group show -g $IdentityResourceGroupName -o json 2>$null | ConvertFrom-Json | Set-Variable resourceGroup -if ($resourceGroup) { - if (!$IdentityLocation) { - $IdentityLocation = $resourceGroup.location - } -} else { - if (!$IdentityLocation) { - $IdentityLocation = (az config get defaults.location --query value -o tsv) - } - if (!$IdentityLocation) { - # Azure location doesn't really matter for MI; the object is in AAD which is a global service - $IdentityLocation = "southcentralus" - } - az group create -g $IdentityResourceGroupName -l $IdentityLocation -o json | ConvertFrom-Json | Set-Variable resourceGroup -} -if (!$ServiceConnectionScope) { - $ServiceConnectionScope = "/subscriptions/${IdentitySubscriptionId}" - Write-Verbose "Parameter ServiceConnectionScope not provided, using '${ServiceConnectionScope}'." -} -$serviceConnectionSubscriptionId = $ServiceConnectionScope.Split('/')[2] - -# Check whether project exists -az devops project show --project $Project --organization $OrganizationUrl --query id -o tsv | Set-Variable projectId -if (!$projectId) { - Write-Error "Project '${Project}' not found in organization '${OrganizationUrl}" - exit 1 -} - -# Test whether Service Connection already exists -$serviceConnectionSubscriptionName = $(az account show --subscription $serviceConnectionSubscriptionId --query name -o tsv) -if (!$ServiceConnectionName) { - $ServiceConnectionName = $serviceConnectionSubscriptionName - $serviceConnectionResourceGroupName = $ServiceConnectionScope.Split('/')[4] - if ($serviceConnectionResourceGroupName) { - $ServiceConnectionName += "-${serviceConnectionResourceGroupName}" - } - $ServiceConnectionName += "-oidc-msi" -} -do { - az devops service-endpoint list -p $Project ` - --organization $OrganizationUrl ` - --query "[?name=='${ServiceConnectionName}'].id" ` - -o tsv ` - | Set-Variable serviceEndpointId - - $ServiceConnectionNameBefore = $ServiceConnectionName - if ($serviceEndpointId) { - Write-Warning "Service connection '${ServiceConnectionName}' already exists. Provide a different name to create a new service connection or press enter to overwrite '${ServiceConnectionName}'." - $ServiceConnectionName = Read-Host -Prompt "Provide the name of the service connection ('${ServiceConnectionName}')" - if (!$ServiceConnectionName) { - $ServiceConnectionName = $ServiceConnectionNameBefore - } - if ($ServiceConnectionName -ieq $ServiceConnectionNameBefore) { - Write-Verbose "Service connection '${ServiceConnectionName}' (${serviceEndpointId}) wil be updated" - break - } - } else { - Write-Verbose "Service connection '${ServiceConnectionName}' (${serviceEndpointId}) wil be created" - } -} while ($serviceEndpointId) - -#----------------------------------------------------------- -# Create Managed Identity -if (!$IdentityName) { - $IdentityName = "${organizationName}-${Project}-${ServiceConnectionName}" -} -Write-Verbose "Creating Managed Identity '${IdentityName}' in resource group '${IdentityResourceGroupName}'..." -Write-Debug "az identity create -n $IdentityName -g $IdentityResourceGroupName -l $IdentityLocation --subscription $IdentitySubscriptionId" -az identity create -n $IdentityName ` - -g $IdentityResourceGroupName ` - -l $IdentityLocation ` - --subscription $IdentitySubscriptionId ` - -o json ` - | ConvertFrom-Json ` - | Set-Variable identity -Write-Verbose "Created Managed Identity $($identity.id)" -$identity | Format-List | Out-String | Write-Debug - -Write-Verbose "Creating role assignment for Managed Identity '${IdentityName}' on subscription '$($subscription.name)'..." -az role assignment create --assignee-object-id $identity.principalId ` - --assignee-principal-type ServicePrincipal ` - --role $ServiceConnectionRole ` - --scope $ServiceConnectionScope ` - --subscription $serviceConnectionSubscriptionId ` - -o json ` - | ConvertFrom-Json ` - | Set-Variable roleAssignment -Write-Verbose "Created role assignment $($roleAssignment.id)" - -Write-Host "`nManaged Identity '$($identity.name)':" -$identity | Format-List -Property id, clientId, federatedSubject, role, scope, subscriptionId, tenantId - -#----------------------------------------------------------- -# TODO: Create the service connection (Azure CLI) -# az devops service-endpoint azurerm create --azure-rm-service-principal-id $identity.clientId ` -# --azure-rm-subscription-id $serviceConnectionSubscriptionId ` -# --azure-rm-subscription-name $ServiceConnectionName ` -# --azure-rm-tenant-id $identity.tenantId ` -# --name $IdentityName ` -# --organization $OrganizationUrl ` -# --project $Project ` - -# Prepare service connection REST API request body -Write-Verbose "Creating / updating service connection '${ServiceConnectionName}'..." -Get-Content -Path (Join-Path $PSScriptRoot manualServiceEndpointRequest.json) ` - | ConvertFrom-Json ` - | Set-Variable serviceEndpointRequest - -$serviceEndpointDescription = "Created by $($MyInvocation.MyCommand.Name). Configured Managed Identity ${IdentityName} (clientId $($identity.clientId)) federated on ${federatedSubject} as ${ServiceConnectionRole} on scope ${ServiceConnectionScope}." -$serviceEndpointRequest.authorization.parameters.servicePrincipalId = $identity.clientId -$serviceEndpointRequest.authorization.parameters.tenantId = $identity.tenantId -$serviceEndpointRequest.data.subscriptionId = $serviceConnectionSubscriptionId -$serviceEndpointRequest.data.subscriptionName = $serviceConnectionSubscriptionName -$serviceEndpointRequest.description = $serviceEndpointDescription -$serviceEndpointRequest.name = $ServiceConnectionName -$serviceEndpointRequest.serviceEndpointProjectReferences[0].description = $serviceEndpointDescription -$serviceEndpointRequest.serviceEndpointProjectReferences[0].name = $ServiceConnectionName -$serviceEndpointRequest.serviceEndpointProjectReferences[0].projectReference.id = $projectId -$serviceEndpointRequest.serviceEndpointProjectReferences[0].projectReference.name = $Project -$serviceEndpointRequest | ConvertTo-Json -Depth 4 | Set-Variable serviceEndpointRequestBody -Write-Debug "Service connection request body: `n${serviceEndpointRequestBody}" - -$apiUri = "${OrganizationUrl}/_apis/serviceendpoint/endpoints" -if ($serviceEndpointId) { - $apiUri += "/${serviceEndpointId}" -} -$apiUri += "?api-version=${apiVersion}" -Invoke-RestMethod -Uri $apiUri ` - -Method ($serviceEndpointId ? 'PUT' : 'POST') ` - -Body $serviceEndpointRequestBody ` - -ContentType 'application/json' ` - -Authentication Bearer ` - -Token (ConvertTo-SecureString $accessToken -AsPlainText) ` - | Set-Variable serviceEndpoint - -$serviceEndpoint | ConvertTo-Json -Depth 4 | Write-Debug -if (!$serviceEndpoint) { - Write-Error "Failed to create / update service connection '${ServiceConnectionName}'" - exit 1 -} -if ($serviceEndpointId) { - Write-Host "Service connection '${ServiceConnectionName}' updated:" -} else { - Write-Host "Service connection '${ServiceConnectionName}' created:" -} -Write-Debug "Service connection data:" -$serviceEndpoint.data | Format-List | Out-String | Write-Debug -Write-Debug "Service connection authorization parameters:" -$serviceEndpoint.authorization.parameters | Format-List | Out-String | Write-Debug - -# Create Federated Credential -Write-Verbose "Configuring Managed Identity '${IdentityName}' with federated subject '$($serviceEndpoint.authorization.parameters.workloadIdentityFederationSubject)'..." -az identity federated-credential create --name $IdentityName ` - --identity-name $IdentityName ` - --resource-group $IdentityResourceGroupName ` - --issuer $serviceEndpoint.authorization.parameters.workloadIdentityFederationIssuer ` - --subject $serviceEndpoint.authorization.parameters.workloadIdentityFederationSubject ` - --subscription $IdentitySubscriptionId ` - -o json ` - | ConvertFrom-Json ` - | Set-Variable federatedCredential -Write-Verbose "Created federated credential $($federatedCredential.id)" -$identity | Add-Member -NotePropertyName federatedSubject -NotePropertyValue $serviceEndpoint.authorization.parameters.workloadIdentityFederationSubject -$identity | Add-Member -NotePropertyName role -NotePropertyValue $ServiceConnectionRole -$identity | Add-Member -NotePropertyName scope -NotePropertyValue $ServiceConnectionScope -$identity | Add-Member -NotePropertyName subscriptionId -NotePropertyValue $IdentitySubscriptionId -$identity | Format-List | Out-String | Write-Debug - -$serviceEndpoint | Select-Object -Property authorization, data, id, name, description, type, createdBy ` - | ForEach-Object { - $_.createdBy = $_.createdBy.uniqueName - $_ | Add-Member -NotePropertyName clientId -NotePropertyValue $_.authorization.parameters.serviceprincipalid - $_ | Add-Member -NotePropertyName creationMode -NotePropertyValue $_.data.creationMode - $_ | Add-Member -NotePropertyName scheme -NotePropertyValue $_.authorization.scheme - $_ | Add-Member -NotePropertyName scopeLevel -NotePropertyValue $_.data.scopeLevel - $_ | Add-Member -NotePropertyName subscriptionName -NotePropertyValue $_.data.subscriptionName - $_ | Add-Member -NotePropertyName subscriptionId -NotePropertyValue $_.data.subscriptionId - $_ | Add-Member -NotePropertyName tenantid -NotePropertyValue $_.authorization.parameters.tenantid - $_ | Add-Member -NotePropertyName workloadIdentityFederationSubject -NotePropertyValue $_.authorization.parameters.workloadIdentityFederationSubject - $_ - } ` - | Select-Object -ExcludeProperty authorization, data - | Format-List From 2b89512d7d47df303e3139202d8b12b7c17cbde9 Mon Sep 17 00:00:00 2001 From: Eric van Wijk Date: Fri, 7 Jun 2024 12:26:31 +0200 Subject: [PATCH 12/12] Use azureConnectionWIF --- scripts/azure-devops/azure-pipelines.yml | 2 +- scripts/azure-pipelines.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/azure-devops/azure-pipelines.yml b/scripts/azure-devops/azure-pipelines.yml index c51d587..117d887 100644 --- a/scripts/azure-devops/azure-pipelines.yml +++ b/scripts/azure-devops/azure-pipelines.yml @@ -218,7 +218,7 @@ jobs: displayName: 'Create ACR, Managed Identity and Docker Registry Service Connection' name: acrIdentity inputs: - azureSubscription: '$(azureConnection)' + azureSubscription: '$(azureConnectionWIF)' failOnStandardError: true scriptType: pscore scriptLocation: inlineScript diff --git a/scripts/azure-pipelines.yml b/scripts/azure-pipelines.yml index 05f1ba8..270aa76 100644 --- a/scripts/azure-pipelines.yml +++ b/scripts/azure-pipelines.yml @@ -43,7 +43,7 @@ jobs: - task: AzureCLI@2 displayName: 'find_workload_identity.ps1' inputs: - azureSubscription: '$(azureConnection)' + azureSubscription: '$(azureConnectionWIF)' scriptType: pscore scriptLocation: inlineScript inlineScript: | @@ -83,7 +83,7 @@ jobs: - task: AzureCLI@2 displayName: 'list_managed_identities.ps1' inputs: - azureSubscription: '$(azureConnection)' + azureSubscription: '$(azureConnectionWIF)' scriptType: pscore scriptLocation: inlineScript inlineScript: |