mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
1366 lines
64 KiB
PowerShell
1366 lines
64 KiB
PowerShell
function Add-DocumentationOutputProvider {
|
|
param([PSCustomObject]$Provider)
|
|
[DocumentationRegistry]::RegisterOutput($Provider)
|
|
}
|
|
|
|
# Public registration wrapper for phase-3 input providers (Settings Catalog, ADMX,
|
|
# Intent, ComplianceV2, generic Profile). Symmetric with Add-DocumentationOutputProvider.
|
|
# Provider hashtable shape:
|
|
# Name [string] provider identifier (e.g. 'SettingsCatalog')
|
|
# Match [scriptblock] takes $PolicyObject, returns $true if this provider claims it
|
|
# Translate [scriptblock] takes ($PolicyObject, $Context); fills the context
|
|
function Add-DocumentationInputProvider {
|
|
param([PSCustomObject]$Provider)
|
|
[DocumentationRegistry]::RegisterInputProvider($Provider)
|
|
}
|
|
|
|
# Public registration wrapper for phase-4 *DocHandler classes. The class is
|
|
# typically constructed at file load time; this just registers the instance.
|
|
function Add-DocumentationHandler {
|
|
param([object]$Handler)
|
|
[DocumentationRegistry]::RegisterHandler($Handler)
|
|
}
|
|
|
|
function Merge-DocumentationOptions {
|
|
param([hashtable]$Options)
|
|
|
|
$merged = [DocumentationContext]::DefaultOptions()
|
|
|
|
# Seed the saved user preference BEFORE the caller's options, so an explicit
|
|
# -Options value still wins. Only for keys the documentation dialog persists
|
|
# and the engine reads on every object; without this a silent run would
|
|
# ignore the checkbox the same user ticked in the UI.
|
|
# Compared, not cast: the store persists the checkbox as text, and
|
|
# [bool]"False" is $true in PowerShell - a cast here switched the fallback
|
|
# back on for every silent run after the user had turned it off. Same
|
|
# idiom Get-SettingValue uses for its registered Boolean settings.
|
|
$stored = Get-DocumentationSetting 'FallbackDocumentation' $null
|
|
if ($null -ne $stored) { $merged.FallbackDocumentation = ($stored -eq $true -or $stored -eq 'true') }
|
|
|
|
if ($Options) {
|
|
foreach ($key in $Options.Keys) { $merged[$key] = $Options[$key] }
|
|
}
|
|
if (-not $merged.Outputs) { $merged.Outputs = @{} }
|
|
# Backward compat: the flag was renamed OfflineDocumentation -> SourceTenantUnavailable.
|
|
# Honour the legacy key from any external -Options @{OfflineDocumentation=$true} caller,
|
|
# but let an explicit new-key value win if both are supplied.
|
|
if ($Options -and $Options.ContainsKey('OfflineDocumentation') -and -not ($Options.ContainsKey('SourceTenantUnavailable'))) {
|
|
$merged.SourceTenantUnavailable = [bool]$Options['OfflineDocumentation']
|
|
}
|
|
$merged.Remove('OfflineDocumentation')
|
|
return $merged
|
|
}
|
|
|
|
# True when ANY Intune tenant is reachable. Generic Intune schema (Settings
|
|
# Catalog setting definitions/categories, Compliance categories, ADMX
|
|
# definitions, built-in Endpoint Security intent templates, role
|
|
# resourceOperations) is identical on every tenant, so it can be resolved from
|
|
# whatever tenant is connected even when the SOURCE tenant of an export is gone
|
|
# (SourceTenantUnavailable). Source-tenant-specific lookups stay additionally
|
|
# gated on -not $ctx.SourceTenantUnavailable.
|
|
function Test-DocumentationGraphAvailable {
|
|
if (-not (Get-Command Invoke-MSGraphAPI -ErrorAction SilentlyContinue)) { return $false }
|
|
return -not [string]::IsNullOrEmpty((Get-CurrentTenantId))
|
|
}
|
|
|
|
# Storage layout for documentation settings:
|
|
# generic / form-level keys -> Documentation
|
|
# per-output-provider keys -> Documentation\<providerValue>
|
|
#
|
|
# Per-provider option keys are prefixed with the provider's registered Value
|
|
# (e.g. HTMLDocumentName for value=html, WordCoverPage for value=word). This
|
|
# function walks every registered output provider and picks the one whose
|
|
# Value is a case-insensitive prefix of the key. Longest match wins so a
|
|
# short-prefix provider can't swallow keys belonging to a longer-prefix one
|
|
# (e.g. hypothetical "H" would not steal "HTML*" keys from the html provider).
|
|
# Falls back to the generic Documentation subkey when no prefix matches, so
|
|
# form-level options (SkipDisabled, IncludeScripts, ...) route there.
|
|
#
|
|
# Adding a new output provider does not require an edit here — dropping a
|
|
# file under OutputProviders/ that calls Add-DocumentationOutputProvider
|
|
# registers the Value automatically and this lookup picks it up.
|
|
function Get-DocumentationSettingSubPath {
|
|
param([string]$Key)
|
|
if (-not $Key) { return 'Documentation' }
|
|
|
|
$best = $null
|
|
foreach ($provider in [DocumentationRegistry]::Outputs) {
|
|
$val = [string]$provider.Value
|
|
if (-not $val) { continue }
|
|
if (-not $Key.StartsWith($val, [System.StringComparison]::OrdinalIgnoreCase)) { continue }
|
|
if (-not $best -or $val.Length -gt ([string]$best.Value).Length) { $best = $provider }
|
|
}
|
|
if ($best) { return "Documentation\$(([string]$best.Value).ToLowerInvariant())" }
|
|
return 'Documentation'
|
|
}
|
|
|
|
# Read a documentation setting, auto-routing to the right subpath by key prefix.
|
|
function Get-DocumentationSetting {
|
|
param([Parameter(Mandatory)][string]$Key, $Default)
|
|
Get-SettingStoreValue (Get-DocumentationSettingSubPath $Key) $Key $Default
|
|
}
|
|
|
|
function Get-DocumentationOutputOption {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Output,
|
|
[Parameter(Mandatory)][string]$Name,
|
|
$Default
|
|
)
|
|
|
|
$options = $script:_docRunOptions
|
|
if ($options -and $options.Outputs -and $options.Outputs[$Output]) {
|
|
$providerOptions = $options.Outputs[$Output]
|
|
if ($providerOptions -is [hashtable] -and $providerOptions.ContainsKey($Name)) {
|
|
return $providerOptions[$Name]
|
|
}
|
|
if ($providerOptions.PSObject.Properties[$Name]) {
|
|
return $providerOptions.$Name
|
|
}
|
|
}
|
|
return Get-SettingStoreValue (Get-DocumentationSettingSubPath $Name) $Name $Default
|
|
}
|
|
|
|
# Read a generic (engine-wide, not per-output-provider) run option. Unlike
|
|
# Get-DocumentationOutputOption, this reads the TOP LEVEL of the merged run
|
|
# options ($script:_docRunOptions) - the same place ToResult reads
|
|
# SkipNotConfigured/SkipDisabled etc. - then falls back to the persisted
|
|
# generic Documentation setting. Use this for flags every output provider must
|
|
# honour identically (e.g. SkipDocumentInfo).
|
|
function Get-DocumentationOption {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Name,
|
|
$Default
|
|
)
|
|
|
|
$options = $script:_docRunOptions
|
|
if ($options -is [hashtable] -and $options.ContainsKey($Name)) {
|
|
return $options[$Name]
|
|
}
|
|
return Get-SettingStoreValue (Get-DocumentationSettingSubPath $Name) $Name $Default
|
|
}
|
|
|
|
function Set-DocumentationContextRunOptions {
|
|
param(
|
|
[DocumentationContext]$Context,
|
|
[hashtable]$Options,
|
|
[string]$Language
|
|
)
|
|
|
|
$wasOffline = $Context.SourceTenantUnavailable
|
|
$merged = Merge-DocumentationOptions $Options
|
|
if ($Language) { $merged.Language = $Language }
|
|
$Context.Options = $merged
|
|
$Context.Language = [string]$merged.Language
|
|
$Context.PropertySeparator = [string]$merged.PropertySeparator
|
|
$Context.ObjectSeparator = [string]$merged.ObjectSeparator
|
|
$Context.SourceTenantUnavailable = [bool]$merged.SourceTenantUnavailable
|
|
if ($wasOffline -ne $Context.SourceTenantUnavailable) {
|
|
$Context.ScopeTags = @()
|
|
# Same reason: what a live run cached must not answer an offline one.
|
|
Reset-DocumentationTenantLookups -Context $Context
|
|
}
|
|
}
|
|
|
|
# Drops the tenant lookups a run caches on the singleton context - the
|
|
# notification message templates and the two app catalogues - so the next run
|
|
# reads current tenant state. They are NoteProperties the lookup helpers in
|
|
# TranslatePrimitives.ps1 attach on first use, which is why this checks for each
|
|
# before touching it. Called at the start of every bulk run and whenever a run
|
|
# flips to offline; the single-object Get-GraphDocumentation path keeps them on
|
|
# purpose, so a script documenting policies one call at a time still fetches
|
|
# each catalogue once.
|
|
function Reset-DocumentationTenantLookups {
|
|
param([DocumentationContext]$Context)
|
|
|
|
if (-not $Context) { return }
|
|
foreach ($name in '_NotificationMessageTemplates', '_AllTenantApps', '_AllManagedApps') {
|
|
$property = $Context.PSObject.Properties[$name]
|
|
if ($property) { $property.Value = $null }
|
|
}
|
|
}
|
|
|
|
function Get-DocumentationPoliciesFromSourceFolder {
|
|
param(
|
|
[Parameter(Mandatory)][string]$SourceFolder,
|
|
[string[]]$PolicyType,
|
|
[string[]]$PolicyGroup
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $SourceFolder -PathType Container)) {
|
|
throw "Documentation source folder not found: $SourceFolder"
|
|
}
|
|
|
|
# Same resolver as the bulk drivers, so Start-GraphBulkDocumentation reports an
|
|
# unknown -PolicyType/-PolicyGroup the same way they do (log + non-terminating
|
|
# error). Note the fallback: with NO selector every type is documented; with a
|
|
# selector that resolves to nothing, NOTHING is. A typo used to widen the run
|
|
# to the whole folder.
|
|
# @( if ... ) and not `$types = if ...`: an empty array written from the block
|
|
# enumerates to nothing and would leave $types as $null - which is exactly
|
|
# how the old code widened an unknown selector to the whole folder.
|
|
$types = @(if ($PolicyType -or $PolicyGroup) {
|
|
(Resolve-IntuneTargetSelectors -PolicyType $PolicyType -PolicyGroup $PolicyGroup -Caller 'Start-GraphBulkDocumentation').Types
|
|
} else {
|
|
$script:IntuneTypes
|
|
})
|
|
# Scan subfolders named by either Id or Folder: most types write to a folder named
|
|
# by their Id, but siblings that share a folder (e.g. the reusable-settings types)
|
|
# export under a common .Folder that differs from their Id. Missing the .Folder name
|
|
# here would silently skip those objects before Get-PoliciesFromFolder can resolve them.
|
|
$subFolders = @($types | ForEach-Object { $_.Id; $_.Folder } | Where-Object { $_ } | Select-Object -Unique)
|
|
$params = @{ Path = $SourceFolder; PolicyTypes = $types; SubFolders = $subFolders }
|
|
@(Get-PoliciesFromFolder @params)
|
|
}
|
|
|
|
function Initialize-DocumentationSourceTenantContext {
|
|
param([Parameter(Mandatory)][string]$SourceFolder)
|
|
|
|
# Documenting an export folder: the source tenant may be unreachable, so
|
|
# source-tenant-specific lookups are suppressed and scope-tag names come from
|
|
# the export's ScopeTags/ sidecar instead. Generic schema is still resolved
|
|
# from any connected tenant (see Test-DocumentationGraphAvailable).
|
|
$ctx = Get-DocContextSingleton -Options @{ SourceTenantUnavailable = $true }
|
|
$ctx.ScopeTags = @()
|
|
$scopeFolder = Join-Path $SourceFolder 'ScopeTags'
|
|
if (Test-Path -LiteralPath $scopeFolder -PathType Container) {
|
|
$tags = foreach ($file in Get-ChildItem -LiteralPath $scopeFolder -Filter '*.json' -File) {
|
|
try {
|
|
$item = [IO.File]::ReadAllText($file.FullName) | ConvertFrom-Json
|
|
if ($item.id) { $item }
|
|
} catch {
|
|
Write-Log "Failed to load source-tenant scope tag sidecar '$($file.FullName)'" 2
|
|
}
|
|
}
|
|
if ($tags) { $ctx.ScopeTags = @($tags) }
|
|
}
|
|
}
|
|
|
|
# Backward-compat alias for the former name (pre-rename of OfflineDocumentation).
|
|
function Initialize-DocumentationOfflineContext {
|
|
param([Parameter(Mandatory)][string]$SourceFolder)
|
|
Initialize-DocumentationSourceTenantContext -SourceFolder $SourceFolder
|
|
}
|
|
|
|
function Test-DocumentationPolicyFilter {
|
|
param(
|
|
[Parameter(Mandatory)]$PolicyObject,
|
|
[string]$Filter
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Filter)) { return $true }
|
|
$filterText = $Filter.Trim()
|
|
if ($filterText -notmatch '^(?i:scope|tag):') {
|
|
return [bool]($PolicyObject.Name -match [regex]::Escape($filterText))
|
|
}
|
|
|
|
$scopeFilter = $filterText.Substring($filterText.IndexOf(':') + 1)
|
|
$obj = if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject) {
|
|
$PolicyObject.JsonObject
|
|
} else {
|
|
$PolicyObject
|
|
}
|
|
$prop = if ($obj.PSObject.Properties['roleScopeTagIds']) { 'roleScopeTagIds' }
|
|
elseif ($obj.PSObject.Properties['roleScopeTags']) { 'roleScopeTags' }
|
|
else { return $false }
|
|
$ctx = Get-DocContextSingleton
|
|
if ((-not $ctx.ScopeTags -or $ctx.ScopeTags.Count -eq 0) -and -not $ctx.SourceTenantUnavailable -and
|
|
(Test-DocumentationGraphAvailable)) {
|
|
try {
|
|
$response = Invoke-MSGraphAPI -Url '/deviceManagement/roleScopeTags'
|
|
if ($response.Value) { $ctx.ScopeTags = @($response.Value) }
|
|
} catch {
|
|
Write-LogError 'Failed to load scope tags for documentation filter' $_.Exception
|
|
}
|
|
}
|
|
foreach ($id in @($obj.$prop)) {
|
|
$name = if ($id -eq '0') { 'Default' } else { ($ctx.ScopeTags | Where-Object Id -EQ $id | Select-Object -First 1).displayName }
|
|
if ($name -match [regex]::Escape($scopeFilter)) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
# ---- Scriptblock NameFilter support (LIST{ } / ITEM{ }) ----
|
|
#
|
|
# The NameFilter option accepts, in addition to the legacy plain-substring and
|
|
# scope:/tag: forms handled by Test-DocumentationPolicyFilter above, two
|
|
# scriptblock stages:
|
|
# LIST{ <expr> } evaluated on list-stage objects, BEFORE hydration
|
|
# ITEM{ <expr> } evaluated AFTER the surviving items are hydrated
|
|
# In both, $_ is the IntunePolicyBase wrapper (.Name, .Platform, .PolicyType,
|
|
# .ScopeTags, .ScopeTagNames, .JsonObject).
|
|
#
|
|
# SECURITY: a filter body is never blindly [scriptblock]::Create'd. It is parsed
|
|
# to an AST and rejected unless it is a pure read-only expression — no method
|
|
# calls, no assignments, no commands, no file redirections. See
|
|
# Assert-DocumentationFilterSafe.
|
|
|
|
# Node types that mutate state or execute code. Any of these in a filter body
|
|
# means the body is rejected. Kept as a script-scope list so it's defined once.
|
|
$script:_docFilterForbiddenAstTypes = @(
|
|
[System.Management.Automation.Language.AssignmentStatementAst] # $_.x = 1
|
|
[System.Management.Automation.Language.InvokeMemberExpressionAst] # $_.Foo(), [Type]::Bar(), .Where({})
|
|
[System.Management.Automation.Language.CommandAst] # Remove-Item, iex, any cmdlet/function/exe
|
|
[System.Management.Automation.Language.FileRedirectionAst] # > out.txt
|
|
)
|
|
|
|
# Parse a filter body to an AST, reject it if it contains any forbidden node
|
|
# type, and return a validated [scriptblock]. Throws with a clear message on a
|
|
# syntax error or a disallowed operation. This is the single choke point that
|
|
# makes LIST{ } / ITEM{ } block creation safe.
|
|
function Assert-DocumentationFilterSafe {
|
|
param([Parameter(Mandatory)][string]$Body)
|
|
|
|
$tokens = $null; $errors = $null
|
|
$ast = [System.Management.Automation.Language.Parser]::ParseInput($Body, [ref]$tokens, [ref]$errors)
|
|
if ($errors -and $errors.Count -gt 0) {
|
|
throw "Documentation filter has a syntax error: $($errors[0].Message)"
|
|
}
|
|
|
|
$forbidden = $ast.FindAll({
|
|
param($node)
|
|
foreach ($t in $script:_docFilterForbiddenAstTypes) {
|
|
if ($node -is $t) { return $true }
|
|
}
|
|
return $false
|
|
}, $true)
|
|
|
|
if ($forbidden -and $forbidden.Count -gt 0) {
|
|
$first = $forbidden[0]
|
|
throw ("Documentation filter contains a disallowed operation " +
|
|
"($($first.GetType().Name)): '$($first.Extent.Text)'. " +
|
|
"Filters may only read properties and compare values - no method calls, " +
|
|
"assignments, commands, or redirections.")
|
|
}
|
|
|
|
return [scriptblock]::Create($Body)
|
|
}
|
|
|
|
# Parse a NameFilter string into its three possible components. Legacy forms
|
|
# (plain substring, scope:, tag:) go into .Legacy and are handled by
|
|
# Test-DocumentationPolicyFilter unchanged. LIST{ } / ITEM{ } bodies are
|
|
# validated (Assert-DocumentationFilterSafe) and returned as scriptblocks.
|
|
function ConvertFrom-DocumentationNameFilter {
|
|
param([string]$Filter)
|
|
|
|
$result = [PSCustomObject]@{ Legacy = $null; List = $null; Item = $null }
|
|
if ([string]::IsNullOrWhiteSpace($Filter)) { return $result }
|
|
|
|
$text = $Filter.Trim()
|
|
|
|
# No LIST{ / ITEM{ marker → the whole string is a legacy filter.
|
|
if ($text -notmatch '(?im)^\s*(LIST|ITEM)\s*\{') {
|
|
$result.Legacy = $text
|
|
return $result
|
|
}
|
|
|
|
# Walk the string extracting one or more LIST{...} / ITEM{...} segments,
|
|
# balancing braces so a body containing { } parses correctly.
|
|
$i = 0
|
|
while ($i -lt $text.Length) {
|
|
while ($i -lt $text.Length -and [char]::IsWhiteSpace($text[$i])) { $i++ }
|
|
if ($i -ge $text.Length) { break }
|
|
|
|
$m = [regex]::Match($text.Substring($i), '^(?i)(LIST|ITEM)\s*\{')
|
|
if (-not $m.Success) {
|
|
throw "Documentation filter: expected 'LIST{' or 'ITEM{' at position $i in '$text'."
|
|
}
|
|
$kind = $m.Groups[1].Value.ToUpperInvariant()
|
|
$braceStart = $i + $m.Length - 1 # index of the opening '{'
|
|
|
|
$depth = 0
|
|
$j = $braceStart
|
|
for (; $j -lt $text.Length; $j++) {
|
|
if ($text[$j] -eq '{') { $depth++ }
|
|
elseif ($text[$j] -eq '}') { $depth--; if ($depth -eq 0) { break } }
|
|
}
|
|
if ($depth -ne 0) {
|
|
throw "Documentation filter has unbalanced '{ }' in the $kind block."
|
|
}
|
|
|
|
$body = $text.Substring($braceStart + 1, $j - $braceStart - 1)
|
|
$sb = Assert-DocumentationFilterSafe -Body $body
|
|
if ($kind -eq 'LIST') { $result.List = $sb } else { $result.Item = $sb }
|
|
|
|
$i = $j + 1
|
|
}
|
|
|
|
return $result
|
|
}
|
|
|
|
# Evaluate a validated filter scriptblock against one policy object with $_
|
|
# bound to it. Fail-closed: a null block passes (no filtering); a block that
|
|
# throws excludes the item and logs. Result coerced to bool (last emitted value).
|
|
function Test-DocumentationFilterScriptBlock {
|
|
param(
|
|
[Parameter(Mandatory)]$PolicyObject,
|
|
[scriptblock]$ScriptBlock
|
|
)
|
|
if (-not $ScriptBlock) { return $true }
|
|
try {
|
|
$out = $PolicyObject | ForEach-Object -Process $ScriptBlock
|
|
return [bool]($out | Select-Object -Last 1)
|
|
}
|
|
catch {
|
|
Write-LogError "Documentation filter scriptblock failed for '$($PolicyObject.Name)'" $_.Exception
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Get-DocumentationLanguages {
|
|
$folder = Join-Path $script:AppRootFolder 'Config\LanguageStrings'
|
|
foreach ($file in Get-ChildItem -LiteralPath $folder -Filter 'Strings-*.json' -ErrorAction SilentlyContinue) {
|
|
$name = $file.BaseName.Substring('Strings-'.Length)
|
|
$englishName = try { ([cultureinfo]$name).EnglishName } catch { $name }
|
|
[PSCustomObject]@{ Name = $name; EnglishName = $englishName }
|
|
}
|
|
}
|
|
|
|
function Get-DocumentationUISetting {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Name,
|
|
[string]$LegacyName,
|
|
$Default
|
|
)
|
|
|
|
$fallback = if ($LegacyName) { Get-SettingValue $LegacyName $Default } else { $Default }
|
|
Get-SettingStoreValue 'Documentation' $Name $fallback
|
|
}
|
|
|
|
function Save-DocumentationOptionsDefaults {
|
|
param(
|
|
[hashtable]$Options,
|
|
[switch]$CommonOnly
|
|
)
|
|
if (-not $Options) { return }
|
|
foreach ($key in $Options.Keys) {
|
|
if ($key -eq 'Outputs') { continue }
|
|
$value = $Options[$key]
|
|
# Skip $null: a missing UI control (e.g. an output XAML that doesn't
|
|
# carry the field yet) returns $null from the form-options collector,
|
|
# and writing that back would erase the user's actually-persisted
|
|
# setting on every click-Export.
|
|
if ($null -eq $value) { continue }
|
|
# Generic keys -> Documentation; per-provider keys -> Documentation\<provider>.
|
|
# Same routing as the loaders (Get-DocumentationSetting) so save/load stay symmetric.
|
|
Save-SettingStoreValue (Get-DocumentationSettingSubPath $key) $key $value
|
|
}
|
|
if (-not $CommonOnly -and $Options.Outputs) {
|
|
foreach ($provider in $Options.Outputs.Keys) {
|
|
foreach ($key in $Options.Outputs[$provider].Keys) {
|
|
$value = $Options.Outputs[$provider][$key]
|
|
if ($null -eq $value) { continue }
|
|
Save-SettingStoreValue (Get-DocumentationSettingSubPath $key) $key $value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Save-DocumentationFile {
|
|
param(
|
|
[string]$Content,
|
|
[string]$FileName,
|
|
[switch]$OpenFile
|
|
)
|
|
|
|
try {
|
|
# Out-File doesn't auto-create the parent directory — when an output
|
|
# provider's path template lands in a fresh date-stamped folder
|
|
# (e.g. D:\Intune\Documentation\Test\YYYYMMDD\...html) the write blows
|
|
# up with "Could not find a part of the path". Create the directory
|
|
# first; idempotent if it already exists.
|
|
$dir = [IO.Path]::GetDirectoryName($FileName)
|
|
if ($dir -and -not [IO.Directory]::Exists($dir)) {
|
|
[void][IO.Directory]::CreateDirectory($dir)
|
|
}
|
|
$Content | Out-File -LiteralPath $FileName -Force -Encoding utf8 -ErrorAction Stop
|
|
Write-Log "$FileName saved successfully"
|
|
if ($OpenFile) {
|
|
# Invoke-Item opens the default app on Windows but does nothing useful
|
|
# for an .html file on Linux/macOS. Open-ExternalUri uses ShellExecute
|
|
# (-> xdg-open / open / default browser) on every host.
|
|
Open-ExternalUri $FileName
|
|
}
|
|
}
|
|
catch {
|
|
Write-LogError "Failed to save file $FileName." $_.Exception
|
|
throw
|
|
}
|
|
}
|
|
|
|
# Shared $script:columnHeaders map — property-name → language-id mappings used by
|
|
# Invoke-DocTranslateColumnHeader to localize table column headers. Seeded with the
|
|
# old engine's table (Documentation.psm1:33). Lookup is case-insensitive, and
|
|
# Invoke-DocTranslateColumnHeader keys on the last '.'-segment, so 'Settings.<key>'
|
|
# columns resolve by <key>. Output providers/handlers can register more via
|
|
# Set-DocColumnHeaderLanguageId.
|
|
$script:columnHeaders = @{
|
|
Name = 'Inputs.displayNameLabel'
|
|
Value = 'TableHeaders.value'
|
|
Description = 'TableHeaders.description'
|
|
GroupMode = 'SettingDetails.modeTableHeader'
|
|
Group = 'TableHeaders.assignedGroups'
|
|
Groups = 'TableHeaders.groups'
|
|
useDeviceContext = 'SettingDetails.installContextLabel'
|
|
uninstallOnDeviceRemoval = 'SettingDetails.UninstallOnRemoval'
|
|
isRemovable = 'SettingDetails.installAsRemovable'
|
|
preventManagedAppBackup = 'AppResources.AppSettingsUx.preventManagedAppBackup'
|
|
preventAutoAppUpdate = 'AppResources.AppSettingsUx.preventAutoAppUpdate'
|
|
vpnConfigurationId = 'PolicyType.vpn'
|
|
Action = 'SettingDetails.actionColumnName'
|
|
Schedule = 'ScheduledAction.List.schedule'
|
|
MessageTemplate = 'ScheduledAction.Notification.messageTemplate'
|
|
EmailCC = 'ScheduledAction.Notification.additionalRecipients'
|
|
Rule = 'ApplicabilityRules.GridLabel.Rule'
|
|
ValueWithLabel = 'TableHeaders.value'
|
|
Status = 'TableHeaders.status'
|
|
CombinedValueWithLabel = 'TableHeaders.value'
|
|
CombinedValue = 'TableHeaders.value'
|
|
useDeviceLicensing = 'TableHeaders.licenseType'
|
|
Filter = 'AppResources.AppSettingsUx.assignmentFilterColumnHeader'
|
|
filterMode = 'AppResources.AppSettingsUx.assignmentFilterTypeColumnHeader'
|
|
deliveryOptimizationPriority = 'AppResources.AppSettingsUx.deliveryOptimizationPriorityHeader'
|
|
startTimeColumnLabel = 'AppResources.AppSettingsUx.startTimeColumnLabel'
|
|
installTimeSettings = 'AppResources.AppSettingsUx.deadlineTimeColumnLabel'
|
|
restartSettings = 'AppResources.AppSettingsUx.restartGracePeriodHeader'
|
|
notifications = 'AppResources.AppSettingsUx.assignmentToast'
|
|
Settings = 'TableHeaders.settings'
|
|
returnCode = 'Win32ReturnCodes.Columns.returnCode'
|
|
type = 'Win32ReturnCodes.Columns.codeType'
|
|
RecommendedValue = 'AzureIAMCommon.Recommended'
|
|
ConfigurationKey = 'SettingDetails.configurationKey'
|
|
ValueType = 'SettingDetails.valueType'
|
|
ConfigurationValue = 'SettingDetails.configurationValue'
|
|
}
|
|
|
|
function Set-DocColumnHeaderLanguageId {
|
|
param([string]$Property, [string]$LanguageId)
|
|
$script:columnHeaders[$Property] = $LanguageId
|
|
}
|
|
|
|
function Invoke-DocTranslateColumnHeader {
|
|
param([string]$ColumnName)
|
|
|
|
if ($script:columnHeaders.ContainsKey($ColumnName)) {
|
|
$lng = Get-LanguageString $script:columnHeaders[$ColumnName]
|
|
if ($lng) { return $lng }
|
|
}
|
|
return $ColumnName
|
|
}
|
|
|
|
# Object-type group/category label. Phase 2 will populate the full GroupId →
|
|
# language-id mapping from the old engine. For now returns the input string
|
|
# as-is so headers render with the raw type/group id.
|
|
function Get-DocObjectTypeString {
|
|
param($ObjectTypeOrGroupId)
|
|
|
|
if ($null -eq $ObjectTypeOrGroupId) { return "" }
|
|
if ($ObjectTypeOrGroupId -is [string]) { return $ObjectTypeOrGroupId }
|
|
if ($ObjectTypeOrGroupId.PSObject.Properties.Name -contains 'Title') {
|
|
return $ObjectTypeOrGroupId.Title
|
|
}
|
|
return "$ObjectTypeOrGroupId"
|
|
}
|
|
|
|
# Resolves the output folder for an object during per-file export (used by CSV).
|
|
# Optionally nests under organization name and/or policy-type id.
|
|
function Get-DocObjectFolder {
|
|
param(
|
|
[string]$RootFolder,
|
|
$PolicyType,
|
|
[switch]$AddOrganization,
|
|
[switch]$AddObjectType
|
|
)
|
|
|
|
$path = $RootFolder
|
|
$orgName = Get-CurrentOrganizationName
|
|
if ($AddOrganization -and $orgName) {
|
|
$path = Join-Path $path $orgName
|
|
}
|
|
if ($AddObjectType -and $PolicyType -and $PolicyType.Id) {
|
|
$path = Join-Path $path $PolicyType.Id
|
|
}
|
|
$path
|
|
}
|
|
|
|
# Documents one PolicyObject. Looks up a registered *DocHandler first (by @odata.type);
|
|
# falls back to the schema-driven input-provider chain when no handler claims it.
|
|
# Returns the per-object result PSCustomObject (see [DocumentationContext]::ToResult).
|
|
#
|
|
# Phase 2 only wires the dispatch shape — handler registry and input providers
|
|
# are populated in phases 4 and 3 respectively. Until then this returns an empty
|
|
# result with InputType='NoProvider' so downstream code can detect uninstrumented
|
|
# types without throwing.
|
|
function Invoke-DocumentationForObject {
|
|
param(
|
|
[Parameter(Mandatory)] $PolicyObject,
|
|
[DocumentationContext]$Context
|
|
)
|
|
|
|
if (-not $Context) {
|
|
$Context = [DocumentationContext]::new($PolicyObject, 'en', $null)
|
|
}
|
|
else {
|
|
$Context.ResetForObject($PolicyObject)
|
|
}
|
|
|
|
# Translate primitives (Invoke-TranslateBoolean / Option / ... and Add-PropertyInfo)
|
|
# read state from a module-level pointer; engine sets it once per object so
|
|
# input providers and handlers can use the old function signatures unchanged.
|
|
Set-CurrentDocumentationContext $Context
|
|
|
|
$odataType = $null
|
|
if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject) {
|
|
$odataType = $PolicyObject.JsonObject.'@odata.type'
|
|
}
|
|
if (-not $odataType -and $PolicyObject.PSObject.Properties['@odata.type']) {
|
|
$odataType = $PolicyObject.'@odata.type'
|
|
}
|
|
|
|
$handler = if ($odataType) { [DocumentationRegistry]::FindHandler($odataType) } else { $null }
|
|
if ($handler) {
|
|
try {
|
|
$handler.Document($PolicyObject, $Context)
|
|
$Context.InputType = "Handler:$($handler.GetType().Name)"
|
|
}
|
|
catch {
|
|
$Context.ErrorText = "Handler $($handler.GetType().Name) failed: $($_.Exception.Message)"
|
|
Write-LogError "Documentation handler $($handler.GetType().Name) failed for $($PolicyObject.Name)" $_.Exception
|
|
}
|
|
Add-ScopeTagsBasicInfoIfApplicable $PolicyObject $Context
|
|
Add-AssignmentsForObjectIfApplicable $PolicyObject $Context
|
|
Add-ComplianceActionsForObjectIfApplicable $PolicyObject $Context
|
|
Add-PolicyIdBasicInfoIfRequested $PolicyObject $Context
|
|
return $Context.ToResult()
|
|
}
|
|
|
|
# Providers are evaluated in Order; the first whose Match claims the object
|
|
# wins and the rest are skipped. The generic fallback registers last
|
|
# (Order = MaxValue) and claims the object only when Options.FallbackDocumentation
|
|
# is enabled, so an unsupported type with the option off falls through to the
|
|
# NoProvider stub below.
|
|
$inputProvider = [DocumentationRegistry]::FindInputProvider($PolicyObject, $Context)
|
|
if ($inputProvider) {
|
|
try {
|
|
& $inputProvider.Translate $PolicyObject $Context
|
|
$Context.InputType = "Input:$($inputProvider.Name)"
|
|
}
|
|
catch {
|
|
$Context.ErrorText = "Input provider $($inputProvider.Name) failed: $($_.Exception.Message)"
|
|
Write-LogError "Documentation input provider $($inputProvider.Name) failed for $($PolicyObject.Name)" $_.Exception
|
|
}
|
|
Finalize-DocumentationObjectInfoObject $Context.CurrentObject
|
|
Add-ScopeTagsBasicInfoIfApplicable $PolicyObject $Context
|
|
Add-AssignmentsForObjectIfApplicable $PolicyObject $Context
|
|
Add-ComplianceActionsForObjectIfApplicable $PolicyObject $Context
|
|
Add-PolicyIdBasicInfoIfRequested $PolicyObject $Context
|
|
return $Context.ToResult()
|
|
}
|
|
|
|
# Nothing claimed the object and the generic fallback is switched off. Say so
|
|
# once, clearly, and emit NOTHING - not even the policy-id row, which on its
|
|
# own produced a document containing a single id and no other content. The
|
|
# caller skips a NoProvider result rather than handing it to the output
|
|
# providers, so the type is absent from the documentation instead of present
|
|
# and blank.
|
|
$Context.InputType = 'NoProvider'
|
|
$typeTitle = if ($PolicyObject.PSObject.Properties['PolicyType'] -and $PolicyObject.PolicyType) { [string]$PolicyObject.PolicyType.Title } else { $null }
|
|
if (-not $typeTitle) { $typeTitle = [string]$odataType }
|
|
Write-Log "Documentation: '$($PolicyObject.Name)' was skipped - the type $typeTitle has no documentation support. Enable 'Document unsupported types' in Output Settings to document it from its raw properties." 2
|
|
return $Context.ToResult()
|
|
}
|
|
|
|
# deviceComplianceActionType members whose Graph name differs from the portal's
|
|
# language id. Verified against the full enum 2026-08-30 - these two are the only
|
|
# ones; everything else resolves as ScheduledAction.<member>.
|
|
$script:_complianceActionLanguageIds = @{
|
|
'notification' = 'notificationLabel'
|
|
'removeResourceAccessProfiles' = 'removeSourceAccessProfile'
|
|
}
|
|
|
|
# Engine post-step: translate an object's scheduledActionsForRule into ComplianceActions
|
|
# rows (the "Actions for noncompliance" table shown for compliance policies, V1 and V2).
|
|
# Run centrally like the other post-steps, matching the old dispatcher tail
|
|
# (Documentation.psm1:347 -> Invoke-TranslateScheduledActionType, :3518). No-op for
|
|
# objects that don't carry scheduledActionsForRule.
|
|
function Add-ComplianceActionsForObjectIfApplicable {
|
|
param([object]$PolicyObject, [DocumentationContext]$Context)
|
|
|
|
$obj = if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject) { $PolicyObject.JsonObject } else { $PolicyObject }
|
|
$rules = @($obj.scheduledActionsForRule)
|
|
if ($rules.Count -eq 0) { return }
|
|
|
|
foreach ($actionRule in $rules) {
|
|
foreach ($actionConfig in @($actionRule.scheduledActionConfigurations)) {
|
|
if (-not $actionConfig) { continue }
|
|
|
|
# Action-type strings live directly under ScheduledAction, EXCEPT where
|
|
# the Graph enum member and the portal's language id disagree (checked
|
|
# against every deviceComplianceActionType member 2026-08-30):
|
|
#
|
|
# notification ScheduledAction.Notification is an OBJECT of
|
|
# email-picker sub-strings and the lookup is
|
|
# case-insensitive, so the direct key hit the
|
|
# container and threw. The label leaf is
|
|
# notificationLabel ("Send email to end user").
|
|
# This is the collision the old engine dodged by
|
|
# renaming to 'emailNotification' - the comment that
|
|
# used to sit here claimed the sub-object no longer
|
|
# existed, which was wrong.
|
|
# removeResourceAccessProfiles
|
|
# the portal ships this as removeSourceAccessProfile
|
|
# ("Remove source access profile"); without the alias
|
|
# the raw enum member rendered.
|
|
#
|
|
# Every other member (noAction/block/retire/wipe/pushNotification/
|
|
# remoteLock) resolves directly.
|
|
$actionKey = [string]$actionConfig.actionType
|
|
if ($script:_complianceActionLanguageIds.ContainsKey($actionKey)) {
|
|
$actionKey = $script:_complianceActionLanguageIds[$actionKey]
|
|
}
|
|
|
|
$actionType = Get-LanguageString "ScheduledAction.$actionKey" -IgnoreMissing
|
|
if ([string]::IsNullOrEmpty($actionType)) { $actionType = [string]$actionConfig.actionType }
|
|
|
|
$schedule = if ($actionConfig.gracePeriodHours -eq 0) {
|
|
Get-LanguageString 'ScheduledAction.List.immediately'
|
|
}
|
|
else {
|
|
# gracePeriodHours is always stored in hours but displayed in days.
|
|
(Get-LanguageString 'ScheduledAction.List.gracePeriodDays') -f ($actionConfig.gracePeriodHours / 24)
|
|
}
|
|
|
|
$notificationTemplate = $null
|
|
$additionalNotifications = $null
|
|
if ($actionConfig.actionType -eq 'notification') {
|
|
$notificationTemplate = if ($actionConfig.notificationTemplateId -ne [Guid]::Empty) {
|
|
Get-LanguageString 'ScheduledAction.Notification.selected'
|
|
} else {
|
|
Get-LanguageString 'ScheduledAction.Notification.noneSelected'
|
|
}
|
|
$additionalNotifications = if (@($actionConfig.notificationMessageCCList).Count -gt 0) {
|
|
(Get-LanguageString 'ScheduledAction.Notification.numSelected') -f @($actionConfig.notificationMessageCCList).Count
|
|
} else {
|
|
Get-LanguageString 'ScheduledAction.Notification.noneSelected'
|
|
}
|
|
}
|
|
|
|
$Context.AddComplianceAction($actionType, $schedule, $notificationTemplate, $additionalNotifications)
|
|
}
|
|
}
|
|
}
|
|
|
|
# Engine post-step: append a Scope tags row to BasicInfo when the raw object
|
|
# has roleScopeTagIds or roleScopeTags. Doing this in the engine (rather than
|
|
# each handler/provider) means every documented type picks it up for free,
|
|
# matching what the old engine did from its central dispatcher tail (old code
|
|
# Documentation.psm1:344). No-op when basicInfo is empty (handler/provider
|
|
# didn't run successfully).
|
|
function Add-ScopeTagsBasicInfoIfApplicable {
|
|
param([object]$PolicyObject, [DocumentationContext]$Context)
|
|
|
|
if ($Context.BasicInfo.Count -eq 0) { return }
|
|
|
|
$obj = if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject) {
|
|
$PolicyObject.JsonObject
|
|
} else {
|
|
$PolicyObject
|
|
}
|
|
Add-ScopeTagStrings $obj
|
|
}
|
|
|
|
# Engine post-step: translate $obj.assignments into Assignment rows on the
|
|
# context (Generic + App). No-op if BasicInfo is empty (handler/provider failed)
|
|
# or if Context.Options.ExcludeAssignments is true. Delegates the actual
|
|
# translation to Add-AssignmentsForObject in TranslatePrimitives.ps1.
|
|
function Add-AssignmentsForObjectIfApplicable {
|
|
param([object]$PolicyObject, [DocumentationContext]$Context)
|
|
|
|
if ($Context.BasicInfo.Count -eq 0) { return }
|
|
|
|
$obj = if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject) {
|
|
$PolicyObject.JsonObject
|
|
} else {
|
|
$PolicyObject
|
|
}
|
|
Add-AssignmentsForObject $obj
|
|
}
|
|
|
|
# Engine post-step: when Context.Options.IncludePolicyId is set, append the
|
|
# raw object's Id to BasicInfo. Doing this in the engine (rather than each
|
|
# handler/provider) means every documented type picks it up for free, and
|
|
# downstream tools can rely on the row being present whenever the option is on.
|
|
function Add-PolicyIdBasicInfoIfRequested {
|
|
param([object]$PolicyObject, [DocumentationContext]$Context)
|
|
|
|
if (-not $Context.Options.IncludePolicyId) { return }
|
|
|
|
$policyId = $null
|
|
if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject -and $PolicyObject.JsonObject.id) {
|
|
$policyId = $PolicyObject.JsonObject.id
|
|
}
|
|
elseif ($PolicyObject.PSObject.Properties['Id']) {
|
|
$policyId = $PolicyObject.Id
|
|
}
|
|
if (-not $policyId) { return }
|
|
|
|
# Label "Policy ID" — the existing SettingDetails.policyId language string
|
|
# already resolves to "Policy ID" in the en strings file; falls back to a
|
|
# literal if the key is ever missing.
|
|
$label = Get-LanguageString 'SettingDetails.policyId'
|
|
if (-not $label) { $label = 'Policy ID' }
|
|
|
|
$idRow = [PSCustomObject]@{ Name = $label; Value = $policyId; EntityKey = 'id' }
|
|
|
|
# Position: right after the conventional Name row (EntityKey='displayName').
|
|
# Fall back to position 1 (just after the first row) if no displayName row
|
|
# exists, or append if BasicInfo is empty.
|
|
$insertAt = -1
|
|
for ($i = 0; $i -lt $Context.BasicInfo.Count; $i++) {
|
|
if ($Context.BasicInfo[$i].EntityKey -eq 'displayName') {
|
|
$insertAt = $i + 1
|
|
break
|
|
}
|
|
}
|
|
if ($insertAt -ge 0) {
|
|
$Context.BasicInfo.Insert($insertAt, $idRow)
|
|
}
|
|
elseif ($Context.BasicInfo.Count -gt 0) {
|
|
$Context.BasicInfo.Insert(1, $idRow)
|
|
}
|
|
else {
|
|
$Context.BasicInfo.Add($idRow)
|
|
}
|
|
}
|
|
|
|
# Orchestrates one batch through one or more registered output providers.
|
|
# Drives the lifecycle hooks (Activate -> PreProcess -> {NewObjectGroup -> NewObjectType -> Process}* -> ProcessAllObjects -> PostProcess).
|
|
#
|
|
# $OutputValue is the registry Value field (e.g. 'json'); accepts comma-separated
|
|
# list for multi-output batches. $PolicyObjects is the iteration source.
|
|
function Get-DocumentationPolicyGroupId {
|
|
param([object]$PolicyObject)
|
|
|
|
$policyType = $PolicyObject.PolicyType
|
|
if ($policyType -and $policyType.PolicyGroup -and $policyType.PolicyGroup.Id) {
|
|
return [string]$policyType.PolicyGroup.Id
|
|
}
|
|
if ($policyType -and $policyType.PSObject.Properties['GroupId'] -and $policyType.GroupId) {
|
|
return [string]$policyType.GroupId
|
|
}
|
|
return 'Other'
|
|
}
|
|
|
|
function Get-DocumentationPolicyGroupName {
|
|
param([object]$PolicyObject)
|
|
|
|
$policyType = $PolicyObject.PolicyType
|
|
if ($policyType -and $policyType.PolicyGroup -and $policyType.PolicyGroup.Title) {
|
|
return [string]$policyType.PolicyGroup.Title
|
|
}
|
|
return Get-DocObjectTypeString (Get-DocumentationPolicyGroupId $PolicyObject)
|
|
}
|
|
|
|
function Get-DocumentationPolicyTypeName {
|
|
param([object]$PolicyObject)
|
|
|
|
# Several policy types can share one second-level heading - see
|
|
# Core/DocumentationEnrollmentGrouping.ps1. Nothing else is affected: the
|
|
# resolver returns $null for every type that is not in a grouping.
|
|
$group = Get-DocumentationTypeGroup $PolicyObject
|
|
if ($group) { return [string]$group.Title }
|
|
|
|
if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Title) {
|
|
return [string]$PolicyObject.PolicyType.Title
|
|
}
|
|
if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) {
|
|
return [string]$PolicyObject.PolicyType.Id
|
|
}
|
|
return 'Other'
|
|
}
|
|
|
|
# The key the second level is grouped on. Must stay in step with
|
|
# Get-DocumentationPolicyTypeName above: grouping on PolicyType.Id while naming from
|
|
# a shared grouping title would emit the same heading once per member type.
|
|
function Get-DocumentationPolicyTypeId {
|
|
param([object]$PolicyObject)
|
|
|
|
$group = Get-DocumentationTypeGroup $PolicyObject
|
|
if ($group) { return [string]$group.Id }
|
|
|
|
if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) {
|
|
return [string]$PolicyObject.PolicyType.Id
|
|
}
|
|
return 'Other'
|
|
}
|
|
|
|
# Run-level prefetch: resolve every tenant-wide lookup and per-policy
|
|
# sub-resource the providers would otherwise fetch one-at-a-time mid-run.
|
|
#
|
|
# 1. Seed ScopeTags + FilterNamesById from the login-time tenant dependency
|
|
# cache (Initialize-TenantDependencyCache preloads both at authentication;
|
|
# previously the doc pipeline ignored that cache and re-fetched).
|
|
# 2. Everything still missing goes into ONE Invoke-GraphBatchRequest call:
|
|
# - deviceManagement/roleScopeTags (if ScopeTags empty)
|
|
# - deviceManagement/assignmentFilters?$select=... (if filters not loaded)
|
|
# - deviceManagement/configurationCategories (if run has Settings Catalog policies)
|
|
# - deviceManagement/complianceCategories (if run has Compliance V2 policies)
|
|
# - configurationPolicies('<id>')/settings?$expand=settingDefinitions per SC policy
|
|
# - compliancePolicies('<id>')/settings?$expand=settingDefinitions per CompV2 policy
|
|
# The batcher chunks at 20 sub-requests and parallelizes chunks, so 91
|
|
# Settings Catalog policies cost ~5 round-trips instead of 91 sequential GETs.
|
|
#
|
|
# Providers keep their per-policy lazy fallbacks for the single-policy
|
|
# Get-GraphDocumentation path; this prefetch just makes the bulk path batch.
|
|
function Initialize-DocumentationRunPrefetch {
|
|
param(
|
|
[object[]]$PolicyObjects,
|
|
[DocumentationContext]$Context
|
|
)
|
|
|
|
if (-not $Context -or $Context.SourceTenantUnavailable) { return }
|
|
if (-not (Get-Command Invoke-GraphBatchRequest -ErrorAction SilentlyContinue)) { return }
|
|
|
|
# Per-run freshness: a policy re-documented in the same session must show
|
|
# its current settings, so the per-policy prefetch never spans runs.
|
|
$Context.PrefetchedPolicySettings = @{}
|
|
|
|
$tokenId = 0
|
|
foreach ($p in @($PolicyObjects)) {
|
|
if ($p -and $null -ne $p._TokenId) { $tokenId = [int]$p._TokenId; break }
|
|
}
|
|
|
|
# ---- 1. Seed from the login-time tenant dependency cache (no Graph) ----
|
|
try {
|
|
# Get-OperationTokenInfo, not Get-TokenInfo: $tokenId is seeded 0 above and a
|
|
# policy carrying no token has _TokenId 0 - never $null - so 0 survives here.
|
|
# Get-TokenInfo reads that 0 as "no filter, every token", which would name
|
|
# both tenants in the DependencyObjects_<tenant> key with a second one live.
|
|
$tokenInfo = Get-OperationTokenInfo $tokenId
|
|
if ($tokenInfo -and $tokenInfo.TenantId) {
|
|
$dep = Get-CacheObject "DependencyObjects_$($tokenInfo.TenantId)"
|
|
if ($dep -is [hashtable]) {
|
|
if ((-not $Context.ScopeTags -or $Context.ScopeTags.Count -eq 0) -and $dep.ContainsKey('ScopeTags')) {
|
|
# Project the IntunePolicyBase wrappers (and the synthetic
|
|
# Default entry) to the raw {id, displayName} shape
|
|
# Add-ScopeTagStrings / Test-DocumentationPolicyFilter read.
|
|
$Context.ScopeTags = @($dep['ScopeTags'] | Where-Object { $_ } | ForEach-Object {
|
|
[PSCustomObject]@{ id = [string]$_.Id; displayName = [string]$_.Name }
|
|
})
|
|
Write-Log "Documentation prefetch: seeded $($Context.ScopeTags.Count) scope tag(s) from tenant dependency cache"
|
|
}
|
|
if (-not $Context.FiltersLoaded) {
|
|
$filters = @($dep.Values | Where-Object {
|
|
$_ -and $_.PSObject.Properties['PolicyType'] -and $_.PolicyType.Id -eq 'AssignmentFilters'
|
|
})
|
|
if ($filters.Count -gt 0) {
|
|
foreach ($f in $filters) { $Context.FilterNamesById[[string]$f.Id] = [string]$f.Name }
|
|
$Context.FiltersLoaded = $true
|
|
Write-Log "Documentation prefetch: seeded $($filters.Count) assignment filter(s) from tenant dependency cache"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
Write-LogDebug "Documentation prefetch: dependency-cache seed failed: $($_.Exception.Message)"
|
|
}
|
|
|
|
# ---- 1b. Batch-resolve every assignment group name in one getByIds call ----
|
|
# Front-loads $ctx.GroupNamesById so Add-AssignmentsForObject is a pure cache hit
|
|
# for every policy instead of one getByIds per policy. Same groups either way
|
|
# (only assignment-referenced ones) and same output - purely fewer round-trips.
|
|
# Not gated on the batching setting: this is one getByIds POST per thousand
|
|
# ids, not a $batch, and skipping it only made a run resolve one group per
|
|
# policy instead. UseBatchAPI now means exactly "combine requests into $batch"
|
|
# (see Test-GraphBatchEnabled) and this path never did that.
|
|
if (-not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
|
try {
|
|
Sync-DocumentationGroupPreload -PolicyObjects $PolicyObjects -Context $Context
|
|
}
|
|
catch {
|
|
Write-LogError 'Documentation prefetch: batch group resolution failed - falling back to per-policy resolution' $_.Exception
|
|
}
|
|
}
|
|
|
|
# ---- 2. One $batch for everything still missing ----
|
|
$minimalHeaders = @{ Accept = 'application/json;odata.metadata=minimal' }
|
|
$settingsHeaders = @{ Accept = 'application/json;odata.metadata=minimal' }
|
|
if ($Context.Language -and $Context.Language -ne 'en') {
|
|
$settingsHeaders['Accept-Language'] = $Context.Language
|
|
}
|
|
|
|
$requests = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$routes = @{} # reqId -> [PSCustomObject]@{ Kind; PolicyId }
|
|
|
|
if (-not $Context.ScopeTags -or $Context.ScopeTags.Count -eq 0) {
|
|
[void]$requests.Add([PSCustomObject]@{ id = 'scopeTags'; method = 'GET'; url = 'deviceManagement/roleScopeTags'; headers = $minimalHeaders })
|
|
$routes['scopeTags'] = [PSCustomObject]@{ Kind = 'scopeTags' }
|
|
}
|
|
if (-not $Context.FiltersLoaded) {
|
|
[void]$requests.Add([PSCustomObject]@{ id = 'filters'; method = 'GET'; url = 'deviceManagement/assignmentFilters?$select=id,displayName&$top=999'; headers = $minimalHeaders })
|
|
$routes['filters'] = [PSCustomObject]@{ Kind = 'filters' }
|
|
}
|
|
|
|
# Category lists, only when the run actually contains policies that use them.
|
|
$Context.CfgCategories = Get-CacheObject "CfgCategories" (@())
|
|
$hasSettingsCatalog = $false
|
|
$hasComplianceV2 = $false
|
|
$settingsIdx = 0
|
|
foreach ($p in @($PolicyObjects)) {
|
|
if (-not $p -or $p.IsFromFile -eq $true) { continue }
|
|
$obj = $p.JsonObject
|
|
if (-not $obj -or -not $p.Id) { continue }
|
|
|
|
$odata = [string]$obj.'@odata.type'
|
|
$needsSettings = $false
|
|
$settingsUrl = $null
|
|
|
|
if ($odata -eq '#microsoft.graph.deviceManagementConfigurationPolicy') {
|
|
$hasSettingsCatalog = $true
|
|
$settingsUrl = "deviceManagement/configurationPolicies('$($p.Id)')/settings?`$expand=settingDefinitions&`$top=1000"
|
|
}
|
|
elseif ($odata -eq '#microsoft.graph.deviceManagementCompliancePolicy') {
|
|
$hasComplianceV2 = $true
|
|
$settingsUrl = "deviceManagement/compliancePolicies('$($p.Id)')/settings?`$expand=settingDefinitions&`$top=1000"
|
|
}
|
|
else { continue }
|
|
|
|
# Skip when the body already carries settings WITH inline definitions —
|
|
# the providers use those directly. Hydrated bodies typically have
|
|
# settings without definitions, so they still need the enrich fetch.
|
|
$needsSettings = $true
|
|
foreach ($s in @($obj.Settings)) {
|
|
if ($s.settingDefinitions -and @($s.settingDefinitions).Count -gt 0) { $needsSettings = $false; break }
|
|
}
|
|
if (-not $needsSettings) { continue }
|
|
|
|
$settingsIdx++
|
|
$reqId = "polset_$settingsIdx"
|
|
[void]$requests.Add([PSCustomObject]@{ id = $reqId; method = 'GET'; url = $settingsUrl; headers = $settingsHeaders })
|
|
$routes[$reqId] = [PSCustomObject]@{ Kind = 'policySettings'; PolicyId = [string]$p.Id }
|
|
}
|
|
|
|
if ($hasSettingsCatalog -and -not ($Context.CfgCategories | Where-Object { $_.settingUsage -eq 'configuration' })) {
|
|
[void]$requests.Add([PSCustomObject]@{ id = 'cfgCats'; method = 'GET'; url = 'deviceManagement/configurationCategories'; headers = $minimalHeaders })
|
|
$routes['cfgCats'] = [PSCustomObject]@{ Kind = 'cfgCats' }
|
|
}
|
|
if ($hasComplianceV2 -and -not ($Context.CfgCategories | Where-Object { $_.settingUsage -eq 'compliance' })) {
|
|
[void]$requests.Add([PSCustomObject]@{ id = 'compCats'; method = 'GET'; url = 'deviceManagement/complianceCategories'; headers = $minimalHeaders })
|
|
$routes['compCats'] = [PSCustomObject]@{ Kind = 'compCats' }
|
|
}
|
|
|
|
if ($requests.Count -eq 0) { return }
|
|
|
|
Write-Log "Documentation prefetch: $($requests.Count) request(s) in one batch (tenant-wide + per-policy settings)"
|
|
$results = @(Invoke-GraphBatchRequest -BatchObjects $requests -BatchType 'Documentation:Prefetch' -TokenId $tokenId -SkipWarnings -IncludedFailed)
|
|
|
|
$categoriesUpdated = $false
|
|
foreach ($result in $results) {
|
|
$route = $routes["$($result.Id)"]
|
|
if (-not $route) { continue }
|
|
if ($result.Status -lt 200 -or $result.Status -ge 300 -or -not $result.body) {
|
|
Write-Log "Documentation prefetch: '$($result.Id)' returned HTTP $($result.Status) - provider lazy fallback will retry" 2
|
|
continue
|
|
}
|
|
|
|
switch ($route.Kind) {
|
|
'scopeTags' {
|
|
if ($result.body.value) { $Context.ScopeTags = @($result.body.value) }
|
|
}
|
|
'filters' {
|
|
foreach ($f in @($result.body.value)) {
|
|
if ($f.id) { $Context.FilterNamesById[[string]$f.id] = $f.displayName }
|
|
}
|
|
$Context.FiltersLoaded = $true
|
|
}
|
|
'cfgCats' {
|
|
$Context.CfgCategories += @($result.body.value)
|
|
$categoriesUpdated = $true
|
|
}
|
|
'compCats' {
|
|
$Context.CfgCategories += @($result.body.value)
|
|
$categoriesUpdated = $true
|
|
}
|
|
'policySettings' {
|
|
$Context.PrefetchedPolicySettings[$route.PolicyId] = @($result.body.value)
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($categoriesUpdated) {
|
|
Set-CacheObject "CfgCategories" $Context.CfgCategories -Persistent
|
|
}
|
|
}
|
|
|
|
# Batch group-name resolution (run from the prefetch when the "Use Batch API"
|
|
# setting is on). Collects every assignment groupId across all policies in the run
|
|
# and resolves them to display names in one getByIds batch (chunked at 1000),
|
|
# caching into $Context.GroupNamesById.
|
|
# Best-effort: assignments must already be hydrated onto the policy bodies (the
|
|
# run hydrate step in Invoke-DocumentationOutputs runs first); anything not found
|
|
# here still resolves lazily per-object in Add-AssignmentsForObject.
|
|
function Sync-DocumentationGroupPreload {
|
|
param(
|
|
[object[]]$PolicyObjects,
|
|
[DocumentationContext]$Context
|
|
)
|
|
if (-not $Context) { return }
|
|
|
|
$ids = [System.Collections.Generic.HashSet[string]]::new()
|
|
foreach ($p in @($PolicyObjects)) {
|
|
if (-not $p) { continue }
|
|
$obj = if ($p.PSObject.Properties['JsonObject']) { $p.JsonObject } else { $p }
|
|
if (-not $obj) { continue }
|
|
foreach ($a in @($obj.assignments)) {
|
|
if (-not $a -or -not $a.target) { continue }
|
|
$gid = [string]$a.target.groupId
|
|
if ($gid) { [void]$ids.Add($gid) }
|
|
}
|
|
}
|
|
if ($ids.Count -eq 0) { return }
|
|
|
|
Write-Status "Documentation - preloading assignment groups" -SkipLog -Force
|
|
Write-Log "Documentation prefetch: preloading $($ids.Count) assignment group name(s) in one batch"
|
|
[void](Resolve-DocumentationGroupNames -GroupIds @($ids) -Context $Context)
|
|
}
|
|
|
|
# What an output provider should call this object in a heading or a caption.
|
|
#
|
|
# Normally the policy's display name, but a documented object may ask to be titled
|
|
# something else through DocumentName - the default enrollment policies do, because
|
|
# five different types all ship under the name "All users and all devices" and the
|
|
# document would not say which one it is. The display name is not lost: it still
|
|
# appears as the Name row inside the table.
|
|
#
|
|
# Headings and captions only. The file name, the CSV rows and the JSON object keep
|
|
# the real display name, because those identify the object rather than describe it.
|
|
function Get-DocumentationDisplayName {
|
|
param($PolicyObject, $DocumentedObject)
|
|
|
|
if ($DocumentedObject -and
|
|
$DocumentedObject.PSObject.Properties['DocumentName'] -and
|
|
-not [string]::IsNullOrWhiteSpace([string]$DocumentedObject.DocumentName)) {
|
|
return [string]$DocumentedObject.DocumentName
|
|
}
|
|
|
|
# A grouped enrollment default is titled by its policy type even when nothing set
|
|
# DocumentName. These five types reach the document by different routes - a
|
|
# dedicated Handler for the platform restrictions, the ObjectInfo manifest for
|
|
# Windows Hello and Windows Restore, the generic fallback for the rest - and a
|
|
# Handler wins over the input provider that would otherwise have set the name, so
|
|
# setting it there alone left the platform restrictions headed "All users and all
|
|
# devices". Resolving it here covers every route once instead of each handler
|
|
# having to remember. See Core/DocumentationEnrollmentGrouping.ps1.
|
|
$enrollmentName = Get-DocumentationEnrollmentDefaultName $PolicyObject
|
|
if ($enrollmentName) { return $enrollmentName }
|
|
|
|
return [string]$PolicyObject.Name
|
|
}
|
|
|
|
# The name the object currently being documented is titled under, for table
|
|
# captions. The per-object Process function of each output provider stamps
|
|
# $script:docDisplayName from Get-DocumentationDisplayName above; this reads it
|
|
# back where only $PolicyObject is in scope. Falls back to the display name so a
|
|
# caption built outside that path still reads correctly.
|
|
#
|
|
# One copy, in the engine: the four document providers share a module scope, so
|
|
# four identical definitions would silently collapse into whichever file loaded
|
|
# last.
|
|
function Get-DocCaptionName {
|
|
param($PolicyObject)
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($script:docDisplayName)) { return $script:docDisplayName }
|
|
return [string]$PolicyObject.Name
|
|
}
|
|
|
|
function Invoke-DocumentationOutputs {
|
|
param(
|
|
[string]$OutputValue,
|
|
[object[]]$PolicyObjects,
|
|
[hashtable]$Options,
|
|
[string]$Language = 'en'
|
|
)
|
|
|
|
if (-not $OutputValue) { throw "OutputValue is required" }
|
|
$Options = Merge-DocumentationOptions $Options
|
|
if ($Language) { $Options.Language = $Language }
|
|
$script:_docRunOptions = $Options
|
|
Set-DocumentationContextRunOptions -Context (Get-DocContextSingleton) -Options $Options -Language $Language
|
|
# A run reads current tenant state: whatever the previous run cached of the
|
|
# message templates and app catalogues is dropped here, not carried across.
|
|
Reset-DocumentationTenantLookups -Context (Get-DocContextSingleton)
|
|
|
|
# Hydrate every input policy through the unified orchestrator BEFORE the
|
|
# output loop. Handlers and input providers read $PolicyObject.JsonObject.*
|
|
# and assume a fully hydrated body + sub-resources (e.g. SettingsCatalog
|
|
# assignments, AdminTemplate definition/presentation values, branding
|
|
# images, RoleDefinition assignment expansion). Without this, online doc
|
|
# runs that source policies from Get-GraphPolicies silently emit incomplete
|
|
# output for any type whose list endpoint doesn't include sub-resources.
|
|
#
|
|
# Skip when the source tenant is unavailable (SourceFolder docs) and per-policy
|
|
# for file-loaded objects — they have no token and Invoke-PolicyHydrate
|
|
# would issue Graph calls under the default token, hitting either an auth
|
|
# error or (worse) the wrong tenant. (Hydrate is source-tenant-specific:
|
|
# by-id GETs that 404 on any other tenant — distinct from generic schema.)
|
|
if (-not $Options.SourceTenantUnavailable) {
|
|
$hydrateTargets = @($PolicyObjects | Where-Object {
|
|
$_ -and $_.PSObject.Properties['_IsFullObject'] -and -not $_._IsFullObject -and
|
|
$_.Id -and $_.PolicyType -and $_.IsFromFile -ne $true
|
|
})
|
|
if ($hydrateTargets.Count -gt 0) {
|
|
Write-Status "Documentation - hydrating policies" -SkipLog -Force
|
|
Invoke-PolicyHydrate -Policies $hydrateTargets
|
|
}
|
|
|
|
# Batch-prefetch tenant-wide lookups + per-policy settings so the
|
|
# output loop below never pays a sequential mid-run Graph GET. Failure
|
|
# is non-fatal: providers keep their lazy per-policy fallbacks.
|
|
try {
|
|
Write-Status "Documentation - pre-fetching settings" -SkipLog -Force
|
|
Initialize-DocumentationRunPrefetch -PolicyObjects $PolicyObjects -Context (Get-DocContextSingleton)
|
|
}
|
|
catch {
|
|
Write-LogError 'Documentation prefetch failed - falling back to per-policy fetches' $_.Exception
|
|
}
|
|
}
|
|
|
|
$failures = [System.Collections.Generic.List[object]]::new()
|
|
$recordFailure = {
|
|
param([string]$Stage, $Output, [string]$PolicyName, [string]$Message, $Exception)
|
|
Write-LogError $Message $Exception
|
|
[void]$failures.Add([PSCustomObject]@{
|
|
Stage = $Stage
|
|
Output = if ($Output) { [string]$Output.Value } else { "" }
|
|
PolicyName = $PolicyName
|
|
Message = $Message
|
|
})
|
|
}
|
|
|
|
$values = $OutputValue.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
|
$selected = foreach ($v in $values) {
|
|
$o = [DocumentationRegistry]::FindOutput($v)
|
|
if (-not $o) {
|
|
& $recordFailure "Lookup" $null "" "No registered output for value '$v'" $null
|
|
continue
|
|
}
|
|
$o
|
|
}
|
|
if (-not $selected) {
|
|
$details = @($failures | ForEach-Object Message | Select-Object -Unique)
|
|
throw "Documentation output failed in $($failures.Count) hook invocation(s): $($details -join '; ')"
|
|
}
|
|
|
|
# Keep the document hierarchy deterministic at each level:
|
|
# object group display name -> object type display name -> policy name.
|
|
# Third key is the title the object will actually be documented under, not its
|
|
# raw display name - see Get-DocumentationSortName.
|
|
$orderedPolicies = @($PolicyObjects | Sort-Object `
|
|
@{ Expression = { Get-DocumentationPolicyGroupName $_ } }, `
|
|
@{ Expression = { Get-DocumentationPolicyTypeName $_ } }, `
|
|
@{ Expression = { Get-DocumentationSortName $_ } })
|
|
$script:_docRunProcessed = 0
|
|
# Types that had no documentation support and were skipped rather than
|
|
# written out empty. Surfaced in the summary so a short document is
|
|
# explained by a number instead of looking like a silent failure.
|
|
$script:_docRunSkipped = 0
|
|
$groups = @($orderedPolicies | Group-Object { Get-DocumentationPolicyGroupId $_ } |
|
|
Sort-Object { Get-DocumentationPolicyGroupName $_.Group[0] })
|
|
|
|
# Output lifecycle contract - providers rely on this fixed ordering:
|
|
# Activate -> PreProcess (once per provider, this loop)
|
|
# -> { NewObjectGroup -> { NewObjectType -> Process* -> ProcessAllObjects } }
|
|
# -> PostProcess (once per provider, below)
|
|
# PreProcess is the single place each provider (re)initializes its $script:
|
|
# accumulators, and it always runs here before any Process call, so Process
|
|
# may assume they exist. (#39: ordering is guaranteed by this loop - some
|
|
# accumulators are shared across providers, so per-provider re-entry guards
|
|
# would be fragile and are deliberately not used.)
|
|
foreach ($output in $selected) {
|
|
try { if ($output.Activate) { & $output.Activate } } catch { & $recordFailure "Activate" $output "" "Activate failed for $($output.Name): $($_.Exception.Message)" $_.Exception }
|
|
try { if ($output.PreProcess) { & $output.PreProcess } } catch { & $recordFailure "PreProcess" $output "" "PreProcess failed for $($output.Name): $($_.Exception.Message)" $_.Exception }
|
|
}
|
|
|
|
foreach ($groupBucket in $groups) {
|
|
$groupName = Get-DocumentationPolicyGroupName $groupBucket.Group[0]
|
|
foreach ($output in $selected) {
|
|
try { if ($output.NewObjectGroup) { & $output.NewObjectGroup $groupName } } catch { & $recordFailure "NewObjectGroup" $output "" "NewObjectGroup failed for $($output.Name): $($_.Exception.Message)" $_.Exception }
|
|
}
|
|
|
|
$typeBuckets = @($groupBucket.Group | Group-Object { Get-DocumentationPolicyTypeId $_ } |
|
|
Sort-Object { Get-DocumentationPolicyTypeName $_.Group[0] })
|
|
foreach ($typeBucket in $typeBuckets) {
|
|
$typeName = Get-DocumentationPolicyTypeName $typeBucket.Group[0]
|
|
foreach ($output in $selected) {
|
|
try { if ($output.NewObjectType) { & $output.NewObjectType $typeName } } catch { & $recordFailure "NewObjectType" $output "" "NewObjectType failed for $($output.Name): $($_.Exception.Message)" $_.Exception }
|
|
}
|
|
|
|
$typeObjects = @($typeBucket.Group | Sort-Object { Get-DocumentationSortName $_ })
|
|
|
|
foreach ($policyObj in $typeObjects) {
|
|
$script:_docRunProcessed++
|
|
Write-Status `
|
|
-Text ("Documenting {0} ({1} of {2})" -f $typeName, $script:_docRunProcessed, $orderedPolicies.Count) `
|
|
-Detail ([string]$policyObj.Name) `
|
|
-SkipLog -Force
|
|
$result = Invoke-DocumentationForObject -PolicyObject $policyObj -Context (Get-DocContextSingleton -Options $Options)
|
|
|
|
# An unsupported type with the fallback off produces no content at
|
|
# all. Handing that to the output providers wrote an empty page -
|
|
# or, with Include policy ID on, a page holding a single id row.
|
|
# Skip it: the engine has already logged why, by name and type.
|
|
if ($result.InputType -eq 'NoProvider') {
|
|
$script:_docRunSkipped++
|
|
continue
|
|
}
|
|
|
|
foreach ($output in $selected) {
|
|
try { if ($output.Process) { & $output.Process $policyObj $result } } catch { & $recordFailure "Process" $output ([string]$policyObj.Name) "Process failed for $($output.Name) on $($policyObj.Name): $($_.Exception.Message)" $_.Exception }
|
|
}
|
|
}
|
|
|
|
foreach ($output in $selected) {
|
|
try { if ($output.ProcessAllObjects) { & $output.ProcessAllObjects $typeObjects } } catch { & $recordFailure "ProcessAllObjects" $output "" "ProcessAllObjects failed for $($output.Name): $($_.Exception.Message)" $_.Exception }
|
|
}
|
|
}
|
|
}
|
|
|
|
Write-Status "Documentation - saving output files" -SkipLog -Force
|
|
foreach ($output in $selected) {
|
|
try { if ($output.PostProcess) { & $output.PostProcess } } catch { & $recordFailure "PostProcess" $output "" "PostProcess failed for $($output.Name): $($_.Exception.Message)" $_.Exception }
|
|
}
|
|
Write-Status $null
|
|
|
|
if ($failures.Count -gt 0) {
|
|
$details = @($failures | ForEach-Object Message | Select-Object -Unique)
|
|
throw "Documentation output failed in $($failures.Count) hook invocation(s): $($details -join '; ')"
|
|
}
|
|
|
|
if ($script:_docRunSkipped -gt 0) {
|
|
Write-Log "Documentation: $($script:_docRunSkipped) object(s) skipped - their type has no documentation support and 'Document unsupported types' is off" 2
|
|
}
|
|
|
|
[PSCustomObject]@{
|
|
Outputs = @($selected | ForEach-Object Value)
|
|
PolicyCount = @($PolicyObjects).Count
|
|
SkippedCount = $script:_docRunSkipped
|
|
FailureCount = 0
|
|
}
|
|
}
|
|
|
|
# Singleton context per-batch so Add* state doesn't leak between objects but
|
|
# we don't pay constructor cost per object either. ResetForObject clears the
|
|
# accumulators between objects.
|
|
$script:_docContextSingleton = $null
|
|
function Get-DocContextSingleton {
|
|
param([hashtable]$Options)
|
|
if (-not $script:_docContextSingleton) {
|
|
$script:_docContextSingleton = [DocumentationContext]::new($null, 'en', (Merge-DocumentationOptions $Options))
|
|
Set-DocumentationContextRunOptions -Context $script:_docContextSingleton -Options $Options
|
|
}
|
|
elseif ($Options) {
|
|
Set-DocumentationContextRunOptions -Context $script:_docContextSingleton -Options $Options
|
|
}
|
|
return $script:_docContextSingleton
|
|
}
|