Private/Write-AACFirewallRulePdf.ps1

function Write-AACFirewallRulePdf {
    <#
    .SYNOPSIS
        Writes AAC.FirewallRule objects (from Get-AACFirewallRule) as a
        landscape A4 PDF report.
    .DESCRIPTION
        Layout:
          1. Summary: title, where the rules came from, tiles with the number
             of policies, rule collection groups, rule collections and DNAT,
             network and application rules, and one row per policy (base
             policy, firewalls, location and counts).
          2. One part per policy (each starting on a new page): its rule
             collection groups by priority, and in each its rule collections
             by priority with their type and action (Allow in green, Deny in
             red, DNAT in amber), then a table of the collection's rules in
             order - source, destination, protocols and ports, and the DNAT
             translation or TLS inspection where there is one. IP Groups are
             shown by name with their addresses underneath.
 
        -Path must be a full path; see Save-AACPdfDocument.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Rule,

        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $Title,

        [System.Collections.IDictionary] $Detail
    )

    $policyCount = @($Rule | Select-Object -ExpandProperty FirewallPolicyId -Unique).Count
    $pdf = New-AACPdfDocument -Title $Title -Subject "$($Rule.Count) Azure Firewall rules in $policyCount policies" -Landscape
    $section = $pdf.Section
    $colors = $pdf.Colors
    $pt = $pdf.Pt
    $actionTone = @{ Allow = $pdf.Tone.Good; Deny = $pdf.Tone.Bad; DNAT = $pdf.Tone.Warn }

    $count = { param([string] $Type) @($Rule | Where-Object RuleType -eq $Type).Count }
    $unique = { param([string] $Property) @($Rule | ForEach-Object { "$($_.FirewallPolicyId)|$($_.$Property)" } | Select-Object -Unique).Count }

    # Adds "label values" as its own paragraph in a cell, when there are values.
    $addLabelled = {
        param($Cell, [string] $Label, [string] $Values)
        if (-not $Values) {
            return
        }
        $paragraph = $Cell.AddParagraph()
        if ($Label) {
            $labelText = $paragraph.AddFormattedText("$Label ")
            $labelText.Size = 7
            $labelText.Color = $colors.Muted
        }
        $paragraph.AddText($Values) | Out-Null
    }
    # IP Groups as "name" in semibold with the addresses in small grey under
    # it, from Get-AACFirewallRule's "name: a, b | name2: c" format.
    $addIpGroups = {
        param($Cell, [string] $Described)
        if (-not $Described) {
            return
        }
        foreach ($entry in ($Described -split ' \| ')) {
            $name, $addresses = $entry -split ': ', 2
            $nameText = $Cell.AddParagraph()
            $label = $nameText.AddFormattedText('IP group ')
            $label.Size = 7
            $label.Color = $colors.Muted
            $nameText.AddFormattedText($name).FontName = 'Segoe UI Semibold'
            if ($addresses) {
                $addressText = $Cell.AddParagraph($addresses)
                $addressText.Format.Font.Size = 7.5
                $addressText.Format.Font.Color = $colors.Muted
            }
        }
    }

    # --- 1. Summary ---------------------------------------------------------------
    & $pdf.AddTitle "$('{0:N0}' -f $Rule.Count) rules in $policyCount firewall $(if ($policyCount -eq 1) { 'policy' } else { 'policies' }) · generated $($pdf.Generated.ToString('dddd d MMMM yyyy, HH:mm'))"

    $facts = [ordered]@{}
    $session = $script:AACSession
    if ($session) {
        $facts['Azure account'] = [string]$session.Account
        $facts['Tenant'] = [string]$session.TenantId
    }
    if ($Detail) {
        foreach ($key in $Detail.Keys) { $facts[[string]$key] = [string]$Detail[$key] }
    }
    $facts['Rule order'] = 'Listed by rule collection group priority, then rule collection priority, as in the Azure portal. Azure Firewall applies all DNAT rules first, then network rules, then application rules (each in that priority order), and a base policy''s rules before the policy''s own.'
    $factTable = & $pdf.NewTable @(4.0, ($pdf.PageWidth - 4.0))
    foreach ($key in $facts.Keys) {
        $row = & $pdf.AddBodyRow $factTable
        $row.Cells[0].AddParagraph($key).Format.Font.Color = $colors.Muted
        $row.Cells[1].AddParagraph($facts[$key]) | Out-Null
    }

    $section.AddParagraph().Format.SpaceAfter = & $pt 6
    $tileData = @(
        @{ Value = $policyCount; Label = 'policies' }
        @{ Value = (& $unique 'RuleCollectionGroup'); Label = 'rule collection groups' }
        @{ Value = (& $unique 'RuleCollection'); Label = 'rule collections' }
        @{ Value = (& $count 'NatRule'); Label = 'DNAT rules' }
        @{ Value = (& $count 'NetworkRule'); Label = 'network rules' }
        @{ Value = (& $count 'ApplicationRule'); Label = 'application rules' }
    )
    $tileWidth = $pdf.PageWidth / $tileData.Count
    $tiles = & $pdf.NewTable @(1..$tileData.Count | ForEach-Object { $tileWidth })
    $tiles.TopPadding = & $pt 8
    $tiles.BottomPadding = & $pt 8
    $tileRow = $tiles.AddRow()
    for ($i = 0; $i -lt $tileData.Count; $i++) {
        $cell = $tileRow.Cells[$i]
        $cell.Shading.Color = $colors.Panel
        $cell.Borders.Left.Width = $(if ($i -gt 0) { 2 } else { 0 })
        $cell.Borders.Left.Color = $colors.White
        $number = $cell.AddParagraph(('{0:N0}' -f $tileData[$i].Value))
        $number.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
        $number.Format.Font.Size = 18
        $number.Format.Font.Name = 'Segoe UI Semibold'
        $caption = $cell.AddParagraph($tileData[$i].Label)
        $caption.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
        $caption.Format.Font.Size = 8
        $caption.Format.Font.Color = $colors.Muted
    }

    $policyGroups = @($Rule | Group-Object -Property FirewallPolicyId | Sort-Object { $_.Group[0].FirewallPolicy })

    $section.AddParagraph('Firewall policies', 'Heading2') | Out-Null
    $policyTable = & $pdf.NewTable @(4.6, 3.4, 4.2, 5.3, 2.2, 1.3, 1.5, 1.2, 1.2, 1.2)
    & $pdf.AddHeaderRow $policyTable @('Policy', 'Base policy', 'Firewalls', 'Subscription · resource group', 'Location', 'Groups', 'Collections', 'DNAT', 'Network', 'App') @(5, 6, 7, 8, 9)
    foreach ($policy in $policyGroups) {
        $first = $policy.Group[0]
        $row = & $pdf.AddBodyRow $policyTable
        $row.Cells[0].AddParagraph($first.FirewallPolicy).Format.Font.Name = 'Segoe UI Semibold'
        $row.Cells[1].AddParagraph($(if ($first.BasePolicy) { $first.BasePolicy } else { '-' })) | Out-Null
        $row.Cells[2].AddParagraph($(if ($first.Firewalls) { $first.Firewalls } else { 'not attached' })) | Out-Null
        $row.Cells[3].AddParagraph("$(if ($first.SubscriptionName) { $first.SubscriptionName } else { $first.SubscriptionId }) · $($first.ResourceGroup)") | Out-Null
        $row.Cells[4].AddParagraph([string]$first.Location) | Out-Null
        $values = @(
            @($policy.Group | Select-Object -ExpandProperty RuleCollectionGroup -Unique).Count
            @($policy.Group | ForEach-Object { "$($_.RuleCollectionGroup)|$($_.RuleCollection)" } | Select-Object -Unique).Count
            @($policy.Group | Where-Object RuleType -eq 'NatRule').Count
            @($policy.Group | Where-Object RuleType -eq 'NetworkRule').Count
            @($policy.Group | Where-Object RuleType -eq 'ApplicationRule').Count
        )
        for ($i = 0; $i -lt $values.Count; $i++) {
            $number = $row.Cells[$i + 5].AddParagraph(('{0:N0}' -f $values[$i]))
            $number.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Right
            if ($values[$i] -eq 0) { $number.Format.Font.Color = $colors.Muted }
        }
    }
    if ($policyGroups.Count -eq 0) {
        $none = $section.AddParagraph('No Firewall Policy rules were found.')
        $none.Format.Font.Color = $colors.Muted
    }

    # --- 2. Every rule, policy by policy -------------------------------------------------
    foreach ($policy in $policyGroups) {
        $first = $policy.Group[0]
        $section.AddPageBreak()
        $section.AddParagraph($first.FirewallPolicy, 'Heading1') | Out-Null
        $about = $section.AddParagraph(@(
                "Base policy: $(if ($first.BasePolicy) { $first.BasePolicy } else { 'none' })"
                "Firewalls: $(if ($first.Firewalls) { $first.Firewalls } else { 'not attached' })"
                "$(if ($first.SubscriptionName) { $first.SubscriptionName } else { $first.SubscriptionId }) · $($first.ResourceGroup) · $($first.Location)"
            ) -join ' · ')
        $about.Format.Font.Color = $colors.Muted
        $about.Format.SpaceAfter = & $pt 4

        foreach ($group in @($policy.Group | Group-Object -Property RuleCollectionGroup | Sort-Object { $_.Group[0].RuleCollectionGroupPriority }, Name)) {
            $groupHeading = $section.AddParagraph('', 'Heading2')
            $groupHeading.AddText($group.Name) | Out-Null
            $groupPriority = $groupHeading.AddFormattedText(" rule collection group · priority $($group.Group[0].RuleCollectionGroupPriority)")
            $groupPriority.Size = 9
            $groupPriority.Color = $colors.Muted

            foreach ($collection in @($group.Group | Group-Object -Property RuleCollection | Sort-Object { $_.Group[0].RuleCollectionPriority }, Name)) {
                $info = $collection.Group[0]
                $collectionHeading = $section.AddParagraph('', 'Heading3')
                $collectionHeading.AddText($collection.Name) | Out-Null
                $collectionMeta = $collectionHeading.AddFormattedText(" priority $($info.RuleCollectionPriority) · $($info.RuleCollectionType) · $($collection.Count) rule(s) ")
                $collectionMeta.Size = 8.5
                $collectionMeta.Color = $colors.Muted
                $tone = $actionTone[[string]$info.Action]
                if (-not $tone) { $tone = $pdf.Tone.Neutral }
                $actionText = $collectionHeading.AddFormattedText(" $(([string]$info.Action).ToUpperInvariant()) ")
                $actionText.Size = 8
                $actionText.Bold = $true
                $actionText.Color = $tone.Text
                $actionText.Font.Name = 'Segoe UI'

                $table = & $pdf.NewTable @(4.6, 6.2, 7.2, 4.1, 4.0)
                & $pdf.AddHeaderRow $table @('Rule', 'Source', 'Destination', 'Protocols and ports', 'Translation / inspection')
                foreach ($item in $collection.Group) {
                    $row = & $pdf.AddBodyRow $table
                    # A coloured bar on the left edge carries the collection's action.
                    $row.Cells[0].Borders.Left.Width = 2.5
                    $row.Cells[0].Borders.Left.Color = $tone.Solid

                    $name = $row.Cells[0].AddParagraph($item.RuleName)
                    $name.Format.Font.Name = 'Segoe UI Semibold'
                    $kind = $row.Cells[0].AddParagraph(($item.RuleType -replace 'Rule$', '' -replace '^Nat$', 'DNAT') + ' rule')
                    $kind.Format.Font.Size = 7
                    $kind.Format.Font.Color = $colors.Muted
                    if ($item.Description) {
                        $description = $row.Cells[0].AddParagraph($item.Description)
                        $description.Format.Font.Size = 7.5
                        $description.Format.Font.Color = $colors.Muted
                    }

                    & $addLabelled $row.Cells[1] '' $item.SourceAddresses
                    & $addIpGroups $row.Cells[1] $item.SourceIpGroupAddresses

                    & $addLabelled $row.Cells[2] '' $item.DestinationAddresses
                    & $addIpGroups $row.Cells[2] $item.DestinationIpGroupAddresses
                    & $addLabelled $row.Cells[2] 'FQDNs' $item.DestinationFqdns
                    & $addLabelled $row.Cells[2] 'FQDNs' $item.TargetFqdns
                    & $addLabelled $row.Cells[2] 'URLs' $item.TargetUrls
                    & $addLabelled $row.Cells[2] 'FQDN tags' $item.FqdnTags
                    & $addLabelled $row.Cells[2] 'Web categories' $item.WebCategories

                    & $addLabelled $row.Cells[3] '' $item.Protocols
                    & $addLabelled $row.Cells[3] 'Ports' $item.DestinationPorts

                    if ($item.RuleType -eq 'NatRule') {
                        $target = if ($item.TranslatedFqdn) { $item.TranslatedFqdn } else { $item.TranslatedAddress }
                        & $addLabelled $row.Cells[4] 'To' "$target$(if ($item.TranslatedPort) { ":$($item.TranslatedPort)" })"
                    }
                    elseif ($item.RuleType -eq 'ApplicationRule') {
                        & $addLabelled $row.Cells[4] 'TLS inspection' $(if ($item.TerminateTls) { 'on' } else { 'off' })
                    }
                    foreach ($cell in $row.Cells) {
                        if ($cell.Elements.Count -eq 0) {
                            $cell.AddParagraph('-').Format.Font.Color = $colors.Muted
                        }
                    }
                }
            }
        }
    }

    Save-AACPdfDocument -Pdf $pdf -Path $Path
}