Private/ConvertTo-AACInventory.ps1

function ConvertTo-AACInventory {
    <#
    .SYNOPSIS
        Builds the tenant inventory tree - tenant > management groups >
        subscriptions > resource groups > resources - from Azure Resource
        Graph rows, with counts rolled up at every level.
    .DESCRIPTION
        Management groups are nested by their parent; a subscription goes
        under the management group it is directly in (the first of its
        managementGroupAncestorsChain); resource groups under their
        subscription; resources under their resource group. When the
        management groups can't be read (no permission on them), or a
        subscription's group isn't among those read, it goes straight under
        the tenant.
 
        Management groups with no subscription in the result are left out -
        unless they were asked for by name (-KeepManagementGroup), so an empty
        group asked for still shows.
 
        With -Security (Microsoft Defender for Cloud, from Resource Graph's
        securityresources), every node also has a security posture:
          - a resource: its secure score - the share of its assessed
            recommendations that are healthy - and its unhealthy findings by
            severity (High, Medium, Low), the worst of them (Severity) and
            the first few by name (TopFindings)
          - a resource group: the same, over its resources
          - a subscription: Defender's own secure score (current / max)
          - a management group and the tenant: their subscriptions' scores
            added up, as Defender does (sum of current / sum of max)
        Rating: Good (70% or more), Fair (40-69%), Poor (under 40%).
 
        Returns a hashtable:
          Root the tenant node; every node is a hashtable with Level, Id,
                 Name, DisplayName, Detail, Children and the rolled-up counts
                 ManagementGroups, Subscriptions, ResourceGroups, Resources
          Items every node, flattened in tree order, as AAC.InventoryItem
                 objects (Level, Depth, Name, Path, Type, Location, ...)
          Stats the totals
          Controls, Recommendations (with -Security) Defender's security
                 controls per subscription, with the potential score increase
                 of fixing each, and every unhealthy recommendation with the
                 resource it is on
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [string] $TenantId,

        [string] $TenantName,

        # Rows: id, name, displayName, parentId.
        [AllowEmptyCollection()]
        [object[]] $ManagementGroup = @(),

        # Rows: subscriptionId, name, state, parentGroup (a management group name), tags.
        [AllowEmptyCollection()]
        [object[]] $Subscription = @(),

        # Rows: id, name, subscriptionId, location, state, managedBy, tags.
        [AllowEmptyCollection()]
        [object[]] $ResourceGroup = @(),

        # Rows: id, name, type, kind, location, resourceGroup, subscriptionId, sku, zones, tags.
        [AllowEmptyCollection()]
        [object[]] $Resource = @(),

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

        # Defender for Cloud rows: Scores (subscriptionId, current, max),
        # Controls (subscriptionId, control, current, max, healthy,
        # unhealthy), Summary (resourceId, healthy, unhealthy, high, medium,
        # low) and Recommendations (resourceId, subscriptionId, name,
        # severity, impact, effort, categories, cause).
        [hashtable] $Security
    )

    function Get-Value($Row, [string] $Key) {
        if ($Row -is [System.Collections.IDictionary]) {
            if ($Row.Contains($Key)) { return $Row[$Key] }
            foreach ($name in $Row.Keys) { if ($name -eq $Key) { return $Row[$name] } }
            return $null
        }
        Get-AACPropertyValue -InputObject $Row -Name $Key
    }
    $text = { param($Row, [string] $Key) $value = Get-Value $Row $Key; if ($null -eq $value) { '' } else { [string]$value } }
    $lower = { param([string] $Value) $Value.ToLowerInvariant() }
    $tagText = {
        param($Tags)
        if ($Tags -is [System.Collections.IDictionary]) { return (@($Tags.Keys | Sort-Object | ForEach-Object { "$_=$($Tags[$_])" }) -join '; ') }
        if ($Tags -and $Tags -isnot [string]) { return (@($Tags.PSObject.Properties | Sort-Object Name | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ') }
        ''
    }
    $newNode = {
        param([string] $Level, [string] $Id, [string] $Name, [string] $DisplayName)
        @{
            Level = $Level; Id = $Id; Name = $Name; DisplayName = $(if ($DisplayName) { $DisplayName } else { $Name })
            Children = [System.Collections.Generic.List[object]]::new(); Parent = $null
            ManagementGroups = 0; Subscriptions = 0; ResourceGroups = 0; Resources = 0
            Location = ''; State = ''; Type = ''; Kind = ''; Sku = ''; Tags = ''; SubscriptionId = ''; SubscriptionName = ''
            ResourceGroup = ''; ManagementGroup = ''; Zones = ''; TypeCounts = @{}; Visible = $true
            # Security posture (Defender for Cloud).
            SecureScore = $null; ScoreCurrent = 0.0; ScoreMax = 0.0; OfficialScore = $false
            Healthy = 0; Unhealthy = 0; High = 0; Medium = 0; Low = 0; Severity = ''; Rating = ''; TopFindings = ''
        }
    }

    # --- The tenant and its management groups ------------------------------------------------------
    $root = & $newNode 'Tenant' "/tenants/$TenantId" $TenantId $(if ($TenantName) { $TenantName } else { $TenantId })
    $groups = @{}
    foreach ($row in $ManagementGroup) {
        $name = & $text $row 'name'
        if (-not $name) { continue }
        $groups[(& $lower $name)] = & $newNode 'ManagementGroup' (& $text $row 'id') $name (& $text $row 'displayName')
        $groups[(& $lower $name)].ParentName = (& $text $row 'parentId') -replace '^.*/', ''
    }
    foreach ($key in @($groups.Keys)) {
        $group = $groups[$key]
        $parentKey = & $lower ([string]$group.ParentName)
        # The tenant root group's parent is the tenant itself.
        $parent = if ($parentKey -and $groups.Contains($parentKey) -and $parentKey -ne $key) { $groups[$parentKey] } else { $root }
        $group.Parent = $parent
        $parent.Children.Add($group)
    }

    # --- Subscriptions, resource groups, resources ------------------------------------------------------
    $subscriptions = @{}
    foreach ($row in $Subscription) {
        $id = & $lower (& $text $row 'subscriptionId')
        if (-not $id -or $subscriptions.Contains($id)) { continue }
        $node = & $newNode 'Subscription' "/subscriptions/$id" (& $text $row 'name') ''
        $node.SubscriptionId = $id
        $node.SubscriptionName = $node.Name
        $node.State = & $text $row 'state'
        $node.Tags = & $tagText (Get-Value $row 'tags')
        $node.QuotaId = & $text $row 'quotaId'
        $parentKey = & $lower (& $text $row 'parentGroup')
        $parent = if ($parentKey -and $groups.Contains($parentKey)) { $groups[$parentKey] } else { $root }
        $node.ManagementGroup = if ($parent.Level -eq 'ManagementGroup') { $parent.DisplayName } else { '' }
        $node.Parent = $parent
        $parent.Children.Add($node)
        $subscriptions[$id] = $node
    }
    $ensureSubscription = {
        param([string] $Id)
        $key = & $lower $Id
        if (-not $subscriptions.Contains($key)) {
            # A subscription seen only through its resources.
            $node = & $newNode 'Subscription' "/subscriptions/$key" $key ''
            $node.SubscriptionId = $key; $node.SubscriptionName = $key; $node.Parent = $root
            $root.Children.Add($node)
            $subscriptions[$key] = $node
        }
        $subscriptions[$key]
    }
    $resourceGroups = @{}
    foreach ($row in $ResourceGroup) {
        $sub = & $ensureSubscription (& $text $row 'subscriptionId')
        $name = & $text $row 'name'
        $key = "$($sub.SubscriptionId)/$(& $lower $name)"
        if ($resourceGroups.Contains($key)) { continue }
        $node = & $newNode 'ResourceGroup' (& $text $row 'id') $name ''
        $node.Location = & $text $row 'location'
        $node.State = & $text $row 'state'
        $node.ManagedBy = & $text $row 'managedBy'
        $node.Tags = & $tagText (Get-Value $row 'tags')
        $node.SubscriptionId = $sub.SubscriptionId; $node.SubscriptionName = $sub.Name; $node.ResourceGroup = $name; $node.ManagementGroup = $sub.ManagementGroup
        $node.Parent = $sub
        $sub.Children.Add($node)
        $resourceGroups[$key] = $node
    }
    foreach ($row in $Resource) {
        $sub = & $ensureSubscription (& $text $row 'subscriptionId')
        $groupName = & $text $row 'resourceGroup'
        $key = "$($sub.SubscriptionId)/$(& $lower $groupName)"
        if (-not $resourceGroups.Contains($key)) {
            $group = & $newNode 'ResourceGroup' "/subscriptions/$($sub.SubscriptionId)/resourceGroups/$groupName" $groupName ''
            $group.SubscriptionId = $sub.SubscriptionId; $group.SubscriptionName = $sub.Name; $group.ResourceGroup = $groupName; $group.ManagementGroup = $sub.ManagementGroup
            $group.Parent = $sub; $sub.Children.Add($group)
            $resourceGroups[$key] = $group
        }
        $parent = $resourceGroups[$key]
        $node = & $newNode 'Resource' (& $text $row 'id') (& $text $row 'name') ''
        $node.Type = & $lower (& $text $row 'type')
        $node.Kind = & $text $row 'kind'
        $node.Location = & $text $row 'location'
        $node.Sku = & $text $row 'sku'
        $node.Zones = (@(Get-Value $row 'zones') | Where-Object { $_ }) -join ', '
        $node.Tags = & $tagText (Get-Value $row 'tags')
        $node.SubscriptionId = $sub.SubscriptionId; $node.SubscriptionName = $sub.Name; $node.ResourceGroup = $groupName; $node.ManagementGroup = $sub.ManagementGroup
        $node.Parent = $parent
        $parent.Children.Add($node)
    }

    # --- Security posture (Defender for Cloud) --------------------------------------------------------
    $hasSecurity = $null -ne $Security -and @(@($Security.Scores) + @($Security.Summary) | Where-Object { $_ }).Count -gt 0
    $byId = @{}
    $controls = @()
    $recommendations = @()
    if ($hasSecurity) {
        $walk = [System.Collections.Generic.Stack[object]]::new()
        $walk.Push($root)
        while ($walk.Count) {
            $node = $walk.Pop()
            if ($node.Id) { $byId[(& $lower $node.Id)] = $node }
            foreach ($child in $node.Children) { $walk.Push($child) }
        }
        foreach ($row in @($Security.Summary)) {
            $node = $byId[(& $lower (& $text $row 'resourceId'))]
            if (-not $node -or $node.Level -ne 'Resource') { continue }
            $node.Healthy = [int](Get-Value $row 'healthy'); $node.Unhealthy = [int](Get-Value $row 'unhealthy')
            $node.High = [int](Get-Value $row 'high'); $node.Medium = [int](Get-Value $row 'medium'); $node.Low = [int](Get-Value $row 'low')
        }
        foreach ($row in @($Security.Scores)) {
            $sub = $subscriptions[(& $lower (& $text $row 'subscriptionId'))]
            if (-not $sub) { continue }
            $sub.ScoreCurrent = [double](Get-Value $row 'current'); $sub.ScoreMax = [double](Get-Value $row 'max'); $sub.OfficialScore = $sub.ScoreMax -gt 0
        }
        # Controls: the potential increase of the subscription's score (in
        # percentage points) if the control were fully healthy.
        $controls = @(foreach ($row in @($Security.Controls)) {
                $sub = $subscriptions[(& $lower (& $text $row 'subscriptionId'))]
                if (-not $sub) { continue }
                $current = [double](Get-Value $row 'current'); $max = [double](Get-Value $row 'max')
                [pscustomobject][ordered]@{
                    PSTypeName        = 'AAC.SecurityControl'
                    Control           = & $text $row 'control'
                    SubscriptionName  = $sub.Name
                    SubscriptionId    = $sub.SubscriptionId
                    Current           = [Math]::Round($current, 2)
                    Max               = [Math]::Round($max, 2)
                    Score             = $(if ($max -gt 0) { [Math]::Round(100 * $current / $max) } else { $null })
                    PotentialIncrease = $(if ($sub.ScoreMax -gt 0) { [Math]::Round(100 * ($max - $current) / $sub.ScoreMax, 1) } else { 0 })
                    HealthyResources  = [int](Get-Value $row 'healthy')
                    UnhealthyResources = [int](Get-Value $row 'unhealthy')
                }
            })
        $severityRank = @{ High = 0; Medium = 1; Low = 2 }
        $recommendations = @(foreach ($row in @($Security.Recommendations)) {
                $node = $byId[(& $lower (& $text $row 'resourceId'))]
                if (-not $node -or $node.Level -notin 'Resource', 'Subscription', 'ResourceGroup') { continue }
                [pscustomobject][ordered]@{
                    PSTypeName       = 'AAC.SecurityRecommendation'
                    Recommendation   = & $text $row 'name'
                    Severity         = & $text $row 'severity'
                    Impact           = & $text $row 'impact'
                    Effort           = & $text $row 'effort'
                    Category         = & $text $row 'categories'
                    Resource         = $node.DisplayName
                    Type             = $node.Type
                    ResourceGroup    = $node.ResourceGroup
                    SubscriptionName = $node.SubscriptionName
                    SubscriptionId   = $node.SubscriptionId
                    Cause            = & $text $row 'cause'
                    ResourceId       = $node.Id
                }
            })
        $recommendations = @($recommendations | Sort-Object -Property @{ Expression = { if ($severityRank.Contains($_.Severity)) { $severityRank[$_.Severity] } else { 3 } } }, Recommendation, Resource)
        foreach ($group in @($recommendations | Where-Object { $_.Type } | Group-Object -Property { ([string]$_.ResourceId).ToLowerInvariant() })) {
            $node = $byId[$group.Name]
            if ($node) { $node.TopFindings = (@($group.Group | Select-Object -First 3 | ForEach-Object { $_.Recommendation })) -join '; ' }
        }
    }

    # --- Counts, rolled up; empty management groups left out -------------------------------------------
    $keep = @($KeepManagementGroup | ForEach-Object { & $lower $_ })
    function Measure-Node($Node) {
        $Node.TypeCounts = @{}
        $Node.ManagementGroups = 0; $Node.Subscriptions = 0; $Node.ResourceGroups = 0; $Node.Resources = 0
        $Node.Keep = $Node.Level -eq 'ManagementGroup' -and ($keep -contains (& $lower $Node.Name))
        if ($Node.Level -ne 'Resource') {
            $Node.Healthy = 0; $Node.Unhealthy = 0; $Node.High = 0; $Node.Medium = 0; $Node.Low = 0
            if (-not $Node.OfficialScore) { $Node.ScoreCurrent = 0.0; $Node.ScoreMax = 0.0 }
        }
        foreach ($child in @($Node.Children)) {
            Measure-Node $child
            switch ($child.Level) {
                'ManagementGroup' { $Node.ManagementGroups += 1 + $child.ManagementGroups }
                'Subscription' { $Node.Subscriptions += 1 }
                'ResourceGroup' { $Node.ResourceGroups += 1 }
                'Resource' { $Node.Resources += 1; $Node.TypeCounts[$child.Type] = 1 + [int]$Node.TypeCounts[$child.Type] }
            }
            if ($child.Level -eq 'ManagementGroup' -and $child.Keep) { $Node.Keep = $true }
            $Node.Healthy += $child.Healthy; $Node.Unhealthy += $child.Unhealthy
            $Node.High += $child.High; $Node.Medium += $child.Medium; $Node.Low += $child.Low
            # A management group's and the tenant's score: their subscriptions' added up.
            if ($Node.Level -in 'Tenant', 'ManagementGroup' -and $child.Level -in 'Subscription', 'ManagementGroup') {
                $Node.ScoreCurrent += $child.ScoreCurrent; $Node.ScoreMax += $child.ScoreMax
            }
            if ($child.Level -ne 'Resource') {
                $Node.Subscriptions += $(if ($child.Level -eq 'Subscription') { 0 } else { $child.Subscriptions })
                $Node.ResourceGroups += $(if ($child.Level -eq 'ResourceGroup') { 0 } else { $child.ResourceGroups })
                $Node.Resources += $child.Resources
                foreach ($type in $child.TypeCounts.Keys) { $Node.TypeCounts[$type] = [int]$Node.TypeCounts[$type] + $child.TypeCounts[$type] }
            }
        }
        # A management group with no subscription below it isn't part of this
        # inventory, unless it was asked for.
        $Node.Children = [System.Collections.Generic.List[object]]@(@($Node.Children) | Where-Object {
                $_.Level -ne 'ManagementGroup' -or $_.Subscriptions -gt 0 -or $_.Keep
            })
        # Recount management groups after pruning.
        $Node.ManagementGroups = 0
        foreach ($child in $Node.Children) { if ($child.Level -eq 'ManagementGroup') { $Node.ManagementGroups += 1 + $child.ManagementGroups } }
    }
    Measure-Node $root

    # Order: management groups, then subscriptions, then groups and resources, by name.
    $order = @{ ManagementGroup = 0; Subscription = 1; ResourceGroup = 2; Resource = 3 }
    function Complete-Node($Node, [string] $Path, [int] $Depth, $Items) {
        $Node.Depth = $Depth
        $Node.Path = if ($Path) { "$Path / $($Node.DisplayName)" } else { $Node.DisplayName }
        $top = @($Node.TypeCounts.GetEnumerator() | Sort-Object -Property @{ Expression = { $_.Value }; Descending = $true }, Name | Select-Object -First 3 | ForEach-Object { "$(($_.Name -split '/')[-1]) $($_.Value)" })
        $Node.TopTypes = $top -join ', '
        if ($hasSecurity) {
            $assessed = $Node.Healthy + $Node.Unhealthy
            $Node.SecureScore = if ($Node.Level -in 'Tenant', 'ManagementGroup', 'Subscription' -and $Node.ScoreMax -gt 0) { [Math]::Round(100 * $Node.ScoreCurrent / $Node.ScoreMax) }
            elseif ($assessed -gt 0) { [Math]::Round(100 * $Node.Healthy / $assessed) }
            else { $null }
            $Node.Severity = if ($Node.High) { 'High' } elseif ($Node.Medium) { 'Medium' } elseif ($Node.Low) { 'Low' } elseif ($assessed) { 'Healthy' } else { '' }
            $Node.Rating = if ($null -eq $Node.SecureScore) { '' } elseif ($Node.SecureScore -ge 70) { 'Good' } elseif ($Node.SecureScore -ge 40) { 'Fair' } else { 'Poor' }
        }
        $Node.Detail = switch ($Node.Level) {
            'Tenant' { $TenantId }
            'ManagementGroup' { $Node.Name }
            'Subscription' { (@($Node.SubscriptionId, $Node.State) | Where-Object { $_ }) -join ' · ' }
            'ResourceGroup' { (@($Node.Location, $(if ($Node.Resources -eq 0) { 'empty' })) | Where-Object { $_ }) -join ' · ' }
            'Resource' { (@(($Node.Type -replace '^microsoft\.', ''), $Node.Location, $Node.Sku) | Where-Object { $_ }) -join ' · ' }
        }
        $Items.Add([pscustomobject][ordered]@{
                PSTypeName       = 'AAC.InventoryItem'
                Level            = $Node.Level
                Depth            = $Depth
                Name             = $Node.DisplayName
                Path             = $Node.Path
                ManagementGroup  = $Node.ManagementGroup
                SubscriptionName = $Node.SubscriptionName
                SubscriptionId   = $Node.SubscriptionId
                ResourceGroup    = $Node.ResourceGroup
                Type             = $Node.Type
                Kind             = $Node.Kind
                Location         = $Node.Location
                Sku              = $Node.Sku
                Zones            = $Node.Zones
                State            = $Node.State
                ManagementGroups = $Node.ManagementGroups
                Subscriptions    = $Node.Subscriptions
                ResourceGroups   = $Node.ResourceGroups
                Resources        = $Node.Resources
                TopTypes         = $Node.TopTypes
                SecureScore      = $Node.SecureScore
                ScorePoints      = $(if ($Node.Level -in 'Tenant', 'ManagementGroup', 'Subscription' -and $Node.ScoreMax -gt 0) { '{0:N1} / {1:N1}' -f $Node.ScoreCurrent, $Node.ScoreMax } else { '' })
                Rating           = $Node.Rating
                Severity         = $Node.Severity
                High             = $Node.High
                Medium           = $Node.Medium
                Low              = $Node.Low
                Findings         = $Node.Unhealthy
                TopFindings      = $Node.TopFindings
                Tags             = $Node.Tags
                Id               = $Node.Id
            })
        $sorted = @($Node.Children | Sort-Object -Property @{ Expression = { $order[$_.Level] } }, @{ Expression = { $_.DisplayName } })
        $Node.Children = [System.Collections.Generic.List[object]]@($sorted)
        foreach ($child in $sorted) { Complete-Node $child $Node.Path ($Depth + 1) $Items }
    }
    $items = [System.Collections.Generic.List[object]]::new()
    Complete-Node $root '' 0 $items

    $all = $items.ToArray()
    @{
        Root  = $root
        Items = $all
        Stats = @{
            ManagementGroups = $root.ManagementGroups
            Subscriptions    = @($all | Where-Object Level -EQ 'Subscription').Count
            ResourceGroups   = @($all | Where-Object Level -EQ 'ResourceGroup').Count
            EmptyGroups      = @($all | Where-Object { $_.Level -eq 'ResourceGroup' -and $_.Resources -eq 0 }).Count
            Resources        = $root.Resources
            Types            = $root.TypeCounts.Count
            Locations        = @($all | Where-Object { $_.Level -eq 'Resource' -and $_.Location } | Select-Object -ExpandProperty Location -Unique).Count
            HasSecurity      = $hasSecurity
            SecureScore      = $root.SecureScore
            Rating           = $root.Rating
            High             = $root.High
            Medium           = $root.Medium
            Low              = $root.Low
            Assessed         = @($all | Where-Object { $_.Level -eq 'Resource' -and $null -ne $_.SecureScore }).Count
        }
        Controls        = $controls
        Recommendations = $recommendations
    }
}