Public/Export-InforcerTenantDocumentation.ps1

<#
.SYNOPSIS
    Generates comprehensive tenant documentation across all M365 products managed via Inforcer.

    Required API scope(s): Tenants.Read + Baselines.Read + tenants.policies.Read (API key);
    Tenants + Baselines + Policies (+ Align with -Baseline) permission (API client, unconfirmed).
.DESCRIPTION
    Export-InforcerTenantDocumentation collects the tenant, its baselines and its policies (the raw
    API data, as Get-InforcerTenant, Get-InforcerBaseline and Get-InforcerTenantPolicies return it),
    normalizes it 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. Both login types
    work.

    Nothing is written unless -OutputPath is given: without it the DocModel is returned so a
    caller can read the configuration without producing files. With it, output is auto-named
    {TenantName}-Documentation.{ext} (e.g., Contoso-Documentation.html).

    The Profile type column is filled from the policy type id when the API sends no type name.
    Excel: values that start with '=' are written as text with a leading apostrophe, and web
    addresses are not turned into links. Markdown: & < > in cells and headings are encoded.
.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 (API-key login only), Microsoft tenant 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; that only works for one tenant, so piping a second tenant to a file
    path stops with an OutputPathIsFileForSeveralTenants error before anything is fetched for it.
    Pass a directory when piping several tenants. Omit it and no files are written: the DocModel is
    returned instead. There is no default - writing is always something you asked for.
.PARAMETER Show
    Open the generated HTML in the default browser. Requires -OutputPath and -Format Html — there
    is nothing to open when no file is written. Each HTML file is opened at most once per call, also
    when several tenants are piped in.

    Defaults to ON in an interactive session and OFF on a CI runner (CI, TF_BUILD, GITHUB_ACTIONS
    and similar), so a human who asked for a report gets to look at it while a build agent does
    not try to launch a browser. Pass -Show to force it, -Show:$false to suppress it; an explicit
    value always wins over the detection.
.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). After 24 hours it checks
    for a newer release and downloads it; a download that is not a valid catalog, or is larger than
    512 MB, never replaces the cached copy. If download fails and no cached copy exists, Settings
    Catalog policies show raw settingDefinitionId values and a warning is emitted.
.PARAMETER FetchGraphData
    When specified, signs in to Microsoft Graph for the tenant and resolves IDs to names: assignment
    groups and filters, scope tags, and the groups, directory roles (built-in and custom), named
    locations and applications in Conditional Access policies. Requests the read scopes
    Directory.Read.All, Policy.Read.All and DeviceManagementConfiguration.Read.All. Requires the
    Microsoft.Graph.Authentication module. If Graph cannot be reached, or refuses a call for a
    missing permission, the affected names stay as raw IDs 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
    The DocModel (hashtable) when -OutputPath is omitted, otherwise System.IO.FileInfo for each
    exported file.
.EXAMPLE
    Export-InforcerTenantDocumentation -TenantId 'Contoso' -Format Html

    Returns the DocModel. No file is written - pass -OutputPath for that.
.EXAMPLE
    Export-InforcerTenantDocumentation -TenantId 'Contoso' -Format Html -OutputPath ./docs -Show

    Writes Contoso-Documentation.html to ./docs and opens it.
.EXAMPLE
    Export-InforcerTenantDocumentation -TenantId 'Contoso' -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 'Contoso' -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([hashtable], [System.IO.FileInfo])]
param(
    [Parameter(Mandatory = $false)]
    [ValidateSet('Html', 'Markdown', 'Excel')]
    [string[]]$Format = @('Html'),

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

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

    # Defaults ON for a human at a prompt, OFF on a CI runner. -Show / -Show:$false always wins.
    [Parameter(Mandatory = $false)]
    [switch]$Show = (Test-InforcerInteractiveHost),

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

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

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

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

begin {
    $tenantCount = 0
    $openedHtml = [System.Collections.Generic.HashSet[string]]::new()
}

process {

$tenantCount++
if ($tenantCount -gt 1 -and $Format.Count -eq 1 -and $OutputPath -and [System.IO.Path]::HasExtension($OutputPath)) {
    throw [System.Management.Automation.ErrorRecord]::new(
        [System.ArgumentException]::new("-OutputPath '$OutputPath' is a file, so a second tenant would overwrite the first. Pass a directory when piping several tenants; each gets its own {TenantName}-Documentation file."),
        'OutputPathIsFileForSeveralTenants', [System.Management.Automation.ErrorCategory]::InvalidArgument, $OutputPath)
}

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

try {
    $resolvedTenantId = 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 = $resolvedTenantId }
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
    $baselineFilterName = Select-InforcerBaselinePolicies -DocData $docData -BaselineId $Baseline
    if ($null -eq $baselineFilterName) {
        Write-Warning 'Exporting all policies.'
        $baselineFilterName = $Baseline
    }
}

# Filter by tag if -Tag specified
if (-not [string]::IsNullOrWhiteSpace($Tag)) {
    Write-Host "Filtering to tag: $Tag" -ForegroundColor Cyan
    $originalCount = @($docData.Policies).Count

    # Collected before the filter so a no-match can name the tags that do exist.
    $availableTags = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($pol in @($docData.Policies)) {
        foreach ($t in @($pol.tags)) {
            if ($null -eq $t) { continue }
            $n = if ($t -is [PSObject] -and $t.PSObject.Properties['name']) { $t.name } else { $t.ToString() }
            if (-not [string]::IsNullOrWhiteSpace($n)) { [void]$availableTags.Add($n) }
        }
    }

    $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

    # No match used to render anyway: a 0.1 KB document and exit 0, which reads as "nothing in
    # this tenant is tagged that way" when the likelier cause is a tag name that does not exist.
    # Naming the real tags is the difference between a dead end and a correctable mistake.
    if (@($docData.Policies).Count -eq 0) {
        $known = if ($availableTags.Count -gt 0) { $availableTags -join "', '" } else { $null }
        $detail = if ($known) { "Tags present in this tenant: '$known'." } else { 'No policy in this tenant carries any tag.' }
        Write-Error -Message "No policy matched tag '$Tag', so there is nothing to document. $detail" `
            -ErrorId 'TagMatchedNothing' -Category ObjectNotFound
        return
    }
}

# Load Settings Catalog only if there are Settings Catalog policies (policyTypeId 10)
$hasCatalogPolicies = @($docData.Policies | Where-Object { $_.policyTypeId -eq 10 }).Count -gt 0
if ($hasCatalogPolicies) {
    $catalogParams = @{}
    if (-not [string]::IsNullOrEmpty($resolvedCatalogPath)) {
        $catalogParams['Path'] = $resolvedCatalogPath
    }
    Import-InforcerSettingsCatalog @catalogParams
} else {
    Write-Host ' No Intune/Defender policies found, skipping Settings Catalog load' -ForegroundColor Gray
}

# Graph maps are built fresh for this tenant only; nothing carries over between piped tenants or calls.
$graphMaps = @{}
if ($FetchGraphData) {
    $tenantLabel = if ($docData.Tenant -and $docData.Tenant.tenantFriendlyName) { " for $($docData.Tenant.tenantFriendlyName)" } else { '' }
    Write-Host "Connecting to Microsoft Graph$tenantLabel..." -ForegroundColor Cyan
    $graphMaps = Resolve-InforcerGraphEnrichment -DocData $docData -ConditionalAccess
}

Write-Host 'Building documentation model...' -ForegroundColor Cyan
$docModelParams = @{ DocData = $docData }
foreach ($key in 'GroupNameMap', 'FilterMap', 'RoleNameMap', 'LocationNameMap', 'AppNameMap', 'ScopeTagMap') {
    if ($graphMaps[$key]) { $docModelParams[$key] = $graphMaps[$key] }
}
$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

# ── No -OutputPath: hand back the DocModel and touch nothing ───────────────────
# Writing is opt-in even here, where the verb promises a file. The old default put documents in
# whatever directory you happened to be standing in and opened a browser for them, which is
# wrong in a pipeline and wrong for a caller that only wants the model to read.
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    Write-Host 'Done. Pass -OutputPath to write files.' -ForegroundColor Cyan
    return $docModel
}

# Render each requested format and write to disk
$extensionMap = @{ Html = 'html'; Markdown = 'md'; Excel = 'xlsx' }
$formatIndex = 0
$htmlFile = $null
$safeName = $docModel.TenantName -replace '[^\w\-]', '-'

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

    if ($Format.Count -eq 1 -and [System.IO.Path]::HasExtension($OutputPath)) {
        $filePath = $OutputPath
    } else {
        $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
        if ($fmt -eq 'Html') { $htmlFile = $filePath }
    }

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

# -Show defaults to on interactively and off in CI (Test-InforcerInteractiveHost), so a human
# who asked for a report sees it while a build agent is never handed a browser to open.
if ($Show -and $htmlFile -and (Test-Path -LiteralPath $htmlFile)) {
    $fullHtmlPath = (Resolve-Path -LiteralPath $htmlFile).Path
    if ($openedHtml.Add($fullHtmlPath)) {
        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
}
}