Public/Export-InforcerTenantDocumentation.ps1

<#
.SYNOPSIS
    Generates comprehensive tenant documentation across all M365 products managed via Inforcer.
.DESCRIPTION
    Export-InforcerTenantDocumentation collects configuration data for the specified tenant by calling
    Get-InforcerBaseline, Get-InforcerTenant, and Get-InforcerTenantPolicies (each using
    -OutputType JsonObject), normalizes the raw API data into a format-agnostic DocModel via
    ConvertTo-InforcerDocModel, and renders the DocModel to one or more output formats.

    Intune Settings Catalog settingDefinitionIDs are resolved to friendly names and descriptions
    using a settings.json catalog sourced from the IntuneSettingsCatalogData GitHub repository.
    By default, the catalog is downloaded and cached at runtime (unless SettingsCatalogPath is
    provided). If the catalog cannot be loaded, Settings Catalog policies show raw
    settingDefinitionId values instead.

    Before calling this cmdlet, you must be connected via Connect-Inforcer. If no active session
    exists, the cmdlet emits a non-terminating error and returns immediately.

    Output files are written to the specified OutputPath directory and auto-named as
    {TenantName}-Documentation.{ext} (e.g., Contoso-Documentation.html).
.PARAMETER Format
    Output format(s) to generate. Accepted values: Html, Markdown, Excel. Multiple formats
    can be specified as a comma-separated list or array. Defaults to Html. Excel format
    creates an .xlsx workbook with one sheet per product (requires ImportExcel module).
.PARAMETER TenantId
    Tenant to document. Accepts a numeric ID, GUID, or tenant name. Required.
.PARAMETER OutputPath
    Directory to write output files to. Files are auto-named {TenantName}-Documentation.{ext}.
    When a single format is specified and this path has a file extension, it is treated as an
    explicit output file path. Defaults to the current directory.
.PARAMETER SettingsCatalogPath
    Path to a local settings.json file for Settings Catalog resolution. When omitted, the cmdlet
    automatically downloads and caches the latest data from the IntuneSettingsCatalogData GitHub
    repository (~65 MB, cached at ~/.inforcercommunity/data/settings.json with a 24-hour TTL).
    If download fails and no cached copy exists, Settings Catalog policies show raw
    settingDefinitionId values and a warning is emitted.
.PARAMETER FetchGraphData
    When specified, uses Invoke-MgGraphRequest to enrich the documentation with live data from
    Microsoft Graph. Currently resolves assignment group/user ObjectIDs to their display names
    via the /directoryObjects endpoint. Requires the Microsoft.Graph.Authentication module and
    an active Graph session (Connect-MgGraph). If Graph is not connected, falls back to raw
    ObjectIDs with a warning.
.PARAMETER Baseline
    Filter to only policies that belong to a specific baseline. Accepts a baseline GUID or
    friendly name (e.g., "Inforcer Blueprint Baseline - Tier 1 - Foundations"). Uses the
    Inforcer alignment details API to retrieve the list of policies in the baseline, then
    filters the documentation to only those policies. The baseline name is shown in the header.
.PARAMETER Tag
    Filter to only policies that have a specific Inforcer tag (e.g., "IAM - Core", "Tier 1").
    Matches against the tag name property on each policy (case-insensitive, contains match).
.OUTPUTS
    System.IO.FileInfo. Returns FileInfo objects for each exported file.
.EXAMPLE
    Export-InforcerTenantDocumentation -TenantId 482 -Format Html

    Writes Contoso-Documentation.html to the current directory.
.EXAMPLE
    Export-InforcerTenantDocumentation -TenantId 482 -Format Html,Markdown,Excel -OutputPath C:\Reports

    Writes three documentation files to C:\Reports.
.EXAMPLE
    Export-InforcerTenantDocumentation -TenantId "Contoso" -Format Html -SettingsCatalogPath .\settings.json

    Uses an explicit settings.json path for Settings Catalog resolution.
.EXAMPLE
    Export-InforcerTenantDocumentation -TenantId 139 -Baseline "Inforcer Blueprint Baseline - Tier 1 - Foundations" -Format Html

    Exports only the policies that belong to the specified baseline.
.LINK
    https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#export-inforcertenantdocumentation
.LINK
    Connect-Inforcer
#>

function Export-InforcerTenantDocumentation {
[CmdletBinding()]
[OutputType([System.IO.FileInfo])]
param(
    [Parameter(Mandatory = $false)]
    [ValidateSet('Html', 'Markdown', 'Excel')]
    [string[]]$Format = @('Html'),

    [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
    [Alias('ClientTenantId')]
    [object]$TenantId,

    [Parameter(Mandatory = $false)]
    [string]$OutputPath = '.',

    [Parameter(Mandatory = $false)]
    [string]$SettingsCatalogPath,

    [Parameter(Mandatory = $false)]
    [switch]$FetchGraphData,

    [Parameter(Mandatory = $false)]
    [string]$Baseline,

    [Parameter(Mandatory = $false)]
    [string]$Tag
)

if (-not (Test-InforcerSession)) {
    Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' `
        -ErrorId 'NotConnected' -Category ConnectionError
    return
}

try {
    $clientTenantId = Resolve-InforcerTenantId -TenantId $TenantId
} catch {
    Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument
    return
}

# Settings catalog path: explicit override or auto-resolved via cache strategy
$resolvedCatalogPath = $SettingsCatalogPath

# Collect data from the 3 source cmdlets and build DocModel
Write-Host 'Collecting tenant data...' -ForegroundColor Cyan
$docDataParams = @{ TenantId = $clientTenantId }
if (-not [string]::IsNullOrEmpty($resolvedCatalogPath)) {
    $docDataParams['SettingsCatalogPath'] = $resolvedCatalogPath
}
$docData = Get-InforcerDocData @docDataParams
if ($null -eq $docData) { return }

# Filter to baseline policies if -Baseline specified
$baselineFilterName = $null
if (-not [string]::IsNullOrWhiteSpace($Baseline)) {
    Write-Host "Filtering to baseline: $Baseline" -ForegroundColor Cyan

    # Resolve baseline name to GUID
    $baselineGuid = $null
    $guidTest = [guid]::Empty
    if ([guid]::TryParse($Baseline.Trim(), [ref]$guidTest)) {
        $baselineGuid = $Baseline.Trim()
    } else {
        # Fetch baselines and resolve by name
        $allBaselines = @(Invoke-InforcerApiRequest -Endpoint '/beta/baselines' -Method GET -OutputType PowerShellObject)
        $baselineGuid = Resolve-InforcerBaselineId -BaselineId $Baseline -BaselineData $allBaselines
        # Find the baseline name for display
        foreach ($bl in $allBaselines) {
            if ($bl.id -eq $baselineGuid) { $baselineFilterName = $bl.name; break }
        }
    }
    if (-not $baselineFilterName) { $baselineFilterName = $Baseline }

    # Get alignment details to find which policies are in the baseline
    Write-Host ' Retrieving alignment details...' -ForegroundColor Gray
    $alignEndpoint = "/beta/tenants/$clientTenantId/alignmentDetails?customBaselineId=$baselineGuid"
    $alignResponse = Invoke-InforcerApiRequest -Endpoint $alignEndpoint -Method GET -OutputType PowerShellObject -ErrorAction SilentlyContinue

    if ($null -eq $alignResponse) {
        Write-Warning "Could not retrieve alignment details for baseline. Exporting all policies."
    } else {
        # Collect all policy names from all alignment status arrays
        $baselinePolicyNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $alignment = $alignResponse.alignment
        if ($null -ne $alignment) {
            $statusArrays = @('matchedPolicies', 'matchedWithAcceptedDeviations', 'deviatedUnaccepted', 'missingFromSubjectUnaccepted', 'additionalInSubjectUnaccepted')
            foreach ($arrayName in $statusArrays) {
                $arr = $alignment.PSObject.Properties[$arrayName]
                if ($arr -and $null -ne $arr.Value) {
                    foreach ($p in @($arr.Value)) {
                        if ($p -is [PSObject] -and $p.PSObject.Properties['policyName']) {
                            [void]$baselinePolicyNames.Add($p.policyName)
                        }
                    }
                }
            }
        }

        if ($baselinePolicyNames.Count -gt 0) {
            # Filter docData.Policies to only those in the baseline
            $originalCount = @($docData.Policies).Count
            $docData.Policies = @($docData.Policies | Where-Object {
                $name = $_.displayName
                if ([string]::IsNullOrWhiteSpace($name)) { $name = $_.friendlyName }
                if ([string]::IsNullOrWhiteSpace($name)) { $name = $_.name }
                $baselinePolicyNames.Contains($name)
            })
            Write-Host " Filtered to $(@($docData.Policies).Count) of $originalCount policies in baseline" -ForegroundColor Gray
        } else {
            Write-Warning 'No policies found in baseline alignment data. Exporting all policies.'
        }
    }
}

# Filter by tag if -Tag specified
if (-not [string]::IsNullOrWhiteSpace($Tag)) {
    Write-Host "Filtering to tag: $Tag" -ForegroundColor Cyan
    $originalCount = @($docData.Policies).Count
    $docData.Policies = @($docData.Policies | Where-Object {
        $policyTags = $_.tags
        if ($null -eq $policyTags -or @($policyTags).Count -eq 0) { return $false }
        foreach ($t in @($policyTags)) {
            $tagName = if ($t -is [PSObject] -and $t.PSObject.Properties['name']) { $t.name } else { $t.ToString() }
            if ($tagName -and $tagName.IndexOf($Tag, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
        }
        return $false
    })
    Write-Host " Filtered to $(@($docData.Policies).Count) of $originalCount policies with tag '$Tag'" -ForegroundColor Gray
}

# Build Graph enrichment maps before DocModel (so assignments resolve during normalization)
$groupNameMap = $null
$filterMap = $null
if ($FetchGraphData) {
    Write-Host 'Connecting to Microsoft Graph...' -ForegroundColor Cyan
    # Extract Azure AD tenant GUID from the Inforcer tenant data so Graph targets the correct tenant
    $msTenantId = $null
    if ($docData.Tenant -and $docData.Tenant.PSObject.Properties['msTenantId']) {
        $msTenantId = $docData.Tenant.msTenantId
    }
    $graphConnectParams = @{ RequiredScopes = @('Directory.Read.All') }
    if ($msTenantId) { $graphConnectParams['TenantId'] = $msTenantId }
    $graphCtx = Connect-InforcerGraph @graphConnectParams

    if (-not $graphCtx) {
        Write-Warning 'Microsoft Graph connection failed. Falling back to raw ObjectIDs.'
    } else {
        Write-Host " Graph connected as: $($graphCtx.Account)" -ForegroundColor Green

        # Validate Graph is connected to the correct tenant
        if ($msTenantId -and $graphCtx.TenantId -and $graphCtx.TenantId -ne $msTenantId) {
            $tenantName = $docData.Tenant.tenantFriendlyName
            Write-Warning "Graph signed into tenant $($graphCtx.TenantId) but exporting tenant '$tenantName' ($msTenantId). Group names and filters may not resolve correctly."
            Write-Warning "Sign in with an account that has access to tenant '$tenantName' or skip -FetchGraphData."
        }

        # Collect all unique group ObjectIDs from raw policy data
        $objectIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($policy in @($docData.Policies)) {
            $rawAssign = $policy.policyData.assignments
            if ($null -eq $rawAssign) { $rawAssign = $policy.assignments }
            if ($null -eq $rawAssign) { continue }
            foreach ($a in @($rawAssign)) {
                $t = $a.target; if ($null -eq $t) { $t = $a }
                if ($t.groupId -and $t.groupId -match '^[0-9a-f]{8}-') {
                    [void]$objectIds.Add($t.groupId)
                }
            }
        }

        if ($objectIds.Count -gt 0) {
            Write-Host " Resolving $($objectIds.Count) unique group/object IDs..." -ForegroundColor Gray
            $groupNameMap = @{}
            $resolved = 0
            foreach ($oid in $objectIds) {
                $obj = Invoke-InforcerGraphRequest -Uri "https://graph.microsoft.com/v1.0/directoryObjects/$oid" -SingleObject
                if ($obj -and $obj.displayName) {
                    $groupNameMap[$oid] = $obj.displayName
                    $resolved++
                } else {
                    $groupNameMap[$oid] = $oid
                }
            }
            Write-Host " Resolved $resolved of $($objectIds.Count) group names" -ForegroundColor Gray
        }

        # Fetch assignment filters from Intune
        Write-Host ' Fetching assignment filters...' -ForegroundColor Gray
        $rawFilters = Invoke-InforcerGraphRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters'
        $filterMap = @{}
        if ($rawFilters) {
            foreach ($f in $rawFilters) { $filterMap[$f.id] = $f }
            Write-Host " Loaded $($filterMap.Count) assignment filters" -ForegroundColor Gray
        }

        # Fetch scope tags from Intune and build ID -> displayName map
        Write-Host ' Fetching scope tags...' -ForegroundColor Gray
        $rawScopeTags = Invoke-InforcerGraphRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/roleScopeTags'
        $script:InforcerScopeTagMap = @{}
        if ($rawScopeTags) {
            foreach ($st in $rawScopeTags) { $script:InforcerScopeTagMap[$st.id.ToString()] = $st.displayName }
            Write-Host " Loaded $($script:InforcerScopeTagMap.Count) scope tags" -ForegroundColor Gray
        }
    }
}

Write-Host 'Building documentation model...' -ForegroundColor Cyan
$docModelParams = @{ DocData = $docData }
if ($groupNameMap)                   { $docModelParams['GroupNameMap'] = $groupNameMap }
if ($filterMap)                      { $docModelParams['FilterMap'] = $filterMap }
if ($script:InforcerScopeTagMap)     { $docModelParams['ScopeTagMap'] = $script:InforcerScopeTagMap }
$docModel = ConvertTo-InforcerDocModel @docModelParams
if ($null -eq $docModel) { return }

# Add filter metadata so renderers can show what's active
if ($baselineFilterName) { $docModel['FilterBaseline'] = $baselineFilterName }
if (-not [string]::IsNullOrWhiteSpace($Tag)) { $docModel['FilterTag'] = $Tag }

$policyCount = 0
foreach ($product in $docModel.Products.Values) {
    foreach ($policies in $product.Categories.Values) { $policyCount += @($policies).Count }
}
Write-Host " Found $policyCount policies across $($docModel.Products.Count) products" -ForegroundColor Gray

# Render each requested format and write to disk
$extensionMap = @{ Html = 'html'; Markdown = 'md'; Excel = 'xlsx' }
$formatIndex = 0

foreach ($fmt in $Format) {
    $formatIndex++
    $ext = $extensionMap[$fmt]

    if ($Format.Count -eq 1 -and [System.IO.Path]::HasExtension($OutputPath)) {
        $filePath = $OutputPath
    } else {
        $safeName = $docModel.TenantName -replace '[^\w\-]', '-'
        $filePath = Join-Path $OutputPath "$safeName-Documentation.$ext"
    }

    $parentDir = Split-Path -Parent $filePath
    if (-not [string]::IsNullOrEmpty($parentDir) -and -not (Test-Path -LiteralPath $parentDir)) {
        [void](New-Item -ItemType Directory -Force -Path $parentDir)
    }

    Write-Host "Rendering $fmt ($formatIndex/$($Format.Count))..." -ForegroundColor Cyan

    if ($fmt -eq 'Excel') {
        # Excel writes directly to disk via ImportExcel
        Export-InforcerDocExcel -DocModel $docModel -FilePath $filePath
        if (-not (Test-Path -LiteralPath $filePath)) { continue }
    } else {
        $content = switch ($fmt) {
            'Html'     { ConvertTo-InforcerHtml     -DocModel $docModel }
            'Markdown' { ConvertTo-InforcerMarkdown -DocModel $docModel }
        }
        Set-Content -Path $filePath -Value $content -Encoding UTF8
    }

    $fileInfo = Get-Item -LiteralPath $filePath
    $sizeKb = [math]::Round($fileInfo.Length / 1KB, 1)
    Write-Host " Exported: $filePath ($sizeKb KB)" -ForegroundColor Green
    $fileInfo
}

# Auto-open HTML output in the default browser (cross-platform)
$htmlFile = $Format | Where-Object { $_ -eq 'Html' } | ForEach-Object {
    $safeName = $docModel.TenantName -replace '[^\w\-]', '-'
    if ($Format.Count -eq 1 -and [System.IO.Path]::HasExtension($OutputPath)) { $OutputPath }
    else { Join-Path $OutputPath "$safeName-Documentation.html" }
}
if ($htmlFile -and (Test-Path -LiteralPath $htmlFile)) {
    $fullHtmlPath = (Resolve-Path -LiteralPath $htmlFile).Path
    if ($IsMacOS) { Start-Process 'open' -ArgumentList $fullHtmlPath }
    elseif ($IsWindows) { Start-Process $fullHtmlPath }
    elseif ($IsLinux) { Start-Process 'xdg-open' -ArgumentList $fullHtmlPath }
}

Write-Host "Done. $($Format.Count) file(s) exported for tenant '$($docModel.TenantName)'." -ForegroundColor Cyan
}