File: Provisioning\EntraIdApiProvisioning.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.EntraId\Aspire.Hosting.Azure.EntraId.csproj (Aspire.Hosting.Azure.EntraId)
// 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 Azure.Provisioning;
using Azure.Provisioning.Expressions;
 
namespace Aspire.Hosting.Azure.EntraId.Provisioning;
 
/// <summary>
/// Writes the Microsoft Graph resources that expose an application as an API and that give it access to other APIs.
/// </summary>
internal static class EntraIdApiProvisioning
{
    private const string DelegatedAccessType = "Scope";
    private const string ApplicationAccessType = "Role";
 
    /// <summary>
    /// Declares the permissions that the application requires from other APIs (<c>requiredResourceAccess</c>).
    /// </summary>
    /// <remarks>
    /// Declaring a permission only lists it under the application's API permissions. It grants nothing, and nobody is
    /// asked to consent to it until a grant (<see cref="ConfigureGrants"/>) or an interactive consent happens.
    /// </remarks>
    public static void ConfigureRequiredResourceAccess(
        EntraIdApplicationResource resource,
        AzureResourceInfrastructure infrastructure,
        EntraIdSymbolNames symbolNames,
        MicrosoftGraphApplication application)
    {
        foreach (var access in resource.ApiAccess.Where(a => a.Declared.Count > 0))
        {
            var required = new MicrosoftGraphRequiredResourceAccess
            {
                ResourceAppId = GetApiApplicationId(access, infrastructure, symbolNames)
            };
 
            foreach (var permission in access.Declared)
            {
                required.ResourceAccess.Add(new MicrosoftGraphResourceAccess
                {
                    Id = permission.Id.ToString("D"),
                    Type = permission.Type == EntraIdPermissionType.Delegated ? DelegatedAccessType : ApplicationAccessType
                });
            }
 
            application.RequiredResourceAccess.Add(required);
        }
    }
 
    /// <summary>
    /// Adds the application ID URI, the exposed scopes and the pre-authorized clients, and returns the application ID URI output value.
    /// </summary>
    public static BicepValue<string>? ConfigureExposedApi(
        EntraIdApplicationResource resource,
        AzureResourceInfrastructure infrastructure,
        EntraIdSymbolNames symbolNames,
        MicrosoftGraphApplication application,
        BicepValue<string> registrationName)
    {
        if (!resource.HasApplicationIdUri)
        {
            return null;
        }
 
        MicrosoftGraphApplication target = application;
        BicepValue<string> identifierUri;
        if (resource.ApplicationIdUri is { } customUri)
        {
            identifierUri = customUri;
        }
        else
        {
            // The default URI contains the client ID, which exists only after the application is created, and Graph
            // cannot read a property of the resource that is being created. Declaring the application a second time under the same
            // unique name updates the created application in place (PATCH semantics) with a value that refers to the first
            // declaration's appId, so no tenant policy needs to allow a name-based URI.
            target = new MicrosoftGraphApplication(symbolNames.ApplicationIdentity(resource))
            {
                DisplayName = registrationName,
                UniqueName = registrationName
            };
            identifierUri = BicepFunction.Interpolate($"api://{application.AppId}").Compile();
        }
 
        target.IdentifierUris = [identifierUri];
 
        foreach (var scope in resource.Scopes)
        {
            var description = scope.Description;
            target.PermissionScopes.Add(new MicrosoftGraphPermissionScope
            {
                Id = resource.GetScopeId(scope).ToString("D"),
                Value = scope.Name,
                Type = scope.RequiresAdminConsent ? "Admin" : "User",
                IsEnabled = scope.IsEnabled,
                AdminConsentDisplayName = scope.DisplayName,
                AdminConsentDescription = description,
                UserConsentDisplayName = scope.UserConsentDisplayName ?? scope.DisplayName,
                UserConsentDescription = scope.UserConsentDescription ?? description
            });
        }
 
        foreach (var preAuthorized in resource.PreAuthorizedClients)
        {
            var preAuthorization = new MicrosoftGraphPreAuthorizedApplication
            {
                AppId = GetClientId(preAuthorized.Client, infrastructure, symbolNames)
            };
 
            foreach (var scope in preAuthorized.Scopes)
            {
                preAuthorization.DelegatedPermissionIds.Add(resource.GetScopeId(scope).ToString("D"));
            }
 
            target.PreAuthorizedApplications.Add(preAuthorization);
        }
 
        // Graph updates an application in place and keeps every property that a request omits, as the Bicep extension does for any
        // template. Once the API is declared, its scopes and pre-authorized clients are the full desired state: sending them even when
        // empty is what lets a deployment retire the last scope or remove the last pre-authorized client. Without any API configuration
        // nothing is sent, so a registration that was never exposed as an API is left as it is.
        if (target.PermissionScopes.Count == 0)
        {
            target.PermissionScopes = [];
        }
 
        if (target.PreAuthorizedApplications.Count == 0)
        {
            target.PreAuthorizedApplications = [];
        }
 
        if (!ReferenceEquals(target, application))
        {
            infrastructure.Add(target);
        }
 
        return identifierUri;
    }
 
    /// <summary>
    /// Writes the consents that the application's creator asked for: tenant-wide delegated grants and application permission assignments.
    /// </summary>
    public static void ConfigureGrants(
        EntraIdApplicationResource resource,
        AzureResourceInfrastructure infrastructure,
        EntraIdSymbolNames symbolNames,
        MicrosoftGraphServicePrincipal servicePrincipal)
    {
        foreach (var access in resource.ApiAccess.Where(a => a.Consented.Count > 0))
        {
            var apiServicePrincipalId = GetApiServicePrincipalId(access, infrastructure, symbolNames);
 
            var delegated = access.Consented.Where(p => p.Type == EntraIdPermissionType.Delegated).ToArray();
            if (delegated.Length > 0)
            {
                // Graph stores the granted scopes as one space-separated string of scope names.
                infrastructure.Add(new MicrosoftGraphOAuth2PermissionGrant(symbolNames.DelegatedGrant(access))
                {
                    ClientId = servicePrincipal.Id,
                    ConsentType = "AllPrincipals",
                    ResourceId = apiServicePrincipalId,
                    Scope = string.Join(' ', delegated.Select(p => p.Name))
                });
            }
 
            foreach (var role in access.Consented.Where(p => p.Type == EntraIdPermissionType.Application))
            {
                infrastructure.Add(new MicrosoftGraphAppRoleAssignedTo(symbolNames.RoleAssignment(access, role))
                {
                    AppRoleId = role.Id.ToString("D"),
                    PrincipalId = servicePrincipal.Id,
                    ResourceId = apiServicePrincipalId
                });
            }
        }
    }
 
    private static BicepValue<string> GetApiApplicationId(EntraIdApiAccess access, AzureResourceInfrastructure infrastructure, EntraIdSymbolNames symbolNames) => access switch
    {
        { Api: { } api } => api.ApplicationId,
        { Application: { } application } => GetClientId(application, infrastructure, symbolNames),
        _ => throw new InvalidOperationException("The API is not configured.")
    };
 
    private static BicepValue<string> GetApiServicePrincipalId(EntraIdApiAccess access, AzureResourceInfrastructure infrastructure, EntraIdSymbolNames symbolNames)
    {
        // The service principal of an API created by Aspire is a deployment output, which is available as soon as the API is deployed.
        if (access.Application is { IsExistingApplication: false } created)
        {
            return created.ServicePrincipalIdOutputReference.AsProvisioningParameter(infrastructure, symbolNames.ServicePrincipalId(access));
        }
 
        // First-party APIs and registrations that already exist have a service principal in the tenant that is found by the application ID.
        var existing = MicrosoftGraphServicePrincipal.FromExisting(symbolNames.ExistingServicePrincipal(access));
        existing.Name = GetApiApplicationId(access, infrastructure, symbolNames);
        infrastructure.Add(existing);
        return existing.Id;
    }
 
    // A name is only allocated when the ID needs a parameter, so a literal ID leaves the template's names untouched.
    private static BicepValue<string> GetClientId(EntraIdApplicationResource application, AzureResourceInfrastructure infrastructure, EntraIdSymbolNames symbolNames) => application.ClientIdValue switch
    {
        BicepOutputReference output => output.AsProvisioningParameter(infrastructure, symbolNames.ClientId(application)),
        ParameterResource parameter => parameter.AsProvisioningParameter(infrastructure, symbolNames.ClientId(application)),
        string clientId => clientId,
        _ => throw new InvalidOperationException($"The client ID of '{application.Name}' is not configured. Call AddExistingEntraApplication with the registration's tenant ID and client ID.")
    };
}