Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -385,4 +385,40 @@ public static IResourceBuilder<AzureContainerAppEnvironmentResource> WithAzureLo

return builder;
}

/// <summary>
/// Specifies an operator principal that should receive administrative access to Azure resources in this environment.
/// </summary>
/// <param name="builder">The container app environment resource builder.</param>
/// <param name="principalId">The Azure AD object ID of the user or group that will act as an operator.</param>
/// <returns><see cref="IResourceBuilder{T}"/></returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="principalId"/> is null.</exception>
/// <remarks>
/// <para>
/// This method designates a principal (user or group) that should receive elevated permissions on Azure resources
/// created within this container app environment. When Azure resources are deployed, they will automatically
/// create role assignments granting the specified operator appropriate administrative access.
/// </para>
/// <example>
/// <code>
/// var builder = DistributedApplication.CreateBuilder(args);
/// var adminGroupObjectId = builder.AddParameter("adminGroupObjectId");
///
/// builder.AddAzureContainerAppEnvironment("env")
/// .WithOperator(adminGroupObjectId);
///
/// builder.AddAzureStorage("storage");
/// </code>
/// </example>
/// </remarks>
public static IResourceBuilder<AzureContainerAppEnvironmentResource> WithOperator(
this IResourceBuilder<AzureContainerAppEnvironmentResource> builder,
IResourceBuilder<ParameterResource> principalId)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(principalId);

builder.WithAnnotation(new OperatorPrincipalAnnotation(principalId.Resource));
return builder;
}
}
9 changes: 8 additions & 1 deletion src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,14 @@ public static IResourceBuilder<AzureStorageResource> AddAzureStorage(this IDistr
.WithDefaultRoleAssignments(StorageBuiltInRole.GetBuiltInRoleName,
StorageBuiltInRole.StorageBlobDataContributor,
StorageBuiltInRole.StorageTableDataContributor,
StorageBuiltInRole.StorageQueueDataContributor);
StorageBuiltInRole.StorageQueueDataContributor)
.WithAnnotation(new OperatorRoleCallbackAnnotation(new HashSet<RoleDefinition>
{
// Operators should have full access to manage storage accounts
new RoleDefinition(
StorageBuiltInRole.StorageAccountContributor.ToString()!,
StorageBuiltInRole.GetBuiltInRoleName(StorageBuiltInRole.StorageAccountContributor))
}));
}

/// <summary>
Expand Down
130 changes: 130 additions & 0 deletions src/Aspire.Hosting.Azure/AzureResourcePreparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap
}
}
}

// Process operator role assignments
ProcessOperatorRoleAssignments(appModel, azureResources);
}

if (globalRoleAssignments.Count > 0)
Expand Down Expand Up @@ -471,6 +474,133 @@ private static void ProcessAzureReferences(HashSet<IAzureResource> azureReferenc

throw new NotSupportedException("Unsupported value type " + value.GetType());
}

private void ProcessOperatorRoleAssignments(
DistributedApplicationModel appModel,
List<(IResource Resource, IAzureResource AzureResource)> azureResources)
{
// Find all compute environments with operator principals
var computeEnvironmentsWithOperators = appModel.Resources
.Where(r => r.TryGetAnnotationsOfType<OperatorPrincipalAnnotation>(out _))
.ToList();

if (computeEnvironmentsWithOperators.Count == 0)
{
return;
}

// For each Azure resource with operator role callbacks, create role assignments for each operator principal
foreach (var (_, azureResource) in azureResources)
{
if (azureResource is not AzureProvisioningResource provisioningResource)
{
continue;
}

if (!provisioningResource.TryGetAnnotationsOfType<OperatorRoleCallbackAnnotation>(out var operatorCallbacks))
{
continue;
}

// Collect all operator roles from the callbacks
var operatorRoles = operatorCallbacks
.SelectMany(c => c.Roles)
.ToHashSet();

if (operatorRoles.Count == 0)
{
continue;
}

// For each operator principal in the compute environments
foreach (var computeEnv in computeEnvironmentsWithOperators)
{
if (!computeEnv.TryGetAnnotationsOfType<OperatorPrincipalAnnotation>(out var operatorAnnotations))
{
continue;
}

foreach (var operatorAnnotation in operatorAnnotations)
{
// Create a role assignment resource for this operator on this Azure resource
var operatorPrincipalId = operatorAnnotation.PrincipalId;

var roleAssignmentResource = new AzureProvisioningResource(
$"{provisioningResource.Name}-operator-{GetOperatorName(operatorPrincipalId)}",
infra => AddOperatorRoleAssignmentsInfrastructure(infra, provisioningResource, operatorRoles, operatorPrincipalId))
{
ProvisioningBuildOptions = options.Value.ProvisioningBuildOptions,
};

// existing resource role assignments need to be scoped to the resource's resource group
if (provisioningResource.TryGetLastAnnotation<ExistingAzureResourceAnnotation>(out var existingAnnotation) &&
existingAnnotation.ResourceGroup is not null)
{
roleAssignmentResource.Scope = new(existingAnnotation.ResourceGroup);
}

appModel.Resources.Add(roleAssignmentResource);

// Add relationship annotation
roleAssignmentResource.Annotations.Add(new ResourceRelationshipAnnotation(provisioningResource, KnownRelationshipTypes.Parent));
}
}
}
}

private void AddOperatorRoleAssignmentsInfrastructure(
AzureResourceInfrastructure infra,
AzureProvisioningResource azureResource,
IEnumerable<RoleDefinition> operatorRoles,
IManifestExpressionProvider operatorPrincipalId)
{
var principalIdParam = operatorPrincipalId.AsProvisioningParameter(infra, parameterName: "operatorPrincipalId");

var context = new OperatorAddRoleAssignmentsContext(
infra,
executionContext,
operatorRoles,
new(() => RoleManagementPrincipalType.User), // Operators can be users or groups
new(() => principalIdParam),
new(() => principalIdParam));

// Add role assignments for the operator
azureResource.AddRoleAssignments(context);
}

private static string GetOperatorName(IManifestExpressionProvider principalId)
{
// If it's a parameter resource, use the parameter name
if (principalId is ParameterResource param)
{
return Infrastructure.NormalizeBicepIdentifier(param.Name);
}

// Otherwise, use a generic name
return "principal";
}

private sealed class OperatorAddRoleAssignmentsContext(
AzureResourceInfrastructure infrastructure,
DistributedApplicationExecutionContext executionContext,
IEnumerable<RoleDefinition> roles,
Lazy<BicepValue<RoleManagementPrincipalType>> getPrincipalType,
Lazy<BicepValue<Guid>> getPrincipalId,
Lazy<BicepValue<string>> getPrincipalName) : IAddRoleAssignmentsContext
{
public AzureResourceInfrastructure Infrastructure { get; } = infrastructure;

public IEnumerable<RoleDefinition> Roles => roles;

public BicepValue<RoleManagementPrincipalType> PrincipalType => getPrincipalType.Value;

public BicepValue<Guid> PrincipalId => getPrincipalId.Value;

public BicepValue<string> PrincipalName => getPrincipalName.Value;

public DistributedApplicationExecutionContext ExecutionContext => executionContext;
}

private static void AppendGlobalRoleAssignments(Dictionary<AzureProvisioningResource, HashSet<RoleDefinition>> globalRoleAssignments, AzureProvisioningResource azureResource, IEnumerable<RoleDefinition> newRoles)
{
if (!globalRoleAssignments.TryGetValue(azureResource, out var existingRoles))
Expand Down
23 changes: 23 additions & 0 deletions src/Aspire.Hosting.Azure/OperatorPrincipalAnnotation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 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;

/// <summary>
/// Specifies an operator principal (user or group) that should receive administrative access to Azure resources
/// deployed from this compute environment.
/// </summary>
/// <param name="principalId">The Azure AD object ID of the user or group that will act as an operator.</param>
/// <remarks>
/// This annotation is applied to compute environment resources (e.g., Azure Container App Environments)
/// to designate principals that should receive elevated permissions on Azure resources created within that environment.
/// </remarks>
public class OperatorPrincipalAnnotation(IManifestExpressionProvider principalId) : IResourceAnnotation
{
/// <summary>
/// Gets the Azure AD object ID of the operator principal.
/// </summary>
public IManifestExpressionProvider PrincipalId { get; } = principalId;
}
23 changes: 23 additions & 0 deletions src/Aspire.Hosting.Azure/OperatorRoleCallbackAnnotation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 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;

/// <summary>
/// Specifies the operator roles that should be assigned to operator principals for an Azure resource.
/// </summary>
/// <param name="roles">The set of roles that should be assigned to operator principals.</param>
/// <remarks>
/// This annotation is applied to Azure resources to define which roles should be assigned to operator principals
/// designated on compute environments. When an operator is specified on a compute environment (e.g., via WithOperator),
/// these roles will be automatically assigned to that operator for this resource.
/// </remarks>
public class OperatorRoleCallbackAnnotation(IReadOnlySet<RoleDefinition> roles) : IResourceAnnotation
{
/// <summary>
/// Gets the set of roles that should be assigned to operator principals.
/// </summary>
public IReadOnlySet<RoleDefinition> Roles { get; } = roles;
}
Loading