Collectors/Policy.ps1

function Get-AerPolicy {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string[]] $SubscriptionIds,
        [Parameter(Mandatory)]            $SubscriptionMap
    )

    $subLookup = @{}
    if ($SubscriptionMap -is [hashtable]) {
        $subLookup = $SubscriptionMap
    } elseif ($SubscriptionMap) {
        $SubscriptionMap.PSObject.Properties | ForEach-Object { $subLookup[$_.Name] = $_.Value }
    }
    function Pctg($part, $total) { if ($total -gt 0) { [int][math]::Round($part / $total * 100) } else { $null } }
    function FromJson($s) { if ($s -and "$s" -ne 'null') { try { $s | ConvertFrom-Json } catch { $null } } else { $null } }
    function Leaf($id) { if ($id) { ($id -split '/')[-1] } else { '' } }
    function EffName($a) {
        switch ($a) {
            'audit'             { 'Audit' }            'deny'             { 'Deny' }
            'denyaction'        { 'DenyAction' }       'deployifnotexists' { 'DeployIfNotExists' }
            'auditifnotexists'  { 'AuditIfNotExists' } 'modify'           { 'Modify' }
            'append'            { 'Append' }           'disabled'         { 'Disabled' }
            'manual'            { 'Manual' }           'addtonetworkgroup' { 'AddToNetworkGroup' }
            default { if ($a) { (($a.Substring(0,1)).ToUpper() + $a.Substring(1)) } else { 'Unknown' } }
        }
    }
    function ScopeLabel($scope) {
        $s = "$scope"
        if (-not $s) { return '' }
        if ($s -match '/managementGroups/([^/]+)') { return 'Management Group: ' + $matches[1] }
        $sub = if ($s -match '/subscriptions/([^/]+)') { $matches[1] } else { '' }
        $rg  = if ($s -match '/resourceGroups/([^/]+)') { $matches[1] } else { '' }
        $subName = if ($sub) { $subLookup[$sub.ToLowerInvariant()] ?? $sub } else { '' }
        if ($rg)  { return "Resource Group: $rg" + $(if ($subName) { " ($subName)" } else { '' }) }
        if ($sub) { return "Subscription: $subName" }
        return $s
    }

    # Policy assignments / definitions live at subscription AND management-group
    # scope. Invoke-AerArgQuery restricts to subscriptions, which drops MG-scoped
    # assignments — so query the ARG REST endpoint WITHOUT a subscription filter
    # (resultFormat=objectArray also sidesteps the columnar quirk).
    function ArgAll($query) {
        $rows = [System.Collections.Generic.List[object]]::new()
        $skipToken = $null
        do {
            $opts = @{ resultFormat = 'objectArray'; '$top' = 1000 }
            if ($skipToken) { $opts['$skipToken'] = $skipToken }
            $body = @{ query = $query; options = $opts } | ConvertTo-Json -Depth 5
            try {
                $r = Invoke-AzRestMethod -Method POST -Uri "https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Payload $body
                if ($r.StatusCode -lt 200 -or $r.StatusCode -ge 300) { break }
                $j = $r.Content | ConvertFrom-Json
                foreach ($d in @($j.data)) { $rows.Add($d) }
                $skipToken = $j.'$skipToken'
            } catch { Write-Warning "[Policy.arg] $($_.Exception.Message)"; break }
        } while ($skipToken)
        return @($rows)
    }

    # ── Policy & initiative definitions (name resolution + initiative members) ─
    $defName = @{}
    try {
        foreach ($d in (ArgAll "policyresources | where type =~ 'microsoft.authorization/policydefinitions' | project id = tolower(id), dn = tostring(properties.displayName)")) {
            if ($d.id) { $defName[$d.id] = if ($d.dn) { "$($d.dn)" } else { (Leaf $d.id) } }
        }
    } catch { Write-Warning "[Policy.defs] $($_.Exception.Message)" }
    $setMembers = @{}; $setRefMap = @{}
    try {
        foreach ($s in (ArgAll "policyresources | where type =~ 'microsoft.authorization/policysetdefinitions' | project id = tolower(id), dn = tostring(properties.displayName), policyDefs = tostring(properties.policyDefinitions)")) {
            if (-not $s.id) { continue }
            $defName[$s.id] = if ($s.dn) { "$($s.dn)" } else { (Leaf $s.id) }
            $members = [System.Collections.Generic.List[string]]::new()
            $refMap = @{}
            foreach ($pd in @(FromJson $s.policyDefs)) {
                $pdId = ("$($pd.policyDefinitionId)").ToLowerInvariant()
                $nm = $defName[$pdId] ?? (Leaf $pdId)
                $members.Add($nm)
                $ref = "$($pd.policyDefinitionReferenceId)"
                if ($ref) { $refMap[$ref] = $nm }
            }
            $setMembers[$s.id] = @($members)
            $setRefMap[$s.id] = $refMap
        }
    } catch { Write-Warning "[Policy.sets] $($_.Exception.Message)" }

    # ── Assignments (unscoped → includes MG scope) ───────────────────────────
    $assignments = [System.Collections.Generic.List[object]]::new()
    $policyAssign = 0; $initiativeAssign = 0
    try {
        foreach ($a in (ArgAll @'
policyresources
| where type =~ 'microsoft.authorization/policyassignments'
| project id, name,
          dn = tostring(properties.displayName),
          defId = tostring(properties.policyDefinitionId),
          scope = tostring(properties.scope),
          enforcementMode = tostring(properties.enforcementMode),
          parameters = tostring(properties.parameters),
          identityType = tostring(identity.type)
'@
)) {
            $defIdL = ("$($a.defId)").ToLowerInvariant()
            $isInit = $defIdL -like '*/policysetdefinitions/*'
            if ($isInit) { $initiativeAssign++ } else { $policyAssign++ }
            $members = if ($isInit) { @($setMembers[$defIdL]) } else { @() }
            $defDisplay = $defName[$defIdL] ?? (Leaf $defIdL)
            $params = FromJson $a.parameters
            $paramList = @()
            if ($params) {
                foreach ($p in $params.PSObject.Properties) {
                    $val = $p.Value.value
                    if ($val -is [System.Array]) { $val = ($val -join ', ') }
                    $paramList += [pscustomobject]@{ Name = $p.Name; Value = "$val" }
                }
            }
            $assignments.Add([pscustomobject]@{
                Id               = "$($a.id)"
                Key              = ("$($a.id)").ToLowerInvariant()
                DefIdL           = $defIdL
                Name             = if ($a.dn) { "$($a.dn)" } else { "$($a.name)" }
                Type             = if ($isInit) { 'Initiative' } else { 'Policy' }
                Definition       = $defDisplay
                Scope            = (ScopeLabel ($a.scope ? $a.scope : $a.id))
                EnforcementMode  = if ($a.enforcementMode) { "$($a.enforcementMode)" } else { 'Default' }
                IdentityType     = if ($a.identityType -and $a.identityType -ne 'None') { "$($a.identityType)" } else { '' }
                Members          = @($members)
                Parameters       = @($paramList)
                Compliant        = 0
                NonCompliant     = 0
                Conflict         = 0
            })
        }
    } catch { Write-Warning "[Policy.assignments] $($_.Exception.Message)" }
    $assignByKey = @{}; foreach ($a in $assignments) { $assignByKey[$a.Key] = $a }

    # ── Policy exemptions (unscoped → includes MG scope) ─────────────────────
    $exemptions = [System.Collections.Generic.List[object]]::new()
    try {
        foreach ($e in (ArgAll @'
policyresources
| where type =~ 'microsoft.authorization/policyexemptions'
| project id, name,
          dn = tostring(properties.displayName),
          assignmentId = tostring(properties.policyAssignmentId),
          category = tostring(properties.exemptionCategory),
          expiresOn = tostring(properties.expiresOn),
          description = tostring(properties.description),
          refIds = tostring(properties.policyDefinitionReferenceIds),
          selectors = tostring(properties.resourceSelectors),
          createdBy = tostring(systemData.createdBy),
          createdByType = tostring(systemData.createdByType)
'@
)) {
            $aKey = ("$($e.assignmentId)").ToLowerInvariant()
            $assign = $assignByKey[$aKey]
            $assignName = if ($assign) { $assign.Name } else { (Leaf $e.assignmentId) }
            $refIds = @(FromJson $e.refIds)
            $refMap = if ($assign -and $assign.DefIdL) { $setRefMap[$assign.DefIdL] } else { $null }
            $appliesTo = if (@($refIds).Count -eq 0) {
                @('All policies in the assignment')
            } else {
                @($refIds | ForEach-Object { if ($refMap -and $refMap.ContainsKey("$_")) { $refMap["$_"] } else { "$_" } })
            }
            $selList = @()
            foreach ($s in @(FromJson $e.selectors)) {
                foreach ($sel in @($s.selectors)) {
                    $vals = @(@($sel.in) + @($sel.notIn) | Where-Object { $_ })
                    $selList += "$($sel.kind): " + (@($vals) -join ', ')
                }
            }
            $expRaw = $e.expiresOn
            $expStr = if ($expRaw -is [datetime]) { $expRaw.ToString('yyyy-MM-dd') } elseif ($expRaw) { ("$expRaw" -replace 'T.*$', '') } else { '' }
            $expired = if ($expRaw) { try { ([datetime]$expRaw) -lt (Get-Date) } catch { $false } } else { $false }
            $exemptions.Add([pscustomobject]@{
                Name          = if ($e.dn) { "$($e.dn)" } else { "$($e.name)" }
                Assignment    = $assignName
                Scope         = (ScopeLabel $e.id)
                Category      = if ($e.category) { "$($e.category)" } else { '' }
                CreatedBy     = "$($e.createdBy)"
                CreatedByType = "$($e.createdByType)"
                ExpiresOn     = $expStr
                Expired       = $expired
                Description   = "$($e.description)"
                ReferenceIds  = @($refIds)
                AppliesTo     = @($appliesTo)
                ResourceSelectors = @($selList)
                Id            = "$($e.id)"
            })
        }
    } catch { Write-Warning "[Policy.exemptions] $($_.Exception.Message)" }

    # ── Remediation: non-compliant resources under DINE/Modify + recent tasks ─
    $remGroups = @{}          # key -> { Policy; Assignment; Scope; Resources(List) }
    $remResources = @{}       # distinct non-compliant remediable resourceIds
    $remTasks = [System.Collections.Generic.List[object]]::new()
    foreach ($sub in $SubscriptionIds) {
        # Remediable non-compliant resources (one page, up to 1000)
        try {
            $filter = "ComplianceState eq 'NonCompliant' and (PolicyDefinitionAction eq 'deployifnotexists' or PolicyDefinitionAction eq 'modify')"
            $select = "resourceId,policyAssignmentId,policyAssignmentName,policyAssignmentScope,policyDefinitionName,policyDefinitionReferenceId"
            $uri = "https://management.azure.com/subscriptions/$sub/providers/Microsoft.PolicyInsights/policyStates/latest/queryResults?api-version=2019-10-01&`$top=1000&`$filter=" + [uri]::EscapeDataString($filter) + "&`$select=" + [uri]::EscapeDataString($select)
            $resp = Invoke-AzRestMethod -Method POST -Uri $uri
            if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) {
                foreach ($row in @(($resp.Content | ConvertFrom-Json).value)) {
                    $rid = "$($row.resourceId)"
                    if ($rid) { $remResources[$rid.ToLowerInvariant()] = $true }
                    $aId = "$($row.policyAssignmentId)"
                    $ref = "$($row.policyDefinitionReferenceId)"
                    $key = ($aId + '|' + $ref).ToLowerInvariant()
                    if (-not $remGroups.ContainsKey($key)) {
                        $remGroups[$key] = [pscustomobject]@{
                            Policy     = $(if ($row.policyDefinitionName) { "$($row.policyDefinitionName)" } elseif ($ref) { $ref } else { 'Policy' })
                            Assignment = $(if ($row.policyAssignmentName) { "$($row.policyAssignmentName)" } else { (Leaf $aId) })
                            Scope      = (ScopeLabel ($row.policyAssignmentScope ? $row.policyAssignmentScope : $aId))
                            Resources  = [System.Collections.Generic.List[string]]::new()
                        }
                    }
                    if ($rid -and $remGroups[$key].Resources.Count -lt 500) { $remGroups[$key].Resources.Add((Leaf $rid)) }
                }
            }
        } catch { Write-Warning "[Policy.remediable:$sub] $($_.Exception.Message)" }

        # Remediation tasks
        try {
            $resp = Invoke-AzRestMethod -Method GET -Uri "https://management.azure.com/subscriptions/$sub/providers/Microsoft.PolicyInsights/remediations?api-version=2021-10-01&`$top=20"
            if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) {
                foreach ($t in @(($resp.Content | ConvertFrom-Json).value)) {
                    $ds = $t.properties.deploymentStatus
                    $remTasks.Add([pscustomobject]@{
                        Name       = "$($t.name)"
                        Assignment = (Leaf $t.properties.policyAssignmentId)
                        Policy     = $(if ($t.properties.policyDefinitionReferenceId) { "$($t.properties.policyDefinitionReferenceId)" } else { '—' })
                        Scope      = (ScopeLabel $t.id)
                        State      = "$($t.properties.provisioningState)"
                        Total      = [int]($ds.totalDeployments ?? 0)
                        Succeeded  = [int]($ds.successfulDeployments ?? 0)
                        Failed     = [int]($ds.failedDeployments ?? 0)
                        CreatedOn  = $t.properties.createdOn
                    })
                }
            }
        } catch { Write-Warning "[Policy.remediations:$sub] $($_.Exception.Message)" }
    }
    $remItems = $remGroups.Values | ForEach-Object {
        [pscustomobject]@{ Policy = $_.Policy; Assignment = $_.Assignment; Scope = $_.Scope; ResourceCount = @($_.Resources).Count; Resources = @($_.Resources) }
    } | Sort-Object ResourceCount -Descending
    $remTasksTop = @($remTasks | Sort-Object { try { [datetime]$_.CreatedOn } catch { [datetime]::MinValue } } -Descending | Select-Object -First 10 |
        ForEach-Object { $_.CreatedOn = $(if ($_.CreatedOn -is [datetime]) { $_.CreatedOn.ToString('yyyy-MM-dd HH:mm') } elseif ($_.CreatedOn) { ("$($_.CreatedOn)" -replace 'T', ' ' -replace 'Z$', '' -replace '\..*$', '') } else { '' }); $_ })

    # ── Compliance / pending / effects + per-assignment states (Policy Insights)
    $compliant = 0; $nonCompliant = 0; $conflict = 0; $pending = 0
    $effects = @{}; $effectSeen = @{}
    $remediable = @('deployifnotexists', 'modify')
    foreach ($sub in $SubscriptionIds) {
        try {
            $apply = 'groupby((policyAssignmentId,complianceState,policyDefinitionAction),aggregate($count as count))'
            $uri = "https://management.azure.com/subscriptions/$sub/providers/Microsoft.PolicyInsights/policyStates/latest/queryResults?api-version=2019-10-01&`$apply=" + [uri]::EscapeDataString($apply)
            $r = Invoke-AzRestMethod -Method POST -Uri $uri
            if ($r.StatusCode -lt 200 -or $r.StatusCode -ge 300) { continue }
            foreach ($row in @(($r.Content | ConvertFrom-Json).value)) {
                $aid = ("$($row.policyAssignmentId)").ToLowerInvariant()
                $cs  = "$($row.complianceState)"
                $act = ("$($row.policyDefinitionAction)").ToLowerInvariant()
                $cnt = [int]$row.count
                if     ($cs -eq 'Compliant')    { $compliant += $cnt }
                elseif ($cs -eq 'NonCompliant') { $nonCompliant += $cnt; if ($remediable -contains $act) { $pending += $cnt } }
                elseif ($cs -eq 'Conflict')     { $conflict += $cnt }
                # effects: distinct assignment × effect (dedupe across subscriptions)
                if ($act) {
                    $ek = "$aid|$act"
                    if (-not $effectSeen.ContainsKey($ek)) { $effectSeen[$ek] = $true; $effects[$act] = ([int]($effects[$act] ?? 0)) + 1 }
                }
                # per-assignment rollup
                $rec = $assignByKey[$aid]
                if ($rec) {
                    if     ($cs -eq 'Compliant')    { $rec.Compliant += $cnt }
                    elseif ($cs -eq 'NonCompliant') { $rec.NonCompliant += $cnt }
                    elseif ($cs -eq 'Conflict')     { $rec.Conflict += $cnt }
                }
            }
        } catch { Write-Warning "[Policy.compliance:$sub] $($_.Exception.Message)" }
    }

    # Finalize assignment rows (compliance status + percent)
    $items = foreach ($a in $assignments) {
        $tot = $a.Compliant + $a.NonCompliant + $a.Conflict
        [pscustomobject]@{
            Name              = $a.Name
            Type              = $a.Type
            Definition        = $a.Definition
            Scope             = $a.Scope
            Id                = $a.Id
            Compliant         = ($a.NonCompliant + $a.Conflict) -eq 0 -and $tot -gt 0
            Evaluated         = ($tot -gt 0)
            CompliancePercent = (Pctg $a.Compliant $tot)
            CompliantCount    = $a.Compliant
            NonCompliantCount = $a.NonCompliant
            EnforcementMode   = $a.EnforcementMode
            IdentityType      = $a.IdentityType
            Members           = @($a.Members)
            Parameters        = @($a.Parameters)
        }
    }
    $items = @($items) | Sort-Object @{ E = { $_.NonCompliantCount }; Descending = $true }, Name

    $evaluated = $compliant + $nonCompliant + $conflict
    $effectsByType = $effects.GetEnumerator() | Sort-Object Value -Descending | ForEach-Object {
        [pscustomobject]@{ Effect = (EffName $_.Key); Count = [int]$_.Value }
    }

    return [pscustomobject]@{
        Compliance = [pscustomobject]@{
            Compliant = $compliant; NonCompliant = $nonCompliant; Conflict = $conflict
            Evaluated = $evaluated; OverallPercent = (Pctg $compliant $evaluated)
        }
        Assignments = [pscustomobject]@{ Policy = $policyAssign; Initiative = $initiativeAssign; Total = $policyAssign + $initiativeAssign }
        PendingRemediation = $pending
        EffectsByType      = @($effectsByType)
        Items              = @($items)
        Exemptions         = @($exemptions)
        Remediation = [pscustomobject]@{
            PoliciesToRemediate  = @($remItems).Count
            ResourcesToRemediate = $remResources.Count
            Items                = @($remItems)
            Tasks                = @($remTasksTop)
        }
    }
}