File: src\Shared\CrossScopeAcrPullIdentityPreparer.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.AppContainers\Aspire.Hosting.Azure.AppContainers.csproj (Aspire.Hosting.Azure.AppContainers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREAZURE001
#pragma warning disable ASPIREAZURE003
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Azure.Provisioning;
using Azure.Provisioning.ContainerRegistry;
using Azure.Provisioning.Roles;
 
// ACA and App Service normally emit their ACR pull identity and AcrPull assignment inline in the
// environment module. That remains the preferred path, but Bicep rejects an inline role assignment
// when PublishAsExisting selects a registry in an explicit resource group or subscription (BCP139).
//
// This linked helper runs after the application model and final registry selection are complete. For
// only that cross-scope case, it creates a standalone identity and lets AzureResourcePreparer emit the
// role assignment as a separately scoped module. Package-specific annotations remain in their owning
// assemblies, so this source is linked into only the ACA and App Service projects.
namespace Aspire.Hosting.Azure;
 
/// <summary>
/// Identifies the package-specific annotation that supplies an environment's ACR pull identity.
/// </summary>
internal interface IAcrPullIdentityAnnotation : IResourceAnnotation
{
    AzureUserAssignedIdentityResource Identity { get; }
}
 
/// <summary>
/// Promotes an inline ACR pull identity to a standalone resource when its final registry is explicitly cross-scoped.
/// </summary>
internal static class CrossScopeAcrPullIdentityPreparer
{
    /// <summary>
    /// Registers late publish preparation while preserving existing inline and user-supplied identity paths.
    /// </summary>
    [AspireExportIgnore(Reason = "Internal publish pipeline wiring.")]
    public static IResourceBuilder<TEnvironment> WithCrossScopeAcrPullIdentity<TEnvironment>(
        this IResourceBuilder<TEnvironment> builder,
        Func<AzureUserAssignedIdentityResource, IAcrPullIdentityAnnotation> createIdentityAnnotation,
        Action<IResourceBuilder<AzureUserAssignedIdentityResource>>? configureIdentity = null)
        where TEnvironment : IResource, IAzureComputeEnvironmentResource
    {
        builder.WithAnnotation(new PipelineStepAnnotation(context =>
        {
            if (!ShouldPrepareIdentity(context.PipelineContext.ExecutionContext, builder.Resource))
            {
                return [];
            }
 
            return
            [
                new PipelineStep
                {
                    Name = $"prepare-cross-scope-acr-pull-identity-{builder.Resource.Name}",
                    Description = $"Prepares the ACR pull identity for {builder.Resource.Name}.",
                    Action = stepContext =>
                    {
                        PrepareIdentity(stepContext, builder, createIdentityAnnotation, configureIdentity);
                        return Task.CompletedTask;
                    },
                    RequiredBySteps = [AzureEnvironmentResource.PrepareResourcesStepName]
                }
            ];
        }));
 
        return builder;
    }
 
    private static bool ShouldPrepareIdentity<TEnvironment>(
        DistributedApplicationExecutionContext executionContext,
        TEnvironment environment)
        where TEnvironment : IResource, IAzureComputeEnvironmentResource
    {
        // Run mode does not emit the environment Bicep that contains the problematic role assignment.
        // Adding a standalone Azure identity there would unnecessarily change the local application model.
        if (!executionContext.IsPublishMode)
        {
            return false;
        }
 
        // WithAcrPullIdentity has already selected a user-supplied identity, so preserve that path.
        if (environment.HasAnnotationOfType<IAcrPullIdentityAnnotation>())
        {
            return false;
        }
 
        // ContainerRegistry is evaluated late so registry replacement APIs have already selected the final registry.
        if (environment.ContainerRegistry is not AzureContainerRegistryResource registry ||
            !registry.TryGetLastAnnotation<ExistingAzureResourceAnnotation>(out var existingRegistry))
        {
            return false;
        }
 
        // An existing registry without an explicit scope resolves in the deployment's resource group and
        // subscription, where the current inline identity and role assignment remain valid.
        return existingRegistry.ResourceGroup is not null || existingRegistry.Subscription is not null;
    }
 
    private static void PrepareIdentity<TEnvironment>(
        PipelineStepContext context,
        IResourceBuilder<TEnvironment> builder,
        Func<AzureUserAssignedIdentityResource, IAcrPullIdentityAnnotation> createIdentityAnnotation,
        Action<IResourceBuilder<AzureUserAssignedIdentityResource>>? configureIdentity)
        where TEnvironment : IResource, IAzureComputeEnvironmentResource
    {
        if (!ShouldPrepareIdentity(context.ExecutionContext, builder.Resource) ||
            builder.Resource.ContainerRegistry is not AzureContainerRegistryResource registry)
        {
            return;
        }
 
        // A cross-scope role assignment cannot be emitted inline in the environment module (BCP139).
        // Promote only this path to a standalone identity so AzureResourcePreparer can emit the
        // role assignment as a module scoped to the existing registry.
        var identityName = $"{builder.Resource.Name}-mi";
        if (context.Model.Resources.TryGetByName(identityName, out _))
        {
            throw new DistributedApplicationException(
                $"Cannot create the cross-scope ACR pull identity '{identityName}' for environment '{builder.Resource.Name}' because a resource with that name already exists. Call 'WithAcrPullIdentity' on the environment to select an existing identity, or use a different resource name.");
        }
 
        var identity = new AzureUserAssignedIdentityResource(identityName);
        var identityBuilder = builder.ApplicationBuilder.CreateResourceBuilder(identity);
        identityBuilder.ConfigureInfrastructure(infrastructure =>
        {
            // The inline identity uses the environment module's standard tags parameter. Recreate that
            // contract on the promoted module so deployment tags and required-tag policies still apply.
            var tags = new ProvisioningParameter("tags", typeof(object))
            {
                Value = new BicepDictionary<string>()
            };
            infrastructure.Add(tags);
 
            var identity = infrastructure.GetProvisionableResources().OfType<UserAssignedIdentity>().Single();
            identity.Tags = tags;
        });
        configureIdentity?.Invoke(identityBuilder);
        identityBuilder.WithRoleAssignments(
            builder.ApplicationBuilder.CreateResourceBuilder(registry),
            ContainerRegistryBuiltInRole.AcrPull);
 
        context.Model.Resources.Add(identity);
        builder.Resource.Annotations.Add(createIdentityAnnotation(identity));
        if (builder.Resource is AzureBicepResource environment)
        {
            environment.References.Add(identity);
        }
    }
}