Private/ConvertTo-AACPolicyState.ps1
|
function ConvertTo-AACPolicyState { <# .SYNOPSIS Builds Get-AACPolicyState's result from Azure Policy state rows (Get-AACPolicyStateQuery): one flattened row per resource and policy, and the compliance per resource, assignment, subscription, resource group and policy. .DESCRIPTION Rolled up exactly as the Azure portal does (https://learn.microsoft.com/azure/governance/policy/concepts/compliance-states): - a resource's state across several policies is the one that ranks first: Non-compliant, Compliant, Error, Conflicting, Protected, Exempt, Unknown - so a resource non-compliant with one policy is non-compliant, and one only exempt or unknown everywhere else stays compliant when one policy finds it so - Not started and Not registered states aren't counted - compliance (%) = (Compliant + Exempt + Unknown + Protected resources) / every resource counted (those, plus Non-compliant, Conflicting and Error) per assignment (its policies), per subscription and resource group (every assignment in them), per policy (its resources) and overall. -Assignment rows name the assignments (display name, scope, enforcement); -SubscriptionName and -ManagementGroupName name the scopes. Returns @{ States (AAC.PolicyState); Resources (AAC.PolicyResource); Assignments (AAC.PolicyAssignmentCompliance); Scopes (AAC.PolicyScope: subscriptions and resource groups); Policies (AAC.PolicyCompliance: one per policy definition, as the portal counts them); Stats } - Stats with the portal's tiles: CompliantCounted of Resources, NonCompliantInitiatives of Initiatives, NonCompliantPolicies of Policies. #> [CmdletBinding()] [OutputType([hashtable])] param( [AllowEmptyCollection()] [object[]] $Row = @(), [AllowEmptyCollection()] [object[]] $Assignment = @(), [hashtable] $SubscriptionName = @{}, [hashtable] $ManagementGroupName = @{} ) $value = { param($Object, [string] $Key) if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($Key)) { $Object[$Key] } } else { Get-AACPropertyValue -InputObject $Object -Name $Key } } $text = { param($Object, [string] $Key) $v = & $value $Object $Key; if ($null -eq $v) { '' } else { [string]$v } } $subscriptionLabel = { param([string] $Id) $key = $Id.ToLowerInvariant(); if ($SubscriptionName.Contains($key)) { $SubscriptionName[$key] } else { $Id } } $scopeLabel = { param([string] $Scope) if ($Scope -match '(?i)/managementGroups/([^/]+)$') { $mg = $Matches[1]; return "Management group $(if ($ManagementGroupName.Contains($mg.ToLowerInvariant())) { $ManagementGroupName[$mg.ToLowerInvariant()] } else { $mg })" } if ($Scope -match '(?i)^/subscriptions/([^/]+)/resourceGroups/([^/]+)$') { return "Resource group $($Matches[2]) ($(& $subscriptionLabel $Matches[1]))" } if ($Scope -match '(?i)^/subscriptions/([^/]+)$') { return "Subscription $(& $subscriptionLabel $Matches[1])" } $Scope } $assignments = @{} foreach ($entry in $Assignment) { $assignments[(& $text $entry 'assignmentId').ToLowerInvariant()] = @{ Name = $(if (& $text $entry 'displayName') { & $text $entry 'displayName' } else { & $text $entry 'name' }) Scope = & $text $entry 'scope' Enforcement = & $text $entry 'enforcement' } } # Azure Policy's rank: the state that wins when a resource has several. $stateRank = @{ NonCompliant = 0; Compliant = 1; Error = 2; Conflict = 3; Protected = 4; Exempt = 5; Unknown = 6 } # The state names Resource Graph and the portal use, as one spelling. $normalize = { param([string] $State) switch -Regex ($State) { '^(?i)noncompliant$|^(?i)non-compliant$' { 'NonCompliant' } '^(?i)compliant$' { 'Compliant' } '^(?i)exempt(ed)?$' { 'Exempt' } '^(?i)conflict(ing)?$' { 'Conflict' } '^(?i)error$' { 'Error' } '^(?i)protected$' { 'Protected' } '^(?i)unknown$' { 'Unknown' } default { $State } } } # --- One row per resource and policy ------------------------------------------------------------- $states = @(foreach ($entry in $Row) { $id = & $text $entry 'resourceId' $subscription = (& $text $entry 'subscriptionId').ToLowerInvariant() if (-not $subscription -and $id -match '^/subscriptions/([^/]+)') { $subscription = $Matches[1] } $assignmentId = (& $text $entry 'assignmentId').ToLowerInvariant() $known = if ($assignments.Contains($assignmentId)) { $assignments[$assignmentId] } else { $null } $group = & $text $entry 'resourceGroup' if (-not $group -and $id -match '/resourcegroups/([^/]+)') { $group = $Matches[1] } $evaluated = [datetime]::MinValue $scope = if ($known -and $known.Scope) { $known.Scope } else { & $text $entry 'assignmentScope' } [pscustomobject][ordered]@{ PSTypeName = 'AAC.PolicyState' ComplianceState = & $normalize (& $text $entry 'state') Resource = $(if ($id -match '^/subscriptions/[^/]+$') { & $subscriptionLabel $subscription } else { ($id -split '/')[-1] }) ResourceType = & $text $entry 'resourceType' ResourceGroup = $group SubscriptionName = & $subscriptionLabel $subscription Location = & $text $entry 'location' Policy = $(if (& $text $entry 'policy') { & $text $entry 'policy' } else { & $text $entry 'definitionName' }) PolicySet = $(if (& $text $entry 'policySet') { & $text $entry 'policySet' } else { & $text $entry 'setName' }) Assignment = $(if ($known) { $known.Name } elseif (& $text $entry 'assignment') { & $text $entry 'assignment' } else { ($assignmentId -split '/')[-1] }) AssignmentScope = & $scopeLabel $scope Enforcement = $(if ($known) { $known.Enforcement } else { '' }) Effect = & $text $entry 'effect' EvaluatedAt = $(if ([datetime]::TryParse((& $text $entry 'evaluated'), [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$evaluated)) { $evaluated } else { $null }) SubscriptionId = $subscription ResourceId = $id PolicyDefinitionId = & $text $entry 'definitionId' PolicySetDefinitionId = (& $text $entry 'setId').ToLowerInvariant() AssignmentId = $assignmentId } }) $states = @($states | Sort-Object -Property @{ Expression = { if ($stateRank.Contains($_.ComplianceState)) { $stateRank[$_.ComplianceState] } else { 9 } } }, SubscriptionName, ResourceGroup, Resource, Policy) # Not started and Not registered: shown, but not part of any roll-up. $counted = @($states | Where-Object { $stateRank.Contains($_.ComplianceState) }) # How a set of states rolls up to its resources: each resource once. $verdict = { param([object[]] $Items) $by = @{} foreach ($item in $Items) { if (-not $stateRank.Contains($item.ComplianceState)) { continue } $key = $item.ResourceId if (-not $by.Contains($key) -or $stateRank[$item.ComplianceState] -lt $stateRank[$by[$key]]) { $by[$key] = $item.ComplianceState } } $counts = @{ NonCompliant = 0; Compliant = 0; Error = 0; Conflict = 0; Protected = 0; Exempt = 0; Unknown = 0 } foreach ($v in $by.Values) { $counts[$v]++ } $counts.Resources = $by.Count # The portal's compliance percentage. $good = $counts.Compliant + $counts.Exempt + $counts.Unknown + $counts.Protected $counts.Good = $good $counts.Rate = if ($by.Count) { [Math]::Round(100 * $good / $by.Count) } else { $null } $counts.Verdict = @{} foreach ($key in $by.Keys) { $counts.Verdict[$key] = $by[$key] } $counts } # --- Per resource --------------------------------------------------------------------------------------- $resources = @(foreach ($group in @($states | Group-Object -Property ResourceId)) { $first = $group.Group[0] $counts = & $verdict $group.Group $bad = @($group.Group | Where-Object ComplianceState -EQ 'NonCompliant') [pscustomobject][ordered]@{ PSTypeName = 'AAC.PolicyResource' Resource = $first.Resource ResourceType = $first.ResourceType ResourceGroup = $first.ResourceGroup SubscriptionName = $first.SubscriptionName Location = $first.Location Compliance = $(if ($counts.Verdict.Contains($first.ResourceId)) { $counts.Verdict[$first.ResourceId] } else { $first.ComplianceState }) NonCompliantPolicies = @($bad | ForEach-Object Policy | Select-Object -Unique).Count PoliciesEvaluated = @($group.Group.Policy | Select-Object -Unique).Count NonCompliantWith = (@($bad | ForEach-Object Policy | Select-Object -Unique) -join '; ') ResourceId = $first.ResourceId } }) $resources = @($resources | Sort-Object -Property @{ Expression = { if ($stateRank.Contains($_.Compliance)) { $stateRank[$_.Compliance] } else { 9 } } }, @{ Expression = 'NonCompliantPolicies'; Descending = $true }, SubscriptionName, Resource) # --- Per assignment, scope and policy ---------------------------------------------------------------------- $assignmentRows = @(foreach ($group in @($states | Group-Object -Property AssignmentId)) { $first = $group.Group[0] $counts = & $verdict $group.Group [pscustomobject][ordered]@{ PSTypeName = 'AAC.PolicyAssignmentCompliance' Assignment = $first.Assignment Scope = $first.AssignmentScope Enforcement = $first.Enforcement ComplianceRate = $counts.Rate NonCompliant = $counts.NonCompliant Compliant = $counts.Compliant Exempt = $counts.Exempt NonCompliantPolicies = @($group.Group | Where-Object ComplianceState -EQ 'NonCompliant' | ForEach-Object Policy | Select-Object -Unique).Count AssignmentId = $first.AssignmentId } }) $assignmentRows = @($assignmentRows | Sort-Object -Property @{ Expression = { if ($null -eq $_.ComplianceRate) { 101 } else { $_.ComplianceRate } } }, Assignment) $scopes = @( foreach ($group in @($states | Group-Object -Property SubscriptionId)) { $counts = & $verdict $group.Group [pscustomobject][ordered]@{ PSTypeName = 'AAC.PolicyScope'; Level = 'Subscription'; Name = $group.Group[0].SubscriptionName; SubscriptionName = $group.Group[0].SubscriptionName; ComplianceRate = $counts.Rate; Resources = $counts.Resources; NonCompliant = $counts.NonCompliant; Compliant = $counts.Compliant; Exempt = $counts.Exempt; SubscriptionId = $group.Name } } foreach ($group in @($states | Where-Object ResourceGroup | Group-Object -Property { "$($_.SubscriptionId)|$($_.ResourceGroup.ToLowerInvariant())" })) { $counts = & $verdict $group.Group [pscustomobject][ordered]@{ PSTypeName = 'AAC.PolicyScope'; Level = 'ResourceGroup'; Name = $group.Group[0].ResourceGroup; SubscriptionName = $group.Group[0].SubscriptionName; ComplianceRate = $counts.Rate; Resources = $counts.Resources; NonCompliant = $counts.NonCompliant; Compliant = $counts.Compliant; Exempt = $counts.Exempt; SubscriptionId = $group.Group[0].SubscriptionId } } ) $scopes = @($scopes | Sort-Object -Property @{ Expression = { if ($_.Level -eq 'Subscription') { 0 } else { 1 } } }, @{ Expression = { if ($null -eq $_.ComplianceRate) { 101 } else { $_.ComplianceRate } } }, SubscriptionName, Name) # A policy is a definition (as the portal counts them), whatever its name. $policies = @(foreach ($group in @($states | Group-Object -Property { if ($_.PolicyDefinitionId) { $_.PolicyDefinitionId } else { $_.Policy } })) { $counts = & $verdict $group.Group [pscustomobject][ordered]@{ PSTypeName = 'AAC.PolicyCompliance' Policy = $group.Group[0].Policy PolicySet = (@($group.Group.PolicySet | Where-Object { $_ } | Select-Object -Unique) -join '; ') Effect = (@($group.Group.Effect | Where-Object { $_ } | Select-Object -Unique) -join ', ') ComplianceRate = $counts.Rate NonCompliant = $counts.NonCompliant Compliant = $counts.Compliant Exempt = $counts.Exempt Assignments = (@($group.Group.Assignment | Select-Object -Unique) -join '; ') } }) $policies = @($policies | Sort-Object -Property @{ Expression = 'NonCompliant'; Descending = $true }, Policy) $overall = & $verdict $counted # Initiatives, as the portal counts them: each initiative once, non-compliant # when any of its policies finds a resource so. $initiatives = @($counted | Where-Object PolicySetDefinitionId | Group-Object -Property PolicySetDefinitionId) @{ States = $states Resources = $resources Assignments = $assignmentRows Scopes = $scopes Policies = $policies # Get-AACPolicyState adds what the reader should know. Notice = @() Stats = @{ States = $states.Count Resources = $overall.Resources # The portal's "313 out of 2560": compliant, exempt, unknown and protected. CompliantCounted = $overall.Good Compliant = $overall.Compliant NonCompliant = $overall.NonCompliant Exempt = $overall.Exempt Unknown = $overall.Unknown Error = $overall.Error Conflict = $overall.Conflict Protected = $overall.Protected NotCounted = $states.Count - $counted.Count ComplianceRate = $overall.Rate Assignments = $assignmentRows.Count Policies = $policies.Count NonCompliantPolicies = @($policies | Where-Object NonCompliant -GT 0).Count Initiatives = $initiatives.Count NonCompliantInitiatives = @($initiatives | Where-Object { @($_.Group | Where-Object ComplianceState -EQ 'NonCompliant').Count }).Count Subscriptions = @($scopes | Where-Object Level -EQ 'Subscription').Count ResourceGroups = @($scopes | Where-Object Level -EQ 'ResourceGroup').Count ByState = @($states | Group-Object ComplianceState | ForEach-Object { [pscustomobject]@{ Label = $_.Name; Value = $_.Count } } | Sort-Object Value -Descending) } } } |