Public/Invoke-AACPolicyAssessment.ps1

function Invoke-AACPolicyAssessment {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Assesses Azure Policy across a tenant, management group or set of
        subscriptions - what is assigned, how compliant it is (overall, by
        subscription, assignment, policy and category), the exemptions and
        the managed identities' roles - and what to improve, with a
        Spectre.Console view, objects, and CSV, PDF and interactive HTML
        reports. In the spirit of AzPolicyLens (github.com/Azure/AzPolicyLens).
    .DESCRIPTION
        Read-only (Reader is enough), Azure Resource Graph only - no Az
        modules. Every assignment, exemption, custom definition and
        initiative, and the management group hierarchy are read tenant-wide,
        so what a management group assigns to a subscription is there; the
        built-in definitions that are assigned are read by ID; compliance is
        read in the subscriptions in scope.
 
        Compliance is counted as AzPolicyLens counts it: each resource once,
        at its worst state (NonCompliant, then Compliant, Conflict, Exempt),
        and compliance = (compliant + exempt) / all - overall, by
        subscription, by management group, by assignment (and subscription),
        by policy and by category. Under -ComplianceWarningPercent (80) is
        Warning, under half of it Poor.
 
        The findings (each with its severity, what was found and what to do):
          Assignments definitions assigned directly (not in an initiative),
                       DoNotEnforce, a definition that can't be found,
                       below the compliance threshold, deprecated or preview
                       policies assigned, excluded scopes that don't exist,
                       the same definition assigned twice on a scope path,
                       Deny with no non-compliance message
          Identity DeployIfNotExists and Modify assignments with no
                       managed identity, or one missing the roles their
                       policies list
          Exemptions expired, expiring within -ExemptionWarningDays (30),
                       never expiring, for an assignment that is gone
          Definitions unassigned custom definitions and initiatives, unused
                       policy definition groups, the same control under
                       different group names, no category in the metadata
          Compliance subscriptions below the threshold
 
        -Audience Platform (the default; AzPolicyLens's detailed wiki) is for
        the team that runs Azure Policy: everything, with hidden- metadata and
        tags. Application (its basic wiki) is for an application team: their
        assignments, compliance and exemptions, without the unassigned
        definitions, metadata hygiene or hidden- metadata.
 
        What you get depends on where the command runs:
          at the prompt tiles, compliance by subscription and category,
                           the assignments least compliant first, the
                           exemptions that need attention and the High and
                           Medium findings, a page at a time
          piped onward the AAC.PolicyAssignmentReport objects, each with
                           its Compliance, PolicyStates and Findings
          -PassThru the view and the objects
          -NoDisplay the objects only
        -CsvPath (a folder) writes a CSV per table. -HtmlPath writes an
        interactive report - the management group hierarchy as a tree with
        each level's compliance, then every table; -PdfPath a PDF.
    .PARAMETER ManagementGroupId
        Assess this management group and everything under it.
    .PARAMETER SubscriptionId
        Assess these subscriptions, and every assignment that reaches them -
        from their management groups too.
    .PARAMETER Audience
        Platform (the default): everything. Application: what an application
        team needs - no unassigned definitions, metadata hygiene or hidden-
        metadata.
    .PARAMETER ComplianceWarningPercent
        Compliance under this percentage (80 by default) is a Warning, under
        half of it Poor - and an assignment or subscription under it is a
        finding.
    .PARAMETER ExemptionWarningDays
        Exemptions expiring within this many days (30 by default) are
        flagged.
    .PARAMETER CsvPath
        A folder to write a CSV per table to.
    .PARAMETER PdfPath
        Write a PDF report to this file.
    .PARAMETER HtmlPath
        Write an interactive HTML report to this file.
    .PARAMETER Title
        The PDF and HTML reports' title.
    .PARAMETER PassThru
        Show the view and also return the objects.
    .PARAMETER NoDisplay
        Return the objects without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once instead of a page at a time.
    .EXAMPLE
        Connect-AAC
        Invoke-AACPolicyAssessment
        Azure Policy across every subscription and management group the account can see.
    .EXAMPLE
        Invoke-AACPolicyAssessment -ManagementGroupId 'mg-landingzones' -HtmlPath .\out\Policy.html -PdfPath .\out\Policy.pdf -CsvPath .\out\policy
        A landing zone's policy, with every report.
    .EXAMPLE
        Invoke-AACPolicyAssessment -SubscriptionId 00000000-0000-0000-0000-000000000000 -Audience Application -HtmlPath .\Policy.html
        An application team's report: the policies on their subscription, how compliant it is, and their exemptions.
    .EXAMPLE
        Invoke-AACPolicyAssessment -NoDisplay | Where-Object { $_.Rating -ne 'Good' } | Select-Object Assignment, Scope, CompliancePercent, NonCompliant
        The assignments under the compliance threshold.
    .OUTPUTS
        AAC.PolicyAssignmentReport (piped onward, or with -PassThru or -NoDisplay)
    #>

    [CmdletBinding(DefaultParameterSetName = 'Tenant')]
    [OutputType('AAC.PolicyAssignmentReport')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'ManagementGroup')]
        [ValidateNotNullOrEmpty()]
        [string] $ManagementGroupId,

        [Parameter(Mandatory, ParameterSetName = 'Subscription')]
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [ValidateSet('Platform', 'Application')]
        [string] $Audience = 'Platform',

        [ValidateRange(1, 99)]
        [int] $ComplianceWarningPercent = 80,

        [ValidateRange(1, 365)]
        [int] $ExemptionWarningDays = 30,

        [string] $CsvPath,

        [string] $PdfPath,

        [string] $HtmlPath,

        [string] $Title = 'Azure Policy assessment',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    # A failure anywhere below ends as a Spectre.Console error panel and this
    # command's own terminating error; a stopped pipeline just returns.
    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $showView = $interactive -and -not ($CsvPath -or $PdfPath -or $HtmlPath)
    $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    # Read here, not inside the progress block (it runs in Invoke-AACProgress's scope).
    $request = @{
        ManagementGroupId        = $ManagementGroupId
        SubscriptionId           = @($SubscriptionId | Where-Object { $_ })
        Audience                 = $Audience
        ComplianceWarningPercent = $ComplianceWarningPercent
        ExemptionWarningDays     = $ExemptionWarningDays
        CsvPath                  = & $resolve $CsvPath
        PdfPath                  = & $resolve $PdfPath
        HtmlPath                 = & $resolve $HtmlPath
        Title                    = $Title
    }

    if ($interactive) {
        Write-AACRule -Title 'Azure Admin Console :: Azure Policy assessment' -Color 'deepskyblue3_1'
    }
    $state = Invoke-AACProgress -ScriptBlock {
        $null = Get-AACAccessToken
        $rowsOf = { param($Read, [string] $Key) @(if ($Read.Rows.Contains($Key)) { $Read.Rows[$Key] }) | Where-Object { $null -ne $_ } }
        $rowsLike = { param($Read, [string] $Prefix) @(foreach ($key in @($Read.Rows.Keys)) { if ($key -like "$Prefix*") { $Read.Rows[$key] } }) | Where-Object { $null -ne $_ } }
        $notices = [System.Collections.Generic.List[string]]::new()

        # --- 1. Discovery: the hierarchy, assignments, exemptions, custom definitions ------------------------------------
        $queries = Get-AACPolicyAssessmentQuery -Stage Discovery
        $labels = @{ subscriptions = 'subscriptions'; managementGroups = 'management groups'; assignments = 'policy assignments'; exemptions = 'policy exemptions'; customDefinitions = 'custom policy definitions'; customInitiatives = 'custom initiatives'; roleDefinitions = 'role definitions' }
        Update-AACProgress -Id 'discover' -Total $queries.Count -Description 'Reading the management groups, subscriptions, assignments, exemptions and custom definitions'
        $discovery = Invoke-AACGraphBatch -Query $queries -AllowFailure @('exemptions', 'roleDefinitions', 'customDefinitions', 'customInitiatives') -OnProgress {
            param($Name, $Done, $Total)
            Update-AACProgress -Id 'discover' -Increment 1 -Description "Read the $($labels[$Name]) ($Done of $Total)"
        }
        foreach ($key in $discovery.Errors.Keys) { $notices.Add("The $($labels[$key]) couldn't be read: $($discovery.Errors[$key])") }
        $subscriptions = @(& $rowsOf $discovery 'subscriptions')
        $groups = @(& $rowsOf $discovery 'managementGroups')
        $assignments = @(& $rowsOf $discovery 'assignments')
        $exemptions = @(& $rowsOf $discovery 'exemptions')
        if ($request.ManagementGroupId -and -not @($groups | Where-Object { [string]$_['name'] -eq $request.ManagementGroupId }).Count) {
            $problem = [System.InvalidOperationException]::new("No management group named '$($request.ManagementGroupId)' was found.")
            $problem.Data['AACHint'] = 'Use the management group''s ID (its name, not its display name), and check your account can read it.'
            throw $problem
        }
        $missing = @($request.SubscriptionId | Where-Object { $wanted = $_; -not @($subscriptions | Where-Object { [string]$_['subscriptionId'] -eq $wanted }).Count })
        foreach ($item in $missing) { Write-Warning "Subscription $item wasn't found, or your account can't read it; it's left out." }
        if ($request.SubscriptionId.Count -and $missing.Count -eq $request.SubscriptionId.Count) {
            $problem = [System.InvalidOperationException]::new('None of those subscriptions was found.')
            $problem.Data['AACHint'] = 'Check the subscription IDs, and that your account has Reader on them.'
            throw $problem
        }
        Update-AACProgress -Id 'discover' -Complete -Description ('Found {0:N0} assignment(s), {1:N0} exemption(s), {2:N0} management group(s) and {3:N0} subscription(s)' -f $assignments.Count, $exemptions.Count, $groups.Count, $subscriptions.Count)

        # --- 2. The built-in definitions assigned (Resource Graph by ID, then Resource Manager) ---------------------------------
        $definitions = @{}
        foreach ($row in @(& $rowsOf $discovery 'customDefinitions') + @(& $rowsOf $discovery 'customInitiatives')) { $definitions[([string]$row['id']).ToLowerInvariant()] = $row }
        $wanted = { param([string[]] $Ids) @($Ids | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() } | Where-Object { -not $definitions.Contains($_) } | Sort-Object -Unique) }
        $fetch = {
            param([string[]] $Ids)
            if (-not $Ids.Count) { return }
            $read = Invoke-AACGraphBatch -Query (Get-AACPolicyAssessmentQuery -Stage Definitions -Id $Ids) -AllowFailure @() -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'definitions' -Increment 1 }
            foreach ($row in @(& $rowsLike $read 'definitions')) { $definitions[([string]$row['id']).ToLowerInvariant()] = $row }
            # Resource Graph doesn't have them all (definitions at a scope it doesn't index): Resource Manager.
            $left = @($Ids | Where-Object { -not $definitions.Contains($_) })
            if ($left.Count) {
                $answers = Invoke-AACArmParallel -Uri @($left | ForEach-Object { "$_`?api-version=2023-04-01" })
                foreach ($id in $left) {
                    $answer = $answers["$id`?api-version=2023-04-01"]
                    if (-not $answer -or $answer.Error -or $answer.Body -isnot [System.Collections.IDictionary]) { continue }
                    $p = $answer.Body['properties']
                    $definitions[$id] = @{ id = [string]$answer.Body['id']; name = [string]$answer.Body['name']; type = [string]$answer.Body['type']; displayName = [string]$p['displayName']; description = [string]$p['description']; policyType = [string]$p['policyType']; mode = [string]$p['mode']; version = [string]$p['version']; metadata = $p['metadata']; parameters = $p['parameters']; rule = $p['policyRule']; members = $p['policyDefinitions']; groups = $p['policyDefinitionGroups'] }
                }
            }
        }
        $assignedIds = & $wanted @($assignments | ForEach-Object { [string]$_['definitionId'] })
        Update-AACProgress -Id 'definitions' -Description "Reading the $($assignedIds.Count) built-in definition(s) and initiative(s) assigned" -Indeterminate
        & $fetch $assignedIds
        # Then the policies inside the assigned initiatives.
        $memberIds = & $wanted @($assignments | ForEach-Object { $set = $definitions[([string]$_['definitionId']).ToLowerInvariant()]; if ($set) { @($set['members']) | Where-Object { $_ } | ForEach-Object { [string]$_['policyDefinitionId'] } } })
        & $fetch $memberIds
        $unreadDefinitions = @(& $wanted @($assignedIds + $memberIds)).Count
        Update-AACProgress -Id 'definitions' -Complete -Description ('Read {0:N0} definition(s) and initiative(s){1}' -f $definitions.Count, $(if ($unreadDefinitions) { ", $unreadDefinitions not readable" }))

        # --- 3. The managed identities' roles ------------------------------------------------------------------------------
        $principals = @($assignments | ForEach-Object {
                $identity = $_['identity']
                if ($identity -is [System.Collections.IDictionary]) {
                    [string]$identity['principalId']
                    if ($identity['userAssignedIdentities'] -is [System.Collections.IDictionary]) { foreach ($user in $identity['userAssignedIdentities'].Values) { [string]$user['principalId'] } }
                }
            } | Where-Object { $_ })
        $roles = @()
        if ($principals.Count) {
            Update-AACProgress -Id 'roles' -Description "Reading the roles of $($principals.Count) managed identit$(if ($principals.Count -eq 1) { 'y' } else { 'ies' })" -Indeterminate
            $read = Invoke-AACGraphBatch -Query (Get-AACPolicyAssessmentQuery -Stage Roles -Id $principals) -AllowFailure @()
            $roles = @(& $rowsLike $read 'roles')
            Update-AACProgress -Id 'roles' -Complete -Description ('Read {0:N0} role assignment(s) of the assignments'' managed identities' -f $roles.Count)
        }

        # --- 4. Compliance in the subscriptions in scope -------------------------------------------------------------------
        $scope = @{}
        if ($request.SubscriptionId.Count) { $scope.SubscriptionId = $request.SubscriptionId }
        elseif ($request.ManagementGroupId) { $scope.ManagementGroupId = @($request.ManagementGroupId) }
        $queries = Get-AACPolicyAssessmentQuery -Stage Compliance
        $labels = @{ complianceBySubscription = 'compliance by subscription'; complianceByAssignment = 'compliance by assignment'; complianceByPolicy = 'compliance by policy' }
        Update-AACProgress -Id 'compliance' -Total $queries.Count -Description 'Reading the compliance states'
        $compliance = Invoke-AACGraphBatch @scope -Query $queries -AllowFailure @($queries.Keys) -OnProgress {
            param($Name, $Done, $Total)
            Update-AACProgress -Id 'compliance' -Increment 1 -Description "Read the $($labels[$Name]) ($Done of $Total)"
        }
        foreach ($key in $compliance.Errors.Keys) { $notices.Add("The $($labels[$key]) couldn't be read: $($compliance.Errors[$key])") }
        Update-AACProgress -Id 'compliance' -Complete -Description ('Read the compliance of {0:N0} subscription(s) and {1:N0} assignment(s)' -f @(& $rowsOf $compliance 'complianceBySubscription').Count, @(& $rowsOf $compliance 'complianceByAssignment' | ForEach-Object { $_['assignmentId'] } | Sort-Object -Unique).Count)

        # --- 5. The assessment ----------------------------------------------------------------------------------------------
        Update-AACProgress -Id 'assess' -Description 'Assessing the assignments, compliance, exemptions and definitions' -Indeterminate
        $assessment = ConvertTo-AACPolicyAssessment -Subscription $subscriptions -ManagementGroup $groups -Assignment $assignments -Exemption $exemptions -Definition $definitions `
            -RoleAssignment $roles -RoleDefinition @(& $rowsOf $discovery 'roleDefinitions') -ComplianceBySubscription @(& $rowsOf $compliance 'complianceBySubscription') `
            -ComplianceByAssignment @(& $rowsOf $compliance 'complianceByAssignment') -ComplianceByPolicy @(& $rowsOf $compliance 'complianceByPolicy') `
            -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId -Audience $request.Audience -ComplianceWarningPercent $request.ComplianceWarningPercent -ExemptionWarningDays $request.ExemptionWarningDays
        if ($unreadDefinitions) { $notices.Add("$unreadDefinitions definition(s) or initiative(s) couldn't be read, so their names, effects and roles are missing.") }
        $assessment.Notices = $notices.ToArray()
        $stats = $assessment.Stats
        Update-AACProgress -Id 'assess' -Complete -Description ('Assessed {0:N0} assignment(s): {1} compliant, {2} high, {3} medium, {4} low finding(s)' -f $stats.Assignments, $(if ($null -ne $stats.CompliancePercent) { "$($stats.CompliancePercent)%" } else { 'no data' }), $stats.High, $stats.Medium, $stats.Low)

        $detail = [ordered]@{ Scope = if ($request.ManagementGroupId) { "management group $($request.ManagementGroupId)" } elseif ($request.SubscriptionId.Count) { "subscription(s) $($request.SubscriptionId -join ', ')" } else { 'every subscription and management group the account can see' } }
        $detail['Audience'] = if ($request.Audience -eq 'Platform') { 'Platform team (everything)' } else { 'Application team' }
        $detail['Thresholds'] = "compliance under $($request.ComplianceWarningPercent)% is a warning; exemptions expiring within $($request.ExemptionWarningDays) days"
        if ($request.CsvPath) {
            Update-AACProgress -Id 'csv' -Description 'Writing the CSV files' -Indeterminate
            $files = @(Write-AACPolicyAssessmentCsv -Assessment $assessment -Path $request.CsvPath)
            Update-AACProgress -Id 'csv' -Complete -Description "CSV: $($files.Count) file(s) in $($request.CsvPath)"
        }
        $null = Invoke-AACExport -PdfPath $request.PdfPath -WritePdf {
            Write-AACPolicyAssessmentPdf -Assessment $assessment -Path $request.PdfPath -Title $request.Title -Detail $detail
        } -HtmlPath $request.HtmlPath -WriteHtml {
            Write-AACPolicyAssessmentHtml -Assessment $assessment -Path $request.HtmlPath -Title $request.Title -Detail $detail
        }
        @{ Assessment = $assessment; Scope = $detail }
    }

    $assessment = $state.Assessment
    if ($showView) {
        Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock {
            Show-AACPolicyAssessmentView -Assessment $assessment -Scope $state.Scope
        }
    }
    elseif ($interactive) {
        foreach ($notice in @($assessment.Notices)) { Write-AACMarkup "[grey58]$([Spectre.Console.Markup]::Escape($notice))[/]" }
    }
    if ($returnObjects) {
        foreach ($item in $assessment.Assignments) {
            $id = $item.ResourceId
            $item | Add-Member -NotePropertyMembers ([ordered]@{
                    Compliance = @($assessment.AssignmentCompliance | Where-Object AssignmentId -EQ $id)
                    PolicyStates = @($assessment.Policies | Where-Object AssignmentId -EQ $id)
                    Findings   = @($assessment.Findings | Where-Object ResourceId -EQ $id)
                }) -Force
            $item
        }
    }
}