Public/Export-PolicyReport.ps1
|
function Export-PolicyReport { <# .SYNOPSIS Export policy comparison reports in multiple formats (CSV, HTML). .DESCRIPTION Generates comprehensive policy compliance reports from comparison results. Supports multiple export formats: - CSV: Multiple files (baseline, summary, details, hierarchical, per-initiative) - HTML: Interactive report with DataTables, scoring gauges, and visual metrics - All: Both CSV and HTML formats .PARAMETER ComparisonResult Comparison result object from Compare-AzPolicyCompliance. Must contain .Summary, .Details, .Scores, and .Metrics properties. .PARAMETER Baseline Baseline object from Get-AzPolicyBaseline. Required for baseline CSV export and HTML report. .PARAMETER OutputFolder Destination folder for report files. Subfolders will be created: reports/, baseline/, initiatives/ .PARAMETER Format Report format to generate: CSV, HTML, or All. Default: All .PARAMETER HtmlOptions Hashtable with HTML report customization options: - LogoPath: Path to logo image file - ProjectName: Project name for header - ProjectVersion: Version string - Theme: Color theme (not yet implemented) - Scope: Scope description (e.g., "Management Group: MyMG") .PARAMETER AssignedPoliciesById Hashtable of assigned policies indexed by ID (for HTML report). Optional. If not provided, will be computed from comparison result. .PARAMETER AssignedPoliciesByNormName Hashtable of assigned policies indexed by normalized name (for HTML report). Optional. If not provided, will be computed from comparison result. .PARAMETER AssignedEffects Hashtable of assigned policy effects (for HTML report). Optional. If not provided, will be computed from comparison result. .PARAMETER MatchByNameOnly Boolean indicating if comparison used name-only matching. Passed to HTML report for display. Default: $false .EXAMPLE # Export all formats with default options $result = Compare-AzPolicyCompliance -Baseline $baseline -Assignments $assignments $files = Export-PolicyReport -ComparisonResult $result ` -Baseline $baseline ` -OutputFolder "C:\Reports" ` -Format All Write-Host "Generated files:" $files | ForEach-Object { Write-Host " $_" } .EXAMPLE # Export only CSV files Export-PolicyReport -ComparisonResult $result ` -Baseline $baseline ` -OutputFolder "C:\Reports" ` -Format CSV .EXAMPLE # Export HTML with custom branding $htmlOptions = @{ LogoPath = "C:\Assets\company-logo.png" ProjectName = "Enterprise Policy Compliance" ProjectVersion = "2.0" Scope = "Production Tenant" } Export-PolicyReport -ComparisonResult $result ` -Baseline $baseline ` -OutputFolder "C:\Reports" ` -Format HTML ` -HtmlOptions $htmlOptions .OUTPUTS Array of generated file paths (absolute paths). .NOTES File Structure: - OutputFolder/ ├── baseline/ │ └── Baseline_ALZ_MCSB_Policies.csv ├── reports/ │ ├── Baseline_Compare_Summary.csv │ ├── Baseline_Compare_Details.csv │ ├── Hierarchical_Initiatives_Policies.csv │ └── Baseline_Compare_Report.html └── initiatives/ ├── Initiative-{Name1}-Policies.csv ├── Initiative-{Name2}-Policies.csv └── ... CSV Formats: - Baseline: All baseline policies with sources - Summary: Per-assignment metrics (one row per assignment) - Details: Policy-level differences (missing, extra, version mismatches) - Hierarchical: Two-level view (initiative rows + policy rows) - Per-Initiative: Individual CSV per assignment with expanded policies HTML Report Sections: - Hero score gauge with color coding - Metrics cards (ALZ Score, MCSB Score, Assignments, Baseline Policies) - Custom Initiatives table - MCSB Initiatives table - Individual Policies table - MCSB Baseline table - ALZ Baseline table - Interactive DataTables with sorting/filtering #> [CmdletBinding()] [OutputType([string[]])] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] [PSCustomObject]$ComparisonResult, [Parameter(Mandatory = $true)] [ValidateNotNull()] [PSCustomObject]$Baseline, [Parameter()] [AllowNull()] [object[]]$Assignments = @(), [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$OutputFolder, [ValidateSet('CSV', 'HTML', 'All')] [string]$Format = 'All', [hashtable]$HtmlOptions = @{}, [hashtable]$AssignedPoliciesById, [hashtable]$AssignedPoliciesByNormName, [hashtable]$AssignedEffects, [bool]$MatchByNameOnly = $false ) begin { Write-Debug "Export-PolicyReport: Starting report generation" Write-Debug " OutputFolder: $OutputFolder" Write-Debug " Format: $Format" # Validate comparison result structure if ($null -eq $ComparisonResult.Summary -or $null -eq $ComparisonResult.Details) { throw "Invalid ComparisonResult object. Must contain .Summary and .Details properties." } # Warn if data is empty but continue if ($ComparisonResult.Summary.Count -eq 0) { Write-Warning "Summary is empty. No policy assignments were successfully compared." } # Validate baseline structure if (-not $Baseline.Policies) { throw "Invalid Baseline object. Must contain .Policies property." } } process { try { $generatedFiles = @() # Create output subfolders $reportsFolder = Join-Path $OutputFolder "reports" $baselineFolder = Join-Path $OutputFolder "baseline" $initiativesFolder = Join-Path $OutputFolder "initiatives" @($reportsFolder, $baselineFolder, $initiativesFolder) | ForEach-Object { if (-not (Test-Path $_)) { New-Item -Path $_ -ItemType Directory -Force | Out-Null Write-Verbose "Created folder: $_" } } # Define output file paths $baselineCsvPath = Join-Path $baselineFolder "Baseline_ALZ_MCSB_Policies.csv" $summaryCsvPath = Join-Path $reportsFolder "Baseline_Compare_Summary.csv" $detailsCsvPath = Join-Path $reportsFolder "Baseline_Compare_Details.csv" $hierarchicalCsvPath = Join-Path $reportsFolder "Hierarchical_Initiatives_Policies.csv" $htmlReportPath = Join-Path $reportsFolder "Baseline_Compare_Report.html" # Export CSV files if ($Format -eq 'CSV' -or $Format -eq 'All') { Write-Verbose "Generating CSV reports..." # 1. Export baseline Write-Verbose " Exporting baseline CSV..." Export-BaselineCsv -Baseline $Baseline.Policies -OutputPath $baselineCsvPath $generatedFiles += $baselineCsvPath # 2. Export summary Write-Verbose " Exporting summary CSV..." Export-SummaryCsv -Summary $ComparisonResult.Summary -OutputPath $summaryCsvPath $generatedFiles += $summaryCsvPath # 3. Export details Write-Verbose " Exporting details CSV..." $allDetails = @() $allDetails += $ComparisonResult.Details.Missing $allDetails += $ComparisonResult.Details.Extra $allDetails += $ComparisonResult.Details.VersionMismatches Export-DetailsCsv -Details $allDetails -OutputPath $detailsCsvPath $generatedFiles += $detailsCsvPath # 4. Export per-initiative CSVs Write-Verbose " Exporting per-initiative CSVs..." foreach ($summaryRow in $ComparisonResult.Summary) { # Find corresponding assignment to get expanded policies # Note: We need the original assignment object with ExpandedPolicies # For now, we'll skip this as we don't have access to original assignments # This would be populated during comparison if we stored it # TODO: Store ExpandedPolicies in summary or pass assignments array Write-Debug "Skipping per-initiative CSV for $($summaryRow.AssignmentDisplayName) - requires ExpandedPolicies" } # 5. Export hierarchical view Write-Verbose " Exporting hierarchical CSV..." Export-HierarchicalCsv -Summary $ComparisonResult.Summary ` -Details $allDetails ` -Baseline $Baseline.Policies ` -InitiativesFolder $initiativesFolder ` -PolicyDefinitionCache $script:policyDefinitionCache ` -OutputPath $hierarchicalCsvPath $generatedFiles += $hierarchicalCsvPath Write-Verbose "CSV reports generated: $($generatedFiles.Count) files" } # Export HTML report if ($Format -eq 'HTML' -or $Format -eq 'All') { Write-Verbose "Generating HTML report..." # Prepare HTML options with defaults # Auto-detect logo path if ($HtmlOptions.ContainsKey('LogoPath') -and $HtmlOptions.LogoPath) { # Custom logo provided $logoPath = $HtmlOptions.LogoPath Write-Verbose "Using custom logo: $logoPath" } else { $publicFolder = $PSScriptRoot $moduleFolder = Split-Path -Parent $publicFolder $modulesFolder = Split-Path -Parent $moduleFolder $srcFolder = Split-Path -Parent $modulesFolder $projectRoot = Split-Path -Parent $srcFolder $logoPath = Join-Path $projectRoot "assets\logo-azurepolicywatch.png" if (-not (Test-Path $logoPath)) { Write-Warning "Default logo not found at: $logoPath" Write-Verbose "HTML report will use default icon (🔍)" $logoPath = "" } else { Write-Verbose "✅ Using default logo: $logoPath" } } $projectName = if ($HtmlOptions.ContainsKey('ProjectName')) { $HtmlOptions.ProjectName } else { "AzurePolicyWatch" } $projectVersion = if ($HtmlOptions.ContainsKey('ProjectVersion')) { $HtmlOptions.ProjectVersion } else { "1.0" } $scope = if ($HtmlOptions.ContainsKey('Scope')) { $HtmlOptions.Scope } else { "Azure Tenant" } # Compute assigned policy indexes if not provided if (-not $AssignedPoliciesById) { $AssignedPoliciesById = @{} # Build from Assignments.ExpandedPolicies (ALL assigned policies, not just those in baseline) if ($Assignments -and $Assignments.Count -gt 0) { foreach ($assignment in $Assignments) { if ($assignment.ExpandedPolicies) { foreach ($policy in $assignment.ExpandedPolicies) { if ($policy.PolicyDefinitionId -and -not $AssignedPoliciesById.ContainsKey($policy.PolicyDefinitionId)) { $AssignedPoliciesById[$policy.PolicyDefinitionId] = $policy } } } } } Write-Verbose "Built AssignedPoliciesById from Assignments: $($AssignedPoliciesById.Count) policies" } if (-not $AssignedPoliciesByNormName) { $AssignedPoliciesByNormName = @{} # Build from Assignments.ExpandedPolicies if ($Assignments -and $Assignments.Count -gt 0) { foreach ($assignment in $Assignments) { if ($assignment.ExpandedPolicies) { foreach ($policy in $assignment.ExpandedPolicies) { $normName = Normalize-PolicyName $policy.PolicyDisplayName if ($normName -and -not $AssignedPoliciesByNormName.ContainsKey($normName)) { $AssignedPoliciesByNormName[$normName] = $policy } } } } } Write-Verbose "Built AssignedPoliciesByNormName from Assignments: $($AssignedPoliciesByNormName.Count) policies" } if (-not $AssignedEffects) { $AssignedEffects = @{} # Build from Assignments.ExpandedPolicies if ($Assignments -and $Assignments.Count -gt 0) { foreach ($assignment in $Assignments) { if ($assignment.ExpandedPolicies) { foreach ($policy in $assignment.ExpandedPolicies) { if ($policy.PolicyDefinitionId -and $policy.Effect) { $AssignedEffects[$policy.PolicyDefinitionId] = $policy.Effect } $normName = Normalize-PolicyName $policy.PolicyDisplayName if ($normName -and $policy.Effect -and -not $AssignedEffects.ContainsKey($normName)) { $AssignedEffects[$normName] = $policy.Effect } } } } } Write-Verbose "Built AssignedEffects from Assignments: $($AssignedEffects.Count) entries" } # Build comprehensive metrics object for HTML $metrics = @{ TotalAssignments = $ComparisonResult.Summary.Count TotalBaseline = $Baseline.Policies.Count AlzMetrics = $ComparisonResult.Metrics.AlzMetrics McsbMetrics = $ComparisonResult.Metrics.McsbMetrics GlobalMetrics = $ComparisonResult.Metrics.GlobalMetrics } # Export HTML Export-HtmlReport -Summary $ComparisonResult.Summary ` -Baseline $Baseline.Policies ` -Scores $ComparisonResult.Scores ` -Metrics $metrics ` -AssignedById $AssignedPoliciesById ` -AssignedByNormName $AssignedPoliciesByNormName ` -AssignedEffects $AssignedEffects ` -MatchByNameOnly $MatchByNameOnly ` -OutputPath $htmlReportPath ` -LogoPath $logoPath ` -ProjectName $projectName ` -ProjectVersion $projectVersion ` -Scope $scope $generatedFiles += $htmlReportPath Write-Verbose "HTML report generated: $htmlReportPath" } Write-Debug "Export-PolicyReport: Completed successfully" Write-Verbose "Total files generated: $($generatedFiles.Count)" return $generatedFiles } catch { Write-Error "Failed to export policy report: $($_.Exception.Message)" throw } } } |