File: Provisioning\EntraIdSymbolNames.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 System.Text.RegularExpressions;
using Azure.Provisioning;
using Azure.Provisioning.Primitives;
 
namespace Aspire.Hosting.Azure.EntraId.Provisioning;
 
/// <summary>
/// Allocates the Bicep symbol names (parameters and resources) that the API access settings add to a registration's template.
/// </summary>
/// <remarks>
/// <para>
/// Names come from the identity of the API, never from text alone: a registration in the AppHost by its resource name, which Aspire
/// requires to be unique, and another API by its application ID. Display text can't identify an API, because <c>orders-api</c> and
/// <c>ordersApi</c>, or two custom APIs that share a display name, would otherwise produce the same symbol, and a parameter that
/// is bound twice silently keeps only the last binding.
/// </para>
/// <para>
/// A name that is already taken, by this allocator or by anything else in the template, gets a numeric suffix. Names are allocated
/// in model order, so a given model produces the same names every time. The same identity and purpose always return the same name.
/// </para>
/// </remarks>
internal sealed partial class EntraIdSymbolNames(AzureResourceInfrastructure infrastructure)
{
    // The symbols that the registration's own template always declares, which are added after the API access settings are written.
    private static readonly HashSet<string> s_fixedNames = new(StringComparer.OrdinalIgnoreCase)
    {
        "application", "servicePrincipal", "tenantResourceSuffix"
    };
 
    private readonly HashSet<string> _allocated = new(StringComparer.OrdinalIgnoreCase);
    private readonly Dictionary<(object Identity, string Purpose), string> _names = [];
 
    /// <summary>Gets the symbol of the second declaration of a registration, which sets the default application ID URI.</summary>
    public string ApplicationIdentity(EntraIdApplicationResource application) => Get(application, "ApplicationIdentity", "applicationIdentity");
 
    /// <summary>Gets the parameter that carries the application (client) ID of a registration in the AppHost.</summary>
    public string ClientId(EntraIdApplicationResource application) => Get(application, "ClientId", $"{Base(application)}ClientId");
 
    /// <summary>Gets the parameter that carries the service principal's object ID of an API registration created by Aspire.</summary>
    public string ServicePrincipalId(EntraIdApiAccess access) => Get(access, "ServicePrincipalId", $"{Base(access)}ServicePrincipalId");
 
    /// <summary>Gets the symbol of the existing service principal that an API is looked up by.</summary>
    public string ExistingServicePrincipal(EntraIdApiAccess access) => Get(access, "ServicePrincipal", $"{Base(access)}ServicePrincipal");
 
    /// <summary>Gets the symbol of the delegated permission grant to an API.</summary>
    public string DelegatedGrant(EntraIdApiAccess access) => Get(access, "DelegatedGrant", $"{Base(access)}DelegatedGrant");
 
    /// <summary>Gets the symbol of the assignment of one application permission of an API.</summary>
    public string RoleAssignment(EntraIdApiAccess access, EntraIdResolvedPermission role) =>
        Get(access, $"RoleAssignment:{role.Id:N}", $"{Base(access)}{ToPascalCase(role.Name)}RoleAssignment");
 
    // A registration in the AppHost has a unique resource name; Aspire's normalization maps '-' to '_' and resource names can't contain
    // '_', so two names never normalize to the same text. Another API is named after its display text, and made unique by allocation.
    private static string Base(EntraIdApplicationResource application) => StartWithLetter(Infrastructure.NormalizeBicepIdentifier(application.Name));
 
    private static string Base(EntraIdApiAccess access) => access.Application is { } application
        ? Base(application)
        : StartWithLetter(ToCamelCase(access.Api!.DisplayName));
 
    // A Bicep identifier can't start with a digit, and an API whose display text has no letters or digits would have no name at all.
    private static string StartWithLetter(string name) => name.Length > 0 && char.IsAsciiLetter(name[0]) ? name : $"api{name}";
 
    private string Get(object owner, string purpose, string name)
    {
        // Names are cached by the API's identity rather than by the access entry, so the same API is always given the same parameter.
        var identity = owner is EntraIdApiAccess access ? GetIdentity(access) : owner;
        if (_names.TryGetValue((identity, purpose), out var existing))
        {
            return existing;
        }
 
        var candidate = name;
        for (var suffix = 2; IsTaken(candidate); suffix++)
        {
            candidate = $"{name}_{suffix}";
        }
 
        _allocated.Add(candidate);
        _names[(identity, purpose)] = candidate;
        return candidate;
    }
 
    private static object GetIdentity(EntraIdApiAccess access) =>
        access.Application is { } application ? application : access.Api!.ApplicationId.ToLowerInvariant();
 
    // Bicep treats parameters and resources as one namespace, and the deployment engine isn't case sensitive, so one
    // case-insensitive check covers everything that is already in the template and the names that other code adds afterwards.
    private bool IsTaken(string name) =>
        _allocated.Contains(name) ||
        s_fixedNames.Contains(name) ||
        FixedParameterPattern().IsMatch(name) ||
        infrastructure.GetProvisionableResources().Any(r =>
            (r is ProvisioningParameter p && string.Equals(p.BicepIdentifier, name, StringComparison.OrdinalIgnoreCase)) ||
            (r is ProvisionableResource pr && string.Equals(pr.BicepIdentifier, name, StringComparison.OrdinalIgnoreCase)));
 
    // Builds a camelCase name from text such as "Microsoft Graph" or "User.Read.All".
    private static string ToCamelCase(string text)
    {
        var words = NonAlphanumeric().Split(text).Where(static word => word.Length > 0);
        return string.Concat(words.Select(static (word, index) =>
            (index == 0 ? char.ToLowerInvariant(word[0]) : char.ToUpperInvariant(word[0])) + word[1..]));
    }
 
    // The part of a name that follows another, such as the role in "{api}{Role}RoleAssignment".
    private static string ToPascalCase(string text)
    {
        var name = ToCamelCase(text);
        return name.Length > 0 ? char.ToUpperInvariant(name[0]) + name[1..] : name;
    }
 
    // Owner and redirect URI parameters are named by their position and added to the template without a check.
    [GeneratedRegex("^(applicationOwner|servicePrincipalOwner|redirectUri)[0-9]+$", RegexOptions.IgnoreCase)]
    private static partial Regex FixedParameterPattern();
 
    [GeneratedRegex("[^A-Za-z0-9]+")]
    private static partial Regex NonAlphanumeric();
}