Private/Show-AACFirewallRuleView.ps1

function Show-AACFirewallRuleView {
    <#
    .SYNOPSIS
        Renders AAC.FirewallRule objects (from Get-AACFirewallRule) as the
        Spectre.Console view shown at the prompt.
    .DESCRIPTION
        Layout, following the policy hierarchy:
 
          ── Azure Admin Console :: Azure Firewall ──────────────────────
          account · tenant · scope · filters · when
          [ rules ] [ allow ] [ deny ] [ DNAT ] [ policies ] [ collections ]
 
          Firewall policies: one row per policy - base policy, attached
          firewalls, where it lives and its rule counts.
 
          ── fwpol-hub ── base: fwpol-base · firewalls: afw-hub ─────────
          ╭ rcg-platform · 100 › Allow-Web · 200 · Filter ALLOW ─────╮ <- green
          │ # │ Rule │ Source │ Destination │ Protocols │ Extra │
          ╰───────────────────────────────────────────────────────────────╯
          ╭ rcg-platform · 100 › Block-Legacy · 300 · Filter DENY ────╮ <- red
 
        Each rule collection is one table, in priority order. Its border and
        badge carry the action: Allow in green, Deny in red, DNAT in orange.
        IP Groups are shown by name with their addresses under them in grey,
        and an Allow rule open to any source or destination ('*') has the
        '*' called out in yellow. The last column holds the DNAT
        translation, or the TLS inspection setting of application rules.
 
        Output goes straight to the Spectre console, so wrap the call in
        Invoke-AACPagedOutput to page it.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Rule,

        [System.Collections.IDictionary] $Scope,

        # The command has already written the title, above its progress display.
        [switch] $NoTitle
    )

    $escape = { param($Text) [Spectre.Console.Markup]::Escape([string]$Text) }
    # Unicode symbols, or ASCII in a console that isn't UTF-8.
    $glyph = Get-AACGlyph
    $actionStyle = @{
        Allow = @{ Color = 'green3'; Badge = '[bold white on green4] ALLOW [/]' }
        Deny  = @{ Color = 'red1'; Badge = '[bold white on red3] DENY [/]' }
        DNAT  = @{ Color = 'orange1'; Badge = '[bold black on orange1] DNAT [/]' }
    }
    $styleOf = { param([string] $Action) $s = $actionStyle[$Action]; if ($s) { $s } else { @{ Color = 'grey70'; Badge = "[black on grey70] $(& $escape $Action.ToUpperInvariant()) [/]" } } }
    $unique = { param([string] $Property) @($Rule | ForEach-Object { "$($_.FirewallPolicyId)|$($_.$Property)" } | Select-Object -Unique).Count }

    # --- Header -------------------------------------------------------------------------
    if (-not $NoTitle) {
        Write-AACRule -Title 'Azure Admin Console :: Azure Firewall' -Color 'deepskyblue3_1'
    }
    $facts = [System.Collections.Generic.List[string]]::new()
    $session = $script:AACSession
    if ($session) {
        $facts.Add("[white]$(& $escape $session.Account)[/]")
        $facts.Add("tenant $(& $escape $session.TenantId)")
    }
    if ($Scope) {
        foreach ($key in $Scope.Keys) {
            $value = [string]$Scope[$key]
            if ($key -eq 'Subscriptions') {
                $ids = @($value -split ',\s*' | Where-Object { $_ -match '^[0-9a-fA-F-]{36}$' })
                $facts.Add($(if ($ids) { "$($ids.Count) subscription$(if ($ids.Count -ne 1) { 's' })" } else { 'all subscriptions' }))
            }
            else {
                $facts.Add("$(& $escape $key.ToLowerInvariant()): $(& $escape $value)")
            }
        }
    }
    $facts.Add((Get-Date).ToString('d MMM yyyy HH:mm'))
    Write-AACMarkup "[grey58]$($facts -join " $($glyph.Dot) ")[/]"
    [Spectre.Console.AnsiConsole]::WriteLine()

    if ($Rule.Count -eq 0) {
        Show-AACPanel -Content '[bold]No Firewall Policy rules[/] [grey58]were found for this account and these filters.[/]' -BorderColor 'grey50' -AllowMarkup
        return
    }

    $policies = @($Rule | Group-Object -Property FirewallPolicyId | Sort-Object { $_.Group[0].FirewallPolicy })
    Show-AACTileRow -Tile @(
        @{ Value = '{0:N0}' -f $Rule.Count; Caption = 'rules'; Color = 'deepskyblue3_1' }
        @{ Value = '{0:N0}' -f @($Rule | Where-Object Action -eq 'Allow').Count; Caption = 'allow'; Color = 'green3' }
        @{ Value = '{0:N0}' -f @($Rule | Where-Object Action -eq 'Deny').Count; Caption = 'deny'; Color = 'red1' }
        @{ Value = '{0:N0}' -f @($Rule | Where-Object RuleType -eq 'NatRule').Count; Caption = 'DNAT'; Color = 'orange1' }
        @{ Value = '{0:N0}' -f $policies.Count; Caption = 'policies'; Color = 'mediumpurple2' }
        @{ Value = '{0:N0}' -f (& $unique 'RuleCollection'); Caption = 'rule collections'; Color = 'grey70' }
    )

    # --- Policies ----------------------------------------------------------------------------
    $table = [Spectre.Console.Table]::new()
    $table.Border = [Spectre.Console.TableBorder]::Rounded
    $table.BorderStyle = [Spectre.Console.Style]::Parse('grey35')
    $table.Expand = $true
    $table.Title = [Spectre.Console.TableTitle]::new('[bold]Firewall policies[/]')
    foreach ($header in 'Policy', 'Base policy', 'Firewalls', 'Subscription / resource group', 'DNAT', 'Network', 'App', 'Allow', 'Deny') {
        $column = [Spectre.Console.TableColumn]::new("[grey62]$header[/]")
        if ($header -in 'DNAT', 'Network', 'App', 'Allow', 'Deny') {
            $column.Alignment = [Spectre.Console.Justify]::Right
            $column.NoWrap = $true
        }
        $table.AddColumn($column) | Out-Null
    }
    $count = {
        param($Items, [string] $Color)
        $n = @($Items).Count
        if ($n -eq 0) { '[grey42]0[/]' } else { "[$Color]$('{0:N0}' -f $n)[/]" }
    }
    foreach ($policy in $policies) {
        $first = $policy.Group[0]
        $where = "$(if ($first.SubscriptionName) { $first.SubscriptionName } else { $first.SubscriptionId }) $($glyph.Dot) $($first.ResourceGroup)"
        [Spectre.Console.TableExtensions]::AddRow($table, [Spectre.Console.Rendering.IRenderable[]]@(
                [Spectre.Console.Markup]::new("[bold]$(& $escape $first.FirewallPolicy)[/]")
                [Spectre.Console.Markup]::new($(if ($first.BasePolicy) { & $escape $first.BasePolicy } else { '[grey42]-[/]' }))
                [Spectre.Console.Markup]::new($(if ($first.Firewalls) { & $escape $first.Firewalls } else { '[grey42]not attached[/]' }))
                [Spectre.Console.Markup]::new("[grey70]$(& $escape $where)[/]")
                [Spectre.Console.Markup]::new((& $count @($policy.Group | Where-Object RuleType -eq 'NatRule') 'orange1'))
                [Spectre.Console.Markup]::new((& $count @($policy.Group | Where-Object RuleType -eq 'NetworkRule') 'white'))
                [Spectre.Console.Markup]::new((& $count @($policy.Group | Where-Object RuleType -eq 'ApplicationRule') 'white'))
                [Spectre.Console.Markup]::new((& $count @($policy.Group | Where-Object Action -eq 'Allow') 'green3'))
                [Spectre.Console.Markup]::new((& $count @($policy.Group | Where-Object Action -eq 'Deny') 'red1'))
            )) | Out-Null
    }
    [Spectre.Console.AnsiConsole]::WriteLine()
    [Spectre.Console.AnsiConsole]::Write($table)

    # --- Rules, policy by policy ---------------------------------------------------------------
    # A list of values, one per line; '*' on an Allow rule is called out.
    $lines = {
        param([string] $Values, [string] $Label, [bool] $FlagAny)
        foreach ($value in @($Values -split ',\s*' | Where-Object { $_ })) {
            $text = if ($FlagAny -and $value -eq '*') { '[bold yellow]* (any)[/]' } else { & $escape $value }
            if ($Label) { "[grey50]$Label[/] $text" } else { $text }
        }
    }
    # IP Groups, from "name: a, b | name2: c": the name, then its addresses in grey.
    $ipGroups = {
        param([string] $Described)
        foreach ($entry in @($Described -split ' \| ' | Where-Object { $_ })) {
            $name, $addresses = $entry -split ': ', 2
            "[deepskyblue1]$(& $escape $name)[/] [grey50](IP group)[/]"
            if ($addresses) { "[grey50]$(& $escape $addresses)[/]" }
        }
    }
    $cell = { param($Lines) [Spectre.Console.Markup]::new($(if (@($Lines).Count) { @($Lines) -join "`n" } else { '[grey42]-[/]' })) }

    foreach ($policy in $policies) {
        $first = $policy.Group[0]
        $about = @(
            if ($first.BasePolicy) { "base: $($first.BasePolicy)" }
            "firewalls: $(if ($first.Firewalls) { $first.Firewalls } else { 'not attached' })"
        ) -join " $($glyph.Dot) "
        [Spectre.Console.AnsiConsole]::WriteLine()
        Write-AACRule -Title "[bold]$(& $escape $first.FirewallPolicy)[/] [grey58]$(& $escape $about)[/]" -Color 'mediumpurple2'

        $collections = @($policy.Group | Group-Object -Property RuleCollectionGroup, RuleCollection | Sort-Object { $_.Group[0].RuleCollectionGroupPriority }, { $_.Group[0].RuleCollectionPriority }, Name)
        foreach ($collection in $collections) {
            $info = $collection.Group[0]
            $style = & $styleOf ([string]$info.Action)
            $isAllow = $info.Action -eq 'Allow'

            $table = [Spectre.Console.Table]::new()
            $table.Border = [Spectre.Console.TableBorder]::Rounded
            $table.BorderStyle = [Spectre.Console.Style]::Parse($style.Color)
            $table.Title = [Spectre.Console.TableTitle]::new(
                "[grey58]$(& $escape $info.RuleCollectionGroup) $($glyph.Dot) $($info.RuleCollectionGroupPriority) $($glyph.Chevron)[/] [bold]$(& $escape $info.RuleCollection)[/] [grey58]$($glyph.Dot) $($info.RuleCollectionPriority) $($glyph.Dot) $(& $escape $info.RuleCollectionType) $($glyph.Dot) $($collection.Count) rule$(if ($collection.Count -ne 1) { 's' })[/] $($style.Badge)")
            # A line between rules, and the same column widths in every table
            # so they line up down the page: shares of the terminal width,
            # less the borders and padding (16 characters for 5 columns).
            $table.ShowRowSeparators = $true
            $table.Expand = $false
            $available = [Math]::Max(80, [Spectre.Console.AnsiConsole]::Profile.Width) - 16
            foreach ($column in @(@('Rule', 0.20), @('Source', 0.23), @('Destination', 0.25), @('Protocols / ports', 0.15), @('Translation / TLS', 0.17))) {
                $tableColumn = [Spectre.Console.TableColumn]::new("[grey62]$($column[0])[/]")
                $tableColumn.Width = [int][Math]::Floor($available * $column[1])
                $table.AddColumn($tableColumn) | Out-Null
            }

            foreach ($item in $collection.Group) {
                $kind = ([string]$item.RuleType -replace 'Rule$', '' -replace '^Nat$', 'DNAT').ToLowerInvariant()
                $ruleLines = @(
                    "[bold]$(& $escape $item.RuleName)[/]"
                    "[grey50]$kind rule[/]"
                    if ($item.Description) { "[grey50 italic]$(& $escape $item.Description)[/]" }
                )
                $source = @(
                    & $lines $item.SourceAddresses '' $isAllow
                    & $ipGroups $item.SourceIpGroupAddresses
                )
                $destination = @(
                    & $lines $item.DestinationAddresses '' $isAllow
                    & $ipGroups $item.DestinationIpGroupAddresses
                    & $lines $item.DestinationFqdns 'fqdn' $isAllow
                    & $lines $item.TargetFqdns 'fqdn' $isAllow
                    & $lines $item.TargetUrls 'url' $false
                    & $lines $item.FqdnTags 'tag' $false
                    & $lines $item.WebCategories 'category' $false
                )
                $protocols = @(
                    & $lines $item.Protocols '' $false
                    if ($item.DestinationPorts) { "[grey50]ports[/] $(& $escape $item.DestinationPorts)" }
                )
                $extra = @(
                    if ($item.RuleType -eq 'NatRule') {
                        $target = if ($item.TranslatedFqdn) { $item.TranslatedFqdn } else { $item.TranslatedAddress }
                        "[orange1]$($glyph.Arrow) $(& $escape $target)$(if ($item.TranslatedPort) { ":$(& $escape $item.TranslatedPort)" })[/]"
                    }
                    elseif ($item.RuleType -eq 'ApplicationRule') {
                        if ($item.TerminateTls) { '[green3]TLS inspection on[/]' } else { '[grey50]TLS inspection off[/]' }
                    }
                )
                [Spectre.Console.TableExtensions]::AddRow($table, [Spectre.Console.Rendering.IRenderable[]]@(
                        (& $cell $ruleLines), (& $cell $source), (& $cell $destination), (& $cell $protocols), (& $cell $extra)
                    )) | Out-Null
            }
            [Spectre.Console.AnsiConsole]::WriteLine()
            [Spectre.Console.AnsiConsole]::Write($table)
        }
    }
}