Skip to content

Commit 74a965b

Browse files
authored
Add support for creating and using user-assigned identities (#9130)
* Add support for creating and using user-assigned identities * Don't add AppIdentityAnnotation on identity resources
1 parent 7f4bea1 commit 74a965b

10 files changed

+319
-43
lines changed

src/Aspire.Hosting.Azure/AppIdentityResource.cs

Lines changed: 0 additions & 31 deletions
This file was deleted.

src/Aspire.Hosting.Azure/AzureResourcePreparer.cs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap
130130
continue;
131131
}
132132

133-
if (!resource.IsContainer() && resource is not ProjectResource)
133+
if (!IsResourceValidForRoleAssignments(resource))
134134
{
135135
continue;
136136
}
@@ -179,10 +179,13 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap
179179
{
180180
var (identityResource, roleAssignmentResources) = CreateIdentityAndRoleAssignmentResources(options, resource, roleAssignments);
181181

182-
// attach the identity resource to compute resource so it can be used by the compute environment
183-
resource.Annotations.Add(new AppIdentityAnnotation(identityResource));
184-
185-
appModel.Resources.Add(identityResource);
182+
if (resource != identityResource)
183+
{
184+
// attach the identity resource to compute resource so it can be used by the compute environment
185+
resource.Annotations.Add(new AppIdentityAnnotation(identityResource));
186+
// add the identity resource to the resource collection so it can be provisioned
187+
appModel.Resources.Add(identityResource);
188+
}
186189
foreach (var roleAssignmentResource in roleAssignmentResources)
187190
{
188191
appModel.Resources.Add(roleAssignmentResource);
@@ -209,6 +212,13 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap
209212
{
210213
await CreateGlobalRoleAssignments(appModel, globalRoleAssignments, options).ConfigureAwait(false);
211214
}
215+
216+
// We can derive role assignments for compute resources and declared
217+
// AzureUserAssignedIdentityResources
218+
static bool IsResourceValidForRoleAssignments(IResource resource)
219+
{
220+
return resource.IsContainer() || resource is ProjectResource || resource is AzureUserAssignedIdentityResource;
221+
}
212222
}
213223

214224
private static Dictionary<AzureProvisioningResource, IEnumerable<RoleDefinition>> GetAllRoleAssignments(IResource resource)
@@ -224,15 +234,17 @@ private static Dictionary<AzureProvisioningResource, IEnumerable<RoleDefinition>
224234
return result;
225235
}
226236

227-
private static (AppIdentityResource IdentityResource, List<AzureBicepResource> RoleAssignmentResources) CreateIdentityAndRoleAssignmentResources(
237+
private static (AzureUserAssignedIdentityResource IdentityResource, List<AzureBicepResource> RoleAssignmentResources) CreateIdentityAndRoleAssignmentResources(
228238
AzureProvisioningOptions provisioningOptions,
229239
IResource resource,
230240
Dictionary<AzureProvisioningResource, IEnumerable<RoleDefinition>> roleAssignments)
231241
{
232-
var identityResource = new AppIdentityResource($"{resource.Name}-identity")
233-
{
234-
ProvisioningBuildOptions = provisioningOptions.ProvisioningBuildOptions
235-
};
242+
var identityResource = resource is AzureUserAssignedIdentityResource existingIdentityResource
243+
? existingIdentityResource
244+
: new AzureUserAssignedIdentityResource($"{resource.Name}-identity")
245+
{
246+
ProvisioningBuildOptions = provisioningOptions.ProvisioningBuildOptions
247+
};
236248

237249
var roleAssignmentResources = CreateRoleAssignmentsResources(provisioningOptions, resource, roleAssignments, identityResource);
238250
return (identityResource, roleAssignmentResources);
@@ -242,7 +254,7 @@ private static List<AzureBicepResource> CreateRoleAssignmentsResources(
242254
AzureProvisioningOptions provisioningOptions,
243255
IResource resource,
244256
Dictionary<AzureProvisioningResource, IEnumerable<RoleDefinition>> roleAssignments,
245-
AppIdentityResource appIdentityResource)
257+
AzureUserAssignedIdentityResource appIdentityResource)
246258
{
247259
var roleAssignmentResources = new List<AzureBicepResource>();
248260
foreach (var (targetResource, roles) in roleAssignments)
@@ -271,7 +283,7 @@ private static void AddRoleAssignmentsInfrastructure(
271283
AzureResourceInfrastructure infra,
272284
AzureProvisioningResource azureResource,
273285
IEnumerable<RoleDefinition> roles,
274-
AppIdentityResource appIdentityResource)
286+
AzureUserAssignedIdentityResource appIdentityResource)
275287
{
276288
var context = new AddRoleAssignmentsContext(
277289
infra,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
6+
namespace Aspire.Hosting.Azure;
7+
8+
/// <summary>
9+
/// Provides extension methods for working with Azure user‑assigned identities.
10+
/// </summary>
11+
public static class AzureUserAssignedIdentityExtensions
12+
{
13+
/// <summary>
14+
/// Adds an Azure user‑assigned identity resource to the application model.
15+
/// </summary>
16+
/// <param name="builder">The builder for the distributed application.</param>
17+
/// <param name="name">The name of the resource.</param>
18+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
19+
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is null or empty.</exception>
20+
/// <remarks>
21+
/// This method adds an Azure user‑assigned identity resource to the application model. It configures the
22+
/// infrastructure for the resource and returns a builder for the resource.
23+
/// The resource is added to the infrastructure only if the application is not in run mode.
24+
/// </remarks>
25+
/// <returns>A reference to the <see cref="IResourceBuilder{AzureUserAssignedIdentityResource}"/> builder.</returns>
26+
public static IResourceBuilder<AzureUserAssignedIdentityResource> AddAzureUserAssignedIdentity(
27+
this IDistributedApplicationBuilder builder,
28+
string name)
29+
{
30+
ArgumentNullException.ThrowIfNull(builder);
31+
ArgumentException.ThrowIfNullOrEmpty(name);
32+
33+
builder.AddAzureProvisioning();
34+
35+
var resource = new AzureUserAssignedIdentityResource(name);
36+
// Don't add the resource to the infrastructure if we're in run mode.
37+
if (builder.ExecutionContext.IsRunMode)
38+
{
39+
return builder.CreateResourceBuilder(resource);
40+
}
41+
42+
return builder.AddResource(resource);
43+
}
44+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Azure.Provisioning;
5+
using Azure.Provisioning.Primitives;
6+
using Azure.Provisioning.Roles;
7+
8+
namespace Aspire.Hosting.Azure;
9+
10+
/// <summary>
11+
/// An Azure Provisioning resource that represents an Azure user assigned managed identity.
12+
/// </summary>
13+
public sealed class AzureUserAssignedIdentityResource(string name)
14+
: AzureProvisioningResource(name, ConfigureAppIdentityInfrastructure), IAppIdentityResource
15+
{
16+
/// <summary>
17+
/// The identifier associated with the user assigned identity.
18+
/// </summary>
19+
public BicepOutputReference Id => new("id", this);
20+
21+
/// <summary>
22+
/// The client ID of the user assigned identity.
23+
/// </summary>
24+
public BicepOutputReference ClientId => new("clientId", this);
25+
26+
/// <summary>
27+
/// The principal ID of the user assigned identity.
28+
/// </summary>
29+
public BicepOutputReference PrincipalId => new("principalId", this);
30+
31+
/// <summary>
32+
/// The principal name of the user assigned identity.
33+
/// </summary>
34+
public BicepOutputReference PrincipalName => new("principalName", this);
35+
36+
private static void ConfigureAppIdentityInfrastructure(AzureResourceInfrastructure infrastructure)
37+
{
38+
var userAssignedIdentity = CreateExistingOrNewProvisionableResource(infrastructure,
39+
(identifier, name) =>
40+
{
41+
var resource = UserAssignedIdentity.FromExisting(identifier);
42+
resource.Name = name;
43+
return resource;
44+
},
45+
(infrastructure) =>
46+
{
47+
var identityName = Infrastructure.NormalizeBicepIdentifier(infrastructure.AspireResource.Name);
48+
var resource = new UserAssignedIdentity(identityName);
49+
return resource;
50+
});
51+
52+
infrastructure.Add(userAssignedIdentity);
53+
54+
infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = userAssignedIdentity.Id });
55+
infrastructure.Add(new ProvisioningOutput("clientId", typeof(string)) { Value = userAssignedIdentity.ClientId });
56+
infrastructure.Add(new ProvisioningOutput("principalId", typeof(string)) { Value = userAssignedIdentity.PrincipalId });
57+
infrastructure.Add(new ProvisioningOutput("principalName", typeof(string)) { Value = userAssignedIdentity.Name });
58+
}
59+
60+
/// <inheritdoc/>
61+
public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra)
62+
{
63+
var store = UserAssignedIdentity.FromExisting(this.GetBicepIdentifier());
64+
store.Name = PrincipalName.AsProvisioningParameter(infra);
65+
infra.Add(store);
66+
return store;
67+
}
68+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
using Aspire.Hosting.Azure.AppContainers;
6+
using Aspire.Hosting.Azure.ContainerRegistry;
7+
using Aspire.Hosting.Utils;
8+
using Azure.Provisioning.ContainerRegistry;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using static Aspire.Hosting.Utils.AzureManifestUtils;
11+
12+
namespace Aspire.Hosting.Azure.Tests;
13+
14+
public class AzureUserAssignedIdentityTests
15+
{
16+
[Fact]
17+
public async Task AddAzureUserAssignedIdentity_GeneratesExpectedResourcesAndBicep()
18+
{
19+
// Arrange
20+
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
21+
22+
builder.AddAzureUserAssignedIdentity("myidentity");
23+
24+
using var app = builder.Build();
25+
await ExecuteBeforeStartHooksAsync(app, default);
26+
27+
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
28+
29+
// Act
30+
var resource = Assert.Single(model.Resources.OfType<AzureUserAssignedIdentityResource>());
31+
32+
var (_, bicep) = await GetManifestWithBicep(resource);
33+
34+
await Verifier.Verify(bicep, extension: "bicep")
35+
.UseHelixAwareDirectory("Snapshots")
36+
.AutoVerify();
37+
}
38+
39+
[Fact]
40+
public async Task AddAzureUserAssignedIdentity_PublishAsExisting_Works()
41+
{
42+
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
43+
44+
builder.AddAzureUserAssignedIdentity("myidentity")
45+
.PublishAsExisting("existingidentity", "my-rg");
46+
47+
using var app = builder.Build();
48+
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
49+
50+
var resource = Assert.Single(model.Resources.OfType<AzureUserAssignedIdentityResource>());
51+
52+
var (_, bicep) = await GetManifestWithBicep(resource);
53+
54+
await Verifier.Verify(bicep, extension: "bicep")
55+
.UseHelixAwareDirectory("Snapshots")
56+
.AutoVerify();
57+
}
58+
59+
[Fact]
60+
public async Task AddAzureUserAssignedIdentity_WithRoleAssignments_Works()
61+
{
62+
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
63+
64+
builder.AddAzureContainerAppEnvironment("cae");
65+
66+
var registry = builder.AddAzureContainerRegistry("myregistry");
67+
builder.AddAzureUserAssignedIdentity("myidentity")
68+
.WithRoleAssignments(registry, [ContainerRegistryBuiltInRole.AcrPush]);
69+
70+
using var app = builder.Build();
71+
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
72+
await ExecuteBeforeStartHooksAsync(app, default);
73+
74+
Assert.Collection(model.Resources.OrderBy(r => r.Name),
75+
r => Assert.IsType<AzureContainerAppEnvironmentResource>(r),
76+
r => Assert.IsType<AzureUserAssignedIdentityResource>(r),
77+
r =>
78+
{
79+
Assert.IsType<AzureProvisioningResource>(r);
80+
Assert.Equal("myidentity-roles-myregistry", r.Name);
81+
},
82+
r => Assert.IsType<AzureContainerRegistryResource>(r));
83+
84+
var identityResource = Assert.Single(model.Resources.OfType<AzureUserAssignedIdentityResource>());
85+
var (_, identityBicep) = await GetManifestWithBicep(identityResource, skipPreparer: true);
86+
87+
var registryResource = Assert.Single(model.Resources.OfType<AzureContainerRegistryResource>());
88+
var (_, registryBicep) = await GetManifestWithBicep(registryResource, skipPreparer: true);
89+
90+
var identityRoleAssignments = Assert.Single(model.Resources.OfType<AzureProvisioningResource>(), r => r.Name == "myidentity-roles-myregistry");
91+
var (_, identityRoleAssignmentsBicep) = await GetManifestWithBicep(identityRoleAssignments, skipPreparer: true);
92+
93+
Target[] targets = [
94+
new Target("bicep", identityBicep),
95+
new Target("bicep", registryBicep),
96+
new Target("bicep", identityRoleAssignmentsBicep)
97+
];
98+
await Verifier.Verify(targets)
99+
.UseHelixAwareDirectory("Snapshots")
100+
.AutoVerify();
101+
}
102+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@description('The location for the resource(s) to be deployed.')
2+
param location string = resourceGroup().location
3+
4+
resource myidentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
5+
name: take('myidentity-${uniqueString(resourceGroup().id)}', 128)
6+
location: location
7+
}
8+
9+
output id string = myidentity.id
10+
11+
output clientId string = myidentity.properties.clientId
12+
13+
output principalId string = myidentity.properties.principalId
14+
15+
output principalName string = myidentity.name
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
@description('The location for the resource(s) to be deployed.')
2+
param location string = resourceGroup().location
3+
4+
resource myidentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = {
5+
name: 'existingidentity'
6+
}
7+
8+
output id string = myidentity.id
9+
10+
output clientId string = myidentity.properties.clientId
11+
12+
output principalId string = myidentity.properties.principalId
13+
14+
output principalName string = myidentity.name
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@description('The location for the resource(s) to be deployed.')
2+
param location string = resourceGroup().location
3+
4+
resource myidentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
5+
name: take('myidentity-${uniqueString(resourceGroup().id)}', 128)
6+
location: location
7+
}
8+
9+
output id string = myidentity.id
10+
11+
output clientId string = myidentity.properties.clientId
12+
13+
output principalId string = myidentity.properties.principalId
14+
15+
output principalName string = myidentity.name
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
@description('The location for the resource(s) to be deployed.')
2+
param location string = resourceGroup().location
3+
4+
resource myregistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
5+
name: take('myregistry${uniqueString(resourceGroup().id)}', 50)
6+
location: location
7+
sku: {
8+
name: 'Basic'
9+
}
10+
tags: {
11+
'aspire-resource-name': 'myregistry'
12+
}
13+
}
14+
15+
output name string = myregistry.name
16+
17+
output loginServer string = myregistry.properties.loginServer

0 commit comments

Comments
 (0)