Private/ConvertTo-AACAssignedPolicy.ps1

function ConvertTo-AACAssignedPolicy {
    <#
    .SYNOPSIS
        Flattens Azure Policy assignments to one row per assignment and
        parameter - the default, assigned and effective value, and the
        resource types the policy applies to - for Get-AACAssignedPolicy.
    .DESCRIPTION
        -Assignment are Resource Graph rows of
        microsoft.authorization/policyassignments (id, name, displayName,
        scope, definitionId, parameters, enforcement, notScopes,
        description). -Definition maps a definition's or initiative's
        lower-case ID to @{ Id; Name; DisplayName; Kind ('Policy' or
        'PolicySet'); PolicyType; Category; Parameters; Rule; Members }.
 
        Which assignments: all of them, or - with -SubscriptionId or
        -ManagementGroupId - those that apply there: at the scope or below
        it, and those inherited from the management groups above it
        (Inherited = $true), as the portal lists a scope's assignments.
        -SubscriptionChain and -GroupChain give each subscription's and
        management group's ancestors, root first. -AssignmentName keeps the
        assignments whose name or display name matches (wildcards).
 
        Each parameter of the definition (or initiative) is one row:
        DefaultValue (the definition's), AssignedValue (the assignment's),
        EffectiveValue (assigned, else the default) and ValueSource
        (Assigned, Default, Not set); arrays are joined with ', ', objects
        written as compact JSON. A definition without parameters is one row
        with no parameter, so every assignment is listed.
 
        ResourceType: the types the policy's rule targets
        (Get-AACPolicyResourceType), with [parameters()] resolved to the
        effective values - so "Not allowed resource types" shows the types
        it denies. For an initiative, the types of the member policies that use
        the row's parameter (each member's parameters resolved through the
        initiative's), or of every member for a row with no parameter -
        'All' when one of them names no type.
 
        Returns @{ Rows (AAC.AssignedPolicy); Assignments
        (AAC.PolicyAssignmentSummary); Missing (definition IDs not found);
        Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Assignment,

        [System.Collections.IDictionary] $Definition = @{},

        [System.Collections.IDictionary] $SubscriptionName = @{},

        [System.Collections.IDictionary] $ManagementGroupName = @{},

        # Subscription ID (lower case) -> management group names, root first.
        [System.Collections.IDictionary] $SubscriptionChain = @{},

        # Management group name (lower case) -> its ancestors and itself, root first.
        [System.Collections.IDictionary] $GroupChain = @{},

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

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

        [string[]] $AssignmentName = @()
    )

    $get = {
        param($Map, [string] $Name)
        if ($Map -isnot [System.Collections.IDictionary]) { return }
        foreach ($k in $Map.Keys) { if ($k -eq $Name) { return $Map[$k] } }
    }
    $has = {
        param($Map, [string] $Name)
        if ($Map -isnot [System.Collections.IDictionary]) { return $false }
        foreach ($k in $Map.Keys) { if ($k -eq $Name) { return $true } }
        $false
    }
    $format = $null
    $format = {
        param($Value)
        if ($null -eq $Value) { return '' }
        if ($Value -is [string]) { return $Value }
        if ($Value -is [bool]) { return $Value.ToString().ToLowerInvariant() }
        if ($Value -is [System.Collections.IDictionary]) { return (ConvertTo-Json -InputObject $Value -Compress -Depth 20) }
        if ($Value -is [System.Collections.IList]) {
            return (@(foreach ($item in $Value) { if ($item -is [System.Collections.IDictionary] -or $item -is [System.Collections.IList]) { ConvertTo-Json -InputObject $item -Compress -Depth 20 } else { & $format $item } }) -join ', ')
        }
        [string]::Format([cultureinfo]::InvariantCulture, '{0}', $Value)
    }

    # --- Where each scope sits: root management group first -------------------------------------
    $pathOf = {
        param([string] $Scope)
        $scope = $Scope.ToLowerInvariant().TrimEnd('/')
        if ($scope -match '^/providers/microsoft\.management/managementgroups/([^/]+)$') {
            $mg = $Matches[1]
            $chain = if ($GroupChain.Contains($mg)) { @($GroupChain[$mg]) } else { @($mg) }
            return @($chain | ForEach-Object { "mg:$($_.ToLowerInvariant())" })
        }
        if ($scope -match '^/subscriptions/([^/]+)(/resourcegroups/([^/]+))?(/.+)?$') {
            $sub = $Matches[1]; $group = $Matches[3]; $rest = $Matches[4]
            $path = [System.Collections.Generic.List[string]]::new()
            if ($SubscriptionChain.Contains($sub)) { foreach ($mg in @($SubscriptionChain[$sub])) { $path.Add("mg:$(([string]$mg).ToLowerInvariant())") } }
            $path.Add("sub:$sub")
            if ($group) { $path.Add("rg:$sub/$group") }
            if ($rest) { $path.Add("res:$scope") }
            return $path.ToArray()
        }
        @($scope)
    }
    $startsWith = {
        param([string[]] $Short, [string[]] $Long)
        if ($Short.Count -gt $Long.Count) { return $false }
        for ($i = 0; $i -lt $Short.Count; $i++) { if ($Short[$i] -ne $Long[$i]) { return $false } }
        $true
    }
    $targets = @(
        foreach ($id in $SubscriptionId) { , (& $pathOf "/subscriptions/$id") }
        foreach ($id in $ManagementGroupId) { , (& $pathOf "/providers/Microsoft.Management/managementGroups/$id") }
    )
    $subscriptionLabel = { param([string] $Id) if ($SubscriptionName.Contains($Id.ToLowerInvariant())) { $SubscriptionName[$Id.ToLowerInvariant()] } else { $Id } }
    $scopeInfo = {
        param([string] $Scope)
        if ($Scope -match '(?i)/managementGroups/([^/]+)$') {
            $mg = $Matches[1]
            return @{ Type = 'Management group'; Name = $(if ($ManagementGroupName.Contains($mg.ToLowerInvariant())) { $ManagementGroupName[$mg.ToLowerInvariant()] } else { $mg }) }
        }
        if ($Scope -match '(?i)^/subscriptions/([^/]+)/resourceGroups/([^/]+)$') { return @{ Type = 'Resource group'; Name = "$($Matches[2]) ($(& $subscriptionLabel $Matches[1]))" } }
        if ($Scope -match '(?i)^/subscriptions/([^/]+)$') { return @{ Type = 'Subscription'; Name = (& $subscriptionLabel $Matches[1]) } }
        if ($Scope -match '(?i)^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/.+/([^/]+)$') { return @{ Type = 'Resource'; Name = "$($Matches[3]) ($($Matches[2]))" } }
        @{ Type = 'Other'; Name = $Scope }
    }

    # --- One definition's parameters, effective values and resource types --------------------------
    $effectiveOf = {
        param($Parameters, $Assigned)
        $values = [ordered]@{}
        if ($Parameters -is [System.Collections.IDictionary]) {
            foreach ($name in $Parameters.Keys) {
                $spec = $Parameters[$name]
                if (& $has $Assigned $name) { $values[$name] = & $get (& $get $Assigned $name) 'value' }
                elseif (& $has $spec 'defaultValue') { $values[$name] = & $get $spec 'defaultValue' }
                else { $values[$name] = $null }
            }
        }
        $values
    }
    $specific = { param($Types) if ($Types.Include.Count) { $Types.Include } else { $Types.Aliased } }

    $rows = [System.Collections.Generic.List[object]]::new()
    $summaries = [System.Collections.Generic.List[object]]::new()
    $missing = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    $typeCounts = @{}

    foreach ($a in $Assignment) {
        $name = [string](& $get $a 'name')
        $displayName = [string](& $get $a 'displayName')
        if ($AssignmentName.Count -and -not @($AssignmentName | Where-Object { $name -like $_ -or $displayName -like $_ }).Count) { continue }
        $scope = [string](& $get $a 'scope')
        $inherited = $false
        if ($targets.Count) {
            $path = & $pathOf $scope
            $below = $false; $above = $false
            foreach ($target in $targets) {
                if (& $startsWith $target $path) { $below = $true }
                elseif (& $startsWith $path $target) { $above = $true }
            }
            if (-not ($below -or $above)) { continue }
            $inherited = $above -and -not $below
        }

        $definitionId = [string](& $get $a 'definitionId')
        $policy = $Definition[$definitionId.ToLowerInvariant()]
        $assigned = & $get $a 'parameters'
        $info = & $scopeInfo $scope
        $kind = if ($policy) { $policy.Kind } elseif ($definitionId -match '(?i)/policySetDefinitions/') { 'PolicySet' } else { 'Policy' }
        if (-not $policy) { $null = $missing.Add($definitionId) }
        $parameters = if ($policy) { $policy.Parameters } else { $null }
        $effective = & $effectiveOf $parameters $assigned

        # Resource types: per parameter for an initiative (its members that use it).
        $rowTypes = @{}
        $allTypes = [System.Collections.Generic.SortedSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        $memberCount = 0
        if ($policy -and $kind -eq 'PolicySet') {
            $byParameter = @{}
            $unrestricted = [System.Collections.Generic.List[string]]::new()
            foreach ($member in @($policy.Members)) {
                if ($member -isnot [System.Collections.IDictionary]) { continue }
                $memberCount++
                $memberDefinition = $Definition[([string](& $get $member 'policyDefinitionId')).ToLowerInvariant()]
                $memberValues = @{}
                $memberRefs = & $get $member 'parameters'
                $uses = [System.Collections.Generic.List[string]]::new()
                if ($memberDefinition -and $memberDefinition.Parameters -is [System.Collections.IDictionary]) {
                    foreach ($pn in $memberDefinition.Parameters.Keys) {
                        if (& $has $memberRefs $pn) {
                            $v = & $get (& $get $memberRefs $pn) 'value'
                            if ($v -is [string] -and $v -match "^\[parameters\('([^']+)'\)\]$") {
                                $setName = $Matches[1]
                                $uses.Add($setName)
                                $memberValues[$pn] = & $get $effective $setName
                            }
                            else { $memberValues[$pn] = $v }
                        }
                        elseif (& $has $memberDefinition.Parameters[$pn] 'defaultValue') { $memberValues[$pn] = & $get $memberDefinition.Parameters[$pn] 'defaultValue' }
                    }
                }
                if (-not $memberDefinition) { continue }
                $found = Get-AACPolicyResourceType -Rule $memberDefinition.Rule -Parameter $memberValues
                $types = @(& $specific $found)
                # A member that names no type ('All', 'All except ...') applies
                # to every type: whatever uses its parameter does too.
                if (-not $types.Count) { $unrestricted.Add($found.Text) }
                foreach ($t in $types) { $null = $allTypes.Add($t) }
                foreach ($setName in $uses) {
                    $key = $setName.ToLowerInvariant()
                    if (-not $byParameter.ContainsKey($key)) { $byParameter[$key] = @{ Types = [System.Collections.Generic.SortedSet[string]]::new([StringComparer]::OrdinalIgnoreCase); Open = [System.Collections.Generic.List[string]]::new() } }
                    foreach ($t in $types) { $null = $byParameter[$key].Types.Add($t) }
                    if (-not $types.Count) { $byParameter[$key].Open.Add($found.Text) }
                }
            }
            $openText = { param($Open, $Types) $distinct = @($Open | Select-Object -Unique); if (-not $Types.Count -and $distinct.Count -eq 1) { $distinct[0] } else { 'All' } }
            foreach ($key in $byParameter.Keys) {
                $entry = $byParameter[$key]
                $rowTypes[$key] = if ($entry.Open.Count) { & $openText $entry.Open $entry.Types } elseif ($entry.Types.Count) { @($entry.Types) -join ', ' } else { 'All' }
            }
            $assignmentTypes = if ($unrestricted.Count) { & $openText $unrestricted $allTypes } elseif ($allTypes.Count) { @($allTypes) -join ', ' } else { 'All' }
        }
        elseif ($policy) {
            $types = Get-AACPolicyResourceType -Rule $policy.Rule -Parameter $effective
            foreach ($t in @(& $specific $types)) { $null = $allTypes.Add($t) }
            $assignmentTypes = $types.Text
        }
        else { $assignmentTypes = '' }
        foreach ($t in $allTypes) { $typeCounts[$t] = 1 + [int]$typeCounts[$t] }

        $base = [ordered]@{
            PSTypeName            = 'AAC.AssignedPolicy'
            AssignmentName        = $name
            AssignmentDisplayName = $displayName
            ScopeType             = $info.Type
            ScopeName             = $info.Name
            Inherited             = $inherited
            EnforcementMode       = $(if (& $get $a 'enforcement') { [string](& $get $a 'enforcement') } else { 'Default' })
            DefinitionType        = $kind
            DefinitionName        = $(if ($policy) { $policy.Name } else { ($definitionId -split '/')[-1] })
            DefinitionDisplayName = $(if ($policy) { $policy.DisplayName } else { '(definition not found)' })
            PolicyType            = $(if ($policy) { $policy.PolicyType } else { '' })
            Category              = $(if ($policy) { $policy.Category } else { '' })
            ResourceType          = $assignmentTypes
            ParameterName         = ''
            ParameterDisplayName  = ''
            ParameterType         = ''
            DefaultValue          = ''
            AssignedValue         = ''
            EffectiveValue        = ''
            ValueSource           = ''
            AllowedValues         = ''
            NotScopes             = (@(& $get $a 'notScopes') | Where-Object { $_ }) -join ', '
            AssignmentScope       = $scope
            AssignmentId          = [string](& $get $a 'id')
            DefinitionId          = $definitionId
        }

        $names = @(if ($parameters -is [System.Collections.IDictionary]) { $parameters.Keys } elseif ($assigned -is [System.Collections.IDictionary]) { $assigned.Keys })
        $assignedCount = 0
        if (-not $names.Count) { $rows.Add([pscustomobject]$base) }
        foreach ($parameterName in $names) {
            $spec = & $get $parameters $parameterName
            $row = [ordered]@{}
            foreach ($key in $base.Keys) { $row[$key] = $base[$key] }
            $row.ParameterName = [string]$parameterName
            $row.ParameterDisplayName = [string](& $get (& $get $spec 'metadata') 'displayName')
            $row.ParameterType = [string](& $get $spec 'type')
            $isAssigned = & $has $assigned $parameterName
            $hasDefault = & $has $spec 'defaultValue'
            $assignedValue = if ($isAssigned) { & $get (& $get $assigned $parameterName) 'value' } else { $null }
            $defaultValue = if ($hasDefault) { & $get $spec 'defaultValue' } else { $null }
            $row.DefaultValue = & $format $defaultValue
            $row.AssignedValue = & $format $assignedValue
            $row.EffectiveValue = $(if ($isAssigned) { $row.AssignedValue } else { $row.DefaultValue })
            $row.ValueSource = $(if ($isAssigned) { 'Assigned' } elseif ($hasDefault) { 'Default' } else { 'Not set' })
            $row.AllowedValues = & $format (& $get $spec 'allowedValues')
            if ($kind -eq 'PolicySet' -and $policy) {
                $row.ResourceType = $(if ($rowTypes.ContainsKey(([string]$parameterName).ToLowerInvariant())) { $rowTypes[([string]$parameterName).ToLowerInvariant()] } else { $assignmentTypes })
            }
            if ($isAssigned) { $assignedCount++ }
            $rows.Add([pscustomobject]$row)
        }

        $summaries.Add([pscustomobject][ordered]@{
                PSTypeName            = 'AAC.PolicyAssignmentSummary'
                AssignmentName        = $name
                AssignmentDisplayName = $(if ($displayName) { $displayName } else { $name })
                ScopeType             = $info.Type
                ScopeName             = $info.Name
                Inherited             = $inherited
                EnforcementMode       = $base.EnforcementMode
                DefinitionType        = $kind
                DefinitionDisplayName = $base.DefinitionDisplayName
                PolicyType            = $base.PolicyType
                Category              = $base.Category
                Members               = $memberCount
                Parameters            = $names.Count
                Assigned              = $assignedCount
                ResourceType          = $assignmentTypes
                ResourceTypes         = $allTypes.Count
                NotScopes             = $base.NotScopes
                AssignmentScope       = $scope
                AssignmentId          = $base.AssignmentId
                DefinitionId          = $definitionId
            })
    }

    $scopeOrder = @{ 'Management group' = 0; Subscription = 1; 'Resource group' = 2; Resource = 3; Other = 4 }
    $sortedRows = @($rows | Sort-Object -Property @{ Expression = { $scopeOrder[$_.ScopeType] } }, ScopeName, AssignmentDisplayName, AssignmentName, ParameterName)
    $sortedSummaries = @($summaries | Sort-Object -Property @{ Expression = { $scopeOrder[$_.ScopeType] } }, ScopeName, AssignmentDisplayName)
    $parameterRows = @($sortedRows | Where-Object ParameterName)
    @{
        Rows        = $sortedRows
        Assignments = $sortedSummaries
        Missing     = @($missing)
        Stats       = @{
            Assignments   = $sortedSummaries.Count
            Initiatives   = @($sortedSummaries | Where-Object DefinitionType -EQ 'PolicySet').Count
            Policies      = @($sortedSummaries | Where-Object DefinitionType -EQ 'Policy').Count
            Rows          = $sortedRows.Count
            Parameters    = $parameterRows.Count
            Assigned      = @($parameterRows | Where-Object ValueSource -EQ 'Assigned').Count
            Default       = @($parameterRows | Where-Object ValueSource -EQ 'Default').Count
            NotSet        = @($parameterRows | Where-Object ValueSource -EQ 'Not set').Count
            DoNotEnforce  = @($sortedSummaries | Where-Object EnforcementMode -EQ 'DoNotEnforce').Count
            Inherited     = @($sortedSummaries | Where-Object Inherited).Count
            Custom        = @($sortedSummaries | Where-Object PolicyType -EQ 'Custom').Count
            Missing       = $missing.Count
            ResourceTypes = @($typeCounts.GetEnumerator() | Sort-Object -Property @{ Expression = 'Value'; Descending = $true }, Name | ForEach-Object { @{ Label = $_.Name; Value = $_.Value } })
        }
    }
}