Consider a scenario where an App Service running in an external tenant needs
to pull an image from our own ACR. The lazy way is to share our ACR
credentials (username/password) with the external party, so they can set
DOCKER_REGISTRY_SERVER_USERNAME and
DOCKER_REGISTRY_SERVER_PASSWORD.
But obviously that's not something we should be doing. We can't be handing
our own passwords to anyone, and we shouldn't be using passwords or secrets
in the first place.
You might think the fix is for them to give their App Service a managed
identity and grant it AcrPull on our ACR. That doesn't work across tenants.
A managed identity only exists in its own tenant.
What does work is copying the image into their ACR first. Then their App
Service pulls from their own registry, in their own tenant, with their own
managed identity, and that's just the ordinary same-tenant case.
So in this post, let's see how we can copy Azure Container Registry (ACR)
images to an external tenant's ACR in Azure DevOps..
Prerequisites
- 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
The concept
- 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 |
Now before clicking on Verify and save, run the following
using displayed Issuer and Subject identifier to create
federated credentials.
# 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"
After this is executed, wait for a few seconds and then click on
Verify and save. It should be verified and saved successfully.
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.
One caution: Tenant ID cannot be edited
after the connection is created. If it is wrong, you have to delete the
connection and create a new one, and therefore a new
Subject identifier, so the federated credential has to be recreated as
well.
At this point, second service connection should be verified and saved
successfully.
Now the moment of truth. We can create a simple pipeline to test the az
acr import end-to-end.
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
And when you run this, it should do the import.
Happy Coding.
Regards,
Jaliya
Jaliya