Src/Public/Invoke-AsBuiltReport.Microsoft.Purview.ps1

function Invoke-AsBuiltReport.Microsoft.Purview {
    <#
    .SYNOPSIS
        PowerShell script to document the configuration of Microsoft Purview
        in Word/HTML/Text formats.
    .DESCRIPTION
        Documents the configuration of Microsoft Purview compliance services
        in Word/HTML/Text formats using PScribo.

        Covers:
          - Information Protection (Sensitivity Labels, DLP)
          - Data Lifecycle Management (Retention Policies & Labels)
          - eDiscovery (Cases, Holds, Content Searches)
          - Audit (Log Config, Retention Policies)
          - Risk & Compliance (Insider Risk, Communication Compliance, Compliance Manager)

    .NOTES
        Version: 0.1.0
        Author: Pai Wei Sing
        Twitter: @paiwsing
        Github: paiwsing

    .LINK
        https://github.com/AsBuiltReport/AsBuiltReport.Microsoft.Purview

    .PARAMETER Target
        Specifies the Microsoft 365 tenant domain name (e.g. contoso.onmicrosoft.com).

    .PARAMETER Credential
        Optional PSCredential. The username is used as the UPN if
        Options.UserPrincipalName is not set in the report config JSON.
    #>


    param (
        [String[]] $Target,
        [PSCredential] $Credential
    )

    Write-ReportModuleInfo -ModuleName 'Microsoft.Purview'

    #---------------------------------------------------------------------------------------------#
    # Dependency Module Version Check #
    #---------------------------------------------------------------------------------------------#
    $ModuleArray = @('AsBuiltReport.Core', 'ExchangeOnlineManagement', 'Microsoft.Graph')

    foreach ($Module in $ModuleArray) {
        try {
            $InstalledVersion = Get-Module -ListAvailable -Name $Module -ErrorAction SilentlyContinue |
                Sort-Object -Property Version -Descending |
                Select-Object -First 1 -ExpandProperty Version

            if ($InstalledVersion) {
                Write-Host " - $Module module v$($InstalledVersion.ToString()) is currently installed."

                # Only check for newer versions if PowerShellGet is available (requires internet)
                $PSGetAvailable = Get-Module -ListAvailable -Name PowerShellGet -ErrorAction SilentlyContinue
                if ($PSGetAvailable) {
                    try {
                        Import-Module PowerShellGet -ErrorAction SilentlyContinue
                        $LatestVersion = Find-Module -Name $Module -Repository PSGallery -ErrorAction SilentlyContinue |
                            Select-Object -ExpandProperty Version
                        if ($LatestVersion -and $InstalledVersion -lt $LatestVersion) {
                            Write-Host " - $Module module v$($LatestVersion.ToString()) is available." -ForegroundColor Red
                            Write-Host " - Run 'Update-Module -Name $Module -Force' to install the latest version." -ForegroundColor Red
                        }
                    } catch {
                        Write-PScriboMessage -IsWarning "Unable to check latest version for $Module`: $($_.Exception.Message)"
                    }
                } else {
                    Write-Host " - Skipping update check for $Module (PowerShellGet not available)."
                }
            } else {
                Write-Host " - $Module module is NOT installed. Run 'Install-Module -Name $Module -Force'." -ForegroundColor Red
            }
        } catch {
            Write-PScriboMessage -IsWarning $_.Exception.Message
        }
    }

    #---------------------------------------------------------------------------------------------#
    # Import Report Configuration #
    #---------------------------------------------------------------------------------------------#
    #---------------------------------------------------------------------------------------------#
    # Report Config Patch (must run before $script:Report assignment) #
    #---------------------------------------------------------------------------------------------#
    # Patch $ReportConfig in-place before $script:Report is assigned.
    # 1. Set correct report name based on ReportType
    # 2. Set CoverPageImage to the Purview logo bundled with this module
    # ($PSScriptRoot = .../Src/Public — logo lives there)
    # ReportType is always "AsBuilt" (default) or "AsBuilt,Assessment" (AsBuilt + POA add-on).
    # Assessment standalone is no longer supported — AsBuilt always runs first.
    $EarlyReportParts = if ($ReportConfig.Options.ReportType) {
        ($ReportConfig.Options.ReportType -split ',').Trim()
    } else { @('AsBuilt') }

    if ($EarlyReportParts -contains 'Assessment') {
        $ReportConfig.Report.Name = 'Microsoft Purview As Built Report with Assessment'
    } else {
        $ReportConfig.Report.Name = 'Microsoft Purview As Built Report'
    }

    # Always point the cover image to our bundled Purview logo PNG
    $script:PurviewLogoPath = Join-Path $PSScriptRoot 'AsBuiltReport.Microsoft.Purview.png'
    if (Test-Path $script:PurviewLogoPath) {
        $ImageBase64 = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($script:PurviewLogoPath ))
        Image -Text 'Microsoft Purview As Built Report' -Align Left -Percent 50 -Base64 $ImageBase64
        Write-Host " - Cover page logo set: $script:PurviewLogoPath" -ForegroundColor Cyan
    } else {
        Write-Warning "Cover page logo not found at: $script:PurviewLogoPath"
    }
    

    $script:Report        = $ReportConfig.Report
    $script:InfoLevel     = $ReportConfig.InfoLevel
    $script:Options       = $ReportConfig.Options
    $script:HealthCheck   = $ReportConfig.HealthCheck
    $script:TextInfo      = (Get-Culture).TextInfo
    $script:SectionTimers = [System.Collections.Generic.Dictionary[string,object]]::new()

    # Transcript log — enabled when Options.TranscriptPath is set in the report config JSON.
    # Generates a timestamped log file capturing all INFO/SUCCESS/WARNING/ERROR events
    # plus PScribo document warnings, useful for troubleshooting without needing to
    # copy/paste console output.
    if ($script:Options.TranscriptPath -and $script:Options.TranscriptPath.Trim() -ne '') {
        # Expand the path and create the directory if needed
        $script:TranscriptLogPath = $script:Options.TranscriptPath.Trim()
        $logDir = Split-Path $script:TranscriptLogPath -Parent
        if ($logDir -and -not (Test-Path $logDir)) {
            New-Item -ItemType Directory -Path $logDir -Force | Out-Null
        }
        # Write a header so it's clear when the session started
        $header = @"
================================================================================
  AsBuiltReport.Microsoft.Purview — Diagnostic Transcript
  Started : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
  Host : $($env:COMPUTERNAME)
  User : $($env:USERNAME)
================================================================================
"@

        Set-Content -Path $script:TranscriptLogPath -Value $header -Encoding UTF8
        Write-Host " - Transcript log: $script:TranscriptLogPath" -ForegroundColor Cyan

        # Hook into PScribo's Write-PScriboMessage so WARNING lines from the
        # document renderer (including "Unexpected System.Boolean" and other
        # internal errors) are captured to the log file automatically.
        # PScribo writes warnings via Write-Warning, so we redirect the
        # WarningVariable stream at the Section/Table level isn't practical —
        # instead we register a custom warning handler using Set-PSDebug isn't right either.
        # The most reliable approach: wrap the entire report generation in a
        # transcript using Start-Transcript which captures everything including warnings.
        try {
            Start-Transcript -Path ($script:TranscriptLogPath -replace '\.log$', '_console.log') `
                -Append -Force -ErrorAction SilentlyContinue | Out-Null
            $script:TranscriptStarted = $true
        } catch {
            $script:TranscriptStarted = $false
        }
    } else {
        $script:TranscriptLogPath  = $null
        $script:TranscriptStarted  = $false
    }

    #---------------------------------------------------------------------------------------------#
    # Disclaimer (HealthCheck) #
    #---------------------------------------------------------------------------------------------#
    if ($Healthcheck) {
        Section -Style TOC -ExcludeFromTOC 'DISCLAIMER' {
            Paragraph 'This report was generated using the AsBuiltReport framework and provides a snapshot of the current Microsoft Purview configuration at the time of generation. The information in this report is intended for documentation and reference purposes. Any configuration settings should be reviewed and validated by a qualified administrator before making changes.'
        }
        PageBreak
    }

    #---------------------------------------------------------------------------------------------#
    # Connection Section #
    #---------------------------------------------------------------------------------------------#
    foreach ($System in $Target) {

        Write-Host " "
        Write-Host "- Starting report for tenant: $System"
        Write-TranscriptLog "Starting Purview report for tenant: $System" 'INFO' 'MAIN' | Out-Null

        #region Resolve UPN
        $ResolvedUPN = $null
        if ($script:Options.UserPrincipalName -and (Test-UserPrincipalName -UserPrincipalName $script:Options.UserPrincipalName)) {
            $ResolvedUPN = $script:Options.UserPrincipalName
        } elseif ($Credential -and (Test-UserPrincipalName -UserPrincipalName $Credential.UserName)) {
            $ResolvedUPN = $Credential.UserName
        } else {
            throw "No valid UserPrincipalName found. Set 'Options.UserPrincipalName' in your report config JSON (e.g. admin@$System), or pass -Credential with a UPN-format username."
        }
        Write-TranscriptLog "Using UPN: $ResolvedUPN" 'INFO' 'AUTH' | Out-Null
        #endregion

        #region Connect
        try {
            Connect-PurviewSession -UserPrincipalName $ResolvedUPN
        } catch {
            throw "Connection failed for tenant '$System'. Error: $($_.Exception.Message)"
        }
        #endregion

        #region Retrieve Tenant Info
        Write-Host " - Retrieving tenant information..."
        try {
            # Try Get-MgOrganization only if the sub-module is already loaded
            # to avoid assembly conflicts from re-importing it
            if (Get-Module -Name 'Microsoft.Graph.Identity.DirectoryManagement' -ErrorAction SilentlyContinue) {
                $script:TenantInfo = Get-MgOrganization -ErrorAction Stop
                $script:TenantId   = $TenantInfo.Id
                $script:TenantName = ($TenantInfo.VerifiedDomains | Where-Object { $_.IsDefault }).Name

                # Map ISO country code -> MCCA geo tags for DLP gap analysis
                $script:TenantCountry = $TenantInfo.CountryLetterCode  # e.g. 'AU', 'US', 'GB'
                $script:TenantGeos    = @('INTL')  # INTL always included
                $countryToGeo = @{
                    # Asia-Pacific
                    'AU'='AUS'; 'NZ'='AUS'
                    'JP'='JPN'
                    'CN'='APC'; 'HK'='APC'; 'SG'='APC'; 'MY'='APC'; 'PH'='APC'
                    'TW'='APC'; 'TH'='APC'; 'TR'='APC'; 'IL'='APC'; 'SA'='APC'; 'ID'='APC'
                    'KR'='KOR'
                    'IN'='IND'
                    'ZA'='ZAF'
                    # North America
                    'US'='NAM'; 'CA'='CAN'; 'MX'='NAM'
                    # UK
                    'GB'='GBR'
                    # Latin America
                    'BR'='LAM'; 'AR'='LAM'; 'CL'='LAM'; 'CO'='LAM'; 'PE'='LAM'
                    # Europe (all EU/EEA + associated countries)
                    'AT'='EUR'; 'BE'='EUR'; 'BG'='EUR'; 'HR'='EUR'; 'CY'='EUR'
                    'CZ'='EUR'; 'DK'='EUR'; 'EE'='EUR'; 'FI'='EUR'; 'FR'='FRA'
                    'DE'='EUR'; 'GR'='EUR'; 'HU'='EUR'; 'IE'='EUR'; 'IT'='EUR'
                    'LV'='EUR'; 'LT'='EUR'; 'LU'='EUR'; 'MT'='EUR'; 'NL'='EUR'
                    'NO'='EUR'; 'PL'='EUR'; 'PT'='EUR'; 'RO'='EUR'; 'SK'='EUR'
                    'SI'='EUR'; 'ES'='EUR'; 'SE'='EUR'; 'CH'='EUR'; 'UA'='EUR'
                    'RU'='EUR'
                }
                if ($script:TenantCountry -and $countryToGeo.ContainsKey($script:TenantCountry)) {
                    $script:TenantGeos += $countryToGeo[$script:TenantCountry]
                    # France also includes EUR
                    if ($countryToGeo[$script:TenantCountry] -eq 'FRA') { $script:TenantGeos += 'EUR' }
                    # GBR also includes EUR (shared EU SITs)
                    if ($countryToGeo[$script:TenantCountry] -eq 'GBR') { $script:TenantGeos += 'EUR' }
                    # CAN also includes NAM
                    if ($countryToGeo[$script:TenantCountry] -eq 'CAN') { $script:TenantGeos += 'NAM' }
                } else {
                    # Unknown country — include all geos so nothing is missed
                    $script:TenantGeos += 'NAM','AUS','EUR','FRA','GBR','APC','JPN','CAN','IND','KOR','LAM','ZAF'
                }
                $script:TenantGeos = $script:TenantGeos | Sort-Object -Unique
                Write-Host " - Tenant country: $($script:TenantCountry) → DLP geos: $($script:TenantGeos -join ', ')" -ForegroundColor Gray
                Write-TranscriptLog "Tenant country: $($script:TenantCountry), DLP geos: $($script:TenantGeos -join ', ')" 'INFO' 'MAIN' | Out-Null
            } else {
                # Fall back to Get-MgContext which is always safe — no sub-module needed
                $MgCtx = Get-MgContext -ErrorAction SilentlyContinue
                $script:TenantId    = if ($MgCtx) { $MgCtx.TenantId } else { $System }
                $script:TenantName  = $System
                $script:TenantCountry = $null
                # Without country, include the most common geos to avoid empty DLP tables
                $script:TenantGeos  = @('INTL','NAM','AUS','EUR','FRA','GBR','APC','JPN','CAN')
            }
            Write-Host " - Tenant: $($script:TenantName) ($($script:TenantId))" -ForegroundColor Cyan
            Write-TranscriptLog "Tenant identified: $($script:TenantName) ($($script:TenantId))" 'INFO' 'MAIN' | Out-Null
        } catch {
            $script:TenantId   = $System
            $script:TenantName = $System
            Write-TranscriptLog "Unable to retrieve tenant info from Graph. Using '$System' as identifier." 'WARNING' 'MAIN' | Out-Null
        }
        #endregion

        #---------------------------------------------------------------------------------------------#
        # Pre-flight: Role & License Check (MCCA-inspired) #
        #---------------------------------------------------------------------------------------------#
        # Checks Entra roles and licensing before generating the report.
        # Warns if sections will have limited data. Does NOT block report generation.
        # Role requirements sourced from MCCA (github.com/OfficeDev/MCCA) role table.
        Write-Host ' - Checking user roles and licensing...'
        try {
            $script:RoleWarnings   = [System.Collections.ArrayList]::new()
            $script:LicenseWarnings = [System.Collections.ArrayList]::new()
            $script:DetectedRoles  = @()
            $script:HasE5License   = $false

            # Get Entra roles for current user via Graph
            $MgCtxAccount = (Get-MgContext -ErrorAction SilentlyContinue).Account
            if ($MgCtxAccount) {
                $RoleResp = Invoke-MgGraphRequest `
                    -Uri "https://graph.microsoft.com/v1.0/users/$MgCtxAccount/transitiveMemberOf/microsoft.graph.directoryRole" `
                    -Method GET -ErrorAction SilentlyContinue -SkipHttpErrorCheck
                if ($RoleResp -and -not $RoleResp.error -and $RoleResp.value) {
                    $script:DetectedRoles = $RoleResp.value.displayName
                }
            }

            # Role tier assessment (from MCCA role table)
            $FullAccessRoles  = @('Global Administrator','Compliance Administrator','Compliance Data Administrator')
            $PartialRoles     = @('Security Administrator','Security Reader','Security Operator','Global Reader')
            $HasFullAccess    = ($script:DetectedRoles | Where-Object { $FullAccessRoles -contains $_ }).Count -gt 0
            $HasPartialAccess = ($script:DetectedRoles | Where-Object { $PartialRoles    -contains $_ }).Count -gt 0

            if ($script:DetectedRoles.Count -gt 0) {
                Write-Host " Detected roles: $($script:DetectedRoles -join ', ')" -ForegroundColor Gray
                Write-TranscriptLog "Detected Entra roles: $($script:DetectedRoles -join ', ')" 'INFO' 'PREFLIGHT' | Out-Null
                if (-not $HasFullAccess -and -not $HasPartialAccess) {
                    $msg = "No recognised compliance role detected. Some sections may be empty. Recommended: Compliance Administrator or Global Administrator."
                    Write-Host " WARNING: $msg" -ForegroundColor Yellow
                    Write-TranscriptLog $msg 'WARNING' 'PREFLIGHT' | Out-Null
                    $null = $script:RoleWarnings.Add($msg)
                } elseif (-not $HasFullAccess) {
                    $msg = "Partial-access role detected. Insider Risk, Communication Compliance and eDiscovery case details may be limited. Full access requires Compliance Administrator."
                    Write-Host " NOTE: $msg" -ForegroundColor Yellow
                    Write-TranscriptLog $msg 'WARNING' 'PREFLIGHT' | Out-Null
                    $null = $script:RoleWarnings.Add($msg)
                } else {
                    Write-Host " Role check: OK ($( ($script:DetectedRoles | Where-Object { $FullAccessRoles -contains $_ }) -join ', '))" -ForegroundColor Green
                    Write-TranscriptLog "Role check passed." 'SUCCESS' 'PREFLIGHT' | Out-Null
                }
            } else {
                Write-Host " Could not retrieve role assignments." -ForegroundColor Gray
            }

            # License check via Graph
            $SkuResp = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/subscribedSkus" `
                -Method GET -ErrorAction SilentlyContinue -SkipHttpErrorCheck
            if ($SkuResp -and -not $SkuResp.error -and $SkuResp.value) {
                $Skus = $SkuResp.value
                $E5Skus = $Skus | Where-Object {
                    $_.skuPartNumber -match 'SPE_E5|COMPLIANCE_E5|M365_E5|ENTERPRISEPREMIUM|Microsoft_365_E5'
                }
                $script:HasE5License = $E5Skus.Count -gt 0
                if ($script:HasE5License) {
                    Write-Host " License check: E5 detected — all sections available." -ForegroundColor Green
                    Write-TranscriptLog "E5 license confirmed: $($E5Skus.skuPartNumber -join ', ')" 'SUCCESS' 'PREFLIGHT' | Out-Null
                } else {
                    $E3Skus = $Skus | Where-Object { $_.skuPartNumber -match 'SPE_E3|ENTERPRISEPACK|Microsoft_365_E3' }
                    $licMsg = if ($E3Skus) {
                        "E3 license detected ($($E3Skus.skuPartNumber -join ', ')). Insider Risk Management, Communication Compliance and Advanced eDiscovery require E5 or M365 E5 Compliance add-on."
                    } else {
                        "License tier unknown. Insider Risk, Communication Compliance and Advanced eDiscovery may require E5 or E5 Compliance add-on."
                    }
                    Write-Host " NOTE: $licMsg" -ForegroundColor Yellow
                    Write-TranscriptLog $licMsg 'WARNING' 'PREFLIGHT' | Out-Null
                    $null = $script:LicenseWarnings.Add($licMsg)
                }
            }
        } catch {
            Write-TranscriptLog "Pre-flight check skipped (non-fatal): $($_.Exception.Message)" 'WARNING' 'PREFLIGHT' | Out-Null
        }

        #---------------------------------------------------------------------------------------------#
        # Report Sections #
        #---------------------------------------------------------------------------------------------#

        # Determine report mode from Options.
        # Supported values: "AsBuilt" (default) | "AsBuilt,Assessment" (AsBuilt + POA add-on)
        # AsBuilt is ALWAYS generated. Assessment is an optional add-on appended after AsBuilt.
        $ReportTypeParts    = if ($script:Options.ReportType) {
            ($script:Options.ReportType -split ',').Trim()
        } else { @('AsBuilt') }
        $IncludeAssessment  = $ReportTypeParts -contains 'Assessment'

        Write-Host " - Report type: $($script:Options.ReportType)" -ForegroundColor Cyan
        Write-TranscriptLog "Report type: $($script:Options.ReportType) | Assessment add-on: $IncludeAssessment" 'INFO' 'MAIN' | Out-Null

        #------------------------------------------------------------------#
        # ASBUILT — Standard documentation sections (always generated) #
        #------------------------------------------------------------------#
        if ($script:InfoLevel.InformationProtection -ge 1 -or $script:InfoLevel.DLP -ge 1) {
            Write-Host '- Working on Information Protection section.'
            Get-AbrPurviewInformationProtectionSection -TenantId $script:TenantName
        }
        if ($script:InfoLevel.DSPM -ge 1) {
            Write-Host '- Working on Data Security Posture Management section.'
            Get-AbrPurviewDataSecurityPostureSection -TenantId $script:TenantName
        }
        if ($script:InfoLevel.Retention -ge 1 -or $script:InfoLevel.RecordManagement -ge 1) {
            Write-Host '- Working on Data Lifecycle Management section.'
            Get-AbrPurviewDataLifecycleSection -TenantId $script:TenantName
        }
        if ($script:InfoLevel.EDiscovery -ge 1) {
            Write-Host '- Working on eDiscovery section.'
            Get-AbrPurviewEDiscoverySection -TenantId $script:TenantName
        }
        if ($script:InfoLevel.Audit -ge 1) {
            Write-Host '- Working on Audit section.'
            Get-AbrPurviewAuditSection -TenantId $script:TenantName
        }
        if ($script:InfoLevel.InsiderRisk -ge 1 -or $script:InfoLevel.CommunicationCompliance -ge 1 -or $script:InfoLevel.ComplianceManager -ge 1) {
            Write-Host '- Working on Risk and Compliance section.'
            Get-AbrPurviewRiskAndComplianceSection -TenantId $script:TenantName
        }

        #------------------------------------------------------------------#
        # ASSESSMENT — POA add-on (only when ReportType includes it) #
        #------------------------------------------------------------------#
        if ($IncludeAssessment) {
            Write-Host '- Working on Purview Optimization Assessment section.'
            Get-AbrPurviewAssessment -TenantId $script:TenantName
        }

        #---------------------------------------------------------------------------------------------#
        # Clean Up Connections #
        #---------------------------------------------------------------------------------------------#
        Write-Host " "
        Write-Host "- Finished report generation for tenant: $($script:TenantName)"
        Write-TranscriptLog "Report generation complete for: $($script:TenantName)" 'SUCCESS' 'MAIN' | Out-Null

        if ($script:Options.KeepConnected -eq $true) {
            Write-Host ' - KeepConnected: skipping session disconnect.' -ForegroundColor Yellow
            Write-TranscriptLog 'KeepConnected is set — sessions left open.' 'INFO' 'MAIN' | Out-Null
        } else {
            Disconnect-PurviewSession
        }

    } #endregion foreach Target loop

    # Stop transcript if we started one
    if ($script:TranscriptStarted) {
        try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null } catch { }
        Write-Host " - Transcript saved to: $($script:Options.TranscriptPath -replace '\.log$', '_console.log')" -ForegroundColor Cyan
    }
}