Monday, August 24, 2026

Azure DevOps: Copy Azure Container Registry (ACR) Images to an External Tenant's ACR

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
Here, the Application (client) ID is the MIRROR_IDENTITY_CLIENT_ID.

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.
Pipeline Run
Hope this helps.

Happy Coding.

Regards,
Jaliya

No comments:

Post a Comment