Private/Read-AACManagementGroupMap.ps1

function Read-AACManagementGroupMap {
    <#
    .SYNOPSIS
        Reads the management group hierarchy and builds it as a map model -
        the tenant root and management groups as nested boxes, each
        subscription a node inside its group - with how many subscriptions,
        policy assignments and role assignments sit at each level.
    .DESCRIPTION
        For Show-AACManagementGroupMap and Export-AACDiagram -View
        Governance. Call it inside Invoke-AACProgress. Reads, tenant-wide in
        Azure Resource Graph: the management groups and their parents, the
        subscriptions and their chains (Resolve-AACScope), the policy and
        role assignments per scope, and the resources per subscription.
 
        Scoped (-ManagementGroupId or -SubscriptionId), only the
        subscriptions in scope and the groups above them appear; unscoped,
        every group, empty ones too.
        Returns @{ Map (Clusters, Nodes, Edges, Stats, Hierarchy - a row per
        management group: name, parent, depth, subscriptions (direct and
        below), policy and role assignments at it); Title; Facts }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [string[]] $ManagementGroupId = @(),

        [string[]] $SubscriptionId = @(),

        [string] $Title
    )

    Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
    $scope = Resolve-AACScope -SubscriptionId $SubscriptionId -ManagementGroupId $ManagementGroupId
    Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
    Update-AACProgress -Id 'hierarchy' -Indeterminate -Description 'Reading the management groups and what is assigned at each'
    $read = Invoke-AACGraphBatch -Query ([ordered]@{
            groups    = @{ Tenant = $true; Query = "resourcecontainers | where type =~ 'microsoft.management/managementgroups' | project name = tolower(name), displayName = tostring(properties.displayName), parent = tolower(tostring(properties.details.parent.name))" }
            policies  = @{ Tenant = $true; Query = "policyresources | where type =~ 'microsoft.authorization/policyassignments' | summarize used = count() by scope = tolower(tostring(properties.scope))" }
            roles     = @{ Tenant = $true; Query = "authorizationresources | where type =~ 'microsoft.authorization/roleassignments' | extend scope = tolower(tostring(properties.scope)) | where scope startswith '/providers/microsoft.management/managementgroups/' or scope matches regex '^/subscriptions/[^/]+$' or scope == '/' | summarize used = count() by scope" }
            resources = "resources | summarize used = count() by subscriptionId = tolower(subscriptionId)"
        }) -SubscriptionId $scope.GraphScope -AllowFailure @('policies', 'roles', 'resources')
    $groupRows = @($read.Rows['groups'] | Where-Object { $_ })
    Update-AACProgress -Id 'hierarchy' -Complete -Description ('Read {0:N0} management group(s)' -f $groupRows.Count)

    $count = @{}
    foreach ($key in 'policies', 'roles') { $count[$key] = @{}; foreach ($row in @($read.Rows[$key] | Where-Object { $_ })) { $count[$key][[string]$row['scope']] = [int]$row['used'] } }
    $resourceCount = @{}; foreach ($row in @($read.Rows['resources'] | Where-Object { $_ })) { $resourceCount[[string]$row['subscriptionId']] = [int]$row['used'] }
    $at = { param([string] $Kind, [string] $Scope) if ($count[$Kind].Contains($Scope)) { $count[$Kind][$Scope] } else { 0 } }

    # The groups: names, parents; the root is the one without a parent.
    $groups = [ordered]@{}
    foreach ($row in $groupRows) { $groups[[string]$row['name']] = @{ Name = [string]$row['name']; Display = $(if ($row['displayName']) { [string]$row['displayName'] } else { [string]$row['name'] }); Parent = [string]$row['parent']; Subs = 0; Below = 0 } }
    $root = @($groups.Values | Where-Object { -not $_.Parent -or -not $groups.Contains($_.Parent) }) | Select-Object -First 1
    $scoped = [bool](@($ManagementGroupId | Where-Object { $_ }).Count -or @($SubscriptionId | Where-Object { $_ }).Count)
    $wanted = @{}
    $subOf = @{}
    foreach ($s in $scope.Subscriptions) {
        $id = ([string]$s['subscriptionId']).ToLowerInvariant()
        $chain = @(@($s['chain']) | Where-Object { $_ -is [System.Collections.IDictionary] } | ForEach-Object { ([string]$_['name']).ToLowerInvariant() })
        $subOf[$id] = if ($chain.Count) { $chain[0] } else { '' }
        foreach ($g in $chain) { $wanted[$g] = $true; if ($groups.Contains($g)) { $groups[$g].Below++ } }
        if ($chain.Count -and $groups.Contains($chain[0])) { $groups[$chain[0]].Subs++ }
    }
    $keep = { param([string] $Name) (-not $scoped) -or $wanted.Contains($Name) }
    $depthOf = { param([string] $Name) $d = 0; $g = $Name; while ($groups.Contains($g) -and $groups[$g].Parent -and $groups.Contains($groups[$g].Parent) -and $d -lt 20) { $d++; $g = $groups[$g].Parent }; $d }
    $clusterId = { param([string] $Name) if ($root -and $Name -eq $root.Name) { 'tenant' } else { "/providers/microsoft.management/managementgroups/$Name" } }

    $clusters = [System.Collections.Generic.List[object]]::new()
    $hierarchy = [System.Collections.Generic.List[object]]::new()
    foreach ($g in @($groups.Values | Sort-Object { & $depthOf $_.Name }, { $_.Display })) {
        if (-not (& $keep $g.Name)) { continue }
        $scopeId = "/providers/microsoft.management/managementgroups/$($g.Name)"
        $policies = & $at 'policies' $scopeId
        $roles = & $at 'roles' $scopeId
        if ($root -and $g.Name -eq $root.Name) { $roles += & $at 'roles' '/' }
        $detail = @(@($(if ($g.Below) { "$($g.Below) subscription(s)" } else { 'no subscriptions' }); $(if ($policies) { "$policies policy assignment(s)" }); $(if ($roles) { "$roles role assignment(s)" })) | Where-Object { $_ })
        $isRoot = $root -and $g.Name -eq $root.Name
        $clusters.Add(@{ id = (& $clusterId $g.Name); kind = $(if ($isRoot) { 'tenant' } else { 'managementgroup' }); parent = $(if ($isRoot -or -not $g.Parent) { '' } else { & $clusterId $g.Parent }); name = $g.Display; detail = @($g.Name, ($detail -join ' · ')); icon = 'resource'; external = $false; badges = @(); chips = @() })
        $row = [pscustomobject][ordered]@{
            ManagementGroup   = $g.Display
            Name              = $g.Name
            Parent            = $(if ($g.Parent -and $groups.Contains($g.Parent)) { $groups[$g.Parent].Display } else { '' })
            Depth             = & $depthOf $g.Name
            Subscriptions     = $g.Subs
            SubscriptionsBelow = $g.Below
            PolicyAssignments = $policies
            RoleAssignments   = $roles
            Id                = $scopeId
        }
        $row.PSObject.TypeNames.Insert(0, 'AAC.ManagementGroup')
        $hierarchy.Add($row)
    }

    $nodes = [System.Collections.Generic.List[object]]::new()
    foreach ($s in @($scope.Subscriptions | Sort-Object { [string]$_['name'] })) {
        $id = ([string]$s['subscriptionId']).ToLowerInvariant()
        $sid = "/subscriptions/$id"
        $parentGroup = $subOf[$id]
        $facts = @(@($(& $at 'policies' $sid) | Where-Object { $_ } | ForEach-Object { "$_ policy assignment(s)" }) + @($(& $at 'roles' $sid) | Where-Object { $_ } | ForEach-Object { "$_ role assignment(s)" }) + @($(if ($resourceCount.Contains($id)) { "$($resourceCount[$id]) resource(s)" })) | Where-Object { $_ })
        $nodes.Add(@{
                id = $sid; parent = $(if ($parentGroup -and $groups.Contains($parentGroup)) { & $clusterId $parentGroup } else { '' }); name = [string]$s['name']; type = 'microsoft.resources/subscriptions'; typeLabel = 'Subscription'; icon = 'subscription'; kind = 'resource'
                location = ''; resourceGroup = ''; subscriptionId = $id; subscription = [string]$s['name']; sku = ''; facts = $facts; tags = $null; orphan = ''; external = $false
                chips = @(); chip = ''; appliedTo = @(); risk = ''; rules = @(); routes = @(); propagation = ''
            })
    }

    $policyTotal = 0; foreach ($h in $hierarchy) { $policyTotal += $h.PolicyAssignments }
    $map = @{
        Clusters  = @($clusters)
        Nodes     = @($nodes)
        Edges     = @()
        Hierarchy = @($hierarchy)
        Stats     = @{ ManagementGroups = $hierarchy.Count; Subscriptions = $nodes.Count; Depth = $(if ($hierarchy.Count) { ($hierarchy | Measure-Object -Property Depth -Maximum).Maximum } else { 0 }); PolicyAssignments = $policyTotal }
    }
    $facts = @("$($hierarchy.Count) management group(s)", "$($nodes.Count) subscription(s)", "$($map.Stats.Depth) level(s) below the root", "drawn $((Get-Date).ToString('d MMM yyyy HH:mm'))")
    @{ Map = $map; Title = $(if ($Title) { $Title } else { "Management groups: $($scope.Label)" }); Facts = $facts }
}