Private/ConvertTo-AACPolicyAssessment.ps1

function ConvertTo-AACPolicyAssessment {
    <#
    .SYNOPSIS
        Assesses Azure Policy across a scope - what is assigned, how
        compliant it is by subscription, assignment, policy and category,
        the exemptions and the managed identities' roles - and what to
        improve: the model behind Invoke-AACPolicyAssessment (AzPolicyLens's
        analysis, in the spirit of github.com/Azure/AzPolicyLens).
    .DESCRIPTION
        No Azure calls: every input is Resource Graph rows
        (Get-AACPolicyAssessmentQuery). The scope:
          (nothing) everything the account can see
          -ManagementGroupId that management group and everything under it
          -SubscriptionId those subscriptions (an application team's),
                               and the assignments that reach them - from
                               their management groups too
        -Audience Platform (AzPolicyLens's detailed wiki) adds the
        management group hierarchy, unassigned custom definitions and
        initiatives, metadata hygiene and hidden- metadata and tags;
        Application (its basic wiki) leaves those out.
 
        Compliance is counted as AzPolicyLens counts it: each resource once,
        at its worst state (NonCompliant, then Compliant, Conflict, Exempt);
        compliance = (compliant + exempt) / all. A rate below
        -ComplianceWarningPercent is Warning, below half of it Poor.
 
        Returns a hashtable: Assignments, AssignmentCompliance (per
        assignment and subscription), Policies (per assignment and policy),
        Categories, Subscriptions, ManagementGroups, Initiatives,
        Definitions, Exemptions, Roles, Findings, Tree (for the HTML
        report), Stats.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [AllowEmptyCollection()] [object[]] $Subscription = @(),
        [AllowEmptyCollection()] [object[]] $ManagementGroup = @(),
        [AllowEmptyCollection()] [object[]] $Assignment = @(),
        [AllowEmptyCollection()] [object[]] $Exemption = @(),
        # Definition or initiative ID (lower case) -> its row: every custom one, and the built-in ones assigned.
        [hashtable] $Definition = @{},
        [AllowEmptyCollection()] [object[]] $RoleAssignment = @(),
        [AllowEmptyCollection()] [object[]] $RoleDefinition = @(),
        [AllowEmptyCollection()] [object[]] $ComplianceBySubscription = @(),
        [AllowEmptyCollection()] [object[]] $ComplianceByAssignment = @(),
        [AllowEmptyCollection()] [object[]] $ComplianceByPolicy = @(),
        [string[]] $SubscriptionId = @(),
        [string] $ManagementGroupId,
        [ValidateSet('Platform', 'Application')]
        [string] $Audience = 'Platform',
        [ValidateRange(1, 99)]
        [int] $ComplianceWarningPercent = 80,
        [ValidateRange(1, 365)]
        [int] $ExemptionWarningDays = 30,
        [datetime] $Now = (Get-Date)
    )

    # --- Helpers -----------------------------------------------------------------------------------------------------
    $at = {
        param($Item, [string] $Path)
        foreach ($part in ($Path -split '\.')) {
            if ($Item -is [System.Collections.IDictionary]) { $Item = if ($Item.Contains($part)) { $Item[$part] } else { $null } }
            elseif ($null -ne $Item -and $Item -isnot [string] -and $Item -isnot [ValueType] -and $Item -isnot [System.Collections.IEnumerable]) { $Item = Get-AACPropertyValue -InputObject $Item -Name $part }
            else { return $null }
        }
        $Item
    }
    $list = { param($Item) @(if ($Item -is [System.Collections.IEnumerable] -and $Item -isnot [string] -and $Item -isnot [System.Collections.IDictionary]) { $Item } elseif ($null -ne $Item) { , $Item }) | Where-Object { $null -ne $_ } }
    $lower = { param($Text) ([string]$Text).ToLowerInvariant() }
    $leaf = { param($Id) if ($Id) { ([string]$Id).TrimEnd('/') -replace '^.*/', '' } else { '' } }
    $isTrue = { param($Value) $null -ne $Value -and [string]$Value -in 'True', 'true' }
    $object = { param([string] $TypeName, [System.Collections.IDictionary] $Property) $item = [pscustomobject]$Property; $item.PSObject.TypeNames.Insert(0, $TypeName); $item }
    $platform = $Audience -eq 'Platform'
    $docs = 'https://learn.microsoft.com/azure/governance/policy'
    $percent = {
        param([long] $Compliant, [long] $Exempt, [long] $Total)
        if ($Total -le 0) { return $null }
        [Math]::Round(($Compliant + $Exempt) / $Total * 100, 1)
    }
    $rate = { param($Percent) if ($null -eq $Percent) { 'No data' } elseif ($Percent -ge $ComplianceWarningPercent) { 'Good' } elseif ($Percent -ge $ComplianceWarningPercent / 2) { 'Warning' } else { 'Poor' } }
    $hidden = {
        # Metadata (or tags) named hidden-* or hidden_*: for the platform team only.
        param($Bag)
        if (-not $platform -or $Bag -isnot [System.Collections.IDictionary]) { return '' }
        (@($Bag.Keys | Where-Object { $_ -match '^hidden[-_]' } | Sort-Object | ForEach-Object { "$_=$(if ($Bag[$_] -is [string] -or $Bag[$_] -is [ValueType]) { $Bag[$_] } else { ConvertTo-Json -InputObject $Bag[$_] -Compress -Depth 5 })" })) -join '; '
    }
    $findings = [System.Collections.Generic.List[object]]::new()
    $finding = {
        param([string] $Severity, [string] $Area, [string] $Title, [string] $Item, [string] $Detail, [string] $Action, [string] $Link, [string] $Scope, [string] $Id)
        $findings.Add((& $object 'AAC.PolicyFinding' ([ordered]@{ Severity = $Severity; Area = $Area; Finding = $Title; Item = $Item; Detail = $Detail; Recommendation = $Action; Link = $Link; Scope = $Scope; ResourceId = $Id })))
    }

    # --- The hierarchy, and what is in scope --------------------------------------------------------------------------
    $groupNames = @{}; $groupParent = @{}
    foreach ($row in $ManagementGroup) { $key = & $lower (& $at $row 'name'); $groupNames[$key] = [string]$(if (& $at $row 'displayName') { & $at $row 'displayName' } else { & $at $row 'name' }); $groupParent[$key] = & $lower (& $at $row 'parent') }
    $ancestorsOfGroup = {
        param([string] $Name)
        $chain = [System.Collections.Generic.List[string]]::new(); $walk = $groupParent[(& $lower $Name)]; $guard = 0
        while ($walk -and $guard -lt 20) { $chain.Add($walk); $walk = $groupParent[$walk]; $guard++ }
        , $chain.ToArray()
    }
    $subscriptionNames = @{}; $subscriptionChain = @{}
    foreach ($row in $Subscription) {
        $key = & $lower (& $at $row 'subscriptionId')
        $subscriptionNames[$key] = [string](& $at $row 'name')
        $subscriptionChain[$key] = @(@(& $list (& $at $row 'chain')) | ForEach-Object { & $lower (& $at $_ 'name') } | Where-Object { $_ })
    }
    $scopeGroup = & $lower $ManagementGroupId
    $wantedSubscriptions = @($SubscriptionId | Where-Object { $_ } | ForEach-Object { & $lower $_ })
    $inScopeSubscriptions = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($key in $subscriptionNames.Keys) {
        if ($wantedSubscriptions.Count) { if ($wantedSubscriptions -contains $key) { [void]$inScopeSubscriptions.Add($key) } }
        elseif ($scopeGroup) { if ($subscriptionChain[$key] -contains $scopeGroup) { [void]$inScopeSubscriptions.Add($key) } }
        else { [void]$inScopeSubscriptions.Add($key) }
    }
    $inScopeGroups = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($key in $groupNames.Keys) {
        if ($wantedSubscriptions.Count) { foreach ($sub in $inScopeSubscriptions) { if ($subscriptionChain[$sub] -contains $key) { [void]$inScopeGroups.Add($key) } } }
        elseif ($scopeGroup) { if ($key -eq $scopeGroup -or (& $ancestorsOfGroup $key) -contains $scopeGroup) { [void]$inScopeGroups.Add($key) } }
        else { [void]$inScopeGroups.Add($key) }
    }
    # Where a scope ID sits: a management group, a subscription (and below).
    $scopeOf = {
        param([string] $ScopeId)
        if ($ScopeId -match '(?i)/providers/Microsoft\.Management/managementGroups/([^/]+)') { return @{ Type = 'Management group'; Group = $Matches[1].ToLowerInvariant(); Subscription = ''; Name = $(if ($groupNames.Contains($Matches[1].ToLowerInvariant())) { $groupNames[$Matches[1].ToLowerInvariant()] } else { $Matches[1] }) } }
        if ($ScopeId -match '(?i)^/subscriptions/([^/]+)(/resourceGroups/([^/]+))?') {
            $sub = $Matches[1].ToLowerInvariant(); $name = if ($subscriptionNames.Contains($sub)) { $subscriptionNames[$sub] } else { $sub }
            return @{ Type = $(if ($Matches[3]) { 'Resource group' } else { 'Subscription' }); Group = ''; Subscription = $sub; Name = $(if ($Matches[3]) { "$name / $($Matches[3])" } else { $name }) }
        }
        @{ Type = 'Tenant'; Group = ''; Subscription = ''; Name = $ScopeId }
    }
    # The subscriptions an assignment applies to (its excluded scopes taken off).
    $reach = {
        param($Row)
        $where = & $scopeOf ([string](& $at $Row 'scope'))
        $excluded = @(@(& $list (& $at $Row 'notScopes')) | ForEach-Object { & $scopeOf ([string]$_) })
        @(foreach ($sub in $subscriptionNames.Keys) {
                $applies = if ($where.Group) { $subscriptionChain[$sub] -contains $where.Group } else { $where.Subscription -eq $sub }
                if (-not $applies) { continue }
                if (@($excluded | Where-Object { ($_.Type -eq 'Subscription' -and $_.Subscription -eq $sub) -or ($_.Group -and $subscriptionChain[$sub] -contains $_.Group) }).Count) { continue }
                $sub
            })
    }

    # --- Definitions, and what each assignment assigns ------------------------------------------------------------------
    $definitionOf = { param($Id) $key = & $lower $Id; if ($key -and $Definition.Contains($key)) { $Definition[$key] } else { $null } }
    $isSet = { param($Id) [string]$Id -match '(?i)/policySetDefinitions/' }
    $deprecated = { param($Row) (& $isTrue (& $at $Row 'metadata.deprecated')) -or [string](& $at $Row 'displayName') -match '^\s*\[Deprecated\]' }
    $preview = { param($Row) (& $isTrue (& $at $Row 'metadata.preview')) -or [string](& $at $Row 'displayName') -match '^\s*\[Preview\]' }
    $nameOf = { param($Id) $row = & $definitionOf $Id; if ($row -and (& $at $row 'displayName')) { [string](& $at $row 'displayName') } else { & $leaf $Id } }
    $effectOf = {
        # A definition's effect: the literal, or its effect parameter's value - the assignment's, else the default.
        param($Row, $Parameters)
        $effect = [string](& $at $Row 'rule.then.effect')
        if ($effect -match "^\[parameters\('([^']+)'\)\]$") {
            $name = $Matches[1]
            $value = & $at $Parameters "$name.value"
            if ($null -eq $value) { $value = & $at $Row "parameters.$name.defaultValue" }
            $effect = [string]$value
        }
        $effect
    }
    $rolesOf = { param($Row) @(@(& $list (& $at $Row 'rule.then.details.roleDefinitionIds')) | ForEach-Object { & $leaf $_ } | ForEach-Object { $_.ToLowerInvariant() }) }
    $roleNames = @{}
    foreach ($row in $RoleDefinition) { $roleNames[(& $lower (& $at $row 'name'))] = [string](& $at $row 'roleName') }
    $assignedDefinitions = [System.Collections.Generic.HashSet[string]]::new()
    $memberCount = @{}

    # --- Assignments in scope -----------------------------------------------------------------------------------------
    $inScope = @(foreach ($row in $Assignment) {
            $where = & $scopeOf ([string](& $at $row 'scope'))
            $subs = @(& $reach $row)
            $keep = if ($wantedSubscriptions.Count) { @($subs | Where-Object { $inScopeSubscriptions.Contains($_) }).Count -gt 0 }
            elseif ($scopeGroup) { ($where.Group -and $inScopeGroups.Contains($where.Group)) -or ($where.Subscription -and $inScopeSubscriptions.Contains($where.Subscription)) }
            else { $true }
            if ($keep) { @{ Row = $row; Where = $where; Subscriptions = @($subs | Where-Object { $inScopeSubscriptions.Contains($_) }) } }
        })
    $assignmentIds = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($entry in $inScope) { [void]$assignmentIds.Add((& $lower (& $at $entry.Row 'id'))) }
    $compliance = @{}
    foreach ($row in $ComplianceByAssignment) {
        $sub = & $lower (& $at $row 'subscriptionId')
        if (-not $inScopeSubscriptions.Contains($sub)) { continue }
        $key = & $lower (& $at $row 'assignmentId')
        if (-not $compliance.Contains($key)) { $compliance[$key] = @{ NonCompliant = 0L; Compliant = 0L; Conflict = 0L; Exempt = 0L } }
        foreach ($state in 'NonCompliant', 'Compliant', 'Conflict', 'Exempt') { $compliance[$key][$state] += [long](& $at $row ($state.Substring(0, 1).ToLowerInvariant() + $state.Substring(1))) }
    }
    $exemptionsOf = @{}
    foreach ($row in $Exemption) { $key = & $lower (& $at $row 'assignmentId'); $exemptionsOf[$key] = 1 + $(if ($exemptionsOf.Contains($key)) { $exemptionsOf[$key] } else { 0 }) }
    $principalRoles = @{}
    foreach ($row in $RoleAssignment) {
        $principal = & $lower (& $at $row 'principalId')
        if (-not $principalRoles.Contains($principal)) { $principalRoles[$principal] = [System.Collections.Generic.List[object]]::new() }
        $principalRoles[$principal].Add($row)
    }
    $principalOf = {
        param($Row)
        $identity = & $at $Row 'identity'
        $type = [string](& $at $identity 'type')
        if ($type -match 'SystemAssigned') { return @{ Type = 'System-assigned'; Principals = @(& $lower (& $at $identity 'principalId')) } }
        if ($type -match 'UserAssigned') { $users = & $at $identity 'userAssignedIdentities'; return @{ Type = 'User-assigned'; Principals = @(if ($users -is [System.Collections.IDictionary]) { foreach ($k in $users.Keys) { & $lower (& $at $users[$k] 'principalId') } }) } }
        @{ Type = ''; Principals = @() }
    }

    $assignmentRows = [System.Collections.Generic.List[object]]::new()
    $roleRows = [System.Collections.Generic.List[object]]::new()
    foreach ($entry in $inScope) {
        $row = $entry.Row
        $id = [string](& $at $row 'id'); $key = & $lower $id
        $definitionId = [string](& $at $row 'definitionId')
        $target = & $definitionOf $definitionId
        $set = & $isSet $definitionId
        [void]$assignedDefinitions.Add((& $lower $definitionId))
        $members = @(if ($set) { & $list (& $at $target 'members') })
        foreach ($member in $members) { [void]$assignedDefinitions.Add((& $lower (& $at $member 'policyDefinitionId'))) }
        $memberCount[(& $lower $definitionId)] = 1 + $(if ($memberCount.Contains((& $lower $definitionId))) { $memberCount[(& $lower $definitionId)] } else { 0 })
        $parameters = & $at $row 'parameters'
        $counts = if ($compliance.Contains($key)) { $compliance[$key] } else { @{ NonCompliant = 0L; Compliant = 0L; Conflict = 0L; Exempt = 0L } }
        $total = $counts.NonCompliant + $counts.Compliant + $counts.Conflict + $counts.Exempt
        $pct = & $percent $counts.Compliant $counts.Exempt $total
        $identity = & $principalOf $row
        # The roles the assignment's policies need to remediate (DeployIfNotExists, Modify).
        $needed = @(@(if ($set) { foreach ($member in $members) { & $rolesOf (& $definitionOf (& $at $member 'policyDefinitionId')) } } else { & $rolesOf $target }) | Sort-Object -Unique)
        $held = @(foreach ($principal in $identity.Principals) { if ($principalRoles.Contains($principal)) { $principalRoles[$principal] } })
        foreach ($role in $held) {
            $roleRows.Add((& $object 'AAC.PolicyRoleAssignment' ([ordered]@{
                            Assignment = [string]$(if (& $at $row 'displayName') { & $at $row 'displayName' } else { & $at $row 'name' }); Identity = $identity.Type; PrincipalId = [string](& $at $role 'principalId')
                            Role = $(if ($roleNames.Contains((& $leaf (& $at $role 'roleDefinitionId')))) { $roleNames[(& $leaf (& $at $role 'roleDefinitionId'))] } else { & $leaf (& $at $role 'roleDefinitionId') })
                            Scope = (& $scopeOf ([string](& $at $role 'scope'))).Name; ScopeId = [string](& $at $role 'scope'); Required = $(if ($needed -contains (& $leaf (& $at $role 'roleDefinitionId'))) { 'Yes' } else { 'No' }); AssignmentId = $id
                        })))
        }
        $heldRoles = @($held | ForEach-Object { & $leaf (& $at $_ 'roleDefinitionId') } | ForEach-Object { $_.ToLowerInvariant() })
        $missingRoles = @($needed | Where-Object { $heldRoles -notcontains $_ })
        $display = [string]$(if (& $at $row 'displayName') { & $at $row 'displayName' } else { & $at $row 'name' })
        $effect = if ($set) { [string](& $at $parameters 'effect.value') } else { & $effectOf $target $parameters }
        $assignmentRows.Add((& $object 'AAC.PolicyAssignmentReport' ([ordered]@{
                        Assignment = $display; Name = [string](& $at $row 'name'); Scope = $entry.Where.Name; ScopeType = $entry.Where.Type
                        Definition = & $nameOf $definitionId; Kind = $(if ($set) { 'Initiative' } else { 'Policy' }); PolicyType = [string](& $at $target 'policyType')
                        Policies = $(if ($set) { $members.Count } else { 1 }); Category = [string](& $at $target 'metadata.category')
                        Enforcement = $(if (& $at $row 'enforcement') { [string](& $at $row 'enforcement') } else { 'Default' }); Effect = $effect
                        Parameters = $(if ($parameters -is [System.Collections.IDictionary]) { $parameters.Count } else { 0 })
                        ExcludedScopes = @(& $list (& $at $row 'notScopes')).Count; Overrides = @(& $list (& $at $row 'overrides')).Count; ResourceSelectors = @(& $list (& $at $row 'resourceSelectors')).Count
                        NonComplianceMessage = $(if (@(& $list (& $at $row 'messages')).Count) { 'Yes' } else { 'No' })
                        Identity = $identity.Type; RolesNeeded = (@($needed | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', ')
                        RolesMissing = (@($missingRoles | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', ')
                        Exemptions = $(if ($exemptionsOf.Contains($key)) { $exemptionsOf[$key] } else { 0 }); Subscriptions = $entry.Subscriptions.Count
                        NonCompliant = $counts.NonCompliant; Compliant = $counts.Compliant; Conflict = $counts.Conflict; Exempt = $counts.Exempt; Resources = $total
                        CompliancePercent = $pct; Rating = & $rate $pct
                        AssignedBy = [string](& $at $row 'metadata.assignedBy'); DefinitionVersion = [string](& $at $row 'definitionVersion'); Description = [string](& $at $row 'description')
                        HiddenMetadata = & $hidden (& $at $row 'metadata'); ResourceId = $id; DefinitionId = $definitionId
                    })))

        # --- Assignment findings ---------------------------------------------------------------------------------------
        $scopeText = $entry.Where.Name
        if (-not $target) { & $finding 'Medium' 'Assignment' 'Definition not found' $display "The assignment's $(if ($set) { 'initiative' } else { 'definition' }) ($definitionId) couldn't be read: deleted, or defined at a scope you can't see." 'Delete the assignment if its definition is gone, or check access to the definition''s scope.' "$docs/concepts/assignment-structure" $scopeText $id }
        if (-not $set -and $target) { & $finding 'Low' 'Assignment' 'Policy definition assigned directly' $display "'$(& $nameOf $definitionId)' is assigned on its own, not through an initiative." 'Group related policies into initiatives and assign those: fewer assignments, one set of parameters, and controls you can map.' "$docs/concepts/initiative-definition-structure" $scopeText $id }
        if ([string](& $at $row 'enforcement') -eq 'DoNotEnforce') { & $finding 'Medium' 'Assignment' 'Not enforced (DoNotEnforce)' $display 'The assignment is evaluated but not enforced: Deny doesn''t block, and DeployIfNotExists and Modify don''t act on new resources.' 'Set enforcement to Default once the impact is understood, or document why it stays audit-only.' "$docs/concepts/assignment-structure#enforcement-mode" $scopeText $id }
        if ($needed.Count -and -not $identity.Principals.Count) { & $finding 'High' 'Identity' 'Remediation without a managed identity' $display "Its policies deploy or modify resources (they need $(@($needed | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', ')), but the assignment has no managed identity: nothing can be remediated." 'Give the assignment a system- or user-assigned managed identity with the roles its policies list.' "$docs/how-to/remediate-resources" $scopeText $id }
        elseif ($missingRoles.Count) { & $finding 'High' 'Identity' 'Managed identity missing roles' $display "The assignment's identity lacks $(@($missingRoles | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', '), which its policies need to remediate." 'Grant the identity those roles at the assignment''s scope (the portal does it when the assignment is created there).' "$docs/how-to/remediate-resources#configure-the-managed-identity" $scopeText $id }
        if ($null -ne $pct -and $pct -lt $ComplianceWarningPercent) { & $finding $(if ($pct -lt $ComplianceWarningPercent / 2) { 'High' } else { 'Medium' }) 'Compliance' 'Assignment below the compliance threshold' $display "$pct% compliant ($($counts.NonCompliant) non-compliant of $total resources), under $ComplianceWarningPercent%." 'Remediate the non-compliant resources (or exempt them on purpose), starting with the policies with the most non-compliant resources.' "$docs/how-to/get-compliance-data" $scopeText $id }
        if ($target) {
            $assignedDeprecated = @(@($target) + @($members | ForEach-Object { & $definitionOf (& $at $_ 'policyDefinitionId') }) | Where-Object { $_ -and (& $deprecated $_) })
            foreach ($item in $assignedDeprecated) { & $finding 'Medium' $(if (& $isSet (& $at $item 'id')) { 'Initiative' } else { 'Definition' }) 'Deprecated policy assigned' $display "'$(& $at $item 'displayName')' is deprecated and may stop being supported." 'Replace it with its up-to-date replacement, or plan to remove it.' "$docs/concepts/definition-structure-basics#common-metadata-properties" $scopeText $id }
            if (& $preview $target) { & $finding 'Low' $(if ($set) { 'Initiative' } else { 'Definition' }) 'Preview policy assigned' $display "'$(& $at $target 'displayName')' is in preview: it can change." 'Use preview policies with audit effects only, and review them when they reach general availability.' "$docs/concepts/definition-structure-basics#common-metadata-properties" $scopeText $id }
        }
        foreach ($notScope in @(& $list (& $at $row 'notScopes'))) {
            $excluded = & $scopeOf ([string]$notScope)
            $exists = if ($excluded.Group) { $groupNames.Contains($excluded.Group) } elseif ($excluded.Type -eq 'Subscription') { $subscriptionNames.Contains($excluded.Subscription) } else { $true }
            if (-not $exists) { & $finding 'Low' 'Assignment' 'Excluded scope that doesn''t exist' $display "It excludes $notScope, which can't be found." 'Remove the excluded scope (or check it is only out of your sight).' "$docs/concepts/assignment-structure#excluded-scopes" $scopeText $id }
        }
        if ([string](& $at $row 'enforcement') -ne 'DoNotEnforce' -and $effect -eq 'deny' -and -not @(& $list (& $at $row 'messages')).Count) { & $finding 'Low' 'Assignment' 'Deny without a non-compliance message' $display 'People who are blocked see only the policy''s name.' 'Add a non-compliance message that says why, and who to ask.' "$docs/concepts/assignment-structure#non-compliance-messages" $scopeText $id }
    }
    # The same definition assigned twice on one scope path.
    $scopeById = @{}
    foreach ($entry in $inScope) { $scopeById[(& $lower (& $at $entry.Row 'id'))] = [string](& $at $entry.Row 'scope') }
    foreach ($group in $assignmentRows | Group-Object { & $lower $_.DefinitionId } | Where-Object Count -GT 1) {
        $rows = @($group.Group)
        for ($i = 0; $i -lt $rows.Count; $i++) {
            for ($j = $i + 1; $j -lt $rows.Count; $j++) {
                $a = & $scopeOf $scopeById[(& $lower $rows[$i].ResourceId)]; $b = & $scopeOf $scopeById[(& $lower $rows[$j].ResourceId)]
                $nested = ($a.Group -and $b.Group -and ($a.Group -eq $b.Group -or (& $ancestorsOfGroup $a.Group) -contains $b.Group -or (& $ancestorsOfGroup $b.Group) -contains $a.Group)) -or
                ($a.Group -and $b.Subscription -and $subscriptionChain[$b.Subscription] -contains $a.Group) -or ($b.Group -and $a.Subscription -and $subscriptionChain[$a.Subscription] -contains $b.Group) -or
                ($a.Subscription -and $a.Subscription -eq $b.Subscription)
                if ($nested) { & $finding 'Low' 'Assignment' 'Assigned twice on the same scope path' "$($rows[$i].Assignment) / $($rows[$j].Assignment)" "'$($rows[$i].Definition)' is assigned at $($rows[$i].Scope) and at $($rows[$j].Scope): resources under both are evaluated twice." 'Keep one assignment (at the higher scope), with exclusions or overrides where the lower one differs.' "$docs/concepts/assignment-structure" $rows[$i].Scope $rows[$i].ResourceId }
            }
        }
    }

    # --- Compliance by subscription, assignment and policy ------------------------------------------------------------------
    $subscriptionRows = @(foreach ($sub in $inScopeSubscriptions) {
            $row = (@($ComplianceBySubscription | Where-Object { (& $lower (& $at $_ 'subscriptionId')) -eq $sub }) | Select-Object -First 1)
            $c = [long](& $at $row 'compliant'); $e = [long](& $at $row 'exempt'); $n = [long](& $at $row 'nonCompliant'); $x = [long](& $at $row 'conflict')
            $pct = & $percent $c $e ($c + $e + $n + $x)
            $tags = & $at ((@($Subscription | Where-Object { (& $lower (& $at $_ 'subscriptionId')) -eq $sub }) | Select-Object -First 1)) 'tags'
            $chain = @($subscriptionChain[$sub])
            [array]::Reverse($chain)
            & $object 'AAC.PolicySubscription' ([ordered]@{
                    Subscription = $subscriptionNames[$sub]; SubscriptionId = $sub; ManagementGroups = (@($chain | ForEach-Object { if ($groupNames.Contains($_)) { $groupNames[$_] } else { $_ } }) -join ' > ')
                    Assignments = @($inScope | Where-Object { $_.Subscriptions -contains $sub }).Count
                    Exemptions = @($Exemption | Where-Object { (& $lower (& $at $_ 'subscriptionId')) -eq $sub -or ([string](& $at $_ 'id')) -match "(?i)^/subscriptions/$sub/" }).Count
                    NonCompliant = $n; Compliant = $c; Conflict = $x; Exempt = $e; Resources = $c + $e + $n + $x; CompliancePercent = $pct; Rating = & $rate $pct
                    HiddenTags = & $hidden $tags; ResourceId = "/subscriptions/$sub"
                })
            if ($null -ne $pct -and $pct -lt $ComplianceWarningPercent) { & $finding 'Medium' 'Compliance' 'Subscription below the compliance threshold' $subscriptionNames[$sub] "$pct% of its resources are compliant ($n non-compliant), under $ComplianceWarningPercent%." 'Work through its non-compliant assignments and policies (the Policies table, by subscription).' "$docs/how-to/get-compliance-data" $subscriptionNames[$sub] "/subscriptions/$sub" }
        }) | Sort-Object -Property @{ Expression = { if ($null -eq $_.CompliancePercent) { 101 } else { $_.CompliancePercent } } }, Subscription
    $assignmentCompliance = @(foreach ($row in $ComplianceByAssignment) {
            $key = & $lower (& $at $row 'assignmentId'); $sub = & $lower (& $at $row 'subscriptionId')
            if (-not $assignmentIds.Contains($key) -or -not $inScopeSubscriptions.Contains($sub)) { continue }
            $report = (@($assignmentRows | Where-Object { (& $lower $_.ResourceId) -eq $key }) | Select-Object -First 1)
            $c = [long](& $at $row 'compliant'); $e = [long](& $at $row 'exempt'); $n = [long](& $at $row 'nonCompliant'); $x = [long](& $at $row 'conflict')
            $pct = & $percent $c $e ($c + $e + $n + $x)
            & $object 'AAC.PolicyAssignmentCompliance' ([ordered]@{ Assignment = $report.Assignment; Subscription = $subscriptionNames[$sub]; NonCompliant = $n; Compliant = $c; Conflict = $x; Exempt = $e; Resources = $c + $e + $n + $x; CompliancePercent = $pct; Rating = & $rate $pct; AssignmentId = $report.ResourceId })
        }) | Sort-Object -Property @{ Expression = 'NonCompliant'; Descending = $true }
    $policyGroups = @{}
    foreach ($row in $ComplianceByPolicy) {
        $key = & $lower (& $at $row 'assignmentId'); $sub = & $lower (& $at $row 'subscriptionId')
        if (-not $assignmentIds.Contains($key) -or -not $inScopeSubscriptions.Contains($sub)) { continue }
        $groupKey = "$key|$(& $at $row 'referenceId')|$(& $lower (& $at $row 'definitionId'))"
        if (-not $policyGroups.Contains($groupKey)) { $policyGroups[$groupKey] = @{ Row = $row; NonCompliant = 0L; Compliant = 0L; Conflict = 0L; Exempt = 0L; Subscriptions = [System.Collections.Generic.HashSet[string]]::new() } }
        foreach ($state in 'NonCompliant', 'Compliant', 'Conflict', 'Exempt') { $policyGroups[$groupKey][$state] += [long](& $at $row ($state.Substring(0, 1).ToLowerInvariant() + $state.Substring(1))) }
        [void]$policyGroups[$groupKey].Subscriptions.Add($sub)
    }
    $policyRows = @(foreach ($groupKey in $policyGroups.Keys) {
            $entry = $policyGroups[$groupKey]; $row = $entry.Row
            $report = (@($assignmentRows | Where-Object { (& $lower $_.ResourceId) -eq (& $lower (& $at $row 'assignmentId')) }) | Select-Object -First 1)
            $policyDefinition = & $definitionOf (& $at $row 'definitionId')
            $member = (@(if ($report.Kind -eq 'Initiative') { @(& $list (& $at (& $definitionOf $report.DefinitionId) 'members')) | Where-Object { [string](& $at $_ 'policyDefinitionReferenceId') -eq [string](& $at $row 'referenceId') } }) | Select-Object -First 1)
            $total = $entry.NonCompliant + $entry.Compliant + $entry.Conflict + $entry.Exempt
            $pct = & $percent $entry.Compliant $entry.Exempt $total
            & $object 'AAC.PolicyAssessmentPolicy' ([ordered]@{
                    Assignment = $report.Assignment; Policy = & $nameOf (& $at $row 'definitionId'); ReferenceId = [string](& $at $row 'referenceId'); Effect = [string](& $at $row 'effect')
                    Category = [string](& $at $policyDefinition 'metadata.category'); Groups = (@(& $list (& $at $member 'groupNames')) -join ', ')
                    NonCompliant = $entry.NonCompliant; Compliant = $entry.Compliant; Conflict = $entry.Conflict; Exempt = $entry.Exempt; Resources = $total
                    CompliancePercent = $pct; Rating = & $rate $pct; Subscriptions = $entry.Subscriptions.Count; PolicyType = [string](& $at $policyDefinition 'policyType')
                    AssignmentId = $report.ResourceId; DefinitionId = [string](& $at $row 'definitionId')
                })
        }) | Sort-Object -Property @{ Expression = 'NonCompliant'; Descending = $true }, Assignment, Policy
    $categoryRows = @(foreach ($group in $policyRows | Group-Object { if ($_.Category) { $_.Category } else { '(no category)' } }) {
            $n = [long](($group.Group | Measure-Object NonCompliant -Sum).Sum); $c = [long](($group.Group | Measure-Object Compliant -Sum).Sum); $e = [long](($group.Group | Measure-Object Exempt -Sum).Sum); $x = [long](($group.Group | Measure-Object Conflict -Sum).Sum)
            $pct = & $percent $c $e ($n + $c + $e + $x)
            & $object 'AAC.PolicyCategory' ([ordered]@{ Category = $group.Name; Policies = @($group.Group | ForEach-Object DefinitionId | Sort-Object -Unique).Count; Assignments = @($group.Group | ForEach-Object AssignmentId | Sort-Object -Unique).Count; NonCompliant = $n; Compliant = $c; Conflict = $x; Exempt = $e; CompliancePercent = $pct; Rating = & $rate $pct })
        }) | Sort-Object -Property @{ Expression = { if ($null -eq $_.CompliancePercent) { 101 } else { $_.CompliancePercent } } }, Category

    # --- Exemptions -----------------------------------------------------------------------------------------------------
    $allAssignmentIds = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($row in $Assignment) { [void]$allAssignmentIds.Add((& $lower (& $at $row 'id'))) }
    $exemptionRows = @(foreach ($row in $Exemption) {
            $id = [string](& $at $row 'id')
            $scopeId = $id -replace '(?i)/providers/Microsoft\.Authorization/policyExemptions/.*$', ''
            $where = & $scopeOf $scopeId
            $assignmentKey = & $lower (& $at $row 'assignmentId')
            $inScopeExemption = if ($wantedSubscriptions.Count -or $scopeGroup) { $assignmentIds.Contains($assignmentKey) -and (($where.Subscription -and $inScopeSubscriptions.Contains($where.Subscription)) -or ($where.Group -and $inScopeGroups.Contains($where.Group))) } else { $true }
            if (-not $inScopeExemption) { continue }
            $expires = [string](& $at $row 'expiresOn')
            $date = [datetime]::MinValue
            $expiry = if ($expires -and [datetime]::TryParse($expires, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$date)) { $date } else { $null }
            $days = if ($expiry) { [int][Math]::Floor(($expiry - $Now.ToUniversalTime()).TotalDays) } else { $null }
            $status = if (-not $expiry) { 'No expiry' } elseif ($days -lt 0) { 'Expired' } elseif ($days -le $ExemptionWarningDays) { 'Expiring' } else { 'Active' }
            $display = [string]$(if (& $at $row 'displayName') { & $at $row 'displayName' } else { & $at $row 'name' })
            $assignmentName = (@($Assignment | Where-Object { (& $lower (& $at $_ 'id')) -eq $assignmentKey } | ForEach-Object { [string]$(if (& $at $_ 'displayName') { & $at $_ 'displayName' } else { & $at $_ 'name' }) }) | Select-Object -First 1)
            & $object 'AAC.PolicyExemptionReport' ([ordered]@{
                    Exemption = $display; Assignment = $(if ($assignmentName) { $assignmentName } else { & $leaf $assignmentKey }); Category = [string](& $at $row 'category'); Scope = $where.Name; ScopeType = $where.Type
                    Policies = $(if (@(& $list (& $at $row 'referenceIds')).Count) { (@(& $list (& $at $row 'referenceIds')) -join ', ') } else { 'All' }); ExpiresOn = $(if ($expiry) { $expiry.ToString('yyyy-MM-dd') } else { '' }); DaysLeft = $days; Status = $status
                    Description = [string](& $at $row 'description'); HiddenMetadata = & $hidden (& $at $row 'metadata'); ResourceId = $id
                })
            switch ($status) {
                'Expired' { & $finding 'Medium' 'Exemption' 'Exemption expired' $display "Expired $(-$days) day(s) ago ($($expiry.ToString('yyyy-MM-dd'))): the resources are evaluated again, so they may show as non-compliant." 'Remove the exemption, or renew it with a new expiry if it is still needed.' "$docs/concepts/exemption-structure#expiration" $where.Name $id }
                'Expiring' { & $finding 'Low' 'Exemption' 'Exemption expiring soon' $display "Expires in $days day(s) ($($expiry.ToString('yyyy-MM-dd')))." 'Fix the resources before then, or agree a renewal with the owner.' "$docs/concepts/exemption-structure#expiration" $where.Name $id }
                'No expiry' { & $finding 'Low' 'Exemption' 'Exemption with no expiry' $display "A$(if ([string](& $at $row 'category') -eq 'Waiver') { ' waiver' } else { 'n exemption' }) that never expires is easily forgotten." 'Give every exemption an expiry date, and review it then.' "$docs/concepts/exemption-structure#expiration" $where.Name $id }
            }
            if (-not $allAssignmentIds.Contains($assignmentKey)) { & $finding 'Low' 'Exemption' 'Exemption for an assignment that doesn''t exist' $display "Its assignment ($assignmentKey) is gone." 'Delete the exemption.' "$docs/concepts/exemption-structure" $where.Name $id }
        }) | Sort-Object -Property @{ Expression = { @{ Expired = 0; Expiring = 1; 'No expiry' = 2; Active = 3 }[$_.Status] } }, ExpiresOn

    # --- Initiatives and definitions -----------------------------------------------------------------------------------
    $definitionScope = { param([string] $Id) if ($Id -match '(?i)^/providers/Microsoft\.Authorization/') { 'Built-in' } else { (& $scopeOf ($Id -replace '(?i)/providers/Microsoft\.Authorization/policy(Set)?Definitions/.*$', '')).Name } }
    $definedInScope = {
        param([string] $Id)
        if ($Id -match '(?i)^/providers/Microsoft\.Authorization/') { return $false }
        $where = & $scopeOf ($Id -replace '(?i)/providers/Microsoft\.Authorization/policy(Set)?Definitions/.*$', '')
        if (-not ($wantedSubscriptions.Count -or $scopeGroup)) { return $true }
        ($where.Group -and $inScopeGroups.Contains($where.Group)) -or ($where.Subscription -and $inScopeSubscriptions.Contains($where.Subscription))
    }
    $usedByInitiative = @{}
    foreach ($key in $Definition.Keys) {
        if (-not (& $isSet $key)) { continue }
        foreach ($member in @(& $list (& $at $Definition[$key] 'members'))) { $m = & $lower (& $at $member 'policyDefinitionId'); $usedByInitiative[$m] = 1 + $(if ($usedByInitiative.Contains($m)) { $usedByInitiative[$m] } else { 0 }) }
    }
    $initiativeRows = [System.Collections.Generic.List[object]]::new()
    $definitionRows = [System.Collections.Generic.List[object]]::new()
    $groupMetadata = @{}
    foreach ($key in $Definition.Keys) {
        $row = $Definition[$key]
        $id = [string](& $at $row 'id')
        $assigned = $assignedDefinitions.Contains($key)
        $custom = [string](& $at $row 'policyType') -eq 'Custom'
        if (-not $assigned -and -not ($custom -and $platform -and (& $definedInScope $id))) { continue }
        $display = [string](& $at $row 'displayName')
        if (& $isSet $key) {
            $members = @(& $list (& $at $row 'members'))
            $groups = @(& $list (& $at $row 'groups'))
            $usedGroups = @($members | ForEach-Object { @(& $list (& $at $_ 'groupNames')) } | Sort-Object -Unique)
            $unused = @($groups | Where-Object { $usedGroups -notcontains [string](& $at $_ 'name') } | ForEach-Object { [string](& $at $_ 'name') })
            $initiativeRows.Add((& $object 'AAC.PolicyInitiative' ([ordered]@{
                            Initiative = $display; PolicyType = [string](& $at $row 'policyType'); Category = [string](& $at $row 'metadata.category'); Version = [string]$(if (& $at $row 'version') { & $at $row 'version' } else { & $at $row 'metadata.version' })
                            Policies = $members.Count; Groups = $groups.Count; Assignments = $(if ($memberCount.Contains($key)) { $memberCount[$key] } else { 0 }); Assigned = $(if ($assigned) { 'Yes' } else { 'No' })
                            Deprecated = $(if (& $deprecated $row) { 'Yes' } else { 'No' }); Preview = $(if (& $preview $row) { 'Yes' } else { 'No' }); DefinedAt = & $definitionScope $id
                            Description = [string](& $at $row 'description'); HiddenMetadata = & $hidden (& $at $row 'metadata'); ResourceId = $id
                        })))
            if ($custom -and $platform) {
                if (-not $assigned) { & $finding 'Low' 'Initiative' 'Unassigned custom initiative' $display 'Not assigned anywhere in scope: perhaps a test, or no longer needed.' 'Assign it, or delete it if it isn''t needed.' "$docs/concepts/initiative-definition-structure" (& $definitionScope $id) $id }
                if ($unused.Count) { & $finding 'Low' 'Initiative' 'Unused policy definition groups' $display "Groups no policy uses: $($unused -join ', ')." 'Remove them from the initiative, or map its policies to them.' "$docs/concepts/initiative-definition-structure#policy-definition-groups" (& $definitionScope $id) $id }
                if (-not (& $at $row 'metadata.category')) { & $finding 'Info' 'Initiative' 'No category in the metadata' $display 'Without metadata.category, compliance can''t be read by category.' 'Set metadata.category (Azure''s common metadata properties).' "$docs/concepts/definition-structure-basics#common-metadata-properties" (& $definitionScope $id) $id }
                foreach ($group in $groups) {
                    $metadataId = & $lower (& $at $group 'additionalMetadataId')
                    if (-not $metadataId) { continue }
                    if (-not $groupMetadata.Contains($metadataId)) { $groupMetadata[$metadataId] = [System.Collections.Generic.List[object]]::new() }
                    $groupMetadata[$metadataId].Add(@{ Name = [string](& $at $group 'name'); Initiative = $display; Id = $id })
                }
            }
        }
        else {
            $effect = & $effectOf $row $null
            $definitionRows.Add((& $object 'AAC.PolicyDefinitionReport' ([ordered]@{
                            Definition = $display; PolicyType = [string](& $at $row 'policyType'); Mode = [string](& $at $row 'mode'); Category = [string](& $at $row 'metadata.category')
                            Version = [string]$(if (& $at $row 'version') { & $at $row 'version' } else { & $at $row 'metadata.version' }); Effect = $effect
                            AllowedEffects = (@(& $list (& $at $row 'parameters.effect.allowedValues')) -join ', '); RolesNeeded = (@(& $rolesOf $row | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', ')
                            Initiatives = $(if ($usedByInitiative.Contains($key)) { $usedByInitiative[$key] } else { 0 }); DirectAssignments = $(if ($memberCount.Contains($key)) { $memberCount[$key] } else { 0 }); Assigned = $(if ($assigned) { 'Yes' } else { 'No' })
                            Deprecated = $(if (& $deprecated $row) { 'Yes' } else { 'No' }); Preview = $(if (& $preview $row) { 'Yes' } else { 'No' }); DefinedAt = & $definitionScope $id
                            Description = [string](& $at $row 'description'); HiddenMetadata = & $hidden (& $at $row 'metadata'); ResourceId = $id
                        })))
            if ($custom -and $platform) {
                if (-not $assigned -and -not $usedByInitiative.Contains($key)) { & $finding 'Low' 'Definition' 'Unassigned custom policy definition' $display 'Neither assigned nor in an initiative: perhaps a test, or no longer needed.' 'Assign it (in an initiative), or delete it if it isn''t needed.' "$docs/concepts/definition-structure-basics" (& $definitionScope $id) $id }
                if (-not (& $at $row 'metadata.category')) { & $finding 'Info' 'Definition' 'No category in the metadata' $display 'Without metadata.category, compliance can''t be read by category.' 'Set metadata.category (Azure''s common metadata properties).' "$docs/concepts/definition-structure-basics#common-metadata-properties" (& $definitionScope $id) $id }
            }
        }
    }
    foreach ($metadataId in $groupMetadata.Keys) {
        $names = @($groupMetadata[$metadataId] | ForEach-Object Name | Sort-Object -Unique)
        if ($names.Count -gt 1) { & $finding 'Low' 'Initiative' 'Same control, different group names' (& $leaf $metadataId) "Policy definition groups for $(& $leaf $metadataId) are named $($names -join ', ') in $(@($groupMetadata[$metadataId] | ForEach-Object Initiative | Sort-Object -Unique) -join ', ')." 'Name the groups for a control the same in every initiative.' "$docs/concepts/initiative-definition-structure#policy-definition-groups" '' $metadataId }
    }

    # --- Management groups (the hierarchy) and the tenant tree -----------------------------------------------------------------
    $subscriptionRate = @{}
    foreach ($row in $subscriptionRows) { $subscriptionRate[$row.SubscriptionId] = $row }
    $groupRows = @(foreach ($key in $inScopeGroups) {
            $under = @($inScopeSubscriptions | Where-Object { $subscriptionChain[$_] -contains $key })
            $n = [long](($under | ForEach-Object { $subscriptionRate[$_].NonCompliant } | Measure-Object -Sum).Sum); $c = [long](($under | ForEach-Object { $subscriptionRate[$_].Compliant } | Measure-Object -Sum).Sum)
            $e = [long](($under | ForEach-Object { $subscriptionRate[$_].Exempt } | Measure-Object -Sum).Sum); $x = [long](($under | ForEach-Object { $subscriptionRate[$_].Conflict } | Measure-Object -Sum).Sum)
            $pct = & $percent $c $e ($n + $c + $e + $x)
            & $object 'AAC.PolicyManagementGroup' ([ordered]@{
                    ManagementGroup = $groupNames[$key]; Name = $key; Parent = $(if ($groupNames.Contains($groupParent[$key])) { $groupNames[$groupParent[$key]] } else { '' }); Depth = @(& $ancestorsOfGroup $key).Count
                    Assignments = @($inScope | Where-Object { $_.Where.Group -eq $key }).Count; Subscriptions = $under.Count
                    NonCompliant = $n; Compliant = $c; Conflict = $x; Exempt = $e; CompliancePercent = $pct; Rating = & $rate $pct
                    ResourceId = "/providers/Microsoft.Management/managementGroups/$key"
                })
        }) | Sort-Object Depth, ManagementGroup
    $node = {
        param([string] $Group, [int] $Depth)
        $row = (@($groupRows | Where-Object Name -EQ $Group) | Select-Object -First 1)
        @{
            l = 'm'; n = $groupNames[$Group]; d = $Group; p = $(if ($null -ne $row.CompliancePercent) { [int]$row.CompliancePercent }); c = @(, @($row.Assignments, 'assignments'))
            f = @{ table = 'policy-assignments'; filters = @{ Scope = $groupNames[$Group] } }
            k = @(
                if ($Depth -lt 12) { foreach ($child in @($groupRows | Where-Object { $groupParent[$_.Name] -eq $Group } | Sort-Object ManagementGroup)) { & $node $child.Name ($Depth + 1) } }
                foreach ($sub in @($subscriptionRows | Where-Object { (@($subscriptionChain[$_.SubscriptionId]) | Select-Object -First 1) -eq $Group } | Sort-Object Subscription)) {
                    @{ l = 's'; n = $sub.Subscription; d = $sub.SubscriptionId; p = $(if ($null -ne $sub.CompliancePercent) { [int]$sub.CompliancePercent }); c = @(@($sub.Assignments, 'assignments'), @($sub.NonCompliant, 'non-compliant')); f = @{ table = 'policy-subscriptions'; filters = @{ Subscription = $sub.Subscription } } }
                }
            )
        }
    }
    $roots = @($groupRows | Where-Object { -not $inScopeGroups.Contains($groupParent[$_.Name]) })
    $overallN = [long](($subscriptionRows | Measure-Object NonCompliant -Sum).Sum); $overallC = [long](($subscriptionRows | Measure-Object Compliant -Sum).Sum)
    $overallE = [long](($subscriptionRows | Measure-Object Exempt -Sum).Sum); $overallX = [long](($subscriptionRows | Measure-Object Conflict -Sum).Sum)
    $overall = & $percent $overallC $overallE ($overallN + $overallC + $overallE + $overallX)
    $tree = @{ l = 't'; n = 'Azure Policy'; p = $(if ($null -ne $overall) { [int]$overall }); c = @(@($assignmentRows.Count, 'assignments'), @($subscriptionRows.Count, 'subscriptions')); k = @(
            foreach ($root in $roots) { & $node $root.Name 0 }
            # Subscriptions whose management group isn't in sight.
            foreach ($sub in @($subscriptionRows | Where-Object { -not $inScopeGroups.Contains([string](@($subscriptionChain[$_.SubscriptionId]) | Select-Object -First 1)) } | Sort-Object Subscription)) {
                @{ l = 's'; n = $sub.Subscription; d = $sub.SubscriptionId; p = $(if ($null -ne $sub.CompliancePercent) { [int]$sub.CompliancePercent }); c = @(@($sub.Assignments, 'assignments'), @($sub.NonCompliant, 'non-compliant')); f = @{ table = 'policy-subscriptions'; filters = @{ Subscription = $sub.Subscription } } }
            }
        )
    }

    $rank = @{ High = 0; Medium = 1; Low = 2; Info = 3 }
    $sorted = @($findings | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, Area, Finding, Item)
    @{
        Assignments          = @($assignmentRows | Sort-Object -Property @{ Expression = { if ($null -eq $_.CompliancePercent) { 101 } else { $_.CompliancePercent } } }, Assignment)
        AssignmentCompliance = @($assignmentCompliance)
        Policies             = @($policyRows)
        Categories           = @($categoryRows)
        Subscriptions        = @($subscriptionRows)
        ManagementGroups     = @($groupRows)
        Initiatives          = @($initiativeRows | Sort-Object Assigned, Initiative -Descending)
        Definitions          = @($definitionRows | Sort-Object Assigned, Definition -Descending)
        Exemptions           = @($exemptionRows)
        Roles                = $roleRows.ToArray()
        Findings             = $sorted
        Tree                 = $tree
        Audience             = $Audience
        Stats                = [ordered]@{
            Assignments          = $assignmentRows.Count
            Initiatives          = @($initiativeRows | Where-Object Assigned -EQ 'Yes').Count
            Definitions          = @($definitionRows | Where-Object Assigned -EQ 'Yes').Count
            Subscriptions        = @($subscriptionRows).Count
            ManagementGroups     = @($groupRows).Count
            Exemptions           = @($exemptionRows).Count
            ExpiringExemptions   = @($exemptionRows | Where-Object { $_.Status -in 'Expired', 'Expiring' }).Count
            CompliancePercent    = $overall
            Rating               = & $rate $overall
            NonCompliant         = $overallN
            Resources            = $overallN + $overallC + $overallE + $overallX
            NotEnforced          = @($assignmentRows | Where-Object Enforcement -EQ 'DoNotEnforce').Count
            High                 = @($sorted | Where-Object Severity -EQ 'High').Count
            Medium               = @($sorted | Where-Object Severity -EQ 'Medium').Count
            Low                  = @($sorted | Where-Object Severity -EQ 'Low').Count
            Info                 = @($sorted | Where-Object Severity -EQ 'Info').Count
        }
    }
}