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