IntuneManagement 4.0.0-beta1

This commit is contained in:
Mikael Karlsson
2026-09-23 19:13:09 +10:00
commit 7869619510
892 changed files with 577109 additions and 0 deletions
@@ -0,0 +1,404 @@
# Per-object documentation context. Replaces all of the $script:* accumulators
# the old Documentation.psm1 reset at the top of every Get-ObjectDocumentation
# call. Input providers and *DocHandler classes mutate the lists via Add*()
# methods; the engine calls ToResult() to produce the per-object PSCustomObject
# the output providers consume.
#
# Substitution map (from DocumentationMigration.md):
# $global:chkIncludeScripts.IsChecked -> Options.IncludeScripts
# $global:chkExcludeScriptSignature.IsChecked -> Options.ExcludeScriptSignature
# $global:documentationLanguage -> Language
# $script:objectBasicInfo -> BasicInfo
# $script:objectSettingsData -> SettingsData
# $script:objectComplianceActionData -> ComplianceActions
# $script:applicabilityRules -> ApplicabilityRules
# $script:objectAssignments -> Assignments
# $script:objectScripts -> Scripts
# $script:customTables -> CustomTables
# $script:admxCategories -> ADMXCategories
# $script:ObjectTypeFullTable -> ObjectTypeFullTable
# $script:scopeTags -> ScopeTags
# $script:languageStrings -> LanguageStrings
# $script:offlineDocumentation -> SourceTenantUnavailable (formerly OfflineDocumentation)
# $script:settingsProperties, CurrentSubCategory, ValueOutputProperty -> per-call
# fields used during input-provider walks; added incrementally as providers land
class DocumentationContext {
# ---- Inputs ----
[object] $PolicyObject
[string] $Language
[hashtable]$Options
# ---- Cross-batch caches (lazily populated; survive ResetForObject so they
# amortize across all policies in one bulk run, matching old code's
# $global:* caches) ----
[hashtable]$LanguageStrings = @{}
[object[]] $ScopeTags = @() # /deviceManagement/roleScopeTags
[hashtable]$CachedCfgSettings = @{} # SettingsCatalog: settingDefinitionId -> definition
[object[]] $CfgCategories = @() # /deviceManagement/configurationCategories + /complianceCategories
# Resolved assignment-filter display names (filterId -> displayName, or $null
# for IDs the directory didn't return on lookup so we don't keep retrying).
# Survives ResetForObject so a bulk-doc run resolves each filter exactly
# once across all policies.
[hashtable]$FilterNamesById = @{}
# True once the tenant-wide assignment-filter list has been loaded into
# FilterNamesById (from the login-time dependency cache, the run prefetch
# batch, or the lazy fallback in Add-AssignmentsForObject). Acts as a
# negative-cache stamp too, so a failed tenant-wide fetch isn't retried
# per policy.
[bool] $FiltersLoaded = $false
# Resolved assignment-group display names (groupId -> displayName, or $null
# for IDs the directory didn't return on lookup so we don't keep retrying).
# Survives ResetForObject so one bulk-doc run resolves each group at most
# once across all policies. Front-loaded in one getByIds batch by the prefetch
# (when the "Use Batch API" setting is on); otherwise filled lazily per-object
# by Add-AssignmentsForObject. Same negative-cache semantics as FilterNamesById.
[hashtable]$GroupNamesById = @{}
# Per-run prefetch of policy sub-resources, populated by
# Initialize-DocumentationRunPrefetch in one Graph $batch before the output
# loop: policyId -> settings[] (with settingDefinitions expanded) for
# Settings Catalog / Compliance V2 policies. Cleared at the start of every
# run so a re-documented policy reflects current tenant state.
[hashtable]$PrefetchedPolicySettings = @{}
# When true, the SOURCE tenant the export came from is not reachable, so
# source-tenant-specific lookups (assignments->groups, scope-tag/filter/app
# names, named locations, ToU, linked certs, reusable settings, per-policy
# settings-by-id) are skipped. It does NOT mean "no tenant at all": generic
# Intune schema (setting definitions/categories, ADMX, intent templates,
# resourceOperations) is identical on every tenant and is still fetched from
# whatever tenant is connected, gated by Test-DocumentationGraphAvailable.
[bool] $SourceTenantUnavailable
# Title the document by something other than the policy's display name, and
# fold the Basics rows into the settings table instead of emitting a second
# table. Both exist for the default enrollment policies: Windows Hello for
# Business, Windows Restore, the device limit, the platform restrictions and
# the enrollment status page all ship under the SAME display name, "All users
# and all devices", so a document headed by that name does not say which
# policy it is, and the two tables between them hold only a handful of rows.
# Set per object by the ObjectInfo customizer; both reset between objects.
[string] $DocumentName = $null
[bool] $MergeBasicInfo = $false
# ---- Per-object accumulators (drained by ToResult) ----
[System.Collections.Generic.List[object]] $BasicInfo = [System.Collections.Generic.List[object]]::new()
[System.Collections.Generic.List[object]] $SettingsData = [System.Collections.Generic.List[object]]::new()
[System.Collections.Generic.List[object]] $ComplianceActions = [System.Collections.Generic.List[object]]::new()
[System.Collections.Generic.List[object]] $ApplicabilityRules = [System.Collections.Generic.List[object]]::new()
[System.Collections.Generic.List[object]] $Assignments = [System.Collections.Generic.List[object]]::new()
[System.Collections.Generic.List[object]] $Scripts = [System.Collections.Generic.List[object]]::new()
[System.Collections.Generic.List[object]] $CustomTables = [System.Collections.Generic.List[object]]::new()
[object[]] $ADMXCategories = @() # /deviceManagement/groupPolicyCategories?$expand=parent,definitions — batch cache, survives ResetForObject
# Intent template caches (deviceManagementIntent input provider). Keyed by
# templateId / categoryId; survive ResetForObject so a bulk run of N intents
# against the same template only pays one round-trip per category.
[hashtable] $IntentCategories = @{} # templateId -> categories[] (with $expand=settingDefinitions)
[hashtable] $IntentCatRecommendedSettings = @{} # categoryId -> recommendedSettings[]
# ---- Cross-type accumulator (for ScopeTags consolidated table at end of batch) ----
[hashtable] $ObjectTypeFullTable = @{}
# ---- Result fields populated by input providers / handlers ----
[string[]] $DefaultDocumentationProperties = @('Name','Value')
[object[]] $DisplayProperties = @()
[string] $ErrorText
[string] $InputType
[bool] $UpdateFilteredObject
[object[]] $UnconfiguredProperties = @()
# ---- Walker / translate-primitive state (replaces $script:currentObject,
# $script:CurrentSubCategory, $script:propLevel, $script:propertySeparator,
# $script:objectSeparator) ----
[object] $CurrentObject
[string] $CurrentSubCategory
[int] $PropLevel = 0
[string] $PropertySeparator = ','
[string] $ObjectSeparator = [System.Environment]::NewLine
DocumentationContext() {
$this.Options = [DocumentationContext]::DefaultOptions()
$this.Language = 'en'
}
DocumentationContext([object]$PolicyObject, [string]$Language, [hashtable]$Options) {
$this.PolicyObject = $PolicyObject
$this.Language = if ($Language) { $Language } else { 'en' }
$this.Options = if ($Options) { $Options } else { [DocumentationContext]::DefaultOptions() }
}
# Default Options hashtable. Keys recognised by the engine:
# IncludeScripts [bool] emit Scripts collection (default true)
# ExcludeScriptSignature [bool] strip script signing blocks (default false)
# IncludePolicyId [bool] engine appends an 'Id' BasicInfo row after
# handler/input dispatch when true (default false)
static [hashtable] DefaultOptions() {
return @{
IncludeScripts = $true
ExcludeScriptSignature = $false
IncludePolicyId = $false
Language = 'en'
PropertySeparator = ';'
ObjectSeparator = [System.Environment]::NewLine
# When true, the engine post-step that translates $obj.assignments
# into Assignment rows is skipped. Old engine gated this on
# $global:chkExcludeAssignments (default off — assignments included).
ExcludeAssignments = $false
# ObjectInfo walker: when a property is null on the input, the walker
# can either skip emitting a row OR substitute the prop's declared
# unconfiguredValue / defaultValue / emptyValueResourceKey. Old engine
# gates these on UI checkboxes. Legacy defaults substitute an explicit
# unconfigured value but do not substitute a declared default value.
SetUnconfiguredValue = $true
SetDefaultValue = $false
SkipNotConfigured = $false
SkipDefaultValues = $false
SkipDisabled = $true
NotConfiguredText = 'notConfigured'
ValueOutputProperty = 'value'
SourceTenantUnavailable = $false
# When true, output providers skip the document-info header they emit
# at the top of a Full document (Organization / Generated by / Generated
# date). Generic because every provider writes the same block; read via
# Get-DocumentationOption so all providers honour it consistently.
SkipDocumentInfo = $false
# A policy type that no handler and no input provider claims is
# documented by the generic fallback - basic info plus one row per
# property - instead of producing a page with nothing on it. On by
# default: a rough table beats a blank page, and the alternative
# (Enrollment Notifications, Windows Hello for Business and friends
# documenting to nothing) reads as a bug to everyone who hits it.
# Off means those types are logged and skipped entirely, not emitted
# as an empty document. Overridden per run via -Options, and per
# user by the Output Settings checkbox, which saves to this key.
FallbackDocumentation = $true
Outputs = @{}
}
}
# Reset per-object accumulators so the same context instance can be reused
# across objects in a bulk run (cheaper than constructing a new one).
[void] ResetForObject([object]$PolicyObject) {
$this.PolicyObject = $PolicyObject
$this.CurrentObject = if ($PolicyObject -and $PolicyObject.PSObject.Properties['JsonObject']) { $PolicyObject.JsonObject } else { $PolicyObject }
$this.BasicInfo.Clear()
$this.SettingsData.Clear()
$this.ComplianceActions.Clear()
$this.ApplicabilityRules.Clear()
$this.Assignments.Clear()
$this.Scripts.Clear()
$this.CustomTables.Clear()
# ADMXCategories deliberately not cleared — batch-scoped cache (matches old $script:admxCategories)
$this.DefaultDocumentationProperties = @('Name','Value')
$this.DisplayProperties = @()
$this.ErrorText = $null
$this.InputType = $null
$this.UpdateFilteredObject = $false
$this.UnconfiguredProperties = @()
$this.CurrentSubCategory = $null
$this.PropLevel = 0
$this.DocumentName = $null
$this.MergeBasicInfo = $false
}
# ---- Add* methods (used by input providers / handlers) ----
[void] AddBasic([string]$Name, [object]$Value) {
$this.AddBasic($Name, $Value, $null)
}
# Preferred overload — pass the source field name (e.g. 'displayName',
# 'state', 'createdDateTime') as EntityKey. Compare logic and other
# downstream tools key rows by EntityKey rather than the localized Name.
[void] AddBasic([string]$Name, [object]$Value, [string]$EntityKey) {
$this.BasicInfo.Add([PSCustomObject]@{
Name = $Name; Value = $Value; EntityKey = $EntityKey
})
}
[void] AddProperty([string]$Name, [object]$Value) {
$this.SettingsData.Add([PSCustomObject]@{ Name = $Name; Value = $Value })
}
[void] AddProperty([string]$Name, [object]$Value, [string]$Category) {
$this.SettingsData.Add([PSCustomObject]@{ Name = $Name; Value = $Value; Category = $Category })
}
[void] AddProperty([string]$Name, [object]$Value, [string]$Category, [string]$SubCategory) {
$this.SettingsData.Add([PSCustomObject]@{ Name = $Name; Value = $Value; Category = $Category; SubCategory = $SubCategory })
}
[void] AddSetting([object]$Setting) {
# Every settings row must carry a stable, language-independent
# EntityKey — it is the join key for documentation-based compare (and
# any other downstream tooling). Manifest/Profile/handler rows set it
# themselves; Settings Catalog / Intent walker rows carry the identity
# as SettingId (+ ParentSettingId / RowIndex), so derive it here:
# [<ParentSettingId>/]<SettingId>[#<RowIndex>]
# RowIndex > 0 disambiguates repeated group-collection rows (e.g.
# firewall rule lists) — positional matching, same as the old project.
if ($Setting -and (-not $Setting.PSObject.Properties['EntityKey'] -or
[string]::IsNullOrEmpty([string]$Setting.EntityKey))) {
$key = $null
if ($Setting.PSObject.Properties['SettingId'] -and $Setting.SettingId) {
$key = [string]$Setting.SettingId
if ($Setting.PSObject.Properties['ParentSettingId'] -and $Setting.ParentSettingId) {
$key = "$($Setting.ParentSettingId)/$key"
}
if ($Setting.PSObject.Properties['RowIndex'] -and [int]$Setting.RowIndex -gt 0) {
$key = "$key#$($Setting.RowIndex)"
}
}
if ($key) {
$Setting | Add-Member -MemberType NoteProperty -Name 'EntityKey' -Value $key -Force
}
else {
Write-LogDebug "Documentation row without EntityKey: '$($Setting.Name)' ($($this.InputType))"
}
}
$this.SettingsData.Add($Setting)
}
[void] AddComplianceAction([string]$Action, [string]$Schedule, [string]$MessageTemplate, [string]$EmailCC) {
$this.ComplianceActions.Add([PSCustomObject]@{
Action = $Action
Schedule = $Schedule
MessageTemplate = $MessageTemplate
EmailCC = $EmailCC
})
}
[void] AddApplicabilityRule([string]$Rule, [string]$Property, [object]$Value) {
$this.ApplicabilityRules.Add([PSCustomObject]@{
Rule = $Rule; Property = $Property; Value = $Value
})
}
[void] AddAssignment([object]$Assignment) {
$this.Assignments.Add($Assignment)
}
[void] AddCustomTable([object]$Table) {
$this.CustomTables.Add($Table)
}
# AddScript honors Options.IncludeScripts so output providers don't need to gate
# on $global:chkIncludeScripts anymore.
[void] AddScript([string]$Header, [string]$Caption, [string]$Content) {
if (-not $this.Options.IncludeScripts) { return }
if (-not $Content) { return }
if ($this.Options.ExcludeScriptSignature) {
$Content = [regex]::Replace(
$Content,
'(?ms)^\s*# SIG # Begin signature block.*?# SIG # End signature block\s*$',
''
).TrimEnd()
}
$this.Scripts.Add([PSCustomObject]@{
Header = $Header
Caption = $Caption
ScriptContent = $Content
})
}
# Produce the per-object result PSCustomObject that output providers consume.
# Output-provider contract: this is the exact set of fields outputs may read.
[PSCustomObject] ToResult() {
$updateNotConfigured = $true
$notConfiguredLoc = Get-LanguageString 'SettingDetails.notConfigured'
$notConfiguredText = ''
if($this.Options.NotConfiguredText -eq 'notConfigured') {
$notConfiguredText = $notConfiguredLoc
}
elseif($this.Options.NotConfiguredText -eq 'asis') {
$updateNotConfigured = $false
}
$settings = @($this.SettingsData | Where-Object {
if((-not ($_.PSObject.Properties | Where-Object Name -eq "RawValue")) -or
($_.AlwaysAddValue -eq $true))
{ return $true }
if ($this.Options.SkipDisabled -and $_.Enabled -is [bool] -and -not $_.Enabled) { return $false }
if ($this.Options.SkipDefaultValues -and
$null -ne $_.RawValue -and
(($null -ne $_.DefaultValue -and $_.RawValue -eq $_.DefaultValue) -or
($null -ne $_.UnconfiguredValue -and $_.RawValue -eq $_.UnconfiguredValue))) { return $false }
if ($this.Options.SkipNotConfigured -and
(($_.RawValue -isnot [array] -and (
$null -eq $_.RawValue -or
"$($_.RawValue)" -eq "" -or
"$($_.RawValue)" -eq "notConfigured")) -or
#($null -ne $_.UnconfiguredValue -and $_.RawValue -eq $_.UnconfiguredValue)) -or
($_.RawValue -is [array] -and $_.RawValue.Count -eq 0) -or
($this.UnconfiguredProperties | Where-Object EntityKey -eq $_.EntityKey))) { return $false }
if($updateNotConfigured -and (($_.RawValue -isnot [array] -and ($null -eq $_.RawValue -or "$($_.RawValue)" -eq "" -or "$($_.RawValue)" -eq "notConfigured") -and [String]::IsNullOrEmpty($_.Value)) -or ($_.RawValue -is [array] -and ($_.RawValue | Measure-Object).Count -eq 0)))
{
$_.Value = $notConfiguredText
}
if ($this.Options.SkipNotConfigured -and $_.Value -eq $notConfiguredLoc) {
Write-Log "Skipping property $($_.Name) based on '$($notConfiguredLoc)' string value" 2
return $false
}
return $true
})
if ($this.Options.NotConfiguredText -eq 'empty') {
foreach ($setting in $settings) {
if ($setting.Value -eq 'notConfigured') { $setting.Value = '' }
}
}
# Basic-info rows carry only a localized display Value (no RawValue), so
# apply SkipNotConfigured the same way settings do at their final check
# above: drop any row whose value is a "not configured" string. Handlers
# emit that value from more than one language key (SettingDetails.notConfigured
# for the settings path, Inputs.notConfigured for basic toggles like
# connectedAppsEnabled / credentialProviderRoleState), so match against both
# so this stays correct if a locale ever diverges the two.
$basicRows = @($this.BasicInfo)
if ($this.Options.SkipNotConfigured) {
$notConfiguredValues = @(
$notConfiguredLoc
Get-LanguageString 'Inputs.notConfigured'
) | Where-Object { $_ } | Select-Object -Unique
$basicRows = @($basicRows | Where-Object {
if ($_.Value -in $notConfiguredValues) {
Write-Log "Skipping basic property $($_.Name) based on '$($_.Value)' string value" 2
return $false
}
return $true
})
}
# One table instead of two: the basic rows lead, then the settings, in the
# order a reader meets them on the portal blade. Done here rather than in
# each output provider so every format gets the same shape, and done AFTER
# the engine's post-steps so the scope-tag and assignment rows they append
# to BasicInfo come along too.
if ($this.MergeBasicInfo) {
$settings = @($basicRows) + @($settings)
$basicRows = @()
}
return [PSCustomObject]@{
BasicInfo = $basicRows
FilteredSettings = $settings
DocumentName = $this.DocumentName
ComplianceActions = @($this.ComplianceActions)
ApplicabilityRules = @($this.ApplicabilityRules)
Assignments = @($this.Assignments)
Scripts = @($this.Scripts)
CustomTables = @($this.CustomTables)
DisplayProperties = $this.DisplayProperties
DefaultDocumentationProperties = $this.DefaultDocumentationProperties
ErrorText = $this.ErrorText
InputType = $this.InputType
UpdateFilteredObject = $this.UpdateFilteredObject
UnconfiguredProperties = $this.UnconfiguredProperties
}
}
}