diff --git a/src/Aspire.Hosting.Azure/AppIdentityResource.cs b/src/Aspire.Hosting.Azure/AppIdentityResource.cs
deleted file mode 100644
index c5bcc29f6cc..00000000000
--- a/src/Aspire.Hosting.Azure/AppIdentityResource.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using Azure.Provisioning;
-using Azure.Provisioning.Roles;
-
-namespace Aspire.Hosting.Azure;
-
-///
-/// An Azure Provisioning resource that represents an Azure user assigned managed identity.
-///
-internal sealed class AppIdentityResource(string name)
- : AzureProvisioningResource(name, ConfigureAppIdentityInfrastructure), IAppIdentityResource
-{
- public BicepOutputReference Id => new("id", this);
- public BicepOutputReference ClientId => new("clientId", this);
- public BicepOutputReference PrincipalId => new("principalId", this);
- public BicepOutputReference PrincipalName => new("principalName", this);
-
- private static void ConfigureAppIdentityInfrastructure(AzureResourceInfrastructure infra)
- {
- var identityName = Infrastructure.NormalizeBicepIdentifier(infra.AspireResource.Name);
- var userAssignedIdentity = new UserAssignedIdentity(identityName);
- infra.Add(userAssignedIdentity);
-
- infra.Add(new ProvisioningOutput("id", typeof(string)) { Value = userAssignedIdentity.Id });
- infra.Add(new ProvisioningOutput("clientId", typeof(string)) { Value = userAssignedIdentity.ClientId });
- infra.Add(new ProvisioningOutput("principalId", typeof(string)) { Value = userAssignedIdentity.PrincipalId });
- infra.Add(new ProvisioningOutput("principalName", typeof(string)) { Value = userAssignedIdentity.Name });
- }
-}
diff --git a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs
index f0fc63b99b0..eb3a0ce9aa5 100644
--- a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs
+++ b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs
@@ -130,7 +130,7 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap
continue;
}
- if (!resource.IsContainer() && resource is not ProjectResource)
+ if (!IsResourceValidForRoleAssignments(resource))
{
continue;
}
@@ -179,10 +179,13 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap
{
var (identityResource, roleAssignmentResources) = CreateIdentityAndRoleAssignmentResources(options, resource, roleAssignments);
- // attach the identity resource to compute resource so it can be used by the compute environment
- resource.Annotations.Add(new AppIdentityAnnotation(identityResource));
-
- appModel.Resources.Add(identityResource);
+ if (resource != identityResource)
+ {
+ // attach the identity resource to compute resource so it can be used by the compute environment
+ resource.Annotations.Add(new AppIdentityAnnotation(identityResource));
+ // add the identity resource to the resource collection so it can be provisioned
+ appModel.Resources.Add(identityResource);
+ }
foreach (var roleAssignmentResource in roleAssignmentResources)
{
appModel.Resources.Add(roleAssignmentResource);
@@ -209,6 +212,13 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap
{
await CreateGlobalRoleAssignments(appModel, globalRoleAssignments, options).ConfigureAwait(false);
}
+
+ // We can derive role assignments for compute resources and declared
+ // AzureUserAssignedIdentityResources
+ static bool IsResourceValidForRoleAssignments(IResource resource)
+ {
+ return resource.IsContainer() || resource is ProjectResource || resource is AzureUserAssignedIdentityResource;
+ }
}
private static Dictionary> GetAllRoleAssignments(IResource resource)
@@ -224,15 +234,17 @@ private static Dictionary
return result;
}
- private static (AppIdentityResource IdentityResource, List RoleAssignmentResources) CreateIdentityAndRoleAssignmentResources(
+ private static (AzureUserAssignedIdentityResource IdentityResource, List RoleAssignmentResources) CreateIdentityAndRoleAssignmentResources(
AzureProvisioningOptions provisioningOptions,
IResource resource,
Dictionary> roleAssignments)
{
- var identityResource = new AppIdentityResource($"{resource.Name}-identity")
- {
- ProvisioningBuildOptions = provisioningOptions.ProvisioningBuildOptions
- };
+ var identityResource = resource is AzureUserAssignedIdentityResource existingIdentityResource
+ ? existingIdentityResource
+ : new AzureUserAssignedIdentityResource($"{resource.Name}-identity")
+ {
+ ProvisioningBuildOptions = provisioningOptions.ProvisioningBuildOptions
+ };
var roleAssignmentResources = CreateRoleAssignmentsResources(provisioningOptions, resource, roleAssignments, identityResource);
return (identityResource, roleAssignmentResources);
@@ -242,7 +254,7 @@ private static List CreateRoleAssignmentsResources(
AzureProvisioningOptions provisioningOptions,
IResource resource,
Dictionary> roleAssignments,
- AppIdentityResource appIdentityResource)
+ AzureUserAssignedIdentityResource appIdentityResource)
{
var roleAssignmentResources = new List();
foreach (var (targetResource, roles) in roleAssignments)
@@ -271,7 +283,7 @@ private static void AddRoleAssignmentsInfrastructure(
AzureResourceInfrastructure infra,
AzureProvisioningResource azureResource,
IEnumerable roles,
- AppIdentityResource appIdentityResource)
+ AzureUserAssignedIdentityResource appIdentityResource)
{
var context = new AddRoleAssignmentsContext(
infra,
diff --git a/src/Aspire.Hosting.Azure/AzureUserAssignedIdentityExtensions.cs b/src/Aspire.Hosting.Azure/AzureUserAssignedIdentityExtensions.cs
new file mode 100644
index 00000000000..5ee34e3349b
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/AzureUserAssignedIdentityExtensions.cs
@@ -0,0 +1,44 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Azure;
+
+///
+/// Provides extension methods for working with Azure user‑assigned identities.
+///
+public static class AzureUserAssignedIdentityExtensions
+{
+ ///
+ /// Adds an Azure user‑assigned identity resource to the application model.
+ ///
+ /// The builder for the distributed application.
+ /// The name of the resource.
+ /// Thrown when is null.
+ /// Thrown when is null or empty.
+ ///
+ /// This method adds an Azure user‑assigned identity resource to the application model. It configures the
+ /// infrastructure for the resource and returns a builder for the resource.
+ /// The resource is added to the infrastructure only if the application is not in run mode.
+ ///
+ /// A reference to the builder.
+ public static IResourceBuilder AddAzureUserAssignedIdentity(
+ this IDistributedApplicationBuilder builder,
+ string name)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ builder.AddAzureProvisioning();
+
+ var resource = new AzureUserAssignedIdentityResource(name);
+ // Don't add the resource to the infrastructure if we're in run mode.
+ if (builder.ExecutionContext.IsRunMode)
+ {
+ return builder.CreateResourceBuilder(resource);
+ }
+
+ return builder.AddResource(resource);
+ }
+}
diff --git a/src/Aspire.Hosting.Azure/AzureUserAssignedIdentityResource.cs b/src/Aspire.Hosting.Azure/AzureUserAssignedIdentityResource.cs
new file mode 100644
index 00000000000..a15f83b48ba
--- /dev/null
+++ b/src/Aspire.Hosting.Azure/AzureUserAssignedIdentityResource.cs
@@ -0,0 +1,68 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Azure.Provisioning;
+using Azure.Provisioning.Primitives;
+using Azure.Provisioning.Roles;
+
+namespace Aspire.Hosting.Azure;
+
+///
+/// An Azure Provisioning resource that represents an Azure user assigned managed identity.
+///
+public sealed class AzureUserAssignedIdentityResource(string name)
+ : AzureProvisioningResource(name, ConfigureAppIdentityInfrastructure), IAppIdentityResource
+{
+ ///
+ /// The identifier associated with the user assigned identity.
+ ///
+ public BicepOutputReference Id => new("id", this);
+
+ ///
+ /// The client ID of the user assigned identity.
+ ///
+ public BicepOutputReference ClientId => new("clientId", this);
+
+ ///
+ /// The principal ID of the user assigned identity.
+ ///
+ public BicepOutputReference PrincipalId => new("principalId", this);
+
+ ///
+ /// The principal name of the user assigned identity.
+ ///
+ public BicepOutputReference PrincipalName => new("principalName", this);
+
+ private static void ConfigureAppIdentityInfrastructure(AzureResourceInfrastructure infrastructure)
+ {
+ var userAssignedIdentity = CreateExistingOrNewProvisionableResource(infrastructure,
+ (identifier, name) =>
+ {
+ var resource = UserAssignedIdentity.FromExisting(identifier);
+ resource.Name = name;
+ return resource;
+ },
+ (infrastructure) =>
+ {
+ var identityName = Infrastructure.NormalizeBicepIdentifier(infrastructure.AspireResource.Name);
+ var resource = new UserAssignedIdentity(identityName);
+ return resource;
+ });
+
+ infrastructure.Add(userAssignedIdentity);
+
+ infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = userAssignedIdentity.Id });
+ infrastructure.Add(new ProvisioningOutput("clientId", typeof(string)) { Value = userAssignedIdentity.ClientId });
+ infrastructure.Add(new ProvisioningOutput("principalId", typeof(string)) { Value = userAssignedIdentity.PrincipalId });
+ infrastructure.Add(new ProvisioningOutput("principalName", typeof(string)) { Value = userAssignedIdentity.Name });
+ }
+
+ ///
+ public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra)
+ {
+ var store = UserAssignedIdentity.FromExisting(this.GetBicepIdentifier());
+ store.Name = PrincipalName.AsProvisioningParameter(infra);
+ infra.Add(store);
+ return store;
+ }
+}
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureUserAssignedIdentityTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureUserAssignedIdentityTests.cs
new file mode 100644
index 00000000000..3e4a2967b7d
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureUserAssignedIdentityTests.cs
@@ -0,0 +1,102 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Azure.AppContainers;
+using Aspire.Hosting.Azure.ContainerRegistry;
+using Aspire.Hosting.Utils;
+using Azure.Provisioning.ContainerRegistry;
+using Microsoft.Extensions.DependencyInjection;
+using static Aspire.Hosting.Utils.AzureManifestUtils;
+
+namespace Aspire.Hosting.Azure.Tests;
+
+public class AzureUserAssignedIdentityTests
+{
+ [Fact]
+ public async Task AddAzureUserAssignedIdentity_GeneratesExpectedResourcesAndBicep()
+ {
+ // Arrange
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ builder.AddAzureUserAssignedIdentity("myidentity");
+
+ using var app = builder.Build();
+ await ExecuteBeforeStartHooksAsync(app, default);
+
+ var model = app.Services.GetRequiredService();
+
+ // Act
+ var resource = Assert.Single(model.Resources.OfType());
+
+ var (_, bicep) = await GetManifestWithBicep(resource);
+
+ await Verifier.Verify(bicep, extension: "bicep")
+ .UseHelixAwareDirectory("Snapshots")
+ .AutoVerify();
+ }
+
+ [Fact]
+ public async Task AddAzureUserAssignedIdentity_PublishAsExisting_Works()
+ {
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ builder.AddAzureUserAssignedIdentity("myidentity")
+ .PublishAsExisting("existingidentity", "my-rg");
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(model.Resources.OfType());
+
+ var (_, bicep) = await GetManifestWithBicep(resource);
+
+ await Verifier.Verify(bicep, extension: "bicep")
+ .UseHelixAwareDirectory("Snapshots")
+ .AutoVerify();
+ }
+
+ [Fact]
+ public async Task AddAzureUserAssignedIdentity_WithRoleAssignments_Works()
+ {
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+
+ builder.AddAzureContainerAppEnvironment("cae");
+
+ var registry = builder.AddAzureContainerRegistry("myregistry");
+ builder.AddAzureUserAssignedIdentity("myidentity")
+ .WithRoleAssignments(registry, [ContainerRegistryBuiltInRole.AcrPush]);
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ await ExecuteBeforeStartHooksAsync(app, default);
+
+ Assert.Collection(model.Resources.OrderBy(r => r.Name),
+ r => Assert.IsType(r),
+ r => Assert.IsType(r),
+ r =>
+ {
+ Assert.IsType(r);
+ Assert.Equal("myidentity-roles-myregistry", r.Name);
+ },
+ r => Assert.IsType(r));
+
+ var identityResource = Assert.Single(model.Resources.OfType());
+ var (_, identityBicep) = await GetManifestWithBicep(identityResource, skipPreparer: true);
+
+ var registryResource = Assert.Single(model.Resources.OfType());
+ var (_, registryBicep) = await GetManifestWithBicep(registryResource, skipPreparer: true);
+
+ var identityRoleAssignments = Assert.Single(model.Resources.OfType(), r => r.Name == "myidentity-roles-myregistry");
+ var (_, identityRoleAssignmentsBicep) = await GetManifestWithBicep(identityRoleAssignments, skipPreparer: true);
+
+ Target[] targets = [
+ new Target("bicep", identityBicep),
+ new Target("bicep", registryBicep),
+ new Target("bicep", identityRoleAssignmentsBicep)
+ ];
+ await Verifier.Verify(targets)
+ .UseHelixAwareDirectory("Snapshots")
+ .AutoVerify();
+ }
+}
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_GeneratesExpectedResourcesAndBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_GeneratesExpectedResourcesAndBicep.verified.bicep
new file mode 100644
index 00000000000..0440636c115
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_GeneratesExpectedResourcesAndBicep.verified.bicep
@@ -0,0 +1,15 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+resource myidentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
+ name: take('myidentity-${uniqueString(resourceGroup().id)}', 128)
+ location: location
+}
+
+output id string = myidentity.id
+
+output clientId string = myidentity.properties.clientId
+
+output principalId string = myidentity.properties.principalId
+
+output principalName string = myidentity.name
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_PublishAsExisting_Works.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_PublishAsExisting_Works.verified.bicep
new file mode 100644
index 00000000000..4a9e46c5446
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_PublishAsExisting_Works.verified.bicep
@@ -0,0 +1,14 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+resource myidentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = {
+ name: 'existingidentity'
+}
+
+output id string = myidentity.id
+
+output clientId string = myidentity.properties.clientId
+
+output principalId string = myidentity.properties.principalId
+
+output principalName string = myidentity.name
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_WithRoleAssignments_Works#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_WithRoleAssignments_Works#00.verified.bicep
new file mode 100644
index 00000000000..0440636c115
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_WithRoleAssignments_Works#00.verified.bicep
@@ -0,0 +1,15 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+resource myidentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
+ name: take('myidentity-${uniqueString(resourceGroup().id)}', 128)
+ location: location
+}
+
+output id string = myidentity.id
+
+output clientId string = myidentity.properties.clientId
+
+output principalId string = myidentity.properties.principalId
+
+output principalName string = myidentity.name
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_WithRoleAssignments_Works#01.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_WithRoleAssignments_Works#01.verified.bicep
new file mode 100644
index 00000000000..30678bba273
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_WithRoleAssignments_Works#01.verified.bicep
@@ -0,0 +1,17 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+resource myregistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
+ name: take('myregistry${uniqueString(resourceGroup().id)}', 50)
+ location: location
+ sku: {
+ name: 'Basic'
+ }
+ tags: {
+ 'aspire-resource-name': 'myregistry'
+ }
+}
+
+output name string = myregistry.name
+
+output loginServer string = myregistry.properties.loginServer
\ No newline at end of file
diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_WithRoleAssignments_Works#02.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_WithRoleAssignments_Works#02.verified.bicep
new file mode 100644
index 00000000000..c1beeaf2a96
--- /dev/null
+++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureUserAssignedIdentityTests.AddAzureUserAssignedIdentity_WithRoleAssignments_Works#02.verified.bicep
@@ -0,0 +1,20 @@
+@description('The location for the resource(s) to be deployed.')
+param location string = resourceGroup().location
+
+param myregistry_outputs_name string
+
+param principalId string
+
+resource myregistry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = {
+ name: myregistry_outputs_name
+}
+
+resource myregistry_AcrPush 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
+ name: guid(myregistry.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8311e382-0749-4cb8-b61a-304f252e45ec'))
+ properties: {
+ principalId: principalId
+ roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8311e382-0749-4cb8-b61a-304f252e45ec')
+ principalType: 'ServicePrincipal'
+ }
+ scope: myregistry
+}
\ No newline at end of file