Public/Get-AACKeyVaultInsight.ps1

function Get-AACKeyVaultInsight {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Key Vault insights across subscriptions - what Azure Monitor's Key
        Vault workbook shows (availability, transactions, latency, failures by
        status, operations from the audit logs) plus saturation, posture,
        callers that are denied or throttled, and secrets, keys and
        certificates near expiry - as a heat map, findings, a
        Spectre.Console view, objects, and CSV, PDF and interactive HTML
        reports.
    .DESCRIPTION
        Read-only. Always (Reader is enough):
          Resource Graph the vaults, their private endpoints, the Defender
                           for Key Vault plan and its alerts
          metrics per vault over -TimeRange: Availability,
                           ServiceApiHit (transactions, and by request type),
                           ServiceApiLatency, ServiceApiResult by status code
                           (2xx, 401, 403, 429, other) and SaturationShoebox
                           (Overall Vault Saturation)
          settings whether AuditEvent reaches a Log Analytics
                           workspace, and in which table
        -IncludeOperations (Log Analytics Reader): the audit logs - both
        AzureDiagnostics and AZKVAuditLogs - by vault, operation and caller,
        with failures, denials (401/403), throttling (429) and p99 duration;
        the SDK's normal Authentication challenge (401) is left out, as in the
        workbook.
        -IncludeObjects (Key Vault Reader, or a List access policy): secrets,
        keys and certificates - metadata only, never a value - with expired,
        expiring within -ExpiryDays and no-expiry. A vault whose firewall
        blocks this machine, or that denies the account, is reported as such.
 
        Findings (High, Medium, Low): soft delete or purge protection off,
        open to every network, access policies instead of RBAC, no audit logs,
        Defender off or alerting, availability under 99.9%, throttling,
        saturation at 50%+ (High at 75%), 403s, other failures, idle vaults,
        callers denied 10+ times, expired, expiring and never-expiring objects.
 
        The heat map scores each vault 0-100 for availability, failures,
        throttling, saturation, posture and objects - the 40 worst at the
        prompt and in the reports; clicking a cell in the HTML lists its
        findings.
 
        -View sets what is returned (piped onward, -PassThru, -NoDisplay) and
        written to -CsvPath: Vault (default), Operation, Object or Finding.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups.
    .PARAMETER ResourceGroupName
        Only these resource groups.
    .PARAMETER Name
        Only vaults with these names; wildcards work.
    .PARAMETER TimeRange
        How far back the metrics and logs go: 1h, 4h, 12h, 1d (default), 3d,
        7d, 14d or 30d.
    .PARAMETER IncludeOperations
        Read the audit logs: operations and callers.
    .PARAMETER IncludeObjects
        Read secrets, keys and certificates (metadata only) for their expiry.
    .PARAMETER ExpiryDays
        An object expiring within this many days is Expiring (30 by default).
    .PARAMETER View
        Vault, Operation, Object or Finding: what is returned and written to
        -CsvPath.
    .PARAMETER ThrottleLimit
        How many requests run at once (1 to 32; 12 by default).
    .PARAMETER CsvPath
        Write the -View rows to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the rows.
    .PARAMETER NoDisplay
        Return the rows without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Connect-AAC
        Get-AACKeyVaultInsight
        Every vault's health, posture and findings over the last day.
    .EXAMPLE
        Get-AACKeyVaultInsight -ResourceGroupName 'rg-app-prod' -TimeRange 7d -IncludeOperations -IncludeObjects -HtmlPath .\out\KeyVault.html -PdfPath .\out\KeyVault.pdf
        A week of a workload's vaults: operations, callers, objects near expiry, as reports.
    .EXAMPLE
        Get-AACKeyVaultInsight -IncludeObjects -View Object -NoDisplay | Where-Object Status -In 'Expired', 'Expiring'
        Secrets, keys and certificates to rotate.
    .EXAMPLE
        Get-AACKeyVaultInsight -IncludeOperations -View Operation -NoDisplay | Where-Object AuthDenied | Sort-Object AuthDenied -Descending | Select-Object -First 10
        The callers denied most often.
    .OUTPUTS
        AAC.KeyVaultInsight, AAC.KeyVaultOperation, AAC.KeyVaultObject or AAC.KeyVaultFinding, by -View (piped onward, or with -PassThru or -NoDisplay)
    #>

    [CmdletBinding()]
    [OutputType('AAC.KeyVaultInsight', 'AAC.KeyVaultOperation', 'AAC.KeyVaultObject', 'AAC.KeyVaultFinding')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [ValidateNotNullOrEmpty()]
        [string[]] $ManagementGroupId,

        [ValidateNotNullOrEmpty()]
        [string[]] $ResourceGroupName,

        [SupportsWildcards()]
        [string[]] $Name,

        [ValidateSet('1h', '4h', '12h', '1d', '3d', '7d', '14d', '30d')]
        [string] $TimeRange = '1d',

        [switch] $IncludeOperations,

        [switch] $IncludeObjects,

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

        [ValidateSet('Vault', 'Operation', 'Object', 'Finding')]
        [string] $View = 'Vault',

        [ValidateRange(1, 32)]
        [int] $ThrottleLimit = 12,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Key Vault insights',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    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
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    # TimeRange -> the span and the metric grain (an interval Azure Monitor accepts).
    $range = @{ '1h' = @(1, 'PT5M', 'PT1H'); '4h' = @(4, 'PT15M', 'PT4H'); '12h' = @(12, 'PT30M', 'PT12H'); '1d' = @(24, 'PT1H', 'P1D'); '3d' = @(72, 'PT6H', 'P3D'); '7d' = @(168, 'PT6H', 'P7D'); '14d' = @(336, 'PT12H', 'P14D'); '30d' = @(720, 'P1D', 'P30D') }[$TimeRange]
    $end = [datetime]::UtcNow
    $request = @{
        SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); Name = @($Name | Where-Object { $_ })
        Timespan = "$($end.AddHours(-$range[0]).ToString('yyyy-MM-ddTHH:mm:ssZ'))/$($end.ToString('yyyy-MM-ddTHH:mm:ssZ'))"; Interval = $range[1]; Duration = $range[2]
        IncludeOperations = [bool]$IncludeOperations; IncludeObjects = [bool]$IncludeObjects; ExpiryDays = $ExpiryDays; ThrottleLimit = $ThrottleLimit
    }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Key Vault insights' -Color 'gold1' }
    $state = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"

        Update-AACProgress -Id 'read' -Indeterminate -Description 'Reading the vaults, Defender and workspaces (Resource Graph)'
        $queries = Get-AACKeyVaultQuery -ResourceGroupName $request.ResourceGroupName
        $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @('pricing', 'alerts', 'workspaces')
        $vaults = @($read.Rows['vaults'] | Where-Object { $n = [string]$_['name']; $null -ne $_ -and (-not $request.Name.Count -or @($request.Name | Where-Object { $n -like $_ }).Count) })
        Update-AACProgress -Id 'read' -Complete -Description ('Found {0:N0} vault(s)' -f $vaults.Count)
        $notices = [System.Collections.Generic.List[string]]::new()
        foreach ($key in @($read.Errors.Keys | Sort-Object)) { $notices.Add("The $key couldn't be read: $($read.Errors[$key] -replace '\s+', ' ')") }

        Update-AACProgress -Id 'metrics' -Total ([Math]::Max(1, $vaults.Count * 4)) -Description 'Reading metrics and diagnostic settings'
        $telemetry = Read-AACKeyVaultTelemetry -VaultId @($vaults | ForEach-Object { [string]$_['id'] }) -Timespan $request.Timespan -Interval $request.Interval -ThrottleLimit $request.ThrottleLimit -OnProgress { param($Done, $Total) Update-AACProgress -Id 'metrics' -Increment 1 }
        Update-AACProgress -Id 'metrics' -Complete -Description ('Read metrics and settings for {0:N0} vault(s)' -f $vaults.Count)

        $objects = $null
        if ($request.IncludeObjects) {
            Update-AACProgress -Id 'objects' -Indeterminate -Description 'Listing secrets, keys and certificates (metadata only)'
            $objects = Read-AACKeyVaultObject -Vault @($vaults | ForEach-Object { @{ Id = [string]$_['id']; Name = [string]$_['name']; Uri = $(if ($_['properties'] -and $_['properties']['vaultUri']) { [string]$_['properties']['vaultUri'] } else { "https://$($_['name']).vault.azure.net/" }) } }) -ThrottleLimit ([Math]::Min(8, $request.ThrottleLimit))
            $blocked = @($objects.Access.Values | Where-Object { $_ -ne 'Read' }).Count
            Update-AACProgress -Id 'objects' -Complete -Description ('Read {0:N0} object(s) from {1:N0} vault(s){2}' -f @($objects.Items).Count, ($vaults.Count - $blocked), $(if ($blocked) { "; $blocked not readable" }))
        }
        $workspaceName = @{}
        $customerId = @{}
        foreach ($w in @($read.Rows['workspaces'])) { if ($w) { $workspaceName[[string]$w['id']] = [string]$w['name']; $customerId[[string]$w['id']] = [string]$w['customerId'] } }
        $common = @{ Vault = $vaults; Telemetry = $telemetry; Pricing = @($read.Rows['pricing']); Alert = @($read.Rows['alerts']); WorkspaceName = $workspaceName; Object = $objects; SubscriptionName = $scope.Names; ExpiryDays = $request.ExpiryDays }
        $result = ConvertTo-AACKeyVaultInsight @common

        if ($request.IncludeOperations) {
            $targets = @{}
            foreach ($v in $result.Vaults) {
                if (-not $v.AuditWorkspaceId) { continue }
                $cid = $customerId[$v.AuditWorkspaceId]
                if (-not $cid) { $notices.Add("$($v.Vault) sends its audit logs to a workspace your account can't see ($(($v.AuditWorkspaceId -split '/')[-1])); its operations are left out."); continue }
                if (-not $targets.Contains($cid)) { $targets[$cid] = [System.Collections.Generic.List[string]]::new() }
                $targets[$cid].Add($v.ResourceId)
            }
            Update-AACProgress -Id 'logs' -Indeterminate -Description ('Reading audit logs from {0:N0} workspace(s)' -f $targets.Count)
            $logs = Read-AACKeyVaultLog -Workspace $targets -Duration $request.Duration
            foreach ($cid in @($logs.Errors.Keys)) { $notices.Add("Audit logs couldn't be read from workspace $($cid): $($logs.Errors[$cid] -replace '\s+', ' ')") }
            $result = ConvertTo-AACKeyVaultInsight @common -Log $logs.Rows
            Update-AACProgress -Id 'logs' -Complete -Description ('Read {0:N0} operation row(s)' -f @($result.Operations).Count)
        }
        @{ Result = $result; Scope = $scope; Notices = $notices.ToArray() }
    }

    # --- Report ------------------------------------------------------------------------------------------------
    $r = $state.Result
    $stats = $r.Stats
    $rank = Get-AACSeverityRank
    $show = { param($Value, [string] $Format = 'N0') if ($null -eq $Value) { '-' } else { ([double]$Value).ToString($Format) } }
    $vaults = @($r.Vaults | Sort-Object -Property @{ Expression = { if ($null -eq $_.Score) { 101 } else { $_.Score } } }, Vault)
    $availabilities = @($r.Vaults | Where-Object { $null -ne $_.Availability -and $_.Transactions } | ForEach-Object { $_.Availability })
    $worstAvailability = if ($availabilities.Count) { ($availabilities | Measure-Object -Minimum).Minimum } else { $null }
    $deniedCallers = @($r.Operations | Where-Object AuthDenied | Group-Object -Property Caller | ForEach-Object { @{ Label = $(if ($_.Name) { $_.Name } else { '(unknown)' }); Value = ($_.Group | Measure-Object -Property AuthDenied -Sum).Sum; Filter = $_.Name } } | Sort-Object -Property { $_.Value } -Descending | Select-Object -First 10)
    $tones = @{ Expired = 'bad'; Expiring = 'warn'; 'No expiry' = 'neutral'; OK = 'good'; Disabled = 'neutral' }
    $report = @{
        Subtitle = "Key Vault insights - last $TimeRange"
        Facts    = [ordered]@{ Scope = $state.Scope.Label; 'Time range' = $TimeRange; Operations = $(if ($IncludeOperations) { 'audit logs read' } else { 'not read (-IncludeOperations)' }); Objects = $(if ($IncludeObjects) { "read; expiring within $ExpiryDays days" } else { 'not read (-IncludeObjects)' }) }
        Status   = $(if ($stats.High) { 'Failed' } elseif (@($r.Findings).Count) { 'Warning' } else { 'Success' })
        Headline = $(if (-not $stats.Vaults) { 'No Key Vault in scope.' } else { "$($stats.Vaults) vault(s), $('{0:N0}' -f $stats.Transactions) transaction(s): $($stats.High) high finding(s); $('{0:N0}' -f $stats.Throttled) throttled, $('{0:N0}' -f $stats.Forbidden) denied (403)$(if ($IncludeObjects) { "; $($stats.Expired) expired, $($stats.Expiring) expiring" })" })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $stats.Vaults; Label = 'vaults'; Tone = 'info'; Table = 'vaults' }
            @{ Value = & $show $worstAvailability 'N2'; Label = 'lowest availability %'; Tone = $(if ($null -eq $worstAvailability) { 'neutral' } elseif ($worstAvailability -ge 99.9) { 'good' } else { 'bad' }) }
            @{ Value = '{0:N0}' -f $stats.Throttled; Label = 'throttled (429)'; Tone = $(if ($stats.Throttled) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Signal = 'Throttling' } }
            @{ Value = '{0:N0}' -f $stats.Saturated; Label = 'saturation 75%+'; Tone = $(if ($stats.Saturated) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Signal = 'Saturation' } }
            @{ Value = '{0:N0}' -f $stats.NoAuditLogs; Label = 'without audit logs'; Tone = $(if ($stats.NoAuditLogs) { 'warn' } else { 'good' }); Table = 'vaults'; Filters = @{ AuditLogsTo = '' } }
            if ($IncludeObjects) { @{ Value = '{0:N0}' -f ($stats.Expired + $stats.Expiring); Label = 'objects expired or expiring'; Tone = $(if ($stats.Expired) { 'bad' } elseif ($stats.Expiring) { 'warn' } else { 'good' }); Table = 'objects' } }
            @{ Value = '{0:N0}' -f $stats.High; Label = 'high findings'; Tone = $(if ($stats.High) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Severity = 'High' } }
        )
        Notices  = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        HeatMaps = @($(if (@($r.HeatMap.Rows).Count) { $r.HeatMap }))
        Charts   = @(
            @{ Title = 'Requests by status'; Kind = 'donut'; CenterLabel = 'requests'; Items = @(foreach ($k in '2xx', '401', '403', '429', 'Other') { if ($r.StatusCodes[$k]) { @{ Label = $k; Value = $r.StatusCodes[$k]; Tone = @{ '2xx' = 'good'; '401' = 'neutral'; '403' = 'bad'; '429' = 'bad'; Other = 'warn' }[$k] } } }) }
            @{ Title = 'Request types'; Items = @($r.RequestTypes.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Key; Value = $_.Value } }); Tone = 'info' }
            @{ Title = 'Callers denied most (401/403)'; Items = $deniedCallers; Table = 'operations'; Column = 'Caller'; Tone = 'bad'; Console = [bool]$deniedCallers.Count }
        )
        Tabs     = @(
            @{ Name = 'Vaults'; Badge = $stats.Vaults; Tone = 'info'; Note = 'Each vault''s health over the period (the workbook''s Overview and Failures), saturation, posture and audit logs.' }
            @{ Name = 'Findings'; Badge = @($r.Findings).Count; Tone = $(if ($stats.High) { 'bad' } else { 'warn' }); Note = 'What to fix, worst first.' }
            @{ Name = 'Operations'; Badge = @($r.Operations).Count; Tone = 'violet'; Note = 'The audit logs by vault, operation and caller (the workbook''s Operations tab), with denials and throttling.' }
            @{ Name = 'Objects'; Badge = @($r.Objects).Count; Tone = 'neutral'; Note = "Secrets, keys and certificates - metadata only - expired, expiring within $ExpiryDays days, or never expiring." }
        )
        Tables   = @(
            @{ Id = 'vaults'; Title = 'Vaults'; Section = 'Vaults'; Rows = $vaults; Noun = 'vaults'; GroupBy = @('Subscription', 'ResourceGroup', 'Network', 'PermissionModel'); ConsoleLimit = 15
                Columns = @(
                    @{ Key = 'Vault'; Label = 'Vault'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'Score'; Label = 'Score'; Type = 'score'; Format = 'N0'; Console = $true; Pdf = $true }
                    @{ Key = 'Availability'; Label = 'Availability %'; Type = 'number'; Format = 'N2'; Console = $true; Pdf = $true }
                    @{ Key = 'Transactions'; Label = 'Transactions'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Forbidden'; Label = '403'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Throttled'; Label = '429'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true }
                    @{ Key = 'OtherFailures'; Label = 'Other failures'; Type = 'number'; Sum = $true }
                    @{ Key = 'Unauthorized'; Label = '401 (incl. SDK challenge)'; Type = 'number'; Sum = $true }
                    @{ Key = 'LatencyAvgMs'; Label = 'Latency ms'; Type = 'number'; Format = 'N0'; Console = $true; Pdf = $true }
                    @{ Key = 'SaturationMax'; Label = 'Saturation max %'; Type = 'number'; Format = 'N0'; Console = $true; Pdf = $true }
                    @{ Key = 'Network'; Label = 'Network'; Type = 'badge'; Tones = @{ Private = 'good'; Firewall = 'info'; Public = 'warn' }; Facet = $true; Pdf = $true }
                    @{ Key = 'PermissionModel'; Label = 'Permissions'; Facet = $true }
                    @{ Key = 'PurgeProtection'; Label = 'Purge protection'; Facet = $true }
                    @{ Key = 'AuditLogsTo'; Label = 'Audit logs to'; Facet = $true }
                    @{ Key = 'Defender'; Label = 'Defender'; Type = 'badge'; Tones = @{ On = 'good'; Off = 'warn' }; Facet = $true }
                    @{ Key = 'RequestTypes'; Label = 'Top request types'; Type = 'wide' }
                    @{ Key = 'Expired'; Label = 'Expired'; Type = 'number'; Sum = $true }
                    @{ Key = 'Expiring'; Label = 'Expiring'; Type = 'number'; Sum = $true }
                    @{ Key = 'ObjectAccess'; Label = 'Objects'; Facet = $true }
                    @{ Key = 'Findings'; Label = 'Findings'; Type = 'number'; Sum = $true }
                    @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                ) }
            @{ Id = 'findings'; Title = 'Findings'; Section = 'Findings'; Rows = @($r.Findings); Noun = 'findings'; GroupBy = @('Signal', 'Finding', 'Severity', 'Vault'); ConsoleLimit = 20
                Empty = 'Nothing to fix.'; EmptyStatus = 'Success'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Vault'; Label = 'Vault'; Type = 'resource'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Signal'; Label = 'Signal'; Facet = $true; Console = $true }
                    @{ Key = 'Finding'; Label = 'Finding'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Recommendation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                ) }
            @{ Id = 'operations'; Title = 'Operations and callers'; Section = 'Operations'; Rows = @($r.Operations | Sort-Object -Property @{ Expression = 'Failed'; Descending = $true }, @{ Expression = 'Count'; Descending = $true }); Noun = 'rows'; GroupBy = @('Vault', 'Operation', 'Caller'); ConsoleLimit = 15
                Columns = @(
                    @{ Key = 'Vault'; Label = 'Vault'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Operation'; Label = 'Operation'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Caller'; Label = 'Caller (app or object ID)'; Type = 'mono'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'CallerIp'; Label = 'Caller IP'; Type = 'mono' }
                    @{ Key = 'Count'; Label = 'Count'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Failed'; Label = 'Failed'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true }
                    @{ Key = 'AuthDenied'; Label = '401/403'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Throttled'; Label = '429'; Type = 'number'; Sum = $true; Pdf = $true }
                    @{ Key = 'P99Ms'; Label = 'p99 ms'; Type = 'number'; Format = 'N0' }
                ) }
            @{ Id = 'objects'; Title = 'Secrets, keys and certificates'; Section = 'Objects'; Rows = @($r.Objects | Sort-Object -Property @{ Expression = { @{ Expired = 0; Expiring = 1; 'No expiry' = 2; OK = 3; Disabled = 4 }[$_.Status] } }, DaysLeft, Vault); Noun = 'objects'; GroupBy = @('Status', 'Kind', 'Vault'); ConsoleLimit = 15
                Columns = @(
                    @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = $tones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Vault'; Label = 'Vault'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Kind'; Label = 'Kind'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Name'; Label = 'Name'; Console = $true; Pdf = $true }
                    @{ Key = 'Expires'; Label = 'Expires'; Type = 'date'; Soon = $true; Console = $true; Pdf = $true }
                    @{ Key = 'DaysLeft'; Label = 'Days left'; Type = 'number'; Console = $true }
                ) }
        )
        Hint     = '-IncludeOperations reads the audit logs (callers, denials); -IncludeObjects reads expiry dates; -TimeRange 7d looks further back; -View Operation, Object or Finding returns those rows; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    $rows = switch ($View) { 'Operation' { @($r.Operations) } 'Object' { @($r.Objects) } 'Finding' { @($r.Findings) } default { $vaults } }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $rows -Noun $View.ToLowerInvariant() -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $rows -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}