Private/ConvertTo-AACAzureLimit.ps1

function ConvertTo-AACAzureLimit {
    <#
    .SYNOPSIS
        Builds Test-AACAzureLimit's list (AAC.AzureLimit) from the Resource
        Graph totals of Get-AACAzureLimitQuery: each scope's use of each
        Azure limit, how close it is, and what to do.
    .DESCRIPTION
        No Azure calls. -Read is Invoke-AACGraphBatch's result; -Scope is
        Resolve-AACScope's (the subscriptions in scope, their names and
        management group chains; an empty GraphScope means every
        subscription the account can see). Management groups count when one of the subscriptions in
        scope sits under them (or, unscoped, all of them).
 
        The limits (Microsoft Learn, "Azure subscription and service
        limits"), each overridable with -Maximum (name -> number):
          RoleAssignmentsPerSubscription 4,000 at or below a subscription
          RoleAssignmentsPerManagementGroup 500 at a management group
          CustomRolesPerTenant 5,000
          PolicyAssignmentsPerScope 200 management group, subscription, resource group
          PolicyDefinitionsPerScope 500 custom, management group or subscription
          PolicySetDefinitionsPerScope 200 custom, management group or subscription
          PolicySetDefinitionsPerTenant 2,500
          PolicyExemptionsPerScope 1,000
          ResourceGroupsPerSubscription 980
          TagsPerSubscription 50
          TagsPerResourceGroup 50 the resource group with the most, per subscription
          TagsPerResource 50 the resource with the most, per subscription
          ManagementGroupDepth 6 levels below the root
          ManagementGroupsPerTenant 10,000
 
        Status: Critical at -CriticalPercent (95) or more of the maximum,
        Warning at -WarningPercent (80), else OK. Nothing in use is left
        out; resource groups only appear when they reach the warning level.
        Returns @{ Limits (worst first); Notices; Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Read,

        [Parameter(Mandatory)]
        [hashtable] $Scope,

        [hashtable] $Maximum = @{},

        [ValidateRange(1, 100)]
        [int] $WarningPercent = 80,

        [ValidateRange(1, 100)]
        [int] $CriticalPercent = 95
    )

    $get = ${function:Get-AACMember}
    $text = { param($Object, [string] $Path) $v = & $get $Object $Path; if ($null -eq $v) { '' } else { [string]$v } }
    $number = { param($Object, [string] $Path) $v = & $get $Object $Path; if ($null -eq $v -or '' -eq $v) { 0 } else { [int64]$v } }
    $rowsOf = { param([string] $Name) @(if ($Read.Rows -and $Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) }
    $notices = [System.Collections.Generic.List[string]]::new()
    $what = @{ rbacBySubscription = 'role assignments'; rbacByGroup = 'management group role assignments'; customRoles = 'custom roles'; policyAssignments = 'policy assignments'; policyDefinitions = 'policy definitions'; policySets = 'initiative definitions'; policyExemptions = 'policy exemptions'; managementGroups = 'management groups'; resourceGroups = 'resource groups'; subscriptionTags = 'subscription tags'; groupTags = 'resource group tags'; resourceTags = 'resource tags' }
    foreach ($key in @($Read.Errors.Keys | Sort-Object)) { $notices.Add("The $(if ($what.Contains($key)) { $what[$key] } else { $key }) couldn't be counted: $($Read.Errors[$key] -replace '\s+', ' ')") }

    $limits = [ordered]@{
        RoleAssignmentsPerSubscription    = @{ Max = 4000; Label = 'Role assignments'; Advice = 'Assign roles to groups, not to each user; remove orphaned and duplicate assignments (Get-AACRoleAssignment lists them).' }
        RoleAssignmentsPerManagementGroup = @{ Max = 500; Label = 'Role assignments'; Advice = 'Assign roles to groups; move assignments that only one subscription needs down to it.' }
        CustomRolesPerTenant              = @{ Max = 5000; Label = 'Custom roles'; Advice = 'Delete unused custom roles and merge near-duplicates (Get-AACRoleAssignment lists the unused ones).' }
        PolicyAssignmentsPerScope         = @{ Max = 200; Label = 'Policy assignments'; Advice = 'Group policies into initiatives and assign the initiative once; assign at a management group rather than each subscription.' }
        PolicyDefinitionsPerScope         = @{ Max = 500; Label = 'Custom policy definitions'; Advice = 'Delete unused definitions; parameterise near-duplicates into one; keep definitions at a parent management group.' }
        PolicySetDefinitionsPerScope      = @{ Max = 200; Label = 'Custom initiatives'; Advice = 'Delete unused initiatives and merge overlapping ones.' }
        PolicySetDefinitionsPerTenant     = @{ Max = 2500; Label = 'Custom initiatives'; Advice = 'Delete unused initiatives and merge overlapping ones.' }
        PolicyExemptionsPerScope          = @{ Max = 1000; Label = 'Policy exemptions'; Advice = 'Remove expired exemptions; exempt at a broader scope, or exclude the scope in the assignment instead.' }
        ResourceGroupsPerSubscription     = @{ Max = 980; Label = 'Resource groups'; Advice = 'Delete empty resource groups (Get-AACUnusedResource), or spread workloads across subscriptions.' }
        TagsPerSubscription               = @{ Max = 50; Label = 'Tags'; Advice = 'Combine related tags into one (a JSON value), and drop tags nobody reads.' }
        TagsPerResourceGroup              = @{ Max = 50; Label = 'Tags (resource group with the most)'; Advice = 'Combine related tags into one, and drop tags nobody reads.' }
        TagsPerResource                   = @{ Max = 50; Label = 'Tags (resource with the most)'; Advice = 'Combine related tags into one, and drop tags nobody reads.' }
        ManagementGroupDepth              = @{ Max = 6; Label = 'Management group depth'; Advice = 'Flatten the hierarchy: group by policy and access needs, not by organisation chart.' }
        ManagementGroupsPerTenant         = @{ Max = 10000; Label = 'Management groups'; Advice = 'Remove empty management groups.' }
    }
    foreach ($key in @($Maximum.Keys)) {
        if (-not $limits.Contains($key)) { throw "Azure Admin Console doesn't know the limit '$key'. Use one of: $($limits.Keys -join ', ')." }
        $limits[$key].Max = [int64]$Maximum[$key]
    }

    # What is in scope: the subscriptions, and the management groups above them.
    $inScope = @{}
    $groupsInScope = @{}
    foreach ($s in @($Scope.Subscriptions)) {
        $inScope[([string]$s['subscriptionId']).ToLowerInvariant()] = $true
        foreach ($m in @($s['chain'])) { if ($m -is [System.Collections.IDictionary]) { $groupsInScope[([string]$m['name']).ToLowerInvariant()] = $true } }
    }
    $unscoped = -not @($Scope['GraphScope'] | Where-Object { $_ }).Count
    $groupName = @{}
    foreach ($row in (& $rowsOf 'managementGroups')) { $groupName[(& $text $row 'name')] = $(if (& $text $row 'displayName') { & $text $row 'displayName' } else { & $text $row 'name' }) }
    $subName = { param([string] $Id) if ($Scope.Names -and $Scope.Names.Contains($Id)) { $Scope.Names[$Id] } else { $Id } }
    # A scope string's kind, name and whether it is in scope.
    $describe = {
        param([string] $ScopeId)
        $s = $ScopeId.ToLowerInvariant()
        if ($s -match '^/providers/microsoft\.management/managementgroups/([^/]+)$') {
            $g = $Matches[1]
            return @{ Type = 'Management group'; Name = $(if ($groupName.Contains($g)) { $groupName[$g] } else { $g }); In = ($unscoped -or $groupsInScope.Contains($g)) }
        }
        if ($s -match '^/subscriptions/([^/]+)/resourcegroups/([^/]+)$') { return @{ Type = 'Resource group'; Name = "$($Matches[2]) ($(& $subName $Matches[1]))"; In = $inScope.Contains($Matches[1]) } }
        if ($s -match '^/subscriptions/([^/]+)$') { return @{ Type = 'Subscription'; Name = (& $subName $Matches[1]); In = $inScope.Contains($Matches[1]) } }
        @{ Type = 'Other'; Name = $ScopeId; In = $false }
    }

    $items = [System.Collections.Generic.List[object]]::new()
    $add = {
        param([string] $Key, [string] $ScopeType, [string] $ScopeName, [string] $ScopeId, [int64] $Used, [string] $Detail = '')
        if ($Used -le 0) { return }
        $limit = $limits[$Key]
        $percent = [Math]::Round(100.0 * $Used / [Math]::Max(1, $limit.Max), 1)
        $status = if ($percent -ge $CriticalPercent) { 'Critical' } elseif ($percent -ge $WarningPercent) { 'Warning' } else { 'OK' }
        if ($ScopeType -eq 'Resource group' -and $status -eq 'OK') { return }
        $item = [pscustomobject][ordered]@{
            Status    = $status
            Limit     = $limit.Label
            ScopeType = $ScopeType
            Scope     = $ScopeName
            Used      = $Used
            Maximum   = $limit.Max
            Percent   = $percent
            Remaining = [Math]::Max(0, $limit.Max - $Used)
            Detail    = $Detail
            Advice    = $(if ($status -eq 'OK') { '' } else { $limit.Advice })
            LimitName = $Key
            ScopeId   = $ScopeId
        }
        $item.PSObject.TypeNames.Insert(0, 'AAC.AzureLimit')
        $items.Add($item)
    }

    # --- Tenant ------------------------------------------------------------------------------------------------------------
    $customRoles = 0; foreach ($row in (& $rowsOf 'customRoles')) { $customRoles += & $number $row 'used' }
    & $add 'CustomRolesPerTenant' 'Tenant' 'Tenant' '/' $customRoles
    $setsTotal = 0; foreach ($row in (& $rowsOf 'policySets')) { $setsTotal += & $number $row 'used' }
    & $add 'PolicySetDefinitionsPerTenant' 'Tenant' 'Tenant' '/' $setsTotal
    $groups = @(& $rowsOf 'managementGroups')
    & $add 'ManagementGroupsPerTenant' 'Tenant' 'Tenant' '/' $groups.Count
    if ($groups.Count) {
        $deepest = @($groups | Sort-Object { & $number $_ 'depth' } -Descending)[0]
        $depth = & $number $deepest 'depth'
        & $add 'ManagementGroupDepth' 'Tenant' 'Tenant' '/' $depth "Deepest: $(& $text $deepest 'displayName')"
    }

    # --- Role assignments ----------------------------------------------------------------------------------------------------
    foreach ($row in (& $rowsOf 'rbacBySubscription')) {
        $id = (& $text $row 'subscriptionId').ToLowerInvariant()
        if ($inScope.Contains($id)) { & $add 'RoleAssignmentsPerSubscription' 'Subscription' (& $subName $id) "/subscriptions/$id" (& $number $row 'used') }
    }
    foreach ($row in (& $rowsOf 'rbacByGroup')) {
        $d = & $describe (& $text $row 'scope')
        if ($d.In) { & $add 'RoleAssignmentsPerManagementGroup' $d.Type $d.Name (& $text $row 'scope') (& $number $row 'used') }
    }

    # --- Policy, per scope ----------------------------------------------------------------------------------------------------
    foreach ($pair in @(@('policyAssignments', 'PolicyAssignmentsPerScope'), @('policyDefinitions', 'PolicyDefinitionsPerScope'), @('policySets', 'PolicySetDefinitionsPerScope'), @('policyExemptions', 'PolicyExemptionsPerScope'))) {
        foreach ($row in (& $rowsOf $pair[0])) {
            $d = & $describe (& $text $row 'scope')
            if ($d.In) { & $add $pair[1] $d.Type $d.Name (& $text $row 'scope') (& $number $row 'used') }
        }
    }

    # --- Per subscription ----------------------------------------------------------------------------------------------------
    foreach ($row in (& $rowsOf 'resourceGroups')) {
        $id = (& $text $row 'subscriptionId').ToLowerInvariant()
        if ($inScope.Contains($id)) { & $add 'ResourceGroupsPerSubscription' 'Subscription' (& $subName $id) "/subscriptions/$id" (& $number $row 'used') }
    }
    foreach ($row in (& $rowsOf 'subscriptionTags')) {
        $id = (& $text $row 'subscriptionId').ToLowerInvariant()
        if ($inScope.Contains($id)) { & $add 'TagsPerSubscription' 'Subscription' (& $subName $id) "/subscriptions/$id" (& $number $row 'used') }
    }
    foreach ($row in (& $rowsOf 'groupTags')) {
        $id = (& $text $row 'subscriptionId').ToLowerInvariant()
        if ($inScope.Contains($id)) { & $add 'TagsPerResourceGroup' 'Subscription' (& $subName $id) "/subscriptions/$id" (& $number $row 'used') "Most: resource group $(& $text $row 'name')" }
    }
    foreach ($row in (& $rowsOf 'resourceTags')) {
        $id = (& $text $row 'subscriptionId').ToLowerInvariant()
        if ($inScope.Contains($id)) { & $add 'TagsPerResource' 'Subscription' (& $subName $id) "/subscriptions/$id" (& $number $row 'used') "Most: $(& $text $row 'name') ($((& $text $row 'type') -replace '^.*/', ''))" }
    }

    $rank = @{ Critical = 0; Warning = 1; OK = 2 }
    $sorted = @($items | Sort-Object -Property @{ Expression = { $rank[$_.Status] } }, @{ Expression = 'Percent'; Descending = $true }, Limit, Scope)
    @{
        Limits  = $sorted
        Notices = @($notices)
        Stats   = @{
            Limits   = $sorted.Count
            Critical = @($sorted | Where-Object Status -EQ 'Critical').Count
            Warning  = @($sorted | Where-Object Status -EQ 'Warning').Count
            Highest  = $(if ($sorted.Count) { (@($sorted | Sort-Object Percent -Descending))[0].Percent } else { 0 })
        }
    }
}