- Both registries need public network access. A registry on Selected networks still counts as enabled, but it also has to allow trusted Azure services to bypass the network, which is on by default.
- Both registries accept Entra ARM tokens. You can test it using the following script.
$TENANT_ID = "<TENANT_ID>" $SUBSCRIPTION_ID = "<SUBSCRIPTION_ID>" $ACR_RESOURCE_GROUP_NAME = "<ACR_RESOURCE_GROUP_NAME>" $ACR_NAME = "<ACR_NAME>" $ACR_RESOURCE_ID = "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$ACR_RESOURCE_GROUP_NAME/providers/Microsoft.ContainerRegistry/registries/$ACR_NAME" # Login to tenant and set the subscription az login ` --tenant $TENANT_ID az account set ` --subscription $SUBSCRIPTION_ID # Confirm the registry accepts Entra ARM tokens az rest ` --method get ` --url "https://management.azure.com$ACR_RESOURCE_ID`?api-version=2023-01-01-preview" ` --query "properties.policies.azureADAuthenticationAsArmPolicy.status" ` --output tsv
- Two Azure DevOps service connections, both in our project, each federated to a different managed identity in a different tenant. No secret in either one, just a trust.
- Ours/Source: A user-assigned managed identity with AcrPull on our ACR, and a service connection federated to it. We add the federated credential to that identity ourselves, because it lives in our tenant.
- If your source registry is ABAC-enabled, grant Container Registry Repository Reader instead. AcrPull isn't honoured there.
- Theirs/External: A user-assigned managed identity with Container Registry Data Importer and Data Reader on their ACR. We create the second service connection pointing at their tenant, and send them the Issuer and Subject it generates. They add the federated credential to their identity.
- At deploy time the pipeline mints a short-lived token using the first connection, then calls az acr import with the second connection.
az acr import `
--name "<THEIR_ACR_NAME>" `
--resource-group "<THEIR_ACR_RESOURCE_GROUP_NAME>" `
--source "<OUR_ACR_LOGIN_SERVER>/<IMAGE_NAME>:<IMAGE_TAG>" `
--image "<IMAGE_NAME>:<IMAGE_TAG>" `
--password "<ACCESS_TOKEN_CREATED_USING_OUR_SERVICE_CONNECTION>"
- Note there is no --username. The access token is only accepted as a lone --password, and adding a username turns it into a basic auth pair.
- az acr import authenticates the source and the target separately.
- The call goes to their registry as an identity in their tenant, and our registry is named in the same request with its own credential alongside.
- Their registry then pulls the image straight from ours, server to server, so the build agent never downloads it.
Now let's see this in action.
Some variables.
$SOURCE_TENANT_ID = "<OUR_TENANT_ID>" $SOURCE_SUBSCRIPTION_ID = "<OUR_SUBSCRIPTION_ID>" # Identity $MIRROR_IDENTITY_NAME = "<MANAGED_IDENTITY_NAME>" $MIRROR_IDENTITY_RESOURCE_GROUP_NAME = "<MANAGED_IDENTITY_RESOURCE_GROUP_NAME>" $MIRROR_IDENTITY_LOCATION = "<LOCATION>" # Source ACR $SOURCE_ACR_RESOURCE_GROUP_NAME = "<ACR_RESOURCE_GROUP>" $SOURCE_ACR_NAME = "<ACR_NAME>" $SOURCE_ACR_RESOURCE_ID = "/subscriptions/$SOURCE_SUBSCRIPTION_ID/resourceGroups/$SOURCE_ACR_RESOURCE_GROUP_NAME/providers/Microsoft.ContainerRegistry/registries/$SOURCE_ACR_NAME"
First step is creating a Managed Identity.
# Login to the tenant and set subscription az login ` --tenant $SOURCE_TENANT_ID az account set ` --subscription $SOURCE_SUBSCRIPTION_ID # Create a user-assigned managed identity az identity create ` --name $MIRROR_IDENTITY_NAME ` --resource-group $MIRROR_IDENTITY_RESOURCE_GROUP_NAME ` --subscription $SOURCE_SUBSCRIPTION_ID ` --location $MIRROR_IDENTITY_LOCATION $sourceIdentity = az identity show ` --name $MIRROR_IDENTITY_NAME ` --resource-group $MIRROR_IDENTITY_RESOURCE_GROUP_NAME ` --subscription $SOURCE_SUBSCRIPTION_ID | ConvertFrom-Json $MIRROR_IDENTITY_PRINCIPAL_ID = $sourceIdentity.principalId $MIRROR_IDENTITY_CLIENT_ID = $sourceIdentity.clientId
Now let's grant the MI AcrPull OR Container Registry Repository Reader on our ACR.
# Assign the AcrPull OR Container Registry Repository Reader role to the managed identity az role assignment create ` --assignee-object-id $MIRROR_IDENTITY_PRINCIPAL_ID ` --assignee-principal-type ServicePrincipal ` --role "<AcrPull OR Container Registry Repository Reader>" ` --scope $SOURCE_ACR_RESOURCE_ID
Now let's create the source Service Connection.
|
|
| New Service Connection: Azure Resource Manager |
Note:
- Identity Type: App registration or managed identity (manual)
- Credential: Workload identity federation
- Directory (tenant) ID: Our Tenant ID.
|
|
| New Service Connection App Registration Details |
# Create federated credential for the managed identity az identity federated-credential create ` --name azure-devops-mirror-source ` --identity-name $MIRROR_IDENTITY_NAME ` --resource-group $MIRROR_IDENTITY_RESOURCE_GROUP_NAME ` --subscription $SOURCE_SUBSCRIPTION_ID ` --issuer "<Issuer>" ` --subject "<Subject identifier>" ` --audiences "api://AzureADTokenExchange"
Now we need to configure the external side. Almost all of the steps are exact same as above with different values. On external side,
- The managed identity needs to be granted role: Container Registry Data Importer and Data Reader on their ACR.
- To create the second service connection, the only thing we need from them up front is their Tenant ID. Note the Directory (tenant) ID is their tenant, not ours.
- Fill in the tenant id, move to the next step, and Azure DevOps generates the Issuer and Subject identifier. Copy both and select Keep as draft.
- Send them the Issuer and Subject identifier so they can create the federated credential on their managed identity. When they confirm, they need to send back the following
- Subscription ID
- Subscription Name
- ACR Name
- ACR Resource Group Name
- Client ID of their Managed Identity
- Once the information is received, go back to the draft connection, fill those in, then Finish setup and Verify and save.
trigger: none pr: none parameters: - name: sourceImageName displayName: Source image, repository:tag with no host type: string default: <IMAGE_NAME>:<IMAGE_TAG> pool: vmImage: ubuntu-latest variables: sourceServiceConnection: <SOURCE_SERVICE_CONNECTION_NAME> targetServiceConnection: <EXTERNAL_SERVICE_CONNECTION_NAME> sourceAcrLoginServer: <SOURCE_ACR_NAME>.azurecr.io targetAcrName: <TARGET_ACR_NAME> targetAcrResourceGroup: <TARGET_ACR_RESOURCE_GROUP> steps: - task: AzureCLI@2 displayName: Get source ACR read token inputs: azureSubscription: $(sourceServiceConnection) scriptType: pscore scriptLocation: inlineScript inlineScript: | az account show ` --query "{tenant:tenantId, subscription:name, id:id, principal:user.name}" ` --output table $SOURCE_ACCESS_TOKEN = (az account get-access-token ` --query accessToken ` --output tsv).Trim() Write-Host "##vso[task.setvariable variable=sourceAccessToken;issecret=true]$SOURCE_ACCESS_TOKEN" - task: AzureCLI@2 displayName: Import image into target ACR inputs: azureSubscription: $(targetServiceConnection) scriptType: pscore scriptLocation: inlineScript inlineScript: | az account show ` --query "{tenant:tenantId, subscription:name, id:id, principal:user.name}" ` --output table az acr import ` --name "$(targetAcrName)" ` --resource-group "$(targetAcrResourceGroup)" ` --source "$(sourceAcrLoginServer)/${{ parameters.sourceImageName }}" ` --image "${{ parameters.sourceImageName }}" ` --password "$(sourceAccessToken)" ` --force - task: AzureCLI@2 displayName: Confirm the tag landed inputs: azureSubscription: $(targetServiceConnection) scriptType: pscore scriptLocation: inlineScript inlineScript: | $IMAGE_NAME = "${{ parameters.sourceImageName }}" az acr repository show-tags ` --name "$(targetAcrName)" ` --repository ($IMAGE_NAME.Split(":")[0]) ` --output table
Jaliya