mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
IntuneManagement 4.0.0-beta1
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
# Permission-based access marking for the left-hand navigation.
|
||||
#
|
||||
# Every registered policy type declares the Graph permissions it needs in
|
||||
# $this._Permissions. The signed-in token carries what was actually granted:
|
||||
# 'scp' (space-separated string) on delegated logins, 'roles' (array) on
|
||||
# app-only logins. Diffing the two yields a per-type access level that the nav
|
||||
# renders as a colour, via the APIAccess enum in Classes/IntuneBaseClasses.ps1:
|
||||
#
|
||||
# Full - every declared permission is granted (no colour)
|
||||
# Limited - the type declares a ReadWrite permission but only (orange)
|
||||
# the matching Read variant is granted: readable,
|
||||
# not writable
|
||||
# None - at least one declared permission is granted in (red)
|
||||
# neither form, so the type cannot be used at all
|
||||
#
|
||||
# The two aggregations are deliberately different in kind:
|
||||
#
|
||||
# * Within a TYPE the declared permissions are conjunctive - a type needs all
|
||||
# of them - so the worst permission decides the type.
|
||||
# * Across a GROUP the member types are independent, so None is reserved for
|
||||
# "nothing in this group is usable". Anything short of uniformly-Full but
|
||||
# not wholly-None is Limited. Without that asymmetry a group with one
|
||||
# inaccessible child out of five would render red and imply total denial.
|
||||
#
|
||||
# A type that declares only a Read permission (several do) is Full when that
|
||||
# Read permission is granted - it is a read-only feature by design, not a
|
||||
# degraded one.
|
||||
#
|
||||
# When there is no token, or the token carries no scp/roles claims at all,
|
||||
# everything is left at the Full default so the nav shows no colour. A sea of
|
||||
# red on a token we simply could not inspect is worse than no signal.
|
||||
|
||||
# Turn a ReadWrite permission into its Read counterpart, or $null when the
|
||||
# permission is not ReadWrite-shaped. Covers both layouts in use:
|
||||
# DeviceManagementConfiguration.ReadWrite.All -> DeviceManagementConfiguration.Read.All
|
||||
# Policy.ReadWrite.ConditionalAccess -> Policy.Read.ConditionalAccess
|
||||
function Get-PermissionReadVariant {
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([string]$Permission)
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($Permission)) { return $null }
|
||||
if($Permission -notmatch '\.ReadWrite\.') { return $null }
|
||||
return ($Permission -replace '\.ReadWrite\.', '.Read.')
|
||||
}
|
||||
|
||||
# Turn a Read permission into its ReadWrite counterpart, or $null when the
|
||||
# permission is not Read-shaped. The mirror of Get-PermissionReadVariant, used to
|
||||
# satisfy a required Read scope from the granted ReadWrite superset:
|
||||
# DeviceManagementConfiguration.Read.All -> DeviceManagementConfiguration.ReadWrite.All
|
||||
# Policy.Read.ConditionalAccess -> Policy.ReadWrite.ConditionalAccess
|
||||
# A type that declares only a bare Read permission (the read-only-by-design types
|
||||
# in Classes/IntuneInfoClasses.ps1 etc.) is fully usable when the token carries
|
||||
# the ReadWrite scope for the same resource - ReadWrite is a strict superset of
|
||||
# Read in Graph - even though the separate Read scope was never consented. Without
|
||||
# this the type was falsely marked None while the read call itself succeeded.
|
||||
function Get-PermissionWriteVariant {
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([string]$Permission)
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($Permission)) { return $null }
|
||||
if($Permission -notmatch '\.Read\.') { return $null }
|
||||
return ($Permission -replace '\.Read\.', '.ReadWrite.')
|
||||
}
|
||||
|
||||
# Normalise any permission collection into a case-insensitive HashSet.
|
||||
#
|
||||
# This exists because PowerShell enumerates collections on output, so a bare
|
||||
# `return $hashSet` hands the caller a plain string or object[] instead. On
|
||||
# those, .Contains() resolves to String.Contains / IList.Contains - a
|
||||
# case-SENSITIVE, and for a string even SUBSTRING, match. That silently
|
||||
# produces wrong access levels, so every entry point normalises first and
|
||||
# returns with the unary comma to stop the unrolling.
|
||||
function ConvertTo-PermissionSet {
|
||||
[CmdletBinding()]
|
||||
param($Permissions)
|
||||
|
||||
if($Permissions -is [System.Collections.Generic.HashSet[string]]) { return ,$Permissions }
|
||||
|
||||
$set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach($p in @($Permissions)) {
|
||||
if($p) { [void]$set.Add(([string]$p).Trim()) }
|
||||
}
|
||||
return ,$set
|
||||
}
|
||||
|
||||
# Decoded claims of the access token currently in play, or $null.
|
||||
#
|
||||
# Deliberately provider-agnostic. Reading $script:MSALDefaultToken directly
|
||||
# would only ever work for the MSAL provider - an OAuth or MgGraph session has
|
||||
# no such global, so the permission marking silently did nothing there. The
|
||||
# active provider is asked for its token through the AuthenticationCore facade
|
||||
# instead, exactly as Invoke-MSGraphAPI does.
|
||||
function Get-AccessTokenClaims {
|
||||
[CmdletBinding()]
|
||||
param($TokenInfo, [int]$TokenId = 0)
|
||||
|
||||
# 1. An explicit TokenInfo that already carries a decoded JWT (MSAL paths,
|
||||
# and how the unit tests inject a payload).
|
||||
if($TokenInfo) {
|
||||
$payload = $null
|
||||
try { $payload = $TokenInfo.JWTAccessToken.Payload } catch { }
|
||||
if($payload) { return $payload }
|
||||
}
|
||||
|
||||
# 2. The token's OWN provider (the explicit -TokenId, else the default token),
|
||||
# via its access token. Get-GraphDomain keeps the resource correct in
|
||||
# sovereign clouds.
|
||||
#
|
||||
# Routing by owner and not by "whichever provider is active" matters as soon
|
||||
# as a second login is live: these claims are combined with the user's Intune
|
||||
# RBAC, which is read through Invoke-MSGraphAPI and therefore routed to the
|
||||
# token's owner. Asking the active provider for someone else's token id would
|
||||
# pair one account's scopes with another account's role. Id 0 / an
|
||||
# unregistered id still falls back to the active provider (Get-AuthProvider
|
||||
# treats an empty ProviderId as "the active one"), as before.
|
||||
$tokenId = if($TokenId -gt 0) { $TokenId } else { Get-DefaultAuthTokenId }
|
||||
$owner = $null
|
||||
try { $owner = Resolve-AuthTokenProvider $tokenId } catch { }
|
||||
|
||||
try {
|
||||
$domain = $null
|
||||
try { $domain = Get-GraphDomain $tokenId } catch { }
|
||||
if(-not $domain) { $domain = "graph.microsoft.com" }
|
||||
|
||||
$providerId = if($owner) { $owner.Id } else { $null }
|
||||
|
||||
$accessToken = Get-AuthProviderAccessToken -TokenId $tokenId -Resource "https://$domain" -ProviderId $providerId
|
||||
if($accessToken) {
|
||||
$jwt = Get-JWTtoken $accessToken
|
||||
if($jwt -and $jwt.Payload) { return $jwt.Payload }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Get-AccessTokenClaims: could not read the token's provider: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# 3. Legacy MSAL global, for callers that run before the registry is set up -
|
||||
# and ONLY for those. Once a provider owns the token, its failure to produce a
|
||||
# bearer means "cannot tell", not "use the MSAL global": that global belongs to
|
||||
# a different sign-in, so returning its claims would pair one account's scopes
|
||||
# with another account's Intune role, which is the mixing step 2 exists to
|
||||
# prevent. No owner (id 0, or an id the registry does not know) is the only
|
||||
# case where there is no identity to contradict.
|
||||
if($owner) {
|
||||
Write-LogDebug "Get-AccessTokenClaims: no token from provider $($owner.Id) for token id $tokenId - not falling back to the MSAL global"
|
||||
return $null
|
||||
}
|
||||
|
||||
$payload = $null
|
||||
try { $payload = $script:MSALDefaultToken.JWTAccessToken.Payload } catch { }
|
||||
return $payload
|
||||
}
|
||||
|
||||
# Collect the permissions granted by a token into a case-insensitive set.
|
||||
# Returns $null (not an empty set) when the token carries no permission claims,
|
||||
# so callers can tell "nothing granted" from "cannot tell".
|
||||
function Get-GrantedGraphPermissions {
|
||||
[CmdletBinding()]
|
||||
param($TokenInfo)
|
||||
|
||||
$payload = Get-AccessTokenClaims $TokenInfo
|
||||
if(-not $payload) { return $null }
|
||||
|
||||
$granted = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
if($payload.scp) {
|
||||
foreach($s in ([string]$payload.scp).Split(' ')) {
|
||||
if($s) { [void]$granted.Add($s.Trim()) }
|
||||
}
|
||||
}
|
||||
if($payload.roles) {
|
||||
foreach($r in @($payload.roles)) {
|
||||
if($r) { [void]$granted.Add(([string]$r).Trim()) }
|
||||
}
|
||||
}
|
||||
|
||||
if($granted.Count -eq 0) { return $null }
|
||||
# Unary comma: without it PowerShell unrolls the set. See ConvertTo-PermissionSet.
|
||||
return ,$granted
|
||||
}
|
||||
|
||||
# Access level for a single policy type. $Granted is the set from
|
||||
# Get-GrantedGraphPermissions. A type declaring no permissions is Full - we
|
||||
# have nothing to judge it on and must not invent a warning.
|
||||
function Get-PolicyTypeAccessLevel {
|
||||
[CmdletBinding()]
|
||||
[OutputType([APIAccess])]
|
||||
param($PolicyType, $Granted)
|
||||
|
||||
if(-not $PolicyType -or -not $Granted) { return [APIAccess]::Full }
|
||||
|
||||
$required = @($PolicyType._Permissions | Where-Object { $_ })
|
||||
if($required.Count -eq 0) { return [APIAccess]::Full }
|
||||
|
||||
$grantedSet = ConvertTo-PermissionSet $Granted
|
||||
|
||||
$worst = [APIAccess]::Full
|
||||
foreach($perm in $required) {
|
||||
if($grantedSet.Contains($perm)) { continue }
|
||||
|
||||
# A required Read scope is fully satisfied by holding the ReadWrite
|
||||
# superset (ReadWrite implies Read in Graph), so the type is usable even
|
||||
# if the bare Read scope was never consented.
|
||||
$writeVariant = Get-PermissionWriteVariant $perm
|
||||
if($writeVariant -and $grantedSet.Contains($writeVariant)) { continue }
|
||||
|
||||
$readVariant = Get-PermissionReadVariant $perm
|
||||
if($readVariant -and $grantedSet.Contains($readVariant)) {
|
||||
# Readable but not writable. Keep looking - a later permission may
|
||||
# be missing outright, which outranks this.
|
||||
if($worst -eq [APIAccess]::Full) { $worst = [APIAccess]::Limited }
|
||||
continue
|
||||
}
|
||||
|
||||
# Granted in neither form: the type cannot function.
|
||||
return [APIAccess]::None
|
||||
}
|
||||
return $worst
|
||||
}
|
||||
|
||||
# Human-readable detail for a type's tooltip. Empty when the type is Full.
|
||||
function Get-PolicyTypeAccessInfo {
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param($PolicyType, $Granted, [APIAccess]$Level)
|
||||
|
||||
if($Level -eq [APIAccess]::Full) { return "" }
|
||||
if(-not $PolicyType -or -not $Granted) { return "" }
|
||||
|
||||
$grantedSet = ConvertTo-PermissionSet $Granted
|
||||
|
||||
$readOnly = @()
|
||||
$missing = @()
|
||||
foreach($perm in @($PolicyType._Permissions | Where-Object { $_ })) {
|
||||
if($grantedSet.Contains($perm)) { continue }
|
||||
# Read satisfied by the ReadWrite superset: not missing at all.
|
||||
$writeVariant = Get-PermissionWriteVariant $perm
|
||||
if($writeVariant -and $grantedSet.Contains($writeVariant)) { continue }
|
||||
$readVariant = Get-PermissionReadVariant $perm
|
||||
if($readVariant -and $grantedSet.Contains($readVariant)) { $readOnly += $perm }
|
||||
else { $missing += $perm }
|
||||
}
|
||||
|
||||
$parts = @()
|
||||
if($readOnly.Count -gt 0) { $parts += "Read-only: missing $($readOnly -join ', ')" }
|
||||
if($missing.Count -gt 0) { $parts += "No access: missing $($missing -join ', ')" }
|
||||
return ($parts -join "`n")
|
||||
}
|
||||
|
||||
# The access a type NEEDS to be fully usable: "ReadWrite" when it declares a
|
||||
# write scope, "Read" when it is read-only by design, "" when it declares no
|
||||
# permission at all (nothing to judge). Drives the Permissions popup's Required
|
||||
# column so "Full" is never shown where it would read as "I have write".
|
||||
function Get-PolicyTypeRequiredAccess {
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param($PolicyType)
|
||||
|
||||
$perms = @($PolicyType._Permissions | Where-Object { $_ })
|
||||
if($perms.Count -eq 0) { return "" }
|
||||
foreach($perm in $perms) { if(Get-PermissionReadVariant $perm) { return "ReadWrite" } }
|
||||
return "Read"
|
||||
}
|
||||
|
||||
# Map an APIAccess level to the concrete capability it represents for a type:
|
||||
# None -> "None"; Limited -> "Read" (readable, not writable - only writable types
|
||||
# ever reach Limited); Full -> the type's Required level (ReadWrite for a writable
|
||||
# type, Read for a read-only one). This is what the popup shows instead of the
|
||||
# bare enum, so a read-only feature reads as "Read", not "Full".
|
||||
#
|
||||
# Limited has two shapes, and the enum cannot tell them apart: a role that allows
|
||||
# no write action at all, and one that allows some (Create and Update but not
|
||||
# Delete or Assign is a common custom role). -PartialWrite names the second, so
|
||||
# the label says "Partial write" rather than "Read" - which would contradict the
|
||||
# tooltip that already says the user can write.
|
||||
function Get-AccessCapabilityLabel {
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([APIAccess]$Level, [string]$RequiredAccess, [switch]$PartialWrite)
|
||||
|
||||
switch($Level) {
|
||||
([APIAccess]::None) { return "None" }
|
||||
([APIAccess]::Limited) { if($PartialWrite) { return "Partial write" } else { return "Read" } }
|
||||
default { if($RequiredAccess) { return $RequiredAccess } else { return "Read" } }
|
||||
}
|
||||
}
|
||||
|
||||
# The bottom-line verdict for the Effective column: Full -> "Match" (you have what
|
||||
# the type needs), Limited -> "Read-only" (you can read a type that needs write)
|
||||
# or "Partial write" when -PartialWrite says some writes are allowed,
|
||||
# None -> "No access".
|
||||
function Get-AccessResultLabel {
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([APIAccess]$EffectiveLevel, [switch]$PartialWrite)
|
||||
|
||||
switch($EffectiveLevel) {
|
||||
([APIAccess]::None) { return "No access" }
|
||||
([APIAccess]::Limited) { if($PartialWrite) { return "Partial write" } else { return "Read-only" } }
|
||||
default { return "Match" }
|
||||
}
|
||||
}
|
||||
|
||||
# Aggregate member-type levels into a group level. See the header for why None
|
||||
# requires ALL children to be None.
|
||||
function Get-PolicyGroupAccessLevel {
|
||||
[CmdletBinding()]
|
||||
[OutputType([APIAccess])]
|
||||
param([APIAccess[]]$ChildLevels)
|
||||
|
||||
$levels = @($ChildLevels)
|
||||
if($levels.Count -eq 0) { return [APIAccess]::Full }
|
||||
|
||||
$noneCount = @($levels | Where-Object { $_ -eq [APIAccess]::None }).Count
|
||||
if($noneCount -eq $levels.Count) { return [APIAccess]::None }
|
||||
|
||||
$fullCount = @($levels | Where-Object { $_ -eq [APIAccess]::Full }).Count
|
||||
if($fullCount -eq $levels.Count) { return [APIAccess]::Full }
|
||||
|
||||
return [APIAccess]::Limited
|
||||
}
|
||||
|
||||
# Tooltip breakdown for a group, e.g. "2 of 5 read-only, 1 of 5 no access".
|
||||
function Get-PolicyGroupAccessInfo {
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([APIAccess[]]$ChildLevels)
|
||||
|
||||
$levels = @($ChildLevels)
|
||||
if($levels.Count -eq 0) { return "" }
|
||||
|
||||
$limited = @($levels | Where-Object { $_ -eq [APIAccess]::Limited }).Count
|
||||
$none = @($levels | Where-Object { $_ -eq [APIAccess]::None }).Count
|
||||
if(($limited + $none) -eq 0) { return "" }
|
||||
|
||||
$parts = @()
|
||||
if($limited -gt 0) { $parts += "$limited of $($levels.Count) read-only" }
|
||||
if($none -gt 0) { $parts += "$none of $($levels.Count) no access" }
|
||||
return ($parts -join ', ')
|
||||
}
|
||||
|
||||
# Stamp AccessType / AccessInfo onto every registered policy type and group.
|
||||
# Called from each backend's Get-IntuneViewItems, so it re-runs on every menu
|
||||
# rebuild (login, view-mode switch, settings change) without needing its own
|
||||
# event subscription. Safe to call with no token: everything resets to Full.
|
||||
function Update-IntuneAccessLevels {
|
||||
[CmdletBinding()]
|
||||
param($TokenInfo)
|
||||
|
||||
# Resolve the claims separately from the permission set so we can tell
|
||||
# "signed out" (say nothing) from "signed in but the token carries no
|
||||
# scp/roles" (worth a warning - it means marking cannot work at all).
|
||||
$claims = Get-AccessTokenClaims $TokenInfo
|
||||
|
||||
# An expired default token is effectively signed out. A JWT still DECODES
|
||||
# after expiry, so Get-AccessTokenClaims can hand back stale claims and leave
|
||||
# the nav marked (coloured) for a session that can no longer call Graph. When
|
||||
# we are marking from the default token (no explicit TokenInfo), treat a
|
||||
# confirmed-expired token as no token so the marking resets to Full - the same
|
||||
# end state as a sign-out - until a silent refresh or new login re-marks it.
|
||||
# Test-DefaultTokenExpired is provider-agnostic and returns $false for still
|
||||
# valid tokens and SDK-managed (MgGraph) sessions, so this never trips
|
||||
# mid-session on a transient failure where the token is still good.
|
||||
if(-not $TokenInfo -and $claims -and (Test-DefaultTokenExpired)) {
|
||||
Write-LogDebug "Update-IntuneAccessLevels: default token has expired; resetting access marking to Full"
|
||||
$claims = $null
|
||||
}
|
||||
|
||||
$granted = if($claims) { Get-GrantedGraphPermissions ([PSCustomObject]@{
|
||||
JWTAccessToken = [PSCustomObject]@{ Payload = $claims } }) } else { $null }
|
||||
|
||||
# Layer 2: the signed-in user's Intune RBAC (Internal/EffectivePermissions.ps1).
|
||||
# $null when it does not apply (setting off, app-only token, lookup failed).
|
||||
# It can only make a type worse, never better.
|
||||
$rbac = $null
|
||||
if($claims) { try { $rbac = Get-IntuneRbacContext -Claims $claims } catch { Write-LogDebug "Get-IntuneRbacContext failed: $($_.Exception.Message)" } }
|
||||
$rbacLimited = 0; $rbacNone = 0
|
||||
|
||||
# Layer 2 for Entra ID objects (Conditional Access etc.): driven by the
|
||||
# directory roles in the token, no Graph call (Internal/EntraRoleAccessLevel.ps1).
|
||||
# Same setting gate as the Intune RBAC layer; also only downgrades.
|
||||
$entraClaims = if($claims -and (Test-RbacAccessMarkingEnabled)) { $claims } else { $null }
|
||||
|
||||
$types = @($script:IntuneTypes | Where-Object { $_ })
|
||||
foreach($type in $types) {
|
||||
$level = Get-PolicyTypeAccessLevel $type $granted
|
||||
$info = Get-PolicyTypeAccessInfo $type $granted $level
|
||||
# A type is either Intune-governed (RBAC verdict) or Entra-governed
|
||||
# (directory-role verdict) - never both, so try RBAC first and fall back.
|
||||
$verdict = $null
|
||||
if($rbac) { $verdict = Get-PolicyTypeRbacAccess $type $rbac }
|
||||
if(-not $verdict -and $entraClaims) { $verdict = Get-PolicyTypeEntraRoleAccess $type $entraClaims }
|
||||
# Catch-all: a Global Reader reads the whole tenant but writes nothing, so
|
||||
# any writable type the two layers above did not resolve is read-only.
|
||||
if(-not $verdict -and $entraClaims) { $verdict = Get-PolicyTypeDirectoryRoleReadFloor $type $entraClaims }
|
||||
if($verdict -and $verdict.Level -ne [APIAccess]::Full) {
|
||||
$merged = Get-WorstAccessLevel $level $verdict.Level
|
||||
if($merged -ne $level) { if($merged -eq [APIAccess]::None) { $rbacNone++ } else { $rbacLimited++ } }
|
||||
$level = $merged
|
||||
$info = (@($info, $verdict.Info) | Where-Object { $_ }) -join "`n"
|
||||
}
|
||||
$type.AccessType = $level
|
||||
$type.AccessInfo = $info
|
||||
}
|
||||
|
||||
foreach($group in @($script:IntuneGroups | Where-Object { $_ })) {
|
||||
# Prefer the group's own member list; fall back to scanning the type
|
||||
# registry for types wired to this group.
|
||||
$members = @($group._PolicyTypes | Where-Object { $_ })
|
||||
if($members.Count -eq 0) {
|
||||
$members = @($types | Where-Object { $_.PolicyGroup -and $_.PolicyGroup.Id -eq $group.Id })
|
||||
}
|
||||
|
||||
$childLevels = @($members | ForEach-Object { $_.AccessType })
|
||||
$group.AccessType = Get-PolicyGroupAccessLevel $childLevels
|
||||
$group.AccessInfo = Get-PolicyGroupAccessInfo $childLevels
|
||||
}
|
||||
|
||||
# Report at warning level, not debug. The first cut logged this through
|
||||
# Write-LogDebug, which the Debug setting suppresses by default - so an
|
||||
# unmarked nav produced no explanation anywhere and looked like the feature
|
||||
# had simply not shipped.
|
||||
if(-not $claims) {
|
||||
Write-LogDebug "Update-IntuneAccessLevels: no access token available yet; access marking left at Full"
|
||||
return
|
||||
}
|
||||
if(-not $granted) {
|
||||
Write-Log ("Access marking unavailable: the access token carries no 'scp' or 'roles' claim, so " +
|
||||
"per-type permissions cannot be determined. The navigation is left unmarked.") 2
|
||||
return
|
||||
}
|
||||
|
||||
$limited = @($types | Where-Object { $_.AccessType -eq [APIAccess]::Limited }).Count
|
||||
$none = @($types | Where-Object { $_.AccessType -eq [APIAccess]::None }).Count
|
||||
if(($limited + $none) -eq 0) {
|
||||
Write-Log "Access marking: all $($types.Count) policy types are fully accessible with the current token"
|
||||
}
|
||||
else {
|
||||
Write-Log ("Access marking: of $($types.Count) policy types, $limited are read-only " +
|
||||
"(orange) and $none have no access (red) with the current token.") 2
|
||||
}
|
||||
if(($rbacLimited + $rbacNone) -gt 0) {
|
||||
Write-Log ("Access marking: the signed-in user's role (Intune RBAC or Entra directory role) lowered " +
|
||||
"$rbacLimited type(s) to read-only and $rbacNone to no access beyond what the token scopes " +
|
||||
"allow. Refresh the token from the Profile popup after a role change.") 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
# Update check - shared, UI-free.
|
||||
#
|
||||
# Both backends need the same two answers ("is a newer version published?" and
|
||||
# "what do the published release notes say?"), and neither answer involves a UI
|
||||
# toolkit. Keeping the network + version comparison here means the backends stay
|
||||
# thin callers (R10) and no WPF type leaks into shared code (R12); the WPF and
|
||||
# Avalonia sides only differ in how they show the result.
|
||||
#
|
||||
# Both versions live in ONE public repo: 3.x on the default branch, 4.x on the
|
||||
# v4 branch until the two are renamed at release. So every lookup asks for the
|
||||
# v4 ref first and falls back to no ref, which means the default branch. That
|
||||
# ordering is self-healing: on the day v4 is renamed to the default branch the
|
||||
# v4 ref stops existing, the first call 404s, and the fallback reads exactly the
|
||||
# content that used to be on v4. Nothing here changes at the swap.
|
||||
|
||||
$script:AppUpdateRepo = 'Micke-K/IntuneManagement'
|
||||
|
||||
# Tried in order. $null means "no ref", i.e. whatever the default branch is.
|
||||
$script:AppUpdateRefs = @('v4', $null)
|
||||
|
||||
# Invoke-RestMethod splat honouring the configured proxy, if any.
|
||||
function Get-AppUpdateRestParams {
|
||||
$params = @{}
|
||||
$proxyURI = Get-ProxyURI
|
||||
if ($proxyURI) {
|
||||
$params.Add('proxy', $proxyURI)
|
||||
$params.Add('UseBasicParsing', $true)
|
||||
}
|
||||
return $params
|
||||
}
|
||||
|
||||
# A version that can carry a pre-release label, which [version] cannot:
|
||||
# [version]'4.0.0-beta1' throws, and so does the 'v4.0.0-beta1' tag form.
|
||||
#
|
||||
# ModuleVersion alone cannot tell beta1 from beta2 - both are 4.0.0, because the
|
||||
# label lives in PrivateData.PSData.Prerelease - so the label is carried here and
|
||||
# compared, or a beta tester could never be told a newer beta exists.
|
||||
function ConvertTo-AppVersion {
|
||||
param([string]$Text)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
|
||||
|
||||
$value = $Text.Trim()
|
||||
if ($value.StartsWith('v', [StringComparison]::OrdinalIgnoreCase)) { $value = $value.Substring(1) }
|
||||
|
||||
$prerelease = $null
|
||||
$dash = $value.IndexOf('-')
|
||||
if ($dash -ge 0) {
|
||||
$prerelease = $value.Substring($dash + 1)
|
||||
$value = $value.Substring(0, $dash)
|
||||
}
|
||||
|
||||
$release = $null
|
||||
try { $release = [version]$value } catch { return $null }
|
||||
|
||||
$result = [PSCustomObject]@{
|
||||
Release = $release
|
||||
Prerelease = $prerelease
|
||||
}
|
||||
# So the UI can keep calling .ToString() and get something a human reads.
|
||||
$result | Add-Member -MemberType ScriptMethod -Name ToString -Force -Value {
|
||||
if ($this.Prerelease) { "$($this.Release)-$($this.Prerelease)" } else { "$($this.Release)" }
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
# -1 / 0 / 1, SemVer-style: a pre-release is OLDER than the same release without
|
||||
# one (4.0.0-beta1 is less than 4.0.0), and two labels compare numerically where
|
||||
# they can, so beta10 never sorts below beta2.
|
||||
function Compare-AppVersion {
|
||||
param($Left, $Right)
|
||||
|
||||
if (-not $Left -and -not $Right) { return 0 }
|
||||
if (-not $Left) { return -1 }
|
||||
if (-not $Right) { return 1 }
|
||||
|
||||
$byRelease = $Left.Release.CompareTo($Right.Release)
|
||||
if ($byRelease -ne 0) { return [Math]::Sign($byRelease) }
|
||||
|
||||
if (-not $Left.Prerelease -and -not $Right.Prerelease) { return 0 }
|
||||
if (-not $Left.Prerelease) { return 1 }
|
||||
if (-not $Right.Prerelease) { return -1 }
|
||||
|
||||
$leftParts = $Left.Prerelease.Split('.')
|
||||
$rightParts = $Right.Prerelease.Split('.')
|
||||
$count = [Math]::Max($leftParts.Count, $rightParts.Count)
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
$a = if ($i -lt $leftParts.Count) { $leftParts[$i] } else { '' }
|
||||
$b = if ($i -lt $rightParts.Count) { $rightParts[$i] } else { '' }
|
||||
if ($a -eq $b) { continue }
|
||||
if (-not $a) { return -1 }
|
||||
if (-not $b) { return 1 }
|
||||
|
||||
# beta2 vs beta10: split the trailing digits off and compare those as numbers.
|
||||
$matchA = [regex]::Match($a, '^(?<t>.*?)(?<n>[0-9]+)$')
|
||||
$matchB = [regex]::Match($b, '^(?<t>.*?)(?<n>[0-9]+)$')
|
||||
if ($matchA.Success -and $matchB.Success -and $matchA.Groups['t'].Value -eq $matchB.Groups['t'].Value) {
|
||||
return [Math]::Sign([int]$matchA.Groups['n'].Value - [int]$matchB.Groups['n'].Value)
|
||||
}
|
||||
return [Math]::Sign([string]::Compare($a, $b, [StringComparison]::OrdinalIgnoreCase))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# The module manifest published at one ref, as an AppVersion, or $null.
|
||||
#
|
||||
# Parsed by writing it to a temp file and reading it with Import-PowerShellDataFile
|
||||
# - the manifest is PowerShell data, not JSON, and this avoids hand-rolling a
|
||||
# parser or invoking the text as code.
|
||||
function Get-AppRemoteManifestVersion {
|
||||
param([string]$Ref)
|
||||
|
||||
$params = Get-AppUpdateRestParams
|
||||
$url = "https://api.github.com/repos/$($script:AppUpdateRepo)/contents/IntuneManagement.psd1"
|
||||
if ($Ref) { $url = $url + '?ref=' + $Ref }
|
||||
|
||||
$tempManifest = $null
|
||||
try {
|
||||
$content = Invoke-RestMethod $url @params
|
||||
if (-not $content.content) { return $null }
|
||||
|
||||
$text = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($content.content))
|
||||
$tempManifest = [IO.Path]::Combine([IO.Path]::GetTempPath(), "IM-update-$([Guid]::NewGuid().ToString('N')).psd1")
|
||||
[IO.File]::WriteAllText($tempManifest, $text, (New-Object System.Text.UTF8Encoding($false)))
|
||||
|
||||
$data = Import-PowerShellDataFile -Path $tempManifest -ErrorAction Stop
|
||||
if (-not $data.ModuleVersion) { return $null }
|
||||
|
||||
$full = [string]$data.ModuleVersion
|
||||
$label = $data.PrivateData.PSData.Prerelease
|
||||
if ($label) { $full = "$full-$label" }
|
||||
return (ConvertTo-AppVersion $full)
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Update check: manifest lookup at ref '$Ref' failed: $($_.Exception.Message)"
|
||||
return $null
|
||||
}
|
||||
finally {
|
||||
if ($tempManifest -and [IO.File]::Exists($tempManifest)) {
|
||||
try { [IO.File]::Delete($tempManifest) } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Newest published release sharing $Major, as an AppVersion, or $null.
|
||||
#
|
||||
# The releases LIST, not releases/latest: that endpoint hides pre-releases by
|
||||
# design, so a beta could never be told about a newer beta. Releases are not tied
|
||||
# to a branch either, so this path is unaffected by the rename. tag_name, not
|
||||
# name - one published release on this repo has an empty name field.
|
||||
function Get-AppRemoteReleaseVersion {
|
||||
param([int]$Major = 0)
|
||||
|
||||
try {
|
||||
$params = Get-AppUpdateRestParams
|
||||
$releases = Invoke-RestMethod "https://api.github.com/repos/$($script:AppUpdateRepo)/releases" @params
|
||||
$best = $null
|
||||
foreach ($release in @($releases)) {
|
||||
if ($release.draft) { continue }
|
||||
$candidate = ConvertTo-AppVersion $release.tag_name
|
||||
if (-not $candidate) { continue }
|
||||
if ($Major -gt 0 -and $candidate.Release.Major -ne $Major) { continue }
|
||||
if (-not $best -or (Compare-AppVersion $candidate $best) -gt 0) { $best = $candidate }
|
||||
}
|
||||
return $best
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Update check: releases lookup failed: $($_.Exception.Message)"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Version published on GitHub, or $null when it cannot be determined.
|
||||
function Get-AppRemoteVersion {
|
||||
param([int]$Major = 0)
|
||||
|
||||
foreach ($ref in $script:AppUpdateRefs) {
|
||||
$fromManifest = Get-AppRemoteManifestVersion -Ref $ref
|
||||
if ($fromManifest -and ($Major -le 0 -or $fromManifest.Release.Major -eq $Major)) { return $fromManifest }
|
||||
}
|
||||
|
||||
# No manifest at any ref matched. The 3.x line has no IntuneManagement.psd1
|
||||
# at all - its manifest is named differently - so ask the releases instead.
|
||||
return (Get-AppRemoteReleaseVersion -Major $Major)
|
||||
}
|
||||
|
||||
# Version of the manifest sitting next to this module, or $null. Carries the
|
||||
# pre-release label so one beta can be told from the next.
|
||||
function Get-AppLocalVersion {
|
||||
try {
|
||||
$manifest = Join-Path $script:AppRootFolder 'IntuneManagement.psd1'
|
||||
if (-not [IO.File]::Exists($manifest)) { return $null }
|
||||
$data = Import-PowerShellDataFile -Path $manifest -ErrorAction Stop
|
||||
if (-not $data.ModuleVersion) { return $null }
|
||||
|
||||
$full = [string]$data.ModuleVersion
|
||||
$label = $data.PrivateData.PSData.Prerelease
|
||||
if ($label) { $full = "$full-$label" }
|
||||
return (ConvertTo-AppVersion $full)
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Update check: local manifest read failed: $($_.Exception.Message)"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Both versions plus the verdict. IsOutdated is only ever $true when both
|
||||
# versions were resolved, so a failed network call reads as "nothing to report"
|
||||
# rather than "up to date" or a spurious upgrade prompt.
|
||||
#
|
||||
# Only a newer version of the SAME major is offered. Crossing a major is a
|
||||
# migration with breaking changes, announced deliberately - not something to push
|
||||
# at an installation through a startup dialog, and not something a branch rename
|
||||
# should trigger for every 3.x user at once. Drop the -Major argument below to go
|
||||
# back to "newest wins".
|
||||
function Get-AppUpdateInfo {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$local = Get-AppLocalVersion
|
||||
$major = if ($local) { $local.Release.Major } else { 0 }
|
||||
$remote = Get-AppRemoteVersion -Major $major
|
||||
|
||||
if (-not $local) { Write-Log 'Failed to get version info from local file' 2 }
|
||||
if (-not $remote) { Write-Log 'Failed to get version info in GitHub' 2 }
|
||||
|
||||
$outdated = ($local -and $remote -and (Compare-AppVersion $local $remote) -lt 0)
|
||||
|
||||
if ($outdated) {
|
||||
Write-Log 'Local version and GitHub version does not match' 2
|
||||
Write-Log "Local version: $($local.ToString())"
|
||||
Write-Log "GitHub version: $($remote.ToString())"
|
||||
}
|
||||
elseif ($local -and $remote) {
|
||||
Write-Log "Running latest version: $($local.ToString())"
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
LocalVersion = $local
|
||||
RemoteVersion = $remote
|
||||
IsOutdated = [bool]$outdated
|
||||
Resolved = [bool]($local -and $remote)
|
||||
}
|
||||
}
|
||||
|
||||
# Published release notes, or $null when unreachable.
|
||||
#
|
||||
# Same ref order as the version lookup, and for the same reason: a 4.x
|
||||
# installation must not be shown the 3.x notes, and after the rename the
|
||||
# fallback lands on what used to be the v4 branch with no change here.
|
||||
#
|
||||
# Returns Text plus Sha: GitHub's blob sha lets a caller tell "the published notes
|
||||
# differ from my local copy" without diffing the text, which is how the WPF
|
||||
# Updates dialog decides whether to flag an update.
|
||||
function Get-AppRemoteReleaseNotes {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$params = Get-AppUpdateRestParams
|
||||
foreach ($ref in $script:AppUpdateRefs) {
|
||||
$url = "https://api.github.com/repos/$($script:AppUpdateRepo)/contents/ReleaseNotes.md"
|
||||
if ($ref) { $url = $url + '?ref=' + $ref }
|
||||
try {
|
||||
$content = Invoke-RestMethod $url @params
|
||||
if ($content.content) {
|
||||
return [PSCustomObject]@{
|
||||
Text = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($content.content))
|
||||
Sha = $content.sha
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Update check: release notes lookup at ref '$ref' failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
# Browserless-login support.
|
||||
#
|
||||
# On Linux/macOS (and Windows with "Use system browser") MSAL drives interactive
|
||||
# sign-in through the system browser + a loopback redirect. On a machine with no
|
||||
# usable default browser the redirect never arrives and the interactive poll loop
|
||||
# waits out its whole timeout with the UI locked. This file provides the pieces
|
||||
# that let the login flow (a) detect that situation up-front, (b) fail fast when a
|
||||
# browser launch actually fails, and (c) fall back to device-code login instead.
|
||||
#
|
||||
# See docs/superpowers/specs/2026-09-05-browserless-login-device-code-fallback-design.md
|
||||
|
||||
# Marker embedded in every "could not open a browser" failure so the caller can
|
||||
# tell a browser-launch failure apart from a normal auth error and fall back to
|
||||
# device code.
|
||||
$script:IMNoBrowserMarker = "IM_NO_BROWSER"
|
||||
|
||||
# Thin, mockable wrapper around Get-Command so the browser probe can be unit
|
||||
# tested without shelling out to the real PATH.
|
||||
function Test-CommandExists {
|
||||
param([string]$Name)
|
||||
if(-not $Name) { return $false }
|
||||
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
# Wraps `xdg-settings get default-web-browser`. Returns the handler (e.g.
|
||||
# 'firefox.desktop') or $null. Separated out so tests can mock it.
|
||||
function Get-XdgDefaultWebBrowser {
|
||||
if(-not (Test-CommandExists "xdg-settings")) { return $null }
|
||||
try {
|
||||
$out = & xdg-settings get default-web-browser 2>$null
|
||||
if($out) {
|
||||
$line = ($out | Select-Object -First 1)
|
||||
if($line) { return ([string]$line).Trim() }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return $null
|
||||
}
|
||||
|
||||
# True when interactive browser sign-in has a realistic chance of working.
|
||||
# Conservative by design: only returns $false when we are confident there is no
|
||||
# browser, so a false negative can't send a browser-capable machine down the
|
||||
# device-code path. The fast-fail launcher (IMMsalBrowserLauncher) covers the
|
||||
# opposite case (a browser that is configured but broken).
|
||||
function Test-InteractiveBrowserAvailable {
|
||||
# Windows: WAM / embedded WebView / Edge is always available.
|
||||
if($script:IsWindowsOS) { return $true }
|
||||
# macOS: LaunchServices always resolves a default handler.
|
||||
if($IsMacOS) { return $true }
|
||||
|
||||
# Linux: probe. 1) $env:BROWSER pointing at a resolvable command.
|
||||
if($env:BROWSER) {
|
||||
$cmd = (($env:BROWSER -split ':')[0] -split '\s+')[0]
|
||||
if($cmd -and (Test-CommandExists $cmd)) { return $true }
|
||||
}
|
||||
|
||||
# 2) A registered xdg default web browser.
|
||||
if(Get-XdgDefaultWebBrowser) { return $true }
|
||||
|
||||
# 3) A known browser binary on PATH. NB: xdg-open is deliberately excluded -
|
||||
# it ships on headless boxes and is not itself a browser.
|
||||
foreach($b in @(
|
||||
"firefox","firefox-esr","google-chrome","google-chrome-stable","chrome",
|
||||
"chromium","chromium-browser","brave-browser","microsoft-edge",
|
||||
"microsoft-edge-stable","opera","vivaldi","vivaldi-stable","epiphany",
|
||||
"konqueror","falkon","midori")) {
|
||||
if(Test-CommandExists $b) { return $true }
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# True when a failure (exception or string) carries the no-browser marker, i.e.
|
||||
# the interactive browser launch could not open a browser at all.
|
||||
function Test-IMNoBrowserFailure {
|
||||
param($Failure)
|
||||
if($null -eq $Failure) { return $false }
|
||||
|
||||
$text = $null
|
||||
if($Failure -is [System.Exception]) {
|
||||
$text = $Failure.Message
|
||||
$inner = $Failure.InnerException
|
||||
$depth = 0
|
||||
while($inner -and $depth -lt 10) {
|
||||
$text = "$text $($inner.Message)"
|
||||
$inner = $inner.InnerException
|
||||
$depth++
|
||||
}
|
||||
}
|
||||
else {
|
||||
$text = [string]$Failure
|
||||
}
|
||||
|
||||
return ($text -match $script:IMNoBrowserMarker)
|
||||
}
|
||||
|
||||
# Compile the compiled-C# browser launcher used for MSAL's
|
||||
# SystemWebViewOptions.OpenBrowserFunc. It MUST be compiled C# (not a ScriptBlock
|
||||
# cast to a delegate): MSAL invokes OpenBrowserFunc on an internal thread with no
|
||||
# PowerShell Runspace, where a ScriptBlock delegate throws "no Runspace
|
||||
# available" - the same constraint documented for the device-code callback.
|
||||
#
|
||||
# The launcher owns the browser launch so it can DETECT failure: if the browser
|
||||
# command can't start, or exits non-zero quickly (xdg-open returns 3 = "no
|
||||
# handler", 4 = "action failed"), it throws with the IM_NO_BROWSER marker, which
|
||||
# faults the MSAL task in ~1s instead of hanging the whole interactive timeout.
|
||||
function Initialize-IMBrowserLauncher {
|
||||
if("IMMsalBrowserLauncher" -as [type]) { return }
|
||||
|
||||
$refAssemblies = @(
|
||||
[System.Object].Assembly.Location,
|
||||
[System.Diagnostics.Process].Assembly.Location,
|
||||
[System.Threading.Tasks.Task].Assembly.Location,
|
||||
[System.Uri].Assembly.Location,
|
||||
# Process derives from System.ComponentModel.Component (System.ComponentModel.Primitives
|
||||
# on .NET Core); without this ref the compile fails with CS0012 on Process.Start.
|
||||
[System.ComponentModel.Component].Assembly.Location,
|
||||
[System.ComponentModel.Win32Exception].Assembly.Location
|
||||
) | Where-Object { $_ } | Select-Object -Unique
|
||||
|
||||
# -CompilerOptions is PS7-only: Windows PowerShell 5.1 compiles through CodeDom
|
||||
# and takes -CompilerParameters instead, so passing it there fails the whole
|
||||
# call ("a parameter cannot be found"). The type then never compiles, the
|
||||
# "already a type?" guard above never becomes true, and every call retried and
|
||||
# logged the same error - on 5.1 the fast-fail browser detection was simply
|
||||
# absent. The option only silences warnings 1701/1702, so 5.1 goes without it.
|
||||
$addTypeParams = @{
|
||||
ReferencedAssemblies = $refAssemblies
|
||||
ErrorAction = 'Stop'
|
||||
}
|
||||
if($PSVersionTable.PSEdition -eq 'Core') {
|
||||
$addTypeParams['CompilerOptions'] = '/nowarn:1701;1702'
|
||||
}
|
||||
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
public static class IMMsalBrowserLauncher {
|
||||
// Default browser command when `$env:BROWSER` is unset. Set from PowerShell
|
||||
// (which knows the platform) in Initialize-IMBrowserLauncher.
|
||||
public static string DefaultCommand = "xdg-open";
|
||||
public const string Marker = "IM_NO_BROWSER";
|
||||
|
||||
public static Task Open(Uri uri) {
|
||||
string exe = ResolveBrowserCommand();
|
||||
if (string.IsNullOrEmpty(exe)) {
|
||||
throw new Exception(Marker + ": no web browser command could be resolved");
|
||||
}
|
||||
|
||||
Process p = null;
|
||||
try {
|
||||
ProcessStartInfo psi = new ProcessStartInfo();
|
||||
psi.FileName = exe;
|
||||
// AbsoluteUri, NOT Uri.ToString().
|
||||
//
|
||||
// Uri.ToString() unescapes the %20 separators MSAL puts in "scope".
|
||||
// ProcessStartInfo.Arguments then sees literal spaces and splits one URL
|
||||
// into several argv entries. AbsoluteUri keeps all whitespace escaped,
|
||||
// so the compatible Arguments property still passes one argument.
|
||||
//
|
||||
// Do not use ProcessStartInfo.ArgumentList here. It is preferable on
|
||||
// modern .NET, but is absent from .NET Framework and makes this Add-Type
|
||||
// fail during module import under Windows PowerShell 5.1.
|
||||
psi.Arguments = uri.AbsoluteUri;
|
||||
psi.UseShellExecute = false;
|
||||
p = Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new Exception(Marker + ": failed to launch '" + exe + "': " + ex.Message, ex);
|
||||
}
|
||||
|
||||
if (p == null) {
|
||||
throw new Exception(Marker + ": failed to launch '" + exe + "'");
|
||||
}
|
||||
|
||||
// A real browser forks and keeps running, so WaitForExit(3000) returns
|
||||
// false and we treat that as "launched OK". A quick non-zero exit means
|
||||
// the launch failed (no handler / action failed).
|
||||
if (p.WaitForExit(3000) && p.ExitCode != 0) {
|
||||
throw new Exception(Marker + ": '" + exe + "' exited with code " + p.ExitCode);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static string ResolveBrowserCommand() {
|
||||
string b = Environment.GetEnvironmentVariable("BROWSER");
|
||||
if (!string.IsNullOrEmpty(b)) {
|
||||
string first = b.Split(':')[0];
|
||||
string[] parts = first.Split(new char[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length > 0) { return parts[0]; }
|
||||
}
|
||||
return DefaultCommand;
|
||||
}
|
||||
}
|
||||
"@ @addTypeParams
|
||||
|
||||
if($IsMacOS) {
|
||||
try { [IMMsalBrowserLauncher]::DefaultCommand = "open" } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# Surface the pending device code (stashed by IMMsalDeviceCodeHelper) into the
|
||||
# GUI: copy the user code to the clipboard and show a status message telling the
|
||||
# user where to enter it. Called from the -DeviceCode poll loop in
|
||||
# Get-MsalAuthenticationToken. No-ops cleanly on a console/headless host (no
|
||||
# UIProvider), where MSAL's callback already printed the code to the console.
|
||||
function Show-DeviceCodeInstruction {
|
||||
if(-not ("IMMsalDeviceCodeHelper" -as [type])) { return }
|
||||
|
||||
$code = $null
|
||||
$url = $null
|
||||
try { $code = [IMMsalDeviceCodeHelper]::UserCode } catch { }
|
||||
try { $url = [IMMsalDeviceCodeHelper]::VerificationUrl } catch { }
|
||||
if(-not $url) { $url = "https://microsoft.com/devicelogin" }
|
||||
if(-not $code) { return }
|
||||
|
||||
$copied = $false
|
||||
if($script:UIProvider) {
|
||||
try {
|
||||
$script:UIProvider.SetClipboardText($code)
|
||||
$copied = $true
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Could not copy device code to clipboard: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$prefix = if($copied) { "Sign-in code copied to clipboard. " } else { "" }
|
||||
# -CancelText surfaces the overlay's Cancel button. The ACTION was armed by the
|
||||
# wait loop that owns the cancellation token (Get-MsalAuthenticationToken), so
|
||||
# this only has to ask for the button - device-code waits run up to 15 minutes
|
||||
# and this is the only way out of an abandoned one.
|
||||
Write-Status "Waiting for sign-in" "${prefix}Open $url and enter code $code" -CancelText "Cancel"
|
||||
}
|
||||
|
||||
# Compile at load so the type is available to the login flow (and tests) without
|
||||
# a lazy first-call cost. Guarded, so re-import (PS7 -Force) is a no-op.
|
||||
Initialize-IMBrowserLauncher
|
||||
@@ -0,0 +1,879 @@
|
||||
# Provider-agnostic authentication core.
|
||||
#
|
||||
# This file owns the provider registry, the central token registry, and the
|
||||
# provider-agnostic facade functions. Each provider self-registers at load: MSAL in
|
||||
# Invoke-MSALInitialize, OAuth in Invoke-OAuthProviderInitialize, MgGraph in
|
||||
# Invoke-MgGraphProviderInitialize. The user picks the active provider via the
|
||||
# ActiveAuthProvider setting.
|
||||
#
|
||||
# Consumers are provider-agnostic: Invoke-MSGraphAPI resolves each call's owning
|
||||
# provider by TokenId (Resolve-AuthTokenProvider) and gets the bearer from it (or lets
|
||||
# the provider run the request), so MSAL, OAuth and MgGraph tokens can be live at once
|
||||
# across different tenants.
|
||||
|
||||
# Provider registry. Keyed by provider Id ("MSAL", "MgGraph", "Az", ...).
|
||||
$script:AuthProviders = @{}
|
||||
$script:ActiveAuthProviderId = $null
|
||||
|
||||
# ---- Central token registry (provider-agnostic) ----
|
||||
# Single source of truth for live tokens across ALL providers. Solves three
|
||||
# problems that per-provider stores can't: (1) globally-unique token ids (MSAL
|
||||
# and OAuth otherwise both allocate from 1 and collide); (2) id -> owning
|
||||
# provider routing so a Graph call reaches the token's own provider, not just
|
||||
# whichever provider is "active"; (3) one place that fires the auth events with
|
||||
# one payload shape ([IMAuthToken]).
|
||||
#
|
||||
# Records hold only the stable routing essentials; the full IMAuthToken is
|
||||
# hydrated on read from the owning provider's GetUserInfo(TokenId) so ExpiresOn
|
||||
# / TenantName never go stale and token refresh needs no registry write.
|
||||
$script:AuthTokens = @{} # [int]TokenId -> @{ TokenId; ProviderId; Cloud; IsDefault }
|
||||
$script:AuthTokenNextId = 1 # central monotonic id allocator (feeds every provider)
|
||||
$script:DefaultAuthTokenId = 0 # 0 = no default
|
||||
|
||||
# Add the events first so providers can fire them during their Initialize() call
|
||||
# without races. Idempotent — Add-AppEvent is safe to call repeatedly.
|
||||
Add-AppEvent "AuthProviderRegistered"
|
||||
Add-AppEvent "ActiveAuthProviderChanged"
|
||||
|
||||
# ---- Common Entra app + cloud data (provider-neutral) ----
|
||||
# Shared by every auth provider: MSAL and OAuth resolve their public client from
|
||||
# Get-EntraApp; MgGraph and the UI cloud pickers read $script:Clouds; everyone
|
||||
# calls Get-DefaultCloud. NOTE: these arrays MUST stay above the Authentication
|
||||
# settings registrations below - the EntraApp / DefaultCloud List settings capture
|
||||
# them as ItemsSource at dot-source time.
|
||||
|
||||
$script:EntraApps = @(
|
||||
(New-Object PSObject -Property @{Name = ""; ClientId = ""; RedirectUri = ""; Authority = "" }),
|
||||
(New-Object PSObject -Property @{Name = "*** Do NOT use *** Microsoft Intune PowerShell"; ClientId = "d1ddf0e4-d672-4dae-b554-9d5bdfd93547"; RedirectUri = "urn:ietf:wg:oauth:2.0:oob"; }),
|
||||
(New-Object PSObject -Property @{Name = "Microsoft Graph PowerShell"; ClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; RedirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"; })
|
||||
)
|
||||
|
||||
$script:DefaultEntraAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"
|
||||
|
||||
$script:EntraEnvironments = @(
|
||||
[PSCustomObject]@{
|
||||
Name = "Entra Public"
|
||||
Value = "public"
|
||||
URL = "login.microsoftonline.com"
|
||||
GraphURL = "graph.microsoft.com"
|
||||
},
|
||||
[PSCustomObject]@{
|
||||
Name = "Entra US Government"
|
||||
Value = "usGov"
|
||||
URL = "login.microsoftonline.us"
|
||||
},
|
||||
[PSCustomObject]@{
|
||||
Name = "Entra China"
|
||||
Value = "china"
|
||||
URL = "login.partner.microsoftonline.cn"
|
||||
GraphURL = "microsoftgraph.chinacloudapi.cn"
|
||||
}
|
||||
)
|
||||
|
||||
# Phase 1 of the cloud redesign (2026-05-22): single Cloud enum replaces the awkward
|
||||
# GraphEnvironment+GCCType pair. Each entry carries everything a provider needs:
|
||||
# AADAuthority — login host used to build MSAL .WithAuthority("https://<host>/<tenant>")
|
||||
# GraphHost — Graph hostname for absolute @odata.bind / @odata.id URLs
|
||||
# ArmHost — Azure Resource Manager hostname the tenant list is READ from
|
||||
# ArmAudiences — the OAuth resource identifier(s) an ARM token is REQUESTED on, most
|
||||
# likely first. Not the same thing as ArmHost: they happen to be the
|
||||
# same string in the public cloud, but a sovereign tenant may only
|
||||
# know the older management.core.* identifier, so both are listed and
|
||||
# Internal/EntraTenantList.ps1 falls back in order.
|
||||
# MgEnvironment — Connect-MgGraph -Environment value (Global / USGov / USGovDOD / China)
|
||||
# LegacyEnv — old GraphEnvironment value (public / usGov / china) — used by the
|
||||
# LegacyGCC — old GCCType value (gccHigh / gccDoD / $null) — deprecation translator
|
||||
# "GCC" (commercial Graph from a Gov tenant) collapses into Public: same endpoints,
|
||||
# no separate cloud entry needed.
|
||||
$script:Clouds = @(
|
||||
[PSCustomObject]@{
|
||||
Value = "Public"
|
||||
Name = "Public (Global)"
|
||||
AADAuthority = "login.microsoftonline.com"
|
||||
GraphHost = "graph.microsoft.com"
|
||||
ArmHost = "management.azure.com"
|
||||
ArmAudiences = @("https://management.azure.com", "https://management.core.windows.net")
|
||||
MgEnvironment = "Global"
|
||||
LegacyEnv = "public"
|
||||
LegacyGCC = $null
|
||||
},
|
||||
[PSCustomObject]@{
|
||||
Value = "USGov"
|
||||
Name = "US Government (GCC High)"
|
||||
AADAuthority = "login.microsoftonline.us"
|
||||
GraphHost = "graph.microsoft.us"
|
||||
ArmHost = "management.usgovcloudapi.net"
|
||||
ArmAudiences = @("https://management.usgovcloudapi.net", "https://management.core.usgovcloudapi.net")
|
||||
MgEnvironment = "USGov"
|
||||
LegacyEnv = "usGov"
|
||||
LegacyGCC = "gccHigh"
|
||||
},
|
||||
[PSCustomObject]@{
|
||||
Value = "USGovDOD"
|
||||
Name = "US Government (DoD)"
|
||||
AADAuthority = "login.microsoftonline.us"
|
||||
GraphHost = "dod-graph.microsoft.us"
|
||||
ArmHost = "management.usgovcloudapi.net"
|
||||
ArmAudiences = @("https://management.usgovcloudapi.net", "https://management.core.usgovcloudapi.net")
|
||||
MgEnvironment = "USGovDOD"
|
||||
LegacyEnv = "usGov"
|
||||
LegacyGCC = "gccDoD"
|
||||
},
|
||||
[PSCustomObject]@{
|
||||
Value = "China"
|
||||
Name = "China (Vianet)"
|
||||
AADAuthority = "login.partner.microsoftonline.cn"
|
||||
GraphHost = "microsoftgraph.chinacloudapi.cn"
|
||||
ArmHost = "management.chinacloudapi.cn"
|
||||
ArmAudiences = @("https://management.chinacloudapi.cn", "https://management.core.chinacloudapi.cn")
|
||||
MgEnvironment = "China"
|
||||
LegacyEnv = "china"
|
||||
LegacyGCC = $null
|
||||
}
|
||||
)
|
||||
|
||||
function Get-CloudByValue
|
||||
{
|
||||
param([string]$Value)
|
||||
if(-not $Value) { $Value = "Public" }
|
||||
$entry = $script:Clouds | Where-Object Value -eq $Value | Select-Object -First 1
|
||||
if(-not $entry) {
|
||||
Write-Log "Unknown cloud '$Value' - falling back to Public" 2
|
||||
$entry = $script:Clouds | Where-Object Value -eq "Public" | Select-Object -First 1
|
||||
}
|
||||
return $entry
|
||||
}
|
||||
|
||||
function Convert-LegacyToCloud
|
||||
{
|
||||
# Translate the historical (GraphEnvironment, GCCType) pair to a new Cloud value.
|
||||
# Used during the deprecation window so callers passing -GraphEnvironment/-GCCType
|
||||
# still land on the correct sovereign cloud after we route through the flat enum.
|
||||
param(
|
||||
[string]$GraphEnvironment,
|
||||
[string]$GCCType
|
||||
)
|
||||
if(-not $GraphEnvironment) { return "Public" }
|
||||
foreach($c in $script:Clouds) {
|
||||
$cGcc = if($c.LegacyGCC) { $c.LegacyGCC } else { "" }
|
||||
$inGcc = if($GCCType) { $GCCType } else { "" }
|
||||
if($c.LegacyEnv -eq $GraphEnvironment -and $cGcc -eq $inGcc) {
|
||||
return $c.Value
|
||||
}
|
||||
}
|
||||
# usGov without gccHigh/gccDoD historically meant commercial Graph from a Gov
|
||||
# tenant (the legacy "gcc" value). Collapse to Public — same endpoints.
|
||||
if($GraphEnvironment -eq "usGov") { return "Public" }
|
||||
return "Public"
|
||||
}
|
||||
|
||||
function Get-DefaultCloud
|
||||
{
|
||||
# Persisted default cloud. Falls back to Public if the setting is missing
|
||||
# or empty (corrupted config).
|
||||
$val = Get-SettingValue "DefaultCloud" "Public"
|
||||
if(-not $val) { return "Public" }
|
||||
return $val
|
||||
}
|
||||
|
||||
function Set-DefaultCloud
|
||||
{
|
||||
# Single write path for the DefaultCloud setting. UI code must use this instead
|
||||
# of Save-SettingStoreValue with a hardcoded path, so reader (Get-DefaultCloud via the
|
||||
# setting's SubPath) and writers always agree on Authentication\DefaultCloud.
|
||||
param([Parameter(Mandatory)][string]$Value)
|
||||
Save-SettingStoreValue "Authentication" "DefaultCloud" $Value
|
||||
}
|
||||
|
||||
# --- Per-tenant cloud memory ---
|
||||
|
||||
function Resolve-CloudFromIss
|
||||
{
|
||||
# Best-effort Cloud detection from a JWT 'iss' (issuer) claim. We can't tell
|
||||
# USGov (GCC High) from USGovDOD purely from iss — both issue from
|
||||
# login.microsoftonline.us / sts.windows.us. When the caller already knows which
|
||||
# USGov variant was requested (via -FallbackCloud), that wins; otherwise we
|
||||
# default to USGov (the more common GCC variant). The same logic applies to any
|
||||
# other ambiguous family.
|
||||
param(
|
||||
[string]$Iss,
|
||||
[string]$FallbackCloud
|
||||
)
|
||||
if(-not $Iss) { return $FallbackCloud }
|
||||
$low = $Iss.ToLowerInvariant()
|
||||
if($low -match "microsoftonline\.us|sts\.windows\.us") {
|
||||
if($FallbackCloud -eq "USGovDOD") { return "USGovDOD" }
|
||||
return "USGov"
|
||||
}
|
||||
if($low -match "partner\.microsoftonline\.cn|chinacloudapi\.cn") {
|
||||
return "China"
|
||||
}
|
||||
if($low -match "microsoftonline\.com|sts\.windows\.net|login\.microsoft\.com") {
|
||||
return "Public"
|
||||
}
|
||||
return $FallbackCloud
|
||||
}
|
||||
|
||||
function Save-TenantCloud
|
||||
{
|
||||
# Persist the cloud a given tenant was authenticated against. Used at silent
|
||||
# refresh + next-launch resume so we hit the right authority without re-asking.
|
||||
param([string]$TenantId, [string]$Cloud)
|
||||
if(-not $TenantId -or -not $Cloud) { return }
|
||||
Save-SettingStoreValue "" "TenantCloud_$TenantId" $Cloud
|
||||
}
|
||||
|
||||
function Get-TenantCloud
|
||||
{
|
||||
param([string]$TenantId)
|
||||
if(-not $TenantId) { return $null }
|
||||
$val = Get-SettingStoreValue "" "TenantCloud_$TenantId" ""
|
||||
if(-not $val) { return $null }
|
||||
return [string]$val
|
||||
}
|
||||
|
||||
function Get-StartupCloudHint
|
||||
{
|
||||
# Resolution chain for a sign-in that doesn't carry an explicit cloud:
|
||||
# 1. TenantCloud_<TenantId> if the caller knows the target tenant
|
||||
# 2. LastLoggedOnCloud (the cloud of the most recent default sign-in)
|
||||
# 3. DefaultCloud setting (user-configured default)
|
||||
# Returns a Cloud value (Public/USGov/USGovDOD/China) — never $null.
|
||||
param([string]$TenantId)
|
||||
if($TenantId) {
|
||||
$t = Get-TenantCloud $TenantId
|
||||
if($t) { return $t }
|
||||
}
|
||||
$last = Get-SettingStoreValue "" "LastLoggedOnCloud" ""
|
||||
if($last) { return [string]$last }
|
||||
return (Get-DefaultCloud)
|
||||
}
|
||||
|
||||
function New-EntraApp {
|
||||
param($ClientId, $TenantId, $RedirectUri, $Authority)
|
||||
|
||||
return New-Object PSObject -Property @{
|
||||
ClientId = $ClientId
|
||||
TenantId = $TenantId
|
||||
RedirectUri = $RedirectUri
|
||||
Authority = $Authority
|
||||
}
|
||||
}
|
||||
|
||||
function Get-EntraApp {
|
||||
[CmdletBinding()]
|
||||
param($AppId,
|
||||
$RedirectURI,
|
||||
$Authority)
|
||||
|
||||
# Resolution order, highest priority first:
|
||||
# 1. Caller-supplied $AppId (explicit override).
|
||||
# 2. "EntraApp" setting (the Settings → Authentication → Entra application
|
||||
# dropdown — picks one of the built-in apps in $script:EntraApps).
|
||||
# 3. "EntraCustomAppId" setting (the Settings → Authentication → Application Id
|
||||
# field — a custom Entra App registration the user controls).
|
||||
# 4. $script:DefaultEntraAppId (Microsoft Graph PowerShell first-party app).
|
||||
# The previous version defaulted $entraAppId to the Microsoft Graph PowerShell
|
||||
# GUID when the dropdown was empty, which then matched a built-in entry and
|
||||
# never fell through to EntraCustomAppId — so a user who filled in only the
|
||||
# custom Application Id field still got logged in via the first-party app.
|
||||
$entraAppObj = $null
|
||||
|
||||
if(-not [String]::IsNullOrWhiteSpace($AppId)) {
|
||||
$entraAppObj = $script:EntraApps | Where-Object ClientId -eq $AppId
|
||||
if(-not $entraAppObj) {
|
||||
$entraAppObj = New-EntraApp -ClientId $AppId `
|
||||
-TenantId (Get-SettingValue "EntraCustomTenantId") `
|
||||
-RedirectUri (Get-SettingValue "EntraCustomAppRedirect" $RedirectURI) `
|
||||
-Authority (Get-SettingValue "EntraCustomAuthority" $Authority)
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $entraAppObj) {
|
||||
$dropdownAppId = Get-SettingValue "EntraApp"
|
||||
if(-not [String]::IsNullOrWhiteSpace($dropdownAppId)) {
|
||||
$entraAppObj = $script:EntraApps | Where-Object ClientId -eq $dropdownAppId
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $entraAppObj) {
|
||||
$customAppId = Get-SettingValue "EntraCustomAppId"
|
||||
if(-not [String]::IsNullOrWhiteSpace($customAppId)) {
|
||||
$entraAppObj = New-EntraApp -ClientId $customAppId `
|
||||
-TenantId (Get-SettingValue "EntraCustomTenantId") `
|
||||
-RedirectUri (Get-SettingValue "EntraCustomAppRedirect" $RedirectURI) `
|
||||
-Authority (Get-SettingValue "EntraCustomAuthority" $Authority)
|
||||
}
|
||||
}
|
||||
|
||||
if((-not $entraAppObj -or -not $entraAppObj.ClientId) -and $script:DefaultEntraAppId) {
|
||||
$entraAppObj = $script:EntraApps | Where-Object ClientId -eq $script:DefaultEntraAppId
|
||||
}
|
||||
|
||||
$entraAppObj
|
||||
}
|
||||
|
||||
function Get-EntraEnvironment
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param($Environment = $null)
|
||||
|
||||
if([String]::IsNullOrEmpty($Environment)) { $Environment = "public"}
|
||||
|
||||
$msalEnvironment = $script:EntraEnvironments | Where-Object Value -eq $Environment
|
||||
if(-not $msalEnvironment) {
|
||||
Write-Log "Entra environment $Environment not found. Using pulic" 2
|
||||
}
|
||||
Write-LogDebug "Use Entra environment $($msalEnvironment.Value)"
|
||||
$msalEnvironment
|
||||
}
|
||||
|
||||
# ---- end common Entra app + cloud data ----
|
||||
|
||||
# Setting that picks which registered provider is active. Read once at AppInitialized
|
||||
# time (after every provider has had a chance to register). If the setting points at
|
||||
# a provider that isn't registered (e.g. one this build doesn't ship) we keep
|
||||
# whatever was set during registration — usually MSAL since it always registers.
|
||||
#
|
||||
# ItemsSource starts empty: this file loads before any provider registers, so the
|
||||
# real list is built from the registry in Invoke-AuthCoreOnAppInitialized. Both
|
||||
# settings dialogs read ItemsSource at render time, long after AppInitialized.
|
||||
Add-SettingsSection -Title "Authentication" -Id "Authentication" -Order 7
|
||||
Add-SettingsObject -Title "Active authentication provider" -Key "ActiveAuthProvider" -Type "List" -DefaultValue "MSAL" `
|
||||
-ItemsSource @() `
|
||||
-Description "Which authentication backend to use. MSAL is the built-in default. MgGraph requires the Microsoft.Graph.Authentication PowerShell module to be installed. OAuth is a pure-PowerShell provider for automation (CI / scheduled tasks / managed identity / workload identity federation) - no SDK required." `
|
||||
-Section "Authentication"
|
||||
|
||||
# Common app-identity + cloud settings, shared by every provider (moved here from
|
||||
# the former Entra section). MSAL and OAuth resolve their public client from these
|
||||
# via Get-EntraApp - they are two ways of authenticating with the SAME app.
|
||||
Add-SettingsObject -Title "Entra application" -Key "EntraApp" -Type "List" -SelectedValuePath "ClientId" -ItemsSource $script:EntraApps `
|
||||
-Description "Built-in Entra application used for interactive sign-in by both the MSAL and OAuth providers. Leave empty to use the custom Application Id below, or the default Microsoft Graph PowerShell app." `
|
||||
-Section "Authentication"
|
||||
|
||||
Add-SettingsObject -Title "Application Id" -Key "EntraCustomAppId" -Type "String" `
|
||||
-Description "Custom Entra application (client) id used by MSAL and OAuth sign-in when no built-in application is selected above. The app registration needs the http://localhost redirect URI for browser-based login." `
|
||||
-Section "Authentication"
|
||||
|
||||
Add-SettingsObject -Title "Redirect URL" -Key "EntraCustomAppRedirect" -Type "String" `
|
||||
-Section "Authentication"
|
||||
|
||||
Add-SettingsObject -Title "Tenant Id" -Key "EntraCustomTenantId" -Type "String" `
|
||||
-Section "Authentication"
|
||||
|
||||
Add-SettingsObject -Title "Authority" -Key "EntraCustomAuthority" -Type "String" `
|
||||
-Section "Authentication"
|
||||
|
||||
Add-SettingsObject -Title "Default cloud" -Key "DefaultCloud" -Type "List" -DefaultValue "Public" `
|
||||
-SelectedValuePath "Value" -ItemsSource $script:Clouds `
|
||||
-Description "Microsoft cloud this tool signs in to by default. Public covers commercial + GCC commercial; USGov is GCC High; USGovDOD is GCC DoD; China is the Vianet cloud." `
|
||||
-Section "Authentication"
|
||||
|
||||
Add-SettingsObject -Title "Interactive login timeout (seconds)" -Key "MSGraphInteractiveTimeoutSec" -Type "Int" -DefaultValue 600 `
|
||||
-Description "Maximum time to wait for an interactive login to complete (MSAL embedded/broker and OAuth browser flows). Only applies to flows a user is waiting in front of; silent and app-secret token requests have their own much shorter cap. The default was 180 s, which killed legitimate sign-ins that needed an account picker plus an MFA approval - and the sign-in status has a Cancel button, so an abandoned window does not depend on this timeout." `
|
||||
-Section "Authentication"
|
||||
|
||||
function Register-AuthProvider {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Register an authentication provider in the multi-provider auth core.
|
||||
.DESCRIPTION
|
||||
The provider must inherit from AuthenticationProvider and set its Id. The
|
||||
first provider registered becomes the active one automatically; pass
|
||||
-SetActive to force-switch.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[AuthenticationProvider]$Provider,
|
||||
|
||||
[switch]$SetActive
|
||||
)
|
||||
|
||||
if(-not $Provider.Id) {
|
||||
Write-Log "Refusing to register an AuthenticationProvider without an Id" 3
|
||||
return
|
||||
}
|
||||
|
||||
if($script:AuthProviders.ContainsKey($Provider.Id)) {
|
||||
Write-Log "AuthenticationProvider '$($Provider.Id)' is already registered; replacing" 2
|
||||
}
|
||||
|
||||
$script:AuthProviders[$Provider.Id] = $Provider
|
||||
Write-Log "Registered authentication provider: $($Provider.Id) - $($Provider.DisplayName)"
|
||||
|
||||
try { $Provider.Initialize() }
|
||||
catch { Write-LogError "Provider $($Provider.Id) Initialize() threw" $_.Exception }
|
||||
|
||||
Invoke-AppEvent "AuthProviderRegistered" $Provider | Out-Null
|
||||
|
||||
if($SetActive -or -not $script:ActiveAuthProviderId) {
|
||||
Set-ActiveAuthProvider -Id $Provider.Id
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AuthProvider {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get a registered authentication provider by Id, or the active one if -Id is
|
||||
omitted.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([AuthenticationProvider])]
|
||||
param([string]$Id)
|
||||
|
||||
if(-not $Id) { $Id = $script:ActiveAuthProviderId }
|
||||
if(-not $Id) { return $null }
|
||||
if(-not $script:AuthProviders.ContainsKey($Id)) { return $null }
|
||||
return $script:AuthProviders[$Id]
|
||||
}
|
||||
|
||||
function Get-RegisteredAuthProviders {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
List every registered authentication provider.
|
||||
#>
|
||||
return @($script:AuthProviders.Values)
|
||||
}
|
||||
|
||||
# Accessor backing the -Provider ArgumentCompleter on Connect-IntuneManagement /
|
||||
# Get-AuthToken (invoked via & (Get-Module IntuneManagement) { Get-AuthProviderValues }).
|
||||
# A [ValidateSet([IValidateSetValuesGenerator])] was deliberately avoided so the
|
||||
# module still imports on Windows PowerShell 5.1 (that interface is PS7-only).
|
||||
# Returns the sorted list of registered provider Ids (MSAL, MgGraph, OAuth, ...).
|
||||
function Get-AuthProviderValues {
|
||||
if (-not $script:AuthProviders -or $script:AuthProviders.Count -eq 0) { return @() }
|
||||
return @($script:AuthProviders.Keys | Sort-Object)
|
||||
}
|
||||
|
||||
# ===================== Central token registry =====================
|
||||
# See the $script:AuthTokens comment block at the top of this file.
|
||||
|
||||
# The single global token-id allocator. Every provider draws its token id from
|
||||
# here instead of its own counter, so ids are unique across MSAL / OAuth / MgGraph.
|
||||
function Get-NextAuthTokenId {
|
||||
$id = $script:AuthTokenNextId
|
||||
$script:AuthTokenNextId = $id + 1
|
||||
return $id
|
||||
}
|
||||
|
||||
# Hydrate an [IMAuthToken] for a registry record: ask the owning provider for the
|
||||
# live identity, then stamp the registry-authoritative fields (TokenId, Provider,
|
||||
# Cloud, IsDefault). Tolerates a provider that returns $null (e.g. torn-down
|
||||
# session) by falling back to the stored record fields — never triggers auth.
|
||||
function ConvertTo-IMAuthToken {
|
||||
param([Parameter(Mandatory)][hashtable]$Record)
|
||||
|
||||
$provider = $null
|
||||
if ($Record.ProviderId -and $script:AuthProviders.ContainsKey($Record.ProviderId)) {
|
||||
$provider = $script:AuthProviders[$Record.ProviderId]
|
||||
}
|
||||
|
||||
$info = $null
|
||||
if ($provider) {
|
||||
try { $info = $provider.GetUserInfo($Record.TokenId) }
|
||||
catch { Write-LogDebug "ConvertTo-IMAuthToken: GetUserInfo failed on '$($Record.ProviderId)' for token $($Record.TokenId): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
$token = if ($info -is [IMAuthToken]) { $info } else { [IMAuthToken]::new() }
|
||||
if ($info -and $info -isnot [IMAuthToken]) {
|
||||
# Provider still returns a loose PSCustomObject (pre-migration) - copy the
|
||||
# known fields across so callers always get a typed IMAuthToken.
|
||||
foreach ($p in 'TenantId','TenantName','Account','UPN','UserId','AppId','AppName','AuthType','ExpiresOn') {
|
||||
if ($info.PSObject.Properties[$p]) { $token.$p = $info.$p }
|
||||
}
|
||||
if (-not $token.Account -and $info.PSObject.Properties['DisplayName']) { $token.Account = $info.DisplayName }
|
||||
}
|
||||
|
||||
# Registry-authoritative overlay.
|
||||
$token.TokenId = [int]$Record.TokenId
|
||||
$token.Provider = [string]$Record.ProviderId
|
||||
if ($Record.Cloud) { $token.Cloud = [string]$Record.Cloud }
|
||||
$token.IsDefault = ($script:DefaultAuthTokenId -eq $Record.TokenId)
|
||||
return $token
|
||||
}
|
||||
|
||||
# Return the [IMAuthToken] for one id, or $null when unknown.
|
||||
function Get-AuthTokenById {
|
||||
param([int]$TokenId)
|
||||
if (-not $script:AuthTokens.ContainsKey($TokenId)) { return $null }
|
||||
return ConvertTo-IMAuthToken -Record $script:AuthTokens[$TokenId]
|
||||
}
|
||||
|
||||
# Return every live token as [IMAuthToken[]], ordered by id.
|
||||
function Get-AuthTokenList {
|
||||
if (-not $script:AuthTokens -or $script:AuthTokens.Count -eq 0) { return [IMAuthToken[]]@() }
|
||||
$rows = foreach ($id in ($script:AuthTokens.Keys | Sort-Object)) {
|
||||
ConvertTo-IMAuthToken -Record $script:AuthTokens[$id]
|
||||
}
|
||||
return [IMAuthToken[]]@($rows)
|
||||
}
|
||||
|
||||
function Get-DefaultAuthTokenId {
|
||||
return $script:DefaultAuthTokenId
|
||||
}
|
||||
|
||||
# Make a token the default. IsDefault everywhere derives from this single value,
|
||||
# so there is no per-record flag to keep in sync.
|
||||
function Set-DefaultAuthToken {
|
||||
param([Parameter(Mandatory)][int]$TokenId)
|
||||
if (-not $script:AuthTokens.ContainsKey($TokenId)) {
|
||||
Write-Log "Set-DefaultAuthToken: token $TokenId is not registered" 2
|
||||
return
|
||||
}
|
||||
$script:DefaultAuthTokenId = $TokenId
|
||||
}
|
||||
|
||||
# Resolve a token id to its owning [AuthenticationProvider]; $null when unknown
|
||||
# (callers fall back to the active provider for id 0 / unregistered ids).
|
||||
function Resolve-AuthTokenProvider {
|
||||
param([int]$TokenId)
|
||||
if ($TokenId -le 0) { return $null }
|
||||
if (-not $script:AuthTokens.ContainsKey($TokenId)) { return $null }
|
||||
$providerId = $script:AuthTokens[$TokenId].ProviderId
|
||||
if ($providerId -and $script:AuthProviders.ContainsKey($providerId)) {
|
||||
return $script:AuthProviders[$providerId]
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Register a freshly-acquired token. Providers call this after storing the token
|
||||
# in their own backend, passing the id they drew from Get-NextAuthTokenId. Sets
|
||||
# the first token (or an explicitly-defaulted one) as default and fires
|
||||
# AuthenticatedNewToken with the hydrated [IMAuthToken]. Returns that token.
|
||||
function Register-AuthToken {
|
||||
# -Provider is intentionally untyped: only $Provider.Id is used here (hydration
|
||||
# later resolves the live provider from $script:AuthProviders by that id). Keeping
|
||||
# it untyped matches how the codebase injects fake providers in tests.
|
||||
param(
|
||||
[Parameter(Mandatory)]$Provider,
|
||||
[Parameter(Mandatory)][int]$TokenId,
|
||||
[string]$Cloud,
|
||||
[switch]$Default
|
||||
)
|
||||
|
||||
$script:AuthTokens[$TokenId] = @{
|
||||
TokenId = $TokenId
|
||||
ProviderId = $Provider.Id
|
||||
Cloud = $Cloud
|
||||
}
|
||||
|
||||
if ($Default -or $script:DefaultAuthTokenId -le 0) {
|
||||
$script:DefaultAuthTokenId = $TokenId
|
||||
}
|
||||
|
||||
$token = ConvertTo-IMAuthToken -Record $script:AuthTokens[$TokenId]
|
||||
try { Invoke-AppEvent "AuthenticatedNewToken" $token | Out-Null } catch { }
|
||||
return $token
|
||||
}
|
||||
|
||||
# Remove a token from the registry. Fires AuthenticationUserDisconnected with a
|
||||
# final snapshot; if the removed token was the default and others remain, promotes
|
||||
# the highest-id survivor and fires AuthenticatedNewToken for it.
|
||||
function Unregister-AuthToken {
|
||||
param([Parameter(Mandatory)][int]$TokenId)
|
||||
if (-not $script:AuthTokens.ContainsKey($TokenId)) { return }
|
||||
|
||||
$snapshot = ConvertTo-IMAuthToken -Record $script:AuthTokens[$TokenId]
|
||||
$wasDefault = ($script:DefaultAuthTokenId -eq $TokenId)
|
||||
$script:AuthTokens.Remove($TokenId) | Out-Null
|
||||
|
||||
$promoted = $null
|
||||
if ($wasDefault) {
|
||||
$script:DefaultAuthTokenId = 0
|
||||
if ($script:AuthTokens.Count -gt 0) {
|
||||
$nextId = ($script:AuthTokens.Keys | Sort-Object -Descending | Select-Object -First 1)
|
||||
$script:DefaultAuthTokenId = $nextId
|
||||
$promoted = ConvertTo-IMAuthToken -Record $script:AuthTokens[$nextId]
|
||||
}
|
||||
}
|
||||
|
||||
try { Invoke-AppEvent "AuthenticationUserDisconnected" $snapshot | Out-Null } catch { }
|
||||
if ($promoted) {
|
||||
try { Invoke-AppEvent "AuthenticatedNewToken" $promoted | Out-Null } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# Fire AuthenticationTokenRefresh for a token after a silent renewal. No registry
|
||||
# write needed (IMAuthToken is hydrated on read); this just notifies consumers.
|
||||
function Update-AuthToken {
|
||||
param([Parameter(Mandatory)][int]$TokenId)
|
||||
if (-not $script:AuthTokens.ContainsKey($TokenId)) { return }
|
||||
$token = ConvertTo-IMAuthToken -Record $script:AuthTokens[$TokenId]
|
||||
try { Invoke-AppEvent "AuthenticationTokenRefresh" $token | Out-Null } catch { }
|
||||
}
|
||||
|
||||
# Fire AuthenticationFailed with a canonical shape, so every provider reports
|
||||
# failures the same way (replaces ad-hoc Exception / $null / raw-object payloads).
|
||||
function Invoke-AuthTokenFailed {
|
||||
param(
|
||||
[string]$Provider,
|
||||
[string]$TenantId,
|
||||
[string]$ErrorCode,
|
||||
[string]$Message,
|
||||
$Exception
|
||||
)
|
||||
$payload = [PSCustomObject]@{
|
||||
Provider = $Provider
|
||||
TenantId = $TenantId
|
||||
ErrorCode = $ErrorCode
|
||||
Message = if ($Message) { $Message } elseif ($Exception) { $Exception.Message } else { $null }
|
||||
Exception = $Exception
|
||||
}
|
||||
try { Invoke-AppEvent "AuthenticationFailed" $payload | Out-Null } catch { }
|
||||
}
|
||||
|
||||
# True when the current default (signed-in) token has passed its expiry with no
|
||||
# valid replacement. Provider-agnostic: uses the owning provider's
|
||||
# GetAccessTokenExpiry(), which returns [datetime]::MaxValue when expiry is
|
||||
# unknown or the SDK manages refresh (MgGraph) - those are treated as NOT expired
|
||||
# so we never falsely flip a still-managed session to a signed-out UI. Returns
|
||||
# $false when nothing is signed in. Used by the UI to decide when the title-bar
|
||||
# profile control should fall back to the Sign-in icon.
|
||||
function Test-DefaultTokenExpired {
|
||||
try {
|
||||
if (-not (Get-Command Get-AuthProvider -ErrorAction SilentlyContinue)) { return $false }
|
||||
$provider = Get-AuthProvider
|
||||
$tokenId = Get-DefaultTokenId
|
||||
if (-not $provider -or -not $tokenId) { return $false }
|
||||
$exp = $provider.GetAccessTokenExpiry($tokenId, "https://graph.microsoft.com")
|
||||
return ($exp -ne [datetime]::MaxValue -and $exp -le (Get-Date))
|
||||
}
|
||||
catch { return $false }
|
||||
}
|
||||
|
||||
# ===================== end token registry =====================
|
||||
|
||||
function Set-ActiveAuthProvider {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Make a registered provider the active one. Subsequent calls to Get-AuthProvider
|
||||
(without -Id) return this provider.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][string]$Id)
|
||||
|
||||
if(-not $script:AuthProviders.ContainsKey($Id)) {
|
||||
Write-Log "Cannot set active auth provider '$Id' - not registered" 3
|
||||
return
|
||||
}
|
||||
$previous = $script:ActiveAuthProviderId
|
||||
$script:ActiveAuthProviderId = $Id
|
||||
Write-Log "Active authentication provider: $Id"
|
||||
|
||||
if($previous -ne $Id) {
|
||||
Invoke-AppEvent "ActiveAuthProviderChanged" $script:AuthProviders[$Id] $previous | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# === Facades that route to the ACTIVE provider ===
|
||||
# Convenience wrappers for callers that just want "the current provider". The hot path
|
||||
# (Invoke-MSGraphAPI) does NOT use these: it resolves each call's OWNING provider by
|
||||
# TokenId (Resolve-AuthTokenProvider) so a call reaches the token's own provider, not
|
||||
# merely whichever provider is active.
|
||||
|
||||
function Get-AuthProviderAccessToken {
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[int]$TokenId,
|
||||
[string]$Resource = "https://graph.microsoft.com",
|
||||
[string]$ProviderId
|
||||
)
|
||||
$provider = Get-AuthProvider $ProviderId
|
||||
if(-not $provider) { return $null }
|
||||
return $provider.GetAccessToken($TokenId, $Resource)
|
||||
}
|
||||
|
||||
function Get-AuthProviderUserInfo {
|
||||
[CmdletBinding()]
|
||||
[OutputType([PSCustomObject])]
|
||||
param([int]$TokenId, [string]$ProviderId)
|
||||
|
||||
$provider = Get-AuthProvider $ProviderId
|
||||
if(-not $provider) { return $null }
|
||||
return $provider.GetUserInfo($TokenId)
|
||||
}
|
||||
|
||||
function Get-AuthProviderCachedAccounts {
|
||||
[CmdletBinding()]
|
||||
[OutputType([PSCustomObject[]])]
|
||||
param([string]$ProviderId)
|
||||
|
||||
$provider = Get-AuthProvider $ProviderId
|
||||
if(-not $provider) { return [PSCustomObject[]]@() }
|
||||
return $provider.GetCachedAccounts()
|
||||
}
|
||||
|
||||
# --- Active-tenant / current-user accessors (architecture R7) ---
|
||||
# The org/tenant identifiers and the signed-in user are auth-module state
|
||||
# (set by the authentication providers on every token change). Non-auth code
|
||||
# must read them through these accessors instead of reaching into the module's
|
||||
# internal $script: variables directly. The values are module-scope, so these
|
||||
# are thin getters; the point is encapsulation - if the backing field is ever
|
||||
# renamed or sourced differently, only these change.
|
||||
|
||||
function Get-CurrentTenantId {
|
||||
[OutputType([string])]
|
||||
param()
|
||||
return $script:OrganizationId
|
||||
}
|
||||
|
||||
function Get-CurrentOrganizationName {
|
||||
[OutputType([string])]
|
||||
param()
|
||||
return $script:OrganizationName
|
||||
}
|
||||
|
||||
function Get-CurrentUser {
|
||||
# The signed-in user object (Graph /me shape when available; may be a
|
||||
# display-name string for app/secret logins, or $null when signed out).
|
||||
param()
|
||||
return $script:CurrentUser
|
||||
}
|
||||
|
||||
function Get-AuthProviderAvailableTenants {
|
||||
[CmdletBinding()]
|
||||
[OutputType([PSCustomObject[]])]
|
||||
param([int]$TokenId, [string]$ProviderId)
|
||||
|
||||
$provider = Get-AuthProvider $ProviderId
|
||||
if(-not $provider) { return [PSCustomObject[]]@() }
|
||||
return $provider.GetAvailableTenants($TokenId)
|
||||
}
|
||||
|
||||
# Honour the user's "ActiveAuthProvider" setting after every provider has had a
|
||||
# chance to register. AppInitialized fires near the end of module load — by then
|
||||
# both MSAL and (if installed) MgGraph have registered themselves.
|
||||
function Invoke-AuthCoreOnAppInitialized {
|
||||
# Populate the ActiveAuthProvider setting's dropdown from the registry now that
|
||||
# every provider has registered. MSAL first (it's the default), rest by Id.
|
||||
$settingObj = (Get-SettingsSection "Authentication").Values | Where-Object Key -eq "ActiveAuthProvider"
|
||||
if($settingObj) {
|
||||
$settingObj.ItemsSource = @(Get-RegisteredAuthProviders |
|
||||
Sort-Object @{ Expression = { $_.Id -ne "MSAL" } }, Id |
|
||||
ForEach-Object { [PSCustomObject]@{ Name = $_.DisplayName; Value = $_.Id } })
|
||||
}
|
||||
|
||||
# IM_AUTH_PROVIDER picks the provider for this session only (launch configs,
|
||||
# "start with OAuth" vs "start with MSAL"); it outranks the persisted setting
|
||||
# but never writes it, so the next plain launch is back on the saved choice.
|
||||
$desired = $env:IM_AUTH_PROVIDER
|
||||
$source = "environment (IM_AUTH_PROVIDER)"
|
||||
if(-not $desired) {
|
||||
$desired = Get-SettingValue "ActiveAuthProvider"
|
||||
$source = "setting"
|
||||
}
|
||||
if($desired) {
|
||||
if($script:AuthProviders.ContainsKey($desired)) {
|
||||
if($script:ActiveAuthProviderId -ne $desired) {
|
||||
Write-Log "Active auth provider switched to '$desired' per $source"
|
||||
Set-ActiveAuthProvider -Id $desired
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Log "$source requested auth provider '$desired' but it isn't registered (active = '$script:ActiveAuthProviderId')" 2
|
||||
}
|
||||
}
|
||||
|
||||
# Give the active provider a chance to silently resume a persisted session at
|
||||
# startup (no interactive prompt). MSAL overrides TryResumeSession to do a silent
|
||||
# Connect-EntraEnvironment from its on-disk cache; MgGraph resumes its
|
||||
# Azure.Identity disk cache. Providers with nothing to resume inherit the base
|
||||
# no-op ($false).
|
||||
$active = Get-AuthProvider
|
||||
if($active) {
|
||||
try {
|
||||
if($active.TryResumeSession()) {
|
||||
Write-LogDebug "Provider '$($active.Id)' resumed a persisted session at startup"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Provider '$($active.Id)' TryResumeSession threw" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Add-AppEventHandler "AppInitialized" "Invoke-AuthCoreOnAppInitialized"
|
||||
|
||||
# Provider-neutral post-auth state sync. Reads the active provider's normalized
|
||||
# GetUserInfo() shape (defined on the AuthenticationProvider base class) and
|
||||
# writes the module-scope globals ($script:OrganizationId, $script:OrganizationName,
|
||||
# $script:CurrentUser) that downstream code -- Get-CurrentTenantId,
|
||||
# Test-DocumentationGraphAvailable, env-badge helpers, etc. -- reads. Replaces
|
||||
# the MSAL-only path in Get-MSALUserInfo (now Update-MSALUserProfile) for these
|
||||
# generic fields, so headless OAuth / MgGraph sessions (no UI event handlers)
|
||||
# still populate the tenant id.
|
||||
function Sync-AuthContextFromProvider {
|
||||
[CmdletBinding()]
|
||||
param([int]$TokenId = 0)
|
||||
|
||||
$provider = Get-AuthProvider
|
||||
if(-not $provider) {
|
||||
$script:OrganizationId = $null
|
||||
$script:OrganizationName = $null
|
||||
$script:CurrentUser = $null
|
||||
return
|
||||
}
|
||||
|
||||
$u = $null
|
||||
try { $u = $provider.GetUserInfo($TokenId) }
|
||||
catch { Write-LogDebug "Sync-AuthContextFromProvider: GetUserInfo failed on '$($provider.Id)': $($_.Exception.Message)" }
|
||||
if(-not $u) { return }
|
||||
|
||||
if($u.TenantId) { $script:OrganizationId = [string]$u.TenantId }
|
||||
if($u.TenantId) { $script:OrganizationName = if($u.TenantName) { [string]$u.TenantName } else { [string]$u.TenantId } }
|
||||
|
||||
# Minimal, provider-independent user shape. UI code that wants MSAL-specific
|
||||
# enrichment (photo, JWT claim inspection) layers it on separately via
|
||||
# Update-MSALUserProfile.
|
||||
$script:CurrentUser = [PSCustomObject]@{
|
||||
displayName = $u.DisplayName
|
||||
userPrincipalName = $u.UPN
|
||||
Id = $u.UserId
|
||||
}
|
||||
}
|
||||
|
||||
# Module-scope AuthenticatedNewToken handler. Fires regardless of whether the UI
|
||||
# is loaded, so headless sessions get the same essential state updates the UI
|
||||
# handlers used to do exclusively (organization id/name, scope-tag + filter
|
||||
# preload into the persistent dependency cache).
|
||||
function Invoke-AuthCoreOnNewToken {
|
||||
[CmdletBinding()]
|
||||
param($TokenInfo)
|
||||
if(-not $TokenInfo) { return }
|
||||
|
||||
# Payload is an [IMAuthToken] (.TokenId) once a provider registers via the
|
||||
# token registry; legacy MSAL fire sites still pass a Get-TokenInfo projection
|
||||
# (.Id) until they migrate. Accept either so the handler is correct throughout
|
||||
# the rollout.
|
||||
$tokenId = 0
|
||||
if($TokenInfo.PSObject.Properties['TokenId'] -and $TokenInfo.TokenId) { $tokenId = [int]$TokenInfo.TokenId }
|
||||
elseif($TokenInfo.PSObject.Properties['Id'] -and $TokenInfo.Id) { $tokenId = [int]$TokenInfo.Id }
|
||||
|
||||
try { Sync-AuthContextFromProvider -TokenId $tokenId }
|
||||
catch { Write-LogError 'Sync-AuthContextFromProvider failed on AuthenticatedNewToken' $_.Exception }
|
||||
|
||||
# Preload ScopeTags + AssignmentFilters into DependencyObjects_<TenantId> so
|
||||
# downstream flows (Documentation NameFilter 'scope:...', Copy / Import
|
||||
# scope-tag pickers) resolve without a mid-run Graph call. Was previously
|
||||
# only invoked from the UI event handlers -- headless flows never got it.
|
||||
if($tokenId -gt 0) {
|
||||
try { Initialize-TenantDependencyCache -TokenId $tokenId }
|
||||
catch { Write-LogError 'Initialize-TenantDependencyCache failed on AuthenticatedNewToken' $_.Exception }
|
||||
}
|
||||
}
|
||||
|
||||
# Declare the event name here in addition to the declaration in
|
||||
# AuthenticationMSALHelpers.ps1. Internal/ files are dot-sourced alphabetically, so
|
||||
# AuthenticationCore.ps1 loads BEFORE AuthenticationMSALHelpers.ps1 — without this
|
||||
# call, Add-AppEventHandler below silently fails because the event doesn't yet
|
||||
# exist in $script:AppEventTriggers. Add-AppEvent is idempotent (checks
|
||||
# ContainsKey), so re-declaring in Invoke-MSALInitialize is harmless.
|
||||
# Add Authentication Events
|
||||
Add-AppEvent "AuthenticatedNewToken"
|
||||
Add-AppEvent "AuthenticationTokenRefresh"
|
||||
Add-AppEvent "AuthenticationUserDisconnected"
|
||||
Add-AppEvent "AuthenticationFailed"
|
||||
|
||||
Add-AppEventHandler "AuthenticatedNewToken" "Invoke-AuthCoreOnNewToken"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Where the MSAL.NET assemblies live for this PowerShell edition.
|
||||
#
|
||||
# Add-MSALPrereq loads from here, and Test-MSALResumeLikely checks the folder
|
||||
# exists before any load is attempted: a deployment that only ever signs in with
|
||||
# the OAuth provider is allowed to delete Bin/ entirely (README, Headless), and
|
||||
# that must not produce an error per missing DLL at every import.
|
||||
#
|
||||
# Own file per R9 - AuthenticationMSALHelpers.ps1 is at its line budget.
|
||||
function Get-MSALBinariesFolder
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$edition = if($PSVersionTable.PSVersion.Major -lt 7) { "MSAL_PS5" } else { "MSAL_PS7" }
|
||||
return (Join-Path (Join-Path $script:AppRootFolder "Bin") $edition)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
# Microsoft.Graph PowerShell SDK provider — initialization & registration.
|
||||
#
|
||||
# Mirrors the pattern used by Internal/AuthenticationMSALHelpers.ps1: the auth backend
|
||||
# has its own init file that the module loader picks up. The init function checks
|
||||
# that the SDK is installed and, if so, registers the provider into the multi-
|
||||
# provider auth core.
|
||||
#
|
||||
# We deliberately do NOT Import-Module Microsoft.Graph.Authentication here — that
|
||||
# load is expensive (hundreds of ms) and the user may not actually use this
|
||||
# provider in a given session. Lazy import happens in AuthenticationMgGraph.Connect().
|
||||
|
||||
# Required modules. Today only Microsoft.Graph.Authentication is needed — it covers
|
||||
# Connect-MgGraph / Disconnect-MgGraph / Get-MgContext. If we later add features that
|
||||
# need Microsoft.Graph.Identity.DirectoryManagement (Get-MgOrganization for tenant
|
||||
# display name) etc., add them here and they'll be included in the install prompt.
|
||||
$script:MgGraphRequiredModules = @('Microsoft.Graph.Authentication')
|
||||
|
||||
# Internal: check that all required SDK modules are installed. Returns missing names.
|
||||
function Get-MgGraphMissingModules {
|
||||
$missing = @()
|
||||
foreach($name in $script:MgGraphRequiredModules) {
|
||||
$found = Get-Module -ListAvailable -Name $name -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if(-not $found) { $missing += $name }
|
||||
}
|
||||
return $missing
|
||||
}
|
||||
|
||||
# Ensures every required SDK module is installed. If any are missing, prompts the
|
||||
# user. Install runs at CurrentUser scope (no admin). Returns $true on success,
|
||||
# $false on user-decline or install failure (caller falls back to MSAL).
|
||||
function Resolve-MgGraphModule {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$missing = Get-MgGraphMissingModules
|
||||
if($missing.Count -eq 0) { return $true }
|
||||
|
||||
Write-Log "MgGraph provider needs the following PowerShell module(s): $($missing -join ', ')"
|
||||
|
||||
$msg = "The Microsoft Graph PowerShell SDK module(s) below are not installed:`n`n " +
|
||||
($missing -join "`n ") +
|
||||
"`n`nInstall now (Install-Module ... -Scope CurrentUser)?"
|
||||
$accepted = Confirm-UserAction -Message $msg -Caption "Install MgGraph module?"
|
||||
|
||||
if(-not $accepted) {
|
||||
Write-Log "User declined MgGraph module install - MgGraph provider unavailable" 2
|
||||
return $false
|
||||
}
|
||||
|
||||
foreach($name in $missing) {
|
||||
try {
|
||||
Write-Log "Installing module '$name' (Scope=CurrentUser)..."
|
||||
Install-Module -Name $name -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
Write-Log "Installed '$name' successfully"
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to install '$name'. Run 'Install-Module $name -Scope CurrentUser' manually." $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Re-verify after install
|
||||
$stillMissing = Get-MgGraphMissingModules
|
||||
if($stillMissing.Count -gt 0) {
|
||||
Write-Log "After install attempt, still missing: $($stillMissing -join ', ')" 3
|
||||
return $false
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-MgGraphProviderInitialize {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
if(-not (Get-Command -Name Register-AuthProvider -ErrorAction SilentlyContinue)) {
|
||||
# AuthenticationCore.ps1 didn't load (shouldn't happen, but be defensive)
|
||||
Write-LogDebug "MgGraph provider: AuthenticationCore not available, skipping registration"
|
||||
return
|
||||
}
|
||||
|
||||
# Always register — even if the SDK module isn't installed yet. The provider's
|
||||
# Connect() will prompt the user to install at first use. This gives users the
|
||||
# ability to choose MgGraph in the settings UI without first installing modules
|
||||
# manually.
|
||||
try {
|
||||
Register-AuthProvider -Provider ([AuthenticationMgGraph]::new())
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to register AuthenticationMgGraph provider" $_.Exception
|
||||
return
|
||||
}
|
||||
|
||||
# Log the install status so it's visible in the app log without prompting.
|
||||
$missing = Get-MgGraphMissingModules
|
||||
if($missing.Count -eq 0) {
|
||||
Write-LogDebug "MgGraph provider: all required SDK modules present"
|
||||
}
|
||||
else {
|
||||
Write-Log "MgGraph provider registered but the following modules need installing on first use: $($missing -join ', ')"
|
||||
}
|
||||
}
|
||||
|
||||
# Extracts the MSAL-v3 cache byte[] from the SDK's private InMemoryTokenCache and
|
||||
# rehydrates it into a transient PublicClientApplication so we can enumerate
|
||||
# cached accounts. Lives here (regular function, late-bound) rather than as a
|
||||
# class method because PowerShell classes parse type references at parse time
|
||||
# and would fail before MSAL DLLs are loaded.
|
||||
#
|
||||
# Source pattern: github.com/microsoftgraph/msgraph-sdk-powershell
|
||||
# src/Authentication/Authentication/Common/InMemoryTokenCache.cs
|
||||
#
|
||||
# NOTE: the cache is in-memory only — only accounts seen during the current
|
||||
# PowerShell session show up. Connect-MgGraph has no -LoginHint so clicking an
|
||||
# entry can't directly switch to it; the list is informational.
|
||||
function Get-MgGraphCachedMsalAccounts {
|
||||
[CmdletBinding()]
|
||||
param([string]$ProviderId = "MgGraph")
|
||||
|
||||
$sessionType = "Microsoft.Graph.PowerShell.Authentication.GraphSession" -as [type]
|
||||
if(-not $sessionType) { return @() }
|
||||
$session = $sessionType::Instance
|
||||
if(-not $session -or -not $session.InMemoryTokenCache) { return @() }
|
||||
|
||||
# Pull the private _tokenCache byte[] via reflection.
|
||||
$cacheObj = $session.InMemoryTokenCache
|
||||
$cacheType = $cacheObj.GetType()
|
||||
$field = $cacheType.GetField('_tokenCache',
|
||||
[System.Reflection.BindingFlags]::NonPublic -bor
|
||||
[System.Reflection.BindingFlags]::Instance)
|
||||
if(-not $field) {
|
||||
Write-LogDebug "Get-MgGraphCachedMsalAccounts: _tokenCache field not present on $($cacheType.FullName)"
|
||||
return @()
|
||||
}
|
||||
$cacheBytes = $field.GetValue($cacheObj)
|
||||
if(-not $cacheBytes -or $cacheBytes.Length -eq 0) { return @() }
|
||||
|
||||
# Make sure the MSAL types are available — Add-MSALPrereq loads them, but on a
|
||||
# pure MgGraph-only setup they might not have been loaded yet. The Microsoft.Graph
|
||||
# SDK ships Microsoft.Identity.Client itself though, so usually fine.
|
||||
$msalBuilderType = "Microsoft.Identity.Client.PublicClientApplicationBuilder" -as [type]
|
||||
if(-not $msalBuilderType) {
|
||||
Write-LogDebug "Get-MgGraphCachedMsalAccounts: MSAL PublicClientApplicationBuilder type not available"
|
||||
return @()
|
||||
}
|
||||
|
||||
# Use the same ClientId the SDK established so the deserialized cache contents match.
|
||||
$clientId = $null
|
||||
try {
|
||||
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||
if($ctx -and $ctx.ClientId) { $clientId = $ctx.ClientId }
|
||||
} catch { }
|
||||
if(-not $clientId) { $clientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" }
|
||||
|
||||
$appBuilder = $msalBuilderType::Create($clientId)
|
||||
[void]$appBuilder.WithAuthority("https://login.microsoftonline.com/organizations/")
|
||||
$tempApp = $appBuilder.Build()
|
||||
|
||||
# Deserialize the SDK's cache directly. Avoids SetBeforeAccess(scriptblock)
|
||||
# which would fire from MSAL's background thread (no PS Runspace there).
|
||||
$tempApp.UserTokenCache.DeserializeMsalV3($cacheBytes, $true)
|
||||
|
||||
$accounts = $tempApp.GetAccountsAsync().GetAwaiter().GetResult()
|
||||
if(-not $accounts -or $accounts.Count -eq 0) { return @() }
|
||||
|
||||
$rows = foreach($acc in $accounts) {
|
||||
[PSCustomObject]@{
|
||||
Provider = $ProviderId
|
||||
Username = $acc.Username
|
||||
UserId = $acc.HomeAccountId.ObjectId
|
||||
TenantId = $acc.HomeAccountId.TenantId
|
||||
Native = $acc
|
||||
}
|
||||
}
|
||||
return $rows
|
||||
}
|
||||
|
||||
Invoke-MgGraphProviderInitialize
|
||||
@@ -0,0 +1,110 @@
|
||||
# Registration and token helpers for the offline mock tenant provider
|
||||
# (Classes/AuthenticationMock.ps1). The provider only exists in a session
|
||||
# started with IM_MOCK_DATA pointing at a data folder - see
|
||||
# Docs/MockTenant.md and Tools/Start-MockTenant.ps1.
|
||||
|
||||
# Delegated scopes stamped into the mock token: every permission a policy type
|
||||
# declares, so access marking shows the whole menu as usable.
|
||||
$script:MockTokenScopes = @(
|
||||
"Agreement.ReadWrite.All", "CloudPC.ReadWrite.All",
|
||||
"DeviceManagementApps.ReadWrite.All", "DeviceManagementConfiguration.ReadWrite.All",
|
||||
"DeviceManagementManagedDevices.ReadWrite.All", "DeviceManagementRBAC.ReadWrite.All",
|
||||
"DeviceManagementScripts.ReadWrite.All", "DeviceManagementServiceConfig.ReadWrite.All",
|
||||
"Directory.Read.All", "Group.ReadWrite.All", "Organization.ReadWrite.All",
|
||||
"Policy.Read.All", "Policy.ReadWrite.ConditionalAccess", "User.Read", "User.Read.All",
|
||||
"openid", "profile", "offline_access"
|
||||
)
|
||||
|
||||
# Intune Administrator role template id: access marking skips the RBAC lookup
|
||||
# for a token carrying it (Internal/EffectivePermissions.ps1).
|
||||
$script:MockTokenIntuneAdminRoleId = "3a2c62db-5318-420d-8d74-23affee5d9d5"
|
||||
|
||||
# tenant.json merged over defaults, so a data folder only has to say what differs.
|
||||
function Get-MockTenantProfile {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][string]$Root)
|
||||
|
||||
$profile = [ordered]@{
|
||||
TenantId = "11111111-2222-3333-4444-555555555555"
|
||||
TenantName = "MyLabTenant"
|
||||
Domain = "mylabtenant.onmicrosoft.com"
|
||||
UPN = "admin@mylabtenant.onmicrosoft.com"
|
||||
DisplayName = "Lab Admin"
|
||||
UserId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
AppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"
|
||||
AppName = "Microsoft Graph Command Line Tools"
|
||||
}
|
||||
$file = Join-Path $Root "tenant.json"
|
||||
if(Test-Path -LiteralPath $file) {
|
||||
try {
|
||||
$data = [IO.File]::ReadAllText($file) | ConvertFrom-Json
|
||||
foreach($prop in $data.PSObject.Properties) {
|
||||
if($profile.Contains($prop.Name) -and $null -ne $prop.Value -and "$($prop.Value)" -ne "") { $profile[$prop.Name] = [string]$prop.Value }
|
||||
}
|
||||
}
|
||||
catch { Write-Log "Mock provider: cannot read $file - using defaults ($($_.Exception.Message))" 2 }
|
||||
}
|
||||
return [PSCustomObject]$profile
|
||||
}
|
||||
|
||||
function ConvertTo-MockBase64Url {
|
||||
param([string]$Text)
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Text)
|
||||
return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||
}
|
||||
|
||||
# An unsigned JWT with the claims the product reads (scp, wids, tid, upn, oid,
|
||||
# name, iat, exp, idtyp). Nothing verifies the signature - the mock never
|
||||
# leaves the process - so the third segment is a placeholder.
|
||||
function New-MockAccessToken {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)]$Tenant)
|
||||
|
||||
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
$header = [ordered]@{ typ = "JWT"; alg = "none" }
|
||||
$payload = [ordered]@{
|
||||
aud = "https://graph.microsoft.com"
|
||||
iss = "https://sts.windows.net/$($Tenant.TenantId)/"
|
||||
iat = $now
|
||||
nbf = $now
|
||||
exp = $now + 31536000
|
||||
app_displayname = $Tenant.AppName
|
||||
appid = $Tenant.AppId
|
||||
idtyp = "user"
|
||||
name = $Tenant.DisplayName
|
||||
oid = $Tenant.UserId
|
||||
preferred_username = $Tenant.UPN
|
||||
scp = ($script:MockTokenScopes -join " ")
|
||||
tid = $Tenant.TenantId
|
||||
unique_name = $Tenant.UPN
|
||||
upn = $Tenant.UPN
|
||||
ver = "1.0"
|
||||
wids = @($script:MockTokenIntuneAdminRoleId)
|
||||
}
|
||||
$h = ConvertTo-MockBase64Url ($header | ConvertTo-Json -Compress)
|
||||
$p = ConvertTo-MockBase64Url ($payload | ConvertTo-Json -Compress)
|
||||
return "$h.$p.mock"
|
||||
}
|
||||
|
||||
function Invoke-MockProviderInitialize {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
if(-not $env:IM_MOCK_DATA) { return }
|
||||
if(-not (Get-Command -Name Register-AuthProvider -ErrorAction SilentlyContinue)) { return }
|
||||
if(-not (Test-Path -LiteralPath $env:IM_MOCK_DATA -PathType Container)) {
|
||||
Write-Log "IM_MOCK_DATA is set but '$($env:IM_MOCK_DATA)' is not a folder - mock provider not registered" 2
|
||||
return
|
||||
}
|
||||
|
||||
# A mock session is only ever meant to run on the mock, so make it the
|
||||
# session's provider unless the launcher already chose one. MSAL registers
|
||||
# later with -SetActive; the AppInitialized handler applies this variable
|
||||
# after every provider has registered, which is what makes it stick.
|
||||
if(-not $env:IM_AUTH_PROVIDER) { $env:IM_AUTH_PROVIDER = "Mock" }
|
||||
|
||||
try { Register-AuthProvider -Provider ([AuthenticationMock]::new()) }
|
||||
catch { Write-LogError "Failed to register the mock tenant provider" $_.Exception }
|
||||
}
|
||||
|
||||
Invoke-MockProviderInitialize
|
||||
@@ -0,0 +1,734 @@
|
||||
# Module-level helpers for AuthenticationOAuth.
|
||||
#
|
||||
# PowerShell class methods bind type names at parse time, so [System.Net.Http.*]
|
||||
# / [System.Security.Cryptography.*] usage that needs late binding (or any
|
||||
# helper that calls module functions like Write-Log) lives here, not on the
|
||||
# class. AuthenticationMgGraph follows the same split.
|
||||
#
|
||||
# Functions:
|
||||
# Invoke-OAuthTokenRequest — POSTs to /oauth2/v2.0/token, surfaces Entra error fields
|
||||
# Invoke-OAuthDeviceCodeFlow — device code grant: request code, poll /token until signed in
|
||||
# Invoke-OAuthAuthCodeFlow — browser auth-code + PKCE over a loopback HttpListener redirect
|
||||
# Get-OAuthFreePort — OS-assigned free loopback port for the redirect listener
|
||||
# Save/Read/Clear-OAuthTokenCache — opt-in DPAPI refresh-token cache (Remember login)
|
||||
# Get-OAuthClientAssertion — builds + signs the private_key_jwt for cert auth
|
||||
# Invoke-OAuthIMDS — Managed identity: IMDS / App Service / Functions
|
||||
# Get-OAuthFederatedAssertion — workload identity federation token loader
|
||||
# Get-OAuthErrorBody — parse the JSON error body off a failed web request (PS5.1 + PS7)
|
||||
# Get-OAuthAADSTSHint — map common AADSTS error codes to actionable messages
|
||||
# ConvertTo-OAuthBase64Url — bytes → base64url string
|
||||
# ConvertFrom-OAuthBase64Url — base64url string → bytes
|
||||
|
||||
function ConvertTo-OAuthBase64Url
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param([Parameter(Mandatory)][byte[]]$Bytes)
|
||||
$b64 = [Convert]::ToBase64String($Bytes)
|
||||
return $b64.Replace('+', '-').Replace('/', '_').TrimEnd('=')
|
||||
}
|
||||
|
||||
function ConvertFrom-OAuthBase64Url
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([byte[]])]
|
||||
param([Parameter(Mandatory)][string]$String)
|
||||
$s = $String.Replace('-', '+').Replace('_', '/')
|
||||
while($s.Length % 4) { $s += '=' }
|
||||
return [Convert]::FromBase64String($s)
|
||||
}
|
||||
|
||||
# Build and sign a client_assertion JWT for the private_key_jwt flow. Entra
|
||||
# rejects with AADSTS700027 if x5t isn't the SHA-1 thumbprint base64url-encoded
|
||||
# (NOT hex), if nbf/exp aren't unix seconds, or if aud isn't the tenant-specific
|
||||
# token endpoint. Encoding follows RFC 7523 §3.
|
||||
function Get-OAuthClientAssertion
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
|
||||
[Parameter(Mandatory)][string]$ClientId,
|
||||
[Parameter(Mandatory)][string]$Authority,
|
||||
[Parameter(Mandatory)][string]$TenantId
|
||||
)
|
||||
|
||||
if(-not $Certificate.HasPrivateKey) {
|
||||
throw "Certificate '$($Certificate.Subject)' has no associated private key - cannot sign client assertion"
|
||||
}
|
||||
|
||||
# SHA-1 thumbprint of the DER cert, base64url encoded. The x5t header tells
|
||||
# Entra which key in the app's keyCredentials list signed this assertion.
|
||||
$sha1 = [System.Security.Cryptography.SHA1]::Create()
|
||||
try {
|
||||
$thumbBytes = $sha1.ComputeHash($Certificate.RawData)
|
||||
}
|
||||
finally {
|
||||
$sha1.Dispose()
|
||||
}
|
||||
$x5t = ConvertTo-OAuthBase64Url -Bytes $thumbBytes
|
||||
|
||||
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
$exp = $now + 600 # 10-minute lifetime is plenty for one /token round-trip
|
||||
|
||||
$header = [ordered]@{
|
||||
alg = 'RS256'
|
||||
typ = 'JWT'
|
||||
x5t = $x5t
|
||||
}
|
||||
$payload = [ordered]@{
|
||||
aud = "https://$Authority/$TenantId/oauth2/v2.0/token"
|
||||
iss = $ClientId
|
||||
sub = $ClientId
|
||||
jti = [Guid]::NewGuid().ToString()
|
||||
nbf = $now
|
||||
exp = $exp
|
||||
iat = $now
|
||||
}
|
||||
|
||||
$headerJson = $header | ConvertTo-Json -Compress
|
||||
$payloadJson = $payload | ConvertTo-Json -Compress
|
||||
$headerB64 = ConvertTo-OAuthBase64Url -Bytes ([System.Text.Encoding]::UTF8.GetBytes($headerJson))
|
||||
$payloadB64 = ConvertTo-OAuthBase64Url -Bytes ([System.Text.Encoding]::UTF8.GetBytes($payloadJson))
|
||||
$signing = "$headerB64.$payloadB64"
|
||||
|
||||
# Pull the RSA private key. Newer PFX imports expose it via GetRSAPrivateKey;
|
||||
# legacy PrivateKey property still works on older runtimes.
|
||||
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
|
||||
if(-not $rsa) {
|
||||
throw "Could not access RSA private key on certificate '$($Certificate.Subject)' - is the key exportable?"
|
||||
}
|
||||
try {
|
||||
$sigBytes = $rsa.SignData(
|
||||
[System.Text.Encoding]::UTF8.GetBytes($signing),
|
||||
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||
}
|
||||
finally {
|
||||
# GetRSAPrivateKey returns a fresh disposable handle (per CryptoAPI docs)
|
||||
# — disposing here releases the CSP/CNG resource without touching the
|
||||
# certificate itself.
|
||||
try { $rsa.Dispose() } catch { }
|
||||
}
|
||||
|
||||
$sigB64 = ConvertTo-OAuthBase64Url -Bytes $sigBytes
|
||||
return "$signing.$sigB64"
|
||||
}
|
||||
|
||||
# Parse the JSON error body off a failed Invoke-RestMethod call. PS7 surfaces
|
||||
# the body in ErrorDetails.Message (HttpResponseException has no response
|
||||
# stream); PS5.1 needs the classic GetResponseStream read. Returns $null when
|
||||
# there's no parsable JSON body.
|
||||
function Get-OAuthErrorBody
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([PSCustomObject])]
|
||||
param([Parameter(Mandatory)]$ErrorRecord)
|
||||
|
||||
try {
|
||||
if($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
return ($ErrorRecord.ErrorDetails.Message | ConvertFrom-Json)
|
||||
}
|
||||
} catch { }
|
||||
try {
|
||||
$stream = $ErrorRecord.Exception.Response.GetResponseStream()
|
||||
if($stream) {
|
||||
$reader = New-Object IO.StreamReader($stream)
|
||||
return ($reader.ReadToEnd() | ConvertFrom-Json)
|
||||
}
|
||||
} catch { }
|
||||
return $null
|
||||
}
|
||||
|
||||
# Actionable messages for the AADSTS codes automation users hit most. Keyed by
|
||||
# the numeric code Entra puts in error_codes (and in the AADSTSnnnnn prefix of
|
||||
# error_description).
|
||||
$script:OAuthAADSTSHints = @{
|
||||
700016 = "The app registration (client id) was not found in this tenant. Check -AppId and -TenantId."
|
||||
700027 = "Client assertion signature validation failed - the certificate does not match any key on the app registration. Upload the certificate's public key to the app, or check -Certificate / -CertificatePath."
|
||||
7000215 = "Invalid client secret. The secret is wrong or expired - create a new client secret on the app registration."
|
||||
50126 = "Wrong username or password (ROPC). Check the -Credential values."
|
||||
50076 = "Multi-factor authentication is required for this account, which ROPC cannot satisfy. Use -DeviceCode instead."
|
||||
50034 = "The user account was not found in this tenant. Check the username's domain and -TenantId."
|
||||
65001 = "Admin consent is missing for a delegated scope. Grant consent to the application in Entra."
|
||||
50105 = "The signed-in user is not assigned to the application. Assign the user (or a group) to the app in Entra."
|
||||
70011 = "Invalid scope value. The scope must look like 'https://graph.microsoft.com/.default'."
|
||||
}
|
||||
|
||||
# Return the human hint for the first recognized AADSTS code on a parsed Entra
|
||||
# error body (error_codes array preferred, AADSTSnnnnn prefix in
|
||||
# error_description as fallback). $null when nothing matches.
|
||||
function Get-OAuthAADSTSHint
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param($ErrorDetail)
|
||||
|
||||
if(-not $ErrorDetail) { return $null }
|
||||
|
||||
$codes = @()
|
||||
if($ErrorDetail.error_codes) { $codes += @($ErrorDetail.error_codes | ForEach-Object { [int]$_ }) }
|
||||
if($ErrorDetail.error_description) {
|
||||
$m = [regex]::Match([string]$ErrorDetail.error_description, 'AADSTS(\d+)')
|
||||
if($m.Success) { $codes += [int]$m.Groups[1].Value }
|
||||
}
|
||||
|
||||
foreach($code in $codes) {
|
||||
if($script:OAuthAADSTSHints.ContainsKey($code)) {
|
||||
return $script:OAuthAADSTSHints[$code]
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# POST to /oauth2/v2.0/token. Entra returns 4xx with a JSON body that includes
|
||||
# `error`, `error_description`, `error_codes`, `correlation_id` and `timestamp`
|
||||
# — surface those as a single error so the caller's log shows what Entra
|
||||
# actually said instead of just "Bad Request". Known AADSTS codes get an
|
||||
# actionable hint appended (the original Entra wording is always kept).
|
||||
#
|
||||
# -Claims carries a CAE claims challenge from a Graph 401 WWW-Authenticate
|
||||
# header. The header value is base64-encoded JSON; /token wants the decoded
|
||||
# JSON in the `claims` form field. Raw JSON is passed through unchanged.
|
||||
function Invoke-OAuthTokenRequest
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([PSCustomObject])]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Authority,
|
||||
[Parameter(Mandatory)][string]$TenantId,
|
||||
[Parameter(Mandatory)][hashtable]$Body,
|
||||
[string]$Claims
|
||||
)
|
||||
|
||||
if($Claims) {
|
||||
$claimsJson = $Claims
|
||||
if(-not $Claims.TrimStart().StartsWith('{')) {
|
||||
try {
|
||||
$decoded = [System.Text.Encoding]::UTF8.GetString((ConvertFrom-OAuthBase64Url -String $Claims))
|
||||
if($decoded.TrimStart().StartsWith('{')) { $claimsJson = $decoded }
|
||||
} catch { }
|
||||
}
|
||||
$Body = $Body.Clone()
|
||||
$Body['claims'] = $claimsJson
|
||||
}
|
||||
|
||||
$url = "https://$Authority/$TenantId/oauth2/v2.0/token"
|
||||
Write-LogDebug "OAuth /token POST -> $url (grant: $($Body.grant_type)$(if($Claims) { ', with claims challenge' }))"
|
||||
|
||||
try {
|
||||
$resp = Invoke-RestMethod -Method POST -Uri $url -Body $Body `
|
||||
-ContentType 'application/x-www-form-urlencoded' `
|
||||
-ErrorAction Stop
|
||||
return $resp
|
||||
}
|
||||
catch {
|
||||
$detail = Get-OAuthErrorBody -ErrorRecord $_
|
||||
|
||||
if($detail -and $detail.error) {
|
||||
$msg = "Entra rejected token request: $($detail.error)"
|
||||
if($detail.error_description) { $msg += " - $($detail.error_description -replace '\r?\n',' ')" }
|
||||
$hint = Get-OAuthAADSTSHint -ErrorDetail $detail
|
||||
if($hint) { $msg += " Hint: $hint" }
|
||||
if($detail.correlation_id) { $msg += " (correlation: $($detail.correlation_id))" }
|
||||
throw $msg
|
||||
}
|
||||
throw # rethrow original — no JSON body to parse
|
||||
}
|
||||
}
|
||||
|
||||
# Device code grant (RFC 8628). POST /devicecode to get a user_code +
|
||||
# verification URI, show them to the user, then poll /token with the
|
||||
# device_code until the user finishes signing in on their other device.
|
||||
# Returns the /token response (access_token + refresh_token when the scope
|
||||
# includes offline_access). Throws on decline, timeout, or a hard error.
|
||||
function Invoke-OAuthDeviceCodeFlow
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([PSCustomObject])]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Authority,
|
||||
[Parameter(Mandatory)][string]$TenantId,
|
||||
[Parameter(Mandatory)][string]$ClientId,
|
||||
[Parameter(Mandatory)][string]$Scope
|
||||
)
|
||||
|
||||
$dcUrl = "https://$Authority/$TenantId/oauth2/v2.0/devicecode"
|
||||
Write-LogDebug "OAuth /devicecode POST -> $dcUrl"
|
||||
try {
|
||||
$dc = Invoke-RestMethod -Method POST -Uri $dcUrl `
|
||||
-Body @{ client_id = $ClientId; scope = $Scope } `
|
||||
-ContentType 'application/x-www-form-urlencoded' `
|
||||
-ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$detail = Get-OAuthErrorBody -ErrorRecord $_
|
||||
if($detail -and $detail.error) {
|
||||
$msg = "Entra rejected device code request: $($detail.error)"
|
||||
if($detail.error_description) { $msg += " - $($detail.error_description -replace '\r?\n',' ')" }
|
||||
$hint = Get-OAuthAADSTSHint -ErrorDetail $detail
|
||||
if($hint) { $msg += " Hint: $hint" }
|
||||
throw $msg
|
||||
}
|
||||
throw
|
||||
}
|
||||
|
||||
if(-not $dc.device_code) { throw "Device code request returned no device_code" }
|
||||
|
||||
# Entra's message field is the canonical user instruction ("To sign in, use
|
||||
# a web browser to open ... and enter the code ..."). Write it both to the
|
||||
# console (the user has to act on it NOW) and the log.
|
||||
$instruction = if($dc.message) { [string]$dc.message }
|
||||
else { "To sign in, open $($dc.verification_uri) in a browser and enter the code $($dc.user_code)" }
|
||||
Write-Log "OAuth device code: $instruction"
|
||||
Write-Host $instruction -ForegroundColor Yellow
|
||||
|
||||
$interval = if($dc.interval) { [int]$dc.interval } else { 5 }
|
||||
$lifetime = if($dc.expires_in) { [int]$dc.expires_in } else { 900 }
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($lifetime)
|
||||
$tokenUrl = "https://$Authority/$TenantId/oauth2/v2.0/token"
|
||||
|
||||
# This loop has nothing cancellable to stop - it is a synchronous poll - so the
|
||||
# action is a no-op and cancelling just breaks the loop. Passing -OnCancel still
|
||||
# matters: it resets a cancel request left over from an earlier operation.
|
||||
# This wait can run for the full code lifetime (15 min by default).
|
||||
Write-Status "Waiting for sign-in" $instruction -CancelText "Cancel" -OnCancel { }
|
||||
try {
|
||||
while([DateTime]::UtcNow -lt $deadline) {
|
||||
# Pumped, cancellable wait - a bare Start-Sleep here would freeze the UI for
|
||||
# the whole interval and the Cancel button would never get its click.
|
||||
if(Wait-StatusCancel -Seconds $interval) {
|
||||
# OperationCanceledException, like the browser flow: user cancellation is
|
||||
# not a failure and callers must be able to tell the two apart.
|
||||
throw [System.OperationCanceledException]::new("Device code sign-in was cancelled")
|
||||
}
|
||||
try {
|
||||
return Invoke-RestMethod -Method POST -Uri $tokenUrl `
|
||||
-Body @{
|
||||
grant_type = 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
client_id = $ClientId
|
||||
device_code = $dc.device_code
|
||||
} `
|
||||
-ContentType 'application/x-www-form-urlencoded' `
|
||||
-ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$detail = Get-OAuthErrorBody -ErrorRecord $_
|
||||
$errCode = if($detail) { [string]$detail.error } else { $null }
|
||||
switch($errCode) {
|
||||
'authorization_pending' { } # user hasn't finished yet — keep polling
|
||||
'slow_down' { $interval += 5 } # RFC 8628 §3.5
|
||||
'authorization_declined' { throw "Device code sign-in was declined by the user" }
|
||||
'expired_token' { throw "Device code expired before sign-in completed - run Connect-IntuneManagement -DeviceCode again" }
|
||||
default {
|
||||
if($detail -and $detail.error) {
|
||||
$msg = "Device code polling failed: $($detail.error)"
|
||||
if($detail.error_description) { $msg += " - $($detail.error_description -replace '\r?\n',' ')" }
|
||||
throw $msg
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw "Device code expired before sign-in completed - run Connect-IntuneManagement -DeviceCode again"
|
||||
}
|
||||
finally {
|
||||
# Clear the overlay and disarm on every exit - success, expiry, decline or
|
||||
# cancel - so the Cancel button never outlives this wait.
|
||||
Write-Status ""
|
||||
}
|
||||
}
|
||||
|
||||
# Pick an OS-assigned free TCP port on the loopback interface for the redirect
|
||||
# listener (bind port 0, read what the OS handed out, release it). There is a tiny
|
||||
# race between release and HttpListener.Start(), but on loopback it is negligible.
|
||||
function Get-OAuthFreePort
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([int])]
|
||||
param()
|
||||
$l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
try { $l.Start(); return [int]$l.LocalEndpoint.Port } finally { $l.Stop() }
|
||||
}
|
||||
|
||||
# Authorization Code + PKCE browser login (native-app pattern, .NET BCL only - no
|
||||
# MSAL). Opens the system default browser to /authorize, captures the redirect on a
|
||||
# loopback HttpListener, then exchanges the code at /token. Returns the SAME
|
||||
# /token response shape as Invoke-OAuthDeviceCodeFlow ({access_token, refresh_token,
|
||||
# expires_in, ...}) so AuthenticationOAuth.Connect handles both identically.
|
||||
#
|
||||
# The redirect wait polls GetContextAsync and pumps the UI (Invoke-UIPump) so the
|
||||
# window stays responsive, with a wall-clock timeout - same model as the MSAL
|
||||
# interactive poll (Get-MsalAuthenticationToken). Throws on timeout / browser-launch
|
||||
# failure / OAuth error so the caller can fall back to device code when headless.
|
||||
function Invoke-OAuthAuthCodeFlow
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([PSCustomObject])]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Authority,
|
||||
[Parameter(Mandatory)][string]$TenantId,
|
||||
[Parameter(Mandatory)][string]$ClientId,
|
||||
[Parameter(Mandatory)][string]$Scope,
|
||||
[int]$RedirectPort = 0,
|
||||
[string]$Prompt,
|
||||
[string]$LoginHint,
|
||||
[int]$TimeoutSec = 180
|
||||
)
|
||||
|
||||
# --- PKCE (RFC 7636, S256) ---
|
||||
$verifierBytes = New-Object byte[] 32
|
||||
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try { $rng.GetBytes($verifierBytes) } finally { $rng.Dispose() }
|
||||
$verifier = ConvertTo-OAuthBase64Url -Bytes $verifierBytes
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try { $challengeBytes = $sha.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($verifier)) } finally { $sha.Dispose() }
|
||||
$challenge = ConvertTo-OAuthBase64Url -Bytes $challengeBytes
|
||||
|
||||
# --- state (CSRF) ---
|
||||
$stateBytes = New-Object byte[] 16
|
||||
$rng2 = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try { $rng2.GetBytes($stateBytes) } finally { $rng2.Dispose() }
|
||||
$state = ConvertTo-OAuthBase64Url -Bytes $stateBytes
|
||||
|
||||
$port = if($RedirectPort -gt 0) { $RedirectPort } else { Get-OAuthFreePort }
|
||||
$redirectUri = "http://localhost:$port/"
|
||||
|
||||
$listener = [System.Net.HttpListener]::new()
|
||||
$listener.Prefixes.Add($redirectUri)
|
||||
try {
|
||||
$listener.Start()
|
||||
}
|
||||
catch {
|
||||
throw "Could not start the loopback redirect listener on $redirectUri : $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
try {
|
||||
# --- build /authorize URL ---
|
||||
$q = [System.Collections.Generic.List[string]]::new()
|
||||
$q.Add("client_id=" + [System.Uri]::EscapeDataString($ClientId))
|
||||
$q.Add("response_type=code")
|
||||
$q.Add("redirect_uri=" + [System.Uri]::EscapeDataString($redirectUri))
|
||||
$q.Add("response_mode=query")
|
||||
$q.Add("scope=" + [System.Uri]::EscapeDataString($Scope))
|
||||
$q.Add("code_challenge=" + [System.Uri]::EscapeDataString($challenge))
|
||||
$q.Add("code_challenge_method=S256")
|
||||
$q.Add("state=" + [System.Uri]::EscapeDataString($state))
|
||||
if($Prompt) { $q.Add("prompt=" + [System.Uri]::EscapeDataString($Prompt)) }
|
||||
if($LoginHint) { $q.Add("login_hint=" + [System.Uri]::EscapeDataString($LoginHint)) }
|
||||
$authorizeUrl = "https://$Authority/$TenantId/oauth2/v2.0/authorize?" + ($q -join '&')
|
||||
|
||||
Write-LogDebug "OAuth /authorize -> redirect_uri=$redirectUri prompt=$Prompt hint=$LoginHint"
|
||||
|
||||
# --- launch the default browser ---
|
||||
try {
|
||||
Start-Process $authorizeUrl -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
throw "Could not open the default browser for sign-in: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# HttpListener.GetContextAsync() takes no CancellationToken, so cancelling
|
||||
# means stopping the listener out from under it - the task then faults and
|
||||
# the loop below exits on the request flag rather than on IsCompleted.
|
||||
# The status click runs later, outside this statement's local scope.
|
||||
# Preserve the listener explicitly so cancellation always stops the
|
||||
# GetContextAsync wait rather than merely setting the polled flag.
|
||||
$cancelListener = $listener
|
||||
Set-StatusCancelAction ({ try { $cancelListener.Stop() } catch { } }.GetNewClosure())
|
||||
Write-Status "Waiting for browser sign-in..." -CancelText "Cancel"
|
||||
|
||||
# --- wait for the redirect (responsive poll + timeout) ---
|
||||
$ctxTask = $listener.GetContextAsync()
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSec)
|
||||
while(-not $ctxTask.IsCompleted) {
|
||||
if(Test-StatusCancelRequested) {
|
||||
throw [System.OperationCanceledException]::new("Browser sign-in was cancelled")
|
||||
}
|
||||
if([DateTime]::UtcNow -gt $deadline) {
|
||||
throw "Browser sign-in timed out after $TimeoutSec s"
|
||||
}
|
||||
Invoke-UIPump
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
# Cancel also completes the task - stopping the listener faults it - so the
|
||||
# loop above can exit without ever seeing the flag when the click lands
|
||||
# during a sleep slice. Re-check before touching the result, or a cancelled
|
||||
# sign-in would surface as an HttpListener error instead, and the caller
|
||||
# would treat it as "browser broken" and fall back to device code.
|
||||
if(Test-StatusCancelRequested) {
|
||||
throw [System.OperationCanceledException]::new("Browser sign-in was cancelled")
|
||||
}
|
||||
$context = $ctxTask.GetAwaiter().GetResult()
|
||||
$req = $context.Request
|
||||
|
||||
# Respond so the browser tab shows a friendly message, then release.
|
||||
$html = "<html><head><title>Sign-in complete</title></head><body style='font-family:Segoe UI,sans-serif;padding:2em'><h3>Sign-in complete</h3><p>You can close this tab and return to IntuneManagement.</p></body></html>"
|
||||
try {
|
||||
$buf = [System.Text.Encoding]::UTF8.GetBytes($html)
|
||||
$context.Response.ContentType = "text/html; charset=utf-8"
|
||||
$context.Response.ContentLength64 = $buf.Length
|
||||
$context.Response.OutputStream.Write($buf, 0, $buf.Length)
|
||||
$context.Response.OutputStream.Close()
|
||||
}
|
||||
catch { }
|
||||
|
||||
# HttpListenerRequest.QueryString is a populated NameValueCollection (no
|
||||
# System.Web dependency needed).
|
||||
$qs = $req.QueryString
|
||||
$returnState = [string]$qs['state']
|
||||
$errCode = [string]$qs['error']
|
||||
$code = [string]$qs['code']
|
||||
|
||||
if($errCode) {
|
||||
$errDesc = [string]$qs['error_description']
|
||||
throw "Browser sign-in failed: $errCode$(if($errDesc) { " - $($errDesc -replace '\r?\n',' ')" })"
|
||||
}
|
||||
if($returnState -ne $state) {
|
||||
throw "Browser sign-in state mismatch - possible CSRF; aborting."
|
||||
}
|
||||
if(-not $code) {
|
||||
throw "Browser sign-in returned no authorization code."
|
||||
}
|
||||
|
||||
# --- exchange the code for tokens ---
|
||||
return Invoke-OAuthTokenRequest -Authority $Authority -TenantId $TenantId -Body @{
|
||||
grant_type = 'authorization_code'
|
||||
client_id = $ClientId
|
||||
code = $code
|
||||
redirect_uri = $redirectUri
|
||||
code_verifier = $verifier
|
||||
scope = $Scope
|
||||
}
|
||||
}
|
||||
finally {
|
||||
# Disarm first: a click arriving after this point must not reach a listener
|
||||
# that is about to be closed.
|
||||
Clear-StatusCancelAction
|
||||
try { $listener.Stop(); $listener.Close() } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# Managed identity acquisition. Detects the host environment in this order:
|
||||
#
|
||||
# 1. App Service / Functions / Container Apps
|
||||
# — IDENTITY_ENDPOINT + IDENTITY_HEADER env vars set; api-version=2019-08-01
|
||||
# 2. Azure Arc-enabled servers
|
||||
# — IDENTITY_ENDPOINT + IMDS_ENDPOINT env vars (challenge-response, not yet
|
||||
# supported here; treat as App Service variant if header is set)
|
||||
# 3. Azure VM / VMSS / AKS host node
|
||||
# — IMDS at 169.254.169.254 with Metadata: true; api-version=2018-02-01
|
||||
#
|
||||
# AKS pods that opt into Workload Identity Federation should NOT use this path —
|
||||
# they should pass the projected token via Get-OAuthFederatedAssertion instead.
|
||||
function Invoke-OAuthIMDS
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([PSCustomObject])]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Resource,
|
||||
[string]$ClientId
|
||||
)
|
||||
|
||||
$appSvcEndpoint = $env:IDENTITY_ENDPOINT
|
||||
$appSvcHeader = $env:IDENTITY_HEADER
|
||||
|
||||
if($appSvcEndpoint -and $appSvcHeader) {
|
||||
# App Service / Functions / Container Apps flow.
|
||||
$params = @{
|
||||
'api-version' = '2019-08-01'
|
||||
'resource' = $Resource
|
||||
}
|
||||
if($ClientId) { $params['client_id'] = $ClientId }
|
||||
$query = ($params.GetEnumerator() | ForEach-Object { "$([uri]::EscapeDataString($_.Key))=$([uri]::EscapeDataString($_.Value))" }) -join '&'
|
||||
$url = "$appSvcEndpoint`?$query"
|
||||
Write-LogDebug "OAuth IMDS (App Service) -> $url"
|
||||
return Invoke-RestMethod -Method GET -Uri $url `
|
||||
-Headers @{ 'X-IDENTITY-HEADER' = $appSvcHeader } `
|
||||
-ErrorAction Stop
|
||||
}
|
||||
|
||||
# VM / VMSS / AKS host-node flow.
|
||||
$params = @{
|
||||
'api-version' = '2018-02-01'
|
||||
'resource' = $Resource
|
||||
}
|
||||
if($ClientId) { $params['client_id'] = $ClientId }
|
||||
$query = ($params.GetEnumerator() | ForEach-Object { "$([uri]::EscapeDataString($_.Key))=$([uri]::EscapeDataString($_.Value))" }) -join '&'
|
||||
$url = "http://169.254.169.254/metadata/identity/oauth2/token`?$query"
|
||||
Write-LogDebug "OAuth IMDS (VM) -> $url"
|
||||
return Invoke-RestMethod -Method GET -Uri $url `
|
||||
-Headers @{ Metadata = 'true' } `
|
||||
-TimeoutSec 5 `
|
||||
-ErrorAction Stop
|
||||
}
|
||||
|
||||
# Workload identity federation — the assertion is already a signed JWT minted
|
||||
# by the workload's identity provider (AKS service-account token, GitHub
|
||||
# Actions OIDC token, Azure DevOps OIDC token, etc.). We don't sign or
|
||||
# validate it; we just hand it to Entra as the client_assertion.
|
||||
function Get-OAuthFederatedAssertion
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[string]$FederatedToken,
|
||||
[string]$FederatedTokenFile
|
||||
)
|
||||
|
||||
if($FederatedToken) { return $FederatedToken.Trim() }
|
||||
if(-not $FederatedTokenFile) {
|
||||
throw "Federated assertion required but neither -FederatedToken nor -FederatedTokenFile was supplied"
|
||||
}
|
||||
if(-not (Test-Path $FederatedTokenFile)) {
|
||||
throw "Federated token file not found: $FederatedTokenFile"
|
||||
}
|
||||
$tok = ([IO.File]::ReadAllText($FederatedTokenFile)).Trim()
|
||||
if(-not $tok) {
|
||||
throw "Federated token file '$FederatedTokenFile' is empty"
|
||||
}
|
||||
if(-not $tok.StartsWith('eyJ')) {
|
||||
# Not strictly required to be a JWT but every real federated token IS one;
|
||||
# warn loudly if it isn't, so a misconfigured file fails fast rather than
|
||||
# surfacing as AADSTS50027 from the wire.
|
||||
Write-Log "Federated token from '$FederatedTokenFile' does not look like a JWT (missing 'eyJ' prefix) - passing through anyway" 2
|
||||
}
|
||||
return $tok
|
||||
}
|
||||
|
||||
# Resolve the tenant's display name via /organization with a raw bearer token.
|
||||
# Returns $null on any failure (e.g. app-only token without
|
||||
# Organization.Read.All) — the caller caches the result either way so a failed
|
||||
# lookup isn't retried.
|
||||
function Get-OAuthTenantOrganizationName
|
||||
{
|
||||
[CmdletBinding()]
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Resource,
|
||||
[Parameter(Mandatory)][string]$AccessToken
|
||||
)
|
||||
|
||||
try {
|
||||
$org = Invoke-RestMethod -Method GET -Uri "$Resource/v1.0/organization?`$select=displayName" `
|
||||
-Headers @{ Authorization = "Bearer $AccessToken" } -ErrorAction Stop
|
||||
if($org -and $org.value -and @($org.value).Count -gt 0 -and $org.value[0].displayName) {
|
||||
return [string]$org.value[0].displayName
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "OAuth: /organization lookup failed ($($_.Exception.Message)); tenant display name will be unknown"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# ===================== OAuth token cache (DPAPI, opt-in) =====================
|
||||
#
|
||||
# Optional cross-restart persistence for the browser/auth-code refresh token,
|
||||
# gated by the OAuthCacheToken ("Remember login") setting. DPAPI (ProtectedData,
|
||||
# CurrentUser scope) - same protection the MSAL cache uses - written to
|
||||
# %LOCALAPPDATA%\IntuneManagement\oauthcache.bin. Only the refresh token + minimal
|
||||
# metadata is stored; never an access token. Windows-only; degrades to no-op
|
||||
# (in-memory session only) elsewhere or if the DPAPI type can't be loaded.
|
||||
|
||||
# App-specific entropy so the blob isn't decryptable by other apps' CurrentUser DPAPI.
|
||||
$script:_oauthCacheEntropy = [System.Text.Encoding]::UTF8.GetBytes("IntuneManagement.OAuth.TokenCache.v1")
|
||||
|
||||
function Get-OAuthTokenCachePath {
|
||||
$folder = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) "IntuneManagement"
|
||||
return (Join-Path $folder "oauthcache.bin")
|
||||
}
|
||||
|
||||
# Resolve System.Security.Cryptography.ProtectedData across PS 5.1 (System.Security)
|
||||
# and PS7 (separate assembly, may need loading from $PSHOME). Returns the type or
|
||||
# $null (persistence then silently disabled).
|
||||
function Get-OAuthProtectedDataType {
|
||||
$t = 'System.Security.Cryptography.ProtectedData' -as [type]
|
||||
if($t) { return $t }
|
||||
if(-not $script:IsWindowsOS) { return $null }
|
||||
foreach($asm in 'System.Security','System.Security.Cryptography.ProtectedData') {
|
||||
try { Add-Type -AssemblyName $asm -ErrorAction Stop } catch { }
|
||||
$t = 'System.Security.Cryptography.ProtectedData' -as [type]
|
||||
if($t) { return $t }
|
||||
}
|
||||
try {
|
||||
$dll = Join-Path $PSHOME 'System.Security.Cryptography.ProtectedData.dll'
|
||||
if(Test-Path -LiteralPath $dll) { Add-Type -Path $dll -ErrorAction Stop }
|
||||
} catch { }
|
||||
return ('System.Security.Cryptography.ProtectedData' -as [type])
|
||||
}
|
||||
|
||||
# Persist the minimal refresh-token state. $Data is a hashtable
|
||||
# (RefreshToken/ClientId/TenantId/Cloud/Authority/Resource/AuthMethod).
|
||||
function Save-OAuthTokenCache {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][hashtable]$Data)
|
||||
$pd = Get-OAuthProtectedDataType
|
||||
if(-not $pd) { Write-LogDebug "OAuth cache: DPAPI unavailable - not persisting"; return $false }
|
||||
try {
|
||||
$path = Get-OAuthTokenCachePath
|
||||
$folder = Split-Path -Parent $path
|
||||
if(-not (Test-Path -LiteralPath $folder)) { [void][IO.Directory]::CreateDirectory($folder) }
|
||||
$json = ($Data | ConvertTo-Json -Depth 5 -Compress)
|
||||
$plain = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||||
$scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
||||
$prot = $pd::Protect($plain, $script:_oauthCacheEntropy, $scope)
|
||||
[IO.File]::WriteAllBytes($path, $prot)
|
||||
Write-LogDebug "OAuth cache: saved refresh-token state to $path"
|
||||
return $true
|
||||
}
|
||||
catch { Write-LogError "OAuth cache: failed to save token cache" $_.Exception; return $false }
|
||||
}
|
||||
|
||||
# Returns the persisted hashtable, or $null when absent/unreadable.
|
||||
function Read-OAuthTokenCache {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$path = Get-OAuthTokenCachePath
|
||||
if(-not (Test-Path -LiteralPath $path)) { return $null }
|
||||
$pd = Get-OAuthProtectedDataType
|
||||
if(-not $pd) { return $null }
|
||||
try {
|
||||
$prot = [IO.File]::ReadAllBytes($path)
|
||||
$scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
||||
$plain = $pd::Unprotect($prot, $script:_oauthCacheEntropy, $scope)
|
||||
$json = [System.Text.Encoding]::UTF8.GetString($plain)
|
||||
$obj = $json | ConvertFrom-Json
|
||||
$ht = @{}
|
||||
foreach($p in $obj.PSObject.Properties) { $ht[$p.Name] = $p.Value }
|
||||
return $ht
|
||||
}
|
||||
catch { Write-LogError "OAuth cache: failed to read token cache (removing it)" $_.Exception; Clear-OAuthTokenCache; return $null }
|
||||
}
|
||||
|
||||
function Clear-OAuthTokenCache {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
try {
|
||||
$path = Get-OAuthTokenCachePath
|
||||
if(Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force -ErrorAction Stop; Write-LogDebug "OAuth cache: cleared $path" }
|
||||
}
|
||||
catch { Write-LogError "OAuth cache: failed to clear token cache" $_.Exception }
|
||||
}
|
||||
|
||||
# OAuth provider has zero dependencies (no DLL, no SDK module) so it always
|
||||
# registers — unlike MgGraph which has to defer until the SDK module is present.
|
||||
function Invoke-OAuthProviderInitialize {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
if(-not (Get-Command -Name Register-AuthProvider -ErrorAction SilentlyContinue)) {
|
||||
Write-LogDebug "OAuth provider: AuthenticationCore not available, skipping registration"
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Register-AuthProvider -Provider ([AuthenticationOAuth]::new())
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to register AuthenticationOAuth provider" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-OAuthProviderInitialize
|
||||
@@ -0,0 +1,343 @@
|
||||
# Internal helpers for Set-GraphBulkAssignments (Public/Set-GraphBulkAssignments.ps1).
|
||||
# Extracted here per architecture rule R11 (keep public driver files thin). These
|
||||
# are module-internal (not exported) but several are also called by the bulk-
|
||||
# assignment UI (Test-BulkAssignmentSupported, Get-BulkAssignmentObjectType, ...)
|
||||
# and the online tests - all run in module scope, so name resolution is unchanged.
|
||||
|
||||
# App @odata.types Graph refuses an assignment filter for. Set-GraphBulkAssignments
|
||||
# assigns these without the filter rather than failing the policy. Extend as
|
||||
# further types are verified live; do not guess.
|
||||
$script:BulkAssignmentAppTypesWithoutFilters = @(
|
||||
'#microsoft.graph.webApp'
|
||||
)
|
||||
|
||||
# Stable string key for an assignment — used for set-membership (dedupe,
|
||||
# no-op detection, removal matching). Encodes the target discriminator +
|
||||
# the fields we expose in the UI; Intent is appended for app-shape
|
||||
# assignments so "Required to Group X" and "Available to Group X" are
|
||||
# distinct entries.
|
||||
function Get-AssignmentSignature
|
||||
{
|
||||
param($Target, [string]$Intent)
|
||||
|
||||
if(-not $Target) { return "" }
|
||||
|
||||
$type = [string]$Target.'@odata.type'
|
||||
# Strip the leading '#microsoft.graph.' if present so signatures compare
|
||||
# equal regardless of whether Graph returned the prefix.
|
||||
$type = $type -replace '^#?microsoft\.graph\.', ''
|
||||
|
||||
$groupId = [string]$Target.groupId
|
||||
$filterId = [string]$Target.deviceAndAppManagementAssignmentFilterId
|
||||
$filterType = [string]$Target.deviceAndAppManagementAssignmentFilterType
|
||||
# "none" and "" are equivalent — Intune sometimes writes one, sometimes
|
||||
# the other. Normalise so signatures don't drift between Add runs. The all-zeros
|
||||
# sentinel is the third spelling of the same thing (Test-AssignmentFilterDefined):
|
||||
# without it, one assignment carrying the sentinel and an identical one carrying no
|
||||
# filter property hash differently, so an already-assigned target looks new.
|
||||
if($filterType -eq "none" -or -not (Test-AssignmentFilterDefined $filterId)) {
|
||||
$filterType = ""
|
||||
$filterId = ""
|
||||
}
|
||||
|
||||
$intentPart = if([string]::IsNullOrEmpty($Intent)) { "" } else { [string]$Intent }
|
||||
return "$type|$groupId|$filterId|$filterType|$intentPart".ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-AssignmentFullSignature
|
||||
{
|
||||
param($Tuple)
|
||||
|
||||
if(-not $Tuple) { return "" }
|
||||
|
||||
$runRemediationPart = ""
|
||||
if($null -ne $Tuple.RunRemediation) { $runRemediationPart = [string][bool]$Tuple.RunRemediation }
|
||||
|
||||
$parts = @(
|
||||
(Get-AssignmentSignature $Tuple.Target $Tuple.Intent),
|
||||
(ConvertTo-StableAssignmentJson $Tuple.Settings),
|
||||
(ConvertTo-StableAssignmentJson $Tuple.RunSchedule),
|
||||
$runRemediationPart
|
||||
)
|
||||
return ($parts -join "|").ToLowerInvariant()
|
||||
}
|
||||
|
||||
function ConvertTo-StableAssignmentJson
|
||||
{
|
||||
param($Value)
|
||||
|
||||
if($null -eq $Value) { return "" }
|
||||
return (ConvertTo-StableAssignmentObject $Value | ConvertTo-Json -Depth 50 -Compress)
|
||||
}
|
||||
|
||||
function ConvertTo-StableAssignmentObject
|
||||
{
|
||||
param($Value)
|
||||
|
||||
if($null -eq $Value) { return $null }
|
||||
if($Value -is [string]) { return $Value }
|
||||
if($Value -is [System.Collections.IDictionary]) {
|
||||
$ordered = [ordered]@{}
|
||||
foreach($key in @($Value.Keys | Sort-Object)) {
|
||||
$ordered[[string]$key] = ConvertTo-StableAssignmentObject $Value[$key]
|
||||
}
|
||||
return [PSCustomObject]$ordered
|
||||
}
|
||||
if($Value -is [System.Collections.IEnumerable] -and $Value -isnot [PSCustomObject]) {
|
||||
$items = [System.Collections.Generic.List[object]]::new()
|
||||
foreach($item in $Value) {
|
||||
[void]$items.Add((ConvertTo-StableAssignmentObject $item))
|
||||
}
|
||||
return $items.ToArray()
|
||||
}
|
||||
if($Value -is [PSCustomObject]) {
|
||||
$ordered = [ordered]@{}
|
||||
foreach($prop in @($Value.PSObject.Properties | Sort-Object Name)) {
|
||||
$ordered[$prop.Name] = ConvertTo-StableAssignmentObject $prop.Value
|
||||
}
|
||||
return [PSCustomObject]$ordered
|
||||
}
|
||||
return $Value
|
||||
}
|
||||
|
||||
# Project a raw assignment target (from Graph response) down to just the
|
||||
# fields the bulk tool supports. Keeps the result POST-safe.
|
||||
function ConvertTo-AssignmentTarget
|
||||
{
|
||||
param($Target)
|
||||
|
||||
$out = [ordered]@{ "@odata.type" = [string]$Target.'@odata.type' }
|
||||
if($Target.groupId) { $out.groupId = [string]$Target.groupId }
|
||||
if($Target.deviceAndAppManagementAssignmentFilterId) {
|
||||
$out.deviceAndAppManagementAssignmentFilterId = [string]$Target.deviceAndAppManagementAssignmentFilterId
|
||||
$out.deviceAndAppManagementAssignmentFilterType = [string]$Target.deviceAndAppManagementAssignmentFilterType
|
||||
}
|
||||
return [PSCustomObject]$out
|
||||
}
|
||||
|
||||
# Build a fresh Graph target object from a UI-supplied assignment descriptor
|
||||
# (the PSCustomObject the UI puts into AssignmentSettings.Assignments).
|
||||
function Build-AssignmentTarget
|
||||
{
|
||||
param($Descriptor)
|
||||
|
||||
$obj = [ordered]@{
|
||||
"@odata.type" = "#microsoft.graph.$($Descriptor.TargetType)"
|
||||
}
|
||||
if($Descriptor.GroupId) { $obj.groupId = [string]$Descriptor.GroupId }
|
||||
if($Descriptor.FilterId) {
|
||||
$obj.deviceAndAppManagementAssignmentFilterId = [string]$Descriptor.FilterId
|
||||
$obj.deviceAndAppManagementAssignmentFilterType = if($Descriptor.FilterType) { [string]$Descriptor.FilterType } else { "include" }
|
||||
}
|
||||
return [PSCustomObject]$obj
|
||||
}
|
||||
|
||||
# Bulk tool supports three assignment shapes:
|
||||
# "simple" — `{target}` only. Most types.
|
||||
# "app" — `{target, intent, settings?}`. mobileAppAssignments.
|
||||
# "script" — `{target, runSchedule?, runRemediationScript?}`. Health
|
||||
# scripts (deviceHealthScriptAssignments).
|
||||
function Get-BulkAssignmentShape
|
||||
{
|
||||
param($PolicyType)
|
||||
|
||||
if(-not $PolicyType) { return $null }
|
||||
switch ([string]$PolicyType.AssignmentsType) {
|
||||
"mobileAppAssignments" { return "app" }
|
||||
"deviceHealthScriptAssignments" { return "script" }
|
||||
}
|
||||
if(-not $PolicyType.AssignmentPropertiesToKeep) { return "simple" }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-BulkAssignmentSupported
|
||||
{
|
||||
param($PolicyType)
|
||||
|
||||
if(-not $PolicyType) { return $false }
|
||||
if(-not $PolicyType.SupportsAssignments) { return $false }
|
||||
if(-not $PolicyType.AssignmentsType) { return $false }
|
||||
|
||||
# These types either do not expose a policy assignment action or use a
|
||||
# custom assignment flow that is not the replace-all /assign contract.
|
||||
# AppProtection uses the polymorphic /managedAppPolicies endpoint for
|
||||
# list/read, but /assign is only bound to the concrete platform subtypes
|
||||
# (iosManagedAppProtections, androidManagedAppProtections, windowsManagedAppProtections,
|
||||
# mdmWindowsInformationProtectionPolicies). Calling /managedAppPolicies/{id}/assign
|
||||
# is unsupported.
|
||||
# AppleEnrollmentTypes has an `assignments` navigation property but no
|
||||
# `/assign` action — assignments are managed by POST/DELETE on the nested
|
||||
# /assignments collection, not the replace-all contract.
|
||||
if($PolicyType.Id -in @(
|
||||
"AppleEnrollmentTypes",
|
||||
"AppProtection",
|
||||
"Autopilot",
|
||||
"IntuneBranding",
|
||||
"Notifications",
|
||||
"ScopeTags",
|
||||
"TermsAndConditions"
|
||||
)) { return $false }
|
||||
|
||||
return ($null -ne (Get-BulkAssignmentShape $PolicyType))
|
||||
}
|
||||
|
||||
function Get-BulkAssignmentObjectType
|
||||
{
|
||||
param($PolicyType)
|
||||
|
||||
if(-not $PolicyType) { return $null }
|
||||
|
||||
# Per-type override on the PolicyType class takes precedence over the
|
||||
# built-in heuristic below. Lets new PolicyTypes declare their assignment
|
||||
# @odata.type next to the type definition instead of editing this central
|
||||
# switch — see _AssignmentObjectType on IntunePolicyTypeBase.
|
||||
$override = [string]$PolicyType.AssignmentObjectType
|
||||
if($override) { return $override }
|
||||
|
||||
switch ([string]$PolicyType.AssignmentsType) {
|
||||
"deviceHealthScriptAssignments" { return "#microsoft.graph.deviceHealthScriptAssignment" }
|
||||
"deviceManagementScriptAssignments" { return "#microsoft.graph.deviceManagementScriptAssignment" }
|
||||
"enrollmentConfigurationAssignments"{ return "#microsoft.graph.enrollmentConfigurationAssignment" }
|
||||
"mobileAppAssignments" { return "#microsoft.graph.mobileAppAssignment" }
|
||||
"hardwareConfigurationAssignments" { return "#microsoft.graph.hardwareConfigurationAssignment" }
|
||||
}
|
||||
|
||||
switch ([string]$PolicyType.Id) {
|
||||
"EndpointSecurity" { return "#microsoft.graph.deviceManagementIntentAssignment" }
|
||||
"EnrollmentSettingsCatalog" { return "#microsoft.graph.deviceManagementConfigurationPolicyAssignment" }
|
||||
"DeviceConfiguration" { return "#microsoft.graph.deviceConfigurationAssignment" }
|
||||
"CompliancePolicies" { return "#microsoft.graph.deviceCompliancePolicyAssignment" }
|
||||
"CompliancePoliciesV2" { return "#microsoft.graph.deviceManagementConfigurationPolicyAssignment" }
|
||||
"SettingsCatalog" { return "#microsoft.graph.deviceManagementConfigurationPolicyAssignment" }
|
||||
"EndpointSecuritySettingsCatalog" { return "#microsoft.graph.deviceManagementConfigurationPolicyAssignment" }
|
||||
"DeviceConfigurationScripts" { return "#microsoft.graph.deviceManagementConfigurationPolicyAssignment" }
|
||||
"InventoryPolicies" { return "#microsoft.graph.deviceManagementConfigurationPolicyAssignment" }
|
||||
"AppConfigurationManagedDevice" { return "#microsoft.graph.managedDeviceMobileAppConfigurationAssignment" }
|
||||
"AppConfigurationManagedApp" { return "#microsoft.graph.targetedManagedAppPolicyAssignment" }
|
||||
"AndroidOEMConfig" { return "#microsoft.graph.managedDeviceMobileAppConfigurationAssignment" }
|
||||
"Applications" { return "#microsoft.graph.mobileAppAssignment" }
|
||||
"IosLobAppProvisioningConfigurations" { return "#microsoft.graph.iosLobAppProvisioningConfigurationAssignment" }
|
||||
"PolicySets" { return "#microsoft.graph.policySetAssignment" }
|
||||
"UpdatePolicies" { return "#microsoft.graph.deviceConfigurationAssignment" }
|
||||
"iOSiPadOSUpdatePolicies" { return "#microsoft.graph.deviceConfigurationAssignment" }
|
||||
"macOSUpdatePolicies" { return "#microsoft.graph.deviceConfigurationAssignment" }
|
||||
"FeatureUpdates" { return "#microsoft.graph.windowsFeatureUpdateProfileAssignment" }
|
||||
"QualityUpdates" { return "#microsoft.graph.windowsQualityUpdateProfileAssignment" }
|
||||
"QualityUpdatePolicies" { return "#microsoft.graph.windowsQualityUpdatePolicyAssignment" }
|
||||
"DriverUpdateProfiles" { return "#microsoft.graph.windowsDriverUpdateProfileAssignment" }
|
||||
"AdministrativeTemplates" { return "#microsoft.graph.groupPolicyConfigurationAssignment" }
|
||||
"W365ProvisioningPolicies" { return "#microsoft.graph.cloudPcProvisioningPolicyAssignment" }
|
||||
"W365UserSettings" { return "#microsoft.graph.cloudPcUserSettingAssignment" }
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Map a policy's @odata.type (e.g. "#microsoft.graph.win32LobApp") to the
|
||||
# matching mobileAppAssignmentSettings derived type name (e.g.
|
||||
# "win32LobAppAssignmentSettings"). The Graph naming convention is
|
||||
# `<appType>AssignmentSettings`, so a strip-and-append is enough. Returns
|
||||
# $null when the input doesn't look like a known app type.
|
||||
function Get-AppSettingsTypeForPolicy
|
||||
{
|
||||
param([string]$PolicyOdataType)
|
||||
|
||||
if([string]::IsNullOrEmpty($PolicyOdataType)) { return $null }
|
||||
$name = $PolicyOdataType -replace '^#?microsoft\.graph\.', ''
|
||||
if([string]::IsNullOrEmpty($name)) { return $null }
|
||||
return "${name}AssignmentSettings"
|
||||
}
|
||||
|
||||
# Build a deviceHealthScriptRunSchedule PSCustomObject from a UI spec
|
||||
# hashtable. Picks the right @odata.type based on scheduleType
|
||||
# (Hourly/Daily/Once) and emits only the fields each schedule supports.
|
||||
# Returns $null when the spec doesn't have enough information.
|
||||
function Build-HealthScriptSchedule
|
||||
{
|
||||
param([Hashtable]$Spec)
|
||||
|
||||
if(-not $Spec -or -not $Spec.ContainsKey('scheduleType')) { return $null }
|
||||
|
||||
$type = [string]$Spec['scheduleType']
|
||||
$interval = 0
|
||||
if($Spec.ContainsKey('interval')) {
|
||||
try { $interval = [int]$Spec['interval'] } catch { $interval = 0 }
|
||||
}
|
||||
|
||||
switch ($type) {
|
||||
"Hourly" {
|
||||
return [PSCustomObject]@{
|
||||
"@odata.type" = "#microsoft.graph.deviceHealthScriptHourlySchedule"
|
||||
interval = $interval
|
||||
}
|
||||
}
|
||||
"Daily" {
|
||||
$obj = [ordered]@{
|
||||
"@odata.type" = "#microsoft.graph.deviceHealthScriptDailySchedule"
|
||||
interval = $interval
|
||||
useUtc = [bool]$Spec['useUtc']
|
||||
}
|
||||
if($Spec.ContainsKey('time') -and $Spec['time']) {
|
||||
$obj.time = [string]$Spec['time']
|
||||
}
|
||||
return [PSCustomObject]$obj
|
||||
}
|
||||
"Once" {
|
||||
$obj = [ordered]@{
|
||||
"@odata.type" = "#microsoft.graph.deviceHealthScriptRunOnceSchedule"
|
||||
interval = $interval
|
||||
useUtc = [bool]$Spec['useUtc']
|
||||
}
|
||||
if($Spec.ContainsKey('date') -and $Spec['date']) { $obj.date = [string]$Spec['date'] }
|
||||
if($Spec.ContainsKey('time') -and $Spec['time']) { $obj.time = [string]$Spec['time'] }
|
||||
return [PSCustomObject]$obj
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Convert a user-built settings hashtable (from the App settings... dialog)
|
||||
# into a Graph-shaped PSCustomObject with the requested @odata.type stamped
|
||||
# at the top. Nested complex types (win32LobAppRestartSettings etc.) are
|
||||
# expected to already carry their own @odata.type in the hashtable; this
|
||||
# function only wraps the outer object.
|
||||
function ConvertTo-AppSettingsObject
|
||||
{
|
||||
param([Hashtable]$Hash, [string]$GraphType)
|
||||
|
||||
if(-not $Hash) { return $null }
|
||||
$obj = [ordered]@{ "@odata.type" = "#microsoft.graph.$GraphType" }
|
||||
foreach($k in $Hash.Keys) {
|
||||
$obj[$k] = ConvertTo-AppSettingsValue $Hash[$k]
|
||||
}
|
||||
return [PSCustomObject]$obj
|
||||
}
|
||||
|
||||
# Recursive companion to ConvertTo-AppSettingsObject. Walks arbitrary
|
||||
# nesting depth, converting any [Hashtable] (including nested ones inside
|
||||
# arrays) into [PSCustomObject] so ConvertTo-Json emits JSON objects rather
|
||||
# than the @{key=value} debug format. Arrays are preserved and their
|
||||
# elements recursed individually. Strings (which are IEnumerable) and
|
||||
# PSCustomObject (already correct shape) pass through unchanged.
|
||||
function ConvertTo-AppSettingsValue
|
||||
{
|
||||
param($Value)
|
||||
|
||||
if($null -eq $Value) { return $null }
|
||||
if($Value -is [Hashtable]) {
|
||||
$nested = [ordered]@{}
|
||||
foreach($k in $Value.Keys) {
|
||||
$nested[$k] = ConvertTo-AppSettingsValue $Value[$k]
|
||||
}
|
||||
return [PSCustomObject]$nested
|
||||
}
|
||||
if($Value -is [string]) { return $Value }
|
||||
if($Value -is [System.Collections.IList]) {
|
||||
$items = [System.Collections.Generic.List[object]]::new()
|
||||
foreach($item in $Value) {
|
||||
[void]$items.Add((ConvertTo-AppSettingsValue $item))
|
||||
}
|
||||
return $items.ToArray()
|
||||
}
|
||||
return $Value
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
# Internal helpers for Start-GraphBulkExport (Public/Start-GraphBulkExport.ps1).
|
||||
# Extracted here per architecture rule R11 (keep public driver files thin); these
|
||||
# are module-internal and never exported. All dot-source into the same module
|
||||
# scope, so the public driver calls them by name.
|
||||
|
||||
# NOTE: the five legacy Sync-BulkExport* policy-hydration helpers (reusable
|
||||
# settings, branding images, app-config target apps, role-assignment details,
|
||||
# Terms-of-Use files) were retired. Each now lives on its owning policy class via
|
||||
# the GetSubResourceBatchRequests / ApplySubResourceBatchResult / FinalizeSubResources
|
||||
# contract, driven by Internal/PolicySubresourceFetch.ps1 (single + batch + parallel
|
||||
# in one place). Internal/PolicyHydrateExtras.ps1 was deleted.
|
||||
|
||||
# Flag every policy whose export file would land on the same path as another so the
|
||||
# per-policy GetFileName appends `_<id>` and the duplicate doesn't silently
|
||||
# overwrite its twin on disk.
|
||||
#
|
||||
# Filename uniqueness was previously left to the user-facing AddIDToExportFile
|
||||
# setting (off by default). Real tenants — one lab tenant has 8 CA policies all named
|
||||
# "Temp Policy 1" and ~30 SettingsCatalog clones — relied on that setting being on
|
||||
# to avoid data loss on bulk export. The collision detector kicks in automatically
|
||||
# so users don't need to discover the setting after losing files.
|
||||
#
|
||||
# Two things decide whether two policies collide, and BOTH have to be part of the
|
||||
# bucket key:
|
||||
#
|
||||
# * The FILE NAME, not the displayName. The two differ whenever sanitization
|
||||
# strips a character, and it is the file name that collides on disk. "Wi-Fi:
|
||||
# Corp" and "Wi-Fi Corp" are distinct displayNames that both sanitize to
|
||||
# "Wi-Fi Corp.json". This always mattered on Windows (':' has always been an
|
||||
# invalid file name char) and PortableFileNames extends the same class of
|
||||
# collision to Linux and macOS.
|
||||
#
|
||||
# * The FOLDER, which is why this runs once across every type rather than once
|
||||
# per type. With AddObjectType off, every type writes straight into the export
|
||||
# root, so a per-type pass compared each type only against itself and never
|
||||
# noticed a Compliance policy and a Configuration policy of the same name
|
||||
# landing on one path. Any two types sharing a .Folder collide the same way
|
||||
# even with AddObjectType on.
|
||||
#
|
||||
# The name comes from the policy's OWN GetFileName rather than a re-derivation of
|
||||
# the base transform: subclasses override it (DeviceEnrollmentObject names default
|
||||
# policies from their id suffix), and a detector that re-implements the base rule
|
||||
# buckets those by a name they never write under.
|
||||
function Set-BulkExportFilenameCollisionFlag {
|
||||
param(
|
||||
[Parameter(Mandatory)]$Policies,
|
||||
# Mirrors ExportSettings.AddObjectType: on = a per-type subfolder, off =
|
||||
# everything in one directory. Decides whether the type's folder is part of
|
||||
# the destination path at all.
|
||||
[switch]$AddObjectType
|
||||
)
|
||||
|
||||
if (-not $Policies -or @($Policies).Count -lt 2) { return }
|
||||
|
||||
# Every name is read before any flag is set. GetFileName's output depends on
|
||||
# _NeedsIdInFilename, so mutating as we go would make later policies in the same
|
||||
# bucket hash to their already-disambiguated name and escape detection.
|
||||
$byPath = @{}
|
||||
foreach ($p in $Policies) {
|
||||
if (-not $p) { continue }
|
||||
|
||||
$name = $null
|
||||
try { $name = $p.GetFileName($null) }
|
||||
catch { Write-LogDebug "Bulk export: could not resolve a file name for '$($p.Name)' - excluded from collision detection: $($_.Exception.Message)" }
|
||||
if (-not $name) { continue }
|
||||
|
||||
$folder = if ($AddObjectType) { [string]$p.PolicyType.Folder } else { '' }
|
||||
|
||||
# ToLowerInvariant because the collision is on the file system, and Windows
|
||||
# and macOS are case-insensitive: "Baseline.json" and "baseline.json" are one
|
||||
# file there. Over-flagging on Linux is harmless - the id suffix is only ever
|
||||
# added, never dropped.
|
||||
$key = "$folder/$name".ToLowerInvariant()
|
||||
|
||||
if (-not $byPath.ContainsKey($key)) {
|
||||
$byPath[$key] = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
[void]$byPath[$key].Add($p)
|
||||
}
|
||||
|
||||
$collided = 0
|
||||
foreach ($key in $byPath.Keys) {
|
||||
$bucket = $byPath[$key]
|
||||
if ($bucket.Count -lt 2) { continue }
|
||||
foreach ($p in $bucket) {
|
||||
if ($p.PSObject.Properties['_NeedsIdInFilename']) {
|
||||
$p._NeedsIdInFilename = $true
|
||||
$collided++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($collided -gt 0) {
|
||||
Write-Log "Bulk export: $collided policy file(s) resolved to duplicate file names - appending '_<id>' to their filenames to avoid silent overwrite"
|
||||
}
|
||||
}
|
||||
|
||||
# ExportFullMembershipPrefixes is gone: all group paths are batched now, so its
|
||||
# performance rationale went with them, and its only observable output was the
|
||||
# #DirectMembers / #DirectMemberCount stamps on group sidecars, which nothing in
|
||||
# the module ever read. An automation run downstream could have read them though,
|
||||
# so a leftover value must not be dropped in silence - the export would "succeed"
|
||||
# with the member lists simply absent from the sidecars.
|
||||
#
|
||||
# Two places can still hold one, and they need different treatment:
|
||||
#
|
||||
# * A settings file. Read-only from here - rewriting the caller's automation
|
||||
# input is not ours to do, so this warns on every run until they edit it.
|
||||
#
|
||||
# * The settings store, where every export used to persist the key alongside
|
||||
# AddCompanyName and ExportAssignments. Nothing reads it now, so it is
|
||||
# cleared after being reported: otherwise the warning would be permanent for
|
||||
# a value the user has no UI left to clear.
|
||||
#
|
||||
# The store holds more than one candidate. The old read went through
|
||||
# Get-SettingValue, which resolves "<tenantId>\IntuneManager" BEFORE the global
|
||||
# "IntuneManager" path, so a tenant override was the value that actually applied
|
||||
# and cannot be skipped here. Every applicable path is walked in that same
|
||||
# precedence order.
|
||||
#
|
||||
# A stored empty string reads back the same as "not stored" (Get-SettingStoreValue
|
||||
# treats "" as unset), so only a non-empty leftover is reportable - which is also
|
||||
# the only one that ever changed any output. The removal is unconditional
|
||||
# regardless, so the dead key does not sit in every user's store for ever.
|
||||
function Clear-BulkExportLegacyMembershipSetting
|
||||
{
|
||||
param(
|
||||
$FileSettings,
|
||||
[string] $SettingsFile,
|
||||
[int] $TokenId = 0
|
||||
)
|
||||
|
||||
$removedKey = 'ExportFullMembershipPrefixes'
|
||||
$sidecarNote = 'Group sidecars no longer carry the #DirectMembers / #DirectMemberCount member lists.'
|
||||
|
||||
if ($FileSettings -and $FileSettings.PSObject.Properties[$removedKey]) {
|
||||
$fileValue = ([string]$FileSettings.$removedKey).Trim()
|
||||
if ($fileValue) {
|
||||
Write-Log "Bulk export: settings file '$SettingsFile' sets $removedKey ('$fileValue'), which has been removed - the value is ignored. $sidecarNote Remove the property from the settings file to silence this warning." 2
|
||||
}
|
||||
else {
|
||||
# Every file written by an older build carries the key. One that was
|
||||
# never set changes nothing, so it is not worth a warning.
|
||||
Write-LogDebug "Bulk export: settings file '$SettingsFile' still carries the removed $removedKey property with an empty value - ignored, no change in output."
|
||||
}
|
||||
}
|
||||
|
||||
# Two tenant ids can be the applicable one: the organization the session is
|
||||
# connected to (which is what Get-SettingValue itself keyed on) and, on a
|
||||
# cross-tenant automation run, the tenant behind this export's own token.
|
||||
$subPaths = [System.Collections.Generic.List[string]]::new()
|
||||
$tenantIds = [System.Collections.Generic.List[string]]::new()
|
||||
if ($script:OrganizationId) { [void]$tenantIds.Add([string]$script:OrganizationId) }
|
||||
$tokenInfo = Get-OperationTokenInfo $TokenId
|
||||
if ($tokenInfo -and $tokenInfo.TenantId) { [void]$tenantIds.Add([string]$tokenInfo.TenantId) }
|
||||
|
||||
foreach ($tenantId in $tenantIds) {
|
||||
# A path stored flat by an older build ("<tenantId>\IntuneManager" as one
|
||||
# property name) resolves from this same string - Get-SettingsTreeNode
|
||||
# checks the flat property before splitting the path.
|
||||
$tenantPath = "$tenantId\IntuneManager"
|
||||
if (-not $subPaths.Contains($tenantPath)) { [void]$subPaths.Add($tenantPath) }
|
||||
}
|
||||
[void]$subPaths.Add("IntuneManager") # global last, as it was read last
|
||||
|
||||
foreach ($subPath in $subPaths) {
|
||||
$storedValue = Get-SettingStoreValue $subPath $removedKey
|
||||
if ($storedValue) {
|
||||
Write-Log "Bulk export: the saved setting $subPath\$removedKey ('$storedValue') has been removed - it is ignored and is being cleared from the settings store. $sidecarNote" 2
|
||||
}
|
||||
# Not inside the if: an empty leftover is worth no warning but is still a
|
||||
# dead key. Removal touches the store only when the key is actually there.
|
||||
Remove-SettingStoreValue $subPath $removedKey
|
||||
}
|
||||
}
|
||||
|
||||
# Pre-warm the AAD-object cache that Add-GraphMigrationObject reads on every
|
||||
# group reference. Walks three sources visible in the already-fetched policy
|
||||
# bodies: assignment targets, CA conditions.users.include/excludeGroups, and
|
||||
# compliance scheduledActionConfigurations.notificationMessageCCList. Without
|
||||
# this each unique group is fetched one-by-one (sequential Invoke-MSGraphAPI
|
||||
# calls per policy) during Phase 3, which dominates the wall time for CA-heavy
|
||||
# or assignment-heavy tenants. Batched here in parallel chunks; subsequent
|
||||
# per-policy lookups are all cache hits.
|
||||
function Sync-BulkExportMigrationGroups
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)] $Policies,
|
||||
[int] $TokenId = 0
|
||||
)
|
||||
|
||||
if (-not $Policies -or $Policies.Count -eq 0) { return }
|
||||
|
||||
# Collect unique group ids referenced by any group-style assignment target.
|
||||
$groupIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
$groupTargetTypes = @(
|
||||
'#microsoft.graph.groupAssignmentTarget'
|
||||
'#microsoft.graph.exclusionGroupAssignmentTarget'
|
||||
'#microsoft.graph.cloudPcManagementGroupAssignmentTarget'
|
||||
)
|
||||
|
||||
# CA condition group ids are special-cased strings, not real group ids.
|
||||
$caSkipIds = @('All','None','GuestsOrExternalUsers')
|
||||
|
||||
foreach ($p in $Policies) {
|
||||
$body = $p.JsonObject
|
||||
if (-not $body) { continue }
|
||||
|
||||
# Assignments live on JsonObject.Assignments after Get-GraphPolicies populates them.
|
||||
foreach ($a in @($body.Assignments)) {
|
||||
foreach ($target in @($a.target)) {
|
||||
if (-not $target -or -not $target.'@odata.type') { continue }
|
||||
if ($groupTargetTypes -contains $target.'@odata.type' -and $target.groupId) {
|
||||
[void]$groupIds.Add($target.groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# CA conditions reference groups by id directly in the policy body. These
|
||||
# are resolved by IntuneConditionalAccessClasses.ps1 PostExportCommand —
|
||||
# one Graph GET per id during the write phase if not pre-cached here.
|
||||
$caUsers = $body.conditions.users
|
||||
if ($caUsers) {
|
||||
foreach ($id in @($caUsers.includeGroups) + @($caUsers.excludeGroups)) {
|
||||
if (-not $id) { continue }
|
||||
if ($caSkipIds -contains $id) { continue }
|
||||
[void]$groupIds.Add($id)
|
||||
}
|
||||
}
|
||||
|
||||
# Compliance notification CC groups — resolved by IntuneComplianceClasses
|
||||
# PostExportCommand. The expand on scheduledActionConfigurations is on by
|
||||
# default for compliance policies, so the ids are already in the body.
|
||||
foreach ($rule in @($body.scheduledActionsForRule)) {
|
||||
foreach ($cfg in @($rule.scheduledActionConfigurations)) {
|
||||
foreach ($id in @($cfg.notificationMessageCCList)) {
|
||||
if ($id) { [void]$groupIds.Add($id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($groupIds.Count -eq 0) { return }
|
||||
|
||||
# One token, never the whole list - see the prefetch above.
|
||||
$tokenInfo = Get-OperationTokenInfo $TokenId
|
||||
if (-not $tokenInfo -or -not $tokenInfo.TenantId) {
|
||||
Write-Log "Bulk export: skipping migration-group prefetch - no token info for TokenId $TokenId" 2
|
||||
return
|
||||
}
|
||||
$cacheKey = "AADObjectCache_$($tokenInfo.TenantId)"
|
||||
$cacheFile = "TenantCache_$($tokenInfo.TenantId)"
|
||||
$cache = Get-CacheObject $cacheKey @{}
|
||||
if ($cache -isnot [Hashtable]) { $cache = @{} }
|
||||
|
||||
# Skip ids already cached (positive OR negative — null entries mean "known missing").
|
||||
$needFetch = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($gid in $groupIds) {
|
||||
if (-not $cache.ContainsKey($gid)) { [void]$needFetch.Add($gid) }
|
||||
}
|
||||
|
||||
if ($needFetch.Count -gt 0) {
|
||||
Write-Log "Bulk export: pre-fetching $($needFetch.Count) AAD group(s) referenced by policies (assignments, CA conditions, compliance notifications)"
|
||||
Write-Status -Detail ("Pre-fetching {0} group(s)" -f $needFetch.Count) -SkipLog -Force
|
||||
|
||||
# Full bodies, a thousand ids per POST, through /directoryObjects/getByIds -
|
||||
# the same resolver the documentation preload uses. This used to be one
|
||||
# GET groups/<id> sub-request per group inside $batch (fifty POSTs for a
|
||||
# thousand groups); now it is one or two POSTs. Verified live 2026-09-07:
|
||||
# the body is identical to GET groups/<id> in every property and value
|
||||
# except an extra @odata.type, which is stripped below so the Groups/
|
||||
# sidecars stay byte-identical to before. A group the directory does not
|
||||
# return is simply absent from the response: deleted, so it is
|
||||
# negative-cached exactly as a 404 was.
|
||||
$resolved = 0
|
||||
$missing = 0
|
||||
for ($i = 0; $i -lt $needFetch.Count; $i += 1000) {
|
||||
$end = [Math]::Min($i + 999, $needFetch.Count - 1)
|
||||
$chunk = @($needFetch[$i..$end])
|
||||
|
||||
$resp = $null
|
||||
try {
|
||||
$body = (@{ ids = $chunk; types = @('group') } | ConvertTo-Json -Compress)
|
||||
$resp = Invoke-MSGraphAPI -Url 'directoryObjects/getByIds' -Content $body -HttpMethod POST -TokenId $TokenId -ODataMetadata 'none'
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Bulk export: getByIds group prefetch failed for a chunk of $($chunk.Count)" $_.Exception
|
||||
}
|
||||
if ($null -eq $resp) {
|
||||
# Transport or auth failure: leave the chunk UNcached so the per-policy
|
||||
# path can still resolve those ids later, rather than negative-caching
|
||||
# a thousand live groups on one bad call.
|
||||
continue
|
||||
}
|
||||
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach ($g in @($resp.value)) {
|
||||
if (-not $g -or -not $g.id) { continue }
|
||||
if ($g.PSObject.Properties['@odata.type']) { [void]$g.PSObject.Properties.Remove('@odata.type') }
|
||||
$cache[[string]$g.id] = $g
|
||||
[void]$seen.Add([string]$g.id)
|
||||
$resolved++
|
||||
}
|
||||
foreach ($gid in $chunk) {
|
||||
if (-not $seen.Contains($gid)) {
|
||||
$cache[$gid] = $null
|
||||
$missing++
|
||||
}
|
||||
}
|
||||
}
|
||||
Set-CacheObject $cacheKey $cache $cacheFile
|
||||
Write-Log "Bulk export: migration-group prefetch complete - $resolved resolved, $missing missing/deleted"
|
||||
}
|
||||
}
|
||||
|
||||
# Pre-walk the nested-group hierarchy in batches so the per-policy recursion
|
||||
# inside Add-GraphMigrationObject doesn't fire one un-batched GET per parent
|
||||
# group plus one per child body. BFS, level-by-level: at each level we batch
|
||||
# /members/microsoft.graph.group for every group at that level, attach the
|
||||
# resulting children list to the parent body as `#NestedChildren`, then batch
|
||||
# /groups/{id} for any newly-discovered children that aren't yet in the
|
||||
# AADObjectCache. Stops when there are no more groups to walk or when we hit
|
||||
# MaxDepth-1 (depth 1 means "just the directly-assigned group", so no walk).
|
||||
#
|
||||
# The dedup inside Add-GraphMigrationObject's recursion still handles cycles
|
||||
# correctly — this function is purely a Graph-call optimizer; it doesn't
|
||||
# change which groups end up in the export.
|
||||
function Sync-BulkExportNestedGroupHierarchy
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)] [int] $MaxDepth,
|
||||
[int] $TokenId = 0
|
||||
)
|
||||
|
||||
if ($MaxDepth -le 1) { return }
|
||||
|
||||
# One token, never the whole list - see the prefetch above.
|
||||
$tokenInfo = Get-OperationTokenInfo $TokenId
|
||||
if (-not $tokenInfo -or -not $tokenInfo.TenantId) {
|
||||
Write-Log "Bulk export: skipping nested-group hierarchy prefetch - no token info for TokenId $TokenId" 2
|
||||
return
|
||||
}
|
||||
|
||||
$cacheKey = "AADObjectCache_$($tokenInfo.TenantId)"
|
||||
$cacheFile = "TenantCache_$($tokenInfo.TenantId)"
|
||||
$cache = Get-CacheObject $cacheKey @{}
|
||||
if ($cache -isnot [Hashtable]) { $cache = @{} }
|
||||
|
||||
# Seed the walk with every group already in cache (i.e. every group the
|
||||
# earlier Sync-BulkExportMigrationGroups resolved). These are the level-1
|
||||
# starting points for the hierarchy.
|
||||
$currentLevelIds = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($entry in $cache.GetEnumerator()) {
|
||||
if ($null -eq $entry.Value) { continue } # negative-cached
|
||||
if ($entry.Value.PSObject.Properties.Name -contains '#NestedChildren') { continue } # already walked this session
|
||||
[void]$currentLevelIds.Add([string]$entry.Key)
|
||||
}
|
||||
|
||||
if ($currentLevelIds.Count -eq 0) { return }
|
||||
|
||||
$totalParents = 0
|
||||
$totalChildren = 0
|
||||
|
||||
# MaxDepth=2 means "walk one level": stamp #NestedChildren on every level-1
|
||||
# group. MaxDepth=3 → walk two levels. Loop bound is MaxDepth-1.
|
||||
for ($depth = 1; $depth -lt $MaxDepth -and $currentLevelIds.Count -gt 0; $depth++) {
|
||||
|
||||
Write-Log "Bulk export: nested-group hierarchy - fetching children for $($currentLevelIds.Count) group(s) at depth $depth"
|
||||
Write-Status -Detail ("Pre-fetching nested groups (depth {0}, {1} parent(s))" -f $depth, $currentLevelIds.Count) -SkipLog -Force
|
||||
|
||||
# Batch-fetch /members/microsoft.graph.group for every parent at this level.
|
||||
$memBatch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$idToParent = @{}
|
||||
$i = 0
|
||||
foreach ($gid in $currentLevelIds) {
|
||||
$i++
|
||||
$reqId = "nest_${depth}_$i"
|
||||
[void]$memBatch.Add([PSCustomObject]@{
|
||||
id = $reqId
|
||||
method = 'GET'
|
||||
url = "groups/$gid/members/microsoft.graph.group?`$select=id,displayName"
|
||||
headers = @{ Accept = 'application/json;odata.metadata=minimal' }
|
||||
})
|
||||
$idToParent[$reqId] = $gid
|
||||
}
|
||||
|
||||
$memResults = Invoke-GraphBatchRequest -BatchObjects $memBatch -BatchType "Nested group members (depth $depth)" -TokenId $TokenId -AllPages -SkipWarnings -IncludedFailed
|
||||
|
||||
# Walk results: stamp #NestedChildren on parents, collect new child ids.
|
||||
$newChildIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
$childListsByParent = @{}
|
||||
foreach ($r in $memResults) {
|
||||
$parentId = $idToParent["$($r.Id)"]
|
||||
if (-not $parentId) { continue }
|
||||
|
||||
$children = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
if ($r.body -and $r.Status -ge 200 -and $r.Status -lt 300) {
|
||||
foreach ($c in @($r.body.value)) {
|
||||
if (-not $c -or -not $c.id) { continue }
|
||||
[void]$children.Add([PSCustomObject]@{ id = $c.id; displayName = $c.displayName })
|
||||
if (-not $cache.ContainsKey($c.id)) { [void]$newChildIds.Add($c.id) }
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-LogDebug "Bulk export: nested-group members fetch for $parentId returned HTTP $($r.Status)"
|
||||
}
|
||||
|
||||
$parentBody = $cache[$parentId]
|
||||
if ($null -ne $parentBody) {
|
||||
Add-Member -InputObject $parentBody -MemberType NoteProperty -Name '#NestedChildren' -Value $children.ToArray() -Force
|
||||
}
|
||||
$childListsByParent[$parentId] = $children
|
||||
$totalParents++
|
||||
}
|
||||
|
||||
# Batch-fetch bodies for newly-discovered children so the recursion
|
||||
# inside Add-GraphMigrationObject hits cache for them too.
|
||||
if ($newChildIds.Count -gt 0) {
|
||||
Write-Status -Detail ("Pre-fetching {0} nested-group bod(ies) (depth {1})" -f $newChildIds.Count, $depth) -SkipLog -Force
|
||||
|
||||
$bodyBatch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$idToChild = @{}
|
||||
$j = 0
|
||||
foreach ($gid in $newChildIds) {
|
||||
$j++
|
||||
$reqId = "nestbody_${depth}_$j"
|
||||
[void]$bodyBatch.Add([PSCustomObject]@{
|
||||
id = $reqId
|
||||
method = 'GET'
|
||||
url = "groups/$gid"
|
||||
headers = @{ Accept = 'application/json;odata.metadata=none' }
|
||||
})
|
||||
$idToChild[$reqId] = $gid
|
||||
}
|
||||
|
||||
$bodyResults = Invoke-GraphBatchRequest -BatchObjects $bodyBatch -BatchType "Nested group bodies (depth $depth)" -TokenId $TokenId -SkipWarnings -IncludedFailed
|
||||
|
||||
foreach ($r in $bodyResults) {
|
||||
$gid = $idToChild["$($r.Id)"]
|
||||
if (-not $gid) { continue }
|
||||
if ($r.body -and $r.Status -ge 200 -and $r.Status -lt 300) {
|
||||
$cache[$gid] = $r.body
|
||||
}
|
||||
else {
|
||||
$cache[$gid] = $null # negative-cache so we don't re-query during the write phase
|
||||
}
|
||||
}
|
||||
$totalChildren += $newChildIds.Count
|
||||
}
|
||||
|
||||
# Next iteration walks the children we just found that weren't already in cache
|
||||
# and don't yet have their own #NestedChildren stamp.
|
||||
$nextLevel = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($pair in $childListsByParent.GetEnumerator()) {
|
||||
foreach ($child in $pair.Value) {
|
||||
$body = $cache[$child.id]
|
||||
if ($null -eq $body) { continue }
|
||||
if ($body.PSObject.Properties.Name -contains '#NestedChildren') { continue }
|
||||
[void]$nextLevel.Add($child.id)
|
||||
}
|
||||
}
|
||||
$currentLevelIds = $nextLevel
|
||||
}
|
||||
|
||||
Set-CacheObject $cacheKey $cache $cacheFile
|
||||
Write-Log "Bulk export: nested-group hierarchy prefetch complete - $totalParents parent(s) walked, $totalChildren new child group bod(ies) cached"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
# Class moved to Classes/CompareClasses.ps1
|
||||
@@ -0,0 +1 @@
|
||||
# Class moved to Classes/CompareClasses.ps1
|
||||
@@ -0,0 +1 @@
|
||||
# Class moved to Classes/CompareClasses.ps1
|
||||
@@ -0,0 +1 @@
|
||||
# Class moved to Classes/CompareClasses.ps1
|
||||
@@ -0,0 +1 @@
|
||||
# Class moved to Classes/CompareClasses.ps1
|
||||
+1610
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
# Loader stub for the Documentation feature.
|
||||
#
|
||||
# The module loader at IntuneManagement.psm1:135 enumerates Internal/*.ps1 non-recursively,
|
||||
# so files under Internal/Documentation/** would not auto-import. This stub recursively
|
||||
# dot-sources them in lexical order. Folder names are chosen so the lexical sort loads
|
||||
# dependencies first: Classes -> Core -> InputProviders -> OutputProviders -> PolicyTypeHandlers
|
||||
# (Assets holds CSS only, no .ps1). "Classes" sorts before "Core" because 'l' < 'o'.
|
||||
|
||||
$documentationRoot = Join-Path $PSScriptRoot 'Documentation'
|
||||
|
||||
if ([IO.Directory]::Exists($documentationRoot)) {
|
||||
Get-ChildItem -Path $documentationRoot -Filter '*.ps1' -Recurse |
|
||||
Sort-Object FullName |
|
||||
ForEach-Object {
|
||||
Write-Verbose "Loading documentation file: $($_.FullName)"
|
||||
if ($script:IsWindowsOS) { Unblock-File -Path $_.FullName -ErrorAction SilentlyContinue }
|
||||
. $_.FullName
|
||||
}
|
||||
}
|
||||
|
||||
# Contribute the "Documentation" comparison type to the compare subsystem when
|
||||
# documentation is present. This inverts the old cross-subsystem coupling where
|
||||
# Internal/Compare.ps1 probed Get-Command "Get-GraphDocumentation" to decide
|
||||
# whether to add the type - documentation now owns and self-registers its own
|
||||
# compare type (Internal/Compare.ps1 loads first, so the wrapper exists). No
|
||||
# Compare scriptblock: doc compare needs the policy wrappers, and
|
||||
# Compare-PolicyObjects routes Value "doc" straight to Compare-ObjectsBasedonDocumentation.
|
||||
if (Get-Command Register-CompareComparisonType -ErrorAction SilentlyContinue) {
|
||||
Register-CompareComparisonType ([PSCustomObject]@{
|
||||
Name = "Documentation"
|
||||
Value = "doc"
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<style type="text/css">
|
||||
|
||||
html { }
|
||||
|
||||
html,body {
|
||||
margin:0;
|
||||
padding:5px;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
.header-level1 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 18px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.header-level2 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 16px;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.header-level3 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 14px;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.header-level4 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.header-level6 {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.table-settings {
|
||||
border: 1px solid #999999;
|
||||
padding-right: 5px;
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.table-settings TR:nth-child(even) {
|
||||
background-color: #FAFAFA
|
||||
}
|
||||
|
||||
.table-settings th {
|
||||
background-color: #D0D0D0;
|
||||
padding: 0px;
|
||||
text-align:left;
|
||||
font-size: 12px;
|
||||
padding-right: 10px;
|
||||
padding-top: 3px;
|
||||
font-weight: bold;
|
||||
border-width: 5px;
|
||||
padding: 5px;
|
||||
border-style: none;
|
||||
border-color: #F2F2F2;
|
||||
}
|
||||
|
||||
.table-settings td {
|
||||
text-align:left;
|
||||
padding-right: 5px;
|
||||
border-width: 5px;
|
||||
padding: 5px;
|
||||
border-style: none;
|
||||
border-color: #F2F2F2;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.code {
|
||||
background: #f4f4f4;
|
||||
border: 1px solid #ddd;
|
||||
border-left: 3px solid #333333;
|
||||
color: #666;
|
||||
page-break-inside: avoid;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
padding: 1em 1.5em;
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.description summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details.description[open] summary::after {
|
||||
content: attr(data-open);
|
||||
}
|
||||
|
||||
details.description:not([open]) summary::after {
|
||||
content: attr(data-close);
|
||||
}
|
||||
|
||||
.row-even {
|
||||
|
||||
}
|
||||
|
||||
.row-odd {
|
||||
|
||||
}
|
||||
|
||||
.category-level1 {
|
||||
background-color: #E0E0E0;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.category-level2 {
|
||||
background-color: #E0E0E0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anchor-style {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anchor-level2 {
|
||||
|
||||
}
|
||||
|
||||
.anchor-level3 {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.anchor-level4 {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.anchor-level5 {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.table-value {
|
||||
border: 0px;
|
||||
width: 100%;
|
||||
padding: 0px;
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.table-value th {
|
||||
background-color: #F9F9F9;
|
||||
padding: 0px;
|
||||
text-align:left;
|
||||
font-size: 11px;
|
||||
padding-right: 0px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
font-weight: normal;
|
||||
border-width: 0px;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
.table-value tr {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.table-value td {
|
||||
border-bottom: 0;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.row-new-property {
|
||||
background-color: #E7E7E7 !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<style type="text/css">
|
||||
|
||||
html { }
|
||||
|
||||
html,body {
|
||||
margin:0;
|
||||
padding:0 px;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
h6 {
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
|
||||
.table-settings {
|
||||
padding-right: 5px;
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.table-settings p {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.table-settings TR:nth-child(even) {
|
||||
background-color: #FAFAFA
|
||||
}
|
||||
|
||||
.table-settings td {
|
||||
text-align:left;
|
||||
border-width: 5px;
|
||||
padding: 5px !important;
|
||||
border-style: none;
|
||||
border-color: #F2F2F2;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.table-header1 td {
|
||||
background-color: #D0D0D0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-width: 5px;
|
||||
padding: 5px;
|
||||
border-style: none;
|
||||
border-color: #F2F2F2;
|
||||
}
|
||||
|
||||
.category-level1 {
|
||||
background-color: #E0E0E0;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.category-level2 {
|
||||
background-color: #E0E0E0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anchor-style {
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anchor-level2 {
|
||||
|
||||
}
|
||||
|
||||
.table-value {
|
||||
border: 0px;
|
||||
width: 100%;
|
||||
padding: 0px;
|
||||
font-family:"Arial",sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.table-value th {
|
||||
background-color: #F9F9F9;
|
||||
padding: 0px;
|
||||
text-align:left;
|
||||
font-size: 11px;
|
||||
padding-right: 0px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
font-weight: normal;
|
||||
border-width: 0px;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
.table-value tr {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.table-value td {
|
||||
border-bottom: 0;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.row-new-property {
|
||||
background-color: #E7E7E7 !important;
|
||||
}
|
||||
|
||||
.description summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details.description[open] summary::after {
|
||||
content: attr(data-open);
|
||||
}
|
||||
|
||||
details.description:not([open]) summary::after {
|
||||
content: attr(data-close);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Abstract base for the per-@odata.type custom documentation handlers populated
|
||||
# in phase 4. Subclasses live under Internal/Documentation/PolicyTypeHandlers/, declare
|
||||
# the @odata.type(s) they claim, and override Document() to fill the context.
|
||||
#
|
||||
# Registration is by composition (handler instance registers itself with the
|
||||
# [DocumentationRegistry] at file load time), not by reflection — explicit so
|
||||
# dispatch order is deterministic and missing registrations are obvious.
|
||||
|
||||
class DocumentationHandlerBase {
|
||||
[string[]] $ODataTypes
|
||||
|
||||
DocumentationHandlerBase() {
|
||||
$this.ODataTypes = @()
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
throw "DocumentationHandlerBase.Document is abstract - override in derived class $($this.GetType().Name)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
class DocumentationRegistry {
|
||||
# ---- Output providers (phase 1, populated) ----
|
||||
static [System.Collections.Generic.List[PSCustomObject]] $Outputs = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# ---- Per-@odata.type custom handler classes (phase 4 populates) ----
|
||||
static [System.Collections.Generic.List[object]] $Handlers = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
# ---- Schema-driven input providers (phase 3 populates) ----
|
||||
# Each provider is a PSCustomObject with: Name, Order (int, lower = higher
|
||||
# priority), Match (scriptblock taking $PolicyObject, $Context), Translate.
|
||||
# FindInputProvider evaluates providers in Order and returns the FIRST whose
|
||||
# Match claims the object; the rest are skipped. The generic fallback registers
|
||||
# with Order = [int]::MaxValue so it is always evaluated last.
|
||||
static [System.Collections.Generic.List[PSCustomObject]] $InputProviders = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# Monotonic registration counter, used as a stable tiebreak when two providers
|
||||
# share the same Order (preserves registration order for equal-Order providers).
|
||||
static [int] $InputProviderSeq = 0
|
||||
|
||||
# ---- ObjectInfo walker customizations ----
|
||||
# These augment schema-driven Manifest/Profile translation without claiming
|
||||
# the whole object like a DocumentationHandlerBase implementation does.
|
||||
static [System.Collections.Generic.List[PSCustomObject]] $ObjectInfoCustomizers = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# ---- Outputs ----
|
||||
|
||||
static [void] RegisterOutput([PSCustomObject]$Provider) {
|
||||
if (-not $Provider.Value) { throw "Output provider must have a Value" }
|
||||
$existing = [DocumentationRegistry]::Outputs | Where-Object { $_.Value -eq $Provider.Value }
|
||||
if ($existing) {
|
||||
[DocumentationRegistry]::Outputs.Remove($existing) | Out-Null
|
||||
}
|
||||
[DocumentationRegistry]::Outputs.Add($Provider)
|
||||
}
|
||||
|
||||
static [PSCustomObject] FindOutput([string]$Value) {
|
||||
return [DocumentationRegistry]::Outputs | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# ---- Handlers ----
|
||||
|
||||
static [void] RegisterHandler([object]$Handler) {
|
||||
if (-not $Handler.ODataTypes -or $Handler.ODataTypes.Count -eq 0) {
|
||||
throw "Documentation handler must declare ODataTypes"
|
||||
}
|
||||
# Replace any existing handler claiming the same @odata.type
|
||||
$newKeys = @($Handler.ODataTypes)
|
||||
$stale = [DocumentationRegistry]::Handlers | Where-Object {
|
||||
$existingKeys = @($_.ODataTypes)
|
||||
foreach ($k in $existingKeys) { if ($newKeys -contains $k) { return $true } }
|
||||
$false
|
||||
}
|
||||
foreach ($s in $stale) { [DocumentationRegistry]::Handlers.Remove($s) | Out-Null }
|
||||
[DocumentationRegistry]::Handlers.Add($Handler)
|
||||
}
|
||||
|
||||
static [object] FindHandler([string]$ODataType) {
|
||||
if (-not $ODataType) { return $null }
|
||||
foreach ($h in [DocumentationRegistry]::Handlers) {
|
||||
if ($h.ODataTypes -contains $ODataType) { return $h }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# ---- ObjectInfo walker customizations ----
|
||||
|
||||
static [void] RegisterObjectInfoCustomizer([PSCustomObject]$Customizer) {
|
||||
if (-not $Customizer.Name) { throw "ObjectInfo customizer must have a Name" }
|
||||
# A customizer either claims specific @odata.types (ODataTypes/ODataTypePatterns)
|
||||
# or opts into MatchAll to run for every object (its hooks branch internally,
|
||||
# matching the old DocumentationCustom.psm1 single-provider model).
|
||||
if (-not $Customizer.MatchAll -and
|
||||
(-not $Customizer.ODataTypes -or $Customizer.ODataTypes.Count -eq 0) -and
|
||||
(-not $Customizer.ODataTypePatterns -or $Customizer.ODataTypePatterns.Count -eq 0)) {
|
||||
throw "ObjectInfo customizer $($Customizer.Name) must declare MatchAll, ODataTypes or ODataTypePatterns"
|
||||
}
|
||||
$existing = [DocumentationRegistry]::ObjectInfoCustomizers | Where-Object Name -EQ $Customizer.Name
|
||||
if ($existing) {
|
||||
[DocumentationRegistry]::ObjectInfoCustomizers.Remove($existing) | Out-Null
|
||||
}
|
||||
[DocumentationRegistry]::ObjectInfoCustomizers.Add($Customizer)
|
||||
}
|
||||
|
||||
static [object[]] FindObjectInfoCustomizers([string]$ODataType) {
|
||||
return @([DocumentationRegistry]::ObjectInfoCustomizers | Where-Object {
|
||||
if ($_.MatchAll) { return $true }
|
||||
if (-not $ODataType) { return $false }
|
||||
if ($_.ODataTypes -contains $ODataType) { return $true }
|
||||
foreach ($pattern in @($_.ODataTypePatterns)) {
|
||||
if ($ODataType -like $pattern) { return $true }
|
||||
}
|
||||
return $false
|
||||
})
|
||||
}
|
||||
|
||||
# ---- Input providers ----
|
||||
|
||||
static [void] RegisterInputProvider([PSCustomObject]$Provider) {
|
||||
if (-not $Provider.Name) { throw "Input provider must have a Name" }
|
||||
if (-not $Provider.Match) { throw "Input provider $($Provider.Name) must have a Match scriptblock" }
|
||||
if (-not $Provider.Translate){ throw "Input provider $($Provider.Name) must have a Translate scriptblock" }
|
||||
|
||||
# Default Order for providers that don't declare one: after the specific
|
||||
# providers (which use < 100), before the fallback ([int]::MaxValue).
|
||||
if (-not $Provider.PSObject.Properties['Order'] -or $null -eq $Provider.Order) {
|
||||
$Provider | Add-Member -NotePropertyName Order -NotePropertyValue 100 -Force
|
||||
}
|
||||
# Stable tiebreak for equal Orders (registration order).
|
||||
$Provider | Add-Member -NotePropertyName _Seq -NotePropertyValue ([DocumentationRegistry]::InputProviderSeq++) -Force
|
||||
|
||||
$existing = [DocumentationRegistry]::InputProviders | Where-Object { $_.Name -eq $Provider.Name }
|
||||
if ($existing) {
|
||||
[DocumentationRegistry]::InputProviders.Remove($existing) | Out-Null
|
||||
}
|
||||
[DocumentationRegistry]::InputProviders.Add($Provider)
|
||||
}
|
||||
|
||||
static [PSCustomObject] FindInputProvider([object]$PolicyObject) {
|
||||
return [DocumentationRegistry]::FindInputProvider($PolicyObject, $null)
|
||||
}
|
||||
|
||||
# Evaluate providers in ascending Order (ties broken by registration sequence)
|
||||
# and return the first whose Match claims the object. $Context is passed to
|
||||
# Match as a second argument; providers that declare only param($PolicyObject)
|
||||
# ignore it, while the fallback reads it to honor the opt-in option.
|
||||
static [PSCustomObject] FindInputProvider([object]$PolicyObject, [object]$Context) {
|
||||
$ordered = [DocumentationRegistry]::InputProviders | Sort-Object @{ Expression = { [int]$_.Order } }, @{ Expression = { [int]$_._Seq } }
|
||||
foreach ($p in $ordered) {
|
||||
try {
|
||||
if (& $p.Match $PolicyObject $Context) {
|
||||
Write-Log "InputProvider found: $($p.Name) matched '$($PolicyObject.Name)' - Policy Type: $($PolicyObject.PolicyName)"
|
||||
return $p
|
||||
}
|
||||
} catch {
|
||||
# A throwing Match must not silently skip the provider - that turns a
|
||||
# buggy Match into a mysterious "no provider matched / empty document".
|
||||
# Log it (warning) and keep evaluating the remaining providers.
|
||||
Write-Log "Documentation input provider '$($p.Name)' Match threw for '$($PolicyObject.Name)': $($_.Exception.Message)" 2
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Clear every registry collection. The static List initializers above run
|
||||
# only on the FIRST type load; on Import-Module -Force the type is cached so
|
||||
# they do NOT re-run and the lists keep their previous-import entries. Each
|
||||
# provider/handler re-registers (replace-by-identity) on every import, but
|
||||
# one whose identity was EDITED between reloads (a handler's ODataTypes, a
|
||||
# provider's Name) would orphan its old entry because the replace can't
|
||||
# match the changed key. Resetting at load time guarantees a clean slate.
|
||||
static [void] Reset() {
|
||||
[DocumentationRegistry]::Outputs.Clear()
|
||||
[DocumentationRegistry]::Handlers.Clear()
|
||||
[DocumentationRegistry]::InputProviders.Clear()
|
||||
[DocumentationRegistry]::InputProviderSeq = 0
|
||||
[DocumentationRegistry]::ObjectInfoCustomizers.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
# Get-DocumentationOutputValues backs the -OutputFormat ArgumentCompleter on
|
||||
# Start-GraphBulkDocumentation (invoked via & (Get-Module IntuneManagement) {
|
||||
# Get-DocumentationOutputValues }). ArgumentCompleter is used instead of a
|
||||
# [ValidateSet([IValidateSetValuesGenerator])] so the module still imports on
|
||||
# Windows PowerShell 5.1 (that interface is PS7-only).
|
||||
|
||||
function Get-DocumentationOutputValues {
|
||||
# Runs in the main IntuneManagement module scope so [DocumentationRegistry] is
|
||||
# visible (invoked from the ArgumentCompleter via & (Get-Module ...)).
|
||||
try {
|
||||
return @([DocumentationRegistry]::Outputs | Sort-Object Value | Select-Object -ExpandProperty Value)
|
||||
}
|
||||
catch {
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
# Start each module import with empty collections (see Reset() above). Safe to
|
||||
# run unconditionally: Documentation.ps1 dot-sources Classes/ before Core/,
|
||||
# InputProviders/, OutputProviders/ and PolicyTypeHandlers/, so this executes
|
||||
# before any Add-Documentation*/RegisterHandler call repopulates the lists.
|
||||
[DocumentationRegistry]::Reset()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
# Documentation-only grouping for the tenant-default enrollment policies.
|
||||
#
|
||||
# deviceManagement/deviceEnrollmentConfigurations carries several policies that read
|
||||
# as one enrollment-restrictions area, but each is its own policy TYPE here
|
||||
# (EnrollmentLimit, EnrollmentRestrictions, EnrollmentStatusPage,
|
||||
# WindowsHelloForBusiness, WindowsRestore). The document builds its second level from
|
||||
# PolicyType.Title, so every one of those types got a heading of its own holding a
|
||||
# single child - and because all five ship a tenant default named "All users and all
|
||||
# devices", three of those children were indistinguishable from each other while the
|
||||
# remaining two repeated their own heading word for word:
|
||||
#
|
||||
# Windows Hello for Business <- heading, from PolicyType.Title
|
||||
# Windows Hello for Business <- the only child, same text
|
||||
#
|
||||
# Group the five under one heading, and title each child by its type's PolicyName
|
||||
# ("Device limit restrictions", "Device platform restrictions", ...) so each entry
|
||||
# says which policy it is.
|
||||
#
|
||||
# Deliberately documentation-only. The heading cannot come from _APITitle: that same
|
||||
# property titles the app's navigation menu, so changing it there would rename the
|
||||
# nav and the type's identity for every other consumer.
|
||||
|
||||
$script:_docEnrollmentGroup = [PSCustomObject]@{
|
||||
# Grouping key, not a real policy-type id. The engine groups the second level on
|
||||
# this value, so it must not collide with any PolicyType.Id or an unrelated type
|
||||
# would be merged into this heading.
|
||||
Id = 'DocEnrollmentRestrictions'
|
||||
Title = 'Enrollment Restrictions'
|
||||
TypeIds = @(
|
||||
'EnrollmentLimit'
|
||||
'EnrollmentRestrictions'
|
||||
'EnrollmentStatusPage'
|
||||
'WindowsHelloForBusiness'
|
||||
'WindowsRestore'
|
||||
)
|
||||
}
|
||||
|
||||
# The documentation grouping a policy belongs to, or $null for everything else -
|
||||
# which is every other policy type, so the engine keeps its normal per-type heading.
|
||||
function Get-DocumentationTypeGroup {
|
||||
param($PolicyObject)
|
||||
|
||||
$typeId = $null
|
||||
if ($PolicyObject -and $PolicyObject.PolicyType) { $typeId = [string]$PolicyObject.PolicyType.Id }
|
||||
if ($typeId -and $script:_docEnrollmentGroup.TypeIds -contains $typeId) {
|
||||
return $script:_docEnrollmentGroup
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# The title a grouped tenant-default policy is documented under, or $null to keep the
|
||||
# policy's own display name.
|
||||
#
|
||||
# Only the tenant default is retitled. A custom policy of the same type - a second
|
||||
# enrollment status page, a per-platform "Block Android Device Administrator
|
||||
# Enrollment" restriction - has a real name of its own and must keep it, otherwise
|
||||
# several of them would collapse onto the same title.
|
||||
#
|
||||
# Default-ness is read from two signals for the same reason
|
||||
# DeviceEnrollmentObject.GetFileName (Classes/IntuneEnrollmentClasses.ps1) uses two:
|
||||
# priority is not guaranteed to be present on every payload, and the id form is only
|
||||
# reliable once the id has been populated. A policy that is default in either sense
|
||||
# is treated as the default.
|
||||
function Get-DocumentationEnrollmentDefaultName {
|
||||
param($PolicyObject)
|
||||
|
||||
if (-not (Get-DocumentationTypeGroup $PolicyObject)) { return $null }
|
||||
|
||||
$obj = if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject) {
|
||||
$PolicyObject.JsonObject
|
||||
} else {
|
||||
$PolicyObject
|
||||
}
|
||||
|
||||
$isDefault = ($obj.priority -eq 0) -or ([string]$PolicyObject.Id -match '_Default')
|
||||
if (-not $isDefault) { return $null }
|
||||
|
||||
# PolicyName first - "Device limit restrictions" says more than the heading-shaped
|
||||
# "Enrollment Limit" - then Title, for a type that declares no _PolicyName.
|
||||
$policyName = [string]$PolicyObject.PolicyType.PolicyName
|
||||
if ([string]::IsNullOrWhiteSpace($policyName)) {
|
||||
$policyName = [string]$PolicyObject.PolicyType.Title
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($policyName)) { return $null }
|
||||
return $policyName
|
||||
}
|
||||
|
||||
# The text an object will be titled with, resolvable BEFORE documentation runs.
|
||||
#
|
||||
# The engine sorts policies up front, but the retitling above happens per object
|
||||
# while it is being documented, so sorting on .Name alone ordered the document by
|
||||
# text the reader never sees - three "All users and all devices" siblings in
|
||||
# arbitrary order. Sorting on this keeps the document and its table of contents in
|
||||
# the same, stable order.
|
||||
function Get-DocumentationSortName {
|
||||
param($PolicyObject)
|
||||
|
||||
$name = Get-DocumentationEnrollmentDefaultName $PolicyObject
|
||||
if ($name) { return $name }
|
||||
return [string]$PolicyObject.Name
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
# Generic fallback documenter.
|
||||
#
|
||||
# Documents policy objects that match NO handler and NO input provider (the
|
||||
# engine's 'NoProvider' path) instead of producing an empty stub. Output is a
|
||||
# deliberately simple, schema-less dump:
|
||||
# - standard basic-info rows (Name / Description / Platform / Profile type /
|
||||
# Created / Modified / Version) via the shared Add-Basic* helpers
|
||||
# - one settings row per non-internal property + value
|
||||
# - object-valued properties recurse into named sub-levels: the parent name
|
||||
# becomes the Category (depth 1) then SubCategory (depth 2). Deeper objects,
|
||||
# and any array-of-objects, are emitted as a single compact-JSON value (the
|
||||
# output model only has two named levels; HTML/MD/Word still indent by Level).
|
||||
# - arrays of scalars are joined; empty arrays / null / empty values are skipped
|
||||
# - secret-looking properties are redacted; very long strings are truncated
|
||||
#
|
||||
# Scope tags + assignments are NOT added here - the engine runs its existing
|
||||
# Add-ScopeTagsBasicInfoIfApplicable / Add-AssignmentsForObjectIfApplicable
|
||||
# post-steps after this returns (BasicInfo is populated, so they are not no-ops).
|
||||
#
|
||||
# Opt-in: the engine only calls this when Options.FallbackDocumentation is $true.
|
||||
|
||||
$script:_fallbackSecretRegex = '(?i)(password|secret|privatekey|private_key|pfxblob|clientsecret|encryptionkey|\bpfx\b)'
|
||||
$script:_fallbackExcludedNames = @(
|
||||
'id', 'createdDateTime', 'lastModifiedDateTime', 'modifiedDateTime', 'version',
|
||||
'roleScopeTagIds', 'roleScopeTags', 'assignments', 'supportsScopeTags',
|
||||
# already emitted as basic-info rows by Add-BasicDefaultValues
|
||||
'displayName', 'name', 'description'
|
||||
)
|
||||
$script:_fallbackMaxNamedDepth = 2 # Category + SubCategory; deeper -> compact JSON
|
||||
$script:_fallbackMaxStringLen = 2000 # truncate longer string values
|
||||
|
||||
function Get-FallbackDisplayName {
|
||||
param([string]$PropName)
|
||||
if (-not $PropName) { return $PropName }
|
||||
$s = [regex]::Replace($PropName, '([a-z0-9])([A-Z])', '$1 $2') # camelCase -> camel Case
|
||||
$s = [regex]::Replace($s, '([A-Z]+)([A-Z][a-z])', '$1 $2') # ABCWord -> ABC Word
|
||||
$s = ($s -replace '[_\-]', ' ').Trim()
|
||||
if ($s.Length -gt 0) { $s = $s.Substring(0, 1).ToUpper() + $s.Substring(1) }
|
||||
return $s
|
||||
}
|
||||
|
||||
function Test-FallbackExcluded {
|
||||
param([string]$Name, $RemoveList)
|
||||
if (-not $Name) { return $true }
|
||||
if ($Name -like '*@odata*') { return $true }
|
||||
if ($Name.StartsWith('#')) { return $true }
|
||||
if ($script:_fallbackExcludedNames -contains $Name) { return $true }
|
||||
if ($RemoveList -and ($RemoveList -contains $Name)) { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-FallbackIsObject {
|
||||
param($Value)
|
||||
return ($Value -is [System.Management.Automation.PSCustomObject] -or $Value -is [hashtable])
|
||||
}
|
||||
|
||||
function Test-FallbackIsArray {
|
||||
param($Value)
|
||||
return ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string])
|
||||
}
|
||||
|
||||
function Format-FallbackScalar {
|
||||
param($Value, [string]$Name)
|
||||
if ($null -eq $Value) { return $null }
|
||||
if ($Name -match $script:_fallbackSecretRegex) { return '*** redacted ***' }
|
||||
if ($Value -is [bool]) { return ([bool]$Value).ToString() }
|
||||
if ($Value -is [datetime]) { return (Format-BasicDateValue $Value) }
|
||||
$s = [string]$Value
|
||||
if ($s.Length -gt $script:_fallbackMaxStringLen) {
|
||||
$s = $s.Substring(0, $script:_fallbackMaxStringLen) + ' ... (truncated)'
|
||||
}
|
||||
return $s
|
||||
}
|
||||
|
||||
function Add-FallbackRow {
|
||||
param([string]$Name, $Value, $RawValue, [string]$Category, [string]$SubCategory, [int]$Level, [string]$EntityKey)
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
$ctx.AddSetting([PSCustomObject]@{
|
||||
Name = $Name; Value = $Value; Category = $Category; SubCategory = $SubCategory
|
||||
Level = $Level; RawValue = $RawValue; EntityKey = $EntityKey
|
||||
})
|
||||
}
|
||||
|
||||
# Recursively walk an object's properties, emitting one row per non-internal
|
||||
# property. Scalars are emitted before nested objects at each level so flat
|
||||
# properties group above their sub-sections.
|
||||
function Add-FallbackProperties {
|
||||
param($Obj, [int]$Level, [string]$Category, [string]$SubCategory, [string]$PathPrefix, $RemoveList)
|
||||
if ($null -eq $Obj) { return }
|
||||
|
||||
$candidates = @()
|
||||
foreach ($p in $Obj.PSObject.Properties) {
|
||||
if (Test-FallbackExcluded $p.Name $RemoveList) { continue }
|
||||
if ($null -eq $p.Value) { continue }
|
||||
$candidates += $p
|
||||
}
|
||||
|
||||
# Pass 1: scalars + arrays (leaf rows). Pass 2: nested objects (sub-levels).
|
||||
$leaves = @($candidates | Where-Object { -not (Test-FallbackIsObject $_.Value) })
|
||||
$nested = @($candidates | Where-Object { (Test-FallbackIsObject $_.Value) })
|
||||
|
||||
foreach ($p in $leaves) {
|
||||
$name = $p.Name
|
||||
$val = $p.Value
|
||||
$display = Get-FallbackDisplayName $name
|
||||
$entityKey = if ($PathPrefix) { "$PathPrefix.$name" } else { $name }
|
||||
|
||||
if ($name -match $script:_fallbackSecretRegex) {
|
||||
Add-FallbackRow $display '*** redacted ***' $null $Category $SubCategory $Level $entityKey
|
||||
continue
|
||||
}
|
||||
if (Test-FallbackIsArray $val) {
|
||||
$items = @($val)
|
||||
if ($items.Count -eq 0) { continue }
|
||||
$hasComplex = $false
|
||||
foreach ($it in $items) { if ((Test-FallbackIsObject $it) -or (Test-FallbackIsArray $it)) { $hasComplex = $true; break } }
|
||||
if ($hasComplex) {
|
||||
Add-FallbackRow $display ($val | ConvertTo-Json -Depth 20 -Compress) $val $Category $SubCategory $Level $entityKey
|
||||
}
|
||||
else {
|
||||
$joined = ($items | ForEach-Object { Format-FallbackScalar $_ $name }) -join ([Environment]::NewLine)
|
||||
if ($joined) { Add-FallbackRow $display $joined $val $Category $SubCategory $Level $entityKey }
|
||||
}
|
||||
continue
|
||||
}
|
||||
$fv = Format-FallbackScalar $val $name
|
||||
if ($null -eq $fv -or "$fv" -eq '') { continue }
|
||||
Add-FallbackRow $display $fv $val $Category $SubCategory $Level $entityKey
|
||||
}
|
||||
|
||||
foreach ($p in $nested) {
|
||||
$name = $p.Name
|
||||
$val = $p.Value
|
||||
$display = Get-FallbackDisplayName $name
|
||||
$entityKey = if ($PathPrefix) { "$PathPrefix.$name" } else { $name }
|
||||
|
||||
if ($Level -lt $script:_fallbackMaxNamedDepth) {
|
||||
$childCat = if ($Level -eq 0) { $display } else { $Category }
|
||||
$childSub = if ($Level -eq 1) { $display } else { $SubCategory }
|
||||
Add-FallbackProperties $val ($Level + 1) $childCat $childSub $entityKey $RemoveList
|
||||
}
|
||||
else {
|
||||
Add-FallbackRow $display ($val | ConvertTo-Json -Depth 20 -Compress) $val $Category $SubCategory $Level $entityKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-GenericFallbackDocumentation {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
Set-CurrentDocumentationContext $Context
|
||||
|
||||
# Standard header rows (Name / Description / Platform / Profile type, then
|
||||
# Created / Modified / Version). Populating BasicInfo also un-gates the
|
||||
# engine's scope-tag and assignment post-steps.
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
|
||||
$obj = if ($PolicyObject.PSObject.Properties['JsonObject'] -and $PolicyObject.JsonObject) {
|
||||
$PolicyObject.JsonObject
|
||||
} else {
|
||||
$PolicyObject
|
||||
}
|
||||
|
||||
$removeList = $null
|
||||
if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.PSObject.Properties['_PropertiesToRemove']) {
|
||||
$removeList = $PolicyObject.PolicyType._PropertiesToRemove
|
||||
}
|
||||
|
||||
Add-FallbackProperties $obj 0 $null $null '' $removeList
|
||||
|
||||
$Context.InputType = 'GenericFallback'
|
||||
}
|
||||
|
||||
# Register the generic fallback as the LAST input provider (Order = MaxValue) so
|
||||
# it only ever sees objects that no specific provider claimed. Its Match honors
|
||||
# the opt-in Options.FallbackDocumentation flag - when off, it declines and the
|
||||
# object falls through to the engine's NoProvider stub, exactly as before.
|
||||
function Invoke-InitializeGenericFallbackInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'GenericFallback'
|
||||
Order = [int]::MaxValue
|
||||
Match = {
|
||||
param($PolicyObject, $Context)
|
||||
return [bool]($Context -and $Context.Options -and $Context.Options.FallbackDocumentation -eq $true)
|
||||
}
|
||||
Translate = { param($PolicyObject, $Context) Invoke-GenericFallbackDocumentation $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
Invoke-InitializeGenericFallbackInput
|
||||
@@ -0,0 +1,112 @@
|
||||
# Shared dispatch for custom behavior used during schema-driven ObjectInfo walks.
|
||||
# Whole-object handlers are intentionally separate: registering a handler prevents
|
||||
# Manifest/Profile fallback, while these customizers augment that fallback.
|
||||
|
||||
function Add-DocumentationObjectInfoCustomizer {
|
||||
param([Parameter(Mandatory)][PSCustomObject]$Customizer)
|
||||
[DocumentationRegistry]::RegisterObjectInfoCustomizer($Customizer)
|
||||
}
|
||||
|
||||
function Get-DocumentationObjectInfoType {
|
||||
param($Obj)
|
||||
if (-not $Obj) { return $null }
|
||||
return [string]$Obj.'@odata.type'
|
||||
}
|
||||
|
||||
function Get-DocumentationObjectInfoCustomizers {
|
||||
param($Obj)
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
$topObj = if ($ctx.CurrentObject) { $ctx.CurrentObject } else { $Obj }
|
||||
return @([DocumentationRegistry]::FindObjectInfoCustomizers((Get-DocumentationObjectInfoType $topObj)))
|
||||
}
|
||||
|
||||
function Initialize-DocumentationObjectInfoObject {
|
||||
param($Obj)
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
|
||||
if ($customizer.InitializeObject) {
|
||||
& $customizer.InitializeObject $Obj $ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-DocumentationObjectInfoGetPropertyObject {
|
||||
param($Obj, $Prop)
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
$topObj = $ctx.CurrentObject
|
||||
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
|
||||
if (-not $customizer.GetPropertyObject) { continue }
|
||||
$ret = & $customizer.GetPropertyObject $topObj $Obj $Prop $ctx
|
||||
if ($ret) { return $ret }
|
||||
}
|
||||
return $Obj
|
||||
}
|
||||
|
||||
function Invoke-DocumentationObjectInfoGetChildObject {
|
||||
param($Obj, $Prop)
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
$topObj = $ctx.CurrentObject
|
||||
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
|
||||
if (-not $customizer.GetChildObject) { continue }
|
||||
$ret = & $customizer.GetChildObject $topObj $Obj $Prop $ctx
|
||||
if ($ret) { return $ret }
|
||||
}
|
||||
return $Obj
|
||||
}
|
||||
|
||||
function Invoke-DocumentationObjectInfoGetProfileValue {
|
||||
param($Obj, $Prop)
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
$topObj = $ctx.CurrentObject
|
||||
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
|
||||
if (-not $customizer.GetProfileValue) { continue }
|
||||
$ret = & $customizer.GetProfileValue $topObj $Obj $Prop $ctx
|
||||
if ($null -ne $ret) { return $ret }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-DocumentationObjectInfoPostAddValue {
|
||||
param($Prop)
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
$topObj = $ctx.CurrentObject
|
||||
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $topObj)) {
|
||||
if ($customizer.PostAddValue) {
|
||||
& $customizer.PostAddValue $topObj $Prop $ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Finalize-DocumentationObjectInfoObject {
|
||||
param($Obj)
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
foreach ($customizer in (Get-DocumentationObjectInfoCustomizers $Obj)) {
|
||||
if ($customizer.FinalizeObject) {
|
||||
& $customizer.FinalizeObject $Obj $ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Translates the id of Reusable Settings in Settings Catalog policies to their display name
|
||||
function Invoke-DocumentationSettingsCatalogPostProcess {
|
||||
param($Obj, [DocumentationContext]$Context)
|
||||
if (-not $Obj.templateReference.templateId -or
|
||||
-not ([string]$Obj.templateReference.templateId).StartsWith('19c8aa67-f286-4861-9aa0-f23541d31680')) {
|
||||
return
|
||||
}
|
||||
# Reusable settings are resolved by id from the source tenant (source-specific).
|
||||
if ($Context.SourceTenantUnavailable -or -not (Test-DocumentationGraphAvailable)) {
|
||||
return
|
||||
}
|
||||
foreach ($setting in @($Context.SettingsData | Where-Object SettingId -EQ 'vendor_msft_firewall_mdmstore_firewallrules_{firewallrulename}_remoteaddressdynamickeywords')) {
|
||||
if (-not $setting.RawValue) { continue }
|
||||
try {
|
||||
$reusable = Invoke-MSGraphAPI -Url "/deviceManagement/reusablePolicySettings/$($setting.RawValue)"
|
||||
if ($reusable.displayName) { $setting.Value = $reusable.displayName }
|
||||
else { Write-Log "No Reusable Settings object found with ID $($setting.RawValue)" 2 }
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to resolve reusable setting $($setting.RawValue)" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
# ObjectInfo JSON walker — the core of the generic Profile input provider.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:2276 (Invoke-TranslateSection,
|
||||
# ~440 LOC) plus the Invoke-VerifyCondition helper (~70 LOC) and
|
||||
# Get-CultureLanguageString (~50 LOC).
|
||||
#
|
||||
# Drives schema-driven translation for ~140 policy types catalogued in
|
||||
# Config/ObjectCategories.json. Each ObjectInfo JSON file under
|
||||
# Config/ObjectInfo/<category>_<policyType>.json describes the per-property
|
||||
# metadata (dataType, entityKey, nameResourceKey, child layout) and the
|
||||
# walker dispatches each property to the appropriate translate primitive
|
||||
# based on dataType.
|
||||
|
||||
# ---- Section walker ----
|
||||
|
||||
# Tracks the parent prop being walked so propLevel adjusts correctly when
|
||||
# recursing into children. Module-scope (replaces old $script:currentParent).
|
||||
$script:_currentSectionParent = $null
|
||||
|
||||
# Defaults: properties whose nameResourceKey shows up in this list are skipped
|
||||
# entirely (purely visual elements in the old portal that don't translate
|
||||
# to documentation content). Old code at Documentation.psm1:2977.
|
||||
# ToDo: Review if these should be implemented or actually ignored
|
||||
$script:_categoriesToIgnore = @(
|
||||
'defenderSecurityCenterContactOptionsText'
|
||||
'globalConfigurationsDescription','generalNetworkSettingsHeader'
|
||||
'firewallCreateRules','exploitGuardCFHeadingText','exploitGuardNFTitle'
|
||||
'exploitGuardEPExplainationPart1','exploitGuardEPExplainationPart2'
|
||||
'exploitGuardEPExplainationPart3','exploitGuardEPExplainationPart4'
|
||||
'defenderSecurityCenterSubHeaderText','defenderSecurityCenterITContactInformationSubHeaderText'
|
||||
'windows10EndpointProtectionDeviceGuardLearnMore'
|
||||
'win10DefaultPrivacyHeader','dfciBuiltinHeaderDescName'
|
||||
)
|
||||
|
||||
# Resource keys that upstream renamed while the blade metadata kept referencing
|
||||
# the old name. Microsoft's own portal cannot render these tooltips either, so
|
||||
# there is nothing to wait for - map them to the current name.
|
||||
# Confirmed 2026-08-22 by the IntuneLanuageAndObjects generator, which extracts
|
||||
# the portal's ClientResources verbatim.
|
||||
$script:_resourceKeyAliases = @{
|
||||
'autoInstallAndRebootAtScheduledTime' = 'autoInstallAndRebootAtScheduledTimeOption'
|
||||
# Only connecteddevices_iosgeneral.json references this, so the iOS variant
|
||||
# is the correct target; a MacOS variant also exists upstream.
|
||||
'blockAirPrintiBeaconDiscoveryDescription' = 'blockAirPrintiBeaconDiscoveryDescriptionIOS'
|
||||
}
|
||||
|
||||
# Resolve an ObjectInfo resource key to its display string, or $null.
|
||||
#
|
||||
# Keys without a namespace live under SettingDetails. Two upstream quirks are
|
||||
# handled here so callers do not each reimplement them:
|
||||
#
|
||||
# - Purely numeric keys are portal metadata artifacts, not string ids. The
|
||||
# AndroidDeviceOwner and AOSP PKCS files carry emptyValueResourceKey:"1"
|
||||
# verbatim from the blade metadata, which resolves to nothing and logs a
|
||||
# "Could not find string" warning on every documented policy. Skipped the way
|
||||
# the existing 'Empty' / 'LearnMore' sentinels are.
|
||||
# - Renamed keys are redirected via $script:_resourceKeyAliases.
|
||||
function Get-ObjectInfoResourceString {
|
||||
param(
|
||||
[string]$Key,
|
||||
# emptyValueResourceKey values are already fully qualified upstream and
|
||||
# must not get the SettingDetails prefix.
|
||||
[switch]$NoPrefix
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Key)) { return $null }
|
||||
if ($Key -match '^\d+$') { return $null }
|
||||
|
||||
if ($script:_resourceKeyAliases.ContainsKey($Key)) { $Key = $script:_resourceKeyAliases[$Key] }
|
||||
|
||||
$full = if ($NoPrefix -or $Key.Contains('.')) { $Key } else { "SettingDetails.$Key" }
|
||||
try { return Get-LanguageString $full }
|
||||
catch {
|
||||
Write-Log "Get-LanguageString '$full' failed: $($_.Exception.Message)" 2
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Walk a flat settings object through an ObjectInfo manifest file. Used by the
|
||||
# AppConfig handlers to give Outlook/Edge their schema-driven rows (the old code
|
||||
# called Invoke-TranslateSection directly against #AppConfig*.json). $ManifestPath
|
||||
# is a full path under Config\ObjectInfo\.
|
||||
function Invoke-DocAppConfigManifest {
|
||||
param($SettingsObject, [string]$ManifestPath, [DocumentationContext]$Context)
|
||||
if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { return }
|
||||
try {
|
||||
$jsonObj = [IO.File]::ReadAllText($ManifestPath) | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to read AppConfig manifest $ManifestPath" $_.Exception
|
||||
return
|
||||
}
|
||||
if (-not $jsonObj) { return }
|
||||
$prev = $Context.CurrentObject
|
||||
$Context.CurrentObject = $SettingsObject
|
||||
try { Invoke-TranslateSection $SettingsObject $jsonObj $null }
|
||||
catch { Write-LogError "Failed to translate AppConfig manifest $(Split-Path -Leaf $ManifestPath)" $_.Exception }
|
||||
finally { $Context.CurrentObject = $prev }
|
||||
}
|
||||
|
||||
function Invoke-TranslateSection {
|
||||
param($Obj, $SectionObject, $ObjInfo, $Parent = $null)
|
||||
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
|
||||
# Reset/adjust propLevel based on whether we're a new walk or recursing
|
||||
if ($null -eq $Parent -or $ctx.PropLevel -lt 0) {
|
||||
$ctx.PropLevel = 0
|
||||
}
|
||||
elseif ($Parent -ne $script:_currentSectionParent) {
|
||||
$ctx.PropLevel++
|
||||
}
|
||||
|
||||
foreach ($prop in $SectionObject) {
|
||||
$value = $null
|
||||
$valueSet = $false
|
||||
$useParentProp = $false
|
||||
$payloadFile = $false
|
||||
$skipChildren = $false
|
||||
|
||||
if (-not (Invoke-VerifyCondition $Obj $prop $ObjInfo)) {
|
||||
Write-LogDebug "Condition returned false: $($prop.Condition | ConvertTo-Json -Depth 50 -Compress)"
|
||||
continue
|
||||
}
|
||||
|
||||
$Obj = Get-CustomPropertyObject $Obj $prop
|
||||
$rawValue = $Obj."$($prop.entityKey)"
|
||||
|
||||
# ---- Section/category headers (dataType 8) ----
|
||||
if ($prop.dataType -eq 8) {
|
||||
if ($prop.nameResourceKey -eq 'LearnMore') { continue }
|
||||
elseif ($prop.nameResourceKey -eq 'Empty') { $ctx.CurrentSubCategory = $null }
|
||||
elseif ($prop.nameResourceKey -in $script:_categoriesToIgnore) { continue }
|
||||
elseif ($prop.nameResourceKey) {
|
||||
$key = if ($prop.nameResourceKey.Contains('.')) { $prop.nameResourceKey } else { "SettingDetails.$($prop.nameResourceKey)" }
|
||||
$tmpStr = Get-LanguageString $key
|
||||
if ($tmpStr -and $tmpStr.Length -lt 75) {
|
||||
$ctx.CurrentSubCategory = $tmpStr
|
||||
}
|
||||
elseif ($tmpStr) {
|
||||
Write-LogDebug "SubCategory ignored based on length: $tmpStr"
|
||||
}
|
||||
}
|
||||
$ctx.PropLevel = -1
|
||||
Invoke-ChildSections $Obj $prop
|
||||
# A header without child sections leaves the -1 reset sentinel dangling;
|
||||
# the next property's childSettings recursion would then reset to level 0
|
||||
# instead of indenting one level under its parent row. Normalize here so
|
||||
# only the header's own children get the flat-level reset.
|
||||
if ($ctx.PropLevel -lt 0) { $ctx.PropLevel = 0 }
|
||||
continue
|
||||
}
|
||||
|
||||
# ---- Complex options (dataType 5) ----
|
||||
if ($prop.dataType -eq 5) {
|
||||
if ($prop.enabled -eq $false -and $ObjInfo.ShowDisabled -ne $true) { continue }
|
||||
if (-not $prop.EntityKey -and $prop.nameResourceKey) {
|
||||
$ctx.PropLevel = -1
|
||||
$key = if ($prop.nameResourceKey.Contains('.')) { $prop.nameResourceKey } else { "SettingDetails.$($prop.nameResourceKey)" }
|
||||
$ctx.CurrentSubCategory = Get-LanguageString $key
|
||||
}
|
||||
else {
|
||||
$ctx.PropLevel--
|
||||
}
|
||||
foreach ($tmpObj in $Obj) {
|
||||
Invoke-TranslateSection $tmpObj $prop.complexOptions $ObjInfo -Parent $prop
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
# ---- Complex option based on sub-property (dataType 6) ----
|
||||
if ($prop.dataType -eq 6) {
|
||||
if ($prop.enabled -eq $false -and $ObjInfo.ShowDisabled -ne $true) { continue }
|
||||
$ctx.PropLevel--
|
||||
$propObj = $null
|
||||
if ($prop.entityKey) { $propObj = $Obj.PSObject.Properties | Where-Object Name -EQ $prop.entityKey }
|
||||
$iter = if ($null -ne $propObj) { $rawValue } else { $Obj }
|
||||
foreach ($tmpObj in $iter) {
|
||||
Invoke-TranslateSection $tmpObj $prop.complexOptions $ObjInfo -Parent $prop
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
# ---- Skip-but-add-children label (dataType 9) ----
|
||||
if ($prop.dataType -eq 9) {
|
||||
$ctx.PropLevel--
|
||||
Invoke-ChildSections $Obj $prop
|
||||
continue
|
||||
}
|
||||
|
||||
# ---- Information box: ignore (dataType 10) ----
|
||||
if ($prop.dataType -eq 10) { continue }
|
||||
|
||||
# ---- Static-string label (dataType 101): language-id lookup ----
|
||||
if ($prop.dataType -eq 101) {
|
||||
if ($prop.value) {
|
||||
$value = Get-LanguageString $prop.value
|
||||
Add-PropertyInfo $prop $value $rawValue $rawValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
# ---- Static value (dataType 107) ----
|
||||
if ($prop.dataType -eq 107) {
|
||||
if ($prop.value) {
|
||||
Add-PropertyInfo $prop $prop.value $prop.value $prop.value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
# ---- Generic property path (dataType varies, requires entityKey) ----
|
||||
if (-not [string]::IsNullOrEmpty($prop.entityKey)) {
|
||||
$valueSet = ($null -ne $rawValue)
|
||||
|
||||
# Determine propValue (with defaults fallback). Old engine gates the
|
||||
# unconfigured/default substitutions on $global:chk* UI checkboxes
|
||||
# which default to UNCHECKED — meaning when a property is null on
|
||||
# the input, the walker just uses null (and most translate primitives
|
||||
# then either skip the row or emit "Not configured" via their own
|
||||
# logic). My port honors that by gating on $ctx.Options.SetUnconfigured
|
||||
# Value / SetDefaultValue (also default false).
|
||||
$propValue = if ($null -ne $rawValue) { $rawValue }
|
||||
elseif (-not [string]::IsNullOrEmpty($prop.unconfiguredValue) -and $ctx.Options.SetUnconfiguredValue) {
|
||||
Add-NotConfiguredProperty $prop
|
||||
$prop.unconfiguredValue
|
||||
}
|
||||
elseif (-not [string]::IsNullOrEmpty($prop.defaultValue) -and $ctx.Options.SetDefaultValue) {
|
||||
$prop.defaultValue
|
||||
}
|
||||
elseif (-not [string]::IsNullOrEmpty($prop.emptyValueResourceKey) -and $ctx.Options.SetDefaultValue) {
|
||||
Get-ObjectInfoResourceString $prop.emptyValueResourceKey -NoPrefix
|
||||
}
|
||||
else { $rawValue }
|
||||
|
||||
$addPropertyInfo = $true
|
||||
$customValue = Get-CustomProfileValue $Obj $prop
|
||||
|
||||
if ($customValue -is [bool] -and $customValue -eq $false) {
|
||||
continue
|
||||
}
|
||||
elseif (-not $customValue) {
|
||||
|
||||
# Linked certificate (dataType 4): live Graph navigationLink
|
||||
# Stub offline — uses #CustomRef_ embedded data when present
|
||||
if ($prop.dataType -eq 4) {
|
||||
$useParentProp = $true
|
||||
$cert = $null
|
||||
if (-not $ctx.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$url = $ctx.CurrentObject."$($prop.entityKey)@odata.navigationLink"
|
||||
if ($url) {
|
||||
# Most policies advertise the navigationLink even when no
|
||||
# certificate is associated; the GET 404s. Cache the
|
||||
# outcome on $ctx so the second walk of the same policy
|
||||
# (the schema lists the same entityKey twice for the
|
||||
# SCEP+PKCS+derived flavours) and any later policies in
|
||||
# the bulk run skip a known-empty fetch.
|
||||
if (-not $ctx.PSObject.Properties['_LinkedCertCache']) {
|
||||
$ctx | Add-Member -MemberType NoteProperty -Name '_LinkedCertCache' -Value (@{}) -Force
|
||||
}
|
||||
if ($ctx._LinkedCertCache.ContainsKey($url)) {
|
||||
$cert = $ctx._LinkedCertCache[$url]
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$cert = Invoke-MSGraphAPI -Url $url -ODataMetadata 'minimal' -NoError
|
||||
} catch { $cert = $null }
|
||||
$ctx._LinkedCertCache[$url] = $cert
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if ($cert) {
|
||||
if ($cert.value -is [object[]]) {
|
||||
$certs = @($cert.value | ForEach-Object { $_.displayName }) | Where-Object { $_ }
|
||||
if ($certs.Count -gt 0) { $value = $certs -join $ctx.ObjectSeparator }
|
||||
}
|
||||
elseif ($cert.displayName) {
|
||||
$value = $cert.displayName
|
||||
}
|
||||
$rawValue = $value
|
||||
}
|
||||
elseif ($ctx.CurrentObject.'@ObjectFromFile' -eq $true -or $ctx.SourceTenantUnavailable) {
|
||||
$refKey = "#CustomRef_$($prop.entityKey)"
|
||||
if ($ctx.CurrentObject.$refKey) {
|
||||
$sep = $ctx.CurrentObject.$refKey.IndexOf('|:|')
|
||||
$value = if ($sep -gt -1) { $ctx.CurrentObject.$refKey.Substring(0, $sep) } else { $ctx.CurrentObject.$refKey }
|
||||
}
|
||||
$rawValue = $value
|
||||
}
|
||||
}
|
||||
# Multi-option based on boolean value where the property name IS the key (dataType 200)
|
||||
elseif ($prop.dataType -eq 200) {
|
||||
$value = Get-LanguageString $prop.entityKey
|
||||
}
|
||||
# Property missing on the object (and not "allowMissing")
|
||||
elseif (-not $prop.allowMissing -and
|
||||
$prop.entityKey -ne '.' -and
|
||||
-not ($Obj.PSObject.Properties | Where-Object Name -EQ $prop.entityKey) -and
|
||||
-not ($Obj.PSObject.Properties | Where-Object Name -EQ "$($prop.entityKey)@odata.navigationLink")) {
|
||||
if ($prop.enabled -ne $false) {
|
||||
Write-Log "Property with EntityKey $($prop.entityKey) is missing. Property will not be added!" 2
|
||||
}
|
||||
else {
|
||||
Write-LogDebug "Disabled property with EntityKey $($prop.entityKey) is missing. Property will not be added!"
|
||||
}
|
||||
continue
|
||||
}
|
||||
else {
|
||||
# NOTE: `continue` inside `switch` only goes to the next
|
||||
# switch case match in PowerShell — it does NOT skip code
|
||||
# after the switch. Cases that handle their own row emission
|
||||
# (Option / Table) must set $addPropertyInfo = $false so the
|
||||
# Add-PropertyInfo call below is skipped. (Earlier port used
|
||||
# `continue` here and produced duplicate rows.)
|
||||
switch ([int]$prop.dataType) {
|
||||
0 { $value = Invoke-TranslateBoolean $Obj $prop }
|
||||
1 {
|
||||
# Base64 e.g. certificate data
|
||||
$value = if ($prop.filenameEntityKey -and $Obj."$($prop.filenameEntityKey)") {
|
||||
$Obj."$($prop.filenameEntityKey)"
|
||||
} else {
|
||||
$v = $Obj."$($prop.EntityKey)"
|
||||
if ($v) { try { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($v)) } catch { $v } } else { $v }
|
||||
}
|
||||
}
|
||||
2 {
|
||||
# Multiline string (often a base64-wrapped XML payload file)
|
||||
if ($prop.filenameEntityKey -and $Obj."$($prop.filenameEntityKey)") {
|
||||
$value = $Obj."$($prop.filenameEntityKey)"
|
||||
$payloadFile = $true
|
||||
}
|
||||
else {
|
||||
$v = $Obj."$($prop.EntityKey)"
|
||||
$value = if ($v) { try { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($v)) } catch { $v } } else { $v }
|
||||
}
|
||||
}
|
||||
3 {
|
||||
# Image — placeholder label; raw image data dropped (no consumer yet).
|
||||
$value = if ($propValue) { 'Image file' } else { $null }
|
||||
}
|
||||
7 { $value = $propValue } # omaSettingDateTime — formatting deferred
|
||||
11 { } # App picker — value left $null
|
||||
12 {
|
||||
# Multiline string / array
|
||||
if (($propValue | Measure-Object).Count -gt 0) {
|
||||
$value = $propValue -join $ctx.ObjectSeparator
|
||||
}
|
||||
}
|
||||
13 { $value = Invoke-TranslateMultiOption $Obj $prop }
|
||||
14 { $value = $propValue } # Int32
|
||||
15 { $value = $propValue } # Int64
|
||||
16 { Invoke-TranslateOption $Obj $prop | Out-Null; $addPropertyInfo = $false; $skipChildren = $true }
|
||||
19 { Invoke-TranslateOption $Obj $prop | Out-Null; $addPropertyInfo = $false; $skipChildren = $true }
|
||||
20 { $value = $propValue } # String
|
||||
21 { Invoke-TranslateTable $Obj $prop; $addPropertyInfo = $false; $skipChildren = $true }
|
||||
22 {
|
||||
# Scale value e.g. "4 Years"
|
||||
$value = $propValue
|
||||
$scaleEntityKey = if ($Obj."$($prop.scaleEntityKey)") { $Obj."$($prop.scaleEntityKey)" } else { $prop.defaultScale }
|
||||
if ($scaleEntityKey) {
|
||||
$scaleOption = $prop.scaleOptions | Where-Object value -EQ $scaleEntityKey | Select-Object -First 1
|
||||
if ($scaleOption.nameResourceKey) {
|
||||
$value = '{0} {1}' -f $propValue, (Get-LanguageString "SettingDetails.$($scaleOption.nameResourceKey)")
|
||||
}
|
||||
}
|
||||
}
|
||||
100 { $value = Invoke-TranslateDuration $Obj $prop }
|
||||
102 {
|
||||
$culture = if ($propValue) { $propValue } else { $prop.unconfiguredValue }
|
||||
$value = Get-CultureLanguageString $culture
|
||||
}
|
||||
103 {
|
||||
# Boolean action but hide children on false
|
||||
$value = Invoke-TranslateBoolean $Obj $prop
|
||||
$skipChildren = ($propValue -eq $false)
|
||||
}
|
||||
104 {
|
||||
$value = Invoke-TranslateMultiOptionBoolean $Obj $prop
|
||||
$skipChildren = ($propValue -eq $false)
|
||||
}
|
||||
105 {
|
||||
$value = Invoke-TranslateMultiOptionBoolean $Obj $prop $false
|
||||
$skipChildren = ($propValue -eq $false)
|
||||
}
|
||||
106 {
|
||||
# Array of cultures
|
||||
$tmp = @()
|
||||
foreach ($lng in $propValue) { $tmp += Get-CultureLanguageString $lng }
|
||||
$value = $tmp -join $ctx.ObjectSeparator
|
||||
}
|
||||
108 {
|
||||
# String with format
|
||||
$value = $propValue
|
||||
if ($prop.formatStringKey) {
|
||||
$fmt = Get-LanguageString $prop.formatStringKey
|
||||
if ($fmt) { $value = $fmt -f $propValue }
|
||||
}
|
||||
}
|
||||
default {
|
||||
$nameForLog = if ($prop.nameResourceKey) { Get-LanguageString "SettingDetails.$($prop.nameResourceKey)" } else { '' }
|
||||
Write-Log "Unsupported property '$nameForLog' ($($prop.nameResourceKey)) for object property $($prop.entityKey). Type: $($prop.dataType)" 2
|
||||
$value = $propValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$value = $customValue.Value
|
||||
$rawValue = $customValue.RawValue
|
||||
$valueSet = ($null -ne $rawValue)
|
||||
$addPropertyInfo = $customValue.AddPropertyInfo
|
||||
}
|
||||
|
||||
if ($addPropertyInfo) {
|
||||
$propForAdd = if ($useParentProp -and $Parent) { $Parent } else { $prop }
|
||||
Add-PropertyInfo $propForAdd $value $rawValue
|
||||
|
||||
if ($payloadFile -and $Obj.payload) {
|
||||
$tmpProp = [PSCustomObject]@{
|
||||
nameResourceKey = 'uploadResult'
|
||||
descriptionResourceKey = ''
|
||||
entityKey = 'payloadData'
|
||||
dataType = 20
|
||||
booleanActions = 0
|
||||
category = $prop.Category
|
||||
}
|
||||
$payloadValue = try { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Obj.payload)) } catch { $Obj.payload }
|
||||
Add-PropertyInfo $tmpProp $payloadValue $Obj.payload
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Log "No property entity key: $($prop.dataType) ($($prop.nameResourceKey))" 2
|
||||
}
|
||||
|
||||
if ($valueSet -and -not $skipChildren) {
|
||||
Invoke-ChildSections $Obj $prop
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -ne $Parent -and $Parent -ne $script:_currentSectionParent -and $ctx.PropLevel -gt 0) {
|
||||
$ctx.PropLevel--
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Condition verifier (dataType-independent property gate) ----
|
||||
function Invoke-VerifyCondition {
|
||||
param($Obj, $Prop, $ObjInfo)
|
||||
|
||||
if (-not $Prop.Condition -or ($Prop.Condition.Expressions | Measure-Object).Count -eq 0) { return $true }
|
||||
|
||||
$type = if ($Prop.Condition.type -eq 'and') { 'and' } else { 'or' }
|
||||
$defaultReturn = ($type -eq 'and')
|
||||
|
||||
foreach ($expression in $Prop.Condition.Expressions) {
|
||||
if (-not $expression.property) { continue }
|
||||
$tmpProp = $Obj.PSObject.Properties | Where-Object Name -EQ $expression.property
|
||||
if (-not $tmpProp) {
|
||||
if ($expression.ignoreMissing -eq $true) { continue }
|
||||
return $false
|
||||
}
|
||||
|
||||
$tmpRet = switch ($expression.operator) {
|
||||
'null' { $null -eq $tmpProp.Value }
|
||||
'ne' { $Obj."$($expression.property)" -ne $expression.value }
|
||||
'gt' { $Obj."$($expression.property)" -gt $expression.value }
|
||||
'ge' { $Obj."$($expression.property)" -ge $expression.value }
|
||||
'lt' { $Obj."$($expression.property)" -lt $expression.value }
|
||||
'le' { $Obj."$($expression.property)" -le $expression.value }
|
||||
'like' { $Obj."$($expression.property)" -like $expression.value }
|
||||
'notlike' { $Obj."$($expression.property)" -notlike $expression.value }
|
||||
default {
|
||||
if ($null -eq $expression.value) {
|
||||
$null -ne $tmpProp.Value
|
||||
}
|
||||
else {
|
||||
$Obj."$($expression.property)" -eq $expression.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($tmpRet -eq $true -and $type -eq 'or') { return $true }
|
||||
if ($tmpRet -eq $false -and $type -eq 'and') { return $false }
|
||||
}
|
||||
return $defaultReturn
|
||||
}
|
||||
|
||||
# ---- Culture-code -> language name ----
|
||||
# Used by dataType 102 (Culture name) and 106 (Array of languages).
|
||||
# Looks up Languages.<culture> in the loaded Strings-en.json; falls back to
|
||||
# the OS culture's EnglishName.
|
||||
function Get-CultureLanguageString {
|
||||
param($Culture)
|
||||
|
||||
if (-not $Culture) { return $null }
|
||||
try {
|
||||
if ($Culture -eq 'os-default') { return Get-LanguageString 'Autopilot.OOBE.useOSDefaultLanguage' }
|
||||
if ($Culture -eq 'user-select') { return Get-LanguageString 'Autopilot.OOBE.userSelect' }
|
||||
|
||||
# Force language strings to load by calling Get-LanguageString once
|
||||
Get-LanguageString $null | Out-Null
|
||||
|
||||
$cache = Get-CacheObject "LanguageStrings_$($Culture)"
|
||||
if (-not $cache) { $cache = Get-CacheObject 'LanguageStrings_en' }
|
||||
if ($cache.Languages.$Culture) { return $cache.Languages.$Culture }
|
||||
|
||||
$parts = $Culture.Split('-')
|
||||
if ($parts.Length -eq 3) {
|
||||
$tri = "$($parts[0])-$($parts[1])"
|
||||
if ($cache.Languages.$tri) { return $cache.Languages.$tri }
|
||||
}
|
||||
if ($parts.Length -gt 1 -and $cache.Languages."$($parts[0])") {
|
||||
return $cache.Languages."$($parts[0])"
|
||||
}
|
||||
|
||||
Write-Log "Translated language for $Culture not found" 2
|
||||
return ([cultureinfo]$Culture).EnglishName
|
||||
}
|
||||
catch { return $null }
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
# Settings Catalog walker.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:1210 (Add-SettingsSetting,
|
||||
# ~230 LOC). Recursive walker over the deviceManagementConfigurationSetting
|
||||
# tree — handles 6 settingInstance variants:
|
||||
# - SimpleSettingInstance (string/int value)
|
||||
# - ChoiceSettingInstance (single dropdown, may have child settings)
|
||||
# - ChoiceSettingCollectionInstance (multi-select dropdown)
|
||||
# - GroupSettingCollectionInstance (table-like rows of grouped sub-settings)
|
||||
# - SimpleSettingCollectionInstance (list of simple values)
|
||||
# - GroupSettingInstance (single group container — emits only children)
|
||||
#
|
||||
# Settings catalog state on the context:
|
||||
# $ctx.CachedCfgSettings - settingDefinitionId -> full definition object
|
||||
# $ctx.CfgCategories - flat list of category objects
|
||||
# $script:_curSettingsCatologPolicy - per-policy buffer of settingInfo rows
|
||||
# (drained by the input provider into $ctx.SettingsData in category order)
|
||||
|
||||
$script:_curSettingsCatologPolicy = @()
|
||||
|
||||
function Reset-SettingsCatalogPolicyBuffer {
|
||||
$script:_curSettingsCatologPolicy = @()
|
||||
}
|
||||
|
||||
function Get-SettingsCatalogPolicyBuffer {
|
||||
return $script:_curSettingsCatologPolicy
|
||||
}
|
||||
|
||||
function Add-SettingsSetting {
|
||||
param(
|
||||
$SettingInstance,
|
||||
$SettingsDefs,
|
||||
[int]$ItemLevel = 0,
|
||||
[switch]$SkipAdd
|
||||
)
|
||||
|
||||
if (-not $SettingInstance) { return }
|
||||
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
|
||||
$defaultValue = $null
|
||||
$tableValue = $null
|
||||
$value = $null
|
||||
$rawValue = $null
|
||||
$rawJsonValue = $null
|
||||
$show = $true
|
||||
$childSettings = @()
|
||||
|
||||
# Look up the settings definition: prefer inline ($expand=settingDefinitions
|
||||
# exports), then context cache, then live Graph as last resort. The live
|
||||
# endpoint (configurationSettings/{id}) is GENERIC schema - identical on every
|
||||
# tenant - so it is gated only on connectivity (Test-DocumentationGraphAvailable),
|
||||
# NOT on SourceTenantUnavailable: documenting an export while signed into a
|
||||
# different tenant must still resolve setting names.
|
||||
$settingsDef = $null
|
||||
if ($SettingsDefs) {
|
||||
$settingsDef = $SettingsDefs | Where-Object id -EQ $SettingInstance.settingDefinitionId | Select-Object -First 1
|
||||
}
|
||||
if (-not $settingsDef -and $SettingInstance.settingDefinitionId) {
|
||||
if ($ctx.CachedCfgSettings.ContainsKey($SettingInstance.settingDefinitionId)) {
|
||||
$settingsDef = $ctx.CachedCfgSettings[$SettingInstance.settingDefinitionId]
|
||||
}
|
||||
elseif (Test-DocumentationGraphAvailable) {
|
||||
try {
|
||||
$settingsDef = Invoke-MSGraphAPI -Url "/deviceManagement/configurationSettings/$($SettingInstance.settingDefinitionId)" -AdditionalHeaders (Get-DocAcceptLanguageHeaders $ctx)
|
||||
if ($settingsDef) {
|
||||
$ctx.CachedCfgSettings[$SettingInstance.settingDefinitionId] = $settingsDef
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch settings catalog definition for $($SettingInstance.settingDefinitionId)" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Category lookup: root category becomes Category, leaf becomes SubCategory
|
||||
$categoryDef = $null
|
||||
$objCategory = $null
|
||||
$subCategory = $null
|
||||
if ($settingsDef.categoryId) {
|
||||
$categoryDef = $ctx.CfgCategories | Where-Object Id -EQ $settingsDef.categoryId | Select-Object -First 1
|
||||
if ($categoryDef -and $settingsDef.categoryId -ne $categoryDef.rootCategoryId) {
|
||||
$objCategory = $ctx.CfgCategories | Where-Object Id -EQ $categoryDef.rootCategoryId | Select-Object -First 1
|
||||
$subCategory = $categoryDef
|
||||
}
|
||||
else {
|
||||
$objCategory = $categoryDef
|
||||
}
|
||||
}
|
||||
|
||||
$settingName = ''
|
||||
$settingDescription = ''
|
||||
if ($settingsDef.displayName) {
|
||||
$settingName = $settingsDef.displayName.Trim([Environment]::NewLine).Trim("`n")
|
||||
}
|
||||
if ($settingsDef.description) {
|
||||
$settingDescription = $settingsDef.description.Trim([Environment]::NewLine).Trim("`n")
|
||||
}
|
||||
|
||||
$settingInfo = [PSCustomObject]@{
|
||||
SettingId = $settingsDef.Id
|
||||
SettingKey = ''
|
||||
SettingName = $settingsDef.Name
|
||||
Name = $settingName
|
||||
Description = $settingDescription
|
||||
CategoryId = $objCategory.id
|
||||
Category = $objCategory.displayName
|
||||
CategoryDefinition = $objCategory
|
||||
SubCategory = $subCategory.displayName
|
||||
SubCategoryDefinition = $subCategory
|
||||
Value = $null
|
||||
RawValue = $null
|
||||
RawJsonValue = $null
|
||||
TableValue = $null
|
||||
DefaultValue = $null
|
||||
Level = $ItemLevel
|
||||
Parent = $null
|
||||
Show = $show
|
||||
Type = $SettingInstance.'@odata.type'
|
||||
PropertyIndex = 0
|
||||
RowIndex = 0
|
||||
ChildSettings = @()
|
||||
}
|
||||
|
||||
if (-not $SkipAdd) {
|
||||
$script:_curSettingsCatologPolicy += $settingInfo
|
||||
}
|
||||
|
||||
switch ($SettingInstance.'@odata.type') {
|
||||
|
||||
'#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance' {
|
||||
# Single dropdown
|
||||
$rawValue = $SettingInstance.choiceSettingValue.value
|
||||
$opt = $settingsDef.Options | Where-Object itemId -EQ $rawValue | Select-Object -First 1
|
||||
$value = $opt.displayName
|
||||
if ($settingsDef.defaultOptionId) {
|
||||
$defaultValue = ($settingsDef.Options | Where-Object itemId -EQ $settingsDef.defaultOptionId).displayName
|
||||
}
|
||||
# Children added to the buffer (NOT -SkipAdd) so the HTML output's
|
||||
# flat row iterator emits them with `Level` padding under the
|
||||
# parent. Old code at Documentation.psm1:1300 declared the
|
||||
# -SkippAdd switch but never honored it, so children were always
|
||||
# added — matching that behavior here. See [[group-setting-collection-children]].
|
||||
foreach ($childSetting in $SettingInstance.choiceSettingValue.children) {
|
||||
$tmp = Add-SettingsSetting $childSetting $SettingsDefs ($ItemLevel + 1)
|
||||
if ($tmp) { $tmp.Parent = $settingInfo; $settingInfo.ChildSettings += $tmp }
|
||||
}
|
||||
}
|
||||
|
||||
'#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance' {
|
||||
# Single primitive value
|
||||
$value = $SettingInstance.simpleSettingValue.value
|
||||
$rawValue = $value
|
||||
if ($settingsDef.defaultValue.value) {
|
||||
$defaultValue = $settingsDef.defaultValue.value
|
||||
}
|
||||
}
|
||||
|
||||
'#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance' {
|
||||
# Multi-select dropdown
|
||||
$itemValues = @()
|
||||
$itemRawValues = @()
|
||||
foreach ($colObj in $SettingInstance.choiceSettingCollectionValue) {
|
||||
$itemRawValues += $colObj.value
|
||||
$opt = $settingsDef.Options | Where-Object itemId -EQ $colObj.Value | Select-Object -First 1
|
||||
$itemValues += $opt.displayName
|
||||
}
|
||||
$value = $itemValues -join $ctx.PropertySeparator
|
||||
$rawValue = $itemRawValues -join $ctx.PropertySeparator
|
||||
$rawJsonValue = $SettingInstance.choiceSettingCollectionValue | ConvertTo-Json -Depth 50 -Compress
|
||||
if ($settingsDef.defaultOptionId) {
|
||||
$defaultValue = ($settingsDef.Options | Where-Object itemId -EQ $settingsDef.defaultOptionId).displayName
|
||||
}
|
||||
}
|
||||
|
||||
'#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance' {
|
||||
# Table-like rows of grouped sub-settings — group row itself isn't shown
|
||||
$settingInfo.Show = $false
|
||||
$rowIndex = 1
|
||||
foreach ($groupSettingCollection in $SettingInstance.groupSettingCollectionValue) {
|
||||
$childArr = @()
|
||||
# Endpoint Security templates supply $settingsDefs.id; pure Settings
|
||||
# Catalog uses $settingsDef.childIds. Old code at L1347-1354.
|
||||
$childIds = if ($ctx.CurrentObject.templateReference.templateId -and $SettingsDefs) {
|
||||
$SettingsDefs.id
|
||||
} else {
|
||||
$settingsDef.childIds
|
||||
}
|
||||
foreach ($childId in $childIds) {
|
||||
$childSetting = $groupSettingCollection.children | Where-Object settingDefinitionId -EQ $childId | Select-Object -First 1
|
||||
if (-not $childSetting) { continue }
|
||||
# Children added to buffer (no -SkipAdd) so the HTML output's
|
||||
# flat-row iterator can render each one with `Level` padding —
|
||||
# the parent itself has Show=false above, so only the
|
||||
# children are visible. Without this, the entire group
|
||||
# vanishes from output (the Linux 'Allowed Distros' regression).
|
||||
$tmp = Add-SettingsSetting $childSetting $SettingsDefs ($ItemLevel + 1)
|
||||
if ($tmp) {
|
||||
$tmp.Parent = $childSettings
|
||||
$tmp.RowIndex = $rowIndex
|
||||
$childSettings += $tmp
|
||||
$childArr += $tmp
|
||||
if (($settingsDef.childIds | Measure-Object).Count -gt 1) {
|
||||
$tmp.PropertyIndex = $childArr.Count
|
||||
}
|
||||
}
|
||||
}
|
||||
$settingInfo.ChildSettings += [PSCustomObject]@{
|
||||
Id = $rowIndex++
|
||||
Type = $groupSettingCollection.'@odata.type'
|
||||
Settings = $childArr
|
||||
}
|
||||
}
|
||||
$rawJsonValue = $SettingInstance.groupSettingCollectionValue | ConvertTo-Json -Depth 50 -Compress
|
||||
}
|
||||
|
||||
'#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance' {
|
||||
# List of primitive values
|
||||
$itemValues = @()
|
||||
foreach ($colObj in $SettingInstance.simpleSettingCollectionValue) {
|
||||
$itemValues += $colObj.value
|
||||
}
|
||||
if ($settingsDef.defaultValue.value) { $defaultValue = $settingsDef.defaultValue.value }
|
||||
$value = $itemValues -join $ctx.PropertySeparator
|
||||
$rawValue = $itemValues -join $ctx.PropertySeparator
|
||||
$rawJsonValue = $SettingInstance.simpleSettingCollectionValue | ConvertTo-Json -Depth 50 -Compress
|
||||
}
|
||||
|
||||
'#microsoft.graph.deviceManagementConfigurationGroupSettingInstance' {
|
||||
# Single group container — group itself isn't emitted, only children
|
||||
$settingInfo.Show = $false
|
||||
foreach ($groupSettingValue in $SettingInstance.groupSettingValue) {
|
||||
foreach ($childSetting in $groupSettingValue.children) {
|
||||
# Same rationale as the GroupSettingCollection case above —
|
||||
# children must reach the buffer (no -SkipAdd) so they
|
||||
# render in HTML output once the Show=false parent is dropped.
|
||||
$tmp = Add-SettingsSetting $childSetting $SettingsDefs ($ItemLevel + 1)
|
||||
if ($tmp) { $tmp.Parent = $settingInfo; $settingInfo.ChildSettings += $tmp }
|
||||
}
|
||||
}
|
||||
$rawJsonValue = $SettingInstance.groupSettingValue | ConvertTo-Json -Depth 50 -Compress
|
||||
}
|
||||
|
||||
default {
|
||||
Write-Log "Unhandled settings catalog instance type: $($SettingInstance.'@odata.type')" 2
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $rawJsonValue -and $rawValue) {
|
||||
$rawJsonValue = $rawValue | ConvertTo-Json -Depth 50 -Compress
|
||||
}
|
||||
|
||||
$settingInfo.Value = $value
|
||||
$settingInfo.RawValue = $rawValue
|
||||
$settingInfo.RawJsonValue = $rawJsonValue
|
||||
$settingInfo.DefaultValue = $defaultValue
|
||||
|
||||
return $settingInfo
|
||||
}
|
||||
|
||||
# Resolve a Settings Catalog payload - Collection(deviceManagementConfigurationSetting) -
|
||||
# into documentation rows, ordered by (Category, SubCategory).
|
||||
#
|
||||
# THE single implementation. Two payload shapes carry settings-catalog settings:
|
||||
# deviceManagement/configurationPolicies (Settings Catalog policies)
|
||||
# deviceAppManagement/targetedManagedAppConfigurations (the "Settings catalog"
|
||||
# step of a MAM app config)
|
||||
# The MAM handler used to keep its own copy of this block, and it had drifted:
|
||||
# it omitted the configurationCategories fetch below, so category/subcategory
|
||||
# grouping silently collapsed on any run that had not already documented a
|
||||
# Settings Catalog policy (making the output order-dependent). Both callers now
|
||||
# go through here.
|
||||
#
|
||||
# Rows are returned rather than pushed onto the context, so the caller decides
|
||||
# whether they belong in the main settings table or in a table of their own.
|
||||
function Get-SettingsCatalogDocumentationRows
|
||||
{
|
||||
param(
|
||||
$Settings,
|
||||
[DocumentationContext]$Context
|
||||
)
|
||||
|
||||
$cfgSettings = @($Settings)
|
||||
if ($cfgSettings.Count -eq 0) { return @() }
|
||||
|
||||
# Generic schema caches (session-persistent, shared by reference so later
|
||||
# writes by the walker warm the cache automatically). Definitions are generic
|
||||
# Intune schema, so they persist across runs and tenant switches.
|
||||
$Context.CachedCfgSettings = Get-CacheObject "DocCfgSettingDefinitions" $Context.CachedCfgSettings
|
||||
Set-CacheObject "DocCfgSettingDefinitions" $Context.CachedCfgSettings -Persistent
|
||||
|
||||
$Context.CfgCategories = Get-CacheObject "CfgCategories" (@())
|
||||
|
||||
# Generic schema (configurationCategories) - same on every tenant - so gated
|
||||
# only on connectivity, not on SourceTenantUnavailable. Without this the
|
||||
# walker cannot resolve a row's category and the nesting disappears.
|
||||
if (-not ($Context.CfgCategories | Where-Object { $_.settingUsage -eq 'configuration' }) -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
Write-Log "Cache Settings Catalog configurationCategories"
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/configurationCategories" -ODataMetadata 'minimal' -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
$Context.CfgCategories += @($resp.Value)
|
||||
Set-CacheObject "CfgCategories" $Context.CfgCategories -Persistent
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to fetch configuration categories' $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
# Seed the definition cache from inline settingDefinitions on each setting
|
||||
foreach ($cfgSetting in $cfgSettings) {
|
||||
if (-not $cfgSetting.settingDefinitions) { continue }
|
||||
$defObj = $cfgSetting.settingDefinitions | Where-Object id -EQ $cfgSetting.settingInstance.settingDefinitionId | Select-Object -First 1
|
||||
if ($defObj -and -not $Context.CachedCfgSettings.ContainsKey($defObj.Id)) {
|
||||
$Context.CachedCfgSettings[$defObj.Id] = $defObj
|
||||
}
|
||||
}
|
||||
|
||||
# Walk each top-level setting into the shared buffer
|
||||
Reset-SettingsCatalogPolicyBuffer
|
||||
foreach ($cfgSetting in $cfgSettings) {
|
||||
Add-SettingsSetting $cfgSetting.settingInstance $cfgSetting.settingDefinitions | Out-Null
|
||||
}
|
||||
|
||||
# Drain the buffer in (Category, SubCategory) order - this grouping is what
|
||||
# produces the portal's nesting in the rendered table.
|
||||
$buffer = Get-SettingsCatalogPolicyBuffer
|
||||
$unique = $buffer |
|
||||
Select-Object @{ l='CategoryID'; e={ $_.CategoryDefinition.Id } },
|
||||
@{ l='SubCategoryID'; e={ $_.SubCategoryDefinition.Id } } -Unique
|
||||
|
||||
$rows = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($pair in $unique) {
|
||||
$matching = $buffer | Where-Object {
|
||||
$_.CategoryDefinition.Id -eq $pair.CategoryID -and
|
||||
$_.SubCategoryDefinition.Id -eq $pair.SubCategoryID
|
||||
}
|
||||
foreach ($row in $matching) {
|
||||
if ($row.Show -eq $false) { continue }
|
||||
[void]$rows.Add($row)
|
||||
}
|
||||
}
|
||||
|
||||
return $rows.ToArray()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
# Administrative Templates (ADMX / Group Policy) input provider.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:916 (Invoke-TranslateADMXObject,
|
||||
# ~190 LOC). Claims @odata.type='#microsoft.graph.groupPolicyConfiguration'
|
||||
# and translates each definitionValue into a documented setting row.
|
||||
#
|
||||
# Definition resolution falls back through three sources, in order:
|
||||
# 1. inline $definitionValue.definition (live $expand=definition export)
|
||||
# 2. #Definition_* flat fields the new project's exporter promotes for
|
||||
# offline use (#Definition_displayName / categoryPath / classType / Id)
|
||||
# 3. live Graph fetch via Invoke-MSGraphAPI
|
||||
# Rows whose displayName can't be resolved (no inline, no embedded, no Graph)
|
||||
# are skipped — matches the golden fixture's offline behavior.
|
||||
#
|
||||
# Presentation values (the configured values for each ADMX setting) translate
|
||||
# differently per presentation type:
|
||||
# DropdownList -> map raw value to item.displayName
|
||||
# ValueList -> name=value pairs joined
|
||||
# MultiText -> values joined
|
||||
# Boolean/Decimal/LongDecimal/Text -> raw value
|
||||
#
|
||||
# Those joined strings stay in Value / ValueWithLabel / RawValue, which Compare,
|
||||
# CSV, Word and JSON all read. The RENDERED table (FullValueTable, used by the
|
||||
# HTML / Markdown / Atlassian outputs) is built separately by
|
||||
# ConvertTo-ADMXValueTable so a list or multi-text setting gets one row per item
|
||||
# instead of one cell holding everything joined, and an explicit-value list gets
|
||||
# its own Key column.
|
||||
#
|
||||
# Settings sorted by CategoryPath at end (matches old code's tail sort).
|
||||
|
||||
function Invoke-InitializeADMXInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'ADMX'
|
||||
Order = 50
|
||||
Match = { param($PolicyObject) $PolicyObject.JsonObject.'@odata.type' -eq '#microsoft.graph.groupPolicyConfiguration' }
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateADMXPolicyObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateADMXPolicyObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$valueProperty = if ($Context.Options.ValueOutputProperty -eq 'valueWithLabel') { 'ValueWithLabel' } else { 'Value' }
|
||||
$Context.DisplayProperties = @('Name','Status','Value','Category','CategoryPath','RawValue','ValueWithLabel','Created','Modified','Class','DefinitionId')
|
||||
$Context.DefaultDocumentationProperties = @('Name','Status',$valueProperty)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# --- BasicInfo header ---
|
||||
Add-BasicDefaultValues $PolicyObject -SkipProperties @('')
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Titles.groupPolicy') '@odata.type'
|
||||
# (Platform supported deliberately omitted — old code at L922 has it commented out;
|
||||
# groupPolicyConfiguration is Windows-only by definition.)
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# --- Categories cache (batch-scoped, lazy) ---
|
||||
# Generic ADMX category/definition catalog - same on every tenant. Seed from the
|
||||
# session-persistent cache (like CfgCategories); on a miss, fetch from any
|
||||
# connected tenant and warm the cache so later runs in the session skip the GET.
|
||||
if (-not $Context.ADMXCategories -or $Context.ADMXCategories.Count -eq 0) {
|
||||
$Context.ADMXCategories = Get-CacheObject "DocADMXCategories" (@())
|
||||
}
|
||||
if ((-not $Context.ADMXCategories -or $Context.ADMXCategories.Count -eq 0) -and
|
||||
(Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$url = "deviceManagement/groupPolicyCategories?`$expand=parent(`$select=id,displayName,isRoot),definitions(`$select=id,displayName,categoryPath,classType,policyType)&`$select=id,displayName,isRoot"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -ODataMetadata 'skip' -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
if ($resp.Value) {
|
||||
$Context.ADMXCategories = @($resp.Value)
|
||||
Set-CacheObject "DocADMXCategories" $Context.ADMXCategories -Persistent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to load ADMX group policy categories' $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
# --- definitionValues ---
|
||||
$definitionValues = @()
|
||||
if ($obj.definitionValues) {
|
||||
$definitionValues = @($obj.definitionValues)
|
||||
}
|
||||
elseif (-not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
# Source-tenant-specific: THIS policy's definitionValues by id (404s elsewhere).
|
||||
try {
|
||||
$url = "deviceManagement/groupPolicyConfigurations('$($obj.Id)')/definitionValues?`$expand=definition(`$select=id,classType,displayName,policyType,groupPolicyCategoryId)"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
$definitionValues = @($resp.Value)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to load definitionValues for ADMX policy $($obj.Id)" $_.Exception
|
||||
}
|
||||
}
|
||||
if ($definitionValues.Count -eq 0) { return }
|
||||
|
||||
$enabledStr = Get-LanguageString 'Inputs.enabled'
|
||||
$disabledStr = Get-LanguageString 'Inputs.disabled'
|
||||
$propertyStr = Get-LanguageString 'ApplicabilityRules.GridLabel.property'
|
||||
$valueStr = Get-LanguageString 'ApplicabilityRules.GridLabel.value'
|
||||
$keyStr = Get-LanguageString 'SettingDetails.keyColumn'
|
||||
|
||||
## ToDo: Preload the presentation Definitions for all definitionValues with presentationValues defined in one batch
|
||||
# e.g. $definitionValues | Where presentationValues -ne $null -> Add to batch and fetch all in one call.
|
||||
|
||||
$rows = @()
|
||||
foreach ($defValue in $definitionValues) {
|
||||
$definition = Resolve-ADMXDefinition $defValue $Context
|
||||
if (-not $definition -or -not $definition.displayName) {
|
||||
# Unresolvable in current mode — skip (matches golden's offline behavior)
|
||||
continue
|
||||
}
|
||||
|
||||
# Category path: prefer the definition's own field; fall back to the cached
|
||||
# categories lookup when only an id is available.
|
||||
$categoryPath = $definition.categoryPath
|
||||
if (-not $categoryPath -and $Context.ADMXCategories.Count -gt 0) {
|
||||
$matched = $Context.ADMXCategories.definitions | Where-Object { $_.id -eq $definition.id } | Select-Object -First 1
|
||||
if ($matched) { $categoryPath = $matched.categoryPath }
|
||||
}
|
||||
|
||||
# Presentation values — only present when the policy carries configured values
|
||||
$presentationValues = Resolve-ADMXPresentationValues $defValue $obj $Context
|
||||
|
||||
$values = @()
|
||||
$valuesWithLabel = @()
|
||||
$rawValues = @()
|
||||
# One entry per presentation - its label plus the rows it contributes to
|
||||
# the rendered table. Multi-valued presentations contribute one row per
|
||||
# item. The flat $values / $valuesWithLabel / $rawValues below are
|
||||
# deliberately built exactly as before: Compare joins on them.
|
||||
$presEntries = @()
|
||||
# Presentation values present: map each to its label + value (per type)
|
||||
foreach ($pv in $presentationValues) {
|
||||
# Generic presentation metadata (label/dropdown items) resolved from any connected tenant.
|
||||
if (-not $pv.presentation -and $pv.'presentation@odata.bind' -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$pres = Invoke-MSGraphAPI -Url $pv.'presentation@odata.bind' -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
if ($pres) { $pv | Add-Member -MemberType NoteProperty -Name 'presentation' -Value $pres -Force }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
$rawValue = $pv.value
|
||||
$label = $pv.presentation.label
|
||||
$value = $null
|
||||
$valueRows = @()
|
||||
|
||||
switch ($pv.presentation.'@odata.type') {
|
||||
'#microsoft.graph.groupPolicyPresentationDropdownList' {
|
||||
$value = ($pv.presentation.items | Where-Object value -EQ $rawValue).displayName
|
||||
$valueRows = @([PSCustomObject]@{ Key = $null; Value = $value })
|
||||
}
|
||||
default {
|
||||
switch ($pv.'@odata.type') {
|
||||
'#microsoft.graph.groupPolicyPresentationValueList' {
|
||||
$arr = @()
|
||||
foreach ($v in $pv.values) {
|
||||
$arr += "$($v.name)$($Context.PropertySeparator)$($v.value)"
|
||||
$valueRows += [PSCustomObject]@{ Key = $v.name; Value = $v.value }
|
||||
}
|
||||
$value = $arr -join $Context.ObjectSeparator
|
||||
# A plain <list> (no explicitValue) stores each item in
|
||||
# 'name' and leaves 'value' empty. Those are single-column
|
||||
# items, not key/value pairs - fold name into the value.
|
||||
if (@($valueRows | Where-Object { "$($_.Value)" -ne '' }).Count -eq 0) {
|
||||
$valueRows = @($valueRows | ForEach-Object { [PSCustomObject]@{ Key = $null; Value = $_.Key } })
|
||||
}
|
||||
}
|
||||
'#microsoft.graph.groupPolicyPresentationValueMultiText' {
|
||||
$value = $pv.values -join $Context.ObjectSeparator
|
||||
$valueRows = @(foreach ($v in $pv.values) { [PSCustomObject]@{ Key = $null; Value = $v } })
|
||||
}
|
||||
default {
|
||||
# Boolean / Decimal / LongDecimal / Text — value is the raw scalar
|
||||
$value = $rawValue
|
||||
$valueRows = @([PSCustomObject]@{ Key = $null; Value = $rawValue })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$presEntries += [PSCustomObject]@{ Label = $label; Rows = @($valueRows) }
|
||||
$valuesWithLabel += "$label $value"
|
||||
$values += $value
|
||||
$rawValues += $rawValue
|
||||
}
|
||||
|
||||
$tableValue = ConvertTo-ADMXValueTable -Entries $presEntries `
|
||||
-PropertyHeader $propertyStr -KeyHeader $keyStr -ValueHeader $valueStr
|
||||
|
||||
$status = if ($defValue.enabled -eq $true) { $enabledStr } else { $disabledStr }
|
||||
|
||||
$combinedValue = $status
|
||||
if ($values) {
|
||||
$combinedValue += $Context.ObjectSeparator + ($values -join $Context.ObjectSeparator)
|
||||
}
|
||||
|
||||
$combinedValueWithLabel = $status
|
||||
if ($valuesWithLabel) {
|
||||
$combinedValueWithLabel += $Context.ObjectSeparator + ($valuesWithLabel -join $Context.ObjectSeparator)
|
||||
}
|
||||
|
||||
$rows += [PSCustomObject]@{
|
||||
Name = $definition.displayName
|
||||
Description = $definition.explainText
|
||||
Status = $status
|
||||
Value = $values -join $Context.ObjectSeparator
|
||||
CombinedValue = $combinedValue
|
||||
ValueWithLabel = $valuesWithLabel -join $Context.ObjectSeparator
|
||||
FullValueTable = $tableValue
|
||||
CombinedValueWithLabel = $combinedValueWithLabel
|
||||
RawValue = $rawValues -join $Context.PropertySeparator
|
||||
Class = $definition.classType
|
||||
DefinitionId = $definition.id
|
||||
Created = $defValue.createdDateTime
|
||||
Modified = $defValue.lastModifiedDateTime
|
||||
Category = $categoryPath
|
||||
CategoryPath = $categoryPath
|
||||
EntityKey = $definition.id # Required for Compare
|
||||
AlwaysAddValue = if($null -ne $defValue.enabled) { $true } else { $false } # Always include Value if defined
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($row in ($rows | Sort-Object -Property CategoryPath, Name)) {
|
||||
$Context.AddSetting($row)
|
||||
}
|
||||
}
|
||||
|
||||
# Builds the rendered value table for one ADMX setting.
|
||||
#
|
||||
# The column shape is decided once for the whole setting, because the HTML,
|
||||
# Markdown and Atlassian renderers read their headers from the FIRST row and then
|
||||
# fetch every later row by those same property names - mixing shapes inside one
|
||||
# setting would silently blank cells. So: two columns (Property | Value) unless
|
||||
# some presentation contributed real key/value pairs, in which case three
|
||||
# (Property | Key | Value) for every row of that setting.
|
||||
#
|
||||
# The property label is written on the first row of each presentation only, so a
|
||||
# multi-item list reads as one labelled block instead of repeating the label.
|
||||
#
|
||||
# $Entries is one object per presentation: { Label; Rows = @({ Key; Value }) }.
|
||||
function ConvertTo-ADMXValueTable {
|
||||
param(
|
||||
$Entries,
|
||||
[string]$PropertyHeader,
|
||||
[string]$KeyHeader,
|
||||
[string]$ValueHeader
|
||||
)
|
||||
|
||||
$entryArr = @($Entries)
|
||||
if ($entryArr.Count -eq 0) { return @() }
|
||||
|
||||
$hasKeys = $false
|
||||
foreach ($entry in $entryArr) {
|
||||
foreach ($row in @($entry.Rows)) {
|
||||
if ("$($row.Key)" -ne '') { $hasKeys = $true; break }
|
||||
}
|
||||
if ($hasKeys) { break }
|
||||
}
|
||||
|
||||
# A translation that collides with another header would throw on the ordered
|
||||
# hashtable below, so fall back to the untranslated column name.
|
||||
if ($hasKeys -and ($KeyHeader -eq $PropertyHeader -or $KeyHeader -eq $ValueHeader -or -not $KeyHeader)) {
|
||||
$KeyHeader = 'Key'
|
||||
}
|
||||
|
||||
$table = @()
|
||||
foreach ($entry in $entryArr) {
|
||||
$rows = @($entry.Rows)
|
||||
# A presentation with nothing configured still shows its label.
|
||||
if ($rows.Count -eq 0) { $rows = @([PSCustomObject]@{ Key = $null; Value = $null }) }
|
||||
|
||||
$first = $true
|
||||
foreach ($row in $rows) {
|
||||
$out = [ordered]@{}
|
||||
$out[$PropertyHeader] = if ($first) { $entry.Label } else { '' }
|
||||
if ($hasKeys) { $out[$KeyHeader] = $row.Key }
|
||||
$out[$ValueHeader] = $row.Value
|
||||
$table += [PSCustomObject]$out
|
||||
$first = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Comma so a single-row table doesn't unroll to a bare object on return.
|
||||
return ,$table
|
||||
}
|
||||
|
||||
# Three-tier definition resolution: inline -> embedded #Definition_* flat
|
||||
# fields -> live Graph fetch. Returns the synthesized/fetched definition or
|
||||
# $null if nothing resolved.
|
||||
function Resolve-ADMXDefinition {
|
||||
param($DefinitionValue, [DocumentationContext]$Context)
|
||||
|
||||
# 1. Inline (live $expand=definition export already populated it)
|
||||
if ($DefinitionValue.definition -and $DefinitionValue.definition.displayName) {
|
||||
return $DefinitionValue.definition
|
||||
}
|
||||
|
||||
# 2. Embedded #Definition_* flat fields (new project's exporter prefix)
|
||||
$embeddedDisplayName = $DefinitionValue.'#Definition_displayName'
|
||||
if ($embeddedDisplayName) {
|
||||
$syn = [PSCustomObject]@{
|
||||
id = $DefinitionValue.'#Definition_Id'
|
||||
displayName = $embeddedDisplayName
|
||||
classType = $DefinitionValue.'#Definition_classType'
|
||||
categoryPath = $DefinitionValue.'#Definition_categoryPath'
|
||||
explainText = $null
|
||||
policyType = $null
|
||||
}
|
||||
# Attach for future calls
|
||||
$DefinitionValue | Add-Member -MemberType NoteProperty -Name 'definition' -Value $syn -Force
|
||||
return $syn
|
||||
}
|
||||
|
||||
# 3. Live Graph fetch via definition@odata.bind URL. The definition is GENERIC
|
||||
# schema (groupPolicyDefinitions) - same on every tenant - so gated only on
|
||||
# connectivity, not on SourceTenantUnavailable.
|
||||
if ($DefinitionValue.'definition@odata.bind' -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$url = $DefinitionValue.'definition@odata.bind'
|
||||
$def = Invoke-MSGraphAPI -Url $url -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
if ($def) {
|
||||
$DefinitionValue | Add-Member -MemberType NoteProperty -Name 'definition' -Value $def -Force
|
||||
return $def
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch ADMX definition from $($DefinitionValue.'definition@odata.bind')" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Resolves presentation values + their presentation metadata. Returns array,
|
||||
# possibly empty for definitionValues with no configured presentationValues
|
||||
# (i.e. ADMX settings that are simply Enabled/Disabled with no inputs).
|
||||
function Resolve-ADMXPresentationValues {
|
||||
param($DefinitionValue, $PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
# Already inline? Order them by the canonical presentation order if we can.
|
||||
if ($DefinitionValue.presentationValues -and $DefinitionValue.presentationValues.Count -gt 0) {
|
||||
# The canonical presentation list is generic schema; reorder only needs a
|
||||
# connected tenant. Without one, keep the inline order.
|
||||
if (-not (Test-DocumentationGraphAvailable)) {
|
||||
return @($DefinitionValue.presentationValues)
|
||||
}
|
||||
# Live: pull the canonical presentation list so we can reorder
|
||||
try {
|
||||
$url = "$($DefinitionValue.'definition@odata.bind')/presentations"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
$canon = @($resp.Value)
|
||||
if ($canon.Count -gt 0) {
|
||||
$ordered = @()
|
||||
foreach ($p in $canon) {
|
||||
$match = $DefinitionValue.presentationValues | Where-Object 'presentation@odata.bind' -Like "*$($p.Id)*" | Select-Object -First 1
|
||||
if ($match) { $ordered += $match } else { $ordered = @(); break }
|
||||
}
|
||||
if ($ordered.Count -gt 0) { return $ordered }
|
||||
}
|
||||
} catch { }
|
||||
return @($DefinitionValue.presentationValues)
|
||||
}
|
||||
|
||||
# Live fetch (when fixture exported without presentationValues inline). These are
|
||||
# the policy's CONFIGURED values, fetched by policy id - source-tenant-specific
|
||||
# (404s elsewhere) - so gated on -not SourceTenantUnavailable.
|
||||
if ($DefinitionValue.id -and -not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
# Should never get here - $DefinitionValue.id will be empty.
|
||||
$url = "/deviceManagement/groupPolicyConfigurations/$($PolicyObject.id)/definitionValues/$($DefinitionValue.id)/presentationValues?`$expand=presentation"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
return @($resp.Value)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch ADMX presentationValues for $($DefinitionValue.id)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
return @()
|
||||
}
|
||||
|
||||
Invoke-InitializeADMXInput
|
||||
@@ -0,0 +1,154 @@
|
||||
# Compliance V2 input provider — schema-driven compliance policies on the
|
||||
# /deviceManagement/compliancePolicies endpoint.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:1444 (Invoke-TranslateComplianceV2-
|
||||
# Object). Claims @odata.type='#microsoft.graph.deviceManagementCompliancePolicy'.
|
||||
#
|
||||
# Mirrors DocumentationInputSettingsCatalog.ps1 — same recursive setting walker
|
||||
# (Add-SettingsSetting), same batch caches on the [DocumentationContext]
|
||||
# ($ctx.CfgCategories, $ctx.CachedCfgSettings), same Category/SubCategory
|
||||
# grouping at the end. Differences:
|
||||
# - Settings endpoint: /deviceManagement/compliancePolicies/<id>/settings
|
||||
# - Categories endpoint: /deviceManagement/complianceCategories with the
|
||||
# linux/linuxMdm template filter (matches old code at L1471)
|
||||
# - platformSupported row uses $obj.platforms directly (compliance policies
|
||||
# are single-platform, no templateReference indirection)
|
||||
|
||||
function Invoke-InitializeComplianceV2Input {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'ComplianceV2'
|
||||
Order = 30
|
||||
Match = { param($PolicyObject) $PolicyObject.JsonObject.'@odata.type' -eq '#microsoft.graph.deviceManagementCompliancePolicy' }
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateComplianceV2Object $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateComplianceV2Object {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# --- BasicInfo header rows ---
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'ConfigurationTypes.settingsCatalog') '@odata.type'
|
||||
|
||||
if ($obj.platforms) {
|
||||
$platformType = Get-LanguageString "Platform.$($obj.platforms)"
|
||||
if ($platformType) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.platformSupported') $platformType 'platforms'
|
||||
}
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# --- Settings ---
|
||||
# Prefer in-policy settings WITH inline settingDefinitions. Inline settings
|
||||
# WITHOUT definitions (the hydrate body only does ?$expand=Settings) would
|
||||
# send the walker into a per-setting /configurationSettings/{id} N+1 — the
|
||||
# same trap the Settings Catalog provider fixed; enrich instead.
|
||||
$cfgSettings = @()
|
||||
if ($obj.Settings -and ($obj.Settings | Measure-Object).Count -gt 0) {
|
||||
$cfgSettings = @($obj.Settings)
|
||||
}
|
||||
|
||||
$hasDefs = $false
|
||||
foreach ($s in $cfgSettings) {
|
||||
if ($s.settingDefinitions -and ($s.settingDefinitions | Measure-Object).Count -gt 0) {
|
||||
$hasDefs = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Bulk runs: Initialize-DocumentationRunPrefetch already fetched these in
|
||||
# one Graph $batch — consume from the per-run cache (authoritative for this
|
||||
# run, even when empty, so an empty-settings policy doesn't trigger a
|
||||
# redundant live GET). The live GET below is the lazy fallback for the
|
||||
# single-policy Get-GraphDocumentation path.
|
||||
if (-not $hasDefs -and $Context.PrefetchedPolicySettings.ContainsKey([string]$obj.Id)) {
|
||||
$cfgSettings = @($Context.PrefetchedPolicySettings[[string]$obj.Id])
|
||||
$hasDefs = $true
|
||||
}
|
||||
|
||||
# Source-tenant-specific: fetches THIS policy's settings by id (404s elsewhere).
|
||||
# Stays gated on -not SourceTenantUnavailable; the walker's generic per-setting
|
||||
# configurationSettings/{id} fallback resolves schema when source is gone.
|
||||
if (-not $hasDefs -and -not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') {
|
||||
$headers['Accept-Language'] = $Context.Language
|
||||
}
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/compliancePolicies('$($obj.Id)')/settings?`$expand=settingDefinitions&`$top=1000" -AdditionalHeaders $headers
|
||||
if ($resp -and $resp.Value) {
|
||||
$cfgSettings = @($resp.Value)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch settings for compliance policy $($obj.Id)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
if ($cfgSettings.Count -eq 0) {
|
||||
Write-Log "ComplianceV2: no settings to document for $($obj.name)" 2
|
||||
return
|
||||
}
|
||||
|
||||
# --- Generic schema caches (session-persistent, shared by reference with the
|
||||
# Settings Catalog provider so the walker's per-setting definition fetches
|
||||
# warm one shared cache). ---
|
||||
$Context.CachedCfgSettings = Get-CacheObject "DocCfgSettingDefinitions" $Context.CachedCfgSettings
|
||||
Set-CacheObject "DocCfgSettingDefinitions" $Context.CachedCfgSettings -Persistent
|
||||
|
||||
# --- Categories (batch-cached). Old code unions linux/linuxMdm template
|
||||
# categories into the same $global:cfgCategories the Settings Catalog uses;
|
||||
# we mirror that by appending to $ctx.CfgCategories rather than replacing.
|
||||
|
||||
|
||||
$Context.CfgCategories = Get-CacheObject "CfgCategories" (@())
|
||||
|
||||
# Generic schema (complianceCategories) - same on every tenant - gated only on connectivity.
|
||||
if (-not ($Context.CfgCategories | Where-Object { $_.settingUsage -eq 'compliance' }) -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/complianceCategories" -ODataMetadata 'minimal' -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context)
|
||||
#$resp = Invoke-MSGraphAPI -Url "/deviceManagement/complianceCategories?`$templateCategory=True&`$filter=platforms has 'linux' and technologies has 'linuxMdm'"
|
||||
$Context.CfgCategories += @($resp.Value)
|
||||
Set-CacheObject "CfgCategories" $Context.CfgCategories -Persistent
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to fetch compliance categories' $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
# --- Seed definition cache from inline settingDefinitions ---
|
||||
foreach ($cfgSetting in $cfgSettings) {
|
||||
if (-not $cfgSetting.settingDefinitions) { continue }
|
||||
$defObj = $cfgSetting.settingDefinitions | Where-Object id -EQ $cfgSetting.settingInstance.settingDefinitionId | Select-Object -First 1
|
||||
if ($defObj -and -not $Context.CachedCfgSettings.ContainsKey($defObj.Id)) {
|
||||
$Context.CachedCfgSettings[$defObj.Id] = $defObj
|
||||
}
|
||||
}
|
||||
|
||||
# --- Walk each top-level setting via the shared SettingsCatalog walker ---
|
||||
Reset-SettingsCatalogPolicyBuffer
|
||||
foreach ($cfgSetting in $cfgSettings) {
|
||||
Add-SettingsSetting $cfgSetting.settingInstance $cfgSetting.settingDefinitions | Out-Null
|
||||
}
|
||||
|
||||
# --- Drain buffer into SettingsData grouped by (Category, SubCategory) ---
|
||||
$buffer = Get-SettingsCatalogPolicyBuffer
|
||||
$unique = $buffer |
|
||||
Select-Object @{ l='CategoryID'; e={ $_.CategoryDefinition.Id } },
|
||||
@{ l='SubCategoryID'; e={ $_.SubCategoryDefinition.Id } } -Unique
|
||||
|
||||
foreach ($pair in $unique) {
|
||||
$rows = $buffer | Where-Object {
|
||||
$_.CategoryDefinition.Id -eq $pair.CategoryID -and
|
||||
$_.SubCategoryDefinition.Id -eq $pair.SubCategoryID
|
||||
}
|
||||
foreach ($row in $rows) {
|
||||
if ($row.Show -eq $false) { continue }
|
||||
$Context.AddSetting($row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeComplianceV2Input
|
||||
@@ -0,0 +1,515 @@
|
||||
# Intent input provider — deviceManagementIntent (Endpoint Security baselines
|
||||
# and templates).
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:1580 (Invoke-TranslateIntent-
|
||||
# Object + helpers). Claims @odata.type='#microsoft.graph.deviceManagementIntent'.
|
||||
#
|
||||
# Intent settings live under /deviceManagement/templates/{templateId}/categories
|
||||
# (with $expand=settingDefinitions) and the per-intent values come from
|
||||
# /deviceManagement/intents/{intentId}/categories/{catId}/settings. Each setting
|
||||
# may be Simple / Collection / Complex / AbstractComplex with recursive children
|
||||
# and dependency constraints that hide settings whose parents aren't configured.
|
||||
#
|
||||
# Live Graph dependencies (resolved via Invoke-MSGraphAPI):
|
||||
# /deviceManagement/templates/{tid}/categories?$expand=settingDefinitions
|
||||
# /deviceManagement/intents/{iid}/categories/{cid}/settings?$expand=...
|
||||
# /deviceManagement/templates/{tid}/categories/{cid}/RecommendedSettings
|
||||
#
|
||||
# Batch-cached on the [DocumentationContext] ($ctx.IntentCategories,
|
||||
# $ctx.IntentCatRecommendedSettings) so a bulk run of N intents against the
|
||||
# same template only pays the round-trips once.
|
||||
|
||||
function Invoke-InitializeIntentInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'Intent'
|
||||
Order = 40
|
||||
Match = { param($PolicyObject) $PolicyObject.JsonObject.'@odata.type' -eq '#microsoft.graph.deviceManagementIntent' }
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateIntentObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateIntentObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$Context.DefaultDocumentationProperties = @('Name','Value','RecommendedValue')
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
|
||||
$baseLineTemplates = Get-CacheObject "BaseLineTemplates"
|
||||
if(-not $baseLineTemplates)
|
||||
{
|
||||
$baseLineTemplates = (Invoke-MSGraphAPI -Url "/deviceManagement/templates").Value
|
||||
Set-CacheObject "BaseLineTemplates" $baseLineTemplates -Persistent
|
||||
}
|
||||
|
||||
$baseLineTemplate = $baseLineTemplates | Where-Object Id -eq $obj.templateId
|
||||
if(-not $baseLineTemplate)
|
||||
{
|
||||
Write-Log "Could not find Baseline Template with Id $($obj.templateId)" 3
|
||||
}
|
||||
else {
|
||||
$platformType = Get-LanguageString "Platform.$($baseLineTemplate.platformType)"
|
||||
|
||||
if($platformType) { Add-BasicPropertyValue (Get-LanguageString "SettingDetails.platformSupported") $platformType 'platformSupported'}
|
||||
|
||||
if ($baseLineTemplate.templateSubtype -eq "none")
|
||||
{
|
||||
$templateCategoory = $baseLineTemplate.templateType
|
||||
} else {
|
||||
$templateCategoory = $baseLineTemplate.templateSubtype
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString "TableHeaders.Category") (Get-IntentCategoryFromTemplateType $templateCategoory) "basicCategory"
|
||||
Add-BasicPropertyValue (Get-LanguageString "TableHeaders.policyType") $baseLineTemplate.displayName "basicPolicyType"
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
if (-not $obj.templateId) {
|
||||
Write-Log "Intent: no templateId on '$($obj.displayName)' - cannot translate settings" 2
|
||||
return
|
||||
}
|
||||
|
||||
# Built-in ES template schema is generic. Seed/share the session-persistent
|
||||
# caches by reference so per-templateId/per-category writes below warm the
|
||||
# cache automatically and survive across runs (and tenant switches).
|
||||
$Context.IntentCategories = Get-CacheObject "DocIntentCategories" $Context.IntentCategories
|
||||
Set-CacheObject "DocIntentCategories" $Context.IntentCategories -Persistent
|
||||
$Context.IntentCatRecommendedSettings = Get-CacheObject "DocIntentRecommendedSettings" $Context.IntentCatRecommendedSettings
|
||||
Set-CacheObject "DocIntentRecommendedSettings" $Context.IntentCatRecommendedSettings -Persistent
|
||||
|
||||
# --- Template categories (batch-cached per templateId) ---
|
||||
$categories = $Context.IntentCategories[$obj.templateId]
|
||||
if (-not $categories) {
|
||||
# Built-in Endpoint Security template schema (by templateId) is generic -
|
||||
# same on every tenant - so resolved from any connected tenant, even when
|
||||
# the source tenant of the export is gone.
|
||||
if (-not (Test-DocumentationGraphAvailable)) {
|
||||
Write-Log "Intent: no tenant connected and no cached template categories for $($obj.templateId) - settings will not render" 2
|
||||
return
|
||||
}
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') { $headers['Accept-Language'] = $Context.Language }
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/templates/$($obj.templateId)/categories?`$expand=settingDefinitions" -AdditionalHeaders $headers
|
||||
$categories = @($resp.Value)
|
||||
$Context.IntentCategories[$obj.templateId] = $categories
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Intent: failed to fetch template categories for $($obj.templateId)" $_.Exception
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# Per-object setting buffer (drained at the end into Context.SettingsData
|
||||
# in dependency-respecting order via Add-IntentSettingObjectToList).
|
||||
$script:_intentObjectSettings = [System.Collections.Generic.List[object]]::new()
|
||||
$script:_intentEmittedIds = @{}
|
||||
|
||||
foreach ($category in ($categories | Sort-Object -Property displayName)) {
|
||||
# Per-intent settings for this category (skipped when the input is an
|
||||
# offline file with .settings inlined).
|
||||
$settings = $null
|
||||
if ($obj.'@ObjectFromFile' -eq $true) {
|
||||
$settings = $obj.settings
|
||||
}
|
||||
elseif (-not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
# Source-tenant-specific: this intent's configured values by id (404s elsewhere).
|
||||
# Export path is the @ObjectFromFile branch above.
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') { $headers['Accept-Language'] = $Context.Language }
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/intents/$($obj.Id)/categories/$($category.Id)/settings?`$expand=Microsoft.Graph.DeviceManagementComplexSettingInstance/Value" -AdditionalHeaders $headers
|
||||
$settings = $resp.Value
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Intent: failed to fetch settings for intent=$($obj.Id) category=$($category.Id)" $_.Exception
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (-not $settings) { continue }
|
||||
|
||||
# Recommended settings (template-level, also batch-cached per categoryId)
|
||||
if (-not $Context.IntentCatRecommendedSettings.ContainsKey($category.Id)) {
|
||||
# Template-level recommended settings (by templateId) are generic schema.
|
||||
if (Test-DocumentationGraphAvailable) {
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') { $headers['Accept-Language'] = $Context.Language }
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/templates/$($obj.templateId)/categories/$($category.Id)/RecommendedSettings" -AdditionalHeaders $headers
|
||||
$Context.IntentCatRecommendedSettings[$category.Id] = @($resp.Value)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Intent: failed to fetch recommended settings for template=$($obj.templateId) category=$($category.Id)" $_.Exception
|
||||
$Context.IntentCatRecommendedSettings[$category.Id] = @()
|
||||
}
|
||||
}
|
||||
else {
|
||||
$Context.IntentCatRecommendedSettings[$category.Id] = @()
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($settingObj in $settings) {
|
||||
Get-IntentSettingInfo $settingObj $category $settingObj.definitionId $settings $Context | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Drain top-level settings (those with no parent and no dependencies).
|
||||
# Children/dependents get visited recursively by Add-IntentSettingObjectToList.
|
||||
$tops = $script:_intentObjectSettings | Where-Object {
|
||||
$null -eq $_.ParentId -and (($_.Dependencies | Measure-Object).Count -eq 0)
|
||||
}
|
||||
foreach ($s in $tops) {
|
||||
Add-IntentSettingObjectToList $s $Context
|
||||
}
|
||||
}
|
||||
|
||||
# Ordered emit: respects dependency constraints (parents resolve to permitted
|
||||
# values before dependent children are added) and recurses to children of any
|
||||
# emitted setting.
|
||||
function Add-IntentSettingObjectToList {
|
||||
param($objSetting, [DocumentationContext]$Context)
|
||||
|
||||
if ($script:_intentEmittedIds.ContainsKey([string]$objSetting.Id)) { return }
|
||||
|
||||
$passConstraint = $true
|
||||
$hasConstraint = $false
|
||||
foreach ($dependencyObj in $objSetting.SettingDefinition.dependencies) {
|
||||
$dependencyItemObj = $script:_intentObjectSettings | Where-Object { $_.SettingDefinition.Id -eq $dependencyObj.definitionId } | Select-Object -First 1
|
||||
if ($dependencyObj.constraints.Count -gt 0) {
|
||||
$hasConstraint = $true
|
||||
foreach ($constraint in $dependencyObj.constraints) {
|
||||
switch ($constraint.'@odata.type') {
|
||||
'#microsoft.graph.deviceManagementSettingBooleanConstraint' {
|
||||
if (($null -eq $dependencyItemObj.RawValue -and $constraint.value -eq $false) -or
|
||||
($dependencyItemObj.RawValue -and "$($dependencyItemObj.RawValue)" -ne "$($constraint.value)")) {
|
||||
$passConstraint = $false
|
||||
}
|
||||
}
|
||||
'#microsoft.graph.deviceManagementEnumConstraint' {
|
||||
if (-not ($constraint.values | Where-Object Value -EQ $dependencyItemObj.RawValue)) {
|
||||
$passConstraint = $false
|
||||
}
|
||||
}
|
||||
'#microsoft.graph.deviceManagementSettingIntegerConstraint' {
|
||||
# Old code inverts the comparison — passes when value is OUT of range.
|
||||
# Preserving the (buggy?) behavior for golden parity.
|
||||
if ($dependencyItemObj.RawValue -ge $constraint.minimumValue -and
|
||||
$dependencyItemObj.RawValue -le $constraint.maximumValue) {
|
||||
$passConstraint = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $passConstraint) { break }
|
||||
}
|
||||
}
|
||||
else {
|
||||
# No explicit constraint — dependency just has to be "set"
|
||||
$passConstraint = ($null -ne $dependencyItemObj.RawValue -and
|
||||
"$($dependencyItemObj.RawValue)" -ne 'NotConfigured' -and
|
||||
"$($dependencyItemObj.RawValue)" -ne 'False')
|
||||
}
|
||||
if (-not $passConstraint) { break }
|
||||
}
|
||||
|
||||
if (-not $passConstraint) { return }
|
||||
|
||||
if ($hasConstraint) { $objSetting.Level = $objSetting.Level + 1 }
|
||||
|
||||
# Attach recommended-value comparison (purely informational on the emitted row)
|
||||
$recommendedSetting = $Context.IntentCatRecommendedSettings[$objSetting.CategoryObject.Id] |
|
||||
Where-Object definitionId -EQ $objSetting.SettingId | Select-Object -First 1
|
||||
if ($recommendedSetting.valueJson -and ($objSetting.ValueSet -eq $false -or
|
||||
$recommendedSetting.valueJson -ne ($objSetting.RawValue | ConvertTo-Json -Depth 50 -Compress))) {
|
||||
$objSetting | Add-Member -MemberType NoteProperty -Name 'RecommendedValue' `
|
||||
-Value ($recommendedSetting.valueJson | ConvertFrom-Json) -Force
|
||||
}
|
||||
|
||||
$Context.AddSetting($objSetting)
|
||||
$script:_intentEmittedIds[[string]$objSetting.Id] = $true
|
||||
|
||||
if ($objSetting.ValueSet -eq $false) { return }
|
||||
|
||||
# Recurse: dependents (settings whose dependencies include this one)
|
||||
foreach ($depObj in ($script:_intentObjectSettings | Where-Object {
|
||||
$_.Dependencies.definitionId -eq $objSetting.SettingDefinition.Id
|
||||
})) {
|
||||
Add-IntentSettingObjectToList $depObj $Context
|
||||
}
|
||||
|
||||
# Recurse: children (settings with ParentId pointing at this one and no deps)
|
||||
foreach ($depObj in ($script:_intentObjectSettings | Where-Object {
|
||||
$_.ParentId -eq $objSetting.Id -and (($_.Dependencies | Measure-Object).Count -eq 0)
|
||||
})) {
|
||||
Add-IntentSettingObjectToList $depObj $Context
|
||||
}
|
||||
}
|
||||
|
||||
# Recursive setting parser. Builds a per-setting PSCustomObject with all the
|
||||
# metadata the emit step needs, pushes it onto $script:_intentObjectSettings,
|
||||
# and recurses into Complex / AbstractComplex / Collection children.
|
||||
function Get-IntentSettingInfo {
|
||||
param(
|
||||
$valueObj, $category, $defId, $allSettings, [DocumentationContext]$Context,
|
||||
[switch]$SkipConvertValue, [switch]$PassThru, $parentDef = $null
|
||||
)
|
||||
|
||||
$defObj = $category.settingDefinitions | Where-Object id -EQ $defId | Select-Object -First 1
|
||||
if (-not $defObj) { return }
|
||||
|
||||
$itemValue = $null
|
||||
$itemFullValue = $null
|
||||
|
||||
$rawValue = if ($SkipConvertValue) { $valueObj } else { $valueObj.valueJson | ConvertFrom-Json }
|
||||
|
||||
$valueSet = Get-IsIntentObjectConfigured $rawValue
|
||||
|
||||
if ($valueSet -eq $false) {
|
||||
# Skip child settings
|
||||
}
|
||||
elseif ($valueObj.'@odata.type' -eq '#microsoft.graph.deviceManagementCollectionSettingInstance' -or
|
||||
$defObj.'@odata.type' -eq '#microsoft.graph.deviceManagementComplexSettingDefinition' -or
|
||||
$defObj.valueType -eq 'collection') {
|
||||
$valueArr = @()
|
||||
$elementDefObj = if ($defObj.elementDefinitionId) {
|
||||
$category.settingDefinitions | Where-Object id -EQ $defObj.elementDefinitionId | Select-Object -First 1
|
||||
} else { $defObj }
|
||||
|
||||
if ($elementDefObj.propertyDefinitionIds) {
|
||||
# Each element is itself a record of N properties — emit the
|
||||
# FullValueTable so output providers can render it as a table.
|
||||
$itemFullValue = @()
|
||||
foreach ($tmpValue in $rawValue) {
|
||||
$htFullPropInfo = [ordered]@{}
|
||||
$arrValue = ''
|
||||
foreach ($propertyDefinitionId in $elementDefObj.propertyDefinitionIds) {
|
||||
$propDefObj = $category.settingDefinitions | Where-Object id -EQ $propertyDefinitionId | Select-Object -First 1
|
||||
if ($propDefObj.elementDefinitionId) {
|
||||
$propDefObj = $category.settingDefinitions | Where-Object id -EQ $propDefObj.elementDefinitionId | Select-Object -First 1
|
||||
}
|
||||
if ($arrValue) { $arrValue = $arrValue + $Context.PropertySeparator }
|
||||
$propName = $propertyDefinitionId.Split('_')[-1]
|
||||
$propValue = @()
|
||||
foreach ($childTmpValue in $tmpValue.$propName) {
|
||||
$propValue += Get-IntentObjectValue $propDefObj $childTmpValue
|
||||
}
|
||||
$colName = if ($propDefObj.displayName) { $propDefObj.displayName } else { $propName }
|
||||
$htFullPropInfo.Add($colName, $tmpValue.$propName)
|
||||
$arrValue = $arrValue + ($propValue -join $Context.PropertySeparator)
|
||||
}
|
||||
$itemFullValue += [PSCustomObject]$htFullPropInfo
|
||||
$valueArr += $arrValue
|
||||
}
|
||||
}
|
||||
elseif ($rawValue) {
|
||||
foreach ($tmpValue in $rawValue) {
|
||||
$valueArr += (Get-IntentObjectValue $elementDefObj $tmpValue)
|
||||
}
|
||||
}
|
||||
|
||||
if ($valueArr.Count -gt 0) {
|
||||
$itemValue = $valueArr -join $Context.ObjectSeparator
|
||||
}
|
||||
$valueSet = $valueArr.Count -gt 0
|
||||
}
|
||||
elseif ($valueObj.'@odata.type' -eq '#microsoft.graph.deviceManagementAbstractComplexSettingInstance' -or
|
||||
$defObj.'@odata.type' -eq '#microsoft.graph.deviceManagementAbstractComplexSettingDefinition') {
|
||||
$tmpDef = $category.settingDefinitions | Where-Object {
|
||||
$_.id -eq $rawValue.implementationId -or $_.id -eq $rawValue.'$implementationId'
|
||||
} | Select-Object -First 1
|
||||
if ($tmpDef) {
|
||||
$itemValue = $tmpDef.displayName
|
||||
}
|
||||
else {
|
||||
$valueSet = $false
|
||||
}
|
||||
}
|
||||
else {
|
||||
$itemValue = Get-IntentObjectValue $defObj $rawValue
|
||||
if (-not $itemValue) { $valueSet = $false }
|
||||
}
|
||||
|
||||
if ($valueSet -eq $false) {
|
||||
$itemValue = Get-LanguageString 'SettingDetails.notConfigured'
|
||||
$rawValue = $null
|
||||
}
|
||||
elseif (-not $itemValue) {
|
||||
$itemValue = $rawValue
|
||||
}
|
||||
|
||||
$curObjectInfo = [PSCustomObject]@{
|
||||
Name = $defObj.displayName
|
||||
Description = $defObj.description
|
||||
Category = $category.displayName
|
||||
CategoryDescription = $category.description
|
||||
CategoryObject = $category
|
||||
Value = $itemValue
|
||||
FullValueTable = $itemFullValue
|
||||
RawValue = $rawValue
|
||||
SettingDefinition = $defObj
|
||||
Dependencies = $defObj.dependencies
|
||||
ValueSet = $valueSet
|
||||
Id = [Guid]::NewGuid()
|
||||
ParentId = $null
|
||||
SettingId = $defObj.Id
|
||||
ParentSettingId = $parentDef.Id
|
||||
Level = 0
|
||||
}
|
||||
$script:_intentObjectSettings.Add($curObjectInfo)
|
||||
|
||||
if ($valueSet -eq $false) {
|
||||
# Skip children if value not set
|
||||
}
|
||||
elseif ($valueObj.'@odata.type' -eq '#microsoft.graph.deviceManagementComplexSettingInstance' -or
|
||||
$defObj.'@odata.type' -eq '#microsoft.graph.deviceManagementComplexSettingDefinition') {
|
||||
if ($valueObj.Value) {
|
||||
$isValueSet = $false
|
||||
if ($defObj.propertyDefinitionIds) {
|
||||
foreach ($childDefId in $defObj.propertyDefinitionIds) {
|
||||
$childSetting = $valueObj.Value | Where-Object DefinitionId -EQ $childDefId | Select-Object -First 1
|
||||
if ($childSetting) {
|
||||
$objValueInfo = Get-IntentSettingInfo $childSetting $category $childSetting.definitionId $allSettings $Context -PassThru -parentDef $defObj
|
||||
$objValueInfo.ParentId = $curObjectInfo.Id
|
||||
if (($objValueInfo.RawValue -is [bool] -and $objValueInfo.RawValue -eq $true) -or
|
||||
($objValueInfo.RawValue -is [string] -and -not [string]::IsNullOrEmpty($objValueInfo.RawValue) -and
|
||||
$objValueInfo.RawValue -ne 'notConfigured' -and -not [string]::IsNullOrEmpty($objValueInfo.Value)) -or
|
||||
($objValueInfo.RawValue -isnot [bool] -and $objValueInfo.RawValue -isnot [string])) {
|
||||
$isValueSet = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($childSetting in $valueObj.Value) {
|
||||
$objValueInfo = Get-IntentSettingInfo $childSetting $category $childSetting.definitionId $allSettings $Context -PassThru -parentDef $defObj
|
||||
$objValueInfo.ParentId = $curObjectInfo.Id
|
||||
}
|
||||
$isValueSet = $true
|
||||
}
|
||||
}
|
||||
elseif ($rawValue -and $defObj.propertyDefinitionIds) {
|
||||
$isValueSet = $false
|
||||
$isDefault = $true
|
||||
foreach ($childDefId in $defObj.propertyDefinitionIds) {
|
||||
$propName = $childDefId.Split('_')[-1]
|
||||
$objValueInfo = Get-IntentSettingInfo $rawValue.$propName $category $childDefId $allSettings $Context -SkipConvertValue -PassThru -parentDef $defObj
|
||||
if ($objValueInfo.ValueSet -eq $true) { $isValueSet = $true }
|
||||
if ($objValueInfo.SettingDefinition.constraints -and
|
||||
$objValueInfo.SettingDefinition.constraints[0].'@odata.type' -eq '#microsoft.graph.deviceManagementEnumConstraint' -and
|
||||
($objValueInfo.SettingDefinition.constraints[0].values | Measure-Object).Count -gt 0) {
|
||||
if ($objValueInfo.SettingDefinition.constraints[0].values[0].value -ne $rawValue.$propName) {
|
||||
$isDefault = $false
|
||||
}
|
||||
}
|
||||
elseif ($objValueInfo.SettingDefinition.valueType -eq 'string') {
|
||||
if ($null -ne $rawValue.$propName) { $isDefault = $false }
|
||||
}
|
||||
elseif ($objValueInfo.SettingDefinition.valueType -eq 'boolean') {
|
||||
if ($false -ne $rawValue.$propName) { $isDefault = $false }
|
||||
}
|
||||
$objValueInfo.ParentId = $curObjectInfo.Id
|
||||
}
|
||||
if ($isDefault) { $isValueSet = $false }
|
||||
}
|
||||
else {
|
||||
$isValueSet = $false
|
||||
}
|
||||
|
||||
$curObjectInfo.Value = if ($isValueSet) { 'Configure' } else { Get-LanguageString 'SettingDetails.notConfigured' }
|
||||
$curObjectInfo.ValueSet = $isValueSet
|
||||
$curObjectInfo.FullValueTable = $null
|
||||
}
|
||||
elseif (($valueObj.'@odata.type' -eq '#microsoft.graph.deviceManagementAbstractComplexSettingInstance' -or
|
||||
$defObj.'@odata.type' -eq '#microsoft.graph.deviceManagementAbstractComplexSettingDefinition') -and
|
||||
$rawValue -and $tmpDef) {
|
||||
foreach ($childDefId in $tmpDef.propertyDefinitionIds) {
|
||||
$propName = $childDefId.Split('_')[-1]
|
||||
$objValueInfo = Get-IntentSettingInfo $rawValue.$propName $category $childDefId $allSettings $Context -SkipConvertValue -PassThru -parentDef $defObj
|
||||
$objValueInfo.ParentId = $curObjectInfo.Id
|
||||
}
|
||||
}
|
||||
|
||||
if ($PassThru) { $curObjectInfo }
|
||||
}
|
||||
|
||||
# Translates a raw setting value via its definition (enum / boolean / raw passthrough).
|
||||
function Get-IntentObjectValue {
|
||||
param($defObj, $rawValue)
|
||||
|
||||
if ($defObj.constraints.'@odata.type' -eq '#microsoft.graph.deviceManagementEnumConstraint') {
|
||||
$tmpOption = $defObj.constraints.Values | Where-Object value -EQ $rawValue | Select-Object -First 1
|
||||
if (-not $tmpOption -and $null -eq $rawValue) {
|
||||
# No defaultValue on the setting definition — fall back to first option.
|
||||
# Old-code wart preserved for golden parity.
|
||||
$tmpOption = $defObj.constraints.Values[0]
|
||||
}
|
||||
return $tmpOption.displayName
|
||||
}
|
||||
elseif ($defObj.valueType -eq 'boolean') {
|
||||
if ($rawValue -eq 'True') { return (Get-LanguageString 'SettingDetails.yes') }
|
||||
return $null
|
||||
}
|
||||
return $rawValue
|
||||
}
|
||||
|
||||
# Hook for custom "is configured?" rules. Old code always returns true; kept as
|
||||
# a function so type-specific overrides can be wired in later.
|
||||
function Get-IsIntentObjectConfigured {
|
||||
param($obj)
|
||||
return $true
|
||||
}
|
||||
|
||||
# Template-type to friendly category-name lookup. Used by BasicInfo "Type"
|
||||
# row when the input provider lands templateType resolution in v2; for now
|
||||
# only exported so handlers can reuse the mapping.
|
||||
function Get-IntentCategoryFromTemplateType {
|
||||
param([string]$TemplateType)
|
||||
|
||||
if (-not $TemplateType) {
|
||||
Write-Log 'Get-IntentCategoryFromTemplateType called with empty TemplateType' 2
|
||||
return $null
|
||||
}
|
||||
|
||||
# Captured before the prefix is stripped: whether the family was security-shaped
|
||||
# is what decides if failing to map it is worth reporting (see the default arm).
|
||||
$isSecurityFamily = $TemplateType.StartsWith('endpointSecurity') -or $TemplateType -match 'baseline'
|
||||
|
||||
if ($TemplateType.StartsWith('endpointSecurity')) {
|
||||
$TemplateType = $TemplateType.Substring(16)
|
||||
}
|
||||
|
||||
switch ($TemplateType) {
|
||||
'accountProtection' { return (Get-LanguageString 'SecurityTemplate.accountProtection') }
|
||||
'antivirus' { return (Get-LanguageString 'SecurityTemplate.antivirus') }
|
||||
'diskEncryption' { return (Get-LanguageString 'SecurityTemplate.diskEncryption') }
|
||||
'endpointDetectionReponse' { return (Get-LanguageString 'SecurityTemplate.eDR') }
|
||||
'attackSurfaceReduction' { return (Get-LanguageString 'SecurityTemplate.aSR') }
|
||||
'firewall' { return (Get-LanguageString 'SecurityTemplate.firewall') }
|
||||
{ $_ -in @('securityBaseline','baseline','advancedThreatProtectionSecurityBaseline','microsoftEdgeSecurityBaseline') } {
|
||||
return (Get-LanguageString 'Titles.securityBaselines')
|
||||
}
|
||||
# Not a security template, but it reaches this mapper the same way: the
|
||||
# Settings Catalog provider asks for a category name for every family it
|
||||
# documents, and the Apple ADE enrollment policies are this one. Without an
|
||||
# arm here the row read 'enrollmentConfiguration'. PolicySet.deviceEnrollment
|
||||
# is an existing key, so the label localizes with everything else.
|
||||
'enrollmentConfiguration' { return (Get-LanguageString 'PolicySet.deviceEnrollment') }
|
||||
default {
|
||||
# Only a security-shaped family is expected to resolve here. The Settings
|
||||
# Catalog provider (Get-IntentCategoryName) calls this for EVERY
|
||||
# templateFamily and documents the raw value when it does not map, so a
|
||||
# family like 'enrollmentConfiguration' is a normal outcome rather than a
|
||||
# problem - warning about it once per policy put a wall of yellow in the
|
||||
# log of any tenant with Apple ADE policies and buried the real signal.
|
||||
if ($isSecurityFamily) {
|
||||
Write-Log "Could not translate Intent Template type $TemplateType" 2
|
||||
}
|
||||
else {
|
||||
Write-LogDebug "No Intent category mapping for template family '$TemplateType'; documented as-is"
|
||||
}
|
||||
return $TemplateType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeIntentInput
|
||||
@@ -0,0 +1,125 @@
|
||||
# Manifest input provider.
|
||||
#
|
||||
# Bridges the old Documentation\ObjectInfo\ "manifest" JSON files (a flat
|
||||
# array of property descriptors describing how to translate a policy
|
||||
# object). Different from the Profile provider's category files, which:
|
||||
# - Live alongside as <category>_<policyType>.json
|
||||
# - Wrap their section array under a key matching the file basename
|
||||
# - Are looked up via ObjectCategories.json (Get-PolicyObjectCategoryInfo)
|
||||
#
|
||||
# Manifest files instead are:
|
||||
# - Flat top-level arrays
|
||||
# - Named directly by @odata.type: <odata.type>.json
|
||||
# e.g. #microsoft.graph.hardwareConfiguration.json
|
||||
# - Or named by PolicyType Id: #<typeId>.json
|
||||
# e.g. #Applications.json, #Autopilot.json
|
||||
#
|
||||
# These files exist for ~21 PolicyTypes that aren't catalogued in
|
||||
# ObjectCategories.json (Applications, AppProtection, BIOS hardware
|
||||
# configs, EnrollmentLimit/Notification/StatusPage, WindowsUpdate
|
||||
# profiles, MacScripts, PowerShell/HealthScripts, etc.) so without this
|
||||
# provider every one of those types renders an empty HTML stub.
|
||||
#
|
||||
# Match order: this file's basename ("Manifest") sorts before "Profile"
|
||||
# so it gets first shot at types that aren't already claimed by a
|
||||
# DocHandler or one of the specific schema providers (ADMX/Compliance V2
|
||||
# /Intent/SettingsCatalog).
|
||||
#
|
||||
# Old code reference: Extensions/Documentation.psm1:268-284 (the dispatcher
|
||||
# branches that test File.Exists on the two filename forms) +
|
||||
# Extensions/Documentation.psm1:4016 (Invoke-TranslateCustomProfileObject —
|
||||
# the helper that loaded a flat array and called Invoke-TranslateSection).
|
||||
|
||||
function Invoke-InitializeManifestInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'Manifest'
|
||||
Order = 10
|
||||
Match = {
|
||||
param($PolicyObject)
|
||||
$path = Get-DocumentationManifestPath $PolicyObject
|
||||
return [bool]$path
|
||||
}
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateManifestPolicyObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
# Looks up a manifest file for $PolicyObject. Returns the resolved path or
|
||||
# $null. Tries @odata.type first, then PolicyType.Id with '#' prefix.
|
||||
function Get-DocumentationManifestPath {
|
||||
param($PolicyObject)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
$dir = Join-Path $script:AppRootFolder 'Config\ObjectInfo'
|
||||
|
||||
$odata = [string]$obj.'@odata.type'
|
||||
if ($odata) {
|
||||
$path = Join-Path $dir "$odata.json"
|
||||
if (Test-Path -LiteralPath $path -PathType Leaf) {
|
||||
Write-Log "Manifest input provider: Found file based on OData type: $path"
|
||||
return $path
|
||||
}
|
||||
}
|
||||
|
||||
$typeId = $null
|
||||
if ($PolicyObject.PSObject.Properties['PolicyType'] -and $PolicyObject.PolicyType) {
|
||||
$typeId = [string]$PolicyObject.PolicyType.Id
|
||||
}
|
||||
if ($typeId) {
|
||||
$path = Join-Path $dir "#$typeId.json"
|
||||
if (Test-Path -LiteralPath $path -PathType Leaf) {
|
||||
Write-Log "Manifest input provider: Found file based on PolicyType.Id: $path"
|
||||
return $path
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-TranslateManifestPolicyObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
$path = Get-DocumentationManifestPath $PolicyObject
|
||||
if (-not $path) { return }
|
||||
|
||||
# Header rows (matches Profile provider so output looks identical for
|
||||
# both code paths — manifest vs category-driven).
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
|
||||
# Add app name for apps
|
||||
$appType = Get-GraphApplicationType $PolicyObject
|
||||
if($appType)
|
||||
{
|
||||
$appTypeName = Get-LanguageString "AppType.$($appType.LanguageId)"
|
||||
if($appTypeName) { Add-BasicPropertyValue (Get-LanguageString "Inputs.installationSourceLabel") $appTypeName }
|
||||
}
|
||||
|
||||
$Context.CurrentObject = $obj
|
||||
Initialize-DocumentationObjectInfoObject $obj
|
||||
|
||||
try {
|
||||
$manifest = [IO.File]::ReadAllText($path) | ConvertFrom-Json
|
||||
} catch {
|
||||
Write-LogError "Failed to read manifest $path" $_.Exception
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $manifest) { return }
|
||||
|
||||
# Manifest is a flat array (no per-file wrapper key), so pass it directly
|
||||
# to the walker. No $ObjInfo - the manifest doesn't come from
|
||||
# ObjectCategories.json.
|
||||
try {
|
||||
$Context.CurrentSubCategory = ''
|
||||
Invoke-TranslateSection $obj $manifest $null
|
||||
} catch {
|
||||
Write-LogError "Failed to translate manifest $(Split-Path -Leaf $path)" $_.Exception
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
|
||||
}
|
||||
|
||||
Invoke-InitializeManifestInput
|
||||
@@ -0,0 +1,195 @@
|
||||
# Generic Profile input provider.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:2057 (Invoke-TranslateProfile-
|
||||
# Object). Claims any @odata.type catalogued in Config/ObjectCategories.json
|
||||
# that isn't already handled by a more-specific input provider or handler
|
||||
# (first-match-wins dispatch in [DocumentationRegistry]).
|
||||
#
|
||||
# For each catalogued type:
|
||||
# 1. Emit BasicInfo via Add-BasicDefaultValues (which itself reads
|
||||
# ObjectCategories.json for Platform-supported + Profile-type rows)
|
||||
# 2. Emit Created/Modified/Version via Add-BasicAdditionalValues
|
||||
# 3. Find category files: either the explicit Categories list, or every
|
||||
# file matching *_<policyType>.json under Config/ObjectInfo/
|
||||
# 4. Load each as JSON, dispatch to Invoke-TranslateSection walker
|
||||
#
|
||||
# The walker handles all the per-prop dataType dispatching to translate
|
||||
# primitives (Boolean/Option/MultiOption/Table/Duration etc.).
|
||||
|
||||
function Invoke-InitializeProfileInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'Profile'
|
||||
Order = 60
|
||||
Match = {
|
||||
param($PolicyObject)
|
||||
$odata = $PolicyObject.JsonObject.'@odata.type'
|
||||
if (-not $odata) { return $false }
|
||||
if (-not (Get-Command Get-PolicyObjectCategoryInfo -ErrorAction SilentlyContinue)) { return $false }
|
||||
$info = Get-PolicyObjectCategoryInfo $odata
|
||||
return ($null -ne $info -and $null -ne $info.PolicyType)
|
||||
}
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateProfilePolicyObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateProfilePolicyObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
$objInfo = Get-PolicyObjectCategoryInfo $obj.'@odata.type'
|
||||
if (-not $objInfo) { return }
|
||||
|
||||
# Header rows
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# Pin '@ObjectFromFile' so the walker's source-unavailable branches (linked
|
||||
# certificates etc.) treat the input as a file-based object even when
|
||||
# SourceTenantUnavailable isn't set.
|
||||
if (-not $obj.PSObject.Properties['@ObjectFromFile']) {
|
||||
$obj | Add-Member -MemberType NoteProperty -Name '@ObjectFromFile' -Value $true -Force
|
||||
}
|
||||
$Context.CurrentObject = $obj
|
||||
Initialize-DocumentationObjectInfoObject $obj
|
||||
|
||||
# Resolve the list of ObjectInfo JSON files to walk for this PolicyType
|
||||
$objectInfoDir = Join-Path $script:AppRootFolder 'Config\ObjectInfo'
|
||||
$allFiles = @()
|
||||
|
||||
if ($objInfo.Categories -and $objInfo.Categories.Count -gt 0) {
|
||||
foreach ($cat in $objInfo.Categories) {
|
||||
$path = Join-Path $objectInfoDir "$($cat.ToLower())_$($objInfo.PolicyType.ToLower()).json"
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
$allFiles += [IO.FileInfo]$path
|
||||
}
|
||||
else {
|
||||
Write-Log "ObjectInfo file '$path' not found for $($objInfo.PolicyType)" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Single-file path — find any *_<policyType>.json
|
||||
$pattern = "*_$($objInfo.PolicyType.ToLower()).json"
|
||||
if (Test-Path $objectInfoDir) {
|
||||
$files = Get-ChildItem -Path $objectInfoDir -Filter $pattern -ErrorAction SilentlyContinue
|
||||
if (-not $files) {
|
||||
Write-Log "No ObjectInfo files matching '$pattern' for $($objInfo.PolicyType)" 2
|
||||
}
|
||||
foreach ($f in $files) { $allFiles += $f }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($fi in $allFiles) {
|
||||
try {
|
||||
$categoryObj = [IO.File]::ReadAllText($fi.FullName) | ConvertFrom-Json
|
||||
$Context.CurrentSubCategory = ''
|
||||
# Per-file custom handlers override the generic walker (old code:
|
||||
# Invoke-CDDocumentTranslateSectionFile, called via docProvider.
|
||||
# TranslateSectionFile hook from Invoke-TranslateProfileObject).
|
||||
# Returns $true if the custom handler emitted rows; $false to fall
|
||||
# through to Invoke-TranslateSection.
|
||||
if (Invoke-DocCustomSectionFileTranslator -Obj $obj -FileInfo $fi -CategoryObj $categoryObj -ObjInfo $objInfo) {
|
||||
continue
|
||||
}
|
||||
# Each ObjectInfo file wraps its section array under a key matching the file's basename
|
||||
$sections = $categoryObj."$($fi.BaseName)"
|
||||
if ($sections) {
|
||||
Invoke-TranslateSection $obj $sections $objInfo
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to translate ObjectInfo file $($fi.Name)" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Custom per-(odata.type, fileBaseName) section-file translators. Mirrors old
|
||||
# Extensions/DocumentationCustom.psm1's Invoke-CDDocumentTranslateSectionFile.
|
||||
# Each block returns $true if it emitted rows and the generic walker should be
|
||||
# skipped for this file, $false to fall through.
|
||||
function Invoke-DocCustomSectionFileTranslator {
|
||||
param($Obj, [IO.FileInfo]$FileInfo, $CategoryObj, $ObjInfo)
|
||||
|
||||
# --- Compliance: Custom Compliance category (Windows 10) ----------------
|
||||
# Generic walker can't emit useful rows for `customcompliance_compliancewindows10`
|
||||
# because the manifest's dataType=25 ("home screen", unused) is the child
|
||||
# carrying the actual content, and the rules live on a separate
|
||||
# $obj.deviceCompliancePolicyScript navigation property rather than on the
|
||||
# boolean entityKey the parent points to. Three rows are emitted by hand:
|
||||
# - Custom compliance (Require / Not configured)
|
||||
# - Select your discovery script (resolved displayName)
|
||||
# - Upload and validate the JSON file (base64-decoded rulesContent)
|
||||
if ($Obj.'@odata.type' -eq '#microsoft.graph.windows10CompliancePolicy' -and
|
||||
$FileInfo.BaseName -eq 'customcompliance_compliancewindows10') {
|
||||
|
||||
$category = Get-PolicyObjectCategoryString ($CategoryObj."$($FileInfo.BaseName)".category)
|
||||
|
||||
if ($null -eq $Obj.deviceCompliancePolicyScript) {
|
||||
$propValue = Get-LanguageString 'BooleanActions.notConfigured'
|
||||
$rawValue = 'notConfigured'
|
||||
} else {
|
||||
$propValue = Get-LanguageString 'BooleanActions.require'
|
||||
$rawValue = 'require'
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.adminConfiguredComplianceSettingName'
|
||||
Value = $propValue
|
||||
EntityKey = 'deviceCompliancePolicyScript'
|
||||
RawValue = $rawValue
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($Obj.deviceCompliancePolicyScript) {
|
||||
# Resolve script displayName via shared cache. Offline runs / cache
|
||||
# miss fall back to the script id so the row isn't blank.
|
||||
$scriptId = [string]$Obj.deviceCompliancePolicyScript.deviceComplianceScriptId
|
||||
$scriptName = $scriptId
|
||||
if (-not [string]::IsNullOrEmpty($scriptId)) {
|
||||
$cache = Get-CacheObject 'DocAllCustomCompliancePolicies'
|
||||
# Custom compliance scripts are authored in the source tenant (not
|
||||
# generic schema), so this is gated on source-tenant availability.
|
||||
if (-not $cache -and -not (Get-CurrentDocumentationContext).SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$cache = @((Invoke-MSGraphAPI -Url "/deviceManagement/deviceComplianceScripts?`$select=displayName,id" -ODataMetadata 'minimal').value)
|
||||
Set-CacheObject 'DocAllCustomCompliancePolicies' $cache
|
||||
} catch {
|
||||
Write-Log "Failed to fetch deviceComplianceScripts for resolution: $($_.Exception.Message)" 2
|
||||
}
|
||||
}
|
||||
if ($cache) {
|
||||
$match = $cache | Where-Object Id -EQ $scriptId | Select-Object -First 1
|
||||
if ($match.displayName) { $scriptName = $match.displayName }
|
||||
}
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'CustomCompliance.FilePicker.scriptFileLabel'
|
||||
Value = $scriptName
|
||||
EntityKey = 'deviceComplianceScriptName'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($Obj.deviceCompliancePolicyScript.rulesContent) {
|
||||
$rules = try {
|
||||
[System.Text.Encoding]::UTF8.GetString(
|
||||
[System.Convert]::FromBase64String($Obj.deviceCompliancePolicyScript.rulesContent))
|
||||
} catch {
|
||||
[string]$Obj.deviceCompliancePolicyScript.rulesContent
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'CustomCompliance.UploadFile.jsonFileLabel'
|
||||
Value = $rules
|
||||
EntityKey = 'jsonFileContent'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
Invoke-InitializeProfileInput
|
||||
@@ -0,0 +1,142 @@
|
||||
# Settings Catalog input provider.
|
||||
#
|
||||
# Ported from old Extensions/Documentation.psm1:1107 (Invoke-TranslateSettings-
|
||||
# Object, ~100 LOC). Claims @odata.type='#microsoft.graph.deviceManagement
|
||||
# ConfigurationPolicy' and translates the policy's settings via the recursive
|
||||
# walker (Add-SettingsSetting in SettingsCatalogWalker.ps1).
|
||||
#
|
||||
# Live Graph dependencies (resolved through Invoke-MSGraphAPI):
|
||||
# /deviceManagement/configurationPolicies/{id}/settings?$expand=settingDefinitions
|
||||
# /deviceManagement/configurationCategories?$filter=platforms has 'windows10' and technologies has 'mdm'
|
||||
# /deviceManagement/configurationSettings/{id} (per-setting fallback when defs aren't expanded)
|
||||
#
|
||||
# These are batch-cached on the [DocumentationContext] ($ctx.CfgCategories,
|
||||
# $ctx.CachedCfgSettings) so a bulk run pays the cost once. The per-policy
|
||||
# settings fetch (by id) is source-tenant-specific and skipped when
|
||||
# $ctx.SourceTenantUnavailable; the GENERIC schema (setting definitions via
|
||||
# the walker's configurationSettings/{id} fallback, and configurationCategories)
|
||||
# is still resolved from any connected tenant (Test-DocumentationGraphAvailable).
|
||||
# With no tenant at all the provider still runs, producing raw IDs.
|
||||
#
|
||||
# OFFLINE SMOKE TEST DEFERRED: golden-file validation against the provided
|
||||
# fixture (C:/Intune/OldDocumentation/SettingsCatalog/[Testing] Windows 11
|
||||
# Settings.json) needs the policy re-exported with $expand=settings($expand=
|
||||
# settingDefinitions) + a sidecar fixture for scope tags. Until then this
|
||||
# provider is exercised live against a tenant; its structure mirrors the old
|
||||
# code's so trust-the-port applies.
|
||||
|
||||
function Invoke-InitializeSettingsCatalogInput {
|
||||
Add-DocumentationInputProvider ([PSCustomObject]@{
|
||||
Name = 'SettingsCatalog'
|
||||
Order = 20
|
||||
Match = { param($PolicyObject) $PolicyObject.JsonObject.'@odata.type' -eq '#microsoft.graph.deviceManagementConfigurationPolicy' }
|
||||
Translate = { param($PolicyObject, $Context) Invoke-TranslateSettingsCatalogObject $PolicyObject $Context }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-TranslateSettingsCatalogObject {
|
||||
param($PolicyObject, [DocumentationContext]$Context)
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# --- BasicInfo header rows ---
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'ConfigurationTypes.settingsCatalog') '@odata.type'
|
||||
|
||||
if ($obj.templateReference.templateId) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.Category') (Get-IntentCategoryName $obj.templateReference.templateFamily) 'templateFamily'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.policyType') $obj.templateReference.templateDisplayName 'templateDisplayName'
|
||||
}
|
||||
|
||||
if ($obj.platforms) {
|
||||
$platformType = Get-LanguageString "Platform.$($obj.platforms)"
|
||||
if ($platformType) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.platformSupported') $platformType 'platforms'
|
||||
}
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# --- Settings ---
|
||||
# Prefer in-policy settings (export / hydrate with $expand=settings has them
|
||||
# inline). When settingDefinitions are not also inline — the hydrate body URL
|
||||
# only does `?$expand=Settings`, NOT `?$expand=Settings($expand=settingDefinitions)`
|
||||
# — the SettingsCatalog walker falls back to a sequential per-setting
|
||||
# /configurationSettings/{id} GET (one round-trip per settingInstance),
|
||||
# which scales linearly with setting count and crushes bulk-doc runs.
|
||||
# One enrich call per policy collapses that N+1 to a single per-policy call.
|
||||
$cfgSettings = @()
|
||||
if ($obj.Settings -and ($obj.Settings | Measure-Object).Count -gt 0) {
|
||||
$cfgSettings = @($obj.Settings)
|
||||
}
|
||||
|
||||
$hasDefs = $false
|
||||
foreach ($s in $cfgSettings) {
|
||||
if ($s.settingDefinitions -and ($s.settingDefinitions | Measure-Object).Count -gt 0) {
|
||||
$hasDefs = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Bulk runs: Initialize-DocumentationRunPrefetch already fetched these in
|
||||
# one Graph $batch — consume from the per-run cache (authoritative for this
|
||||
# run, even when empty, so an empty-settings policy doesn't trigger a
|
||||
# redundant live GET). The live GET below is the lazy fallback for the
|
||||
# single-policy Get-GraphDocumentation path.
|
||||
if (-not $hasDefs -and $Context.PrefetchedPolicySettings.ContainsKey([string]$obj.Id)) {
|
||||
$cfgSettings = @($Context.PrefetchedPolicySettings[[string]$obj.Id])
|
||||
$hasDefs = $true
|
||||
}
|
||||
|
||||
# Source-tenant-specific: fetches THIS policy's settings by id, which 404s on
|
||||
# any other tenant. Stays gated on -not SourceTenantUnavailable. When the
|
||||
# source is gone but the export carries settings inline (no defs), the walker's
|
||||
# generic per-setting configurationSettings/{id} fallback resolves the schema.
|
||||
if (-not $hasDefs -and -not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$headers = @{}
|
||||
if ($Context.Language -and $Context.Language -ne 'en') {
|
||||
$headers['Accept-Language'] = $Context.Language
|
||||
}
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceManagement/configurationPolicies('$($obj.Id)')/settings?`$expand=settingDefinitions&`$top=1000" -AdditionalHeaders $headers -ODataMetadata 'minimal'
|
||||
if ($resp -and $resp.Value) {
|
||||
$cfgSettings = @($resp.Value)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch settings for policy $($obj.Id)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
if ($cfgSettings.Count -eq 0) {
|
||||
Write-Log "SettingsCatalog: no settings to document for $($obj.name)" 2
|
||||
return
|
||||
}
|
||||
|
||||
# Schema caching, the walk and the (Category, SubCategory) grouping are shared
|
||||
# with the MAM app-configuration handler - see
|
||||
# Get-SettingsCatalogDocumentationRows in Core/SettingsCatalogWalker.ps1.
|
||||
foreach ($row in (Get-SettingsCatalogDocumentationRows $cfgSettings $Context)) {
|
||||
$Context.AddSetting($row)
|
||||
}
|
||||
|
||||
Invoke-DocumentationSettingsCatalogPostProcess $obj $Context
|
||||
}
|
||||
|
||||
# Settings Catalog uses an intent-style category mapping that's distinct from
|
||||
# Get-DocObjectTypeString (which is for group/category headers in the OUTPUT,
|
||||
# not for BasicInfo rows). Delegates to the Intent provider's
|
||||
# Get-IntentCategoryFromTemplateType (the port of old Documentation.psm1:1523
|
||||
# Get-IntentCategory), so endpoint-security-family catalogs show the localized
|
||||
# category name instead of the raw templateFamily (e.g. endpointSecurityAntivirus).
|
||||
function Get-IntentCategoryName {
|
||||
param($TemplateType)
|
||||
if (-not $TemplateType) { return '' }
|
||||
if (Get-Command Get-IntentCategoryFromTemplateType -ErrorAction SilentlyContinue) {
|
||||
$mapped = Get-IntentCategoryFromTemplateType $TemplateType
|
||||
if ($mapped) { return $mapped }
|
||||
}
|
||||
if ($TemplateType -is [string]) { return $TemplateType }
|
||||
return "$TemplateType"
|
||||
}
|
||||
|
||||
Invoke-InitializeSettingsCatalogInput
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,589 @@
|
||||
# Atlassian (Confluence Storage Format) output provider.
|
||||
#
|
||||
# Emits Confluence-compatible XHTML for pasting into a Confluence page editor
|
||||
# (Rich Text -> Source view) or POSTing via the Confluence REST API as
|
||||
# `representation=storage`. Structurally identical to the HTML provider
|
||||
# (BasicInfo / FilteredSettings / ComplianceActions / ApplicabilityRules /
|
||||
# Assignments / CustomTables + a per-run table of contents), only the emitted
|
||||
# markup differs: no CSS embed, no <HTML>/<body> wrapper, code and long-text
|
||||
# blocks use Confluence macros (<ac:structured-macro name='code'|'expand'>).
|
||||
#
|
||||
# Heading anchors: every heading carries id='<anchor>' plus an inline `anchor`
|
||||
# macro of the same name, and the table of contents links that name. Anchors are
|
||||
# positional - 'section-N' for every heading, numbered in emission order across
|
||||
# the whole run (including headings kept out of the TOC),
|
||||
# so a name is never reused. 'table-N' is reserved for the -ToT caption form,
|
||||
# which no call site in this provider currently uses - table captions are plain
|
||||
# level-6 headings here, as in the HTML provider. The id= attribute is a
|
||||
# documented contract for consumers that parse the generated file before it is
|
||||
# published (Confluence itself discards the attribute); it always equals the
|
||||
# macro name. Changing the naming scheme is a breaking change for those
|
||||
# consumers.
|
||||
#
|
||||
# Options (via $Options.Outputs.atlassian):
|
||||
# AtlassianDocumentName - target file path. Supports Expand-FileName
|
||||
# tokens (%MyDocuments%, %Organization%, %Date%,
|
||||
# %DateTime%). Default: %MyDocuments%\%Organization%-%Date%.html
|
||||
# AtlassianDocumentFileType - 'Full' (single file) or 'Object' (one file per
|
||||
# policy + a TOC index file). Default: 'Full'.
|
||||
# AtlassianTitleProperty - H1 title of the index page. Default: 'Intune documentation'.
|
||||
# AtlassianOpenFile - After writing, launch the file with the OS
|
||||
# default handler. Set $false for CI runs.
|
||||
# Default: $true.
|
||||
|
||||
function Invoke-InitializeAtlassianOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Atlassian"
|
||||
Value = "atlassian"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment). Drives
|
||||
# the "default output folder" inference on the bulk-doc form, whose
|
||||
# Atlassian options panel mirrors the HTML one minus the CSS row.
|
||||
PrimaryPathOption = "AtlassianDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-AtlassianPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-AtlassianNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-AtlassianNewObjectType @args }
|
||||
Process = { Invoke-AtlassianProcessItem @args }
|
||||
PostProcess = { Invoke-AtlassianPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-AtlassianProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-AtlassianPreProcessItems {
|
||||
$script:atlSectionAnchors = @()
|
||||
$script:atlTotAnchors = @()
|
||||
# Anchor numbering is deliberately NOT derived from the anchor lists above:
|
||||
# a -SkipTOC heading is emitted (and needs an anchor) without being listed,
|
||||
# so a list-derived number would be handed out twice. See Add-AtlassianHeader.
|
||||
$script:atlSectionCount = 0
|
||||
$script:atlTotCount = 0
|
||||
$script:atlBody = $null
|
||||
$script:atlCurrentItemFileName = $null
|
||||
|
||||
$fileName = Get-DocumentationOutputOption atlassian "AtlassianDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.html" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
$script:atlOutFile = $fileName
|
||||
$script:atlDocumentPath = [IO.Path]::GetDirectoryName($fileName)
|
||||
$script:atlOutputType = Get-DocumentationOutputOption atlassian "AtlassianDocumentFileType" "Full"
|
||||
|
||||
if ($script:atlOutputType -eq "Object") {
|
||||
Write-Log "Atlassian: document one file for each object + index file"
|
||||
}
|
||||
else {
|
||||
Write-Log "Atlassian: document one single file for all objects"
|
||||
$script:atlOutputType = "Full"
|
||||
$script:atlBody = [System.Text.StringBuilder]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-AtlassianPostProcessItems {
|
||||
$userName = $null
|
||||
$mail = ""
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
if ($me.mail) { $mail = " ($($me.mail))" }
|
||||
}
|
||||
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
|
||||
$title = Get-DocumentationOutputOption atlassian "AtlassianTitleProperty" "Intune documentation"
|
||||
if (-not $title) { $title = "Intune documentation" }
|
||||
|
||||
# Escaped for the same reason heading text is: an '&' in the tenant's
|
||||
# organization name or in the configured title would make the document body
|
||||
# invalid XML, and Confluence rejects the upload of the whole page.
|
||||
$content = [System.Text.StringBuilder]::new()
|
||||
[void]$content.AppendLine("<h1>$(Get-AtlassianXmlText $title)</h1>")
|
||||
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
if ($orgName) { [void]$content.AppendLine("Organization: $(Get-AtlassianXmlText $orgName)") }
|
||||
if ($userName) { [void]$content.AppendLine("Generated by: $(Get-AtlassianXmlText "$userName$mail")") }
|
||||
[void]$content.AppendLine("Generated: $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())")
|
||||
}
|
||||
|
||||
if ($script:atlSectionAnchors.Count -gt 0) {
|
||||
[void]$content.AppendLine("<h2>Table of Contents</h2>")
|
||||
Add-AtlassianTableOfContents $content
|
||||
}
|
||||
|
||||
$text = $content.ToString()
|
||||
if ($script:atlOutputType -eq "Full" -and $script:atlBody) {
|
||||
$text += $script:atlBody.ToString()
|
||||
}
|
||||
|
||||
Save-DocumentationFile $text $script:atlOutFile -OpenFile:((Get-DocumentationOutputOption atlassian "AtlassianOpenFile" $true) -eq $true)
|
||||
}
|
||||
|
||||
function Invoke-AtlassianNewObjectGroup {
|
||||
param($groupId)
|
||||
$script:atlObjectHeaderLevel = 2
|
||||
Add-AtlassianHeader (Get-DocObjectTypeString $groupId)
|
||||
}
|
||||
|
||||
function Invoke-AtlassianNewObjectType {
|
||||
param($objectTypeName)
|
||||
$script:atlObjectHeaderLevel = 3
|
||||
Add-AtlassianHeader $objectTypeName
|
||||
$script:atlObjectHeaderLevel = 4
|
||||
}
|
||||
|
||||
function Invoke-AtlassianProcessAllObjects {
|
||||
param($documentationInfo)
|
||||
# ScopeTags consolidated table is deferred (matches HTML provider stub).
|
||||
}
|
||||
|
||||
function Invoke-AtlassianProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
if ($script:atlOutputType -eq "Object") {
|
||||
# Table numbering restarts per file (each object is its own page), section
|
||||
# numbering does not - see the header comment on anchor uniqueness.
|
||||
$script:atlTotAnchors = @()
|
||||
$script:atlTotCount = 0
|
||||
$script:atlBody = [System.Text.StringBuilder]::new()
|
||||
$script:atlCurrentItemFileName = Get-AtlassianObjectFileName $PolicyObject
|
||||
}
|
||||
|
||||
Add-AtlassianHeader $objName
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
$lngId = "SettingDetails.basics"
|
||||
}
|
||||
else {
|
||||
$properties = if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$documentedObj.DefaultDocumentationProperties
|
||||
} else {
|
||||
@("Name","Value")
|
||||
}
|
||||
$lngId = "TableHeaders.settings"
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-AtlassianObjectScripts $documentedObj
|
||||
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Sort-Object -Property Order)) {
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $null -ne $_.Settings } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$properties += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-AtlassianTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
|
||||
if ($script:atlOutputType -eq "Object") {
|
||||
$fileName = Join-Path $script:atlDocumentPath $script:atlCurrentItemFileName
|
||||
Save-DocumentationFile $script:atlBody.ToString() $fileName
|
||||
$script:atlBody = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AtlassianObjectFileName {
|
||||
param($PolicyObject)
|
||||
|
||||
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
|
||||
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
|
||||
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
|
||||
$suffix = if ($typeId -and $id) { " [$typeId-$id]" }
|
||||
elseif ($id) { " [$id]" }
|
||||
else { '' }
|
||||
return Remove-InvalidFileNameChars "$objName$suffix.html"
|
||||
}
|
||||
|
||||
# Escape text for storage format. Confluence storage format is strict XML and
|
||||
# declares only the five XML built-in entities, so a bare '&' or '<' arriving
|
||||
# from tenant data (policy names, localized captions) makes the whole document
|
||||
# body malformed and Confluence rejects the upload - not just that heading.
|
||||
# Values inside table cells go through Set-AtlassianText, which does this plus
|
||||
# the code/expand macro wrapping; headers and TOC labels need only the escape.
|
||||
function Get-AtlassianXmlText {
|
||||
param([string]$Text)
|
||||
|
||||
if (-not $Text) { return "" }
|
||||
return $Text.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
|
||||
# The author-controlled link target for a heading.
|
||||
#
|
||||
# Confluence strips author-specified id= attributes when it converts storage
|
||||
# format to ADF, so an id alone is not linkable - '#name' only ever resolves to
|
||||
# an anchor macro or to Confluence's own heading-text-derived anchor.
|
||||
#
|
||||
# The macro stays inline inside the heading until the downstream rewrite observed
|
||||
# in Docs/AtlassianAnchorVerification-2026-09-15.md has been attributed. Moving it
|
||||
# to a preceding paragraph before that measurement was an unverified fix that could
|
||||
# add a blank line at every heading without changing the publisher's output. Inline
|
||||
# placement is legal ADF (`anchor` is an inline macro and headings accept inline
|
||||
# content), keeps the jump target on the heading, and is the known baseline while
|
||||
# the runbook and Confluence import paths are tested separately.
|
||||
#
|
||||
# Attributes are single-quoted like every other macro in this file. Consumers
|
||||
# JSON-escape the document body before publishing it, and a double-quoted
|
||||
# attribute arrives as ac:name=\"anchor\" and breaks the macro.
|
||||
function Get-AtlassianAnchorMacro {
|
||||
param([string]$Name)
|
||||
|
||||
if (-not $Name) { return "" }
|
||||
return "<ac:structured-macro ac:name='anchor' ac:schema-version='1'>" +
|
||||
"<ac:parameter ac:name=''>$Name</ac:parameter>" +
|
||||
"</ac:structured-macro>"
|
||||
}
|
||||
|
||||
# Build the href for a TOC entry: a percent-encoded relative file name plus the
|
||||
# anchor fragment, safe to drop into a single-quoted attribute.
|
||||
#
|
||||
# In 'Object' mode the file name comes from the policy name (see
|
||||
# Get-AtlassianObjectFileName), and only path-invalid characters are stripped
|
||||
# from it. '&', '<' and "'" therefore survive - one of them makes the whole
|
||||
# document body malformed XML, or terminates the attribute - and a '#' in a
|
||||
# policy name would open a second fragment and retarget the link. Percent-encode
|
||||
# the file-name component (never the '#' that separates the fragment), then
|
||||
# XML-escape what is left.
|
||||
function Get-AtlassianHref {
|
||||
param([string]$FileName, [string]$Anchor)
|
||||
|
||||
$target = ""
|
||||
if ($FileName) {
|
||||
# A bare file name, no directory separators, so encoding the whole string
|
||||
# is correct. EscapeDataString covers ' on .NET Core but not on every
|
||||
# .NET Framework version; the explicit replace is a no-op when it did.
|
||||
$target = [Uri]::EscapeDataString($FileName).Replace("'", "%27")
|
||||
}
|
||||
|
||||
return Get-AtlassianXmlText "$target#$Anchor"
|
||||
}
|
||||
|
||||
function Add-AtlassianHeader {
|
||||
param(
|
||||
[string]$HeaderText,
|
||||
[int]$Level = $script:atlObjectHeaderLevel,
|
||||
[switch]$ToT,
|
||||
[switch]$SkipTOC
|
||||
)
|
||||
|
||||
if ($ToT) {
|
||||
# 'Table N. ' is a visible caption prefix - that is what the Markdown
|
||||
# provider does with it. It used to be prepended to the id instead of to
|
||||
# the text, producing id="Table 1. table-1": spaces and a period in an
|
||||
# identifier, and no visible numbering anywhere. The number matches the
|
||||
# 'table-N' anchor below.
|
||||
$HeaderText = "Table $($script:atlTotCount + 1). $HeaderText"
|
||||
}
|
||||
|
||||
if ($script:atlBody) {
|
||||
# Every heading that reaches the body consumes a number, whether or not it
|
||||
# is listed in the TOC. Numbering off $atlSectionAnchors.Count instead gave
|
||||
# a -SkipTOC heading (script captions, Add-AtlassianObjectScripts) the same
|
||||
# 'section-N' as the next listed heading: two anchor macros with one name,
|
||||
# so the TOC entry for the policy jumped to the script caption above it.
|
||||
if ($ToT) {
|
||||
$script:atlTotCount++
|
||||
$sectionAnchor = "table-$($script:atlTotCount)"
|
||||
}
|
||||
else {
|
||||
$script:atlSectionCount++
|
||||
$sectionAnchor = "section-$($script:atlSectionCount)"
|
||||
}
|
||||
|
||||
# id= is kept even though Confluence discards it: consumers parse the
|
||||
# generated file (before upload) and read the anchor from it, so it must
|
||||
# stay byte-identical to the anchor macro name.
|
||||
$anchorMacro = Get-AtlassianAnchorMacro $sectionAnchor
|
||||
[void]$script:atlBody.AppendLine("<h$Level id='$sectionAnchor'>$anchorMacro$(Get-AtlassianXmlText $HeaderText)</h$Level>")
|
||||
$fileName = $script:atlCurrentItemFileName
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = $null
|
||||
$fileName = $null
|
||||
}
|
||||
|
||||
if ($ToT) {
|
||||
$script:atlTotAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipTOC) {
|
||||
$script:atlSectionAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Render the per-run table of contents into $Content.
|
||||
#
|
||||
# Each entry links the anchor its heading actually emitted. Deriving the target
|
||||
# from the heading text instead - which this did - is unreliable twice over:
|
||||
# duplicate policy names all resolved to the first occurrence (Confluence
|
||||
# disambiguates its own text-derived anchors with .1/.2, which a naive
|
||||
# derivation cannot reproduce), and every character other than a plain space
|
||||
# survived into the fragment, including '.', '(', ')', ':' and U+00A0.
|
||||
#
|
||||
# Confluence still generates its text-derived anchors, so externally saved
|
||||
# '#Policy-Name' links keep working; only the TOC moves to the reliable form.
|
||||
function Add-AtlassianTableOfContents {
|
||||
param(
|
||||
[System.Text.StringBuilder]$Content,
|
||||
[int]$MaxLevel = 4
|
||||
)
|
||||
|
||||
foreach ($header in $script:atlSectionAnchors) {
|
||||
if ($MaxLevel -gt 0 -and $header.Level -gt $MaxLevel) { continue }
|
||||
# Nest visually via non-breaking-space padding - Confluence doesn't honour
|
||||
# CSS anchor-level classes on imported storage-format content. Use the
|
||||
# numeric reference   (not the HTML entity ): storage format is
|
||||
# strict XML and only declares the five XML built-in entities.
|
||||
$indent = ""
|
||||
for ($i = 2; $i -lt $header.Level; $i++) { $indent += "  " }
|
||||
|
||||
$label = Get-AtlassianXmlText $header.Name
|
||||
if ($header.Anchor) {
|
||||
$href = Get-AtlassianHref $header.FileName $header.Anchor
|
||||
[void]$Content.AppendLine("$indent<a href='$href'>$label</a>")
|
||||
}
|
||||
else {
|
||||
# Registered while no body was open (a group/type header in 'Object'
|
||||
# mode), so the heading exists in no file and has no anchor. A
|
||||
# '#'-only href would jump to the top of the page instead; emit the
|
||||
# label as plain text.
|
||||
[void]$Content.AppendLine("$indent$label")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-AtlassianTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride
|
||||
)
|
||||
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId -and $PolicyObject) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $(Get-DocCaptionName $PolicyObject)"
|
||||
}
|
||||
elseif ($PolicyObject) {
|
||||
$caption = "$(Get-DocCaptionName $PolicyObject) ($TypeTitle)"
|
||||
}
|
||||
else {
|
||||
$caption = $TypeTitle
|
||||
}
|
||||
|
||||
$table = [System.Text.StringBuilder]::new()
|
||||
[void]$table.AppendLine("<table>")
|
||||
[void]$table.AppendLine("<tr>")
|
||||
|
||||
$columnCount = 0
|
||||
foreach ($prop in $Properties) {
|
||||
[void]$table.AppendLine("<th>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</th>")
|
||||
$columnCount++
|
||||
}
|
||||
[void]$table.AppendLine("</tr>")
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
|
||||
foreach ($itemObj in $Items) {
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
[void]$table.AppendLine("<tr><td colspan='$columnCount'><strong>$($itemObj.Category)</strong></td></tr>")
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
[void]$table.AppendLine("<tr><td colspan='$columnCount'><em>$($itemObj.SubCategory)</em></td></tr>")
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
}
|
||||
|
||||
try {
|
||||
[void]$table.AppendLine("<tr>")
|
||||
|
||||
$curCol = 0
|
||||
foreach ($prop in $Properties) {
|
||||
$curCol++
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
|
||||
[void]$table.AppendLine("<td><table><tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$table.AppendLine("<th>$($colProp.Name)</th>")
|
||||
}
|
||||
[void]$table.AppendLine("</tr>")
|
||||
foreach ($rowVal in $itemObj.FullValueTable) {
|
||||
[void]$table.AppendLine("<tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$table.AppendLine("<td>$((Set-AtlassianText $rowVal."$($colProp.Name)"))</td>")
|
||||
}
|
||||
[void]$table.AppendLine("</tr>")
|
||||
}
|
||||
[void]$table.AppendLine("</table></td>")
|
||||
}
|
||||
else {
|
||||
$indent = ""
|
||||
if ($curCol -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
# One indent unit per nesting level (Level 1 = first
|
||||
# indent), matching the HTML/MD/Word providers. Was
|
||||
# off-by-one ($i started at 1), so Level-1 children
|
||||
# rendered flush. Negative levels produce no indent.
|
||||
$level = [int]$itemObj.Level
|
||||
#   (numeric non-breaking space) not —
|
||||
# Confluence storage format is strict XML and only
|
||||
# declares the five XML built-in entities, so
|
||||
# is dropped/rejected. Numeric refs always render.
|
||||
for ($i = 0; $i -lt $level; $i++) { $indent += "  " }
|
||||
} catch {}
|
||||
}
|
||||
[void]$table.AppendLine("<td>$($indent)$((Set-AtlassianText $tmpObj.$propName))</td>")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
finally {
|
||||
[void]$table.AppendLine("</tr>")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$table.AppendLine("</table>")
|
||||
[void]$script:atlBody.Append($table.ToString())
|
||||
# No -ToT: the caption is a plain level-6 heading, matching the HTML provider.
|
||||
# Passing -ToT here would add visible 'Table N. ' numbering (what the Markdown
|
||||
# provider does) and move the caption into $atlTotAnchors. That is a formatting
|
||||
# decision for the HTML and Atlassian outputs together, not a port detail.
|
||||
Add-AtlassianHeader $caption -Level 6
|
||||
}
|
||||
|
||||
# Confluence Storage Format text emitter. Escapes HTML special chars in plain
|
||||
# text; wraps XML-looking values in a `code` macro; wraps long text (>250
|
||||
# chars) in an `expand` macro with a first-line summary as the caption.
|
||||
function Set-AtlassianText {
|
||||
param([string]$Text, [switch]$NoCodeBlock)
|
||||
|
||||
if (-not $Text) { return }
|
||||
|
||||
$txtSummary = ""
|
||||
if ($Text.Length -gt 250) {
|
||||
$summaryMax = 40
|
||||
$idx = $Text.IndexOfAny(@("`r","`n"))
|
||||
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
|
||||
$txtSummary = $Text.Substring(0, $summaryMax)
|
||||
}
|
||||
|
||||
$isCode = $false
|
||||
if (-not $NoCodeBlock) {
|
||||
$trim = $Text.Trim()
|
||||
if ($trim.StartsWith("<") -and $trim.EndsWith(">")) {
|
||||
$isCode = $true
|
||||
$Text = "<ac:structured-macro ac:name='code' ac:schema-version='1'>" +
|
||||
"<ac:parameter ac:name='language'>xml</ac:parameter>" +
|
||||
"<ac:plain-text-body><![CDATA[$Text]]></ac:plain-text-body>" +
|
||||
"</ac:structured-macro>"
|
||||
if ($txtSummary) {
|
||||
$txtSummary = $txtSummary.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $isCode) {
|
||||
$Text = $Text.Replace('&','&').Replace('<','<').Replace('>','>') #.Replace("`r`n",'<br />').Replace("`n",'<br />')
|
||||
}
|
||||
|
||||
if ($txtSummary) {
|
||||
"<ac:structured-macro ac:name='expand' ac:schema-version='1'>" +
|
||||
"<ac:parameter ac:name='title'>$txtSummary...</ac:parameter>" +
|
||||
"<ac:rich-text-body>$Text</ac:rich-text-body>" +
|
||||
"</ac:structured-macro>"
|
||||
}
|
||||
else {
|
||||
$Text
|
||||
}
|
||||
}
|
||||
|
||||
function Add-AtlassianObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
[void]$script:atlBody.AppendLine("<ac:structured-macro ac:name='code' ac:schema-version='1'>")
|
||||
[void]$script:atlBody.AppendLine("<ac:parameter ac:name='language'>powershell</ac:parameter>")
|
||||
[void]$script:atlBody.AppendLine("<ac:plain-text-body><![CDATA[")
|
||||
[void]$script:atlBody.AppendLine($scriptItem.ScriptContent)
|
||||
[void]$script:atlBody.AppendLine("]]></ac:plain-text-body>")
|
||||
[void]$script:atlBody.AppendLine("</ac:structured-macro>")
|
||||
Add-AtlassianHeader $scriptItem.Caption -Level 6 -SkipTOC
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeAtlassianOutput
|
||||
@@ -0,0 +1,129 @@
|
||||
# CSV output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Writes one CSV file per object under
|
||||
# <RootFolder>[/<Organization>][/<ObjectType.Id>]/<ObjectName>.csv.
|
||||
#
|
||||
# Headless: explicit public options override persisted Documentation settings.
|
||||
# UI form construction belongs to the active UI backend; this file no longer
|
||||
# imports XAML at module load. See [[architecture-rules]] R1/R2.
|
||||
#
|
||||
# Settings consumed:
|
||||
# CSVExportProperties simple | extended | custom default 'simple'
|
||||
# CSVCustomDisplayProperties comma-separated property names default 'Name,Value,Category'
|
||||
# CSVDelimiter CSV delimiter character default '' (auto)
|
||||
# CSVDocumentationPath root folder for export default ''
|
||||
# CSVAddObjectType $true to nest under ObjectType default $true
|
||||
# CSVAddCompanyName $true to nest under Organization default $false
|
||||
|
||||
function Invoke-InitializeCSVOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "CSV"
|
||||
Value = "csv"
|
||||
# UI hints — where does this provider store its primary output path, and
|
||||
# is that path a folder (CSV writes many files) or a file?
|
||||
PrimaryPathOption = "CSVDocumentationPath"
|
||||
PathIsFolder = $true
|
||||
PreProcess = { Invoke-CSVPreProcessItems @args }
|
||||
Process = { Invoke-CSVProcessItem @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-CSVPreProcessItems {
|
||||
# No-op. Settings are the source of truth; the UI form (when wired) writes
|
||||
# them via Save-SettingStoreValue directly. Hook retained for symmetry / future use.
|
||||
}
|
||||
|
||||
function Invoke-CSVProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
$rootFolder = Get-DocumentationOutputOption csv "CSVDocumentationPath" ""
|
||||
$addObjectType = (Get-DocumentationOutputOption csv "CSVAddObjectType" $true) -eq $true
|
||||
$addCompanyName = (Get-DocumentationOutputOption csv "CSVAddCompanyName" $false) -eq $true
|
||||
$folder = Get-DocObjectFolder -RootFolder $rootFolder -PolicyType $PolicyObject.PolicyType -AddObjectType:$addObjectType -AddOrganization:$addCompanyName
|
||||
|
||||
$objName = $PolicyObject.Name
|
||||
|
||||
try {
|
||||
if (-not [IO.Directory]::Exists($folder)) {
|
||||
[IO.Directory]::CreateDirectory($folder) | Out-Null
|
||||
}
|
||||
|
||||
$mode = Get-DocumentationOutputOption csv "CSVExportProperties" "simple"
|
||||
$customProps = Get-DocumentationOutputOption csv "CSVCustomDisplayProperties" "Name,Value,Category"
|
||||
$delimiter = Get-DocumentationOutputOption csv "CSVDelimiter" ""
|
||||
|
||||
$csvParams = @{}
|
||||
if ($delimiter) { $csvParams['Delimiter'] = $delimiter }
|
||||
|
||||
$itemsToExport = @()
|
||||
|
||||
$useSectioned = ($mode -eq 'extended' -and $documentedObj.DisplayProperties) -or
|
||||
($mode -eq 'custom' -and $customProps)
|
||||
|
||||
if ($useSectioned) {
|
||||
if (($documentedObj.BasicInfo | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Basic info"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.BasicInfo | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.FilteredSettings | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Settings"
|
||||
$itemsToExport += ""
|
||||
if ($mode -eq 'extended') {
|
||||
$displayProperties = $documentedObj.DisplayProperties
|
||||
}
|
||||
else {
|
||||
$displayProperties = $customProps.Split(",")
|
||||
}
|
||||
$itemsToExport += $documentedObj.FilteredSettings | Select-Object $displayProperties | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Applicability Rules"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.ApplicabilityRules | Select-Object Rule, Property, Value, Category | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Compliance Actions"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.ComplianceActions | Select-Object Action, Schedule, MessageTemplate, EmailCC, Category | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) { $properties = @("GroupMode","Group","Category","SubCategory") }
|
||||
elseif ($documentedObj.Assignments[0].Group) { $properties = @("GroupMode","Group","Category") }
|
||||
else { $properties = @("GroupMode","Groups","Category") }
|
||||
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += "# Assignments"
|
||||
$itemsToExport += ""
|
||||
$itemsToExport += $documentedObj.Assignments | Select-Object $properties | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
}
|
||||
else {
|
||||
$rows = @()
|
||||
$rows += $documentedObj.BasicInfo
|
||||
$rows += $documentedObj.FilteredSettings
|
||||
$itemsToExport = $rows | Select-Object Name, Value | ConvertTo-Csv -NoTypeInformation @csvParams
|
||||
}
|
||||
|
||||
$safeName = Remove-InvalidFileNameChars $objName
|
||||
$fileName = Join-Path $folder "$safeName.csv"
|
||||
Write-Log "Save documentation to $fileName"
|
||||
$itemsToExport | Out-File -LiteralPath $fileName -Encoding utf8 -Force
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to save CSV file for $objName in $folder" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeCSVOutput
|
||||
@@ -0,0 +1,476 @@
|
||||
# HTML output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Renders a single HTML document (or one file per
|
||||
# object) with CSS from Internal/Documentation/Assets/DefaultHTMLStyle.css.
|
||||
#
|
||||
# Differences vs old DocumentationHTML.psm1:
|
||||
# - Drops V1 NewObjectGroup/NewObjectType hooks (V2 string-arg is registered)
|
||||
# - Drops extended/custom property selectors that read $global:txt*/$global:cb*
|
||||
# UI controls. Always uses DefaultDocumentationProperties or ('Name','Value').
|
||||
# Phase 2 [DocumentationContext] will reintroduce these via $ctx.Options.
|
||||
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType.Title
|
||||
# - Invoke-HTMLProcessAllObjects (ScopeTags consolidated table) is stubbed
|
||||
# because Get-TableObjects doesn't exist yet — wire up in phase 2.
|
||||
|
||||
function Invoke-InitializeHTMLOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "HTML"
|
||||
Value = "html"
|
||||
# Path metadata for the bulk-doc UI: which option key stores the
|
||||
# primary output path, and whether that path is a folder or a file.
|
||||
# File-picker button wiring for the UI is declared separately in
|
||||
# UI/<backend>/ClassExtensions/DocumentationOutputHTMLUIExtension.ps1
|
||||
# to keep XAML control names + toolkit-specific filter strings out
|
||||
# of this pure-logic file.
|
||||
PrimaryPathOption = "HTMLDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-HTMLPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-HTMLNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-HTMLNewObjectType @args }
|
||||
Process = { Invoke-HTMLProcessItem @args }
|
||||
PostProcess = { Invoke-HTMLPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-HTMLProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-HTMLPreProcessItems {
|
||||
$script:sectionAnchors = @()
|
||||
$script:totAnchors = @()
|
||||
$script:htmlStrings = $null
|
||||
$script:currentItemFileName = $null
|
||||
|
||||
$defaultCSSFile = [IO.Path]::Combine($script:AppRootFolder, "Internal", "Documentation", "Assets", "DefaultHTMLStyle.css")
|
||||
$htmlCssFile = Get-DocumentationOutputOption html "HTMLCSSFile" $defaultCSSFile
|
||||
|
||||
if (-not $htmlCssFile) {
|
||||
Write-Log "CSS file not specified. Using default" 2
|
||||
$htmlCssFile = $defaultCSSFile
|
||||
}
|
||||
elseif (-not [IO.File]::Exists($htmlCssFile)) {
|
||||
Write-Log "CSS file $htmlCssFile not found. Using default" 2
|
||||
$htmlCssFile = $defaultCSSFile
|
||||
}
|
||||
|
||||
if ([IO.File]::Exists($htmlCssFile)) {
|
||||
Write-Log "Using CSS file $htmlCssFile"
|
||||
$script:cssStyle = ([IO.File]::ReadAllText($htmlCssFile)) + [Environment]::NewLine
|
||||
}
|
||||
else {
|
||||
Write-Log "CSS file $htmlCssFile not found. No styles applied" 2
|
||||
$script:cssStyle = ""
|
||||
}
|
||||
|
||||
$fileName = Get-DocumentationOutputOption html "HTMLDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.html" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
$script:outFile = $fileName
|
||||
$script:documentPath = [IO.Path]::GetDirectoryName($fileName)
|
||||
$script:outputType = Get-DocumentationOutputOption html "HTMLDocumentFileType" "Full"
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
Write-Log "Document one file for each object + index file"
|
||||
}
|
||||
else {
|
||||
Write-Log "Document one single file for all objects"
|
||||
$script:outputType = "Full"
|
||||
$script:htmlStrings = [System.Text.StringBuilder]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-HTMLPostProcessItems {
|
||||
$userName = $null
|
||||
$mail = ""
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
if ($me.mail) { $mail = " ($($me.mail))" }
|
||||
}
|
||||
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
|
||||
$title = Get-DocumentationOutputOption html "HTMLTitleProperty" "Intune documentation"
|
||||
if (-not $title) { $title = "Intune documentation" }
|
||||
|
||||
$htmlContent = [System.Text.StringBuilder]::new()
|
||||
[void]$htmlContent.AppendLine("<HTML>")
|
||||
[void]$htmlContent.AppendLine($script:cssStyle)
|
||||
[void]$htmlContent.AppendLine("<H1 class='header-level1'>$title</H1>")
|
||||
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
if ($orgName) { [void]$htmlContent.AppendLine("Organization: $orgName<br />") }
|
||||
if ($userName) { [void]$htmlContent.AppendLine("Generated by: $userName$mail<br />") }
|
||||
[void]$htmlContent.AppendLine("Generated: $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())<br />")
|
||||
}
|
||||
|
||||
if ($script:sectionAnchors.Count -gt 0) {
|
||||
[void]$htmlContent.AppendLine("<br />")
|
||||
[void]$htmlContent.AppendLine("<H2 class='header-level2'>Table of Contents</H2>")
|
||||
}
|
||||
|
||||
$tocMaxLevel = 4
|
||||
foreach ($header in $script:sectionAnchors) {
|
||||
if ($tocMaxLevel -gt 0 -and $header.Level -gt $tocMaxLevel) { continue }
|
||||
[void]$htmlContent.AppendLine("<a href='$($header.FileName)#$($header.Anchor)' class='anchor-style anchor-level$($header.Level)'>$($header.Name)</a><br />")
|
||||
}
|
||||
if ($script:sectionAnchors.Count -gt 0) { [void]$htmlContent.AppendLine("<br />") }
|
||||
|
||||
$htmlText = $htmlContent.ToString()
|
||||
if ($script:outputType -eq "Full" -and $script:htmlStrings) {
|
||||
$htmlText += $script:htmlStrings.ToString()
|
||||
}
|
||||
$htmlText += "</HTML>"
|
||||
|
||||
Save-DocumentationFile $htmlText $script:outFile -OpenFile:((Get-DocumentationOutputOption html "HTMLOpenFile" $true) -eq $true)
|
||||
}
|
||||
|
||||
function Invoke-HTMLNewObjectGroup {
|
||||
param($groupId)
|
||||
$script:objectHeaderLevel = 2
|
||||
Add-HTMLHeader (Get-DocObjectTypeString $groupId)
|
||||
}
|
||||
|
||||
function Invoke-HTMLNewObjectType {
|
||||
param($objectTypeName)
|
||||
$script:objectHeaderLevel = 3
|
||||
Add-HTMLHeader $objectTypeName
|
||||
$script:objectHeaderLevel = 4
|
||||
}
|
||||
|
||||
function Invoke-HTMLProcessAllObjects {
|
||||
param($documentationInfo)
|
||||
# ScopeTags consolidated table is deferred — Get-TableObjects helper lands
|
||||
# in phase 2 alongside the engine. For now this is a no-op.
|
||||
}
|
||||
|
||||
function Invoke-HTMLProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$script:totAnchors = @()
|
||||
$script:htmlStrings = [System.Text.StringBuilder]::new()
|
||||
$script:currentItemFileName = Get-HTMLObjectFileName $PolicyObject
|
||||
}
|
||||
|
||||
Add-HTMLHeader $objName
|
||||
[void]$script:htmlStrings.AppendLine("<br />")
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
$lngId = "SettingDetails.basics"
|
||||
}
|
||||
else {
|
||||
if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$properties = $documentedObj.DefaultDocumentationProperties
|
||||
}
|
||||
else {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
$lngId = "TableHeaders.settings"
|
||||
}
|
||||
|
||||
# Custom tables with a negative Order belong ABOVE the settings
|
||||
# table: the portal shows a MAM app config's "Settings catalog"
|
||||
# blade above its "Settings" blade.
|
||||
if ($tableType -eq "FilteredSettings") {
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-HTMLObjectScripts $documentedObj
|
||||
|
||||
# Negative Order already rendered above the settings table.
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$properties += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-HTMLTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$perObject = [System.Text.StringBuilder]::new()
|
||||
[void]$perObject.AppendLine("<HTML>")
|
||||
[void]$perObject.AppendLine($script:cssStyle)
|
||||
$htmlText = $perObject.ToString() + $script:htmlStrings.ToString() + "</HTML>"
|
||||
$fileName = Join-Path $script:documentPath $script:currentItemFileName
|
||||
Save-DocumentationFile $htmlText $fileName
|
||||
$script:htmlStrings = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-HTMLObjectFileName {
|
||||
param($PolicyObject)
|
||||
|
||||
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
|
||||
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
|
||||
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
|
||||
$suffix = if ($typeId -and $id) { " [$typeId-$id]" }
|
||||
elseif ($id) { " [$id]" }
|
||||
else { '' }
|
||||
return Remove-InvalidFileNameChars "$objName$suffix.html"
|
||||
}
|
||||
|
||||
function Add-HTMLHeader {
|
||||
param(
|
||||
[string]$HeaderText,
|
||||
[int]$Level = $script:objectHeaderLevel,
|
||||
[switch]$ToT,
|
||||
[switch]$SkipTOC
|
||||
)
|
||||
|
||||
if ($script:htmlStrings) {
|
||||
$prefix = ""
|
||||
if ($ToT) {
|
||||
$prefix = "Table $($script:totAnchors.Count + 1). "
|
||||
$sectionAnchor = "table-$($script:totAnchors.Count + 1)"
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = "section-$($script:sectionAnchors.Count + 1)"
|
||||
}
|
||||
|
||||
[void]$script:htmlStrings.AppendLine("<H$Level id=`"$prefix$sectionAnchor`" class='header-level$Level'>$HeaderText</H$Level>")
|
||||
$fileName = $script:currentItemFileName
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = $null
|
||||
$fileName = $null
|
||||
}
|
||||
|
||||
if ($ToT) {
|
||||
$script:totAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipTOC) {
|
||||
$script:sectionAnchors += [PSCustomObject]@{
|
||||
Name = $HeaderText; Anchor = $sectionAnchor; Level = $Level; FileName = $fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-HTMLTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride
|
||||
)
|
||||
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId -and $PolicyObject) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $(Get-DocCaptionName $PolicyObject)"
|
||||
}
|
||||
elseif ($PolicyObject) {
|
||||
$caption = "$(Get-DocCaptionName $PolicyObject) ($TypeTitle)"
|
||||
}
|
||||
else {
|
||||
$caption = $TypeTitle
|
||||
}
|
||||
|
||||
$tableText = [System.Text.StringBuilder]::new()
|
||||
[void]$tableText.AppendLine("<table class='table-settings'>")
|
||||
[void]$tableText.AppendLine("<tr>")
|
||||
|
||||
$columnCount = 0
|
||||
foreach ($prop in $Properties) {
|
||||
[void]$tableText.AppendLine("<th>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</th>")
|
||||
$columnCount++
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
$row = 1
|
||||
|
||||
foreach ($itemObj in $Items) {
|
||||
$additionalRowClass = ""
|
||||
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level1'>$($itemObj.Category)</td></tr>")
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
$row = 1
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level2'>$($itemObj.SubCategory)</td></tr>")
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
$row = 1
|
||||
}
|
||||
|
||||
if ($itemObj.PropertyIndex -is [int] -and $itemObj.PropertyIndex -eq 1) {
|
||||
$additionalRowClass = "row-new-property"
|
||||
}
|
||||
|
||||
try {
|
||||
$rowClass = if (($row % 2) -eq 1) { "row-odd" } else { "row-even" }
|
||||
$row++
|
||||
[void]$tableText.AppendLine("<tr class='$rowClass $additionalRowClass'>")
|
||||
|
||||
$curCol = 1
|
||||
foreach ($prop in $Properties) {
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
|
||||
[void]$tableText.AppendLine("<td><table class='table-value'><tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<th>$($colProp.Name)</th>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
foreach ($rowVal in $itemObj.FullValueTable) {
|
||||
[void]$tableText.AppendLine("<tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<td>$($rowVal."$($colProp.Name)")</td>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</table></td>")
|
||||
}
|
||||
else {
|
||||
$style = ""
|
||||
if ($curCol -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
$level = [int]$itemObj.Level
|
||||
$style = " style='padding-left:$((5 + ($level * 5)))px;'"
|
||||
} catch {}
|
||||
}
|
||||
[void]$tableText.AppendLine("<td class='property-column$curCol'$style>$((Set-HTMLText $tmpObj.$propName))</td>")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
$curCol++
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
finally {
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$tableText.AppendLine("</table>")
|
||||
[void]$script:htmlStrings.Append($tableText.ToString())
|
||||
Add-HTMLHeader $caption -Level 6
|
||||
}
|
||||
|
||||
function Set-HTMLText {
|
||||
param([string]$Text, [switch]$NoCodeBlock)
|
||||
|
||||
if (-not $Text) { return }
|
||||
|
||||
$txtSummary = ""
|
||||
if ($Text.Length -gt 250) {
|
||||
$summaryMax = 40
|
||||
$idx = $Text.IndexOfAny(@("`r","`n"))
|
||||
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
|
||||
$txtSummary = $Text.Substring(0, $summaryMax)
|
||||
}
|
||||
|
||||
$code = $false
|
||||
if (-not $NoCodeBlock) {
|
||||
$trim = $Text.Trim()
|
||||
if ($trim.StartsWith("<") -and $trim.EndsWith(">")) {
|
||||
$code = $true
|
||||
$Text = "<pre class='code'>$($Text.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"'))</pre>"
|
||||
if ($txtSummary) {
|
||||
$txtSummary = $txtSummary.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $code) {
|
||||
$Text = $Text.Replace("`r`n", "<br />").Replace("`n", "<br />").Replace('&', '&')
|
||||
}
|
||||
|
||||
if ($txtSummary) {
|
||||
"<details class='description'><summary data-open='Minimize' data-close='$txtSummary...expand'></summary>$Text</details>"
|
||||
}
|
||||
else {
|
||||
$Text
|
||||
}
|
||||
}
|
||||
|
||||
function Add-HTMLObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
[void]$script:htmlStrings.AppendLine("<pre class='code'>")
|
||||
[void]$script:htmlStrings.AppendLine($scriptItem.ScriptContent)
|
||||
[void]$script:htmlStrings.AppendLine("</pre>")
|
||||
Add-HTMLHeader $scriptItem.Caption -Level 6 -SkipTOC
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeHTMLOutput
|
||||
@@ -0,0 +1,214 @@
|
||||
# JSON output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result:
|
||||
# BasicInfo [{Name, Value}]
|
||||
# FilteredSettings [{Name, Value, Category?, SubCategory?}]
|
||||
# ComplianceActions [{Action, Schedule, MessageTemplate, EmailCC}]
|
||||
# ApplicabilityRules [{Rule, Property, Value}]
|
||||
# CustomTables [{Values[], Columns[], LanguageId?, Order}]
|
||||
# Assignments [{Group, GroupMode?, Filter?, FilterMode?, Settings?, RawIntent?}]
|
||||
# Scripts [{ScriptContent, Caption}] (pre-filtered by engine per options)
|
||||
|
||||
function Invoke-InitializeJsonOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Json"
|
||||
Value = "json"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
|
||||
# Json exposes no file-browse buttons on the bulk-doc form today;
|
||||
# add UI/<backend>/ClassExtensions/DocumentationOutputJsonUIExtension.ps1
|
||||
# if that changes.
|
||||
PrimaryPathOption = "JSONDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-JsonPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-JsonNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-JsonNewObjectType @args }
|
||||
Process = { Invoke-JsonProcessItem @args }
|
||||
PostProcess = { Invoke-JsonPostProcessItems @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-JsonPreProcessItems {
|
||||
$script:jsonAllObjects = [System.Collections.Generic.List[object]]::new()
|
||||
$script:jsonCurrentTypeObjects = [System.Collections.Generic.List[object]]::new()
|
||||
$script:jsonCurrentTypeName = $null
|
||||
|
||||
$script:jsonOutputType = Get-DocumentationOutputOption json "JSONOutputFileType" "Full"
|
||||
|
||||
$jsonFileName = Get-DocumentationOutputOption json "JSONDocumentName" ""
|
||||
if (-not $jsonFileName) { $jsonFileName = "%MyDocuments%\%Organization%-%Date%.json" }
|
||||
|
||||
$script:jsonOutFile = Expand-FileName $jsonFileName
|
||||
$script:jsonDocumentPath = [IO.Path]::GetDirectoryName($script:jsonOutFile)
|
||||
}
|
||||
|
||||
function Invoke-JsonNewObjectGroup {
|
||||
param($groupId)
|
||||
# Groups are not used in flat JSON output
|
||||
}
|
||||
|
||||
function Invoke-JsonNewObjectType {
|
||||
param($objectTypeName)
|
||||
|
||||
if ($script:jsonOutputType -eq "ObjectType" -and
|
||||
$script:jsonCurrentTypeName -and
|
||||
$script:jsonCurrentTypeObjects.Count -gt 0) {
|
||||
Save-JsonTypeFile $script:jsonCurrentTypeName $script:jsonCurrentTypeObjects
|
||||
}
|
||||
|
||||
$script:jsonCurrentTypeName = $objectTypeName
|
||||
$script:jsonCurrentTypeObjects = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
|
||||
function Invoke-JsonProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
$objName = $PolicyObject.Name
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
try {
|
||||
$jsonObj = [ordered]@{
|
||||
objectType = $typeTitle
|
||||
name = $objName
|
||||
}
|
||||
|
||||
if (($documentedObj.BasicInfo | Measure-Object).Count -gt 0) {
|
||||
$basicInfo = [ordered]@{}
|
||||
foreach ($item in $documentedObj.BasicInfo) {
|
||||
if ($item.Name) { $basicInfo[$item.Name] = $item.Value }
|
||||
}
|
||||
$jsonObj.basicInfo = $basicInfo
|
||||
}
|
||||
|
||||
if (($documentedObj.FilteredSettings | Measure-Object).Count -gt 0) {
|
||||
$settings = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($item in $documentedObj.FilteredSettings) {
|
||||
$setting = [ordered]@{ name = $item.Name; value = $item.Value }
|
||||
if ($item.Category) { $setting.category = $item.Category }
|
||||
if ($item.SubCategory) { $setting.subCategory = $item.SubCategory }
|
||||
if ($item.PSObject.Properties['Level'] -and $item.Level) { $setting.level = $item.Level }
|
||||
$settings.Add($setting)
|
||||
}
|
||||
$jsonObj.settings = $settings
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
$actions = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($item in $documentedObj.ComplianceActions) {
|
||||
$actions.Add([ordered]@{
|
||||
action = $item.Action
|
||||
schedule = $item.Schedule
|
||||
messageTemplate = $item.MessageTemplate
|
||||
emailCC = $item.EmailCC
|
||||
})
|
||||
}
|
||||
$jsonObj.complianceActions = $actions
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
$rules = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($item in $documentedObj.ApplicabilityRules) {
|
||||
$rules.Add([ordered]@{
|
||||
rule = $item.Rule
|
||||
property = $item.Property
|
||||
value = $item.Value
|
||||
})
|
||||
}
|
||||
$jsonObj.applicabilityRules = $rules
|
||||
}
|
||||
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Sort-Object -Property Order)) {
|
||||
if (-not $customTable.Values -or ($customTable.Values | Measure-Object).Count -eq 0) { continue }
|
||||
|
||||
$tableKey = if ($customTable.LanguageId) { $customTable.LanguageId.Split('.')[-1] } else { "customTable" }
|
||||
$tableArr = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($item in $customTable.Values) {
|
||||
$tableObj = [ordered]@{}
|
||||
foreach ($col in $customTable.Columns) {
|
||||
$colName = $col.Split('.')[-1]
|
||||
$tableObj[$colName] = "$($item.$colName)"
|
||||
}
|
||||
$tableArr.Add($tableObj)
|
||||
}
|
||||
$jsonObj[$tableKey] = $tableArr
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
$assignments = [System.Collections.Generic.List[object]]::new()
|
||||
$hasRawIntent = $null -ne $documentedObj.Assignments[0].RawIntent
|
||||
|
||||
foreach ($item in $documentedObj.Assignments) {
|
||||
if ($hasRawIntent) {
|
||||
$assignObj = [ordered]@{
|
||||
groupMode = $item.GroupMode
|
||||
group = $item.Group
|
||||
}
|
||||
if ($null -ne $item.Filter) { $assignObj.filter = $item.Filter }
|
||||
if ($null -ne $item.FilterMode) { $assignObj.filterMode = $item.FilterMode }
|
||||
if ($item.Settings) {
|
||||
$settingsObj = [ordered]@{}
|
||||
foreach ($key in $item.Settings.Keys) {
|
||||
if ($key -in @("Category","RawIntent")) { continue }
|
||||
$settingsObj[$key] = $item.Settings[$key]
|
||||
}
|
||||
$assignObj.settings = $settingsObj
|
||||
}
|
||||
}
|
||||
else {
|
||||
$assignObj = [ordered]@{ group = $item.Group }
|
||||
if ($item.PSObject.Properties.Name -contains "Filter") { $assignObj.filter = $item.Filter }
|
||||
if ($item.PSObject.Properties.Name -contains "FilterMode") { $assignObj.filterMode = $item.FilterMode }
|
||||
}
|
||||
$assignments.Add($assignObj)
|
||||
}
|
||||
$jsonObj.assignments = $assignments
|
||||
}
|
||||
|
||||
if (($documentedObj.Scripts | Measure-Object).Count -gt 0) {
|
||||
$scripts = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent) { continue }
|
||||
$scripts.Add([ordered]@{
|
||||
caption = $scriptItem.Caption
|
||||
content = $scriptItem.ScriptContent
|
||||
})
|
||||
}
|
||||
if ($scripts.Count -gt 0) { $jsonObj.scripts = $scripts }
|
||||
}
|
||||
|
||||
$script:jsonCurrentTypeObjects.Add($jsonObj)
|
||||
if ($script:jsonOutputType -ne "ObjectType") { $script:jsonAllObjects.Add($jsonObj) }
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-JsonPostProcessItems {
|
||||
$openFile = (Get-DocumentationOutputOption json "JSONOpenFile" $true) -eq $true
|
||||
|
||||
if ($script:jsonOutputType -eq "ObjectType") {
|
||||
if ($script:jsonCurrentTypeName -and $script:jsonCurrentTypeObjects.Count -gt 0) {
|
||||
Save-JsonTypeFile $script:jsonCurrentTypeName $script:jsonCurrentTypeObjects
|
||||
}
|
||||
Write-Log "Json documentation saved to folder: $($script:jsonDocumentPath)"
|
||||
}
|
||||
else {
|
||||
$jsonContent = ConvertTo-Json -InputObject @($script:jsonAllObjects) -Depth 20
|
||||
Save-DocumentationFile $jsonContent $script:jsonOutFile -OpenFile:$openFile
|
||||
}
|
||||
}
|
||||
|
||||
function Save-JsonTypeFile {
|
||||
param($typeName, $objects)
|
||||
|
||||
$safeTypeName = Remove-InvalidFileNameChars ($typeName.Replace(" ", "_"))
|
||||
$typeFileName = [IO.Path]::Combine($script:jsonDocumentPath, "$safeTypeName.json")
|
||||
$jsonContent = ConvertTo-Json -InputObject @($objects) -Depth 20
|
||||
Save-DocumentationFile $jsonContent $typeFileName
|
||||
Write-Log "Saved $($objects.Count) objects to $typeFileName"
|
||||
}
|
||||
|
||||
Invoke-InitializeJsonOutput
|
||||
@@ -0,0 +1,497 @@
|
||||
# Markdown output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Renders HTML-tabled markdown with CSS styling
|
||||
# from Internal/Documentation/Assets/DefaultMDStyle.css (or a user-supplied .css file).
|
||||
#
|
||||
# Differences vs old DocumentationMD.psm1:
|
||||
# - Drops V1 NewObjectGroup/NewObjectType hooks; V2 (string-arg) is registered
|
||||
# - Drops the extended/custom property selectors that read $global:cb*/$global:txt*
|
||||
# UI controls. Always uses DefaultDocumentationProperties or ('Name','Value').
|
||||
# Phase 2 [DocumentationContext] will reintroduce these via $ctx.Options.
|
||||
# - Drops the unused commented-out block at end of Invoke-MDPostProcessItems
|
||||
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType.Title
|
||||
# instead of Get-GraphObjectName $obj $objectType + $objectType.Title
|
||||
|
||||
function Invoke-InitializeMDOutput {
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Markdown"
|
||||
Value = "md"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
|
||||
# UI browse-button wiring lives in
|
||||
# UI/<backend>/ClassExtensions/DocumentationOutputMDUIExtension.ps1.
|
||||
PrimaryPathOption = "MDDocumentName"
|
||||
PathIsFolder = $false
|
||||
PreProcess = { Invoke-MDPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-MDNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-MDNewObjectType @args }
|
||||
Process = { Invoke-MDProcessItem @args }
|
||||
PostProcess = { Invoke-MDPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-MDProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-MDProcessAllObjects {
|
||||
param($allObjectTypeObjects, $objectType)
|
||||
# Reserved for cross-object aggregation (e.g. consolidated ScopeTags table)
|
||||
}
|
||||
|
||||
function Invoke-MDPreProcessItems {
|
||||
$script:sectionAnchors = @()
|
||||
$script:totAnchors = @()
|
||||
$script:mdStrings = $null
|
||||
$script:currentItemFileName = $null
|
||||
|
||||
$defaultCSSFile = [IO.Path]::Combine($script:AppRootFolder, "Internal", "Documentation", "Assets", "DefaultMDStyle.css")
|
||||
$mdCssFile = Get-DocumentationOutputOption md "MDCSSFile" $defaultCSSFile
|
||||
$includeCss = (Get-DocumentationOutputOption md "MDIncludeCSS" $true) -eq $true
|
||||
|
||||
if (-not $mdCssFile) {
|
||||
Write-Log "CSS file not specified. Using default" 2
|
||||
$mdCssFile = $defaultCSSFile
|
||||
}
|
||||
elseif (-not [IO.File]::Exists($mdCssFile)) {
|
||||
Write-Log "CSS file $mdCssFile not found. Using default" 2
|
||||
$mdCssFile = $defaultCSSFile
|
||||
}
|
||||
|
||||
if ($includeCss -and [IO.File]::Exists($mdCssFile)) {
|
||||
Write-Log "Using CSS file $mdCssFile"
|
||||
$script:cssStyle = ([IO.File]::ReadAllText($mdCssFile)) + [Environment]::NewLine
|
||||
}
|
||||
else {
|
||||
Write-Log "CSS file $mdCssFile not found. No styles applied" 2
|
||||
$script:cssStyle = ""
|
||||
}
|
||||
|
||||
$fileName = Get-DocumentationOutputOption md "MDDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.md" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
$script:outFile = $fileName
|
||||
$script:documentPath = [IO.Path]::GetDirectoryName($fileName)
|
||||
$script:outputType = Get-DocumentationOutputOption md "MDDocumentFileType" "Full"
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
Write-Log "Document one file for each object + index file"
|
||||
}
|
||||
else {
|
||||
Write-Log "Document one single file for all objects"
|
||||
$script:outputType = "Full"
|
||||
$script:mdStrings = [System.Text.StringBuilder]::new()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MDPostProcessItems {
|
||||
$userName = $null
|
||||
$mail = ""
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
if ($me.mail) { $mail = " ($($me.mail))" }
|
||||
}
|
||||
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
|
||||
$title = Get-DocumentationOutputOption md "MDTitleProperty" "Intune documentation"
|
||||
if (-not $title) { $title = "Intune documentation" }
|
||||
|
||||
$mdContent = [System.Text.StringBuilder]::new()
|
||||
[void]$mdContent.AppendLine("# $title")
|
||||
[void]$mdContent.AppendLine("")
|
||||
[void]$mdContent.AppendLine("")
|
||||
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
if ($orgName) { [void]$mdContent.AppendLine("*Organization:* $orgName`n") }
|
||||
if ($userName) { [void]$mdContent.AppendLine("*Generated by:* $userName$mail`n") }
|
||||
|
||||
$skipDate = (Get-DocumentationOutputOption md "MDDocumentSkipDate" $false) -eq $true
|
||||
if (-not $skipDate) {
|
||||
[void]$mdContent.AppendLine("*Generated:* $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())`n")
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:sectionAnchors.Count -gt 0) {
|
||||
[void]$mdContent.AppendLine("")
|
||||
[void]$mdContent.AppendLine("## Table of Contents")
|
||||
}
|
||||
|
||||
foreach ($header in $script:sectionAnchors) {
|
||||
$indent = [string]::new(" ", (($header.Level - 1) * 2))
|
||||
[void]$mdContent.AppendLine("$indent- [$($header.Name)]($($header.FileName)#$($header.Anchor))`n")
|
||||
}
|
||||
[void]$mdContent.AppendLine("")
|
||||
|
||||
$mdText = $script:cssStyle + $mdContent.ToString()
|
||||
if ($script:outputType -eq "Full" -and $script:mdStrings) {
|
||||
$mdText += $script:mdStrings.ToString()
|
||||
}
|
||||
|
||||
Save-DocumentationFile $mdText $script:outFile -OpenFile:((Get-DocumentationOutputOption md "MDOpenFile" $true) -eq $true)
|
||||
}
|
||||
|
||||
function Invoke-MDNewObjectGroup {
|
||||
param($groupId)
|
||||
Add-MDHeader (Get-DocObjectTypeString $groupId) -Level 1 -UseHTML
|
||||
}
|
||||
|
||||
function Invoke-MDNewObjectType {
|
||||
param($objectTypeName)
|
||||
Add-MDHeader $objectTypeName -Level 2 -UseHTML
|
||||
}
|
||||
|
||||
# Per-object file name for Object mode. Identity, not title: the policy's own
|
||||
# display name plus its type and id, the shape Get-HTMLObjectFileName uses.
|
||||
#
|
||||
# The display name alone was never unique. Five enrollment defaults - device
|
||||
# limit, platform restrictions, enrollment status page, Windows Hello for
|
||||
# Business, Windows Restore - are all called "All users and all devices", so
|
||||
# they all wrote All_users_and_all_devices.md and the last one won. Deriving the
|
||||
# name from the heading instead would not do either: the heading may be a
|
||||
# DocumentName override, and a file name has to identify the object, not
|
||||
# describe it. The type and id settle it. Spaces become underscores and the
|
||||
# suffix carries no brackets, because this name lands inside Markdown link
|
||||
# destinations.
|
||||
function Get-MDObjectFileName {
|
||||
param($PolicyObject)
|
||||
|
||||
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
|
||||
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
|
||||
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
|
||||
$suffix = if ($typeId -and $id) { " $typeId-$id" }
|
||||
elseif ($id) { " $id" }
|
||||
else { '' }
|
||||
|
||||
# A policy name can run past 200 characters and the suffix adds up to
|
||||
# around 120 more - past what a path may hold, where the name-only file
|
||||
# still wrote. The suffix is the identity, so it is the name that gives way.
|
||||
$maxNameLength = 80
|
||||
if ($objName.Length -gt $maxNameLength) { $objName = $objName.Substring(0, $maxNameLength).TrimEnd() }
|
||||
|
||||
return (Remove-InvalidFileNameChars "$objName$suffix.md").Replace(' ', '_')
|
||||
}
|
||||
|
||||
function Invoke-MDProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$script:totAnchors = @()
|
||||
$script:mdStrings = [System.Text.StringBuilder]::new()
|
||||
$script:currentItemFileName = "./$(Get-MDObjectFileName $PolicyObject)"
|
||||
}
|
||||
|
||||
Add-MDHeader $objName -Level 3 -UseHTML
|
||||
[void]$script:mdStrings.AppendLine("")
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
$lngId = "SettingDetails.basics"
|
||||
}
|
||||
else {
|
||||
if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$properties = $documentedObj.DefaultDocumentationProperties
|
||||
}
|
||||
else {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
$lngId = "TableHeaders.settings"
|
||||
}
|
||||
|
||||
# Custom tables with a negative Order belong ABOVE the settings
|
||||
# table: the portal shows a MAM app config's "Settings catalog"
|
||||
# blade above its "Settings" blade.
|
||||
if ($tableType -eq "FilteredSettings") {
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-MDObjectScripts $documentedObj
|
||||
|
||||
# Negative Order already rendered above the settings table.
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
|
||||
Add-MDTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$properties += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
|
||||
if ($script:outputType -eq "Object") {
|
||||
$perObjectText = $script:cssStyle + $script:mdStrings.ToString()
|
||||
$fileName = Join-Path $script:documentPath $script:currentItemFileName
|
||||
Save-DocumentationFile $perObjectText $fileName
|
||||
$script:mdStrings = $null
|
||||
}
|
||||
}
|
||||
|
||||
function Add-MDTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride
|
||||
)
|
||||
|
||||
$objName = Get-DocCaptionName $PolicyObject
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $objName"
|
||||
}
|
||||
else {
|
||||
$caption = "$objName ($TypeTitle)"
|
||||
}
|
||||
|
||||
$tableText = [System.Text.StringBuilder]::new()
|
||||
[void]$tableText.AppendLine("<table class='table-settings'>")
|
||||
[void]$tableText.AppendLine("<tr class='table-header1'>")
|
||||
|
||||
$columnCount = 0
|
||||
foreach ($prop in $Properties) {
|
||||
[void]$tableText.AppendLine("<td>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</td>")
|
||||
$columnCount++
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
|
||||
foreach ($itemObj in $Items) {
|
||||
$additionalRowClass = ""
|
||||
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level1'>$((Set-MDText $itemObj.Category))</td></tr>")
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level2'>$((Set-MDText $itemObj.SubCategory))</td></tr>")
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
}
|
||||
|
||||
if ($itemObj.PropertyIndex -is [int] -and $itemObj.PropertyIndex -eq 1) {
|
||||
$additionalRowClass = "row-new-property"
|
||||
}
|
||||
|
||||
try {
|
||||
[void]$tableText.AppendLine("<tr class='$additionalRowClass'>")
|
||||
$curCol = 1
|
||||
foreach ($prop in $Properties) {
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
|
||||
[void]$tableText.AppendLine("<td><table class='table-value'><tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<td class='table-header1'>$($colProp.Name)</td>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
foreach ($rowVal in $itemObj.FullValueTable) {
|
||||
[void]$tableText.AppendLine("<tr>")
|
||||
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
|
||||
[void]$tableText.AppendLine("<td>$($rowVal."$($colProp.Name)")</td>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
[void]$tableText.AppendLine("</table></td>")
|
||||
}
|
||||
else {
|
||||
$style = ""
|
||||
if ($curCol -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
$level = [int]$itemObj.Level
|
||||
$style = " style='padding-left:$((5 + ($level * 5)))px !important;'"
|
||||
} catch {}
|
||||
}
|
||||
[void]$tableText.AppendLine("<td class='property-column$curCol'$style>$((Set-MDText $tmpObj.$propName -CodeBlock))</td>")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
$curCol++
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
finally {
|
||||
[void]$tableText.AppendLine("</tr>")
|
||||
}
|
||||
}
|
||||
|
||||
[void]$tableText.AppendLine("</table>")
|
||||
Add-MDText $tableText.ToString()
|
||||
Add-MDHeader $caption -Level 6 -ToT -AddParagraph
|
||||
}
|
||||
|
||||
function Add-MDText {
|
||||
param([string]$Text, [switch]$AddParagraph)
|
||||
[void]$script:mdStrings.AppendLine($Text)
|
||||
if ($AddParagraph) { [void]$script:mdStrings.AppendLine("") }
|
||||
}
|
||||
|
||||
function Set-MDText {
|
||||
param([string]$Text, [switch]$CodeBlock)
|
||||
|
||||
if ($null -eq $Text) { return }
|
||||
|
||||
$txtSummary = ""
|
||||
$textOut = ""
|
||||
|
||||
if ($Text -and $Text.Length -gt 250) {
|
||||
$summaryMax = 40
|
||||
$idx = $Text.IndexOfAny(@("`r","`n"))
|
||||
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
|
||||
$txtSummary = $Text.Substring(0, $summaryMax)
|
||||
}
|
||||
|
||||
if ($CodeBlock) {
|
||||
$trim = $Text.Trim()
|
||||
if ($trim.StartsWith("<?xml") -or $trim.StartsWith("<xml") -or ($trim.StartsWith("<") -and $trim.EndsWith(">"))) {
|
||||
$nl = [Environment]::NewLine
|
||||
$textOut = "$nl$nl``````xml$nl$Text$nl```````$nl$nl"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $CodeBlock -or -not $textOut) {
|
||||
$t = $Text.Replace("|", '`|')
|
||||
$t = $t.Replace("*", '`*')
|
||||
$t = $t.Replace("`$", '`$')
|
||||
$t = $t.Replace("`r`n", "<br />")
|
||||
$textOut = $t.Replace("`n", "<br />")
|
||||
}
|
||||
|
||||
if ($txtSummary) {
|
||||
"<details class='description'><summary data-open='Minimize' data-close='$txtSummary...expand'></summary>$textOut</details>"
|
||||
}
|
||||
else {
|
||||
$textOut
|
||||
}
|
||||
}
|
||||
|
||||
function Add-MDHeader {
|
||||
param(
|
||||
[string]$Text,
|
||||
[int]$Level = 1,
|
||||
[switch]$AddParagraph,
|
||||
[switch]$UseHTML,
|
||||
[switch]$ToT,
|
||||
[switch]$SkipTOC
|
||||
)
|
||||
|
||||
if ($script:mdStrings) {
|
||||
$prefix = ""
|
||||
if ($ToT) { $prefix = "Table $($script:totAnchors.Count + 1). " }
|
||||
|
||||
if ($UseHTML) {
|
||||
if ($ToT) { $sectionAnchor = "table-$($script:totAnchors.Count + 1)" }
|
||||
else { $sectionAnchor = "section-$($script:sectionAnchors.Count + 1)" }
|
||||
|
||||
[void]$script:mdStrings.AppendLine("<h$Level id=`"$prefix$sectionAnchor`">$Text</h$Level>")
|
||||
}
|
||||
else {
|
||||
$Text = "$prefix$Text"
|
||||
$sectionAnchor = $Text.ToLower().Replace(" ", "-").Replace("[","").Replace("]","")
|
||||
$mdHeader = [string]::new('#', $Level)
|
||||
[void]$script:mdStrings.AppendLine("$mdHeader $Text")
|
||||
}
|
||||
$fileName = $script:currentItemFileName
|
||||
}
|
||||
else {
|
||||
$sectionAnchor = $null
|
||||
$fileName = $null
|
||||
}
|
||||
|
||||
if ($ToT) {
|
||||
$script:totAnchors += [PSCustomObject]@{
|
||||
Name = $Text; Anchor = $sectionAnchor; FileName = $fileName; Level = $Level
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipTOC) {
|
||||
$script:sectionAnchors += [PSCustomObject]@{
|
||||
Name = $Text; Anchor = $sectionAnchor; FileName = $fileName; Level = $Level
|
||||
}
|
||||
}
|
||||
|
||||
if ($AddParagraph) { [void]$script:mdStrings.AppendLine("`n") }
|
||||
}
|
||||
|
||||
function Add-MDObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
[void]$script:mdStrings.AppendLine("~~~powershell")
|
||||
[void]$script:mdStrings.AppendLine($scriptItem.ScriptContent)
|
||||
[void]$script:mdStrings.AppendLine("~~~")
|
||||
Add-MDHeader $scriptItem.Caption -Level 6 -SkipTOC -AddParagraph
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeMDOutput
|
||||
@@ -0,0 +1,929 @@
|
||||
# Word output provider.
|
||||
#
|
||||
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
|
||||
# header for the field list). Writes a .docx, .docm/.xml (strict), or .pdf via
|
||||
# Microsoft.Office.Interop.Word COM automation. Word must be installed locally.
|
||||
#
|
||||
# https://docs.microsoft.com/en-us/office/vba/api/overview/word
|
||||
#
|
||||
# Differences vs old DocumentationWord.psm1:
|
||||
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType
|
||||
# instead of Get-GraphObjectName / Get-ObjectTypeString taking $objectType
|
||||
# - V1 NewObjectGroup/NewObjectType ($obj-arg) replaced by V2 (string-arg);
|
||||
# the old V1 versions were unreachable (registration used V2)
|
||||
# - The "Attach raw object JSON" Word feature is stubbed out — it depends on
|
||||
# Export-GraphObject which lives in old MSGraph.psm1 and hasn't been ported
|
||||
# to the new project. A "feature unavailable" log message replaces it; phase 2
|
||||
# can re-enable once the equivalent exporter is wired up.
|
||||
# - Invoke-WordProcessAllObjects ScopeTags consolidated table is stubbed too,
|
||||
# same reason as the HTML provider (Get-TableObjects deferred to phase 2).
|
||||
# - All other COM logic — cover page, ToC, building blocks, style hashtable,
|
||||
# option snapshot/restore, save & close — preserved verbatim.
|
||||
|
||||
# Load the Word primary interop assembly. Deliberately NOT called at module
|
||||
# import: Add-Type -AssemblyName fails on PS7 (it resolves against the current
|
||||
# directory, not the GAC), so this always fell through to a recursive scan of
|
||||
# %windir%\assembly\GAC_MSIL - ~77ms on every single Import-Module, for a
|
||||
# feature most sessions never use. It also put an assembly in the AppDomain
|
||||
# whose GetExportedTypes() throws, which is one of the two things that used to
|
||||
# take down Avalonia's XAML loader (see Host.SanitizeXamlTypeSystem).
|
||||
#
|
||||
# Idempotent: returns $true as soon as the interop types are resolvable.
|
||||
#
|
||||
# The readiness probe is WdSaveFormat, not the Application coclass. Everything
|
||||
# this provider needs from the interop assembly is enums - the Word instance
|
||||
# itself comes from late-bound `New-Object -ComObject Word.Application` - and on
|
||||
# PS7 the enums resolve while the coclass does not. Probing Application would
|
||||
# therefore report "not loaded" forever on PS7, which is also why the previous
|
||||
# code's short-circuit never fired there and re-scanned the GAC on every import.
|
||||
function Initialize-WordInteropAssembly {
|
||||
if ("Microsoft.Office.Interop.Word.WdSaveFormat" -as [Type]) { return $true }
|
||||
|
||||
try {
|
||||
Add-Type -AssemblyName Microsoft.Office.Interop.Word -ErrorAction Stop
|
||||
if ("Microsoft.Office.Interop.Word.WdSaveFormat" -as [Type]) { return $true }
|
||||
}
|
||||
catch { }
|
||||
|
||||
try {
|
||||
$wordFile = Get-ChildItem -Path "$($env:windir)\assembly\GAC_MSIL" -Filter "Microsoft.Office.Interop.Word.dll" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($wordFile -and $wordFile.Exists) {
|
||||
Add-Type -Path $wordFile.FullName
|
||||
if ("Microsoft.Office.Interop.Word.WdSaveFormat" -as [Type]) { return $true }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add Word Interop type. Cannot create Word documents. Verify that Word is installed properly." $_.Exception
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-LogError "Word Interop type not found. Cannot create Word documents. Verify that Word is installed properly." $null
|
||||
return $false
|
||||
}
|
||||
|
||||
function Invoke-InitializeWordOutput {
|
||||
# Word output relies on Microsoft.Office.Interop.Word COM automation, which only
|
||||
# exists on Windows with Word installed. Skip the provider entirely elsewhere.
|
||||
if (-not $script:IsWindowsOS) {
|
||||
Write-Log "Word documentation output is not available on this platform (requires Windows + Word). Skipping."
|
||||
return
|
||||
}
|
||||
|
||||
# Registration stays gated on Word being installed, as before - but probed by
|
||||
# reading the COM registration straight out of the registry, which loads
|
||||
# nothing into the AppDomain. [Type]::GetTypeFromProgID looks like the natural
|
||||
# call here and is correct on PS7, but on PS5.1 the .NET Framework resolves a
|
||||
# ProgID to its primary interop assembly and loads it - which would reintroduce
|
||||
# exactly the eager load this is meant to remove, on the one shell where the
|
||||
# old code's short-circuit actually worked.
|
||||
#
|
||||
# The interop itself is loaded lazily by Invoke-WordActivate, i.e. only when a
|
||||
# documentation run actually selects Word output.
|
||||
$wordRegistered = (Test-Path 'HKLM:\SOFTWARE\Classes\Word.Application') -or
|
||||
(Test-Path 'HKCU:\SOFTWARE\Classes\Word.Application')
|
||||
if (-not $wordRegistered) {
|
||||
Write-Log "Word is not registered on this machine. Word documentation output will not be available." 2
|
||||
return
|
||||
}
|
||||
|
||||
Add-DocumentationOutputProvider ([PSCustomObject]@{
|
||||
Name = "Word"
|
||||
Value = "word"
|
||||
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
|
||||
# UI browse-button wiring lives in
|
||||
# UI/<backend>/ClassExtensions/DocumentationOutputWordUIExtension.ps1.
|
||||
PrimaryPathOption = "WordDocumentName"
|
||||
PathIsFolder = $false
|
||||
Activate = { Invoke-WordActivate @args }
|
||||
PreProcess = { Invoke-WordPreProcessItems @args }
|
||||
NewObjectGroup = { Invoke-WordNewObjectGroup @args }
|
||||
NewObjectType = { Invoke-WordNewObjectType @args }
|
||||
Process = { Invoke-WordProcessItem @args }
|
||||
PostProcess = { Invoke-WordPostProcessItems @args }
|
||||
ProcessAllObjects = { Invoke-WordProcessAllObjects @args }
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-WordActivate {
|
||||
# Lazy load point for the interop assembly. Activate is the first lifecycle
|
||||
# hook the engine runs for a selected provider, so this happens only when a
|
||||
# documentation run actually asks for Word output. Throwing here is
|
||||
# deliberate: the engine wraps Activate in its $recordFailure handler, so the
|
||||
# run reports "Activate failed for Word: ..." instead of failing later and
|
||||
# less clearly when Process touches an interop enum.
|
||||
if (-not (Initialize-WordInteropAssembly)) {
|
||||
throw "Word Interop assembly could not be loaded. Cannot create Word documents. Verify that Word is installed properly."
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordPreProcessItems {
|
||||
# Validate Limit-mode bounds
|
||||
$script:limitMaxValue = 100
|
||||
$script:truncateValueLength = $script:limitMaxValue
|
||||
|
||||
if ((Get-DocumentationOutputOption word "WordDocumentationLevel" "full") -eq "limited") {
|
||||
$maxText = Get-DocumentationOutputOption word "WordDocumentationLimitMaxLength" ""
|
||||
$truncateText = Get-DocumentationOutputOption word "WordDocumentationLimitTruncateLength" ""
|
||||
|
||||
if ($maxText) {
|
||||
try { $script:limitMaxValue = [int]::Parse($maxText) }
|
||||
catch { Write-LogError "Failed to parse '$maxText' to int. Max value length will be set to 100." $_.Exception }
|
||||
}
|
||||
if ($truncateText) {
|
||||
try { $script:truncateValueLength = [int]::Parse($truncateText) }
|
||||
catch { Write-LogError "Failed to parse '$truncateText' to int. Truncate length will be set to $script:limitMaxValue." $_.Exception }
|
||||
}
|
||||
|
||||
if ($script:limitMaxValue -lt 20) {
|
||||
Write-Log "Max value length must be 20 or more. Changed to 0" 2
|
||||
$script:limitMaxValue = 0
|
||||
}
|
||||
if ($script:truncateValueLength -lt 0) {
|
||||
Write-Log "Truncate length must be 0 or more. Changed to 0" 2
|
||||
$script:truncateValueLength = 0
|
||||
}
|
||||
elseif ($script:truncateValueLength -gt $script:limitMaxValue) {
|
||||
Write-Log "Truncate length cannot be larger than Max value length. Changed to: $script:limitMaxValue" 2
|
||||
$script:truncateValueLength = $script:limitMaxValue
|
||||
}
|
||||
}
|
||||
|
||||
# Create Word COM app
|
||||
try {
|
||||
$script:wordApp = New-Object -ComObject Word.Application
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to create Word App object. Word documentation aborted..." $_.Exception
|
||||
return $false
|
||||
}
|
||||
|
||||
# Performance: suppress UI redraw and background processing while filling the document.
|
||||
# Application-level Options persist to the user profile, so we snapshot and restore them in PostProcess.
|
||||
$script:wordApp.ScreenUpdating = $false
|
||||
$script:wordApp.DisplayAlerts = 0 # wdAlertsNone
|
||||
|
||||
$script:wordOptionsBackup = $null
|
||||
try {
|
||||
$script:wordOptionsBackup = @{
|
||||
Pagination = $script:wordApp.Options.Pagination
|
||||
CheckGrammarAsYouType = $script:wordApp.Options.CheckGrammarAsYouType
|
||||
CheckSpellingAsYouType = $script:wordApp.Options.CheckSpellingAsYouType
|
||||
BackgroundSave = $script:wordApp.Options.BackgroundSave
|
||||
}
|
||||
$script:wordApp.Options.Pagination = $false
|
||||
$script:wordApp.Options.CheckGrammarAsYouType = $false
|
||||
$script:wordApp.Options.CheckSpellingAsYouType = $false
|
||||
$script:wordApp.Options.BackgroundSave = $false
|
||||
}
|
||||
catch { }
|
||||
|
||||
$template = Get-DocumentationOutputOption word "WordDocumentTemplate" ""
|
||||
if ($template) {
|
||||
try {
|
||||
$script:doc = $script:wordApp.Documents.Add($template)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to create document based on template: $template" $_.Exception
|
||||
}
|
||||
}
|
||||
else {
|
||||
$script:doc = $script:wordApp.Documents.Add()
|
||||
}
|
||||
|
||||
# Get BuiltIn properties
|
||||
$script:builtInProps = [System.Collections.Generic.List[object]]::new()
|
||||
$script:doc.BuiltInDocumentProperties | ForEach-Object {
|
||||
$name = [System.__ComObject].InvokeMember("name", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null)
|
||||
$value = $null
|
||||
try { $value = [System.__ComObject].InvokeMember("value", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null) } catch {}
|
||||
if ($name) {
|
||||
$script:builtInProps.Add([PSCustomObject]@{ Name = $name; Value = $value })
|
||||
}
|
||||
}
|
||||
|
||||
# Get Custom properties
|
||||
$script:customProps = [System.Collections.Generic.List[object]]::new()
|
||||
$script:doc.CustomDocumentProperties | ForEach-Object {
|
||||
$name = [System.__ComObject].InvokeMember("name", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null)
|
||||
$value = $null
|
||||
try { $value = [System.__ComObject].InvokeMember("value", [System.Reflection.BindingFlags]::GetProperty, $null, $_, $null) } catch {}
|
||||
if ($name) {
|
||||
$script:customProps.Add([PSCustomObject]@{ Name = $name; Value = $value })
|
||||
}
|
||||
}
|
||||
|
||||
# Style cache: O(1) lookup by NameLocal (replaces per-call linear scan in Get-DocStyle / Set-DocObjectStyle)
|
||||
$script:wordStyles = @{}
|
||||
$script:doc.Styles | ForEach-Object {
|
||||
if ($_.NameLocal -and -not $script:wordStyles.ContainsKey($_.NameLocal)) {
|
||||
$script:wordStyles[$_.NameLocal] = [PSCustomObject]@{
|
||||
Name = $_.NameLocal; Type = $_.Type; Style = $_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Built-in style cache: same O(1) treatment for the ~376 enum names
|
||||
$script:builtinStyles = @{}
|
||||
foreach ($builtinName in [Enum]::GetNames([Microsoft.Office.Interop.Word.wdBuiltinStyle])) {
|
||||
$script:builtinStyles[$builtinName] = $true
|
||||
}
|
||||
|
||||
if (-not $template) {
|
||||
$script:doc.Application.Templates.LoadBuildingBlocks()
|
||||
$bb = $script:doc.Application.Templates | Where-Object { $_.Name -eq 'Built-In Building Blocks.dotx' }
|
||||
if ($bb) {
|
||||
$coverPageName = Get-DocumentationOutputOption word "WordCoverPage" "Ion (Dark)"
|
||||
if (-not $coverPageName) { $coverPageName = 'Ion (Dark)' }
|
||||
|
||||
try {
|
||||
$blocks = @()
|
||||
for ($i = 1; $i -le $bb.BuildingBlockEntries.Count; $i++) {
|
||||
$blocks += $bb.BuildingBlockEntries.Item($i)
|
||||
}
|
||||
$coverPages = ($blocks | Where-Object { $_.Type.Index -eq 2 } | Select-Object Name | Sort-Object -Property Name).Name
|
||||
|
||||
if (($coverPages | Measure-Object).Count -gt 0) {
|
||||
if ($coverPageName -notin $coverPages) {
|
||||
Write-Log "$coverPageName not found in available Cover Page list. Using: $($coverPages[0])"
|
||||
Write-Log "Available Cover Pages: $($coverPages -join ',')"
|
||||
$coverPageName = $coverPages[0]
|
||||
}
|
||||
else {
|
||||
Write-Log "Add Cover Page: $coverPageName"
|
||||
}
|
||||
}
|
||||
|
||||
$coverPage = $bb.BuildingBlockEntries.Item($coverPageName)
|
||||
$coverPage.Insert($script:wordApp.Selection.Range, $true) | Out-Null
|
||||
$script:wordApp.Selection.InsertNewPage()
|
||||
}
|
||||
catch { Write-LogError "Failed to create Cover Page" $_.Exception }
|
||||
|
||||
try {
|
||||
$coverPageProps = $script:doc.CustomXMLParts | Where-Object { $_.NamespaceURI -match "coverPageProps$" }
|
||||
if ($coverPageProps) {
|
||||
Write-Log "Available Cover Page properties for $($coverPageName): $((([xml]$coverPageProps.DocumentElement.XML).ChildNodes[0].ChildNodes).Name -join ',')"
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try {
|
||||
$script:doc.TablesOfContents.Add($script:wordApp.Selection.Range) | Out-Null
|
||||
$script:wordApp.Selection.InsertNewPage()
|
||||
}
|
||||
catch { Write-LogError "Failed to create Table of Contents" $_.Exception }
|
||||
}
|
||||
}
|
||||
else {
|
||||
Invoke-DocGoToEnd
|
||||
$script:wordApp.Selection.InsertNewPage()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordPostProcessItems {
|
||||
$userName = $null
|
||||
$me = Get-CurrentUser
|
||||
if ($me) {
|
||||
if ($me.givenName -and $me.surname) {
|
||||
$userName = "$($me.givenName) $($me.surname)"
|
||||
}
|
||||
else {
|
||||
$userName = $me.displayName
|
||||
}
|
||||
}
|
||||
|
||||
$titleProp = Get-DocumentationOutputOption word "WordTitleProperty" "Intune documentation"
|
||||
$subjectProp = Get-DocumentationOutputOption word "WordSubjectProperty" "Intune documentation"
|
||||
if (-not $titleProp) { $titleProp = "Intune documentation" }
|
||||
if (-not $subjectProp) { $subjectProp = "Intune documentation" }
|
||||
|
||||
Set-WordDocBuiltInProperty "wdPropertyTitle" $titleProp
|
||||
Set-WordDocBuiltInProperty "wdPropertySubject" $subjectProp
|
||||
# Author + Company are the "who generated this" document info. Word writes them
|
||||
# as built-in file metadata (not a visible top-of-document block like the other
|
||||
# providers), but they are the same info the generic SkipDocumentInfo flag hides.
|
||||
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
|
||||
Set-WordDocBuiltInProperty "wdPropertyAuthor" $userName
|
||||
$orgName = Get-CurrentOrganizationName
|
||||
if ($orgName) {
|
||||
Set-WordDocBuiltInProperty "wdPropertyCompany" $orgName
|
||||
}
|
||||
}
|
||||
Set-WordDocBuiltInProperty "wdPropertyKeywords" "Intune,Endpoint Manager,MEM"
|
||||
|
||||
try {
|
||||
$controls = Get-DocumentationOutputOption word "WordContentControls" ""
|
||||
foreach ($ccObj in $controls.Split(';')) {
|
||||
$ccName, $ccVal = $ccObj.Split('=')
|
||||
Set-WordContentControlText $ccName $ccVal
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
foreach ($field in @("Fields","TablesOfContents","TablesOfFigures","TablesOfAuthorities")) {
|
||||
try { $script:doc.$field | ForEach-Object { $_.Update() | Out-Null } }
|
||||
catch { Write-LogError "Failed to update document $field" $_.Exception }
|
||||
}
|
||||
|
||||
# Restore Application Options before saving so user settings aren't permanently changed.
|
||||
try {
|
||||
if ($script:wordOptionsBackup) {
|
||||
$script:wordApp.Options.Pagination = $script:wordOptionsBackup.Pagination
|
||||
$script:wordApp.Options.CheckGrammarAsYouType = $script:wordOptionsBackup.CheckGrammarAsYouType
|
||||
$script:wordApp.Options.CheckSpellingAsYouType = $script:wordOptionsBackup.CheckSpellingAsYouType
|
||||
$script:wordApp.Options.BackgroundSave = $script:wordOptionsBackup.BackgroundSave
|
||||
}
|
||||
$script:wordApp.ScreenUpdating = $true
|
||||
$script:doc.Repaginate()
|
||||
}
|
||||
catch { }
|
||||
|
||||
$formatStr = Get-DocumentationOutputOption word "WordDocumentFormat" "wdFormatDocumentDefault"
|
||||
if ($formatStr -eq "pdf") { $formatStr = "wdFormatPDF" }
|
||||
elseif ($formatStr -eq "docx") { $formatStr = "wdFormatDocumentDefault" }
|
||||
Write-Log "Using document format: $formatStr"
|
||||
$format = $null
|
||||
try {
|
||||
$format = [Microsoft.Office.Interop.Word.WdSaveFormat]$formatStr
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Document format validation failed; defaulting to wdFormatDocumentDefault" $_.Exception
|
||||
$format = [Microsoft.Office.Interop.Word.WdSaveFormat]::wdFormatDocumentDefault
|
||||
}
|
||||
|
||||
$fileName = Get-DocumentationOutputOption word "WordDocumentName" ""
|
||||
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.docx" }
|
||||
$fileName = Expand-FileName $fileName
|
||||
|
||||
if ($format -eq [Microsoft.Office.Interop.Word.WdSaveFormat]::wdFormatPDF -and $fileName -notlike "*.pdf") {
|
||||
$fileName = [IO.Path]::ChangeExtension($fileName, ".pdf")
|
||||
}
|
||||
|
||||
try {
|
||||
$script:doc.SaveAs2([ref]$fileName, [ref]$format)
|
||||
Write-Log "Document $fileName saved successfully"
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to save file $fileName" $_.Exception
|
||||
}
|
||||
|
||||
try {
|
||||
$openDocSetting = Get-DocumentationOutputOption word "WordOpenDocument" "true"
|
||||
$openDoc = ($openDocSetting -ne "false") -and ($openDocSetting -ne $false)
|
||||
# Only pop Word visible in interactive UI mode; headless / silent / bulk runs
|
||||
# close it (the .docx is already saved). ($global:hideUI was a dead old-project
|
||||
# global, never assigned -> Word always opened, even during automation.)
|
||||
$hideUI = (Get-CacheObject "ShowUI") -ne $true
|
||||
if ($openDoc -and -not $hideUI) {
|
||||
$script:wordApp.Visible = $true
|
||||
$script:wordApp.WindowState = [Microsoft.Office.Interop.Word.WdWindowState]::wdWindowStateMaximize
|
||||
$script:wordApp.Activate()
|
||||
}
|
||||
else {
|
||||
$script:doc.Close([Microsoft.Office.Interop.Word.WdSaveOptions]::wdDoNotSaveChanges)
|
||||
$script:wordApp.Quit()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to close the Word application" $_.Exception
|
||||
}
|
||||
finally {
|
||||
try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($script:doc) } catch { }
|
||||
try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($script:wordApp) } catch { }
|
||||
[GC]::Collect()
|
||||
[GC]::WaitForPendingFinalizers()
|
||||
}
|
||||
}
|
||||
|
||||
function Set-WordContentControlText {
|
||||
param([string]$ControlName, $Value)
|
||||
|
||||
if (-not $ControlName) { return }
|
||||
|
||||
try {
|
||||
$ctrl = $script:doc.SelectContentControlsByTitle($ControlName)
|
||||
if ($ctrl) {
|
||||
Write-LogDebug "Update ContentControl $ControlName (Type: $($ctrl[1].Type))"
|
||||
if ($ctrl[1].Type -eq 6) {
|
||||
if ($ctrl[1].DateDisplayFormat) {
|
||||
$ctrl[1].Range.Text = (Get-Date).ToString($ctrl[1].DateDisplayFormat)
|
||||
}
|
||||
else {
|
||||
$ctrl[1].Range.Text = (Get-Date).ToShortDateString()
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (-not $Value) { return }
|
||||
$ctrl[1].Range.Text = $Value
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to set ContentControl $ControlName" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordNewObjectGroup {
|
||||
param($groupId)
|
||||
|
||||
$header1 = Get-DocumentationOutputOption word "WordHeader1Style" "Heading 1"
|
||||
if (-not $header1) { $header1 = "Heading 1" }
|
||||
Add-DocText (Get-DocObjectTypeString $groupId) $header1
|
||||
}
|
||||
|
||||
function Invoke-WordNewObjectType {
|
||||
param($objectTypeName)
|
||||
|
||||
$script:objectHeaderLevel = 2
|
||||
Add-DocText $objectTypeName (Get-ObjectLevelHeader)
|
||||
$script:objectHeaderLevel = 3
|
||||
}
|
||||
|
||||
function Get-ObjectLevelHeader {
|
||||
if ($script:objectHeaderLevel -eq 3) {
|
||||
$h3 = Get-DocumentationOutputOption word "WordHeader3Style" ""
|
||||
if ($h3) { return $h3 }
|
||||
}
|
||||
$h2 = Get-DocumentationOutputOption word "WordHeader2Style" "Heading 2"
|
||||
if (-not $h2) { $h2 = "Heading 2" }
|
||||
return $h2
|
||||
}
|
||||
|
||||
function Invoke-WordProcessItem {
|
||||
param($PolicyObject, $documentedObj)
|
||||
|
||||
if (-not $documentedObj -or -not $PolicyObject) { return }
|
||||
|
||||
# A documented object may ask to be titled by something other than its display
|
||||
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
|
||||
# file name below deliberately does not.
|
||||
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
|
||||
$script:docDisplayName = $objName
|
||||
$typeTitle = $PolicyObject.PolicyType.Title
|
||||
|
||||
Add-DocText $objName (Get-ObjectLevelHeader)
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
|
||||
$propMode = Get-DocumentationOutputOption word "WordExportProperties" "simple"
|
||||
$customProps = Get-DocumentationOutputOption word "WordCustomDisplayProperties" ""
|
||||
$docLevel = Get-DocumentationOutputOption word "WordDocumentationLevel" "full"
|
||||
$addCategories = (Get-DocumentationOutputOption word "WordAddCategories" $true) -eq $true
|
||||
$addSubCats = (Get-DocumentationOutputOption word "WordAddSubCategories" $true) -eq $true
|
||||
$attachJson = (Get-DocumentationOutputOption word "WordAttachJsonFile" $false) -eq $true
|
||||
|
||||
try {
|
||||
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
|
||||
if ($tableType -eq "BasicInfo") {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
elseif ($propMode -eq 'extended' -and $documentedObj.DisplayProperties) {
|
||||
$properties = @("Name","Value","Description")
|
||||
}
|
||||
elseif ($propMode -eq 'custom' -and $customProps) {
|
||||
$properties = @()
|
||||
foreach ($prop in $customProps.Split(",")) {
|
||||
$propInfo = $prop.Split('=')
|
||||
if (($propInfo | Measure-Object).Count -gt 1) {
|
||||
$properties += $propInfo[0]
|
||||
Set-DocColumnHeaderLanguageId $propInfo[0] $propInfo[1]
|
||||
}
|
||||
else {
|
||||
$properties += $prop
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($documentedObj.DefaultDocumentationProperties) {
|
||||
$properties = $documentedObj.DefaultDocumentationProperties
|
||||
}
|
||||
else {
|
||||
$properties = @("Name","Value")
|
||||
}
|
||||
}
|
||||
|
||||
if ($docLevel -eq "basic" -and $tableType -ne "BasicInfo") { continue }
|
||||
|
||||
# Custom tables with a negative Order belong ABOVE the settings
|
||||
# table: the portal shows a MAM app config's "Settings catalog"
|
||||
# blade above its "Settings" blade.
|
||||
if ($tableType -eq "FilteredSettings") {
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns -LngId $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties -AddCategories:$addCategories -AddSubcategories:$addSubCats -ForceFullValue:($tableType -eq "BasicInfo")
|
||||
}
|
||||
}
|
||||
|
||||
if ($docLevel -ne "basic") {
|
||||
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") -LngId "Category.complianceActionsLabel"
|
||||
}
|
||||
|
||||
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") -LngId "SettingDetails.applicabilityRules"
|
||||
}
|
||||
|
||||
Add-DocObjectScripts $documentedObj
|
||||
|
||||
# Negative Order already rendered above the settings table.
|
||||
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
|
||||
Add-DocTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns -LngId $customTable.LanguageId -AddCategories -AddSubcategories
|
||||
}
|
||||
}
|
||||
|
||||
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
|
||||
$settingProps = $null
|
||||
if ($documentedObj.Assignments[0].RawIntent) {
|
||||
$properties = @("GroupMode","Group","Filter","FilterMode")
|
||||
$settingProps = @("Filter","FilterMode")
|
||||
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
|
||||
if ($settingsObj) {
|
||||
foreach ($objProp in $settingsObj.Settings.Keys) {
|
||||
if ($objProp -in $properties) { continue }
|
||||
if ($objProp -in @("Category","RawIntent")) { continue }
|
||||
$settingProps += "Settings.$objProp"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$hasFilter = $false
|
||||
foreach ($a in $documentedObj.Assignments) {
|
||||
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
|
||||
}
|
||||
$properties = @("Group")
|
||||
if ($hasFilter) { $properties += @("Filter","FilterMode") }
|
||||
}
|
||||
|
||||
Add-DocTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties -LngId "TableHeaders.assignments" -AddCategories
|
||||
|
||||
if ($null -ne $settingProps) {
|
||||
# Adds additional values to the assignments table for Apps assignments
|
||||
Set-DocTableSettingsItems $documentedObj.Assignments $settingProps 3
|
||||
}
|
||||
}
|
||||
|
||||
if ($attachJson) {
|
||||
# Embed the full raw object JSON as an OLE object (old feature gated on
|
||||
# chkWordAttachJsonFile). The full object is already in hand, so write it
|
||||
# to a temp file directly rather than depending on an external exporter.
|
||||
try {
|
||||
# The policy's real name, not the heading: the heading may be a
|
||||
# DocumentName override, and this is the attached object's label.
|
||||
$safeName = ([string]$PolicyObject.Name)
|
||||
foreach ($ch in [IO.Path]::GetInvalidFileNameChars()) { $safeName = $safeName.Replace($ch, '_') }
|
||||
if ([string]::IsNullOrEmpty($safeName)) { $safeName = 'object' }
|
||||
$fi = [IO.FileInfo](Join-Path ([IO.Path]::GetTempPath()) "$safeName.json")
|
||||
($PolicyObject.JsonObject | ConvertTo-Json -Depth 50) | Out-File -LiteralPath $fi.FullName -Encoding UTF8
|
||||
$fi.Refresh()
|
||||
if ($fi.Exists) {
|
||||
$script:doc.Application.Selection.InlineShapes.AddOLEObject("", $fi.FullName, $false, $true, "$($env:WinDir)\System32\Notepad.exe", 0, $fi.Name)
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
try { $fi.Delete() } catch { }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to attach JSON for $objName" $_.Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to process object $objName" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Set-DocTableSettingsItems {
|
||||
param($items, $properties, [int]$firstColumn)
|
||||
|
||||
$secondColumn = $firstColumn + 1
|
||||
|
||||
$script:docTable.Cell(1, $firstColumn).Range.Text = (Invoke-DocTranslateColumnHeader "Settings")
|
||||
$script:docTable.Cell(1, $secondColumn).Range.Text = ""
|
||||
|
||||
$row = 2
|
||||
foreach ($itemObj in $items) {
|
||||
while ($script:docTable.Cell($row, 1).Next.RowIndex -gt $row) {
|
||||
# Category / Sub-category row — skip
|
||||
$row++
|
||||
}
|
||||
$script:docTable.Cell($row, $firstColumn).Range.Text = ""
|
||||
$script:docTable.Cell($row, $secondColumn).Range.Text = ""
|
||||
$script:docTable.Cell($row, $firstColumn).Split($properties.Count, 1)
|
||||
$script:docTable.Cell($row, $secondColumn).Split($properties.Count, 1)
|
||||
|
||||
$cellRow = $row
|
||||
foreach ($settingProp in $properties) {
|
||||
if ([string]::IsNullOrEmpty($settingProp)) { continue }
|
||||
|
||||
$script:docTable.Cell($cellRow, $firstColumn).Range.Text = (Invoke-DocTranslateColumnHeader ($settingProp.Split('.')[-1]))
|
||||
|
||||
$propArr = $settingProp.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
|
||||
$script:docTable.Cell($cellRow, $secondColumn).Range.Text = "$($tmpObj.$propName)"
|
||||
$cellRow++
|
||||
}
|
||||
$row = $row + $properties.Count
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WordProcessAllObjects {
|
||||
param($allObjectTypeObjects)
|
||||
# ScopeTags consolidated table is deferred — depends on Get-TableObjects-style
|
||||
# cross-object accumulation that hasn't been ported yet. Phase 2 re-enables.
|
||||
}
|
||||
|
||||
function Add-DocTableItems {
|
||||
param(
|
||||
$PolicyObject,
|
||||
[string]$TypeTitle,
|
||||
$Items,
|
||||
[string[]]$Properties,
|
||||
[string]$LngId,
|
||||
[switch]$AddCategories,
|
||||
[switch]$AddSubcategories,
|
||||
$CaptionOverride,
|
||||
[switch]$ForceFullValue
|
||||
)
|
||||
|
||||
if (($Items | Measure-Object).Count -eq 0) { return }
|
||||
|
||||
$tblHeaderStyle = Get-DocumentationOutputOption word "WordTableHeaderStyle" ""
|
||||
$tblCategoryStyle = Get-DocumentationOutputOption word "WordCategoryHeaderStyle" ""
|
||||
$tblSubCategoryStyle = Get-DocumentationOutputOption word "WordSubCategoryHeaderStyle" ""
|
||||
$tblTextStyle = Get-DocumentationOutputOption word "WordTableTextStyle" ""
|
||||
$tblStyle = Get-DocumentationOutputOption word "WordTableStyle" "Grid table 4 - Accent 3"
|
||||
$captionPos = Get-DocumentationOutputOption word "WordTableCaptionPosition" "below"
|
||||
$docLevel = Get-DocumentationOutputOption word "WordDocumentationLevel" "full"
|
||||
$limitAttach = (Get-DocumentationOutputOption word "WordDocumentationLimitAttach" $false) -eq $true
|
||||
|
||||
$range = $script:doc.Application.Selection.Range
|
||||
|
||||
# Pre-pass: count category / sub-category rows so the table can be allocated at its final size.
|
||||
# Boundary logic MUST stay in sync with the main fill loop below.
|
||||
$extraRows = 0
|
||||
$preCat = ""
|
||||
$preSubCat = ""
|
||||
foreach ($itemObj in $Items) {
|
||||
if ($itemObj.Category -and $preCat -ne $itemObj.Category -and $AddCategories) {
|
||||
$extraRows++
|
||||
$preCat = $itemObj.Category
|
||||
$preSubCat = ""
|
||||
}
|
||||
if ($itemObj.SubCategory -and $preSubCat -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
$extraRows++
|
||||
$preSubCat = $itemObj.SubCategory
|
||||
}
|
||||
}
|
||||
|
||||
$totalRows = @($Items).Count + 1 + $extraRows
|
||||
|
||||
# Create with wdAutoFitFixed during fill — wdAutoFitWindow recalculates column widths after every cell write.
|
||||
# We re-enable wdAutoFitWindow once after the rows are populated.
|
||||
$script:docTable = $script:doc.Tables.Add($range, $totalRows, $Properties.Count, [Microsoft.Office.Interop.Word.WdDefaultTableBehavior]::wdWord9TableBehavior, [Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitFixed)
|
||||
$script:docTable.ApplyStyleHeadingRows = $true
|
||||
Set-DocObjectStyle $script:docTable $tblStyle | Out-Null
|
||||
|
||||
if ($CaptionOverride) {
|
||||
$caption = $CaptionOverride
|
||||
}
|
||||
elseif ($LngId -and $PolicyObject) {
|
||||
$caption = "$((Get-LanguageString $LngId)) - $(Get-DocCaptionName $PolicyObject)"
|
||||
}
|
||||
elseif ($PolicyObject) {
|
||||
$caption = "$(Get-DocCaptionName $PolicyObject) ($TypeTitle)"
|
||||
}
|
||||
else {
|
||||
$caption = $TypeTitle
|
||||
}
|
||||
|
||||
$i = 1
|
||||
foreach ($prop in $Properties) {
|
||||
if ([string]::IsNullOrEmpty($prop)) { continue }
|
||||
$script:docTable.Cell(1, $i).Range.Text = (Invoke-DocTranslateColumnHeader ($prop.Split('.')[-1]))
|
||||
$i++
|
||||
}
|
||||
|
||||
if (-not (Set-DocObjectStyle $script:docTable.Rows(1).Range $tblHeaderStyle)) {
|
||||
$script:docTable.Rows(1).Range.Font.Size += 2
|
||||
$script:docTable.Rows(1).Range.Font.Bold = $true
|
||||
}
|
||||
|
||||
$curCategory = ""
|
||||
$curSubCategory = ""
|
||||
|
||||
$row = 2
|
||||
foreach ($itemObj in $Items) {
|
||||
try {
|
||||
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
|
||||
try { $script:docTable.Rows.Item($row).Cells.Merge() } catch { }
|
||||
$script:docTable.Cell($row, 1).Range.Text = $itemObj.Category
|
||||
|
||||
if (-not (Set-DocObjectStyle $script:docTable.Rows($row).Range $tblCategoryStyle)) {
|
||||
$script:docTable.Rows($row).Range.Font.Size += 2
|
||||
$script:docTable.Rows($row).Range.Font.Italic = $true
|
||||
}
|
||||
|
||||
$row++
|
||||
$curCategory = $itemObj.Category
|
||||
$curSubCategory = ""
|
||||
}
|
||||
|
||||
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
|
||||
try { $script:docTable.Rows.Item($row).Cells.Merge() } catch { }
|
||||
$script:docTable.Cell($row, 1).Range.Text = $itemObj.SubCategory
|
||||
|
||||
if (-not (Set-DocObjectStyle $script:docTable.Rows($row).Range $tblSubCategoryStyle)) {
|
||||
$script:docTable.Rows($row).Range.Font.Italic = $true
|
||||
}
|
||||
|
||||
$row++
|
||||
$curSubCategory = $itemObj.SubCategory
|
||||
}
|
||||
|
||||
$i = 1
|
||||
foreach ($prop in $Properties) {
|
||||
try {
|
||||
$propArr = $prop.Split('.')
|
||||
$tmpObj = $itemObj
|
||||
$propName = $propArr[-1]
|
||||
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
|
||||
$tmpObj = $tmpObj."$($propArr[$x])"
|
||||
}
|
||||
$propValue = "$($tmpObj.$propName)"
|
||||
$propValueFull = $null
|
||||
|
||||
if (-not $ForceFullValue -and $docLevel -eq "limited" -and $propValue.Length -gt $script:limitMaxValue) {
|
||||
$propValueFull = $propValue
|
||||
if ($script:truncateValueLength -gt 0) {
|
||||
$propValue = $propValue.Substring(0, $script:truncateValueLength) + "..."
|
||||
if ($limitAttach) { $propValue = "`r`n" + $propValue }
|
||||
}
|
||||
else {
|
||||
$propValue = $null
|
||||
}
|
||||
}
|
||||
|
||||
$levelExtra = ""
|
||||
if ($i -eq 1 -and $itemObj.Level) {
|
||||
try {
|
||||
$level = [int]$itemObj.Level
|
||||
if ($level -lt 0) { $level = 0 }
|
||||
if ($level -gt 0) {
|
||||
$levelExtra = [string]::new(" ", ($level * 2))
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if ($null -ne $propValue) {
|
||||
$script:docTable.Cell($row, $i).Range.Text = "$levelExtra$propValue"
|
||||
}
|
||||
|
||||
if ($propValueFull -and $limitAttach) {
|
||||
$tmpName = "$($PolicyObject.Name)-$propName"
|
||||
$tmpFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "$tmpName.txt")
|
||||
$tmpFile = Remove-InvalidFileNameChars $tmpFile
|
||||
$propValueFull | Out-File -LiteralPath $tmpFile -Force
|
||||
$fi = [IO.FileInfo]$tmpFile
|
||||
[void]$script:docTable.Cell($row, $i).Range.InlineShapes.AddOLEObject("", $fi.FullName, $false, $true, "$($env:WinDir)\System32\Notepad.exe", 0, "Full value")
|
||||
try { $fi.Delete() } catch { }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to add property value for $prop" $_.Exception
|
||||
}
|
||||
$i++
|
||||
}
|
||||
|
||||
Set-DocObjectStyle $script:docTable.Rows($row).Range $tblTextStyle | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to process property" 2
|
||||
}
|
||||
|
||||
$row++
|
||||
}
|
||||
|
||||
try { $script:docTable.AutoFitBehavior([Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitWindow) } catch { }
|
||||
|
||||
# -2 = Table caption, 1 = Below / 0 = Above
|
||||
$capPos = if ($captionPos -eq "above") { 0 } else { 1 }
|
||||
$script:docTable.Application.Selection.InsertCaption(-2, ". $caption", $null, $capPos)
|
||||
|
||||
Invoke-DocGoToEnd
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
}
|
||||
|
||||
function Add-DocTableScript {
|
||||
param([string]$Caption, [string]$Header, [string]$ScriptText)
|
||||
|
||||
if (-not $ScriptText) { return }
|
||||
|
||||
$primary = Get-DocumentationOutputOption word "WordScriptTableStyle" ""
|
||||
if (-not $primary) {
|
||||
$primary = Get-DocumentationOutputOption word "WordTableStyle" "Grid table 4 - Accent 3"
|
||||
}
|
||||
$scriptStyle = Get-DocumentationOutputOption word "WordScriptStyle" ""
|
||||
|
||||
$range = $script:doc.Application.Selection.Range
|
||||
$scriptTable = $script:doc.Tables.Add($range, 2, 1, [Microsoft.Office.Interop.Word.WdDefaultTableBehavior]::wdWord9TableBehavior, [Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitFixed)
|
||||
$scriptTable.ApplyStyleHeadingRows = $true
|
||||
Set-DocObjectStyle $scriptTable $primary | Out-Null
|
||||
|
||||
if ($Header) {
|
||||
$scriptTable.Cell(1, 1).Range.Text = $Header
|
||||
}
|
||||
|
||||
$scriptTable.Cell(2, 1).Range.Font.Bold = $false
|
||||
$scriptTable.Cell(2, 1).Range.Text = $ScriptText
|
||||
if ($scriptStyle) {
|
||||
Set-DocObjectStyle $scriptTable.Rows(2).Range $scriptStyle | Out-Null
|
||||
}
|
||||
else {
|
||||
$tmp = $script:wordStyles["HTML Code"]
|
||||
if ($tmp) {
|
||||
$scriptTable.Cell(2, 1).Range.Font = $tmp.Style.Font
|
||||
}
|
||||
$scriptTable.Cell(2, 1).Range.Font.Bold = $false
|
||||
}
|
||||
$scriptTable.Cell(2, 1).Range.NoProofing = $true
|
||||
|
||||
try { $scriptTable.AutoFitBehavior([Microsoft.Office.Interop.Word.WdAutoFitBehavior]::wdAutoFitWindow) } catch { }
|
||||
$scriptTable.Application.Selection.InsertCaption(-2, ". $Caption", $null, 1)
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
}
|
||||
|
||||
function Get-DocStyle {
|
||||
param([string]$StyleName)
|
||||
|
||||
$tmpStyle = $null
|
||||
if ($StyleName -and $script:wordStyles.ContainsKey($StyleName)) {
|
||||
$tmpStyle = $script:wordStyles[$StyleName].Style
|
||||
}
|
||||
if (-not $tmpStyle) { Write-Log "Style $StyleName not found" }
|
||||
$tmpStyle
|
||||
}
|
||||
|
||||
function Add-DocText {
|
||||
param([string]$Text, [string]$Style, [switch]$SkipAddParagraph)
|
||||
|
||||
Set-DocObjectStyle $script:doc.Application.Selection $Style | Out-Null
|
||||
$script:doc.Application.Selection.TypeText($Text)
|
||||
if (-not $SkipAddParagraph) {
|
||||
$script:doc.Application.Selection.TypeParagraph()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-DocGoToEnd {
|
||||
$script:doc.Application.Selection.GoTo([Microsoft.Office.Interop.Word.WdGoToItem]::wdGoToBookmark, $null, $null, '\EndOfDoc') | Out-Null
|
||||
}
|
||||
|
||||
function Set-WordDocBuiltInProperty {
|
||||
param([string]$PropertyName, $Value)
|
||||
|
||||
try {
|
||||
$script:doc.BuiltInDocumentProperties([Microsoft.Office.Interop.Word.WdBuiltInProperty]$PropertyName) = $Value
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to set built in property $PropertyName to $Value" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
function Set-DocObjectStyle {
|
||||
param($DocObj, [string]$ObjStyle)
|
||||
|
||||
$styleSet = $false
|
||||
if ($DocObj -and $ObjStyle) {
|
||||
try {
|
||||
if ($script:builtinStyles.ContainsKey($ObjStyle)) {
|
||||
$DocObj.style = [Microsoft.Office.Interop.Word.wdBuiltinStyle]$ObjStyle
|
||||
}
|
||||
else {
|
||||
$DocObj.style = $ObjStyle
|
||||
}
|
||||
$styleSet = $true
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to set style: $ObjStyle" 3
|
||||
}
|
||||
}
|
||||
$styleSet
|
||||
}
|
||||
|
||||
function Add-DocObjectScripts {
|
||||
param($documentedObj)
|
||||
|
||||
foreach ($scriptItem in $documentedObj.Scripts) {
|
||||
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
|
||||
Add-DocTableScript $scriptItem.Caption $scriptItem.Header $scriptItem.ScriptContent
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-InitializeWordOutput
|
||||
@@ -0,0 +1,294 @@
|
||||
# Android Managed Store App Configuration documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:1928. Claims
|
||||
# @odata.type='#microsoft.graph.androidManagedStoreAppConfiguration' and
|
||||
# (per the plan's batching) also the legacy androidForWorkMobileAppConfig
|
||||
# variant since both have the same shape.
|
||||
#
|
||||
# Profile applicability translates to one of three workProfile/deviceOwner
|
||||
# variants which becomes the "Profile type" basic-info value.
|
||||
# Outlook ObjectInfo translation deferred until the walker is ported.
|
||||
|
||||
class AppConfigAndroidStoreDocHandler : DocumentationHandlerBase {
|
||||
AppConfigAndroidStoreDocHandler() {
|
||||
$this.ODataTypes = @(
|
||||
'#microsoft.graph.androidManagedStoreAppConfiguration',
|
||||
'#microsoft.graph.androidForWorkMobileAppConfiguration'
|
||||
)
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# ProfileType maps to a localized "App configuration" suffix
|
||||
$profileString = switch ($obj.profileApplicability) {
|
||||
'default' { Get-LanguageString 'ProfileType.workProfileAndDeviceOwner' }
|
||||
'androidWorkProfile' { Get-LanguageString 'ProfileType.workProfileOnly' }
|
||||
'androidDeviceOwner' { Get-LanguageString 'ProfileType.deviceOwnerOnly' }
|
||||
default { $null }
|
||||
}
|
||||
# Pass profileString as the Profile-type override so Add-BasicDefaultValues
|
||||
# emits one (and only one) Profile type row matching old engine's pattern
|
||||
# at DocumentationCustom.psm1:1955.
|
||||
Add-BasicDefaultValues $PolicyObject $profileString
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
# Targeted apps — resolved to displayNames when catalog available
|
||||
$allApps = Get-CDAllTenantApps
|
||||
$appsList = @()
|
||||
foreach ($id in $obj.targetedMobileApps) {
|
||||
$app = $allApps | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
$appsList += if ($app -and $app.displayName) { $app.displayName } else { $id }
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.targetedAppLabel') ($appsList -join $Context.ObjectSeparator) 'targetedMobileApps'
|
||||
|
||||
if ($obj.packageId) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.packageId') $obj.packageId 'packageId'
|
||||
}
|
||||
|
||||
# appSupportsOemConfig is the discriminator the portal uses to split OEMConfig
|
||||
# policies into their own blade - surface it so an OEMConfig policy isn't
|
||||
# documented as an ordinary app configuration. NB: TableHeaders.configurationType
|
||||
# renders as "Profile type" and would collide with the row above.
|
||||
if ($obj.appSupportsOemConfig -eq $true) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.policyType') (Get-LanguageString 'ConfigurationTypes.androidForWorkOemConfig') 'appSupportsOemConfig'
|
||||
}
|
||||
|
||||
# connectedAppsEnabled - "Connected apps" toggle. The portal offers
|
||||
# Enabled / Not configured (not Enabled/Disabled).
|
||||
$connKey = if ($obj.connectedAppsEnabled -eq $true) { 'Inputs.enabled' } else { 'Inputs.notConfigured' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.connectedApps') (Get-LanguageString $connKey) 'connectedAppsEnabled'
|
||||
|
||||
# credentialProviderRoleState - androidAppCredentialProviderRoleState enum
|
||||
# (notConfigured / allowed only). The portal renders the same two options as
|
||||
# the connected-apps toggle: Enabled / Not configured.
|
||||
$credKeys = @{
|
||||
'notConfigured' = 'Inputs.notConfigured'
|
||||
'allowed' = 'Inputs.enabled'
|
||||
}
|
||||
$credRaw = "$($obj.credentialProviderRoleState)"
|
||||
if ($credRaw) {
|
||||
$credKey = $credKeys[$credRaw]
|
||||
$credValue = if ($credKey) { Get-LanguageString $credKey -IgnoreMissing } else { $null }
|
||||
if ([string]::IsNullOrEmpty($credValue)) { $credValue = $credRaw }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.credentialProvider') $credValue 'credentialProviderRoleState'
|
||||
}
|
||||
|
||||
if (-not $obj.payloadJson) { return }
|
||||
|
||||
$payloadData = $null
|
||||
try {
|
||||
$payloadData = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($obj.payloadJson)) | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to parse Android managed-store payloadJson' $_.Exception
|
||||
return
|
||||
}
|
||||
|
||||
# Outlook gets schema-driven translation (port of old DocumentationCustom.psm1:1987-2005).
|
||||
if ($obj.packageId -eq 'com.microsoft.office.outlook') {
|
||||
$hasAccountType = @($payloadData.managedProperty | Where-Object { $_.key -eq 'com.microsoft.outlook.EmailProfile.AccountType' })
|
||||
$outlookSettings = [PSCustomObject]@{ configureEmail = [bool]$hasAccountType }
|
||||
foreach ($mp in @($payloadData.managedProperty)) {
|
||||
$valueProp = $mp.PSObject.Properties | Where-Object Name -Like 'value*' | Select-Object -First 1
|
||||
$outlookSettings | Add-Member -MemberType NoteProperty -Name $mp.key -Value $valueProp.Value -Force
|
||||
}
|
||||
Invoke-DocAppConfigManifest $outlookSettings (Join-Path (Join-Path $script:AppRootFolder 'Config\ObjectInfo') '#AppConfigOutlookDevice.json') $Context
|
||||
}
|
||||
|
||||
# Outlook translation applied above; remaining managedProperty entries
|
||||
# fall through to the additional-settings table.
|
||||
$addedSettings = Get-DocumentedSettings
|
||||
|
||||
# Friendly names / descriptions / enum labels come from the app's own
|
||||
# managed-configuration schema when a tenant is reachable.
|
||||
$schema = Get-CDAndroidAppConfigSchema $obj.packageId
|
||||
|
||||
$additionalSettings = @()
|
||||
$hasDescription = $false
|
||||
foreach ($row in (Expand-AndroidManagedProperties $payloadData.managedProperty '' 0 $schema)) {
|
||||
if ($addedSettings | Where-Object EntityKey -EQ $row.Key) { continue }
|
||||
if ($row.Description) { $hasDescription = $true }
|
||||
|
||||
$additionalSettings += [PSCustomObject]@{
|
||||
Name = $row.Name
|
||||
Key = $row.Key
|
||||
ValueType = $row.ValueType
|
||||
Value = $row.Value
|
||||
Description = $row.Description
|
||||
EntityKey = $row.Key
|
||||
Category = Get-LanguageString 'TACSettings.generalSettings'
|
||||
SubCategory = Get-LanguageString 'SettingDetails.additionalConfiguration'
|
||||
}
|
||||
}
|
||||
if ($additionalSettings.Count -gt 0) {
|
||||
# Keep the raw key visible next to the friendly name, and only add the
|
||||
# description column when the schema actually supplied any.
|
||||
$columns = if ($hasDescription) { @('Name','Key','ValueType','Value','Description') } else { @('Name','Key','ValueType','Value') }
|
||||
Add-CustomTable 'AdditionalSettings' $columns $additionalSettings -Order 110
|
||||
}
|
||||
|
||||
# Permissions table. Portal grid is 4 columns: friendly name, permission
|
||||
# state, raw permission name (prefix stripped) and permission group.
|
||||
$permissions = @()
|
||||
foreach ($p in $obj.permissionActions) {
|
||||
$tail = $p.permission.Split('.')[-1]
|
||||
$permissionStr = if ($tail) {
|
||||
# Language ids drop the underscores (READ_CALENDAR -> readCalendar);
|
||||
# PowerShell member lookup is case-insensitive so the raw upper-case
|
||||
# form resolves too.
|
||||
$lngId = $tail -replace '_',''
|
||||
$resolved = Get-LanguageString "AndroidForWorkAppPermissions.Permissions.$lngId" -IgnoreMissing
|
||||
if ($resolved) { $resolved } else { $tail }
|
||||
} else { $p.permission }
|
||||
|
||||
$actionStr = $p.action
|
||||
$resolvedAction = Get-LanguageString "AndroidForWorkAppPermissions.Action.$($p.action)" -IgnoreMissing
|
||||
if ($resolvedAction) { $actionStr = $resolvedAction }
|
||||
|
||||
$permissions += [PSCustomObject]@{
|
||||
Permission = $permissionStr
|
||||
PermissionState = $actionStr
|
||||
PermissionName = $tail
|
||||
PermissionGroup = Get-AndroidPermissionGroup $tail
|
||||
EntityKey = $p.permission
|
||||
}
|
||||
}
|
||||
if ($permissions.Count -gt 0) {
|
||||
Add-CustomTable 'Permissions' @('Permission','PermissionState','PermissionName','PermissionGroup') $permissions -Order 115 -LanguageId 'AndroidForWorkAppPermissions.permissionsTitle'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Managed-configuration schema for one Managed Google Play app. The portal uses
|
||||
# this to show friendly names instead of raw keys, a description column, the real
|
||||
# data type (choice/multiselect/bundle...) and enum labels via `selections`.
|
||||
#
|
||||
# GET /deviceManagement/androidManagedStoreAppConfigurationSchemas('app:<packageId>')
|
||||
#
|
||||
# Returns a hashtable keyed by schemaItemKey. `nestedSchemaItems` carries the
|
||||
# members of bundles/bundle arrays (linked to their parent by index/parentIndex),
|
||||
# so nested leaves get friendly names too. Cached per run and per package; offline
|
||||
# / source-unavailable returns an empty map and every caller degrades to raw keys.
|
||||
function Get-CDAndroidAppConfigSchema {
|
||||
param([string]$PackageId)
|
||||
|
||||
if (-not $PackageId) { return @{} }
|
||||
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
if (-not $ctx.PSObject.Properties['_AndroidAppConfigSchemas']) {
|
||||
$ctx | Add-Member -MemberType NoteProperty -Name '_AndroidAppConfigSchemas' -Value (@{}) -Force
|
||||
}
|
||||
if ($ctx._AndroidAppConfigSchemas.ContainsKey($PackageId)) { return $ctx._AndroidAppConfigSchemas[$PackageId] }
|
||||
|
||||
$map = @{}
|
||||
# The schema is generic app metadata (same on every tenant), so this is gated on
|
||||
# connectivity only - not on SourceTenantUnavailable.
|
||||
if (Test-DocumentationGraphAvailable) {
|
||||
try {
|
||||
$url = "/deviceManagement/androidManagedStoreAppConfigurationSchemas('app:$PackageId')"
|
||||
$resp = Invoke-MSGraphAPI -Url $url -ODataMetadata 'minimal' -NoError
|
||||
foreach ($item in @($resp.schemaItems) + @($resp.nestedSchemaItems)) {
|
||||
if ($item.schemaItemKey -and -not $map.ContainsKey($item.schemaItemKey)) {
|
||||
$map[$item.schemaItemKey] = $item
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to load Android app configuration schema for $PackageId" $_.Exception
|
||||
}
|
||||
}
|
||||
$ctx._AndroidAppConfigSchemas[$PackageId] = $map
|
||||
return $map
|
||||
}
|
||||
|
||||
# Android permission -> permission group. Groups are Android platform constants
|
||||
# (not localized - the portal renders them verbatim in its 4th grid column).
|
||||
$script:_androidPermissionGroups = @{
|
||||
'READ_CALENDAR' = 'CALENDAR'; 'WRITE_CALENDAR' = 'CALENDAR'
|
||||
'CAMERA' = 'CAMERA'
|
||||
'READ_CONTACTS' = 'CONTACTS'; 'WRITE_CONTACTS' = 'CONTACTS'; 'GET_ACCOUNTS' = 'CONTACTS'
|
||||
'ACCESS_FINE_LOCATION' = 'LOCATION'; 'ACCESS_COARSE_LOCATION' = 'LOCATION'; 'ACCESS_BACKGROUND_LOCATION' = 'LOCATION'
|
||||
'RECORD_AUDIO' = 'MICROPHONE'
|
||||
'READ_PHONE_STATE' = 'PHONE'; 'CALL_PHONE' = 'PHONE'; 'READ_CALL_LOG' = 'PHONE'; 'WRITE_CALL_LOG' = 'PHONE'
|
||||
'ADD_VOICEMAIL' = 'PHONE'; 'USE_SIP' = 'PHONE'; 'PROCESS_OUTGOING_CALLS' = 'PHONE'
|
||||
'BODY_SENSORS' = 'SENSORS'; 'BODY_SENSORS_BACKGROUND' = 'SENSORS'
|
||||
'SEND_SMS' = 'SMS'; 'RECEIVE_SMS' = 'SMS'; 'READ_SMS' = 'SMS'; 'RECEIVE_WAP_PUSH' = 'SMS'; 'RECEIVE_MMS' = 'SMS'
|
||||
'READ_EXTERNAL_STORAGE' = 'STORAGE'; 'WRITE_EXTERNAL_STORAGE' = 'STORAGE'
|
||||
'POST_NOTIFICATIONS' = 'NOTIFICATIONS'
|
||||
'READ_MEDIA_VIDEO' = 'MEDIA'; 'READ_MEDIA_IMAGES' = 'MEDIA'; 'READ_MEDIA_AUDIO' = 'MEDIA'
|
||||
'BLUETOOTH_CONNECT' = 'DEVICES'; 'NEARBY_WIFI_DEVICES' = 'DEVICES'; 'NEARBY_DEVICES' = 'DEVICES'
|
||||
}
|
||||
|
||||
function Get-AndroidPermissionGroup {
|
||||
param([string]$PermissionName)
|
||||
if (-not $PermissionName) { return $null }
|
||||
$script:_androidPermissionGroups[$PermissionName]
|
||||
}
|
||||
|
||||
# Flatten a Google managed-configuration `managedProperty` array into one row per
|
||||
# LEAF value. The payload supports six value shapes, two of which nest without
|
||||
# bound (portal JSON-editor schema: valueBool / valueInteger / valueString /
|
||||
# valueStringArray / valueBundle / valueBundleArray):
|
||||
#
|
||||
# valueBundle -> { managedProperty: [ ... ] } rendered as "parent.child"
|
||||
# valueBundleArray -> [ { managedProperty: [...] }, ... ] rendered as "parent[0].child"
|
||||
#
|
||||
# Without this, a bundle rendered as the PowerShell object stringification and a
|
||||
# bundle array rendered as a bare "," (the -join of an array of objects).
|
||||
function Expand-AndroidManagedProperties {
|
||||
param($ManagedProperties, [string]$Prefix = '', [int]$Depth = 0, $Schema = @{})
|
||||
|
||||
if ($Depth -gt 10) {
|
||||
Write-Log 'Android app config: managedProperty nesting deeper than 10 levels; remaining levels not documented' 2
|
||||
return
|
||||
}
|
||||
|
||||
$ctx = Get-CurrentDocumentationContext
|
||||
|
||||
foreach ($mp in @($ManagedProperties)) {
|
||||
if (-not $mp) { continue }
|
||||
$key = if ($Prefix) { "$Prefix$($mp.key)" } else { [string]$mp.key }
|
||||
|
||||
$valueProp = $mp.PSObject.Properties | Where-Object Name -Like 'value*' | Select-Object -First 1
|
||||
if (-not $valueProp) { continue }
|
||||
|
||||
# Schema is keyed by the app's own schemaItemKey, not by our dotted path
|
||||
$schemaItem = $Schema[[string]$mp.key]
|
||||
|
||||
switch ($valueProp.Name) {
|
||||
'valueBundle' {
|
||||
Expand-AndroidManagedProperties $valueProp.Value.managedProperty "$key." ($Depth + 1) $Schema
|
||||
}
|
||||
'valueBundleArray' {
|
||||
$idx = 0
|
||||
foreach ($bundle in @($valueProp.Value)) {
|
||||
Expand-AndroidManagedProperties $bundle.managedProperty "$key[$idx]." ($Depth + 1) $Schema
|
||||
$idx++
|
||||
}
|
||||
}
|
||||
default {
|
||||
$val = $valueProp.Value
|
||||
# choice / multiselect store the selection VALUE; the schema carries
|
||||
# the friendly name for each in `selections`
|
||||
if ($schemaItem.selections) {
|
||||
$val = @($val | ForEach-Object {
|
||||
$raw = $_
|
||||
$sel = $schemaItem.selections | Where-Object { "$($_.value)" -eq "$raw" } | Select-Object -First 1
|
||||
if ($sel.name) { $sel.name } else { $raw }
|
||||
})
|
||||
}
|
||||
if ($val -is [array]) { $val = $val -join $ctx.ObjectSeparator }
|
||||
|
||||
[PSCustomObject]@{
|
||||
Key = $key
|
||||
Name = ?? $schemaItem.displayName $key
|
||||
ValueType = if ($schemaItem.dataType) { $schemaItem.dataType } else { $valueProp.Name.Substring(5) }
|
||||
Value = $val
|
||||
Description = $schemaItem.description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AppConfigAndroidStoreDocHandler]::new())
|
||||
@@ -0,0 +1,192 @@
|
||||
# iOS Mobile App Configuration documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:2065. Claims
|
||||
# @odata.type='#microsoft.graph.iosMobileAppConfiguration'.
|
||||
#
|
||||
# Two main paths:
|
||||
# 1. iOS plist (base64'd encodedSettingXml) — parsed offline, key/value/type
|
||||
# rows emitted directly
|
||||
# 2. settings collection (Outlook-specific or generic appConfig key/value)
|
||||
# The Outlook ObjectInfo translation needs the ObjectInfo JSON walker
|
||||
# (deferred); offline we fall through to raw key=value rows under the
|
||||
# generic "Additional configuration" subcategory.
|
||||
|
||||
class AppConfigMobileAppDocHandler : DocumentationHandlerBase {
|
||||
AppConfigMobileAppDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.iosMobileAppConfiguration')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'SettingDetails.appConfiguration') '@odata.type'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.enrollmentTypeLabel') (Get-LanguageString 'EnrollmentType.devicesWithEnrollment') 'enrollmentType'
|
||||
|
||||
$platformId = Get-ObjectPlatformFromType $obj
|
||||
if ($platformId) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString "Platform.$platformId") 'platform'
|
||||
}
|
||||
|
||||
# Targeted apps — resolve IDs to displayNames when the tenant catalog is
|
||||
# available, otherwise emit raw IDs.
|
||||
$allApps = Get-CDAllTenantApps
|
||||
$appsList = @()
|
||||
foreach ($id in $obj.targetedMobileApps) {
|
||||
$app = $allApps | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
$appsList += if ($app -and $app.displayName) { $app.displayName } else { $id }
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.targetedAppLabel') ($appsList -join $Context.ObjectSeparator) 'targetedMobileApps'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
|
||||
if ($obj.encodedSettingXml) {
|
||||
# iOS plist. The portal emits a bare <dict> root but Graph also accepts a
|
||||
# <plist> wrapper, and the portal's validator explicitly allows nested
|
||||
# <dict>/<array> values - so the walk has to recurse.
|
||||
$xml = $null
|
||||
try {
|
||||
$xml = [xml]([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($obj.encodedSettingXml)))
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to convert iOS encodedSettingXml to XML' $_.Exception
|
||||
return
|
||||
}
|
||||
|
||||
$rootDict = if ($xml.dict) { $xml.dict } elseif ($xml.plist.dict) { $xml.plist.dict } else { $null }
|
||||
if (-not $rootDict) {
|
||||
Write-Log 'iOS app config: encodedSettingXml has no <dict> root; no settings documented' 2
|
||||
return
|
||||
}
|
||||
|
||||
$plistRows = @(Expand-IosPlistDictionary $rootDict)
|
||||
|
||||
# ValueType is not part of the default documentation properties, so also
|
||||
# emit the portal's 3-column grid (key / value type / value).
|
||||
if ($plistRows.Count -gt 0) {
|
||||
$typeRows = foreach ($row in $plistRows) {
|
||||
[PSCustomObject]@{
|
||||
ConfigurationKey = $row.Key
|
||||
ValueType = Get-AppConfigValueTypeName $row.ValueType
|
||||
ConfigurationValue = $row.Value
|
||||
EntityKey = $row.Key
|
||||
}
|
||||
}
|
||||
Add-CustomTable 'AppConfigSettings' @('ConfigurationKey','ValueType','ConfigurationValue') $typeRows -Order 110 -LanguageId 'TableHeaders.settings'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Outlook gets schema-driven translation (port of old DocumentationCustom.psm1:2141-2176).
|
||||
$isOutlook = $false
|
||||
foreach ($id in $obj.targetedMobileApps) {
|
||||
$app = $allApps | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
if ($app.displayName -eq 'Microsoft Outlook') { $isOutlook = $true; break }
|
||||
}
|
||||
if (-not $isOutlook -and @($obj.settings | Where-Object { $_.appConfigKey -like 'com.microsoft.outlook*' })) { $isOutlook = $true }
|
||||
if ($isOutlook) {
|
||||
$hasAccountType = @($obj.settings | Where-Object { $_.appConfigKey -eq 'com.microsoft.outlook.EmailProfile.AccountType' })
|
||||
$outlookSettings = [PSCustomObject]@{ configureEmail = [bool]$hasAccountType }
|
||||
foreach ($setting in @($obj.settings)) {
|
||||
$val = if ($setting.appConfigKeyType -eq 'booleanType') { $setting.appConfigKeyValue -eq 'true' } else { $setting.appConfigKeyValue }
|
||||
$outlookSettings | Add-Member -MemberType NoteProperty -Name $setting.appConfigKey -Value $val -Force
|
||||
}
|
||||
Invoke-DocAppConfigManifest $outlookSettings (Join-Path (Join-Path $script:AppRootFolder 'Config\ObjectInfo') '#AppConfigOutlookDevice.json') $Context
|
||||
}
|
||||
|
||||
# Remaining settings fall through to raw key=value rows under the
|
||||
# "Additional configuration" subcategory.
|
||||
$addedSettings = Get-DocumentedSettings
|
||||
$languageTitleId = 'TableHeaders.settings'
|
||||
|
||||
$typeRows = @()
|
||||
foreach ($setting in $obj.settings) {
|
||||
if ($addedSettings | Where-Object EntityKey -EQ $setting.appConfigKey) {
|
||||
$languageTitleId = 'SettingDetails.additionalConfiguration'
|
||||
continue
|
||||
}
|
||||
|
||||
# The portal grid shows the value TYPE as its own column; keep that
|
||||
# (a tokenType value like {{userprincipalname}} is not a literal string).
|
||||
$typeRows += [PSCustomObject]@{
|
||||
ConfigurationKey = $setting.appConfigKey
|
||||
ValueType = Get-AppConfigValueTypeName $setting.appConfigKeyType
|
||||
ConfigurationValue = $setting.appConfigKeyValue
|
||||
EntityKey = $setting.appConfigKey
|
||||
}
|
||||
}
|
||||
if ($typeRows.Count -gt 0) {
|
||||
Add-CustomTable 'AppConfigSettings' @('ConfigurationKey','ValueType','ConfigurationValue') $typeRows -Order 110 -LanguageId $languageTitleId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Localized name for an app-config value type. Accepts both the Graph
|
||||
# mdmAppConfigKeyType members (stringType/integerType/realType/booleanType/
|
||||
# tokenType) and the bare plist element names (string/integer/real/boolean/...).
|
||||
# tokenType has no language string - the portal shows it blank, so the raw value
|
||||
# is a strict improvement.
|
||||
function Get-AppConfigValueTypeName {
|
||||
param([string]$ValueType)
|
||||
|
||||
if (-not $ValueType) { return $null }
|
||||
$key = switch -Regex ($ValueType) {
|
||||
'^string' { 'SettingDetails.string' }
|
||||
'^integer' { 'SettingDetails.integer' }
|
||||
'^real' { 'SettingDetails.real' }
|
||||
'^boolean' { 'SettingDetails.boolean' }
|
||||
default { $null }
|
||||
}
|
||||
if (-not $key) { return $ValueType }
|
||||
$value = Get-LanguageString $key -IgnoreMissing
|
||||
if ([string]::IsNullOrEmpty($value)) { $ValueType } else { $value }
|
||||
}
|
||||
|
||||
# Flatten an iOS plist <dict> into one row per LEAF value. Nested containers are
|
||||
# addressed the way the plist itself addresses them:
|
||||
# <dict> -> "parent.child"
|
||||
# <array> -> "parent[0]"
|
||||
# Without this, a nested container produced an EMPTY value (.'#text' on an element
|
||||
# with element children returns nothing) and the payload was silently lost.
|
||||
function Expand-IosPlistDictionary {
|
||||
param($DictNode, [string]$Prefix = '', [int]$Depth = 0)
|
||||
|
||||
if ($Depth -gt 10) {
|
||||
Write-Log 'iOS app config: plist nesting deeper than 10 levels; remaining levels not documented' 2
|
||||
return
|
||||
}
|
||||
|
||||
$children = @($DictNode.ChildNodes)
|
||||
for ($i = 0; $i -lt $children.Count; $i++) {
|
||||
if ($children[$i].Name -ne 'key') { continue }
|
||||
$name = $children[$i].'#text'
|
||||
$i++
|
||||
if ($i -ge $children.Count) { break }
|
||||
$valueNode = $children[$i]
|
||||
$key = if ($Prefix) { "$Prefix$name" } else { [string]$name }
|
||||
|
||||
switch ($valueNode.Name) {
|
||||
'true' { [PSCustomObject]@{ Key = $key; ValueType = 'boolean'; Value = 'true' } }
|
||||
'false' { [PSCustomObject]@{ Key = $key; ValueType = 'boolean'; Value = 'false' } }
|
||||
'dict' { Expand-IosPlistDictionary $valueNode "$key." ($Depth + 1) }
|
||||
'array' {
|
||||
$idx = 0
|
||||
foreach ($item in @($valueNode.ChildNodes)) {
|
||||
$itemKey = "$key[$idx]"
|
||||
if ($item.Name -eq 'dict') { Expand-IosPlistDictionary $item "$itemKey." ($Depth + 1) }
|
||||
elseif ($item.Name -eq 'true' -or $item.Name -eq 'false') {
|
||||
[PSCustomObject]@{ Key = $itemKey; ValueType = 'boolean'; Value = $item.Name }
|
||||
}
|
||||
else {
|
||||
[PSCustomObject]@{ Key = $itemKey; ValueType = $item.Name; Value = $item.'#text' }
|
||||
}
|
||||
$idx++
|
||||
}
|
||||
}
|
||||
default { [PSCustomObject]@{ Key = $key; ValueType = $valueNode.Name; Value = $valueNode.'#text' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AppConfigMobileAppDocHandler]::new())
|
||||
@@ -0,0 +1,48 @@
|
||||
# Assignment Filter documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3823 (Invoke-CDDocument-
|
||||
# AssignmentFilter, ~35 LOC). Claims @odata.type='#microsoft.graph.deviceAnd
|
||||
# AppManagementAssignmentFilter' and produces a 7-row BasicInfo + 1-row
|
||||
# Settings table (the rule syntax).
|
||||
#
|
||||
# Platform value: app-management platforms (androidMobileApplicationManagement
|
||||
# etc.) resolve to empty strings in Strings-en.json which Get-LanguageString
|
||||
# returns as $null — so BasicInfo emits the row with Value=null, matching the
|
||||
# golden's `"Platform": null` for app filters.
|
||||
|
||||
class AssignmentFilterDocHandler : DocumentationHandlerBase {
|
||||
AssignmentFilterDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceAndAppManagementAssignmentFilter')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# BasicInfo order: Name, Description, Created, Last modified, Profile type, Platform
|
||||
# (Scope tags appended automatically by the engine's post-step.)
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Filters.filters') '@odata.type'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString "Platform.$($obj.platform)") 'platform'
|
||||
|
||||
# Filter scope: devices vs apps. Disambiguates app-management filters, whose
|
||||
# platform row resolves to null (see header note).
|
||||
$mgmtType = switch ($obj.assignmentFilterManagementType) {
|
||||
'devices' { Get-LanguageString 'Titles.devices' }
|
||||
'apps' { Get-LanguageString 'Titles.apps' }
|
||||
default { $obj.assignmentFilterManagementType }
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.managementType') $mgmtType 'assignmentFilterManagementType'
|
||||
|
||||
# Settings: a single Rule syntax row
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'Filters.ruleSyntax'
|
||||
Value = $obj.rule
|
||||
EntityKey = 'rule'
|
||||
Category = Get-LanguageString 'SettingDetails.rules'
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AssignmentFilterDocHandler]::new())
|
||||
@@ -0,0 +1,44 @@
|
||||
# Authentication Context documentation handler.
|
||||
#
|
||||
# Claims @odata.type='#microsoft.graph.authenticationContextClassReference'. Auth
|
||||
# contexts (c1..c99) were previously only referenced by Conditional Access policies
|
||||
# (ID -> displayName, see ConditionalAccessDocHandler); this handler documents the
|
||||
# standalone object: display name, description and whether it is published to apps
|
||||
# (isAvailable).
|
||||
#
|
||||
# Note: AuthenticationContextType strips @odata.type on export (_PropertiesToRemove),
|
||||
# so this handler matches live documentation runs. File-based runs of an exported
|
||||
# auth context lose the discriminator and fall through to NoProvider - a pre-existing
|
||||
# export-cleanup limitation, not addressed here.
|
||||
|
||||
class AuthenticationContextDocHandler : DocumentationHandlerBase {
|
||||
AuthenticationContextDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.authenticationContextClassReference')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# No plain-noun language string exists for the auth-context type, so use the
|
||||
# PolicyType title for the Profile type row.
|
||||
$nameValue = if ($obj.displayName) { $obj.displayName } else { $PolicyObject.Name }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $nameValue 'displayName'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') $PolicyObject.PolicyType.Title '@odata.type'
|
||||
if ($obj.description) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $obj.description 'description'
|
||||
}
|
||||
|
||||
if ($null -ne $obj.isAvailable) {
|
||||
$availKey = if ($obj.isAvailable -eq $true) { 'Inputs.enabled' } else { 'Inputs.disabled' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.AuthContext.InfoBlade.publishLabel')
|
||||
Value = (Get-LanguageString $availKey)
|
||||
Category = $null
|
||||
SubCategory = $null
|
||||
EntityKey = 'isAvailable'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AuthenticationContextDocHandler]::new())
|
||||
@@ -0,0 +1,76 @@
|
||||
# Authentication Strength documentation handler.
|
||||
#
|
||||
# Claims @odata.type='#microsoft.graph.authenticationStrengthPolicy'. Authentication
|
||||
# strengths were previously only referenced as a Conditional Access grant control
|
||||
# (ID -> displayName, see ConditionalAccessDocHandler); this handler documents the
|
||||
# standalone policy object.
|
||||
#
|
||||
# The policy's substance is allowedCombinations: an OR-list of method combinations,
|
||||
# where each combination is an AND-set of authentication methods (comma-joined in
|
||||
# the raw value, e.g. "password,microsoftAuthenticatorPush"). Each method maps to
|
||||
# AzureCA.AuthenticationStrength.Mode.<method>. A few methods (federatedMultiFactor,
|
||||
# federatedSingleFactor) have no Mode string yet, so fall back to a humanised token
|
||||
# rather than emit a raw enum value.
|
||||
|
||||
class AuthenticationStrengthDocHandler : DocumentationHandlerBase {
|
||||
AuthenticationStrengthDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.authenticationStrengthPolicy')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# BasicInfo: Name + Profile type + Description
|
||||
$nameValue = if ($obj.displayName) { $obj.displayName } else { $PolicyObject.Name }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $nameValue 'displayName'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'AzureCA.WhatIfBlade.authenticationStrength') '@odata.type'
|
||||
if ($obj.description) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $obj.description 'description'
|
||||
}
|
||||
|
||||
# allowedCombinations: OR-list of AND-combinations. Render each combination
|
||||
# as "Method A + Method B" on its own line.
|
||||
$comboLines = @()
|
||||
foreach ($combo in @($obj.allowedCombinations)) {
|
||||
if ([string]::IsNullOrWhiteSpace($combo)) { continue }
|
||||
$methodNames = @()
|
||||
foreach ($method in ($combo -split ',')) {
|
||||
$m = $method.Trim()
|
||||
if (-not $m) { continue }
|
||||
$methodNames += (Get-AuthenticationMethodLabel $m)
|
||||
}
|
||||
if ($methodNames.Count -gt 0) {
|
||||
$comboLines += ($methodNames -join ' + ')
|
||||
}
|
||||
}
|
||||
|
||||
if ($comboLines.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.policyControlAuthenticationStrengthDisplayedName')
|
||||
Value = ($comboLines -join $Context.ObjectSeparator)
|
||||
Category = $null
|
||||
SubCategory = $null
|
||||
EntityKey = 'allowedCombinations'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Map an authentication-method enum token to its localised label, falling back to
|
||||
# a humanised form (federatedMultiFactor -> "Federated Multi Factor") for tokens
|
||||
# that have no Mode string. -IgnoreMissing keeps the log clean for known gaps.
|
||||
function Get-AuthenticationMethodLabel {
|
||||
param([string]$Method)
|
||||
|
||||
if ([string]::IsNullOrEmpty($Method)) { return $Method }
|
||||
|
||||
$label = Get-LanguageString "AzureCA.AuthenticationStrength.Mode.$Method" -IgnoreMissing
|
||||
if (-not [string]::IsNullOrEmpty($label)) { return $label }
|
||||
|
||||
# No Mode string: split camelCase into Title-cased words.
|
||||
$spaced = [regex]::Replace($Method, '(?<=[a-z0-9])(?=[A-Z])', ' ')
|
||||
$ci = [System.Globalization.CultureInfo]::InvariantCulture
|
||||
return (($spaced -split ' ' | Where-Object { $_ } | ForEach-Object { $ci.TextInfo.ToTitleCase($_.ToLower()) }) -join ' ')
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([AuthenticationStrengthDocHandler]::new())
|
||||
@@ -0,0 +1,53 @@
|
||||
# Co-Management Settings documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3862. Hardcoded
|
||||
# Platform = Windows 10 (Co-Management is Windows-only).
|
||||
|
||||
class CoManagementDocHandler : DocumentationHandlerBase {
|
||||
CoManagementDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceComanagementAuthorityConfiguration')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') ((Get-LanguageString 'WindowsEnrollment.coManagementAuthorityTitle').Trim()) '@odata.type'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString 'Platform.Windows10') 'platform'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
$yes = Get-LanguageString 'BooleanActions.yes'
|
||||
$no = Get-LanguageString 'SettingDetails.no'
|
||||
|
||||
$installValue = if ($obj.installConfigurationManagerAgent -eq $true) { $yes } else { $no }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'CoManagementAuthority.installAgent')
|
||||
Value = $installValue
|
||||
EntityKey = 'installConfigurationManagerAgent'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($obj.installConfigurationManagerAgent -eq $true) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'CoManagementAuthority.commandLineArgs')
|
||||
Value = $obj.configurationManagerAgentCommandLineArgument
|
||||
EntityKey = 'configurationManagerAgentCommandLineArgument'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
$ownershipValue = if ($obj.managedDeviceAuthority -eq 1) { $yes } else { $no }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'CoManagementAuthority.managedDeviceOwnership')
|
||||
Value = $ownershipValue
|
||||
EntityKey = 'managedDeviceAuthority'
|
||||
Category = $category
|
||||
SubCategory = (Get-LanguageString 'CoManagementAuthority.advancedProperty')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([CoManagementDocHandler]::new())
|
||||
@@ -0,0 +1,73 @@
|
||||
# Custom compliance script documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:4788
|
||||
# (Invoke-CDDocumentDeviceComplianceScript). Claims
|
||||
# @odata.type='#microsoft.graph.deviceComplianceScript'.
|
||||
#
|
||||
# deviceComplianceScript has no ObjectCategories entry, so - like the Scope
|
||||
# Tag handler - basic info rows are emitted manually instead of via
|
||||
# Add-BasicDefaultValues (which would add blank Platform/Profile rows).
|
||||
|
||||
class ComplianceScriptDocHandler : DocumentationHandlerBase {
|
||||
ComplianceScriptDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceComplianceScript')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
if ($PolicyObject.Name) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $PolicyObject.Name 'displayName'
|
||||
}
|
||||
$descValue = if ($obj.description) { $obj.description } else { '' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $descValue 'description'
|
||||
if ($obj.publisher) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.publisher') $obj.publisher 'publisher'
|
||||
}
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Titles.complianceScriptManagementPreview') 'configurationType'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
$valueYes = Get-LanguageString 'BooleanActions.yes'
|
||||
$valueNo = Get-LanguageString 'SettingDetails.no'
|
||||
|
||||
if ($obj.detectionScriptContent -and -not ($Context.Options -and $Context.Options['IncludeScripts'] -eq $false)) {
|
||||
$scriptBody = ''
|
||||
try { $scriptBody = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($obj.detectionScriptContent)) } catch { }
|
||||
if ($scriptBody) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'ProactiveRemediations.Create.Settings.DetectionScriptMultiLineTextBox.label'
|
||||
Value = $scriptBody
|
||||
EntityKey = 'detectionScriptContent'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'WindowsManagement.scriptContextLabel'
|
||||
Value = $(if ($obj.runAsAccount -eq 'system') { $valueNo } else { $valueYes })
|
||||
EntityKey = 'runAsAccount'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'WindowsManagement.enforceSignatureCheckLabel'
|
||||
Value = $(if ($obj.enforceSignatureCheck -eq $false) { $valueNo } else { $valueYes })
|
||||
EntityKey = 'enforceSignatureCheck'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'WindowsManagement.runAs64BitLabel'
|
||||
Value = $(if ($obj.runAs32Bit -eq $true) { $valueNo } else { $valueYes })
|
||||
EntityKey = 'runAs32Bit'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([ComplianceScriptDocHandler]::new())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
# Custom OMA-URI documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3519. Emits Name +
|
||||
# Platform basic info, then 4-5 rows per OMA-URI setting (Name, Description,
|
||||
# OMA-URI path, Data type, Value). Encrypted values are skipped offline;
|
||||
# live runs fetch via /deviceConfigurations/.../getOmaSettingPlainTextValue.
|
||||
#
|
||||
# Claims all 4 CustomConfiguration variants in one handler.
|
||||
|
||||
class CustomOMAUriDocHandler : DocumentationHandlerBase {
|
||||
CustomOMAUriDocHandler() {
|
||||
$this.ODataTypes = @(
|
||||
'#microsoft.graph.windows10CustomConfiguration',
|
||||
'#microsoft.graph.androidForWorkCustomConfiguration',
|
||||
'#microsoft.graph.androidWorkProfileCustomConfiguration',
|
||||
'#microsoft.graph.androidCustomConfiguration'
|
||||
)
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
# Note: old code at L3534 has the configurationType BasicPropertyValue
|
||||
# commented out. Faithful port — skipping.
|
||||
|
||||
$platformId = Get-ObjectPlatformFromType $obj
|
||||
if ($platformId) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString "Platform.$platformId") 'platform'
|
||||
}
|
||||
|
||||
$category = Get-LanguageString 'SettingDetails.customPolicyOMAURISettingsName'
|
||||
|
||||
$typeLabelMap = @{
|
||||
'#microsoft.graph.omaSettingString' = 'SettingDetails.stringName'
|
||||
'#microsoft.graph.omaSettingBase64' = 'SettingDetails.base64Name'
|
||||
'#microsoft.graph.omaSettingBoolean' = 'SettingDetails.booleanName'
|
||||
'#microsoft.graph.omaSettingDateTime' = 'SettingDetails.dateTimeName'
|
||||
'#microsoft.graph.omaSettingFloatingPoint' = 'SettingDetails.floatingPointName'
|
||||
'#microsoft.graph.omaSettingInteger' = 'SettingDetails.integerName'
|
||||
'#microsoft.graph.omaSettingStringXml' = 'SettingDetails.stringXMLName'
|
||||
}
|
||||
|
||||
foreach ($setting in $obj.omaSettings) {
|
||||
$sub = $setting.displayName
|
||||
$oma = $setting.omaUri
|
||||
$type = if ($setting.PSObject.Properties['@OData.Type']) { $setting.'@OData.Type' } else { $setting.'@odata.type' }
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.nameName')
|
||||
Value = $setting.displayName
|
||||
EntityKey = "displayName_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TableHeaders.description')
|
||||
Value = $setting.description
|
||||
EntityKey = "description_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.oMAURIName')
|
||||
Value = $oma
|
||||
EntityKey = "omaUri_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
|
||||
$typeKey = $typeLabelMap[$type]
|
||||
if ($typeKey) {
|
||||
$typeValue = Get-LanguageString $typeKey
|
||||
if ($typeValue) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.dataTypeName')
|
||||
Value = $typeValue
|
||||
EntityKey = "type_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
# Value row — skip when encrypted unless we can resolve via Graph
|
||||
if ($setting.isEncrypted -ne $true) {
|
||||
$value = $setting.value
|
||||
if ($type -eq '#microsoft.graph.omaSettingStringXml' -and $value) {
|
||||
try { $value = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($value)) } catch { }
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.valueName')
|
||||
Value = $value
|
||||
EntityKey = "value_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
elseif (-not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable) -and $setting.secretReferenceValueId) {
|
||||
try {
|
||||
$url = "/deviceManagement/deviceConfigurations/$($obj.id)/getOmaSettingPlainTextValue(secretReferenceValueId='$($setting.secretReferenceValueId)')"
|
||||
$resp = Invoke-MSGraphAPI -Url $url
|
||||
if ($resp.Value) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.valueName')
|
||||
Value = $resp.Value
|
||||
EntityKey = "value_$oma"
|
||||
Category = $category; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
}
|
||||
catch { Write-LogError "Failed to resolve encrypted OMA-URI value for $oma" $_.Exception }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([CustomOMAUriDocHandler]::new())
|
||||
@@ -0,0 +1,24 @@
|
||||
# Device Category documentation handler.
|
||||
#
|
||||
# Claims @odata.type='#microsoft.graph.deviceCategory'. Device categories are
|
||||
# name + description only; there is no ObjectCategories entry (so no
|
||||
# Add-BasicDefaultValues - it would emit blank Platform/Profile rows) and no
|
||||
# old-project documenter to port.
|
||||
|
||||
class DeviceCategoryDocHandler : DocumentationHandlerBase {
|
||||
DeviceCategoryDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceCategory')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
if ($PolicyObject.Name) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $PolicyObject.Name 'displayName'
|
||||
}
|
||||
$descValue = if ($obj.description) { $obj.description } else { '' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $descValue 'description'
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([DeviceCategoryDocHandler]::new())
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Device Enrollment Platform Restriction documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:4345. Claims the two
|
||||
# platform-restriction @odata.types:
|
||||
# ...deviceEnrollmentPlatformRestrictionConfiguration (single platform)
|
||||
# ...deviceEnrollmentPlatformRestrictionsConfiguration (all platforms — the
|
||||
# aggregate that emits
|
||||
# one row block per
|
||||
# platform sub-restriction)
|
||||
#
|
||||
# Doesn't handle deviceEnrollmentLimitConfiguration — that has a different
|
||||
# shape (single "Device limit" setting) and lives behind the generic Profile
|
||||
# input provider in the old engine.
|
||||
|
||||
class EnrollmentPlatformRestrictionDocHandler : DocumentationHandlerBase {
|
||||
EnrollmentPlatformRestrictionDocHandler() {
|
||||
$this.ODataTypes = @(
|
||||
'#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
|
||||
'#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration'
|
||||
)
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Titles.deviceTypeEnrollmentRestrictions') '@odata.type'
|
||||
|
||||
# platformType (single variant) -> Platform.* language id
|
||||
$singlePlatformLngId = switch ($obj.platformType) {
|
||||
'androidForWork' { 'androidWorkProfile' }
|
||||
'mac' { 'macOS' }
|
||||
'ios' { 'iOS' }
|
||||
'android' { 'android' }
|
||||
'windows' { 'windows' }
|
||||
'tvos' { 'tvOS' }
|
||||
'visionOS' { 'visionOS' }
|
||||
default { $obj.platformType }
|
||||
}
|
||||
|
||||
$isAggregate = $obj.'@odata.type' -eq '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration'
|
||||
if ($isAggregate) {
|
||||
# The default "All users and all devices" config carries one sub-restriction
|
||||
# per platform. Graph exposes 10, including the legacy macRestriction (a dupe
|
||||
# of the version-capable macOSRestriction) and the deprecated
|
||||
# windowsMobileRestriction. Render the current platforms; prefer
|
||||
# macOSRestriction over macRestriction. $platformMap: property -> name key.
|
||||
$platform = Get-LanguageString 'AzureCA.classicPolicyAllPlatforms'
|
||||
$platformMap = [ordered]@{
|
||||
'androidForWorkRestriction' = 'Platform.androidWorkProfile'
|
||||
'androidRestriction' = 'Platform.android'
|
||||
'iosRestriction' = 'Platform.iOS'
|
||||
'macOSRestriction' = 'Platform.macOS'
|
||||
'tvosRestriction' = 'Platform.tvOS'
|
||||
'visionOSRestriction' = 'Platform.visionOS'
|
||||
'windowsRestriction' = 'Platform.windows'
|
||||
'windowsHomeSkuRestriction' = 'Devices.windowsHomeSku'
|
||||
}
|
||||
}
|
||||
else {
|
||||
$platform = Get-LanguageString "Platform.$singlePlatformLngId"
|
||||
$platformMap = [ordered]@{ 'platformRestriction' = "Platform.$singlePlatformLngId" }
|
||||
}
|
||||
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') $platform 'platformType'
|
||||
|
||||
$allowStr = Get-LanguageString 'BooleanActions.allow'
|
||||
$blockStr = Get-LanguageString 'BooleanActions.block'
|
||||
$category = Get-LanguageString 'EnrollmentRestrictions.DeviceType.platformSettings'
|
||||
$cantRestrictStr = Get-LanguageString 'EnrollmentRestrictions.DeviceType.cannotRestrict'
|
||||
|
||||
foreach ($prop in $platformMap.Keys) {
|
||||
$restrict = $obj.$prop
|
||||
if (-not $restrict) { continue }
|
||||
|
||||
$nameKey = $platformMap[$prop]
|
||||
$typeStr = Get-LanguageString $nameKey
|
||||
|
||||
# OS version range, blank when unset. macOSRestriction is version-capable,
|
||||
# so (unlike the legacy macRestriction the old handler forced to "cannot
|
||||
# restrict") every platform now reports its actual osMin/osMax range.
|
||||
$version = if ($restrict.osMinimumVersion -or $restrict.osMaximumVersion) {
|
||||
"$($restrict.osMinimumVersion)-$($restrict.osMaximumVersion)"
|
||||
} else { '' }
|
||||
|
||||
# Manufacturer blocking: old code has a typo (`'andriod'` instead of
|
||||
# `'android'`) which means only 'androidWorkProfile' actually emits
|
||||
# the blockedManufacturers list. Everything else — including the
|
||||
# correctly-spelled 'android' (device administrator) — falls
|
||||
# through to "Restriction not supported". Preserving the behavior
|
||||
# because golden fixtures match it; the typo is a known wart in
|
||||
# old code that fixing would silently change output.
|
||||
$blockedManufacturers = if ($nameKey -eq 'Platform.androidWorkProfile') {
|
||||
@($restrict.blockedManufacturers) -join $Context.PropertySeparator
|
||||
} else {
|
||||
$cantRestrictStr
|
||||
}
|
||||
|
||||
# Aggregate variant uses platform name as SubCategory; single variant uses $null
|
||||
$subCategory = if ($isAggregate) { $typeStr } else { $null }
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.type')
|
||||
Value = $typeStr
|
||||
EntityKey = 'platformType'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
|
||||
$platformAccess = if ($restrict.platformBlocked) { $blockStr } else { $allowStr }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.platform')
|
||||
Value = $platformAccess
|
||||
EntityKey = 'platformBlocked'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.versions')
|
||||
Value = $version
|
||||
EntityKey = 'versions'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
|
||||
$personalAccess = if ($restrict.personalDeviceEnrollmentBlocked) { $blockStr } else { $allowStr }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.personal')
|
||||
Value = $personalAccess
|
||||
EntityKey = 'personalDeviceEnrollmentBlocked'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'EnrollmentRestrictions.DeviceType.deviceManufacturer')
|
||||
Value = $blockedManufacturers
|
||||
EntityKey = 'blockedManufacturers'
|
||||
Category = $category; SubCategory = $subCategory
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([EnrollmentPlatformRestrictionDocHandler]::new())
|
||||
@@ -0,0 +1,206 @@
|
||||
# Managed App Configuration documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:2196. Claims
|
||||
# @odata.type='#microsoft.graph.targetedManagedAppConfiguration'.
|
||||
#
|
||||
# Outlook + Edge ObjectInfo translations (and the Edge bookmark/AllowList/
|
||||
# BlockList delimiter rewrites) deferred until the walker is ported. Offline
|
||||
# falls through to raw customSettings under TACSettings.generalSettings.
|
||||
|
||||
class ManagedAppConfigDocHandler : DocumentationHandlerBase {
|
||||
ManagedAppConfigDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.targetedManagedAppConfiguration')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'SettingDetails.appConfiguration') '@odata.type'
|
||||
|
||||
$customApps, $publishedApps = Get-CDMobileApps $obj.Apps
|
||||
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.enrollmentTypeLabel') (Get-LanguageString 'EnrollmentType.devicesWithoutEnrollment') 'enrollmentType'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.publicApps') ($publishedApps -join $Context.ObjectSeparator) 'publishedApps'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.customApps') ($customApps -join $Context.ObjectSeparator) 'customApps'
|
||||
|
||||
# appGroupType - "Target policy to". The all* variants don't list individual
|
||||
# apps, so surfacing the mode is what tells the reader the scope. Graph's
|
||||
# enum member is allCoreMicrosoftApps; the portal's string is coreMicrosoftApps.
|
||||
$appGroupTypeKeys = @{
|
||||
'selectedPublicApps' = 'AppGroupType.selectedPublicApps'
|
||||
'allApps' = 'AppGroupType.allApps'
|
||||
'allMicrosoftApps' = 'AppGroupType.allMicrosoftApps'
|
||||
'allCoreMicrosoftApps' = 'AppGroupType.coreMicrosoftApps'
|
||||
}
|
||||
$agtRaw = "$($obj.appGroupType)"
|
||||
if ($agtRaw) {
|
||||
$agtKey = $appGroupTypeKeys[$agtRaw]
|
||||
$agtValue = if ($agtKey) { Get-LanguageString $agtKey -IgnoreMissing } else { $null }
|
||||
if ([string]::IsNullOrEmpty($agtValue)) { $agtValue = $agtRaw }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.targetPolicyTo') $agtValue 'appGroupType'
|
||||
}
|
||||
|
||||
# targetedAppManagementLevels - flags enum, returned as a comma-separated
|
||||
# string (e.g. "mdm, androidEnterprise"). Map each flag to its label,
|
||||
# falling back to the raw flag value when no string exists.
|
||||
$mgmtLevelKeys = @{
|
||||
'unspecified' = 'AppProtection.allAppTypes'
|
||||
'unmanaged' = 'AppProtection.appsOnUnmanagedDevices'
|
||||
'mdm' = 'AppProtection.appsOnIntuneManagedDevices'
|
||||
'androidEnterprise' = 'AppProtection.appsInAndroidWorkProfile'
|
||||
'androidEnterpriseDedicatedDevicesWithAzureAdSharedMode' = 'AppProtection.appsOnAndroidEnterpriseDedicatedDevicesWithAzureAdSharedMode'
|
||||
'androidOpenSourceProjectUserAssociated' = 'AppProtection.appsOnAndroidOpenSourceProjectUserAssociated'
|
||||
'androidOpenSourceProjectUserless' = 'AppProtection.appsOnAndroidOpenSourceProjectUserless'
|
||||
}
|
||||
$mgmtRaw = "$($obj.targetedAppManagementLevels)"
|
||||
if ($mgmtRaw) {
|
||||
$mgmtParts = @()
|
||||
foreach ($lvl in ($mgmtRaw -split ',')) {
|
||||
$lvlTrim = $lvl.Trim()
|
||||
if (-not $lvlTrim) { continue }
|
||||
$lvlKey = $mgmtLevelKeys[$lvlTrim]
|
||||
$lvlValue = if ($lvlKey) { Get-LanguageString $lvlKey -IgnoreMissing } else { $null }
|
||||
if ([string]::IsNullOrEmpty($lvlValue)) { $lvlValue = $lvlTrim }
|
||||
$mgmtParts += $lvlValue
|
||||
}
|
||||
if ($mgmtParts.Count -gt 0) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.managementType') ($mgmtParts -join $Context.ObjectSeparator) 'targetedAppManagementLevels'
|
||||
}
|
||||
}
|
||||
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
|
||||
# Outlook / Edge get schema-driven translation via their ObjectInfo files
|
||||
# (port of old DocumentationCustom.psm1:2229-2260). Build a flat settings
|
||||
# object keyed by customSetting name, then walk the matching manifest.
|
||||
$appSettings = [PSCustomObject]@{}
|
||||
foreach ($setting in @($obj.customSettings)) {
|
||||
$appSettings | Add-Member -MemberType NoteProperty -Name $setting.name -Value $setting.value -Force
|
||||
}
|
||||
$objInfoDir = Join-Path $script:AppRootFolder 'Config\ObjectInfo'
|
||||
|
||||
# Unpack every packed/delimited value BEFORE the manifests read them, so the
|
||||
# rewrite also benefits the raw fall-through rows below.
|
||||
# NB Where-Object: on a property-less object .PSObject.Properties.Name is
|
||||
# $null, and @($null) is a one-element array containing $null
|
||||
foreach ($name in @($appSettings.PSObject.Properties.Name | Where-Object { $_ })) {
|
||||
$sep = $script:_mamPackedSettingSeparators[$name]
|
||||
if (-not $sep -or -not $appSettings.$name -or $appSettings.$name -isnot [string]) { continue }
|
||||
$unpacked = $appSettings.$name
|
||||
# Record separator first - doing it the other way round destroys it
|
||||
if ($sep.RecordSep) { $unpacked = $unpacked.Replace($sep.RecordSep, $Context.ObjectSeparator) }
|
||||
if ($sep.FieldSep) { $unpacked = $unpacked.Replace($sep.FieldSep, $Context.PropertySeparator) }
|
||||
$appSettings.$name = $unpacked
|
||||
}
|
||||
|
||||
# App identities differ per platform: Outlook/Edge are packageId on Android,
|
||||
# bundleId on iOS and windowsAppId on Windows. Matching only one of them
|
||||
# silently skipped the whole manifest for the other platforms.
|
||||
if (Test-MamAppTargeted $obj.Apps @('com.microsoft.office.outlook')) {
|
||||
Invoke-DocAppConfigManifest $appSettings (Join-Path $objInfoDir '#AppConfigOutlookApp.json') $Context
|
||||
}
|
||||
if (Test-MamAppTargeted $obj.Apps @('com.microsoft.msedge', 'com.microsoft.emmx', 'com.microsoft.edge')) {
|
||||
Invoke-DocAppConfigManifest $appSettings (Join-Path $objInfoDir '#AppConfigEdgeApp.json') $Context
|
||||
}
|
||||
|
||||
# Settings-catalog settings (the "Settings catalog" wizard step, used by the
|
||||
# Windows MAM flavour). Without this a policy whose entire payload lives in
|
||||
# `settings` documented as a header and nothing else.
|
||||
Invoke-DocMamSettingsCatalog $obj $Context
|
||||
|
||||
# Remaining customSettings fall through to raw key=value rows.
|
||||
$addedSettings = Get-DocumentedSettings
|
||||
$category = Get-LanguageString 'TACSettings.generalSettings'
|
||||
|
||||
foreach ($setting in $obj.customSettings) {
|
||||
if ($addedSettings | Where-Object EntityKey -EQ $setting.name) { continue }
|
||||
# Use the unpacked value when one was produced above
|
||||
$value = if ($null -ne $appSettings.PSObject.Properties[$setting.name]) { $appSettings."$($setting.name)" } else { $setting.value }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $setting.name
|
||||
Value = $value
|
||||
EntityKey = $setting.name
|
||||
Category = $category
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Document the settings-catalog part of a MAM app configuration.
|
||||
#
|
||||
# The Managed apps wizard has a "Settings catalog" step whose values land in the
|
||||
# `settings` navigation property (Collection(deviceManagementConfigurationSetting))
|
||||
# rather than in customSettings - Windows MAM policies are entirely settings-catalog.
|
||||
# The portal renders it as its own blade ABOVE the classic Settings blade, so these
|
||||
# rows go into a table of their own (negative Order = before the settings table)
|
||||
# instead of being merged into it.
|
||||
#
|
||||
# Resolution is the SettingsCatalog provider's own code - see
|
||||
# Get-SettingsCatalogDocumentationRows. This used to be a copy of it that had
|
||||
# drifted, losing the category grouping.
|
||||
function Invoke-DocMamSettingsCatalog {
|
||||
param($Obj, [DocumentationContext]$Context)
|
||||
|
||||
$cfgSettings = @($Obj.settings)
|
||||
|
||||
$hasDefs = $false
|
||||
foreach ($s in $cfgSettings) {
|
||||
if ($s.settingDefinitions -and ($s.settingDefinitions | Measure-Object).Count -gt 0) { $hasDefs = $true; break }
|
||||
}
|
||||
|
||||
# Source-tenant-specific fetch (by policy id) - same gating as the Settings
|
||||
# Catalog provider. Exports carrying settings inline still work offline via the
|
||||
# walker's generic per-setting definition fallback.
|
||||
if (-not $hasDefs -and $Obj.Id -and -not $Context.SourceTenantUnavailable -and (Test-DocumentationGraphAvailable)) {
|
||||
try {
|
||||
$resp = Invoke-MSGraphAPI -Url "/deviceAppManagement/targetedManagedAppConfigurations('$($Obj.Id)')/settings?`$expand=settingDefinitions&`$top=1000" -AdditionalHeaders (Get-DocAcceptLanguageHeaders $Context) -ODataMetadata 'minimal' -NoError
|
||||
if ($resp -and $resp.Value) { $cfgSettings = @($resp.Value) }
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to fetch settings catalog settings for app configuration $($Obj.Id)" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
if (@($cfgSettings).Count -eq 0) { return }
|
||||
|
||||
$rows = @(Get-SettingsCatalogDocumentationRows $cfgSettings $Context)
|
||||
if ($rows.Count -eq 0) { return }
|
||||
|
||||
Add-CustomTable 'SettingsCatalog' @('Name','Value') $rows -Order -100 -LanguageId 'SettingDetails.settingsCatalog'
|
||||
}
|
||||
|
||||
# MAM settings whose value packs multiple records/fields into one string.
|
||||
# RecordSep splits repeated records, FieldSep splits fields inside a record.
|
||||
$script:_mamPackedSettingSeparators = @{
|
||||
# title|url pairs, records separated by ||
|
||||
'com.microsoft.intune.mam.managedbrowser.bookmarks' = @{ RecordSep = '||'; FieldSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.managedTopSites' = @{ RecordSep = '||'; FieldSep = '|' }
|
||||
# plain pipe-separated lists
|
||||
'com.microsoft.intune.mam.managedbrowser.AllowListURLs' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.BlockListURLs' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.disabledFeatures' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.InternalPagesBlockList' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.PopupsAllowedForUrls' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.PopupsBlockedForUrls' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.FileUploadAllowedForUrls' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.FileUploadBlockedForUrls' = @{ RecordSep = '|' }
|
||||
'com.microsoft.intune.mam.managedbrowser.NewTabPageLayout.Custom' = @{ RecordSep = '|' }
|
||||
}
|
||||
|
||||
# True when any targeted app matches one of the given app identifiers on ANY
|
||||
# platform identity (Android packageId / iOS bundleId / Windows windowsAppId).
|
||||
function Test-MamAppTargeted {
|
||||
param($Apps, [string[]]$Identifiers)
|
||||
|
||||
foreach ($app in @($Apps)) {
|
||||
$id = $app.mobileAppIdentifier
|
||||
if (-not $id) { continue }
|
||||
foreach ($candidate in @($id.packageId, $id.bundleId, $id.windowsAppId)) {
|
||||
if ($candidate -and $candidate -in $Identifiers) { return $true }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([ManagedAppConfigDocHandler]::new())
|
||||
@@ -0,0 +1,89 @@
|
||||
# Named Location documentation handler (Country + IP variants).
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:2280 (country) + 2323 (IP).
|
||||
# Single class claims both @odata.types since they share the same BasicInfo
|
||||
# header shape and one varies only the settings.
|
||||
|
||||
class NamedLocationDocHandler : DocumentationHandlerBase {
|
||||
NamedLocationDocHandler() {
|
||||
$this.ODataTypes = @(
|
||||
'#microsoft.graph.countryNamedLocation',
|
||||
'#microsoft.graph.ipNamedLocation',
|
||||
'#microsoft.graph.compliantNetworkNamedLocation'
|
||||
)
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'AzureCA.menuItemNamedNetworks') '@odata.type'
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
switch ($obj.'@odata.type') {
|
||||
'#microsoft.graph.countryNamedLocation' { Invoke-NamedLocationCountrySettings $obj $Context }
|
||||
'#microsoft.graph.ipNamedLocation' { Invoke-NamedLocationIPSettings $obj $Context }
|
||||
'#microsoft.graph.compliantNetworkNamedLocation' { Invoke-NamedLocationCompliantNetworkSettings $obj $Context }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-NamedLocationCountrySettings {
|
||||
param($obj, [DocumentationContext]$Context)
|
||||
|
||||
$lookupSuffix = if ($obj.countryLookupMethod -eq 'clientIpAddress') { 'ip' } else { 'gps' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Form.CountryLookup.ariaLabel')
|
||||
Value = (Get-LanguageString "AzureCA.NamedLocation.Form.CountryLookup.$lookupSuffix")
|
||||
EntityKey = 'countryLookupMethod'
|
||||
})
|
||||
|
||||
$includeKey = if ($obj.includeUnknownCountriesAndRegions -eq $true) { 'Inputs.enabled' } else { 'Inputs.disabled' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Form.Include.label')
|
||||
Value = (Get-LanguageString $includeKey)
|
||||
EntityKey = 'includeUnknownCountriesAndRegions'
|
||||
})
|
||||
|
||||
$countryNames = @()
|
||||
foreach ($code in $obj.countriesAndRegions) {
|
||||
$countryNames += Get-LanguageString "AzureIAMCommon.CountryNames.countryName$($code.ToUpper())"
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Type.countries')
|
||||
Value = ($countryNames -join $Context.ObjectSeparator)
|
||||
EntityKey = 'countriesAndRegions'
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-NamedLocationIPSettings {
|
||||
param($obj, [DocumentationContext]$Context)
|
||||
|
||||
$trustedKey = if ($obj.isTrusted -eq $true) { 'Inputs.enabled' } else { 'Inputs.disabled' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Form.Trusted.label')
|
||||
Value = (Get-LanguageString $trustedKey)
|
||||
EntityKey = 'isTrusted'
|
||||
})
|
||||
|
||||
$ipList = @()
|
||||
foreach ($range in $obj.ipRanges) { $ipList += $range.cidrAddress }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.namedNetworkIpRangesTab')
|
||||
Value = ($ipList -join $Context.ObjectSeparator)
|
||||
EntityKey = 'ipRanges'
|
||||
})
|
||||
}
|
||||
|
||||
function Invoke-NamedLocationCompliantNetworkSettings {
|
||||
param($obj, [DocumentationContext]$Context)
|
||||
|
||||
# Built-in read-only location; its only meaningful setting is the trusted flag.
|
||||
$trustedKey = if ($obj.isTrusted -eq $true) { 'Inputs.enabled' } else { 'Inputs.disabled' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'AzureCA.NamedLocation.Form.Trusted.label')
|
||||
Value = (Get-LanguageString $trustedKey)
|
||||
EntityKey = 'isTrusted'
|
||||
})
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([NamedLocationDocHandler]::new())
|
||||
@@ -0,0 +1,95 @@
|
||||
# Notification message template documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3660. Emits 4 branding-
|
||||
# option rows ("Show company logo/name/contact/portal link" enable/disable)
|
||||
# plus one row per localized message template with the locale name as label
|
||||
# and the subject+body as value.
|
||||
|
||||
class NotificationDocHandler : DocumentationHandlerBase {
|
||||
NotificationDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.notificationMessageTemplate')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Titles.notifications') '@odata.type'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
|
||||
# brandingOptions is a comma-separated string like "includeCompanyLogo,includeCompanyName"
|
||||
# or "none". Split into a hash for membership tests.
|
||||
$brandingFlags = @{}
|
||||
if ($obj.brandingOptions) {
|
||||
foreach ($flag in $obj.brandingOptions.Split(',')) {
|
||||
$brandingFlags[$flag.Trim()] = $true
|
||||
}
|
||||
}
|
||||
|
||||
$brandingLabelMap = [ordered]@{
|
||||
'includeCompanyLogo' = 'NotificationMessage.companyLogo'
|
||||
'includeCompanyName' = 'NotificationMessage.companyName'
|
||||
'includeContactInformation' = 'NotificationMessage.companyContact'
|
||||
'includeCompanyPortalLink' = 'NotificationMessage.iwLink'
|
||||
'includeDeviceDetails' = 'NotificationMessage.deviceDetails'
|
||||
}
|
||||
|
||||
foreach ($flag in $brandingLabelMap.Keys) {
|
||||
$valueKey = if ($brandingFlags.ContainsKey($flag)) { 'BooleanActions.enable' } else { 'BooleanActions.disable' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString $brandingLabelMap[$flag])
|
||||
Value = (Get-LanguageString $valueKey)
|
||||
EntityKey = $flag
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
# Localized message templates
|
||||
$subCategory = Get-LanguageString 'NotificationMessage.listTitle'
|
||||
foreach ($template in $obj.localizedNotificationMessages) {
|
||||
$label = Get-NotificationLocaleLabel $template.locale
|
||||
if (-not $label) { continue }
|
||||
|
||||
$value = $template.subject
|
||||
if ($template.isDefault) {
|
||||
$value = $value + $Context.ObjectSeparator + (Get-LanguageString 'NotificationMessage.isDefaultLocale') + ': ' + (Get-LanguageString 'SettingDetails.trueOption')
|
||||
}
|
||||
$fullValue = $value + $Context.ObjectSeparator + $template.messageTemplate
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $label
|
||||
Value = $fullValue
|
||||
EntityKey = $template.locale
|
||||
Category = $category
|
||||
SubCategory = $subCategory
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Localized message templates are labeled by language name. Most languages
|
||||
# pass through [cultureinfo].EnglishName.ToLower(); a handful with regional
|
||||
# splits (en-US/UK, es-ES/MX, fr-CA/FR, pt-PT/BR, zh-TW/CN, nb-* -> norwegian)
|
||||
# get a suffix. Old code at DocumentationCustom.psm1:3735-3796.
|
||||
function Get-NotificationLocaleLabel {
|
||||
param([string]$Locale)
|
||||
if (-not $Locale) { return $null }
|
||||
|
||||
$first, $second = $Locale.Split('-')
|
||||
try { $lng = ([cultureinfo]$first).EnglishName.ToLower() } catch { return $null }
|
||||
|
||||
switch ($first) {
|
||||
'en' { switch ($second) { 'US' { $lng += 'US' }; 'GB' { $lng += 'UK' } } }
|
||||
'es' { switch ($second) { 'es' { $lng += 'Spain' }; 'mx' { $lng += 'Mexico' } } }
|
||||
'fr' { switch ($second) { 'ca' { $lng += 'Canada' }; 'fr' { $lng += 'France' } } }
|
||||
'pt' { switch ($second) { 'pt' { $lng += 'Portugal' }; 'br' { $lng += 'Brazil' } } }
|
||||
'zh' { switch ($second) { 'tw' { $lng += 'Traditional' }; 'cn' { $lng += 'Simplified' } } }
|
||||
'nb' { $lng = 'norwegian' }
|
||||
}
|
||||
|
||||
return (Get-LanguageString "NotificationMessage.NotificationMessageTemplatesTab.$lng")
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([NotificationDocHandler]::new())
|
||||
@@ -0,0 +1,100 @@
|
||||
# Policy Set documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3422. Categorizes the
|
||||
# policy-set items (apps / device configs / enrollment) into 3 sections, then
|
||||
# emits one row per item with the item's displayName and a type-specific
|
||||
# value (priority number for ordered items, AAD/AD for autopilot, etc.).
|
||||
|
||||
class PolicySetDocHandler : DocumentationHandlerBase {
|
||||
PolicySetDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.policySet')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'SettingDetails.appConfiguration') '@odata.type'
|
||||
|
||||
$sections = @(
|
||||
[PSCustomObject]@{
|
||||
Category = (Get-LanguageString 'PolicySet.appManagement')
|
||||
Types = @(
|
||||
@{ ODataType = '#microsoft.graph.mobileAppPolicySetItem'; SubKey = 'appTitle' }
|
||||
@{ ODataType = '#microsoft.graph.targetedManagedAppConfigurationPolicySetItem'; SubKey = 'appConfigurationTitle' }
|
||||
@{ ODataType = '#microsoft.graph.managedDeviceMobileAppConfigurationPolicySetItem'; SubKey = 'appConfigurationTitle' }
|
||||
@{ ODataType = '#microsoft.graph.managedAppProtectionPolicySetItem'; SubKey = 'appProtectionTitle' }
|
||||
@{ ODataType = '#microsoft.graph.iosLobAppProvisioningConfigurationPolicySetItem'; SubKey = 'iOSAppProvisioningTitle' }
|
||||
)
|
||||
}
|
||||
[PSCustomObject]@{
|
||||
Category = (Get-LanguageString 'PolicySet.deviceManagement')
|
||||
Types = @(
|
||||
@{ ODataType = '#microsoft.graph.deviceConfigurationPolicySetItem'; SubKey = 'deviceConfigurationTitle' }
|
||||
@{ ODataType = '#microsoft.graph.deviceManagementConfigurationPolicyPolicySetItem'; SubKey = 'SettingDetails.settingsCatalog' }
|
||||
@{ ODataType = '#microsoft.graph.deviceCompliancePolicyPolicySetItem'; SubKey = 'deviceComplianceTitle' }
|
||||
@{ ODataType = '#microsoft.graph.deviceManagementScriptPolicySetItem'; SubKey = 'powershellScriptTitle' }
|
||||
)
|
||||
}
|
||||
[PSCustomObject]@{
|
||||
Category = (Get-LanguageString 'PolicySet.deviceEnrollment')
|
||||
Types = @(
|
||||
@{ ODataType = '#microsoft.graph.enrollmentRestrictionsConfigurationPolicySetItem'; SubKey = 'deviceTypeRestrictionTitle' }
|
||||
@{ ODataType = '#microsoft.graph.windowsAutopilotDeploymentProfilePolicySetItem'; SubKey = 'windowsAutopilotDeploymentProfileTitle' }
|
||||
@{ ODataType = '#microsoft.graph.windows10EnrollmentCompletionPageConfigurationPolicySetItem'; SubKey = 'enrollmentStatusSettingTitle' }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($section in $sections) {
|
||||
foreach ($subType in $section.Types) {
|
||||
foreach ($item in ($obj.items | Where-Object { $_.'@OData.Type' -eq $subType.ODataType -or $_.'@odata.type' -eq $subType.ODataType })) {
|
||||
if ($item.status -eq 'error') {
|
||||
Write-Log "Skipping missing $($subType.ODataType) type with id $($item.id). Error code: $($item.errorCode)" 2
|
||||
continue
|
||||
}
|
||||
|
||||
# SubKey is a bare key under PolicySet.* unless it already
|
||||
# carries a namespace (dotted), letting new item types reuse
|
||||
# strings that live outside the PolicySet section.
|
||||
$subKeyFull = if ($subType.SubKey -like '*.*') { $subType.SubKey } else { "PolicySet.$($subType.SubKey)" }
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $item.displayName
|
||||
Value = (Get-PolicySetItemValue $item)
|
||||
EntityKey = $item.id
|
||||
Category = $section.Category
|
||||
SubCategory = (Get-LanguageString $subKeyFull)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PolicySetItemValue {
|
||||
param($item)
|
||||
|
||||
$odata = if ($item.PSObject.Properties['@OData.Type']) { $item.'@OData.Type' } else { $item.'@odata.type' }
|
||||
|
||||
if ($odata -in @(
|
||||
'#microsoft.graph.enrollmentRestrictionsConfigurationPolicySetItem',
|
||||
'#microsoft.graph.windows10EnrollmentCompletionPageConfigurationPolicySetItem'
|
||||
)) {
|
||||
return $item.Priority
|
||||
}
|
||||
|
||||
if ($odata -eq '#microsoft.graph.windowsAutopilotDeploymentProfilePolicySetItem') {
|
||||
if ($item.itemType -eq '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile') {
|
||||
return (Get-LanguageString 'Autopilot.DirectoryService.azureAD')
|
||||
}
|
||||
if ($item.itemType -eq '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile') {
|
||||
return (Get-LanguageString 'Autopilot.DirectoryService.activeDirectoryAD')
|
||||
}
|
||||
}
|
||||
|
||||
# TODO phase-4-followup: other PolicySet item types as fixtures arrive
|
||||
return $null
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([PolicySetDocHandler]::new())
|
||||
@@ -0,0 +1,68 @@
|
||||
# Reusable setting (deviceManagementReusablePolicySetting) documentation
|
||||
# handler.
|
||||
#
|
||||
# Claims @odata.type='#microsoft.graph.deviceManagementReusablePolicySetting'.
|
||||
# Covers the reusable settings surfaced as policy types (currently the Linux
|
||||
# custom-compliance discovery script). The object is a name + description +
|
||||
# settingDefinitionId wrapper around a single settings-catalog setting
|
||||
# instance whose simpleSettingValue carries the payload (base64 script for
|
||||
# the discovery-script definition). No old-project documenter existed.
|
||||
|
||||
class ReusableSettingDocHandler : DocumentationHandlerBase {
|
||||
ReusableSettingDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceManagementReusablePolicySetting')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
if ($PolicyObject.Name) {
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $PolicyObject.Name 'displayName'
|
||||
}
|
||||
$descValue = if ($obj.description) { $obj.description } else { '' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $descValue 'description'
|
||||
|
||||
$category = Get-LanguageString 'TableHeaders.settings'
|
||||
|
||||
if ($obj.settingDefinitionId) {
|
||||
$definitionLabel = Get-LanguageString 'SettingDetails.settingIdName' -IgnoreMissing
|
||||
if (-not $definitionLabel) { $definitionLabel = 'Setting definition' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $definitionLabel
|
||||
Value = [string]$obj.settingDefinitionId
|
||||
EntityKey = 'settingDefinitionId'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
$rawValue = [string]$obj.settingInstance.simpleSettingValue.value
|
||||
if ($rawValue -and -not ($Context.Options -and $Context.Options['IncludeScripts'] -eq $false)) {
|
||||
# The discovery-script definition stores the script base64-encoded;
|
||||
# fall back to the raw value for definitions that don't.
|
||||
$value = $rawValue
|
||||
try {
|
||||
$decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($rawValue))
|
||||
if ($decoded) { $value = $decoded }
|
||||
}
|
||||
catch { }
|
||||
|
||||
$valueLabel = if ([string]$obj.settingDefinitionId -like '*discoveryscript*') {
|
||||
Get-LanguageString 'ProactiveRemediations.Create.Settings.DetectionScriptMultiLineTextBox.label'
|
||||
}
|
||||
else {
|
||||
$lbl = Get-LanguageString 'SettingDetails.valueName' -IgnoreMissing
|
||||
if ($lbl) { $lbl } else { 'Value' }
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $valueLabel
|
||||
Value = $value
|
||||
EntityKey = 'settingInstanceValue'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([ReusableSettingDocHandler]::new())
|
||||
@@ -0,0 +1,186 @@
|
||||
# Role Definition documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:4513. Resolves
|
||||
# permissions to resource/action names via /deviceManagement/resourceOperations
|
||||
# (generic schema - resolved from any connected tenant) and enriches assignments
|
||||
# with directory display names (source-tenant-specific - skipped when the source
|
||||
# tenant is unavailable, emitting raw IDs).
|
||||
|
||||
class RoleDefinitionDocHandler : DocumentationHandlerBase {
|
||||
RoleDefinitionDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.deviceAndAppManagementRoleDefinition')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'RoleAssignment.rolesMenuTitle') '@odata.type'
|
||||
|
||||
# Built-in vs custom role. isBuiltIn is true for Microsoft-supplied roles.
|
||||
if ($null -ne $obj.isBuiltIn) {
|
||||
$builtInValue = if ($obj.isBuiltIn) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'DisplayRoleTypes.builtInRole') $builtInValue 'isBuiltIn'
|
||||
}
|
||||
|
||||
# --- Permissions section: resolve action IDs to resource/action names ---
|
||||
$roleResources = @()
|
||||
# resourceOperations is a GENERIC catalog (resource/action names) - same on
|
||||
# every tenant - so resolved from any connected tenant.
|
||||
if (Test-DocumentationGraphAvailable) {
|
||||
try {
|
||||
$resp = Invoke-MSGraphAPI -Url '/deviceManagement/resourceOperations'
|
||||
$roleResources = @($resp.Value)
|
||||
}
|
||||
catch {
|
||||
Write-LogError 'Failed to fetch /deviceManagement/resourceOperations for role permissions' $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
$permissionsCategory = Get-LanguageString 'Titles.permissions'
|
||||
|
||||
# Prefer the modern rolePermissions structure: union allowedResourceActions
|
||||
# across ALL rolePermissions/resourceActions. Fall back to the legacy flat
|
||||
# permissions[0].actions list when rolePermissions is absent (older payloads
|
||||
# and some built-in roles only populate the legacy list).
|
||||
$actionIds = @()
|
||||
if ($obj.rolePermissions) {
|
||||
foreach ($rolePermission in @($obj.rolePermissions)) {
|
||||
foreach ($resourceAction in @($rolePermission.resourceActions)) {
|
||||
foreach ($allowed in @($resourceAction.allowedResourceActions)) {
|
||||
if ($allowed -and $actionIds -notcontains $allowed) { $actionIds += $allowed }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($actionIds.Count -eq 0 -and $obj.permissions -and $obj.permissions[0]) {
|
||||
$actionIds = @($obj.permissions[0].actions)
|
||||
}
|
||||
|
||||
if ($roleResources.Count -gt 0 -and $actionIds.Count -gt 0) {
|
||||
# Resolved live: group resolved actions by resourceName
|
||||
$assignedActions = @()
|
||||
foreach ($id in $actionIds) {
|
||||
$r = $roleResources | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
if ($r) { $assignedActions += $r }
|
||||
}
|
||||
|
||||
$byResource = $assignedActions | Select-Object resourceName -Unique | Sort-Object -Property resourceName
|
||||
foreach ($rn in $byResource.resourceName) {
|
||||
$actions = @($assignedActions | Where-Object resourceName -EQ $rn)
|
||||
$resourceId = $actions[0].resource
|
||||
$actionNames = ($actions | ForEach-Object { $_.actionName })
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = $rn
|
||||
Value = ($actionNames -join $Context.ObjectSeparator)
|
||||
EntityKey = $resourceId
|
||||
Category = $permissionsCategory
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
elseif ($actionIds.Count -gt 0) {
|
||||
# Offline: emit a single row with raw action IDs so the row count
|
||||
# is non-zero and compare-style downstream tools have something
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'Titles.permissions')
|
||||
Value = ($actionIds -join $Context.ObjectSeparator)
|
||||
EntityKey = 'actions'
|
||||
Category = $permissionsCategory
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
# --- Assignments section ---
|
||||
# roleAssignments are enriched in place (full assignment + roleScopeTags)
|
||||
# by RoleDefinitionObject's sub-resource contract during hydration, so
|
||||
# each entry already carries displayName/description/members/scopeMembers/
|
||||
# roleScopeTags — no per-assignment Graph fetch here. The
|
||||
# deviceManagement/roleAssignments/<id> API now lives only on the class.
|
||||
# Offline runs still skip this section (matching prior behavior); the
|
||||
# member display-name resolution (getByIds) is shared reference data and
|
||||
# stays online-only.
|
||||
if ($Context.SourceTenantUnavailable -or -not (Test-DocumentationGraphAvailable)) { return }
|
||||
|
||||
$assignmentsCategory = Get-LanguageString 'TableHeaders.assignments'
|
||||
foreach ($info in @($obj.roleAssignments)) {
|
||||
if (-not $info -or [string]::IsNullOrWhiteSpace([string]$info.id)) {
|
||||
Write-Log 'RoleDefinition: skipping role assignment without an id' 2
|
||||
continue
|
||||
}
|
||||
|
||||
# Resolve member + scope IDs to displayNames in one batch
|
||||
$ids = @()
|
||||
foreach ($id in @($info.members + $info.scopeMembers)) {
|
||||
if ($id -and $ids -notcontains $id) { $ids += $id }
|
||||
}
|
||||
$idInfo = @()
|
||||
if ($ids.Count -gt 0) {
|
||||
try {
|
||||
$body = @{ ids = $ids } | ConvertTo-Json
|
||||
$resp = Invoke-MSGraphAPI -Url "/directoryObjects/getByIds?`$select=displayName,id" -Content $body -Method POST
|
||||
$idInfo = @($resp.Value)
|
||||
}
|
||||
catch { Write-LogError 'Failed to resolve role-assignment member display names' $_.Exception }
|
||||
}
|
||||
|
||||
$sub = $info.displayName
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.nameName')
|
||||
Value = $info.displayName
|
||||
EntityKey = 'displayName'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
if ($info.description) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'SettingDetails.descriptionName')
|
||||
Value = $info.description
|
||||
EntityKey = 'description'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
|
||||
$admins = @()
|
||||
foreach ($id in @($info.members)) {
|
||||
$resolved = $idInfo | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
$admins += if ($resolved.displayName) { $resolved.displayName } else { $id }
|
||||
}
|
||||
if ($admins.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'RoleAssignment.RoleAssignmentAdmin')
|
||||
Value = ($admins -join $Context.ObjectSeparator)
|
||||
EntityKey = 'members'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
|
||||
$scopeMembers = @()
|
||||
foreach ($id in @($info.scopeMembers)) {
|
||||
$resolved = $idInfo | Where-Object Id -EQ $id | Select-Object -First 1
|
||||
$scopeMembers += if ($resolved.displayName) { $resolved.displayName } else { $id }
|
||||
}
|
||||
if ($scopeMembers.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'RoleAssignment.RoleAssignmentScope')
|
||||
Value = ($scopeMembers -join $Context.ObjectSeparator)
|
||||
EntityKey = 'scopeMembers'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
|
||||
$scopeTags = @($info.roleScopeTags | ForEach-Object { $_.displayName })
|
||||
if ($scopeTags.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TableHeaders.scopeTags')
|
||||
Value = ($scopeTags -join $Context.ObjectSeparator)
|
||||
EntityKey = 'scopeTags'
|
||||
Category = $assignmentsCategory; SubCategory = $sub
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([RoleDefinitionDocHandler]::new())
|
||||
@@ -0,0 +1,46 @@
|
||||
# Role Scope Tag documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:4882 (Invoke-
|
||||
# CDDocumentScopeTag). Claims @odata.type='#microsoft.graph.roleScopeTag'.
|
||||
#
|
||||
# Old handler accumulated per-tag rows into a cross-batch
|
||||
# $script:ObjectTypeFullTable hashtable that the output providers flushed as a
|
||||
# single consolidated "Scope Tags" table at PostProcess time. The new engine
|
||||
# already has $ctx.ObjectTypeFullTable for the same purpose, but no output
|
||||
# provider consumes it yet — until that's wired up, we just emit per-object
|
||||
# BasicInfo so each tag at least documents independently rather than being
|
||||
# dropped on the floor as NoProvider.
|
||||
#
|
||||
# Assignments are intentionally NOT translated here: Invoke-TranslateAssignments
|
||||
# is a separate ~370-LOC port (DocumentationMigration.md risk #4) that no
|
||||
# handler in the new project calls yet. When that lands, this handler can
|
||||
# trivially call it after the BasicInfo block.
|
||||
|
||||
class ScopeTagDocHandler : DocumentationHandlerBase {
|
||||
ScopeTagDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.roleScopeTag')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# Plain Name + Description rows. Scope tags don't have Platform supported
|
||||
# or Profile type rows in any old-engine path, so skip the
|
||||
# Add-BasicDefaultValues helper (which would emit blank Platform /
|
||||
# Profile rows from a missing ObjectCategories entry).
|
||||
if ($PolicyObject.Name) {
|
||||
$nameProp = if ($PolicyObject.PolicyType._NameProperty) { $PolicyObject.PolicyType._NameProperty } else { 'displayName' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $PolicyObject.Name $nameProp
|
||||
}
|
||||
|
||||
$descValue = if ($obj.description) { $obj.description } else { '' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.descriptionName') $descValue 'description'
|
||||
|
||||
if ($null -ne $obj.isBuiltIn) {
|
||||
$val = if ($obj.isBuiltIn) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-BasicPropertyValue (Get-LanguageString 'RoleScopeTag.isBuiltIn') $val 'isBuiltIn'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([ScopeTagDocHandler]::new())
|
||||
@@ -0,0 +1,90 @@
|
||||
# Terms of Use (agreement) documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:2361. Note this handler
|
||||
# does NOT use Add-BasicDefaultValues — the old engine only emits Name +
|
||||
# Profile type for agreements (no Description, no Created/Modified).
|
||||
|
||||
class TermsOfUseDocHandler : DocumentationHandlerBase {
|
||||
TermsOfUseDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.agreement')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
$offLabel = Get-LanguageString 'SettingDetails.offOption'
|
||||
$onLabel = Get-LanguageString 'SettingDetails.onOption'
|
||||
|
||||
# BasicInfo: just Name + Profile type
|
||||
Add-BasicPropertyValue (Get-LanguageString 'SettingDetails.nameName') $obj.displayName 'displayName'
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'AzureCA.menuItemTermsOfUse') '@odata.type'
|
||||
|
||||
$viewingValue = if ($obj.isViewingBeforeAcceptanceRequired) { $onLabel } else { $offLabel }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.agreementIsViewingBeforeAcceptanceRequiredLabel')
|
||||
Value = $viewingValue; Category = $null; SubCategory = $null
|
||||
EntityKey = 'isViewingBeforeAcceptanceRequired'
|
||||
})
|
||||
|
||||
$perDeviceValue = if ($obj.isPerDeviceAcceptanceRequired) { $onLabel } else { $offLabel }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.agreementIsPerDeviceAcceptanceRequiredLabel')
|
||||
Value = $perDeviceValue; Category = $null; SubCategory = $null
|
||||
EntityKey = 'isPerDeviceAcceptanceRequired'
|
||||
})
|
||||
|
||||
$expirationValue = if ($obj.termsExpiration) { $onLabel } else { $offLabel }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.isAcceptanceExpirationEnabledLabel')
|
||||
Value = $expirationValue; Category = $null; SubCategory = $null
|
||||
EntityKey = 'isAcceptanceExpirationEnabledLabel'
|
||||
})
|
||||
|
||||
# Expiration details (only when termsExpiration is set)
|
||||
if ($obj.termsExpiration.startDateTime) {
|
||||
try {
|
||||
if ($obj.termsExpiration.startDateTime -is [datetime]) {
|
||||
$tmp = if ($obj.termsExpiration.startDateTime.Kind -eq 'Utc') { $obj.termsExpiration.startDateTime.ToLocalTime() } else { $obj.termsExpiration.startDateTime }
|
||||
}
|
||||
else {
|
||||
$tmp = ([datetime]::Parse($obj.termsExpiration.startDateTime, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal)).ToLocalTime()
|
||||
}
|
||||
$startStr = $tmp.ToShortDateString()
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to parse date from string $($obj.termsExpiration.startDateTime)" 2
|
||||
$startStr = $obj.termsExpiration.startDateTime
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.acceptanceExpirationStartDateTimeLabel')
|
||||
Value = $startStr; Category = $null; SubCategory = $null
|
||||
EntityKey = 'startDateTime'
|
||||
})
|
||||
|
||||
$freqValue = switch ($obj.termsExpiration.frequency) {
|
||||
'P365D' { Get-LanguageString 'TermsOfUse.AcceptanceExpirationFrequency.annually' }
|
||||
'P180D' { Get-LanguageString 'TermsOfUse.AcceptanceExpirationFrequency.biannually' }
|
||||
'P30D' { Get-LanguageString 'TermsOfUse.AcceptanceExpirationFrequency.monthly' }
|
||||
'P90D' { Get-LanguageString 'TermsOfUse.AcceptanceExpirationFrequency.quarterly' }
|
||||
default { $null }
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.acceptanceExpirationFrequencyLabel')
|
||||
Value = $freqValue; Category = $null; SubCategory = $null
|
||||
EntityKey = 'frequency'
|
||||
})
|
||||
}
|
||||
|
||||
if ($null -ne $obj.userReacceptRequiredFrequency) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = (Get-LanguageString 'TermsOfUse.Wizard.acceptanceDurationLabel')
|
||||
Value = (Get-DurationValue $obj.userReacceptRequiredFrequency)
|
||||
Category = $null; SubCategory = $null
|
||||
EntityKey = 'userReacceptRequiredFrequency'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([TermsOfUseDocHandler]::new())
|
||||
@@ -0,0 +1,400 @@
|
||||
# Windows Kiosk Configuration documentation handler.
|
||||
#
|
||||
# Ported from old Extensions/DocumentationCustom.psm1:3923 (Invoke-
|
||||
# CDDocumentWindowsKioskConfiguration). Claims @odata.type=
|
||||
# '#microsoft.graph.windowsKioskConfiguration'.
|
||||
#
|
||||
# Generic Profile/walker can't handle this type because the actual settings
|
||||
# live under nested $obj.kioskProfiles[0].appConfiguration /
|
||||
# .userAccountsConfiguration with discriminated @odata.type subtypes
|
||||
# (windowsKioskSingleWin32App, windowsKioskSingleUWPApp, windowsKioskMultipleApps,
|
||||
# windowsKioskAutologon, windowsKioskAzureADGroup/User, etc.). A handler with
|
||||
# explicit subtype dispatch is required.
|
||||
#
|
||||
# Preserves an old-engine quirk: when userAccountsConfiguration is an array of
|
||||
# mixed AAD User + AAD Group entries, PS evaluates the switch on the implicit
|
||||
# array of @odata.types and `-eq 'kioskAADUserAndGroup'` on the resulting array
|
||||
# returns the matching items (truthy in if-test). The "User logon type" row
|
||||
# then shows empty because `"SettingDetails.$($logonTypeLngId)"` interpolates
|
||||
# the array as space-joined.
|
||||
|
||||
class WindowsKioskDocHandler : DocumentationHandlerBase {
|
||||
WindowsKioskDocHandler() {
|
||||
$this.ODataTypes = @('#microsoft.graph.windowsKioskConfiguration')
|
||||
}
|
||||
|
||||
[void] Document([object]$PolicyObject, [DocumentationContext]$Context) {
|
||||
$obj = $PolicyObject.JsonObject
|
||||
|
||||
# ---- Basic info ----
|
||||
Add-BasicDefaultValues $PolicyObject
|
||||
Add-BasicAdditionalValues $PolicyObject
|
||||
Add-BasicPropertyValue (Get-LanguageString 'TableHeaders.configurationType') (Get-LanguageString 'Category.kioskConfigurationV2') '@odata.type'
|
||||
# Old engine emits a Platform row from $obj.platform. The raw payload
|
||||
# doesn't carry one for this type, so the lookup resolves to empty —
|
||||
# golden fixtures still contain the empty row, so emit it for parity.
|
||||
Add-BasicPropertyValue (Get-LanguageString 'Inputs.platformLabel') (Get-LanguageString "Platform.$($obj.platform)") 'platform'
|
||||
|
||||
# ---- Settings ----
|
||||
$category = Get-LanguageString 'Category.kiosk'
|
||||
|
||||
$appConfig = $obj.kioskProfiles[0].appConfiguration
|
||||
$userConfig = $obj.kioskProfiles[0].userAccountsConfiguration
|
||||
|
||||
# kioskMode dispatch
|
||||
if ($appConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskSingleWin32App' -or
|
||||
$appConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskSingleUWPApp') {
|
||||
$kioskModeType = 'single'
|
||||
$kioskMode = Get-LanguageString 'SettingDetails.kioskSelectionSingleMode'
|
||||
}
|
||||
else {
|
||||
$kioskModeType = 'multi'
|
||||
$kioskMode = Get-LanguageString 'SettingDetails.kioskSelectionMultiMode'
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskSelectionName'
|
||||
Value = $kioskMode
|
||||
EntityKey = 'kioskMode'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
# User logon type
|
||||
$logonTypeLngId = switch ($userConfig.'@odata.type') {
|
||||
'#microsoft.graph.windowsKioskAutologon' { 'kioskUserLogonTypeAutologon' }
|
||||
'#microsoft.graph.windowsKioskAzureADUser' { 'kioskAADUserAndGroup' }
|
||||
'#microsoft.graph.windowsKioskAzureADGroup' { 'kioskAADUserAndGroup' }
|
||||
'#microsoft.graph.windowsKioskLocalUser' { 'kioskAppTypeStore' }
|
||||
'#microsoft.graph.windowsKioskVisitor' { 'kioskVisitor' }
|
||||
}
|
||||
$logonType = if ($logonTypeLngId) {
|
||||
Get-LanguageString "SettingDetails.$logonTypeLngId"
|
||||
} else {
|
||||
Write-Log "Unknown kiosk user logon type. $($userConfig.'@odata.type')" 2
|
||||
$null
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskSelectionUsers'
|
||||
Value = $logonType
|
||||
EntityKey = 'userAccountsConfigurationType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
# User logon name(s)
|
||||
if ($logonTypeLngId -eq 'kioskAADUserAndGroup') {
|
||||
$aadUser = Get-LanguageString 'SettingDetails.kioskAADUser'
|
||||
$aadGroup = Get-LanguageString 'SettingDetails.kioskAADGroup'
|
||||
$users = @()
|
||||
foreach ($u in $userConfig) {
|
||||
$sep = $Context.PropertySeparator
|
||||
if ($u.'@odata.type' -eq '#microsoft.graph.windowsKioskAzureADUser') {
|
||||
$users += "$($u.userPrincipalName)$sep$aadUser"
|
||||
}
|
||||
else {
|
||||
$users += "$($u.displayName)$sep$aadGroup"
|
||||
}
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskUserAccountName'
|
||||
Value = $users -join $Context.ObjectSeparator
|
||||
EntityKey = 'userAccounts'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($userConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskLocalUser') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskUserAccountName'
|
||||
Value = $userConfig.userName
|
||||
EntityKey = 'userName'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
# Single-app: detect underlying app type and emit type-specific rows
|
||||
if ($kioskModeType -eq 'single') {
|
||||
$uwpAppType = $null
|
||||
$appType = $null
|
||||
if ($appConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskSingleWin32App') {
|
||||
$uwpAppType = 'win32App'
|
||||
$appType = Get-LanguageString 'SettingDetails.selectWin32AppForEdge86'
|
||||
}
|
||||
elseif ($appConfig.'@odata.type' -eq '#microsoft.graph.windowsKioskSingleUWPApp') {
|
||||
if ($appConfig.uwpApp.appUserModelId -like 'Microsoft.MicrosoftEdge*') {
|
||||
$uwpAppType = 'edge'
|
||||
$appType = Get-LanguageString 'SettingDetails.selectMicrosoftEdgeApp'
|
||||
}
|
||||
elseif ($appConfig.uwpApp.appUserModelId -like 'Microsoft.KioskBrowser*') {
|
||||
$uwpAppType = 'kioskBrowser'
|
||||
$appType = Get-LanguageString 'SettingDetails.selectKioskBrowserApp'
|
||||
}
|
||||
else {
|
||||
$uwpAppType = 'storeApp'
|
||||
$appType = Get-LanguageString 'SettingDetails.selectStoreApp'
|
||||
}
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskApplicationType'
|
||||
Value = $appType
|
||||
EntityKey = 'kioskApplicationType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
$edgeKioskModeType = if ($appConfig.win32App.edgeKioskType -eq 'publicBrowsing') {
|
||||
Get-LanguageString 'SettingDetails.edgeKioskModeTypePublicBrowsingInPrivate'
|
||||
} else {
|
||||
Get-LanguageString 'SettingDetails.edgeKioskModeTypeDigitalSignage'
|
||||
}
|
||||
|
||||
if ($uwpAppType -eq 'win32App') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win32EdgeKioskUrl'
|
||||
Value = $appConfig.win32App.edgeKiosk
|
||||
EntityKey = 'edgeKiosk'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.edgeKioskModeType'
|
||||
Value = $edgeKioskModeType
|
||||
EntityKey = 'edgeKioskType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.edgeKioskResetAfterIdleTimeInMinutesName'
|
||||
Value = $appConfig.win32App.edgeKioskIdleTimeoutMinutes
|
||||
EntityKey = 'edgeKioskIdleTimeoutMinutes'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($uwpAppType -eq 'edge') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.edgeKioskModeType'
|
||||
Value = $edgeKioskModeType
|
||||
EntityKey = 'edgeKioskType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($uwpAppType -eq 'kioskBrowser') {
|
||||
$show = Get-LanguageString 'BooleanActions.show'
|
||||
$hide = Get-LanguageString 'BooleanActions.hide'
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskBrowserDefaultWebsiteName'
|
||||
Value = $obj.kioskBrowserDefaultUrl
|
||||
EntityKey = 'kioskBrowserDefaultUrl'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskBrowserHomeButtonName'
|
||||
Value = if ($obj.kioskBrowserEnableHomeButton) { $show } else { $hide }
|
||||
EntityKey = 'kioskBrowserEnableHomeButton'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskBrowserNavigationButtonName'
|
||||
Value = if ($obj.kioskBrowserEnableNavigationButtons) { $show } else { $hide }
|
||||
EntityKey = 'kioskBrowserEnableNavigationButtons'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskBrowserEnableEndSessionButtonName'
|
||||
Value = if ($obj.kioskBrowserEnableEndSessionButton) { $show } else { $hide }
|
||||
EntityKey = 'kioskBrowserEnableEndSessionButton'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.edgeKioskResetAfterIdleTimeInMinutesName'
|
||||
Value = $obj.kioskBrowserRestartOnIdleTimeInMinutes
|
||||
EntityKey = 'kioskBrowserRestartOnIdleTimeInMinutes'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10BlockedWebsitesName'
|
||||
Value = $obj.kioskBrowserBlockedURLs -join $Context.ObjectSeparator
|
||||
EntityKey = 'kioskBrowserBlockedURLs'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10AllowedWebsitesName'
|
||||
Value = $obj.kioskBrowserBlockedUrlExceptions -join $Context.ObjectSeparator
|
||||
EntityKey = 'kioskBrowserBlockedUrlExceptions'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($uwpAppType -eq 'storeApp') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskModeAppStoreUrlOrManagedAppIdName'
|
||||
Value = $appConfig.uwpApp.name
|
||||
EntityKey = 'edgeKioskType'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
# Multi-app: app table + start-layout / taskbar / downloads rows
|
||||
if ($kioskModeType -eq 'multi') {
|
||||
$apps = @()
|
||||
foreach ($app in $appConfig.apps) {
|
||||
$kioskTypeLngId = switch ($app.appType) {
|
||||
'aumId' { 'kioskAppTypeAUMID' }
|
||||
'desktop' { 'kioskAppTypeDesktop' }
|
||||
'store' { 'kioskAppTypeStore' }
|
||||
default { 'kioskAppTypeUnknown' }
|
||||
}
|
||||
$kioskTileLngId = switch ($app.startLayoutTileSize) {
|
||||
'medium' { 'kioskTileMedium' }
|
||||
'small' { 'kioskTileSmall' }
|
||||
'wide' { 'kioskTileWide' }
|
||||
'large' { 'kioskTileLarge' }
|
||||
}
|
||||
$sep = $Context.PropertySeparator
|
||||
$autoLaunchStr = if ($app.autoLaunch -eq $true) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
$apps += '{0}{1}{2}{3}{4}{5}{6}' -f $app.Name, $sep, (Get-LanguageString "SettingDetails.$kioskTypeLngId"), $sep, $autoLaunchStr, $sep, (Get-LanguageString "SettingDetails.$kioskTileLngId")
|
||||
}
|
||||
|
||||
if ($apps.Count -gt 0) {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskAppTableName'
|
||||
Value = $apps -join $Context.ObjectSeparator
|
||||
EntityKey = 'kioskApps'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
$altLayout = if ($null -ne $appConfig.startMenuLayoutXml) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.alternativeStartLayoutName'
|
||||
Value = $altLayout
|
||||
EntityKey = 'alternativeStartLayout'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($null -ne $appConfig.startMenuLayoutXml) {
|
||||
$xmlStr = try {
|
||||
[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($appConfig.startMenuLayoutXml))
|
||||
} catch { $appConfig.startMenuLayoutXml }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskStartMenuLayoutXmlName'
|
||||
Value = $xmlStr
|
||||
EntityKey = 'startMenuLayoutXml'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
$taskBar = if ($appConfig.showTaskBar) { Get-LanguageString 'BooleanActions.show' } else { Get-LanguageString 'BooleanActions.hide' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskShowTaskbarName'
|
||||
Value = $taskBar
|
||||
EntityKey = 'showTaskBar'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
$downloads = if ($appConfig.allowAccessToDownloadsFolder) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.win10KioskAccessDownloadsFolderName'
|
||||
Value = $downloads
|
||||
EntityKey = 'allowAccessToDownloadsFolder'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
# disallowDesktopApps blocks classic Win32/desktop apps on the multi-app
|
||||
# kiosk. No scraped label exists for this toggle, so use an ASCII literal
|
||||
# (the Yes/No value is still localized).
|
||||
$disallowDesktopApps = if ($appConfig.disallowDesktopApps) { Get-LanguageString 'SettingDetails.yes' } else { Get-LanguageString 'SettingDetails.no' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = 'Block desktop (Win32) apps'
|
||||
Value = $disallowDesktopApps
|
||||
EntityKey = 'disallowDesktopApps'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
|
||||
# Force-restart maintenance window
|
||||
$forceUpdateLng = if ($obj.windowsKioskForceUpdateSchedule) { 'BooleanActions.require' } else { 'BooleanActions.notConfigured' }
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskForceRestart'
|
||||
Value = Get-LanguageString $forceUpdateLng
|
||||
EntityKey = 'windowsKioskForceUpdateSchedule'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($obj.windowsKioskForceUpdateSchedule) {
|
||||
try {
|
||||
$startDateObj = if ($obj.windowsKioskForceUpdateSchedule.startDateTime -is [DateTime]) {
|
||||
$tmp = $obj.windowsKioskForceUpdateSchedule.startDateTime
|
||||
if ($tmp.Kind -eq [DateTimeKind]::Utc) { $tmp.ToLocalTime() } else { $tmp }
|
||||
} else {
|
||||
Get-Date $obj.windowsKioskForceUpdateSchedule.startDateTime -ErrorAction Stop
|
||||
}
|
||||
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskStartDateTime'
|
||||
Value = ($startDateObj.ToShortDateString() + $Context.ObjectSeparator + $startDateObj.ToShortTimeString())
|
||||
EntityKey = 'startDateTime'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
$recurrenceType = switch ($obj.windowsKioskForceUpdateSchedule.recurrence) {
|
||||
'weekly' { 'kioskWeekly' }
|
||||
'monthly' { 'kioskMonthly' }
|
||||
default { 'kioskDaily' }
|
||||
}
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.kioskRecurrence'
|
||||
Value = Get-LanguageString "SettingDetails.$recurrenceType"
|
||||
EntityKey = 'recurrence'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
|
||||
if ($obj.windowsKioskForceUpdateSchedule.recurrence -eq 'weekly') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.dayOfWeek'
|
||||
Value = Get-LanguageString "SettingDetails.$($obj.windowsKioskForceUpdateSchedule.dayofWeek)"
|
||||
EntityKey = 'dayofWeek'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
elseif ($obj.windowsKioskForceUpdateSchedule.recurrence -eq 'monthly') {
|
||||
Add-CustomSettingObject ([PSCustomObject]@{
|
||||
Name = Get-LanguageString 'SettingDetails.dayOfMonth'
|
||||
Value = $obj.windowsKioskForceUpdateSchedule.dayofMonth
|
||||
EntityKey = 'dayofMonth'
|
||||
Category = $category
|
||||
SubCategory = $null
|
||||
})
|
||||
}
|
||||
}
|
||||
catch { Write-Log "Failed to format kiosk force-update schedule: $($_.Exception.Message)" 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DocumentationRegistry]::RegisterHandler([WindowsKioskDocHandler]::new())
|
||||
@@ -0,0 +1,639 @@
|
||||
# Layer 2 of the access marking: the signed-in USER's Intune RBAC, on top of the
|
||||
# APP's consented scopes that Internal/AccessLevel.ps1 (Layer 1) diffs.
|
||||
#
|
||||
# For a delegated login the effective access is the intersection of both. The
|
||||
# token carries scp (app consent) and wids (Entra directory roles) but nothing
|
||||
# about Intune role assignments - a user holding only the built-in Read Only
|
||||
# Operator role signs in with a token that says ReadWrite.All, Layer 1 marks
|
||||
# everything Full, and every PATCH gets a 403. This file asks Graph what the
|
||||
# user can actually do and lets Update-IntuneAccessLevels stamp the worse of
|
||||
# the two answers. See Docs/EffectivePermissions-Plan.md.
|
||||
#
|
||||
# Rules, in priority order:
|
||||
# * Setting off, no claims, or an app-only token -> no Layer 2 at all.
|
||||
# Application permissions bypass Intune RBAC, so 'roles' IS the answer.
|
||||
# * Intune Administrator / Global Administrator in wids -> Full for every
|
||||
# Intune category, no Graph call. Both grant complete Intune RBAC.
|
||||
# * Global Reader in wids -> read guaranteed for every Intune category, never
|
||||
# write. Used as a FLOOR, not a short-circuit: the getEffectivePermissions
|
||||
# call still runs (an additional Intune role may add write on top), but a
|
||||
# category the response omits is still treated as readable, and when the
|
||||
# call comes back empty the type is marked read-only instead of no-access.
|
||||
# A user who holds Global Reader plus an Intune role that grants write keeps
|
||||
# that write (it comes from the response); the floor only ever adds read.
|
||||
# * Otherwise one GET of deviceManagement/getEffectivePermissions per token.
|
||||
# The answer is cached under the token FINGERPRINT (tid|oid|iat): any newly
|
||||
# minted token - routine renewal, explicit login, Force refresh in the
|
||||
# Profile popup - misses the cache and re-asks. There is no TTL on purpose:
|
||||
# Force refresh is the one refresh path, exactly as for the token itself.
|
||||
# * Plus one GET of deviceManagement/resourceOperations: the CATALOGUE of
|
||||
# resource actions that exist. getEffectivePermissions answers with the
|
||||
# allowed actions only - notAllowedResourceActions comes back empty - so on
|
||||
# its own it cannot tell "the role denies this action" from "no such action
|
||||
# for this category", and both a denied write and a category that has no
|
||||
# Assign action look identical. Without the catalogue the write check was a
|
||||
# contradiction and every mapped type came out Full, which is the whole
|
||||
# point of Layer 2 missing. The catalogue is tenant-level and
|
||||
# token-independent, so it is cached per tenant and survives a refresh.
|
||||
# * Layer 2 can only DOWNGRADE. A missing scope is fatal regardless of RBAC.
|
||||
# * Unknown never colours. A failed call, a type with no category mapping,
|
||||
# an API that Intune RBAC does not govern, or a category the response never
|
||||
# mentions all return $null and Layer 1 stands. A sea of red on data we
|
||||
# could not read is worse than no signal.
|
||||
#
|
||||
# Scope tags are NOT modelled. getEffectivePermissions is the global answer; a
|
||||
# user scoped to a subset of tags can still 403 on specific objects.
|
||||
|
||||
# Directory role template ids that imply full Intune RBAC. Template ids are
|
||||
# fixed across tenants (wids carries the template id, not the tenant's role
|
||||
# object id).
|
||||
$script:RbacFullAccessRoleTemplateIds = @(
|
||||
"3a2c62db-5318-420d-8d74-23affee5d9d5", # Intune Administrator
|
||||
"62e90394-69f5-4237-9190-012177145e10" # Global Administrator
|
||||
)
|
||||
|
||||
# Directory role template ids that imply tenant-wide READ but no write. Global
|
||||
# Reader reads every Intune resource; it is a floor on read, not a replacement
|
||||
# for the per-action lookup (see the header). Kept separate from the full-access
|
||||
# list so it downgrades a writable type to read-only rather than granting Full.
|
||||
$script:RbacReadOnlyRoleTemplateIds = @(
|
||||
"f2ef992c-3afb-46b9-b7cf-a126ee74c451" # Global Reader
|
||||
)
|
||||
|
||||
# The Graph function and the scope it is asked for. Both are what the Intune
|
||||
# portal uses. Kept as variables so a live finding is a one-line change.
|
||||
$script:RbacEffectivePermissionsUrl = "/deviceManagement/getEffectivePermissions(scope='*')"
|
||||
$script:RbacResourceActionPrefix = "Microsoft.Intune_"
|
||||
|
||||
# The action catalogue: every resource action Intune defines, one entry per
|
||||
# action, with .id exactly '<prefix><Category>_<Action>'. Tenant-level and the
|
||||
# same for every caller, so it is cached per tenant rather than per token.
|
||||
$script:RbacResourceOperationsUrl = "/deviceManagement/resourceOperations"
|
||||
|
||||
# Actions that make a type writable. Only the ones that EXIST for a category
|
||||
# count - Roles has no Assign, ManagedGooglePlay uses Modify - so a category
|
||||
# with fewer write actions is not penalised for actions it lacks. Existence
|
||||
# comes from the catalogue, which is why Layer 2 fetches it.
|
||||
$script:RbacWriteActions = @("Create", "Update", "Delete", "Assign", "Modify")
|
||||
|
||||
# _API path -> Intune RBAC resource category (the middle segment of
|
||||
# Microsoft.Intune_<Category>_<Action>). Longest prefix wins so a specific
|
||||
# sub-path can override its parent. An entry may also override the action
|
||||
# suffixes when a category does not use the plain Read/Create/... names.
|
||||
#
|
||||
# Every Category name below exists in the live resourceOperations catalogue, and
|
||||
# a test asserts that against the catalogue fixture - a typo like the former
|
||||
# 'Filters' (the portal's label; the resource is 'AssignmentFilter') now fails
|
||||
# the suite instead of silently marking the type Unknown. UNVERIFIED marks the
|
||||
# other half, the API -> category association, which the catalogue cannot
|
||||
# confirm: it is safe because an action name the catalogue does not know yields
|
||||
# Unknown, not a colour. Types can override any of this with _ResourceCategory.
|
||||
$script:RbacApiCategoryMap = @{
|
||||
# Device configuration family
|
||||
"deviceManagement/deviceConfigurations" = "DeviceConfigurations"
|
||||
"deviceManagement/configurationPolicies" = "DeviceConfigurations"
|
||||
"deviceManagement/configurationPolicyTemplates" = "DeviceConfigurations"
|
||||
"deviceManagement/groupPolicyConfigurations" = "DeviceConfigurations"
|
||||
"deviceManagement/groupPolicyUploadedDefinitionFiles" = "DeviceConfigurations"
|
||||
"deviceManagement/reusablePolicySettings" = "DeviceConfigurations"
|
||||
"deviceManagement/hardwareConfigurations" = "DeviceConfigurations"
|
||||
"deviceManagement/deviceManagementScripts" = "DeviceConfigurations"
|
||||
"deviceManagement/deviceShellScripts" = "DeviceConfigurations"
|
||||
"deviceManagement/deviceCustomAttributeShellScripts" = "DeviceConfigurations"
|
||||
"deviceManagement/deviceHealthScripts" = "DeviceConfigurations" # UNVERIFIED (remediations)
|
||||
"deviceManagement/windowsFeatureUpdateProfiles" = "DeviceConfigurations" # UNVERIFIED
|
||||
"deviceManagement/windowsQualityUpdateProfiles" = "DeviceConfigurations" # UNVERIFIED
|
||||
"deviceManagement/windowsQualityUpdatePolicies" = "DeviceConfigurations" # UNVERIFIED
|
||||
"deviceManagement/windowsDriverUpdateProfiles" = "DeviceConfigurations" # UNVERIFIED
|
||||
"deviceManagement/inventoryPolicies" = "DeviceConfigurations" # UNVERIFIED
|
||||
"deviceManagement/deviceEnrollmentConfigurations" = "DeviceConfigurations" # UNVERIFIED (ESP / restrictions)
|
||||
# Security baselines are intents; the template catalogue sits beside them.
|
||||
"deviceManagement/intents" = "SecurityBaselines"
|
||||
"deviceManagement/templates" = "SecurityBaselines"
|
||||
# Compliance family (Intune spells the category 'Polices')
|
||||
"deviceManagement/deviceCompliancePolicies" = "DeviceCompliancePolices"
|
||||
"deviceManagement/compliancePolicies" = "DeviceCompliancePolices"
|
||||
"deviceManagement/deviceComplianceScripts" = "DeviceCompliancePolices" # UNVERIFIED
|
||||
"deviceManagement/notificationMessageTemplates" = "DeviceCompliancePolices" # UNVERIFIED
|
||||
# Tenant administration
|
||||
"deviceManagement/roleDefinitions" = "Roles"
|
||||
"deviceManagement/roleScopeTags" = "Roles"
|
||||
"deviceManagement/termsAndConditions" = "TermsAndConditions"
|
||||
"deviceManagement/assignmentFilters" = "AssignmentFilter" # 'Filters' is the portal label, not the resource
|
||||
"deviceManagement/operationApprovalPolicies" = @{
|
||||
Category = "MultiAdminApproval"
|
||||
Actions = @{ Read = "ReadAccessPolicy"; Create = "CreateAccessPolicy"; Update = "UpdateAccessPolicy"; Delete = "DeleteAccessPolicy" }
|
||||
}
|
||||
"deviceManagement/intuneBrandingProfiles" = "Customization"
|
||||
"deviceManagement/settings" = "Organization" # UNVERIFIED
|
||||
# Enrollment
|
||||
"deviceManagement/appleUserInitiatedEnrollmentProfiles" = "AppleEnrollmentProfiles" # UNVERIFIED
|
||||
"deviceManagement/depOnboardingSettings" = "AppleEnrollmentProfiles" # UNVERIFIED
|
||||
# Android Enterprise ('AndroidSync'). There is no Create/Delete action for a
|
||||
# profile: the one write action is UpdateEnrollmentProfiles, so it has to be
|
||||
# named here or the type would look writable to anyone who can read.
|
||||
"deviceManagement/androidForWorkEnrollmentProfiles" = @{
|
||||
Category = "AndroidSync"
|
||||
Actions = @{ Update = "UpdateEnrollmentProfiles" }
|
||||
}
|
||||
"deviceManagement/androidDeviceOwnerEnrollmentProfiles" = @{
|
||||
Category = "AndroidSync"
|
||||
Actions = @{ Update = "UpdateEnrollmentProfiles" }
|
||||
}
|
||||
"deviceManagement/androidManagedStoreAccountEnterpriseSettings" = "ManagedGooglePlay" # UNVERIFIED
|
||||
# Autopilot profiles are governed by the Enrollment programs permission,
|
||||
# whose *profile* actions are the AppleEnrollmentProfiles resource - the
|
||||
# action names there are generic ("Read profile", "Assign profile") and the
|
||||
# catalogue has no Windows- or Autopilot-specific resource at all. The
|
||||
# alternative reading, EnrollmentProgramToken (the *token* actions), is the
|
||||
# same portal permission group and every built-in role grants the two sets
|
||||
# together, so the verdict is identical either way; only a custom role that
|
||||
# ticks tokens without profiles could tell them apart.
|
||||
"deviceManagement/windowsAutopilotDeploymentProfiles" = "AppleEnrollmentProfiles"
|
||||
# Apps
|
||||
"deviceAppManagement/mobileApps" = "MobileApps"
|
||||
"deviceAppManagement/mobileAppConfigurations" = "MobileApps" # UNVERIFIED
|
||||
"deviceAppManagement/iosLobAppProvisioningConfigurations" = "MobileApps" # UNVERIFIED
|
||||
"deviceAppManagement/managedAppPolicies" = "ManagedApps"
|
||||
"deviceAppManagement/targetedManagedAppConfigurations" = "ManagedApps"
|
||||
"deviceAppManagement/policySets" = "PolicySets"
|
||||
}
|
||||
|
||||
# Intune-governed APIs that deliberately have NO category. The completeness test
|
||||
# (Tests/EffectivePermissions.Tests.ps1) fails on any deviceManagement/ or
|
||||
# deviceAppManagement/ type that is in neither table, so a new type cannot fall
|
||||
# through silently. Value = the reason.
|
||||
$script:RbacUnmappedApis = @{
|
||||
"deviceManagement/deviceCategories" = "no matching resource in the action catalogue"
|
||||
"deviceManagement/virtualEndpoint" = "Windows 365 uses its own RBAC namespace"
|
||||
"deviceAppManagement/vppTokens" = "no VPP resource in the action catalogue (MicrosoftStoreForBusiness is a different store)"
|
||||
}
|
||||
|
||||
#region Category resolution
|
||||
|
||||
# The category entry for a policy type: @{ Category; Actions } or $null when the
|
||||
# type is not Intune-governed / unmapped. _ResourceCategory on the type wins.
|
||||
function Get-PolicyTypeRbacCategory {
|
||||
[CmdletBinding()]
|
||||
param($PolicyType)
|
||||
|
||||
if(-not $PolicyType) { return $null }
|
||||
|
||||
$override = $null
|
||||
try { $override = $PolicyType._ResourceCategory } catch { }
|
||||
if($override) { return @{ Category = [string]$override; Actions = @{} } }
|
||||
|
||||
$api = $null
|
||||
try { $api = [string]$PolicyType._API } catch { }
|
||||
return (Get-RbacCategoryForApi $api)
|
||||
}
|
||||
|
||||
function Get-RbacCategoryForApi {
|
||||
[CmdletBinding()]
|
||||
param([string]$Api)
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($Api)) { return $null }
|
||||
$api = $Api.Trim().TrimStart('/')
|
||||
|
||||
# Longest matching prefix wins; a prefix must end at a path boundary.
|
||||
# "$($key)?" not "$key?": on PS7 '?' is a legal variable-name character, so
|
||||
# "$key?" reads the (empty) variable 'key?' and StartsWith("") matches all.
|
||||
$best = $null
|
||||
foreach($key in $script:RbacApiCategoryMap.Keys) {
|
||||
if($api -eq $key -or $api.StartsWith("$key/", [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$api.StartsWith("$($key)?", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
if(-not $best -or $key.Length -gt $best.Length) { $best = $key }
|
||||
}
|
||||
}
|
||||
if(-not $best) { return $null }
|
||||
|
||||
$entry = $script:RbacApiCategoryMap[$best]
|
||||
if($entry -is [string]) { return @{ Category = $entry; Actions = @{} } }
|
||||
$actions = if($entry.Actions) { $entry.Actions } else { @{} }
|
||||
return @{ Category = [string]$entry.Category; Actions = $actions }
|
||||
}
|
||||
|
||||
# Full resource action name for a category + logical action, honouring the
|
||||
# entry's suffix overrides.
|
||||
function Get-RbacActionName {
|
||||
[CmdletBinding()]
|
||||
param($Entry, [string]$Action)
|
||||
|
||||
$suffix = $Action
|
||||
if($Entry.Actions -and $Entry.Actions.ContainsKey($Action)) { $suffix = $Entry.Actions[$Action] }
|
||||
return "$($script:RbacResourceActionPrefix)$($Entry.Category)_$suffix"
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response parsing
|
||||
|
||||
# Turn a getEffectivePermissions payload into two sets: the actions the role
|
||||
# allows, and the ones it explicitly denies. The shape is walked defensively -
|
||||
# value[].resourceActions[].allowed/notAllowed - and a bare object with
|
||||
# resourceActions is accepted too.
|
||||
#
|
||||
# NotAllowed is empty in practice: Graph answers with the allowed list only. It
|
||||
# is still read, because when it is populated it is a better source for "this
|
||||
# action exists" than the catalogue (Get-RbacActionCatalog falls back to it).
|
||||
function ConvertTo-RbacActionSets {
|
||||
[CmdletBinding()]
|
||||
param($Response)
|
||||
|
||||
$allowed = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
$notAllowed = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
if(-not $Response) { return @{ Allowed = $allowed; NotAllowed = $notAllowed } }
|
||||
|
||||
$items = @()
|
||||
if($Response.PSObject.Properties['value']) { $items = @($Response.value) } else { $items = @($Response) }
|
||||
|
||||
foreach($item in $items) {
|
||||
if(-not $item) { continue }
|
||||
$resourceActions = @()
|
||||
if($item.PSObject.Properties['resourceActions']) { $resourceActions = @($item.resourceActions) }
|
||||
elseif($item.PSObject.Properties['allowedResourceActions']) { $resourceActions = @($item) }
|
||||
foreach($ra in $resourceActions) {
|
||||
if(-not $ra) { continue }
|
||||
foreach($a in @($ra.allowedResourceActions)) {
|
||||
if($a) { [void]$allowed.Add(([string]$a).Trim()) }
|
||||
}
|
||||
foreach($a in @($ra.notAllowedResourceActions)) {
|
||||
if($a) { [void]$notAllowed.Add(([string]$a).Trim()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return @{ Allowed = $allowed; NotAllowed = $notAllowed }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Action catalogue
|
||||
|
||||
function Get-RbacCatalogCacheName {
|
||||
param([string]$TenantId)
|
||||
return "RbacResourceOperations_$TenantId"
|
||||
}
|
||||
|
||||
# The set of resource action names that EXIST, from deviceManagement/
|
||||
# resourceOperations. $null when the call fails or returns nothing usable -
|
||||
# callers must treat that as "existence unknown", not as "nothing exists".
|
||||
#
|
||||
# Cached per tenant with no timeout and no token fingerprint: the catalogue is a
|
||||
# property of the Intune service, not of the signed-in user, so a token refresh
|
||||
# re-asks getEffectivePermissions but reuses this.
|
||||
function Get-RbacActionCatalog {
|
||||
[CmdletBinding()]
|
||||
param([string]$TenantId, [int]$TokenId = 0)
|
||||
|
||||
# The cached value is a hashtable wrapping the set, and every return of the
|
||||
# set has a leading comma. PowerShell enumerates a HashSet when it is written
|
||||
# to the pipeline - both here and inside Get-CacheObject - so without those
|
||||
# two guards the caller gets an object[] of names instead of the set, and
|
||||
# object[].Contains() is case-SENSITIVE. A hashtable is not enumerated.
|
||||
$cacheName = Get-RbacCatalogCacheName $TenantId
|
||||
$cached = Get-CacheObject $cacheName
|
||||
if($cached -and $cached.Actions.Count -gt 0) { return ,$cached.Actions }
|
||||
|
||||
$response = $null
|
||||
try {
|
||||
$params = @{ Url = $script:RbacResourceOperationsUrl; GraphVersion = "beta"; ODataMetadata = "minimal"; NoError = $true; AllPages = $true }
|
||||
if($TokenId -gt 0) { $params.TokenId = $TokenId }
|
||||
$response = Invoke-MSGraphAPI @params
|
||||
}
|
||||
catch { }
|
||||
|
||||
$actions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach($op in @($response.value)) {
|
||||
if($op -and $op.id) { [void]$actions.Add(([string]$op.id).Trim()) }
|
||||
}
|
||||
if($actions.Count -eq 0) {
|
||||
Write-Log ("Intune RBAC marking: the resource action catalogue ($($script:RbacResourceOperationsUrl)) could not be read. " +
|
||||
"A type is marked read-only only when the role allows no write action at all for its category.") 2
|
||||
return $null
|
||||
}
|
||||
|
||||
Set-CacheObject $cacheName @{ Actions = $actions } "TenantCache_$TenantId" -Persistent
|
||||
return ,$actions
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Context (fetch + cache)
|
||||
|
||||
function Get-RbacTokenFingerprint {
|
||||
[CmdletBinding()]
|
||||
param($Claims)
|
||||
if(-not $Claims) { return $null }
|
||||
return "$($Claims.tid)|$($Claims.oid)|$($Claims.iat)"
|
||||
}
|
||||
|
||||
function Test-RbacAppOnlyClaims {
|
||||
[CmdletBinding()]
|
||||
param($Claims)
|
||||
if(-not $Claims) { return $true }
|
||||
if($Claims.PSObject.Properties['idtyp'] -and ([string]$Claims.idtyp) -eq 'app') { return $true }
|
||||
# No scp at all means no delegated consent - app-only or unreadable.
|
||||
return (-not $Claims.PSObject.Properties['scp'] -or -not $Claims.scp)
|
||||
}
|
||||
|
||||
function Test-RbacFullAccessRole {
|
||||
[CmdletBinding()]
|
||||
param($Claims)
|
||||
if(-not $Claims -or -not $Claims.PSObject.Properties['wids']) { return $false }
|
||||
foreach($w in @($Claims.wids)) {
|
||||
if($w -and $script:RbacFullAccessRoleTemplateIds -contains ([string]$w).Trim()) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-RbacReadOnlyRole {
|
||||
[CmdletBinding()]
|
||||
param($Claims)
|
||||
if(-not $Claims -or -not $Claims.PSObject.Properties['wids']) { return $false }
|
||||
foreach($w in @($Claims.wids)) {
|
||||
if($w -and $script:RbacReadOnlyRoleTemplateIds -contains ([string]$w).Trim()) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-RbacAccessMarkingEnabled {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$v = $null
|
||||
try { $v = Get-SettingValue "UseRbacAccessMarking" } catch { }
|
||||
# Compare as text: `$false -eq ""` is TRUE in PowerShell (the right side is
|
||||
# coerced to bool), which would read an explicit off as "unset".
|
||||
$text = [string]$v
|
||||
if($text -eq "") { return $true }
|
||||
return ($text -eq "true")
|
||||
}
|
||||
|
||||
function Get-RbacCacheName {
|
||||
param([string]$TenantId)
|
||||
return "EffectivePermissions_$TenantId"
|
||||
}
|
||||
|
||||
# The Layer 2 context for the current (or given) token, or $null when Layer 2
|
||||
# does not apply. Never prompts and never throws: every failure path returns
|
||||
# $null after one log line, and a failed fetch is remembered for the token so a
|
||||
# nav rebuild does not re-ask until the token changes.
|
||||
#
|
||||
# Context shape:
|
||||
# Fingerprint, TenantId, AsOf (token iat), Source ('DirectoryRole'|'Graph'),
|
||||
# AllAllowed (bool), Allowed / NotAllowed / Catalog (HashSet[string], Catalog
|
||||
# $null when the catalogue could not be read), Raw (response)
|
||||
function Get-IntuneRbacContext {
|
||||
[CmdletBinding()]
|
||||
param($Claims, [int]$TokenId = 0, [switch]$IgnoreSetting)
|
||||
|
||||
if(-not $IgnoreSetting -and -not (Test-RbacAccessMarkingEnabled)) { return $null }
|
||||
|
||||
if(-not $Claims) { $Claims = Get-AccessTokenClaims }
|
||||
if(-not $Claims) { return $null }
|
||||
if(Test-RbacAppOnlyClaims $Claims) {
|
||||
Write-LogDebug "Intune RBAC marking: app-only token, application permissions are the effective set"
|
||||
return $null
|
||||
}
|
||||
|
||||
$fingerprint = Get-RbacTokenFingerprint $Claims
|
||||
$tenantId = [string]$Claims.tid
|
||||
$asOf = $null
|
||||
try { if($Claims.iat) { $asOf = [datetime]::new(1970, 1, 1, 0, 0, 0, 0, [System.DateTimeKind]::Utc).AddSeconds([double]$Claims.iat).ToLocalTime() } } catch { }
|
||||
|
||||
$cacheName = Get-RbacCacheName $tenantId
|
||||
$cached = Get-CacheObject $cacheName
|
||||
if($cached -and $cached.Fingerprint -eq $fingerprint) {
|
||||
if($cached.Failed) { return $null }
|
||||
return $cached
|
||||
}
|
||||
|
||||
$ctx = [PSCustomObject]@{
|
||||
Fingerprint = $fingerprint
|
||||
TenantId = $tenantId
|
||||
AsOf = $asOf
|
||||
Source = $null
|
||||
AllAllowed = $false
|
||||
AllRead = $false
|
||||
Allowed = $null
|
||||
NotAllowed = $null
|
||||
Catalog = $null
|
||||
Raw = $null
|
||||
Failed = $false
|
||||
Error = $null
|
||||
}
|
||||
|
||||
if(Test-RbacFullAccessRole $Claims) {
|
||||
$ctx.Source = "DirectoryRole"
|
||||
$ctx.AllAllowed = $true
|
||||
Write-Log "Intune RBAC marking: Intune Administrator / Global Administrator role in token; full Intune access assumed, no permission lookup"
|
||||
}
|
||||
else {
|
||||
# Global Reader (or equivalent) guarantees read everywhere. Recorded now
|
||||
# so it applies whether or not the per-action lookup below succeeds.
|
||||
$ctx.AllRead = (Test-RbacReadOnlyRole $Claims)
|
||||
$response = $null
|
||||
try {
|
||||
$params = @{ Url = $script:RbacEffectivePermissionsUrl; GraphVersion = "beta"; ODataMetadata = "minimal"; NoError = $true }
|
||||
if($TokenId -gt 0) { $params.TokenId = $TokenId }
|
||||
$response = Invoke-MSGraphAPI @params
|
||||
}
|
||||
catch {
|
||||
$ctx.Error = $_.Exception.Message
|
||||
}
|
||||
|
||||
$sets = ConvertTo-RbacActionSets $response
|
||||
if(-not $response -or ($sets.Allowed.Count -eq 0 -and $sets.NotAllowed.Count -eq 0)) {
|
||||
if($ctx.AllRead) {
|
||||
# A pure Global Reader with no Intune RBAC assignment: the lookup
|
||||
# says nothing, but the directory role still guarantees read. Mark
|
||||
# from the role instead of failing, so the type shows read-only.
|
||||
$ctx.Source = "DirectoryRole"
|
||||
Write-Log "Intune RBAC marking: Global Reader directory role in token; read-only Intune access assumed (getEffectivePermissions returned nothing)"
|
||||
}
|
||||
else {
|
||||
$ctx.Failed = $true
|
||||
if(-not $ctx.Error) { $ctx.Error = "empty or unreadable response" }
|
||||
Write-Log ("Intune RBAC marking unavailable: getEffectivePermissions returned nothing usable ($($ctx.Error)). " +
|
||||
"The navigation reflects token scopes only until the next token refresh.") 2
|
||||
}
|
||||
}
|
||||
else {
|
||||
$ctx.Source = "Graph"
|
||||
$ctx.Allowed = $sets.Allowed
|
||||
$ctx.NotAllowed = $sets.NotAllowed
|
||||
$ctx.Raw = $response
|
||||
# A populated notAllowed list would be the authoritative statement of
|
||||
# what exists; the catalogue is what makes the verdict possible when
|
||||
# it is empty, which is every response seen so far.
|
||||
$ctx.Catalog = Get-RbacActionCatalog -TenantId $tenantId -TokenId $TokenId
|
||||
if(-not $ctx.Catalog -and $sets.NotAllowed.Count -gt 0) {
|
||||
$ctx.Catalog = [System.Collections.Generic.HashSet[string]]::new($sets.Allowed, [System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach($a in $sets.NotAllowed) { [void]$ctx.Catalog.Add($a) }
|
||||
}
|
||||
$of = if($ctx.Catalog) { " of $($ctx.Catalog.Count)" } else { "" }
|
||||
Write-Log "Intune RBAC marking: $($sets.Allowed.Count)$of Intune resource actions allowed for the signed-in user"
|
||||
}
|
||||
}
|
||||
|
||||
# Persistent + tenant tag: no timeout (the fingerprint is the invalidation)
|
||||
# and Clear-TenantCache sweeps it with the other per-tenant entries.
|
||||
Set-CacheObject $cacheName $ctx "TenantCache_$tenantId" -Persistent
|
||||
if($ctx.Failed) { return $null }
|
||||
return $ctx
|
||||
}
|
||||
|
||||
function Clear-IntuneRbacContext {
|
||||
[CmdletBinding()]
|
||||
param([string]$TenantId)
|
||||
|
||||
if($TenantId) {
|
||||
Clear-CacheObject -Name (Get-RbacCacheName $TenantId)
|
||||
Clear-CacheObject -Name (Get-RbacCatalogCacheName $TenantId)
|
||||
return
|
||||
}
|
||||
foreach($name in @($script:cacheObjects.Keys | Where-Object { $_ -like "EffectivePermissions_*" -or $_ -like "RbacResourceOperations_*" })) {
|
||||
Clear-CacheObject -Name $name
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-RbacEventUserDisconnected {
|
||||
param($Snapshot)
|
||||
$tenantId = $null
|
||||
try { $tenantId = [string]$Snapshot.TenantId } catch { }
|
||||
Clear-IntuneRbacContext -TenantId $tenantId
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Per-type evaluation
|
||||
|
||||
# True when the type declares at least one ReadWrite permission, i.e. it is a
|
||||
# writable feature rather than read-only by design. Shared by the read-guarantee
|
||||
# and write-check paths.
|
||||
function Test-PolicyTypeDeclaresWrite {
|
||||
[CmdletBinding()]
|
||||
param($PolicyType)
|
||||
foreach($perm in @($PolicyType._Permissions | Where-Object { $_ })) {
|
||||
if(Get-PermissionReadVariant $perm) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Layer 2 verdict for one policy type against a context. Returns
|
||||
# @{ Level = [APIAccess]; Info = <tooltip text>; Missing = @(actions) } or $null
|
||||
# for Unknown (no category, category never mentioned, no context).
|
||||
function Get-PolicyTypeRbacAccess {
|
||||
[CmdletBinding()]
|
||||
param($PolicyType, $Context)
|
||||
|
||||
if(-not $PolicyType -or -not $Context) { return $null }
|
||||
$entry = Get-PolicyTypeRbacCategory $PolicyType
|
||||
if(-not $entry) { return $null }
|
||||
|
||||
if($Context.AllAllowed) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
||||
|
||||
# A directory read-only role (Global Reader) with no per-action data: read is
|
||||
# guaranteed, write is not. Read-only-by-design types are Full; the rest are
|
||||
# read-only. Handled before the Allowed check so a pure Global Reader whose
|
||||
# getEffectivePermissions came back empty is still marked.
|
||||
if($Context.AllRead -and -not $Context.Allowed) {
|
||||
if(-not (Test-PolicyTypeDeclaresWrite $PolicyType)) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
||||
return @{
|
||||
Level = [APIAccess]::Limited
|
||||
Info = "Intune role: read-only for $($entry.Category) (Global Reader directory role grants read, not write)"
|
||||
Missing = @()
|
||||
}
|
||||
}
|
||||
if(-not $Context.Allowed) { return $null }
|
||||
|
||||
# The catalogue says which actions exist. Without it (the call failed) every
|
||||
# existence question is unanswerable, and the verdict falls back to the one
|
||||
# thing the allowed list alone can prove - see the write check below.
|
||||
$catalog = $Context.Catalog
|
||||
$readAction = Get-RbacActionName $entry "Read"
|
||||
|
||||
if($catalog -and -not $catalog.Contains($readAction)) {
|
||||
# Intune has no such action: this row of the category table is wrong, not
|
||||
# the user's role. Say nothing rather than something false.
|
||||
return $null
|
||||
}
|
||||
|
||||
# AllRead (Global Reader) guarantees read even when the response omits this
|
||||
# category's read action.
|
||||
if(-not $Context.AllRead -and -not $Context.Allowed.Contains($readAction)) {
|
||||
if(-not $catalog) {
|
||||
# Could be a denied read or a wrong mapping. Unknown.
|
||||
return $null
|
||||
}
|
||||
return @{
|
||||
Level = [APIAccess]::None
|
||||
Info = "Intune role: no read access to $($entry.Category) (missing $readAction)"
|
||||
Missing = @($readAction)
|
||||
}
|
||||
}
|
||||
|
||||
# Read-only by design (declares only Read scopes) needs nothing more.
|
||||
if(-not (Test-PolicyTypeDeclaresWrite $PolicyType)) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
||||
|
||||
$missing = @()
|
||||
$exists = 0
|
||||
foreach($action in $script:RbacWriteActions) {
|
||||
$name = Get-RbacActionName $entry $action
|
||||
if($catalog -and -not $catalog.Contains($name)) { continue } # no such action for this category
|
||||
$exists++
|
||||
if(-not $Context.Allowed.Contains($name)) { $missing += $name }
|
||||
}
|
||||
if(-not $catalog) {
|
||||
# Naming individual missing actions would be inventing them, so the only
|
||||
# honest degraded verdict is the coarse one: a role that allows no write
|
||||
# action at all for the category cannot write it.
|
||||
if($missing.Count -lt $exists) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
||||
return @{
|
||||
Level = [APIAccess]::Limited
|
||||
Info = "Intune role: read-only for $($entry.Category) (no write action allowed)"
|
||||
Missing = @()
|
||||
}
|
||||
}
|
||||
if($missing.Count -eq 0) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
||||
|
||||
# "read-only" is only true when the role allows no write action at all. A role
|
||||
# that can Create and Update but not Delete or Assign - a common custom role - is
|
||||
# still Limited, but calling that read-only misdescribes what the user can do.
|
||||
$short = @($missing | ForEach-Object { $_.Substring($_.LastIndexOf('_') + 1) })
|
||||
$info = if($missing.Count -ge $exists) {
|
||||
"Intune role: read-only for $($entry.Category) (missing $($short -join ', '))"
|
||||
}
|
||||
else {
|
||||
"Intune role: partial write access to $($entry.Category) (missing $($short -join ', '))"
|
||||
}
|
||||
return @{
|
||||
Level = [APIAccess]::Limited
|
||||
Info = $info
|
||||
Missing = $missing
|
||||
# Carried as data, not only as tooltip text, so the Permissions popup's
|
||||
# Role/Effective/Result columns can say "Partial write" instead of
|
||||
# contradicting the tooltip with "Read" and "Read-only".
|
||||
Partial = ($missing.Count -lt $exists)
|
||||
}
|
||||
}
|
||||
|
||||
# The worse of two levels. None outranks Limited outranks Full.
|
||||
function Get-WorstAccessLevel {
|
||||
[CmdletBinding()]
|
||||
[OutputType([APIAccess])]
|
||||
param([APIAccess]$A, [APIAccess]$B)
|
||||
if($A -eq [APIAccess]::None -or $B -eq [APIAccess]::None) { return [APIAccess]::None }
|
||||
if($A -eq [APIAccess]::Limited -or $B -eq [APIAccess]::Limited) { return [APIAccess]::Limited }
|
||||
return [APIAccess]::Full
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
function Invoke-RbacEventAppInitialized {
|
||||
Add-SettingsObject -Title "Use Intune role permissions for access marking" -Key "UseRbacAccessMarking" -Type "Boolean" `
|
||||
-Description "Also ask Intune which resource actions the signed-in user's role allows, and mark menu items the user cannot change (orange) or read (red). Off = mark from the app's token scopes only. Refresh the token from the Profile popup after a role change." `
|
||||
-DefaultValue $true -Section "General"
|
||||
}
|
||||
|
||||
Add-AppEventHandler "AppInitialized" "Invoke-RbacEventAppInitialized"
|
||||
Add-AppEventHandler "AuthenticationUserDisconnected" "Invoke-RbacEventUserDisconnected"
|
||||
@@ -0,0 +1,165 @@
|
||||
# Layer 2 for Entra ID (directory) objects: Conditional Access and its siblings.
|
||||
#
|
||||
# Internal/EffectivePermissions.ps1 asks Intune "what can this user do" via
|
||||
# deviceManagement/getEffectivePermissions. That call is Intune-only - it knows
|
||||
# nothing about Conditional Access, Named Locations, Authentication Strengths or
|
||||
# Authentication Context, which are Entra ID directory objects
|
||||
# (identity/conditionalAccess/*) governed by Entra RBAC, not Intune RBAC.
|
||||
#
|
||||
# Entra ID has no equivalent per-object "getEffectivePermissions" surfaced as a
|
||||
# simple Graph call, so this layer reads the directory roles already carried in
|
||||
# the token's `wids` claim (no extra Graph traffic) and marks from the well-known
|
||||
# roles that govern Conditional Access:
|
||||
#
|
||||
# * Global Administrator -> Full (governs everything).
|
||||
# * Conditional Access Administrator -> write (and therefore read).
|
||||
# * Security Administrator -> write (and therefore read).
|
||||
# * Global Reader / Security Reader -> read only -> a writable type is
|
||||
# marked read-only (orange).
|
||||
#
|
||||
# Conservative on purpose - it only ever DOWNGRADES (Get-WorstAccessLevel), and
|
||||
# only when the token proves a governing role is present:
|
||||
# * No governing role in wids -> Unknown ($null). A custom directory role can
|
||||
# grant Conditional Access access without being one of the ids above, so a
|
||||
# missing role is NOT reported as no-access; Layer 1 (token scopes) stands.
|
||||
# This layer therefore never produces None, only Full / read-only / Unknown.
|
||||
# * A user who holds Global Reader AND a custom role that grants write would be
|
||||
# shown read-only. That combination is unusual and read-only is the safe
|
||||
# reading; scope-limited administrative units are likewise not modelled, the
|
||||
# same caveat Internal/EffectivePermissions.ps1 carries for Intune scope tags.
|
||||
#
|
||||
# Terms of Use (identityGovernance/termsOfUse) is deliberately NOT covered: it is
|
||||
# a different API family and permission model (Agreement.ReadWrite.All), so
|
||||
# Layer 1 alone marks it.
|
||||
|
||||
# Directory role template ids are fixed across tenants (wids carries the template
|
||||
# id, not the tenant's role object id).
|
||||
$script:EntraDirectoryRoleTemplateIds = @{
|
||||
GlobalAdministrator = "62e90394-69f5-4237-9190-012177145e10"
|
||||
GlobalReader = "f2ef992c-3afb-46b9-b7cf-a126ee74c451"
|
||||
SecurityAdministrator = "194ae4cb-b126-40b2-bd5b-6091b380977d"
|
||||
SecurityReader = "5d6b6bb7-de71-4623-b4af-96380a352509"
|
||||
ConditionalAccessAdministrator = "b1be1c3e-b65d-4f19-8427-f6fa0d97feb9"
|
||||
}
|
||||
|
||||
# _API prefix -> the Entra roles that grant write / read for that area. Longest
|
||||
# matching prefix wins (same rule as the Intune map). Global Administrator implies
|
||||
# both everywhere and is handled separately, so it is not repeated here.
|
||||
$script:EntraRoleApiMap = @{
|
||||
"identity/conditionalAccess" = @{
|
||||
Category = "Conditional Access"
|
||||
WriteRoles = @("SecurityAdministrator", "ConditionalAccessAdministrator")
|
||||
ReadRoles = @("GlobalReader", "SecurityReader")
|
||||
}
|
||||
}
|
||||
|
||||
# The Entra-role entry for an API, or $null when this layer does not govern it.
|
||||
function Get-EntraRoleCategoryForApi {
|
||||
[CmdletBinding()]
|
||||
param([string]$Api)
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($Api)) { return $null }
|
||||
$api = $Api.Trim().TrimStart('/')
|
||||
|
||||
$best = $null
|
||||
foreach($key in $script:EntraRoleApiMap.Keys) {
|
||||
if($api -eq $key -or $api.StartsWith("$key/", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
if(-not $best -or $key.Length -gt $best.Length) { $best = $key }
|
||||
}
|
||||
}
|
||||
if(-not $best) { return $null }
|
||||
return $script:EntraRoleApiMap[$best]
|
||||
}
|
||||
|
||||
# The set of directory role template ids in the token, case-insensitive.
|
||||
function Get-EntraDirectoryRoleIds {
|
||||
[CmdletBinding()]
|
||||
param($Claims)
|
||||
|
||||
$set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
if(-not $Claims -or -not $Claims.PSObject.Properties['wids']) { return ,$set }
|
||||
foreach($w in @($Claims.wids)) {
|
||||
if($w) { [void]$set.Add(([string]$w).Trim()) }
|
||||
}
|
||||
return ,$set
|
||||
}
|
||||
|
||||
# Layer 2 verdict for one Entra-governed policy type against the token claims.
|
||||
# Returns @{ Level = [APIAccess]; Info; Missing = @() } or $null (Unknown) when
|
||||
# this layer does not apply or cannot tell. Never returns None (see the header).
|
||||
function Get-PolicyTypeEntraRoleAccess {
|
||||
[CmdletBinding()]
|
||||
param($PolicyType, $Claims)
|
||||
|
||||
if(-not $PolicyType -or -not $Claims) { return $null }
|
||||
$entry = Get-EntraRoleCategoryForApi ([string]$PolicyType._API)
|
||||
if(-not $entry) { return $null }
|
||||
|
||||
$wids = Get-EntraDirectoryRoleIds $Claims
|
||||
if($wids.Count -eq 0) { return $null } # no directory roles to judge by
|
||||
|
||||
if($wids.Contains($script:EntraDirectoryRoleTemplateIds.GlobalAdministrator)) {
|
||||
return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() }
|
||||
}
|
||||
|
||||
$hasWrite = $false
|
||||
foreach($role in @($entry.WriteRoles)) {
|
||||
$id = $script:EntraDirectoryRoleTemplateIds[$role]
|
||||
if($id -and $wids.Contains($id)) { $hasWrite = $true; break }
|
||||
}
|
||||
if($hasWrite) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
||||
|
||||
$hasRead = $false
|
||||
foreach($role in @($entry.ReadRoles)) {
|
||||
$id = $script:EntraDirectoryRoleTemplateIds[$role]
|
||||
if($id -and $wids.Contains($id)) { $hasRead = $true; break }
|
||||
}
|
||||
if(-not $hasRead) { return $null } # a role we do not model may still grant access
|
||||
|
||||
# Read confirmed. Read-only-by-design types are Full; writable types are
|
||||
# marked read-only because a reader role grants no write.
|
||||
if(-not (Test-PolicyTypeDeclaresWrite $PolicyType)) { return @{ Level = [APIAccess]::Full; Info = ""; Missing = @() } }
|
||||
return @{
|
||||
Level = [APIAccess]::Limited
|
||||
Info = "Entra role: read-only for $($entry.Category) (directory role grants read, not write)"
|
||||
Missing = @()
|
||||
}
|
||||
}
|
||||
|
||||
# Tenant-wide read-only floor from the Global Reader directory role, for the types
|
||||
# neither the Intune RBAC layer (Internal/EffectivePermissions.ps1) nor the
|
||||
# Conditional Access layer above resolve: Device Categories, Windows 365, Entra
|
||||
# Branding, Terms of Use - anything whose API maps to no Intune RBAC category and
|
||||
# is not Conditional Access, so it would otherwise sit at the token level (Full).
|
||||
#
|
||||
# Global Reader is the read-only twin of Global Administrator: it can read
|
||||
# essentially every admin surface in the tenant but write none, so a writable
|
||||
# type is read-only for it. This is the catch-all applied after the two
|
||||
# API-specific layers return Unknown. Only ever DOWNGRADES:
|
||||
# * Global Administrator -> $null. It can write; Full from Layer 1 stands.
|
||||
# * Global Reader -> a writable type => Limited (read-only); a
|
||||
# read-only-by-design type => $null (Full stands).
|
||||
# * Neither role in wids -> $null. Layer 1 stands; a custom role may grant more.
|
||||
# Never returns None: if the token also lacks the read scope, Layer 1 already
|
||||
# marked the type None and a downgrade-only verdict cannot lift it. Scope-limited
|
||||
# administrative units are not modelled (the same caveat as the layers above).
|
||||
function Get-PolicyTypeDirectoryRoleReadFloor {
|
||||
[CmdletBinding()]
|
||||
param($PolicyType, $Claims)
|
||||
|
||||
if(-not $PolicyType -or -not $Claims) { return $null }
|
||||
|
||||
$wids = Get-EntraDirectoryRoleIds $Claims
|
||||
if($wids.Count -eq 0) { return $null }
|
||||
|
||||
# A global writer keeps Full; only a pure global reader forces read-only.
|
||||
if($wids.Contains($script:EntraDirectoryRoleTemplateIds.GlobalAdministrator)) { return $null }
|
||||
if(-not $wids.Contains($script:EntraDirectoryRoleTemplateIds.GlobalReader)) { return $null }
|
||||
|
||||
if(-not (Test-PolicyTypeDeclaresWrite $PolicyType)) { return $null } # read-only feature: Full stands
|
||||
return @{
|
||||
Level = [APIAccess]::Limited
|
||||
Info = "Entra role: read-only (Global Reader grants read across the tenant, not write)"
|
||||
Missing = @()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
# Enumerating the tenants a signed-in account can reach.
|
||||
#
|
||||
# Microsoft Graph still has no API for this. tenantRelationships
|
||||
# findTenantInformationByTenantId / ByDomainName resolve ONE tenant you already
|
||||
# know the id or domain of, and the multi-tenant-organization APIs only list the
|
||||
# members of a configured MTO. Neither answers "which tenants can I sign in to".
|
||||
# Azure Resource Manager's /tenants does, and remains the endpoint the portal's
|
||||
# own directory switcher uses.
|
||||
#
|
||||
# The catch is the audience: ARM tokens are not Graph tokens, so the account has
|
||||
# to mint a second one for the ARM resource. That is why only MSAL implements
|
||||
# AuthenticationProvider.GetAccessibleTenants today - the MgGraph SDK hands out
|
||||
# Graph-scoped tokens only. It is also why this fails on app registrations that
|
||||
# were never granted the delegated Azure Service Management permission, which is
|
||||
# the case this file reports clearly instead of swallowing (Get-TenantList's
|
||||
# original catch{} left the list silently empty).
|
||||
|
||||
# Delegated permission on the "Windows Azure Service Management API" service
|
||||
# principal. The resource it is requested on is per-cloud, so the scope is built at
|
||||
# call time from Get-EntraArmAudience.
|
||||
$script:EntraArmScopeSuffix = "/user_impersonation"
|
||||
|
||||
# The ARM host the tenant list is READ from. Kept beside its only consumer; the flat
|
||||
# cloud table in AuthenticationCore.ps1 owns the value itself (ArmHost).
|
||||
function Get-EntraArmHost {
|
||||
param([string]$Cloud)
|
||||
|
||||
$entry = Get-CloudByValue $Cloud
|
||||
if($entry -and $entry.ArmHost) { return $entry.ArmHost }
|
||||
return "management.azure.com"
|
||||
}
|
||||
|
||||
# The OAuth resource the ARM token is REQUESTED on - a separate value from the host
|
||||
# above, even though the two are the same string in the public cloud. Reusing the
|
||||
# REST endpoint as the audience is what made sovereign clouds wrong: USGov and China
|
||||
# have their own Azure Service Management identifiers, and Entra answers a request
|
||||
# for a resource its tenant does not know with AADSTS500011, not with anything that
|
||||
# points at the resource string.
|
||||
#
|
||||
# An ordered list, because which identifier a given tenant accepts is not something
|
||||
# this module can decide from here: current Azure SDK cloud metadata uses the ARM
|
||||
# endpoint itself as the audience, while Az and the CLI still carry the older
|
||||
# management.core.* ASM identifier, and sovereign tenants differ in which is
|
||||
# provisioned. First entry is tried first, and Get-EntraAccessibleTenant only moves
|
||||
# on when the failure says the resource itself was rejected.
|
||||
function Get-EntraArmAudience {
|
||||
param([string]$Cloud)
|
||||
|
||||
$entry = Get-CloudByValue $Cloud
|
||||
if($entry -and $entry.ArmAudiences) { return @($entry.ArmAudiences) }
|
||||
return @("https://$(Get-EntraArmHost $Cloud)")
|
||||
}
|
||||
|
||||
# Classify an acquire failure. "The app was never granted Azure Service Management"
|
||||
# is one of several things that can go wrong here, and reporting the others as it
|
||||
# sends the user after the wrong thing: a prompt that timed out, a refresh token that
|
||||
# was revoked, conditional access or an MFA requirement are not fixed by granting a
|
||||
# permission. Get-MsalAuthenticationToken reports a timeout as a plain string, a user
|
||||
# cancel as OperationCanceledException and anything else as the MSAL exception, so
|
||||
# all three shapes arrive here.
|
||||
#
|
||||
# None - nothing captured; the silent acquire simply found no cached token
|
||||
# Resource - Entra did not accept the requested resource identifier, so another
|
||||
# audience is worth trying
|
||||
# Consent - the app or the user has not consented to Azure Service Management
|
||||
# Interaction - UI required, but NOT specifically consent: an expired or revoked
|
||||
# grant, conditional access, MFA, login_required, an expired
|
||||
# password. MSAL reports all of these as MsalUiRequiredException and
|
||||
# Entra as invalid_grant, so neither of those on its own means consent
|
||||
# Cancelled - the user closed the prompt
|
||||
# TimedOut - the prompt was never answered
|
||||
# Other - anything else; its own message is all the caller can act on
|
||||
function Get-EntraArmFailureKind {
|
||||
param($Failure)
|
||||
|
||||
if(-not $Failure) { return "None" }
|
||||
if($Failure -is [string]) { return "TimedOut" }
|
||||
if($Failure -is [System.OperationCanceledException]) { return "Cancelled" }
|
||||
|
||||
$text = "$($Failure.ErrorCode) $($Failure.Message)"
|
||||
|
||||
# Rule out a rejected resource identifier before anything else: AADSTS500011
|
||||
# ("resource principal not found in the tenant") is exactly what a tenant says
|
||||
# about an audience it does not know, which includes an audience this module
|
||||
# picked wrongly, and AADSTS650057 names an invalid resource outright.
|
||||
if($text -match 'AADSTS500011|AADSTS650057|invalid_resource') { return "Resource" }
|
||||
|
||||
# AADSTS65001: no consent recorded for the app. AADSTS65004: the user declined it.
|
||||
if($text -match 'AADSTS65001|AADSTS65004') { return "Consent" }
|
||||
|
||||
# Type names, not a type literal: MSAL may not be loaded in the caller's session,
|
||||
# and PSObject.TypeNames carries the whole inheritance chain of a real exception.
|
||||
if(@($Failure.PSObject.TypeNames) -like '*MsalUiRequiredException') {
|
||||
# MSAL's own verdict when it has one. Without it, this exception says no more
|
||||
# than "a prompt is needed", which is true of every interaction state.
|
||||
if("$($Failure.Classification)" -eq 'ConsentRequired') { return "Consent" }
|
||||
return "Interaction"
|
||||
}
|
||||
|
||||
return "Other"
|
||||
}
|
||||
|
||||
# The no-prompt interactive round, kept in its own function so the decision about
|
||||
# which failure to report is testable without MSAL: this is the only part of the
|
||||
# acquire that needs the assembly loaded. It succeeds when the existing session
|
||||
# already carries the consent, and fails fast otherwise.
|
||||
#
|
||||
# "No prompt" is about what Entra will ASK, not about what appears on screen, and
|
||||
# the difference matters to anyone reusing this: Prompt.NoPrompt sends prompt=none,
|
||||
# so the STS answers login_required / interaction_required instead of asking for
|
||||
# credentials or consent - but this is still AcquireTokenInteractive, so MSAL starts
|
||||
# the interactive flow and the system browser or the broker can open a window (and
|
||||
# on some platforms flash one) before that answer arrives. It is therefore only
|
||||
# safe where a user is present, which is what the caller's -AllowInteractive gate
|
||||
# means: the MSAL provider sets it only once the UI is up.
|
||||
#
|
||||
# Returns $authResult, $failure like Get-MsalAuthenticationToken.
|
||||
function Get-EntraArmInteractiveToken {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$App,
|
||||
[Parameter(Mandatory = $true)]$Account,
|
||||
[Parameter(Mandatory = $true)][string[]]$Scope,
|
||||
[string]$TenantId
|
||||
)
|
||||
|
||||
$interactive = $App.AcquireTokenInteractive($Scope)
|
||||
[void]$interactive.WithLoginHint($Account.Username)
|
||||
# Suppresses the credential and consent prompts, not the browser or broker itself.
|
||||
[void]$interactive.WithPrompt([Microsoft.Identity.Client.Prompt]::NoPrompt)
|
||||
if($TenantId) { [void]$interactive.WithTenantId($TenantId) }
|
||||
|
||||
return @(Get-MsalAuthenticationToken $interactive -Interactive)
|
||||
}
|
||||
|
||||
# One acquire round for one audience: silent first and, only with a user present, a
|
||||
# no-prompt interactive round. Returns $authResult, $failure - the same pair shape as
|
||||
# Get-MsalAuthenticationToken.
|
||||
function Get-EntraArmToken {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$App,
|
||||
[Parameter(Mandatory = $true)]$Account,
|
||||
[Parameter(Mandatory = $true)][string[]]$Scope,
|
||||
[string]$TenantId,
|
||||
[switch]$AllowInteractive
|
||||
)
|
||||
|
||||
$authResult = $null
|
||||
$failure = $null
|
||||
|
||||
try {
|
||||
$silentBuilder = $App.AcquireTokenSilent($Scope, $Account)
|
||||
if($TenantId) { [void]$silentBuilder.WithTenantId($TenantId) }
|
||||
$authResult, $failure = Get-MsalAuthenticationToken $silentBuilder
|
||||
|
||||
# A bare "UI required" is the one failure a no-prompt round can still resolve:
|
||||
# the cache has nothing, but the browser session may. Consent, a rejected
|
||||
# resource, a cancel or a timeout are all answered already - prompting again
|
||||
# only risks a window nobody asked for.
|
||||
if(-not $authResult -and $AllowInteractive -and (Get-EntraArmFailureKind $failure) -eq "Interaction") {
|
||||
$authResult, $interactiveFailure = Get-EntraArmInteractiveToken -App $App -Account $Account `
|
||||
-Scope $Scope -TenantId $TenantId
|
||||
|
||||
# The interactive reason is the newer and, in every kind but one, the more
|
||||
# specific one: a cancel or timeout is the user's decision, a named consent
|
||||
# or resource error is the answer this round was for, and a proxy, broker or
|
||||
# service error is the thing that actually stopped it - reporting the silent
|
||||
# "a prompt is needed" over any of those sends the user off to sign in again
|
||||
# for a problem signing in cannot fix.
|
||||
#
|
||||
# The exception is another UI-required state: a no-prompt round says that
|
||||
# whenever the browser session cannot satisfy it, which is no more than the
|
||||
# silent failure already said, so the silent reason stays.
|
||||
$interactiveKind = Get-EntraArmFailureKind $interactiveFailure
|
||||
if(-not $authResult -and $interactiveKind -notin @("None", "Interaction")) {
|
||||
$failure = $interactiveFailure
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$failure = $_.Exception
|
||||
Write-LogDebug "Tenant list: ARM token acquire threw - $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
return @($authResult, $failure)
|
||||
}
|
||||
|
||||
# Ask Azure Resource Manager which tenants this account can reach.
|
||||
#
|
||||
# Returns a result object rather than a bare list so callers can tell the three
|
||||
# outcomes apart:
|
||||
# Tenants - the rows ARM returned (possibly empty)
|
||||
# ConsentMissing - the app registration lacks the Azure Service Management
|
||||
# permission (or ARM refused the token with 401/403). Only set
|
||||
# for consent-shaped failures: a cancelled prompt, an expired
|
||||
# grant, conditional access or a network error leave it false and
|
||||
# report their own reason in Message.
|
||||
# Message - one sentence describing what to do about it
|
||||
# ArmHost - the host the list was read from
|
||||
# ArmAudience - the resource identifier the token was issued for
|
||||
function Get-EntraAccessibleTenant {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# MSAL application object able to acquire for this account.
|
||||
[Parameter(Mandatory = $true)]$App,
|
||||
# MSAL account (TokenInfo.Token.Account).
|
||||
[Parameter(Mandatory = $true)]$Account,
|
||||
[string]$TenantId,
|
||||
[string]$Cloud,
|
||||
# Allow a no-prompt interactive round when the silent acquire needs one. Off for
|
||||
# scripted callers: prompt=none stops Entra asking for credentials or consent,
|
||||
# but the round is still an interactive acquire, so a browser or broker window
|
||||
# can appear and there would be nobody to deal with it.
|
||||
[switch]$AllowInteractive
|
||||
)
|
||||
|
||||
$armHost = Get-EntraArmHost $Cloud
|
||||
$audiences = Get-EntraArmAudience $Cloud
|
||||
$result = [PSCustomObject]@{
|
||||
Tenants = @()
|
||||
ConsentMissing = $false
|
||||
Message = $null
|
||||
ArmHost = $armHost
|
||||
ArmAudience = $audiences[0]
|
||||
}
|
||||
|
||||
$authResult = $null
|
||||
$authenticationFailure = $null
|
||||
$failureKind = "None"
|
||||
|
||||
foreach($audience in $audiences) {
|
||||
$scope = [string[]]("$audience$($script:EntraArmScopeSuffix)")
|
||||
$authResult, $authenticationFailure = Get-EntraArmToken -App $App -Account $Account `
|
||||
-Scope $scope -TenantId $TenantId -AllowInteractive:$AllowInteractive
|
||||
if($authResult) {
|
||||
$result.ArmAudience = $audience
|
||||
break
|
||||
}
|
||||
|
||||
$failureKind = Get-EntraArmFailureKind $authenticationFailure
|
||||
|
||||
# A rejected resource identifier is the only failure another audience can fix.
|
||||
# Every other kind would fail identically on the next one, and each extra round
|
||||
# is another chance of an unwanted prompt.
|
||||
if($failureKind -ne "Resource") { break }
|
||||
Write-Log "Tenant list: $audience was not accepted as an Azure Service Management resource for this tenant - trying the next identifier" 2
|
||||
}
|
||||
|
||||
if(-not $authResult) {
|
||||
$reason = if($authenticationFailure -is [string]) { $authenticationFailure }
|
||||
elseif($authenticationFailure) { $authenticationFailure.Message }
|
||||
else { "no reason was reported" }
|
||||
|
||||
if($failureKind -eq "None" -or $failureKind -eq "Consent") {
|
||||
# Nothing cached for this resource, or Entra naming consent outright: the
|
||||
# never-granted app registration, which is the historical verdict here.
|
||||
$result.ConsentMissing = $true
|
||||
$result.Message = "The application could not get an Azure Service Management token for this account, so the tenant list is unavailable. Grant the Entra app registration the delegated permission 'Azure Service Management / user_impersonation' and consent to it, then sign in again."
|
||||
Write-Log "Tenant list: no ARM token for $armHost - the app is most likely missing the Azure Service Management delegated permission" 2
|
||||
}
|
||||
elseif($failureKind -eq "Resource") {
|
||||
# Every known identifier for this cloud was refused. Either the Azure
|
||||
# Service Management principal is not in the tenant, or none of the
|
||||
# identifiers this module knows is the one it wants - say both rather than
|
||||
# picking one.
|
||||
$result.Message = "Entra did not accept $($audiences -join ' or ') as an Azure Service Management resource for this tenant, so the tenant list is unavailable. Either the 'Windows Azure Service Management API' service principal does not exist in the tenant, or this cloud uses a different resource identifier - $reason"
|
||||
Write-Log "Tenant list: no audience accepted for $armHost - $reason" 2
|
||||
}
|
||||
elseif($failureKind -eq "Interaction") {
|
||||
# UI required for a reason that is not consent. Naming a permission grant
|
||||
# here would be a guess, and the wrong one whenever a sign-in is what is
|
||||
# actually needed.
|
||||
$result.Message = "Could not get an Azure Service Management token for $armHost without prompting, so the tenant list is unavailable. The account may need to sign in again - an expired or revoked grant, conditional access and an MFA requirement all end here - $reason"
|
||||
Write-Log "Tenant list: ARM token needs interaction for $armHost - $reason" 2
|
||||
}
|
||||
else {
|
||||
# Cancelled, timed out, or something else entirely. Its own message is the
|
||||
# only thing the caller can act on.
|
||||
$result.Message = "Could not get an Azure Service Management token for $armHost, so the tenant list is unavailable - $reason"
|
||||
Write-Log "Tenant list: ARM token acquire failed for $armHost - $reason" 2
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
$params = @{}
|
||||
$proxyURI = Get-ProxyURI
|
||||
if($proxyURI) {
|
||||
$params.Add("proxy", $proxyURI)
|
||||
$params.Add("UseBasicParsing", $true)
|
||||
}
|
||||
|
||||
try {
|
||||
$headers = @{
|
||||
'Content-Type' = 'application/json'
|
||||
'Authorization' = "Bearer " + $authResult.AccessToken
|
||||
}
|
||||
$response = Invoke-RestMethod "https://$armHost/tenants?api-version=2020-01-01" -Headers $headers @params
|
||||
if($response) { $result.Tenants = @($response.Value) }
|
||||
Write-Log "Tenant list: $($result.Tenants.Count) tenant(s) returned by $armHost"
|
||||
}
|
||||
catch {
|
||||
$status = $null
|
||||
try { $status = [int]$_.Exception.Response.StatusCode } catch { }
|
||||
if($status -in @(401, 403)) {
|
||||
$result.ConsentMissing = $true
|
||||
$result.Message = "Azure Resource Manager rejected the token with HTTP $status. The Entra app registration needs the delegated permission 'Azure Service Management / user_impersonation', with consent granted for this account."
|
||||
}
|
||||
else {
|
||||
$result.Message = "Could not read the tenant list from $armHost - $($_.Exception.Message)"
|
||||
}
|
||||
Write-LogError "Tenant list: request to $armHost failed" $_.Exception
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
# Organization tokenization for exported JSON.
|
||||
#
|
||||
# Exported files replace tenant-specific values with placeholders so the same
|
||||
# file can be imported into another tenant: the tenant id becomes
|
||||
# %OrganizationId%, and the organization (company) name can become
|
||||
# %OrganizationName% - but does not by default, see $script:GraphExportTokensDefault.
|
||||
# Before this file the replace was open-coded at four call sites (two export, two
|
||||
# import), was unconditional, and used the raw value as a REGEX - so an
|
||||
# organization name containing regex metacharacters, e.g. "Contoso (AU)", was
|
||||
# silently never replaced.
|
||||
#
|
||||
# This file owns the whole concept:
|
||||
# * which tokens exist, how they are spelled, and whether import restores them
|
||||
# ($script:GraphExportTokens - one row per token, both directions declared);
|
||||
# * the two settings that control it (below);
|
||||
# * the replace itself. Convert-GraphExportLiteralReplace is the ONLY place in
|
||||
# the project that turns an organization value into a token or back.
|
||||
#
|
||||
# Direction of the two converters:
|
||||
# Convert-GraphOrganizationValueToToken export: "Contoso" -> %OrganizationName%
|
||||
# Convert-GraphOrganizationTokenToValue import: %OrganizationName% -> "Fabrikam"
|
||||
#
|
||||
# Every token the export can write, the import puts back - to the TARGET tenant's
|
||||
# values, which is what makes an exported file tenant-neutral rather than merely
|
||||
# tenant-anonymised.
|
||||
#
|
||||
# Only the export direction is gated by the settings. Restoring is unconditional
|
||||
# on purpose: a file exported last year carries tokens no matter how the setting
|
||||
# is configured today, and leaving them literal would import broken values.
|
||||
#
|
||||
# See Docs/ExportImportAndCopy.md ("Organization Tokens").
|
||||
|
||||
# This file sorts before Internal/IntuneManager.ps1, which is where the
|
||||
# Import/Export section is normally declared. Add-SettingsSection is idempotent,
|
||||
# so declaring it here too makes the registration below load-order independent.
|
||||
Add-SettingsSection -Title "Import/Export" -Id "ImportExport" -Order 10
|
||||
|
||||
Add-SettingsObject -Title "Replace organization values in export files" -Key "ExportReplaceOrganizationValues" -Type "Boolean" `
|
||||
-Description "Replace tenant-specific values with placeholders in exported JSON. By default the tenant id becomes %OrganizationId%; the organization name is left as written unless you opt in. Turn this off to export the raw values - readable and diff-friendly, but the file is then tied to the tenant it came from. Importing a file that already contains placeholders always resolves them, whatever this is set to. Which values are replaced can be changed, see Docs/ExportImportAndCopy.md." -DefaultValue $true `
|
||||
-SubPath "IntuneManager" -Section "ImportExport"
|
||||
|
||||
# One row per token. Order matters and matches the original open-coded order:
|
||||
# the id is replaced before the name, which is what keeps a tenant whose
|
||||
# organization name is unset (the authentication providers then fall back to
|
||||
# using the tenant GUID as the name) producing %OrganizationId%, not
|
||||
# %OrganizationName%, for that GUID.
|
||||
#
|
||||
# RestoreOnImport is $true for BOTH tokens. Anything the export writes, the
|
||||
# import has to put back - a token is a placeholder for a value, not a value.
|
||||
#
|
||||
# It used to be $false for the name, inherited from before this file existed:
|
||||
# the open-coded export replaced both values but the open-coded import resolved
|
||||
# %OrganizationId% only. So a tokenized name was never put back and the import
|
||||
# CREATED the object with the placeholder still in it - a policy literally named
|
||||
# "%OrganizationName% - Baseline", and every description, rule or OMA-URI value
|
||||
# that mentioned the organization carrying "%OrganizationName%" verbatim. Even a
|
||||
# same-tenant export/re-import round trip lost the name that way.
|
||||
#
|
||||
# The justification recorded for it was that restoring would defeat cross-tenant
|
||||
# name matching, since Normalize-IntuneImportPolicyName strips the token so
|
||||
# "%OrganizationName% - Baseline" matches "Contoso - Baseline" in the target.
|
||||
# That does not follow: match resolution runs in the driver/UI layer on the
|
||||
# object loaded from the FILE (Resolve-IntuneImportUpdateTarget, called before
|
||||
# ImportObject/UpdateObject), and the restore runs inside those. The matcher
|
||||
# therefore sees the token either way, and restoring changes only what is
|
||||
# written to Graph. Repeat imports of the same file keep matching too: the file
|
||||
# still holds the token, and the normalizer also strips the literal organization
|
||||
# name of the tenant being imported INTO.
|
||||
#
|
||||
# If the target organization name cannot be resolved, Get-GraphExportTokenValue
|
||||
# returns nothing and the token is left literal rather than replaced with a
|
||||
# guess. Convert-GraphOrganizationTokenToValue logs a warning when that happens
|
||||
# to a token the document actually contains, because the object it then creates
|
||||
# carries a placeholder in a user-visible field.
|
||||
#
|
||||
# Name is the canonical value for the ExportReplaceTokens setting; Aliases are
|
||||
# the other spellings accepted there (Name itself included, so matching is one
|
||||
# lookup). Adding a token is one row plus one case in Get-GraphExportTokenValue.
|
||||
$script:GraphExportTokens = @(
|
||||
[PSCustomObject]@{
|
||||
Name = "OrganizationId"
|
||||
Token = "%OrganizationId%"
|
||||
Aliases = @("OrganizationId", "TenantId", "OrgId")
|
||||
RestoreOnImport = $true
|
||||
}
|
||||
[PSCustomObject]@{
|
||||
Name = "OrganizationName"
|
||||
Token = "%OrganizationName%"
|
||||
Aliases = @("OrganizationName", "CompanyName", "OrgName", "TenantName")
|
||||
RestoreOnImport = $true
|
||||
}
|
||||
)
|
||||
|
||||
# Used when the hidden ExportReplaceTokens setting is missing or empty.
|
||||
#
|
||||
# The tenant id only. The id is machine-readable and unsafe to leave literal - it
|
||||
# is what ties a file to one tenant, and nothing reads it as prose. The
|
||||
# organization name is the opposite on both counts: it appears inside policy
|
||||
# names, descriptions, rules and OMA-URI values, where it is text a human wrote,
|
||||
# and the boundary guard cannot make a short name ("IT") safe in every document.
|
||||
# A false match there does not just mask a value, it rewrites unrelated prose -
|
||||
# and on import rewrites it again, to the target organization's name. Opting in
|
||||
# is a deliberate choice about your own naming conventions, so it is not made for
|
||||
# you. Add OrganizationName to ExportReplaceTokens to enable it - see the setting
|
||||
# comment in Get-GraphExportTokenNames.
|
||||
$script:GraphExportTokensDefault = "OrganizationId"
|
||||
|
||||
# Every token literal, for callers that need the spellings but not the replace
|
||||
# (import name normalization).
|
||||
function Get-GraphExportTokenStrings
|
||||
{
|
||||
[OutputType([string[]])]
|
||||
param()
|
||||
|
||||
return @($script:GraphExportTokens | ForEach-Object { $_.Token })
|
||||
}
|
||||
|
||||
# Case-insensitive LITERAL replace - the single replace in the token pipeline.
|
||||
#
|
||||
# Both sides need escaping. -replace treats its pattern as a regex, so an
|
||||
# organization name like "Contoso (AU)" used to be read as a capture group and
|
||||
# never matched; and it treats '$' in the replacement as a group reference, so a
|
||||
# value containing '$' would be mangled ('$$' is how .NET spells a literal '$').
|
||||
# IgnoreCase keeps the pre-existing behaviour of -replace, which matters for a
|
||||
# hand-edited file that spells the token in another case.
|
||||
#
|
||||
# -RequireValueBoundary rejects a match that is glued to an alphanumeric on
|
||||
# either side, so the value is only replaced where it stands as a value of its
|
||||
# own. It is what stops a short organization name from being found inside
|
||||
# unrelated words: with an organization literally named "IT", an unguarded
|
||||
# replace turned every "Security" in the file into "Secur%OrganizationName%y".
|
||||
# The exported file is corrupt past recovery either way - importing it now
|
||||
# resolves the placeholder to the target organization, so "Security" comes back
|
||||
# as "SecurFabrikamy" rather than as itself. The boundary is spelled
|
||||
# [\p{L}\p{N}] rather than \w on purpose - '_' has to count as a boundary, or a
|
||||
# tenant id inside a compound Graph id ("<guid>_<guid>_0") would stop being
|
||||
# masked - and rather than [0-9A-Za-z], which gave a non-ASCII name no guard at all.
|
||||
#
|
||||
# The cost is that a value glued to a word is no longer replaced:
|
||||
# "ContosoBaseline" keeps the literal name where it used to become
|
||||
# "%OrganizationName%Baseline". That is the right trade - a missed placeholder
|
||||
# leaves a readable, tenant-specific name, a false one silently rewrites
|
||||
# unrelated data - but it does mean cross-tenant name matching only works for
|
||||
# names where the organization name is a separate word.
|
||||
#
|
||||
# Only the export direction passes the switch. On import the search string is a
|
||||
# token literal ("%OrganizationId%"), which is delimited by '%' already.
|
||||
function Convert-GraphExportLiteralReplace
|
||||
{
|
||||
[OutputType([string])]
|
||||
param([string]$Text, [string]$Find, [string]$ReplaceWith, [switch]$RequireValueBoundary)
|
||||
|
||||
if(-not $Text -or -not $Find) { return $Text }
|
||||
|
||||
$pattern = [Regex]::Escape($Find)
|
||||
|
||||
if($RequireValueBoundary)
|
||||
{
|
||||
# [\p{L}\p{N}] - any Unicode letter or number - rather than [0-9A-Za-z].
|
||||
# Organization names are not ASCII: with an ASCII-only class, a name
|
||||
# starting with 'A' or 'E' got no left-hand guard at all (neither is an
|
||||
# ASCII alphanumeric), so the guard silently did nothing on that side and
|
||||
# the name was still found inside unrelated words. \p also keeps '_' a
|
||||
# boundary, which \w would not - a tenant id inside a compound Graph id
|
||||
# ("<guid>_<guid>_0") has to stay maskable.
|
||||
#
|
||||
# Anchored per side: a value that already starts or ends with punctuation
|
||||
# ("Contoso (AU)") needs no guard on that side, and adding one there would
|
||||
# never match - there is no boundary between two non-alphanumerics.
|
||||
if($Find -match '^[\p{L}\p{N}]') { $pattern = "(?<![\p{L}\p{N}])$pattern" }
|
||||
if($Find -match '[\p{L}\p{N}]$') { $pattern = "$pattern(?![\p{L}\p{N}])" }
|
||||
}
|
||||
|
||||
return [Regex]::Replace($Text,
|
||||
$pattern,
|
||||
($ReplaceWith -replace '\$', '$$$$'),
|
||||
[Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
}
|
||||
|
||||
# The value a token stands for. Callers can override per call: a cross-tenant
|
||||
# export has to mask the exported object's OWN tenant id, not the id of the
|
||||
# tenant that happens to be the default one.
|
||||
function Get-GraphExportTokenValue
|
||||
{
|
||||
[OutputType([string])]
|
||||
param([string]$Name, [string]$OrganizationId, [string]$OrganizationName)
|
||||
|
||||
switch($Name)
|
||||
{
|
||||
"OrganizationId" { if($OrganizationId) { return $OrganizationId } return (Get-CurrentTenantId) }
|
||||
"OrganizationName" {
|
||||
if($OrganizationName) { return $OrganizationName }
|
||||
|
||||
# Only fall back to the connected tenant's name when the call IS about the
|
||||
# connected tenant. An -OrganizationId for another tenant with no name meant
|
||||
# tenant A's data was searched for tenant B's organization name: a false hit
|
||||
# rewrites unrelated data with a token import never restores, and a miss
|
||||
# leaves the file unchanged. Returning nothing leaves the name literal,
|
||||
# which is the recoverable outcome.
|
||||
if($OrganizationId -and $OrganizationId -ne [string](Get-CurrentTenantId)) { return $null }
|
||||
|
||||
return (Get-CurrentOrganizationName)
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# The organization an object's DATA belongs to, which on a cross-tenant export is
|
||||
# not the organization the default token points at. Exporting an object fetched
|
||||
# from tenant A while tenant B is the default used to mask B's id in A's data,
|
||||
# leaving A's real id in the file - a mixed-tenant export that then imported A's
|
||||
# tenant id verbatim into the target.
|
||||
#
|
||||
# Preference order: an explicit TenantId on the object, then the token it was
|
||||
# fetched with (a listed policy carries _TokenId, not a tenant id), then the
|
||||
# current tenant.
|
||||
#
|
||||
# The id and the name are ONE pair and always describe the same tenant. They used
|
||||
# to be resolved independently, so an object carrying an explicit TenantId with no
|
||||
# usable token - which is exactly what Get-GraphPolicyFromFile -TenantId produces -
|
||||
# got that tenant's id alongside the CONNECTED tenant's organization name.
|
||||
function Get-GraphObjectOrganizationInfo
|
||||
{
|
||||
[OutputType([PSCustomObject])]
|
||||
param($GraphObject)
|
||||
|
||||
$id = $null
|
||||
$name = $null
|
||||
|
||||
if($GraphObject)
|
||||
{
|
||||
# PSObject.Properties, not a bare $obj.Prop: these objects are PowerShell
|
||||
# class instances as well as PSCustomObjects, and a missing property on a
|
||||
# class instance is not a silent $null under StrictMode.
|
||||
foreach($prop in @("TenantId", "_TenantId"))
|
||||
{
|
||||
if($GraphObject.PSObject.Properties[$prop] -and $GraphObject.$prop)
|
||||
{
|
||||
$id = [string]$GraphObject.$prop
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# -gt 0, not -ne $null: _TokenId is declared [int] on IntunePolicyBase, so it
|
||||
# is 0 - never $null - on an object that carries no token, and Get-TokenInfo
|
||||
# does not filter on 0. A null check therefore handed back EVERY registered
|
||||
# token, making $tokenInfo an array and .TenantID an array of tenant ids the
|
||||
# moment a second tenant was signed in. 0 means "the default token", which is
|
||||
# what the fallback below already resolves.
|
||||
if($GraphObject.PSObject.Properties['_TokenId'] -and [int]$GraphObject._TokenId -gt 0)
|
||||
{
|
||||
$tokenInfo = Get-OperationTokenInfo ([int]$GraphObject._TokenId)
|
||||
if($tokenInfo)
|
||||
{
|
||||
if(-not $id -and $tokenInfo.TenantID) { $id = [string]$tokenInfo.TenantID }
|
||||
|
||||
# Only when the token describes the tenant the id came from. An object
|
||||
# can carry an explicit TenantId (A) and a token for another tenant (B),
|
||||
# and A's id paired with B's name is the same mixed-tenant export the
|
||||
# id resolution above exists to prevent.
|
||||
if($tokenInfo.TenantName -and $id -eq [string]$tokenInfo.TenantID)
|
||||
{
|
||||
$name = [string]$tokenInfo.TenantName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $id) { $id = Get-CurrentTenantId }
|
||||
|
||||
# Name resolved FOR the id, not independently of it. The connected tenant's name is
|
||||
# only the right answer when the id is the connected tenant; for any other tenant
|
||||
# the name has to come from that tenant's own token.
|
||||
if(-not $name -and $id)
|
||||
{
|
||||
if($id -eq [string](Get-CurrentTenantId))
|
||||
{
|
||||
$name = Get-CurrentOrganizationName
|
||||
}
|
||||
else
|
||||
{
|
||||
$tenantToken = Get-TokenInfoForTenant $id
|
||||
if($tenantToken -and $tenantToken.TenantName) { $name = [string]$tenantToken.TenantName }
|
||||
}
|
||||
}
|
||||
|
||||
# The name can legitimately come back empty: a tenant nobody is signed in to has no
|
||||
# discoverable organization name. Empty is the correct answer - it leaves the name
|
||||
# literal in the exported file, which stays readable and importable. The old
|
||||
# behaviour, substituting whichever organization happened to be connected, silently
|
||||
# tokenized the WRONG name, so import would resolve it to the target organization
|
||||
# and the original text would be unrecoverable from the file.
|
||||
return [PSCustomObject]@{ OrganizationId = $id; OrganizationName = $name }
|
||||
}
|
||||
|
||||
# Which tokens the export direction should write, as canonical names. Empty array
|
||||
# = replace nothing (export raw values).
|
||||
#
|
||||
# -OrganizationId selects whose per-tenant settings are consulted. It is the tenant
|
||||
# being EXPORTED, which on a cross-tenant export is not the connected one - so a
|
||||
# per-tenant "also replace the organization name" would otherwise be read from the
|
||||
# wrong tenant and silently not apply.
|
||||
function Get-GraphExportTokenNames
|
||||
{
|
||||
[OutputType([string[]])]
|
||||
param([string]$OrganizationId)
|
||||
|
||||
# Resolved before the master switch is read, because BOTH settings are
|
||||
# per-tenant and both have to answer for the tenant being exported. Reading the
|
||||
# switch against the connected tenant while reading the selector against the
|
||||
# exported one was worse than reading either consistently: a source tenant that
|
||||
# had turned replacement ON exported raw values because the DEFAULT tenant had
|
||||
# it off, which is the case tokenization exists to prevent.
|
||||
$tenantId = if($OrganizationId) { $OrganizationId } else { Get-CurrentTenantId }
|
||||
|
||||
# Master switch. Off = the pre-tokenization behaviour: raw values in the file.
|
||||
if((Get-SettingValue "ExportReplaceOrganizationValues" -TenantID $tenantId) -ne $true) { return @() }
|
||||
|
||||
# HIDDEN setting - deliberately not registered with Add-SettingsObject, so it
|
||||
# never appears in the settings UI. Set it by hand to narrow what is
|
||||
# replaced:
|
||||
#
|
||||
# Windows: HKCU:\Software\IntuneManagement\IntuneManager
|
||||
# (or HKCU:\Software\IntuneManagement\<TenantId>\IntuneManager
|
||||
# for one tenant only)
|
||||
# value name ExportReplaceTokens, type String
|
||||
# non-Windows: the "IntuneManager" object in the settings JSON file
|
||||
#
|
||||
# e.g. "OrganizationId,OrganizationName" -> the organization name is tokenized
|
||||
# too, which is what the default used to be.
|
||||
#
|
||||
# Missing, empty or blank falls back to $script:GraphExportTokensDefault - the
|
||||
# tenant id, which is also what v3.x replaced. Get-SettingStoreValue already
|
||||
# treats "" as missing; the IsNullOrWhiteSpace checks extend that to a value
|
||||
# of nothing but spaces, which is a blank value by any reading and must not
|
||||
# mean "replace nothing".
|
||||
$configured = $null
|
||||
if($tenantId) { $configured = Get-SettingStoreValue "$tenantId\IntuneManager" "ExportReplaceTokens" }
|
||||
if([string]::IsNullOrWhiteSpace($configured)) { $configured = Get-SettingStoreValue "IntuneManager" "ExportReplaceTokens" $script:GraphExportTokensDefault }
|
||||
if([string]::IsNullOrWhiteSpace($configured)) { $configured = $script:GraphExportTokensDefault }
|
||||
|
||||
# Same separators as ImportMatchOrganizationTokens: comma, semicolon, newline.
|
||||
$selected = @()
|
||||
foreach($part in ([regex]::Split([string]$configured, '[,;\r\n]+')))
|
||||
{
|
||||
if([string]::IsNullOrWhiteSpace($part)) { continue }
|
||||
|
||||
$trimmed = $part.Trim()
|
||||
$definition = $script:GraphExportTokens | Where-Object { $_.Aliases -contains $trimmed }
|
||||
if(-not $definition)
|
||||
{
|
||||
# Warn rather than fall back to the default: silently replacing more
|
||||
# than was asked for is worse than replacing nothing, and the export
|
||||
# is valid either way.
|
||||
Write-Log "ExportReplaceTokens: unknown token '$trimmed' ignored. Valid values: $(($script:GraphExportTokens | ForEach-Object { $_.Name }) -join ', ')" 2
|
||||
continue
|
||||
}
|
||||
$selected += $definition.Name
|
||||
}
|
||||
|
||||
# Table order, not the order they were listed in, so the id is always
|
||||
# replaced before the name (see the table comment) whatever the setting says.
|
||||
# Dedup comes free.
|
||||
return @($script:GraphExportTokens |
|
||||
Where-Object { $selected -contains $_.Name } |
|
||||
ForEach-Object { $_.Name })
|
||||
}
|
||||
|
||||
# Export direction. -Tokens restricts the call site to a subset (the settings can
|
||||
# only narrow it further); omit it for "whatever is enabled".
|
||||
function Convert-GraphOrganizationValueToToken
|
||||
{
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[string]$Json,
|
||||
[string]$OrganizationId,
|
||||
[string]$OrganizationName,
|
||||
[string[]]$Tokens
|
||||
)
|
||||
|
||||
if(-not $Json) { return $Json }
|
||||
|
||||
foreach($name in (Get-GraphExportTokenNames -OrganizationId $OrganizationId))
|
||||
{
|
||||
if($Tokens -and $Tokens -notcontains $name) { continue }
|
||||
|
||||
$definition = $script:GraphExportTokens | Where-Object Name -eq $name
|
||||
if(-not $definition) { continue }
|
||||
|
||||
$value = Get-GraphExportTokenValue -Name $name -OrganizationId $OrganizationId -OrganizationName $OrganizationName
|
||||
if(-not $value) { continue }
|
||||
|
||||
$Json = Convert-GraphExportLiteralReplace $Json $value $definition.Token -RequireValueBoundary
|
||||
}
|
||||
|
||||
return $Json
|
||||
}
|
||||
|
||||
# Import direction. NOT gated by the settings - see the header comment.
|
||||
function Convert-GraphOrganizationTokenToValue
|
||||
{
|
||||
[OutputType([string])]
|
||||
param(
|
||||
[string]$Json,
|
||||
[string]$OrganizationId,
|
||||
[string]$OrganizationName
|
||||
)
|
||||
|
||||
if(-not $Json) { return $Json }
|
||||
|
||||
foreach($definition in $script:GraphExportTokens)
|
||||
{
|
||||
if($definition.RestoreOnImport -ne $true) { continue }
|
||||
|
||||
$value = Get-GraphExportTokenValue -Name $definition.Name -OrganizationId $OrganizationId -OrganizationName $OrganizationName
|
||||
if(-not $value)
|
||||
{
|
||||
# Left literal rather than replaced with a guess - but say so. The
|
||||
# object about to be created carries the placeholder in whatever field
|
||||
# held it, which for a display name or description is user-visible.
|
||||
# Silence here is what made the old never-restore behaviour hard to
|
||||
# spot: the file looked fine and the imported object was wrong.
|
||||
if($Json.IndexOf($definition.Token, [StringComparison]::OrdinalIgnoreCase) -ge 0)
|
||||
{
|
||||
Write-Log "Import: cannot resolve $($definition.Token) for the target tenant - it stays literal in the imported object. Sign in to the target tenant, or edit the file." 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
$Json = Convert-GraphExportLiteralReplace $Json $definition.Token $value
|
||||
}
|
||||
|
||||
return $Json
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
# Pacing for the Graph endpoints that allow one request per second.
|
||||
#
|
||||
# Microsoft Graph limits the Conditional Access and identity-protection
|
||||
# resources (conditionalAccessPolicy, namedLocation, riskDetection, riskyUser
|
||||
# and the Conditional Access siblings) to ONE request per second per tenant,
|
||||
# counted across every application that talks to the tenant, and it sends no
|
||||
# Retry-After header when it throttles them. Every $batch sub-request counts on
|
||||
# its own. A list of forty Conditional Access policies fetched twenty per
|
||||
# $batch, or in parallel, therefore throttles at once and then backs off ten
|
||||
# seconds at a time - which the user sees as a hang.
|
||||
#
|
||||
# This file keeps a "last sent" clock per (rate class, tenant) and lets a
|
||||
# caller wait out the remainder of the interval before it sends. The single
|
||||
# request path gates in Invoke-MSGraphAPI; the batch layer keeps paced requests
|
||||
# out of parallel dispatch and posts them one per $batch. The
|
||||
# GraphPaceIdentityEndpoints setting (default on) switches the whole thing off.
|
||||
|
||||
$script:GraphRateClasses = @(
|
||||
[PSCustomObject]@{
|
||||
Class = 'ConditionalAccess'
|
||||
Label = 'Conditional Access'
|
||||
IntervalSeconds = 1.0
|
||||
# Resource paths below the API version, compared case-insensitively.
|
||||
Prefixes = @(
|
||||
'identity/conditionalAccess/policies',
|
||||
'identity/conditionalAccess/namedLocations',
|
||||
'identity/conditionalAccess/authenticationStrengths',
|
||||
'identity/conditionalAccess/authenticationContextClassReferences',
|
||||
'identityProtection/riskDetections',
|
||||
'identityProtection/riskyUsers'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
# (class|tenant) -> [DateTime] UTC of the last send
|
||||
$script:GraphPaceLastSend = @{}
|
||||
|
||||
function Test-GraphPacingEnabled
|
||||
{
|
||||
return -not ((Get-SettingValue "GraphPaceIdentityEndpoints") -eq $false)
|
||||
}
|
||||
|
||||
# The pure classifier: the rate class for a URL, or $null when the URL is not
|
||||
# paced. Accepts every shape the engine produces - absolute (nextLinks),
|
||||
# relative with or without a leading slash, with or without the API version,
|
||||
# with query strings and OData key segments.
|
||||
function Resolve-GraphRateClass
|
||||
{
|
||||
param([string]$Url)
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($Url)) { return $null }
|
||||
|
||||
$path = $Url.Trim()
|
||||
if($path -match '^https?://[^/]+(/.*)?$') { $path = "$($Matches[1])" }
|
||||
$path = ($path -split '[?#]', 2)[0]
|
||||
$path = $path.TrimStart('/')
|
||||
$path = $path -replace '^(beta|v1\.0)/', ''
|
||||
|
||||
foreach($rc in $script:GraphRateClasses)
|
||||
{
|
||||
foreach($prefix in $rc.Prefixes)
|
||||
{
|
||||
if(-not $path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { continue }
|
||||
# Segment boundary: 'policies' must not match 'policiesTemplates'.
|
||||
if($path.Length -eq $prefix.Length -or $path[$prefix.Length] -eq '/' -or $path[$prefix.Length] -eq '(')
|
||||
{
|
||||
return $rc.Class
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# The classifier callers use: honours the setting.
|
||||
function Get-GraphRateClass
|
||||
{
|
||||
param([string]$Url)
|
||||
|
||||
if(-not (Test-GraphPacingEnabled)) { return $null }
|
||||
return (Resolve-GraphRateClass -Url $Url)
|
||||
}
|
||||
|
||||
function Get-GraphRateClassInfo
|
||||
{
|
||||
param([string]$Class)
|
||||
|
||||
foreach($rc in $script:GraphRateClasses) { if($rc.Class -eq $Class) { return $rc } }
|
||||
return $null
|
||||
}
|
||||
|
||||
# Mockable clock.
|
||||
function Get-GraphPaceNow
|
||||
{
|
||||
return [DateTime]::UtcNow
|
||||
}
|
||||
|
||||
# The limit is per tenant across all applications, so two tokens for the same
|
||||
# tenant must share one clock. Resolution mirrors the %OrganizationId%
|
||||
# substitution in Invoke-MSGraphAPI; the token id is the last resort.
|
||||
function Get-GraphPaceTenantKey
|
||||
{
|
||||
param($TokenId = 0)
|
||||
|
||||
try
|
||||
{
|
||||
$provider = Get-AuthProvider
|
||||
if($provider)
|
||||
{
|
||||
$userInfo = $provider.GetUserInfo($TokenId)
|
||||
if($userInfo -and $userInfo.TenantId) { return "$($userInfo.TenantId)" }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
$tenantId = Get-CurrentTenantId
|
||||
if($tenantId) { return "$tenantId" }
|
||||
}
|
||||
catch { }
|
||||
|
||||
return "token:$TokenId"
|
||||
}
|
||||
|
||||
# Pull the paced requests out of a batch queue (in place) and return them as a
|
||||
# queue of their own, in their original order. Graph counts every $batch
|
||||
# sub-request on its own and sends no Retry-After for these, so batching them
|
||||
# twenty at a time only throttles at once. Returns an empty queue when the
|
||||
# setting is off.
|
||||
function Split-GraphPacedBatchRequests
|
||||
{
|
||||
param(
|
||||
[System.Collections.Generic.List[PSCustomObject]]$RequestObjects,
|
||||
[string]$BatchType = 'Graph'
|
||||
)
|
||||
|
||||
$paced = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
if(-not (Test-GraphPacingEnabled)) { return ,$paced }
|
||||
|
||||
$total = $RequestObjects.Count
|
||||
for($i = $RequestObjects.Count - 1; $i -ge 0; $i--)
|
||||
{
|
||||
if(Resolve-GraphRateClass -Url $RequestObjects[$i].url)
|
||||
{
|
||||
$paced.Insert(0, $RequestObjects[$i])
|
||||
$RequestObjects.RemoveAt($i)
|
||||
}
|
||||
}
|
||||
|
||||
if($paced.Count -gt 0)
|
||||
{
|
||||
Write-Log "Batch $($BatchType): $($paced.Count) of $total request(s) target one-per-second endpoints - sending them one at a time after the rest"
|
||||
}
|
||||
# The comma keeps an empty or single-item list from unrolling on return.
|
||||
return ,$paced
|
||||
}
|
||||
|
||||
# Wait until the tenant's interval for the class has passed since the last
|
||||
# send, then record this send. Call it immediately before the request goes out.
|
||||
function Wait-GraphRatePace
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Class,
|
||||
$TokenId = 0
|
||||
)
|
||||
|
||||
$rc = Get-GraphRateClassInfo -Class $Class
|
||||
if(-not $rc) { return }
|
||||
|
||||
$key = "{0}|{1}" -f $Class, (Get-GraphPaceTenantKey -TokenId $TokenId)
|
||||
$now = Get-GraphPaceNow
|
||||
|
||||
if($script:GraphPaceLastSend.ContainsKey($key))
|
||||
{
|
||||
$last = $script:GraphPaceLastSend[$key]
|
||||
$remaining = $rc.IntervalSeconds - ($now - $last).TotalSeconds
|
||||
if($remaining -gt 0)
|
||||
{
|
||||
Write-LogDebug ("{0}: pacing - waiting {1:0.00}s before the next request to the tenant" -f $rc.Label, $remaining)
|
||||
# Sliced and pumped so the window stays alive; no status text of its
|
||||
# own - the callers show their progress.
|
||||
Wait-UIAware -Seconds $remaining -SliceMilliseconds 100
|
||||
# The send happens right after the wait: never record it earlier
|
||||
# than the interval boundary, even if the clock did not move.
|
||||
$after = Get-GraphPaceNow
|
||||
$boundary = $last.AddSeconds($rc.IntervalSeconds)
|
||||
$now = if($after -gt $boundary) { $after } else { $boundary }
|
||||
}
|
||||
}
|
||||
|
||||
$script:GraphPaceLastSend[$key] = $now
|
||||
}
|
||||
|
||||
# Record one retryable batch response against its sub-request budget and say
|
||||
# whether that budget is now spent. Throttling and server errors are counted
|
||||
# separately on purpose: a 429 clears when the rate window rolls over, while a
|
||||
# 5xx that repeats is usually a permanent data error and re-sending it ten times
|
||||
# only freezes the app. $RetryObjects is keyed by batch sub-request id.
|
||||
function Register-GraphRetryAttempt
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Id,
|
||||
[int]$Status,
|
||||
[Parameter(Mandatory = $true)][hashtable]$RetryObjects,
|
||||
[int]$MaxThrottleRetry = 10,
|
||||
[int]$MaxServerErrorRetry = 3,
|
||||
# Optional tally so the caller can report the statuses it actually saw.
|
||||
[hashtable]$StatusCounts
|
||||
)
|
||||
|
||||
if($null -ne $StatusCounts)
|
||||
{
|
||||
$key = "$Status"
|
||||
if(-not $StatusCounts.ContainsKey($key)) { $StatusCounts[$key] = 0 }
|
||||
$StatusCounts[$key]++
|
||||
}
|
||||
|
||||
$isThrottled = ($Status -eq 429)
|
||||
if(-not $RetryObjects.ContainsKey($Id)) { $RetryObjects[$Id] = @{ Throttle = 0; ServerError = 0 } }
|
||||
$counter = $RetryObjects[$Id]
|
||||
if($isThrottled) { $counter.Throttle++ } else { $counter.ServerError++ }
|
||||
|
||||
$used = if($isThrottled) { $counter.Throttle } else { $counter.ServerError }
|
||||
$limit = if($isThrottled) { $MaxThrottleRetry } else { $MaxServerErrorRetry }
|
||||
|
||||
return [PSCustomObject]@{ Attempts = $used; Exhausted = ($used -ge $limit) }
|
||||
}
|
||||
|
||||
# Render a tally of retryable statuses ({"429" = 2; "500" = 1}) as "429 x2, 500".
|
||||
# Used by the batch dispatcher so its back-off message names the status Graph
|
||||
# actually returned.
|
||||
function Format-GraphRetryStatus
|
||||
{
|
||||
param([hashtable]$StatusCounts)
|
||||
|
||||
if(-not $StatusCounts -or $StatusCounts.Count -eq 0) { return "a retryable status" }
|
||||
|
||||
$parts = $StatusCounts.GetEnumerator() | Sort-Object { [int]$_.Key } | ForEach-Object {
|
||||
if($_.Value -gt 1) { "$($_.Key) x$($_.Value)" } else { "$($_.Key)" }
|
||||
}
|
||||
$text = ($parts -join ', ')
|
||||
if($StatusCounts.ContainsKey("429") -and $StatusCounts.Count -eq 1) { return "$text - 'Too many requests'" }
|
||||
return "status $text"
|
||||
}
|
||||
|
||||
# ---- Retry-After ----
|
||||
# Moved here from Internal/MSGraph.ps1 with the pacing work: parsing the back-off
|
||||
# header Graph sends belongs beside the clock for the endpoints that never send one.
|
||||
# Normalize an HTTP Retry-After header value into a wait, in seconds. Retry-After can be
|
||||
# delta-seconds (e.g. "120") or an HTTP-date (e.g. "Fri, 31 Jan 2026 23:59:59 GMT"); both
|
||||
# forms are honored. When the header is absent or unparseable we return $DefaultSeconds so
|
||||
# the caller always backs off instead of busy-retrying a throttled/erroring backend. Shared
|
||||
# by the single-request retry (Invoke-MSGraphAPI) and the per-batch-item retry in MSGraph.ps1 so both
|
||||
# treat 429/5xx back-off identically.
|
||||
function Get-GraphRetryAfterSeconds
|
||||
{
|
||||
param(
|
||||
$RetryAfterValue,
|
||||
[int]$DefaultSeconds = 10
|
||||
)
|
||||
|
||||
if($null -eq $RetryAfterValue) { return $DefaultSeconds }
|
||||
|
||||
$raw = ([string]$RetryAfterValue).Trim()
|
||||
if(-not $raw) { return $DefaultSeconds }
|
||||
|
||||
# delta-seconds (the form Graph almost always uses)
|
||||
$seconds = 0
|
||||
if([int]::TryParse($raw, [ref]$seconds))
|
||||
{
|
||||
if($seconds -gt 0) { return $seconds }
|
||||
return $DefaultSeconds
|
||||
}
|
||||
|
||||
# HTTP-date form - wait until that instant (invariant culture; header is always GMT)
|
||||
$when = [DateTimeOffset]::MinValue
|
||||
if([DateTimeOffset]::TryParse($raw, [System.Globalization.CultureInfo]::InvariantCulture,
|
||||
[System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$when))
|
||||
{
|
||||
$delta = [int][Math]::Ceiling(($when - [DateTimeOffset]::UtcNow).TotalSeconds)
|
||||
if($delta -gt 0) { return $delta }
|
||||
return $DefaultSeconds
|
||||
}
|
||||
|
||||
return $DefaultSeconds
|
||||
}
|
||||
@@ -0,0 +1,968 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Module for Intune Applications
|
||||
|
||||
.DESCRIPTION
|
||||
This module manages Application objects in Intune e.g. uploading application files
|
||||
|
||||
.NOTES
|
||||
Author: Mikael Karlsson
|
||||
#>
|
||||
function Get-ModuleVersion
|
||||
{
|
||||
'3.9.6'
|
||||
}
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Upload file functions are based on the following scripts
|
||||
# https://github.com/microsoftgraph/powershell-intune-samples/tree/master/LOB_Application
|
||||
#
|
||||
#########################################################################################
|
||||
|
||||
function Export-IntunewinFileObject
|
||||
{
|
||||
param($IntunewinFile, $ObjectName, $ToFile)
|
||||
|
||||
Add-Type -Assembly System.IO.Compression.FileSystem
|
||||
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($IntunewinFile)
|
||||
|
||||
$zip.Entries | Where-Object { $_.Name -like $ObjectName } | ForEach-Object {
|
||||
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, $ToFile, $true)
|
||||
}
|
||||
|
||||
$zip.Dispose()
|
||||
}
|
||||
|
||||
function Get-MSIFileInformation
|
||||
{
|
||||
param($MSIFile, $Properties)
|
||||
|
||||
$values = @{}
|
||||
|
||||
if(-not $MSIFile) { return }
|
||||
|
||||
$fi = [IO.FileInfo]$MSIFile
|
||||
|
||||
if($fi.Extension -ne ".msi") { return }
|
||||
|
||||
# Reading MSI properties relies on the WindowsInstaller COM automation object,
|
||||
# which only exists on Windows. (Marshal.ReleaseComObject in the finally below
|
||||
# also throws PlatformNotSupportedException off Windows.) Skip gracefully.
|
||||
if(-not $script:IsWindowsOS) {
|
||||
Write-Log "MSI property extraction requires Windows (COM automation). Skipping for $($fi.Name)." 2
|
||||
return
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$wiObj = New-Object -ComObject WindowsInstaller.Installer
|
||||
$MSIDb = $wiObj.GetType().InvokeMember("OpenDatabase", "InvokeMethod", $null, $wiObj, @($MSIFile, 0))
|
||||
|
||||
foreach($prop in $Properties)
|
||||
{
|
||||
$Query = "SELECT Value FROM Property WHERE Property = '$($prop)'"
|
||||
$View = $MSIDb.GetType().InvokeMember("OpenView", "InvokeMethod", $null, $MSIDb, ($Query))
|
||||
$View.GetType().InvokeMember("Execute", "InvokeMethod", $null, $View, $null) | Out-Null
|
||||
$Record = $View.GetType().InvokeMember("Fetch", "InvokeMethod", $null, $View, $null)
|
||||
$values.Add($prop, $Record.GetType().InvokeMember("StringData", "GetProperty", $null, $Record, 1).ToString().Trim())
|
||||
}
|
||||
|
||||
$MSIDb.GetType().InvokeMember("Commit", "InvokeMethod", $null, $MSIDb, $null) | Out-Null
|
||||
$View.GetType().InvokeMember("Close", "InvokeMethod", $null, $View, $null) | Out-Null
|
||||
$MSIDb = $null
|
||||
$View = $null
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to get MSI info from $MSIFile. $($_.Exception.Message)" 3
|
||||
}
|
||||
finally
|
||||
{
|
||||
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($wiObj) | Out-Null
|
||||
[System.GC]::Collect() | Out-Null
|
||||
}
|
||||
|
||||
$values
|
||||
}
|
||||
|
||||
function Copy-MSILOB
|
||||
{
|
||||
param($MsiFile, $PolicyObject)
|
||||
|
||||
if(-not $MsiFile -or (Test-Path $MsiFile) -eq $false)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
$AppId = $PolicyObject.Id
|
||||
$AppType = $PolicyObject.JsonObject.'@odata.type'.Trim('#')
|
||||
|
||||
$tmpFile = [IO.Path]::GetTempFileName()
|
||||
|
||||
$msiInfo = Get-MSIFileInformation $MsiFile @("ProductName", "ProductCode", "ProductVersion", "ProductLanguage", "UpgradeCode", "ALLUSERS")
|
||||
|
||||
if(-not $msiInfo) { return }
|
||||
|
||||
$fileEncryptionInfo = New-IntuneEncryptedFile $MsiFile $tmpFile
|
||||
|
||||
[xml]$manifestXML = '<MobileMsiData MsiExecutionContext="Any" MsiRequiresReboot="false" MsiUpgradeCode="" MsiIsMachineInstall="true" MsiIsUserInstall="false" MsiIncludesServices="false" MsiContainsSystemRegistryKeys="false" MsiContainsSystemFolders="false"></MobileMsiData>'
|
||||
$manifestXML.MobileMsiData.MsiUpgradeCode = $msiInfo["UpgradeCode"]
|
||||
if($msiInfo["ALLUSERS"] -eq 1)
|
||||
{
|
||||
$manifestXML.MobileMsiData.MsiExecutionContext = "System"
|
||||
}
|
||||
|
||||
$appFileBody = @{
|
||||
"@odata.type" = "#microsoft.graph.mobileAppContentFile"
|
||||
name = [IO.Path]::GetFileName($MsiFile)
|
||||
size = (Get-Item $MsiFile).Length
|
||||
sizeEncrypted = (Get-Item $tmpFile).Length
|
||||
manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifestXML.OuterXml))
|
||||
isDependency = $false
|
||||
}
|
||||
|
||||
Add-FileToIntuneApp $AppId $AppType $tmpFile $appFileBody $PolicyObject.TokenId
|
||||
|
||||
Remove-Item $tmpFile -Force
|
||||
|
||||
$fileEncryptionInfo
|
||||
}
|
||||
|
||||
function Copy-MSIXLOB
|
||||
{
|
||||
param($MsixFile, $PolicyObject)
|
||||
|
||||
if(-not $MsixFile -or (Test-Path $MsixFile) -eq $false)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
$fi = [IO.FileInfo]$MsixFile
|
||||
|
||||
$AppId = $PolicyObject.Id
|
||||
$AppType = $PolicyObject.JsonObject.'@odata.type'.Trim('#')
|
||||
|
||||
$tmpFile = [IO.Path]::GetTempFileName()
|
||||
|
||||
$fileEncryptionInfo = New-IntuneEncryptedFile $MsixFile $tmpFile
|
||||
|
||||
$manifest = $fi.Name
|
||||
|
||||
$appFileBody = @{
|
||||
"@odata.type" = "#microsoft.graph.mobileAppContentFile"
|
||||
name = [IO.Path]::GetFileName($MsixFile)
|
||||
size = (Get-Item $MsixFile).Length
|
||||
sizeEncrypted = (Get-Item $tmpFile).Length
|
||||
manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifest))
|
||||
isDependency = $false
|
||||
}
|
||||
|
||||
Add-FileToIntuneApp $AppId $AppType $tmpFile $appFileBody $PolicyObject.TokenId
|
||||
|
||||
Remove-Item $tmpFile -Force
|
||||
|
||||
$fileEncryptionInfo
|
||||
}
|
||||
|
||||
function Copy-iOSLOB
|
||||
{
|
||||
param($PkgFile, $PolicyObject)
|
||||
|
||||
if(-not $PkgFile -or (Test-Path $PkgFile) -eq $false)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
$AppId = $PolicyObject.Id
|
||||
$AppType = $PolicyObject.JsonObject.'@odata.type'.Trim('#')
|
||||
|
||||
$tmpFile = [IO.Path]::GetTempFileName()
|
||||
|
||||
$fileEncryptionInfo = New-IntuneEncryptedFile $PkgFile $tmpFile
|
||||
|
||||
[string]$manifestStr = '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>items</key><array><dict><key>assets</key><array><dict><key>kind</key><string>software-package</string><key>url</key><string>{UrlPlaceHolder}</string></dict></array><key>metadata</key><dict><key>AppRestrictionPolicyTemplate</key> <string>http://management.microsoft.com/PolicyTemplates/AppRestrictions/iOS/v1</string><key>AppRestrictionTechnology</key><string>Windows Intune Application Restrictions Technology for iOS</string><key>IntuneMAMVersion</key><string></string><key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array><key>MinimumOSVersion</key><string>9.0</string><key>bundle-identifier</key><string>bundleid</string><key>bundle-version</key><string>bundleversion</string><key>kind</key><string>software</string><key>subtitle</key><string>LaunchMeSubtitle</string><key>title</key><string>bundletitle</string></dict></dict></array></dict></plist>'
|
||||
|
||||
$manifestStr = $manifestStr.replace("bundleid", $appObj.bundleId)
|
||||
$manifestStr = $manifestStr.replace("bundleversion",$appObj.identityVersion)
|
||||
$manifestStr = $manifestStr.replace("bundletitle",$appObj.$displayName)
|
||||
|
||||
$appFileBody = @{
|
||||
"@odata.type" = "#microsoft.graph.mobileAppContentFile"
|
||||
name = [IO.Path]::GetFileName($PkgFile)
|
||||
size = (Get-Item $PkgFile).Length
|
||||
sizeEncrypted = (Get-Item $tmpFile).Length
|
||||
manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifestStr))
|
||||
}
|
||||
|
||||
Add-FileToIntuneApp $AppId $AppType $tmpFile $appFileBody $PolicyObject.TokenId
|
||||
|
||||
Remove-Item $tmpFile -Force
|
||||
|
||||
$fileEncryptionInfo
|
||||
}
|
||||
|
||||
function Copy-AndroidLOB
|
||||
{
|
||||
param($PkgFile, $PolicyObject)
|
||||
|
||||
if(-not $PkgFile -or (Test-Path $PkgFile) -eq $false)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
$AppId = $PolicyObject.Id
|
||||
$AppType = $PolicyObject.JsonObject.'@odata.type'.Trim('#')
|
||||
|
||||
$tmpFile = [IO.Path]::GetTempFileName()
|
||||
|
||||
$fileEncryptionInfo = New-IntuneEncryptedFile $PkgFile $tmpFile
|
||||
|
||||
[xml]$manifestXML = '<?xml version="1.0" encoding="utf-8"?><AndroidManifestProperties xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><Package>com.leadapps.android.radio.ncp</Package><PackageVersionCode>10</PackageVersionCode><PackageVersionName>1.0.5.4</PackageVersionName><ApplicationName>A_Online_Radio_1.0.5.4.apk</ApplicationName><MinSdkVersion>3</MinSdkVersion><AWTVersion></AWTVersion></AndroidManifestProperties>'
|
||||
|
||||
$manifestXML.AndroidManifestProperties.Package = $appObj.identityName
|
||||
$manifestXML.AndroidManifestProperties.PackageVersionCode = $appObj.versionCode
|
||||
$manifestXML.AndroidManifestProperties.PackageVersionName = $appObj.versionName
|
||||
$manifestXML.AndroidManifestProperties.ApplicationName = [IO.Path]::GetFileName($PkgFile)
|
||||
|
||||
$appFileBody = @{
|
||||
"@odata.type" = "#microsoft.graph.mobileAppContentFile"
|
||||
name = [IO.Path]::GetFileName($PkgFile)
|
||||
size = (Get-Item $PkgFile).Length
|
||||
sizeEncrypted = (Get-Item $tmpFile).Length
|
||||
manifest = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($manifestXML.OuterXml))
|
||||
}
|
||||
|
||||
Add-FileToIntuneApp $AppId $AppType $tmpFile $appFileBody $PolicyObject.TokenId
|
||||
|
||||
Remove-Item $tmpFile -Force
|
||||
|
||||
$fileEncryptionInfo
|
||||
}
|
||||
|
||||
function Copy-Win32LOBPackage
|
||||
{
|
||||
param($IntunewinFile, $PolicyObject)
|
||||
|
||||
if(-not $IntunewinFile -or (Test-Path $IntunewinFile) -eq $false)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
$AppId = $PolicyObject.Id
|
||||
$AppType = $PolicyObject.JsonObject.'@odata.type'.Trim('#')
|
||||
|
||||
#Extract the detection.xml from the intunewin file
|
||||
|
||||
$tmpFile = [IO.Path]::GetTempFileName()
|
||||
|
||||
Export-IntunewinFileObject $IntunewinFile "detection.xml" $tmpFile
|
||||
|
||||
[xml]$DetectionXML = Get-Content $tmpFile -Encoding UTF8
|
||||
|
||||
Remove-Item -Path $tmpFile
|
||||
|
||||
$fi = [IO.FileInfo]$IntunewinFile
|
||||
|
||||
# Get encryption info from detection.xml and build encryptionInfo object
|
||||
|
||||
$encryptionInfo = @{}
|
||||
$encryptionInfo.encryptionKey = $DetectionXML.ApplicationInfo.EncryptionInfo.EncryptionKey
|
||||
$encryptionInfo.macKey = $DetectionXML.ApplicationInfo.EncryptionInfo.macKey
|
||||
$encryptionInfo.initializationVector = $DetectionXML.ApplicationInfo.EncryptionInfo.initializationVector
|
||||
$encryptionInfo.mac = $DetectionXML.ApplicationInfo.EncryptionInfo.mac
|
||||
$encryptionInfo.profileIdentifier = "ProfileVersion1"
|
||||
$encryptionInfo.fileDigest = $DetectionXML.ApplicationInfo.EncryptionInfo.fileDigest
|
||||
$encryptionInfo.fileDigestAlgorithm = $DetectionXML.ApplicationInfo.EncryptionInfo.fileDigestAlgorithm
|
||||
|
||||
$tmpIntunewinPath = [IO.Path]::Combine([IO.Path]::GetTempPath(), [Guid]::NewGuid().ToString("n"))
|
||||
New-Item -ItemType Directory -Path $tmpIntunewinPath -Force | Out-Null
|
||||
$tmpIntunewinFile = [IO.Path]::Combine($tmpIntunewinPath, $fi.Name)
|
||||
|
||||
# Extract the encrypted file from the intunewin file
|
||||
Export-IntunewinFileObject $IntunewinFile $DetectionXML.ApplicationInfo.FileName $tmpIntunewinFile
|
||||
|
||||
# Create mobileAppContentFile object for the file
|
||||
$fileEncryptionInfo = @{}
|
||||
$fileEncryptionInfo.fileEncryptionInfo = $encryptionInfo
|
||||
|
||||
$FileBody = @{
|
||||
"@odata.type" = "#microsoft.graph.mobileAppContentFile"
|
||||
name = "IntunePackage.intunewin"
|
||||
size = [int64]$DetectionXML.ApplicationInfo.UnencryptedContentSize
|
||||
sizeEncrypted = (Get-Item $tmpIntunewinFile).Length
|
||||
manifest = $null
|
||||
isDependency = $false
|
||||
}
|
||||
|
||||
Add-FileToIntuneApp $AppId $AppType $tmpIntunewinFile $FileBody $PolicyObject.TokenId
|
||||
|
||||
# Remove extracted inintunewin file
|
||||
Remove-Item $tmpIntunewinPath -Force -Recurse
|
||||
|
||||
$fileEncryptionInfo
|
||||
}
|
||||
|
||||
function Add-FileToIntuneApp
|
||||
{
|
||||
param($AppId, $AppType, $AppFile, $FileBody, $TokenId)
|
||||
|
||||
$contentVersion = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions" -HttpMethod POST -Content "{}" -ODataMetadata "Minimal" -TokenId $TokenId
|
||||
$contentVersionId = $contentVersion.id
|
||||
$fileObj = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions/$contentVersionId/files" -HttpMethod POST -Content (ConvertTo-Json $FileBody -Depth 5) -ODataMetadata "Minimal" -TokenId $TokenId
|
||||
|
||||
if(-not $fileObj)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "File object created. ID: $($fileObj.id)"
|
||||
|
||||
# Wait for Azure storage URI
|
||||
$fileObj = Wait-IntuneFileState "deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions/$contentVersionId/files/$($fileObj.Id)" "AzureStorageUriRequest"
|
||||
if(-not $fileObj)
|
||||
{
|
||||
Write-Log "No File Object returned from commit. Upload failed" 3
|
||||
return
|
||||
}
|
||||
|
||||
# Upload file
|
||||
Send-IntuneFileToAzureStorage $fileObj.azureStorageUri $AppFile "deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions/$contentVersionId/files/$($fileObj.Id)" | Out-Null
|
||||
|
||||
# Commit the file
|
||||
Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions/$contentVersionId/files/$($fileObj.Id)/commit" -HttpMethod POST -Content (ConvertTo-Json $fileEncryptionInfo -Depth 5) -TokenId $TokenId | Out-Null
|
||||
|
||||
Wait-IntuneFileState "deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions/$contentVersionId/files/$($fileObj.Id)" "CommitFile" | Out-Null
|
||||
|
||||
# Commit the content version
|
||||
$commitAppBody = @{
|
||||
"@odata.type" = "#$AppType"
|
||||
committedContentVersion = $contentVersionId
|
||||
}
|
||||
|
||||
$fiUpload = [IO.FileInfo]$AppFile
|
||||
$fileUploadName = $fiUpload.Name
|
||||
|
||||
$commitAppBody.Add("fileName",$fileUploadName)
|
||||
|
||||
Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$AppId" -HttpMethod PATCH -Content (ConvertTo-Json $commitAppBody -Depth 5) -TokenId $TokenId | Out-Null
|
||||
Write-Log "Upload finished for file $fileUploadName version $contentVersionId"
|
||||
}
|
||||
|
||||
function Wait-IntuneFileState
|
||||
{
|
||||
param($FileUri, $State, $MaxWait = 60)
|
||||
|
||||
Write-Status "Wait for state $State"
|
||||
|
||||
$endWait = (Get-Date).AddMinutes($MaxWait)
|
||||
|
||||
$successState = "$($State)Success"
|
||||
$pendingState = "$($State)Pending"
|
||||
#$failedState = "$($State)Failed"
|
||||
#$timedOutState = "$($State)TimedOut"
|
||||
|
||||
$file = $null
|
||||
$succes = $false
|
||||
|
||||
while ((Get-Date) -lt $endWait)
|
||||
{
|
||||
$file = Invoke-MSGraphAPI -Url $FileUri -TokenId $TokenId
|
||||
|
||||
if ($file.uploadState -eq $successState)
|
||||
{
|
||||
$succes = $true
|
||||
break
|
||||
}
|
||||
elseif ($file.uploadState -ne $pendingState)
|
||||
{
|
||||
Write-Log "Failed to upload file. State: $($file.uploadState)" 3
|
||||
return
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
if($succes -eq $false)
|
||||
{
|
||||
Write-Log "Wait for state operation timed out" 3
|
||||
return
|
||||
}
|
||||
|
||||
$file
|
||||
}
|
||||
|
||||
function Send-IntuneFileToAzureStorage
|
||||
{
|
||||
param($SasUri, $Filepath, $FileUri)
|
||||
|
||||
try
|
||||
{
|
||||
$chunkSizeInBytes = 5MB
|
||||
|
||||
# Start the timer for SAS URI renewal.
|
||||
$sasRenewalTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# Find the file size and open the file.
|
||||
$fileSize = (Get-Item $Filepath).length
|
||||
$chunks = [Math]::Ceiling($fileSize / $chunkSizeInBytes)
|
||||
$reader = New-Object System.IO.BinaryReader([System.IO.File]::Open($Filepath, [System.IO.FileMode]::Open))
|
||||
$reader.BaseStream.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null
|
||||
|
||||
# Upload each chunk. Check whether a SAS URI renewal is required after each chunk is uploaded and renew if needed.
|
||||
$Ids = @()
|
||||
|
||||
for ($chunk = 0; $chunk -lt $chunks; $chunk++)
|
||||
{
|
||||
|
||||
$Id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunk.ToString("0000")))
|
||||
$Ids += $Id
|
||||
|
||||
$start = $chunk * $chunkSizeInBytes
|
||||
$length = [Math]::Min([uint64]($chunkSizeInBytes), [uint64]($fileSize - $start))
|
||||
$bytes = $reader.ReadBytes($length)
|
||||
|
||||
$currentChunk = $chunk + 1
|
||||
|
||||
Write-Status "Uploading file to Azure Storage`n`nUploading chunk $currentChunk of $chunks ($(("{0:N2}" -f ($currentChunk / $chunks*100)))%)"
|
||||
|
||||
if((Write-AzureStorageChunk $SasUri $Id $bytes) -eq $false)
|
||||
{
|
||||
Write-Log "Upload failed. Abourting..." 3
|
||||
break
|
||||
}
|
||||
|
||||
if ($currentChunk -lt $chunks -and $sasRenewalTimer.ElapsedMilliseconds -ge 450000)
|
||||
{
|
||||
Request-RenewAzureStorageUpload $FileUri
|
||||
$sasRenewalTimer.Restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to send file to Intune. $($_.Exception.Message)" 3
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $reader)
|
||||
{
|
||||
$reader.Close()
|
||||
$reader.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Finalize the upload.
|
||||
Set-FinalizeAzureStorageUpload $SasUri $Ids | Out-Null
|
||||
}
|
||||
|
||||
function Request-RenewAzureStorageUpload
|
||||
{
|
||||
param($FileUri)
|
||||
|
||||
Invoke-MSGraphAPI -Url "$FileUri/renewUpload" -HttpMethod POST -TokenId $TokenId | Out-Null
|
||||
|
||||
Wait-IntuneFileState $FileUri "AzureStorageUriRenewal" $azureStorageRenewSasUriBackOffTimeInSeconds | Out-Null
|
||||
}
|
||||
|
||||
function Resolve-IntuneAppUploadUri
|
||||
{
|
||||
param([string]$Uri)
|
||||
|
||||
if($Uri -match "^http://|^https://") { return $Uri }
|
||||
|
||||
return "https://$(Get-GraphDomain)/$($Uri.TrimStart('/'))"
|
||||
}
|
||||
|
||||
function Set-FinalizeAzureStorageUpload
|
||||
{
|
||||
param($SasUri, $Ids)
|
||||
|
||||
$uri = "$SasUri&comp=blocklist"
|
||||
|
||||
$uri = Resolve-IntuneAppUploadUri $uri
|
||||
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><BlockList>'
|
||||
foreach ($Id in $Ids)
|
||||
{
|
||||
$xml += "<Latest>$Id</Latest>"
|
||||
}
|
||||
$xml += '</BlockList>'
|
||||
|
||||
$params = @{}
|
||||
$proxyURI = Get-ProxyURI
|
||||
if($proxyURI)
|
||||
{
|
||||
$params.Add("proxy", $proxyURI)
|
||||
$params.Add("UseBasicParsing", $true)
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Invoke-RestMethod $uri -Method Put -Body $xml @params
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to finilize upload. $($_.Exception.Message)" 3
|
||||
}
|
||||
}
|
||||
|
||||
function Write-AzureStorageChunk
|
||||
{
|
||||
param($SasUri, $Id, $Body)
|
||||
|
||||
$uri = "$SasUri&comp=block&blockid=$Id"
|
||||
|
||||
$uri = Resolve-IntuneAppUploadUri $uri
|
||||
|
||||
$iso = [System.Text.Encoding]::GetEncoding("iso-8859-1")
|
||||
$encodedBody = $iso.GetString($Body)
|
||||
$contentType = "application/octet-stream"
|
||||
if($PSVersionTable.PSVersion -ge [Version]"7.4") {
|
||||
$contentType += "; charset=iso-8859-1"
|
||||
}
|
||||
$headers = @{
|
||||
"x-ms-blob-type" = "BlockBlob"
|
||||
"Content-Type" = $contentType
|
||||
}
|
||||
|
||||
$curProgressPreference = $ProgressPreference
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$success = $false
|
||||
$retryCount = 0
|
||||
$params = @{}
|
||||
$proxyURI = Get-ProxyURI
|
||||
if($proxyURI)
|
||||
{
|
||||
$params.Add("proxy", $proxyURI)
|
||||
}
|
||||
|
||||
while($true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Invoke-WebRequest $uri -Method Put -Headers $headers -Body $encodedBody -UseBasicParsing @params | Out-Null
|
||||
if($retryCount -gt 0)
|
||||
{
|
||||
Write-Log "Chunk uploaded successfully"
|
||||
}
|
||||
$success = $true
|
||||
break
|
||||
}
|
||||
catch
|
||||
{
|
||||
if($_.Exception.HResult -eq -2146233079 -and $retryCount -lt 6)
|
||||
{
|
||||
Write-Log "Failed to upload file chunk. Retry in 10 s" 2
|
||||
$retryCount++
|
||||
Wait-UIAware -Seconds 10 -DetailFormat "Upload chunk failed - retrying in {0}s"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Failed to upload file chunk. $($_.Exception.Message)" 3
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$ProgressPreference = $curProgressPreference
|
||||
$success
|
||||
}
|
||||
|
||||
function Get-IntuneKey
|
||||
{
|
||||
try
|
||||
{
|
||||
$aes = [System.Security.Cryptography.Aes]::Create()
|
||||
$aesProvider = New-Object System.Security.Cryptography.AesCryptoServiceProvider
|
||||
$aesProvider.GenerateKey()
|
||||
$aesProvider.Key
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $aesProvider) { $aesProvider.Dispose() }
|
||||
if ($null -ne $aes) { $aes.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-IntuneKeyIV
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
$aes = [System.Security.Cryptography.Aes]::Create()
|
||||
$aes.IV
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $aes) { $aes.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
function Start-EncryptFileWithIV
|
||||
{
|
||||
param($SourceFile, $TargetFile, $EncryptionKey, $HmacKey, $InitializationVector)
|
||||
|
||||
$bufferBlockSize = 1024 * 4
|
||||
$computedMac = $null
|
||||
|
||||
try
|
||||
{
|
||||
$aes = [System.Security.Cryptography.Aes]::Create()
|
||||
$hmacSha256 = New-Object System.Security.Cryptography.HMACSHA256
|
||||
$hmacSha256.Key = $HmacKey
|
||||
$hmacLength = $hmacSha256.HashSize / 8
|
||||
|
||||
$buffer = New-Object byte[] $bufferBlockSize
|
||||
$bytesRead = 0
|
||||
|
||||
$targetStream = [System.IO.File]::Open($TargetFile, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)
|
||||
$targetStream.Write($buffer, 0, $hmacLength + $InitializationVector.Length)
|
||||
|
||||
try
|
||||
{
|
||||
$encryptor = $aes.CreateEncryptor($EncryptionKey, $InitializationVector)
|
||||
$sourceStream = [System.IO.File]::Open($SourceFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
||||
$cryptoStream = New-Object System.Security.Cryptography.CryptoStream -ArgumentList @($targetStream, $encryptor, [System.Security.Cryptography.CryptoStreamMode]::Write)
|
||||
|
||||
$targetStream = $null
|
||||
while (($bytesRead = $sourceStream.Read($buffer, 0, $bufferBlockSize)) -gt 0)
|
||||
{
|
||||
$cryptoStream.Write($buffer, 0, $bytesRead)
|
||||
$cryptoStream.Flush()
|
||||
}
|
||||
$cryptoStream.FlushFinalBlock()
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $cryptoStream) { $cryptoStream.Dispose() }
|
||||
if ($null -ne $sourceStream) { $sourceStream.Dispose() }
|
||||
if ($null -ne $encryptor) { $encryptor.Dispose() }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$finalStream = [System.IO.File]::Open($TargetFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::Read)
|
||||
|
||||
$finalStream.Seek($hmacLength, [System.IO.SeekOrigin]::Begin) > $null
|
||||
$finalStream.Write($InitializationVector, 0, $InitializationVector.Length)
|
||||
$finalStream.Seek($hmacLength, [System.IO.SeekOrigin]::Begin) > $null
|
||||
|
||||
$hmac = $hmacSha256.ComputeHash($finalStream)
|
||||
$computedMac = $hmac
|
||||
|
||||
$finalStream.Seek(0, [System.IO.SeekOrigin]::Begin) > $null
|
||||
$finalStream.Write($hmac, 0, $hmac.Length)
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $finalStream) { $finalStream.Dispose() }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $targetStream) { $targetStream.Dispose() }
|
||||
if ($null -ne $aes) { $aes.Dispose() }
|
||||
}
|
||||
|
||||
$computedMac
|
||||
}
|
||||
|
||||
function New-IntuneEncryptedFile
|
||||
{
|
||||
param($SourceFile, $TargetFile)
|
||||
|
||||
$EncryptionKey = Get-IntuneKey
|
||||
$HmacKey = Get-IntuneKey
|
||||
$InitializationVector = Get-IntuneKeyIV
|
||||
|
||||
# Create the encrypted target file and compute the HMAC value.
|
||||
$mac = Start-EncryptFileWithIV $SourceFile $TargetFile $EncryptionKey $HmacKey $InitializationVector
|
||||
|
||||
# Compute the SHA256 hash of the source file and convert the result to bytes.
|
||||
$fileDigest = (Get-FileHash $SourceFile -Algorithm SHA256).Hash
|
||||
$fileDigestBytes = New-Object byte[] ($fileDigest.Length / 2)
|
||||
for ($i = 0; $i -lt $fileDigest.Length; $i += 2)
|
||||
{
|
||||
$fileDigestBytes[$i / 2] = [System.Convert]::ToByte($fileDigest.Substring($i, 2), 16)
|
||||
}
|
||||
|
||||
# Return an object that will serialize correctly to the file commit Graph API.
|
||||
$encryptionInfo = @{}
|
||||
$encryptionInfo.encryptionKey = [System.Convert]::ToBase64String($EncryptionKey)
|
||||
$encryptionInfo.macKey = [System.Convert]::ToBase64String($HmacKey)
|
||||
$encryptionInfo.initializationVector = [System.Convert]::ToBase64String($InitializationVector)
|
||||
$encryptionInfo.mac = [System.Convert]::ToBase64String($mac)
|
||||
$encryptionInfo.profileIdentifier = "ProfileVersion1"
|
||||
$encryptionInfo.fileDigest = [System.Convert]::ToBase64String($fileDigestBytes)
|
||||
$encryptionInfo.fileDigestAlgorithm = "SHA256"
|
||||
|
||||
$fileEncryptionInfo = @{}
|
||||
$fileEncryptionInfo.fileEncryptionInfo = $encryptionInfo
|
||||
|
||||
$fileEncryptionInfo
|
||||
}
|
||||
|
||||
function Start-DecryptFile
|
||||
{
|
||||
param($SourceFile, $TargetFile, $EncryptionKey, $InitializationVector)
|
||||
|
||||
if([IO.File]::Exists($TargetFile))
|
||||
{
|
||||
$fi = [IO.FileInfo]$TargetFile
|
||||
$newName = $fi.Name + "_$((Get-Date).ToString("yyyyMMdd_HHmm"))" + $fi.Extension
|
||||
$TargetFile = [IO.Path]::Combine($fi.DirectoryName, $newName)
|
||||
Write-Log "Target file exists. Changing target file to $TargetFile" 2
|
||||
}
|
||||
else {
|
||||
Write-Log "Target file: $TargetFile"
|
||||
}
|
||||
|
||||
$bufferBlockSize = 1024 * 4
|
||||
|
||||
try
|
||||
{
|
||||
$aes = [System.Security.Cryptography.Aes]::Create()
|
||||
|
||||
$buffer = New-Object byte[] $bufferBlockSize
|
||||
$bytesRead = 0
|
||||
|
||||
$targetStream = [System.IO.File]::Open($TargetFile, [System.IO.FileMode]::Create, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
|
||||
|
||||
try
|
||||
{
|
||||
$sourceStream = [System.IO.File]::Open($SourceFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None)
|
||||
|
||||
$decryptor = $aes.CreateDecryptor([Convert]::FromBase64String($EncryptionKey), [Convert]::FromBase64String($InitializationVector))
|
||||
|
||||
$decryptoStream = New-Object System.Security.Cryptography.CryptoStream -ArgumentList @($targetStream, $decryptor, [System.Security.Cryptography.CryptoStreamMode]::Write)
|
||||
|
||||
$sourceStream.Seek(48L, [System.IO.SeekOrigin]::Begin)
|
||||
|
||||
while (($bytesRead = $sourceStream.Read($buffer, 0, $bufferBlockSize)) -gt 0)
|
||||
{
|
||||
$decryptoStream.Write($buffer, 0, $bytesRead)
|
||||
$decryptoStream.Flush()
|
||||
}
|
||||
$decryptoStream.FlushFinalBlock()
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $decryptoStream) { $decryptoStream.Dispose() }
|
||||
if ($null -ne $targetStream) { $targetStream.Dispose() }
|
||||
if ($null -ne $decryptor) { $decryptor.Dispose() }
|
||||
if ($null -ne $sourceStream) { $sourceStream.Dispose() }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if ($null -ne $sourceStream) { $sourceStream.Dispose() }
|
||||
if ($null -ne $aes) { $aes.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
function Start-DownloadAppContent
|
||||
{
|
||||
param($AppPolicy, $DestinationFile, [switch]$GetContentFileInfoOnly)
|
||||
# Not use but kept for reference. File can be download but it will be encrypted
|
||||
|
||||
if([IO.File]::Exists($DestinationFile))
|
||||
{
|
||||
try { [IO.File]::Delete($encryptionFile) }
|
||||
catch {}
|
||||
}
|
||||
|
||||
$AppId = $AppPolicy.Id
|
||||
|
||||
$appInfo = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$AppId" -TokenId $AppPolicy.TokenId
|
||||
|
||||
$AppType = $appInfo.'@odata.type'.Trim('#')
|
||||
|
||||
#$contentVersions = Invoke-MSGraphAPI -Url "https://$(Get-GraphDomain)/deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions"
|
||||
#$contentVerId = $contentVersions.Value[0].id
|
||||
|
||||
$contentVerId = $appInfo.committedContentVersion
|
||||
|
||||
$contentFiles = Invoke-MSGraphAPI "deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions/$contentVerId/files" -TokenId $AppPolicy.TokenId
|
||||
|
||||
$contentFile = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions/$contentVerId/files/$($contentFiles.value[-1].Id)" -NoError -TokenId $AppPolicy.TokenId
|
||||
|
||||
if(-not $contentFile)
|
||||
{
|
||||
foreach($file in $contentFiles.value)
|
||||
{
|
||||
if($contentFiles.value[-1].Id -eq $file.id) { continue }
|
||||
|
||||
# NOT happy about this. file objects are not always returned in the order of upload.
|
||||
$contentFile = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$AppId/$AppType/contentVersions/$contentVerId/files/$($file.Id)" -NoError -TokenId $AppPolicy.TokenId
|
||||
if($contentFile)
|
||||
{
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($contentFile.azureStorageUri)
|
||||
{
|
||||
if($GetContentFileInfoOnly -ne $true)
|
||||
{
|
||||
Start-DownloadFile $contentFile.azureStorageUri $DestinationFile
|
||||
}
|
||||
return $contentFile
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Could not find file object for app $($AppPolicy.Name) ($($AppId))" 2
|
||||
}
|
||||
}
|
||||
|
||||
function Find-AppEncryptionFile
|
||||
{
|
||||
param($Obj, $ContentFileObj, $RootFolders)
|
||||
|
||||
$search = @()
|
||||
$search += "$($Obj.displayName)_$($Obj.id)_$($Obj.committedContentVersion)"
|
||||
$search += "$([IO.Path]::GetFileNameWithoutExtension($Obj.fileName))_$($ContentFileObj.size)"
|
||||
$search += "$($Obj.displayName)_$($ContentFileObj.size)"
|
||||
|
||||
foreach($rootFolder in $RootFolders)
|
||||
{
|
||||
foreach($searchName in $search)
|
||||
{
|
||||
$fullName = [IO.Path]::Combine($rootFolder, "$($searchName).json")
|
||||
if([IO.File]::Exists($fullName))
|
||||
{
|
||||
return $fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region Application type/name/platform helpers
|
||||
# Moved here from Internal/MSGraph.ps1 (2026-06-21). MSGraph.ps1 is generic-only
|
||||
# (architecture rule R4); these resolve application-specific @odata.type/platform
|
||||
# metadata via Config/AppTypes.json + AppResources language strings, so they
|
||||
# belong with the rest of the Application feature code.
|
||||
function Get-GraphApplicationName
|
||||
{
|
||||
param($AppPolicy)
|
||||
|
||||
try {
|
||||
$defaultName = $AppPolicy.Object.'@OData.Type'.Split('.')[-1]
|
||||
$appType = Get-GraphApplicationType $AppPolicy
|
||||
if($appType) {
|
||||
$appTypeName = Get-LanguageString "AppResources.AppType.$($appType.LanguageId)"
|
||||
if($appTypeName) { return $appTypeName }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
|
||||
}
|
||||
return $defaultName
|
||||
}
|
||||
|
||||
function Get-GraphApplicationType
|
||||
{
|
||||
param($AppPolicy)
|
||||
|
||||
if(-not $script:allAppTypes)
|
||||
{
|
||||
$appTypesPath = [IO.Path]::Combine($script:AppRootFolder, "Config", "AppTypes.json")
|
||||
$fi = [IO.FileInfo]$appTypesPath
|
||||
if(!$fi.Exists)
|
||||
{
|
||||
return
|
||||
}
|
||||
$script:allAppTypes = [IO.File]::ReadAllText($appTypesPath) | ConvertFrom-Json
|
||||
}
|
||||
|
||||
foreach($appType in ($script:allAppTypes | Where-Object ODataType -eq $AppPolicy.JsonObject.'@OData.Type'))
|
||||
{
|
||||
if($appType.Condition)
|
||||
{
|
||||
if($AppPolicy.Object."$($appType.Condition.Property)" -eq $appType.Condition.Value)
|
||||
{
|
||||
return $appType
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return $appType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GraphApplicationPlatform
|
||||
{
|
||||
param($AppPolicy)
|
||||
|
||||
$platform = $null
|
||||
|
||||
$lowerAppType = $AppPolicy.JsonObject.'@OData.Type'.ToLower()
|
||||
if($lowerAppType.Contains("ios"))
|
||||
{
|
||||
$platform = "iOS"
|
||||
}
|
||||
elseif($lowerAppType.Contains("mac"))
|
||||
{
|
||||
$platform = "macOS"
|
||||
}
|
||||
elseif($lowerAppType.Contains("win"))
|
||||
{
|
||||
$platform = "Windows"
|
||||
}
|
||||
elseif($lowerAppType.Contains("android"))
|
||||
{
|
||||
$platform = "Android"
|
||||
}
|
||||
elseif($lowerAppType.Contains("web"))
|
||||
{
|
||||
$platform = "web"
|
||||
}
|
||||
|
||||
if($platform) { return (Get-LanguageString "AppResources.AppTypePlatform.$platform") }
|
||||
|
||||
# No platform token in the @odata.type (officeSuiteApp,
|
||||
# microsoftStoreForBusinessApp). ApplicationObject.Init assigns this result
|
||||
# unconditionally, so returning nothing here would wipe whatever the base
|
||||
# Init chain resolved - go through the shared override table instead.
|
||||
return (Get-PolicyPlatformOverride $AppPolicy.JsonObject.'@OData.Type')
|
||||
}
|
||||
|
||||
function Get-GraphApplicationTypeGroup
|
||||
{
|
||||
param($AppPolicy)
|
||||
|
||||
$categoryId = $null
|
||||
|
||||
$lowerAppType = $AppPolicy.JsonObject.'@OData.Type'.ToLower()
|
||||
if($lowerAppType.Contains("store"))
|
||||
{
|
||||
$categoryId = "storeApp"
|
||||
}
|
||||
elseif($lowerAppType.Contains("office"))
|
||||
{
|
||||
$categoryId = "office365Suite"
|
||||
}
|
||||
elseif($lowerAppType.Contains("defender"))
|
||||
{
|
||||
$categoryId = "microsoftDefenderATP"
|
||||
}
|
||||
elseif($lowerAppType.Contains("edge"))
|
||||
{
|
||||
$categoryId = "microsoftEdge"
|
||||
}
|
||||
elseif($lowerAppType.Contains("web"))
|
||||
{
|
||||
$categoryId = "webApplication"
|
||||
}
|
||||
else
|
||||
{
|
||||
$categoryId = "other"
|
||||
}
|
||||
|
||||
(Get-LanguageString "AppCategories.$categoryId")
|
||||
}
|
||||
#endregion
|
||||
@@ -0,0 +1,685 @@
|
||||
function Initialize-IntuneAssignmentsModule
|
||||
{
|
||||
$script:intuneAssignmentsProviders = @(
|
||||
[IntuneAssignmentsFolderProvider]::new()
|
||||
[IntuneAssignmentsIntuneProvider]::new()
|
||||
)
|
||||
}
|
||||
|
||||
function Test-IntuneAssignmentsSupported
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
True when a policy type can carry assignments at all.
|
||||
|
||||
.DESCRIPTION
|
||||
Types whose Graph entity has no assignments navigation property (Conditional
|
||||
Access, Terms of Use, Named Locations, Filters, Role Definitions, ...) declare
|
||||
_SupportsAssignments = $false. Listing them in the Assignments tool produced a
|
||||
row per policy with an empty assignment set, which is noise.
|
||||
|
||||
Only an explicit $false excludes a type. PolicyType is duck-typed in places
|
||||
(tests pass a PSCustomObject), and a missing property must not silently hide a
|
||||
type that really does support assignments - same rule as
|
||||
Add-GraphPolicyAssignments.
|
||||
#>
|
||||
param($PolicyType)
|
||||
|
||||
if(-not $PolicyType) { return $false }
|
||||
return ($PolicyType.SupportsAssignments -ne $false)
|
||||
}
|
||||
|
||||
function Get-IntuneAssignmentPolicyTypes
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
The policy types the Assignments tool should look at.
|
||||
#>
|
||||
param($PolicyTypes = $script:IntuneTypes)
|
||||
|
||||
return @($PolicyTypes | Where-Object { Test-IntuneAssignmentsSupported $_ })
|
||||
}
|
||||
|
||||
function Get-IntuneAssignmentViewOptions
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
The View choices offered above the Assignments list.
|
||||
|
||||
.DESCRIPTION
|
||||
Returned as plain Name/Value objects so both UI backends can adapt them:
|
||||
WPF binds them directly (DisplayMemberPath/SelectedValuePath), Avalonia wraps
|
||||
them in [NameValueComboItem] because its binder needs a real CLR type.
|
||||
The first entry is the default.
|
||||
#>
|
||||
|
||||
return @(
|
||||
[PSCustomObject]@{ Name = "Show all"; Value = "All" }
|
||||
[PSCustomObject]@{ Name = "Show assigned policies only"; Value = "Assigned" }
|
||||
[PSCustomObject]@{ Name = "Show policies with no assignments"; Value = "Unassigned" }
|
||||
)
|
||||
}
|
||||
|
||||
function Test-IntuneAssignmentViewMatch
|
||||
{
|
||||
<#
|
||||
.SYNOPSIS
|
||||
True when a row belongs in the currently selected View.
|
||||
|
||||
.DESCRIPTION
|
||||
An unknown or empty view shows everything, so a UI that has not populated its
|
||||
combo yet (or a saved value from a later version) never blanks the list.
|
||||
#>
|
||||
param($Row, [string]$View)
|
||||
|
||||
if(-not $Row) { return $false }
|
||||
|
||||
$count = 0
|
||||
if($null -ne $Row.AssignmentCount) { $count = [int]$Row.AssignmentCount }
|
||||
|
||||
switch($View)
|
||||
{
|
||||
"Assigned" { return ($count -gt 0) }
|
||||
"Unassigned" { return ($count -le 0) }
|
||||
default { return $true }
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-IntuneAssignmentRow
|
||||
{
|
||||
param(
|
||||
$Policy,
|
||||
[hashtable]$GroupsById = $null,
|
||||
[hashtable]$FiltersById = $null,
|
||||
[hashtable]$GroupCache = $null,
|
||||
$TokenId = $null
|
||||
)
|
||||
|
||||
if(-not $Policy -or -not $Policy.Object) { return $null }
|
||||
|
||||
$nameProp = if($Policy.PolicyType -and $Policy.PolicyType.NameProperty) { $Policy.PolicyType.NameProperty } else { "displayName" }
|
||||
$name = $Policy.Object.$nameProp
|
||||
if([string]::IsNullOrWhiteSpace("$name") -and $Policy.PSObject.Properties['Name']) { $name = $Policy.Name }
|
||||
|
||||
$type = "$($Policy.Object.'@OData.Type')"
|
||||
if($type) { $type = $type.Split('.')[-1] }
|
||||
elseif($Policy.PolicyType -and $Policy.PolicyType.Title) { $type = $Policy.PolicyType.Title }
|
||||
else { $type = "" }
|
||||
|
||||
$included = @()
|
||||
$excluded = @()
|
||||
$includedFilters = @()
|
||||
$excludedFilters = @()
|
||||
|
||||
foreach($assignment in @($Policy.Object.assignments))
|
||||
{
|
||||
if(-not $assignment -or -not $assignment.target) { continue }
|
||||
|
||||
$targetType = "$($assignment.target.'@odata.type')"
|
||||
$resolved = $null
|
||||
$isExcluded = $false
|
||||
|
||||
switch($targetType)
|
||||
{
|
||||
"#microsoft.graph.groupAssignmentTarget" { $resolved = Resolve-IntuneAssignmentGroup $assignment.target.groupId $GroupsById $GroupCache $TokenId }
|
||||
"#microsoft.graph.exclusionGroupAssignmentTarget" { $resolved = Resolve-IntuneAssignmentGroup $assignment.target.groupId $GroupsById $GroupCache $TokenId; $isExcluded = $true }
|
||||
"#microsoft.graph.allDevicesAssignmentTarget" { $resolved = "All Devices" }
|
||||
"#microsoft.graph.allLicensedUsersAssignmentTarget" { $resolved = "All Users" }
|
||||
default
|
||||
{
|
||||
if($assignment.target.groupId) { $resolved = Resolve-IntuneAssignmentGroup $assignment.target.groupId $GroupsById $GroupCache $TokenId }
|
||||
}
|
||||
}
|
||||
|
||||
if($null -eq $resolved) { continue }
|
||||
|
||||
if($isExcluded) { $excluded += $resolved } else { $included += $resolved }
|
||||
|
||||
$filterId = [string]$assignment.target.deviceAndAppManagementAssignmentFilterId
|
||||
if(Test-AssignmentFilterDefined $filterId) {
|
||||
$filterName = Resolve-IntuneAssignmentFilter $filterId $FiltersById
|
||||
$filterType = [string]$assignment.target.deviceAndAppManagementAssignmentFilterType
|
||||
$filterLabel = if($filterType) { "$filterName ($filterType)" } else { $filterName }
|
||||
|
||||
if($filterType -eq "exclude") { $excludedFilters += $filterLabel }
|
||||
else { $includedFilters += $filterLabel }
|
||||
}
|
||||
}
|
||||
|
||||
$included = @($included | Where-Object { $_ } | Select-Object -Unique)
|
||||
$excluded = @($excluded | Where-Object { $_ } | Select-Object -Unique)
|
||||
$includedFilters = @($includedFilters | Where-Object { $_ } | Select-Object -Unique)
|
||||
$excludedFilters = @($excludedFilters | Where-Object { $_ } | Select-Object -Unique)
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Object = $Policy.Object
|
||||
Id = $Policy.Object.id
|
||||
Name = $name
|
||||
Type = $type
|
||||
AssignmentCount = @($Policy.Object.assignments).Count
|
||||
HasFilters = ($includedFilters.Count -gt 0 -or $excludedFilters.Count -gt 0)
|
||||
Included = $included
|
||||
Excluded = $excluded
|
||||
IncludedFilters = $includedFilters
|
||||
ExcludedFilters = $excludedFilters
|
||||
IncludedString = ($included -join "; ")
|
||||
ExcludedString = ($excluded -join "; ")
|
||||
IncludedFilterString = ($includedFilters -join "; ")
|
||||
ExcludedFilterString = ($excludedFilters -join "; ")
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-IntuneAssignmentGroup
|
||||
{
|
||||
param(
|
||||
[string] $GroupId,
|
||||
[hashtable]$GroupsById,
|
||||
[hashtable]$GroupCache,
|
||||
$TokenId
|
||||
)
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($GroupId)) { return $null }
|
||||
|
||||
if($GroupsById -and $GroupsById.ContainsKey($GroupId)) { return $GroupsById[$GroupId] }
|
||||
|
||||
if($null -ne $GroupCache -and $GroupCache.ContainsKey($GroupId)) { return $GroupCache[$GroupId] }
|
||||
|
||||
if($TokenId)
|
||||
{
|
||||
try
|
||||
{
|
||||
$group = Invoke-MSGraphAPI -Url "groups/$($GroupId)?`$select=id,displayName" -ODataMetadata "skip" -TokenId $TokenId
|
||||
if($group -and $group.displayName)
|
||||
{
|
||||
if($GroupCache -ne $null) { $GroupCache[$GroupId] = $group.displayName }
|
||||
return $group.displayName
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Could not resolve group with ID $GroupId" 2
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Could not find a group with ID $GroupId" 2
|
||||
}
|
||||
|
||||
return $GroupId
|
||||
}
|
||||
|
||||
# Intune's "no assignment filter" sentinel.
|
||||
#
|
||||
# Graph writes an all-zeros filter id on assignment targets, including on types the
|
||||
# portal offers no filter UI for at all (enrollment notifications, for one), and it is
|
||||
# usually paired with filterType 'exclude'. Taken at face value that reads as a real
|
||||
# filter, so documentation reported a filter literally named
|
||||
# 00000000-0000-0000-0000-000000000000 in Exclude mode against an assignment that has
|
||||
# no filter. It is never a real filter id.
|
||||
$script:NoAssignmentFilterId = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
# $true only for a filter id that actually identifies a filter. The sentinel and an
|
||||
# absent/blank value are the same thing - "not defined" - so every caller can ask this
|
||||
# one question instead of re-deriving it (the answer was already open-coded in the
|
||||
# documentation batch lookup and the migration walk, and missing everywhere else).
|
||||
function Test-AssignmentFilterDefined
|
||||
{
|
||||
[OutputType([bool])]
|
||||
param($FilterId)
|
||||
|
||||
$id = [string]$FilterId
|
||||
if([string]::IsNullOrWhiteSpace($id)) { return $false }
|
||||
return $id.Trim() -ne $script:NoAssignmentFilterId
|
||||
}
|
||||
|
||||
function Resolve-IntuneAssignmentFilter
|
||||
{
|
||||
param(
|
||||
[string]$FilterId,
|
||||
[hashtable]$FiltersById
|
||||
)
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($FilterId)) { return $null }
|
||||
if($FiltersById -and $FiltersById.ContainsKey($FilterId)) { return $FiltersById[$FilterId] }
|
||||
return "<unresolved: $FilterId>"
|
||||
}
|
||||
|
||||
function Get-IntuneAssignmentFiltersFromFolder
|
||||
{
|
||||
param([string]$Path)
|
||||
|
||||
$filtersById = @{}
|
||||
if(-not $Path -or -not [IO.Directory]::Exists($Path)) { return $filtersById }
|
||||
|
||||
$filterType = $null
|
||||
if($script:IntuneTypes) {
|
||||
$filterType = $script:IntuneTypes | Where-Object Id -eq 'AssignmentFilters' | Select-Object -First 1
|
||||
}
|
||||
|
||||
$searchFolders = @()
|
||||
if($filterType -and $filterType.Folder) {
|
||||
$typeFolder = [IO.Path]::Combine($Path, $filterType.Folder)
|
||||
if([IO.Directory]::Exists($typeFolder)) { $searchFolders += $typeFolder }
|
||||
}
|
||||
if($searchFolders.Count -eq 0) {
|
||||
$searchFolders += $Path
|
||||
}
|
||||
|
||||
foreach($folder in $searchFolders) {
|
||||
foreach($file in [IO.Directory]::EnumerateFiles($folder, "*.json", [IO.SearchOption]::AllDirectories))
|
||||
{
|
||||
try {
|
||||
$obj = ConvertFrom-Json ([IO.File]::ReadAllText($file))
|
||||
$odataType = "$($obj.'@odata.type')$($obj.'@OData.Type')"
|
||||
if($obj.id -and $obj.displayName -and (
|
||||
$odataType -match 'deviceAndAppManagementAssignmentFilter' -or
|
||||
$obj.PSObject.Properties['assignmentFilterManagementType'])) {
|
||||
$filtersById[$obj.id] = $obj.displayName
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to parse assignment filter file $file" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $filtersById
|
||||
}
|
||||
|
||||
function Get-IntuneAssignmentFiltersFromIntune
|
||||
{
|
||||
param([int]$TokenId)
|
||||
|
||||
$filtersById = @{}
|
||||
try {
|
||||
$resp = Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters?`$select=id,displayName" -TokenId $TokenId -AllPages
|
||||
$filters = @()
|
||||
if($resp -and $resp.value) { $filters = @($resp.value) }
|
||||
elseif($resp -is [Array]) { $filters = @($resp) }
|
||||
|
||||
foreach($filter in $filters) {
|
||||
if($filter.id -and $filter.displayName) { $filtersById[$filter.id] = $filter.displayName }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Could not load assignment filters" 2
|
||||
}
|
||||
|
||||
return $filtersById
|
||||
}
|
||||
|
||||
function Get-IntuneAssignmentGroupIds
|
||||
{
|
||||
param($Policies)
|
||||
|
||||
$ids = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($policy in @($Policies)) {
|
||||
foreach($assignment in @($policy.Object.assignments)) {
|
||||
$groupId = [string]$assignment.target.groupId
|
||||
if(-not [string]::IsNullOrWhiteSpace($groupId)) { [void]$ids.Add($groupId) }
|
||||
}
|
||||
}
|
||||
return @($ids)
|
||||
}
|
||||
|
||||
function Get-IntuneAssignmentGroupsFromIntune
|
||||
{
|
||||
param(
|
||||
[string[]]$GroupIds,
|
||||
[int]$TokenId
|
||||
)
|
||||
|
||||
$groupsById = @{
|
||||
"adadadad-808e-44e2-905a-0b7873a8a531" = "All Devices"
|
||||
"acacacac-9df4-4c7d-9d50-4ef0226f57a9" = "All Users"
|
||||
}
|
||||
|
||||
$toLookup = @($GroupIds | Where-Object { $_ -and -not $groupsById.ContainsKey($_) } | Select-Object -Unique)
|
||||
if($toLookup.Count -eq 0) { return $groupsById }
|
||||
|
||||
$batch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$idx = 0
|
||||
foreach($groupId in $toLookup) {
|
||||
$idx++
|
||||
[void]$batch.Add([PSCustomObject]@{
|
||||
id = "grp_$idx"
|
||||
method = "GET"
|
||||
url = "groups/$groupId/?`$select=displayName,id"
|
||||
headers = @{ "Accept" = "application/json" }
|
||||
})
|
||||
}
|
||||
|
||||
$idByRequest = @{}
|
||||
$idx = 0
|
||||
foreach($groupId in $toLookup) {
|
||||
$idx++
|
||||
$idByRequest["grp_$idx"] = $groupId
|
||||
}
|
||||
|
||||
try {
|
||||
$results = @(Invoke-GraphBatchRequest -BatchObjects $batch -BatchType "AssignmentGroupNames" -TokenId $TokenId -SkipWarnings -IncludedFailed)
|
||||
foreach($result in $results) {
|
||||
$groupId = $idByRequest["$($result.Id)"]
|
||||
if(-not $groupId) { continue }
|
||||
if($result.Status -ge 200 -and $result.Status -lt 300 -and $result.body -and $result.body.displayName) {
|
||||
$groupsById[$groupId] = $result.body.displayName
|
||||
}
|
||||
else {
|
||||
$groupsById[$groupId] = "<unresolved: $groupId>"
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Could not batch resolve assignment groups" 2
|
||||
}
|
||||
|
||||
return $groupsById
|
||||
}
|
||||
|
||||
function Get-IntuneAssignmentsFromFolder
|
||||
{
|
||||
param([string]$Path)
|
||||
|
||||
Write-Status "Loading assignments from folder"
|
||||
|
||||
$rows = @()
|
||||
if(-not $Path -or -not [IO.Directory]::Exists($Path)) { return $rows }
|
||||
|
||||
Save-SettingStoreValue "IntuneAssignments" "ExportPath" $Path
|
||||
Save-SettingStoreValue "" "LastUsedFullPath" $Path
|
||||
|
||||
$groupsById = @{}
|
||||
$filtersById = Get-IntuneAssignmentFiltersFromFolder $Path
|
||||
$groupsPath = [IO.Path]::Combine($Path, "Groups")
|
||||
if([IO.Directory]::Exists($groupsPath))
|
||||
{
|
||||
foreach($file in [IO.Directory]::EnumerateFiles($groupsPath, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
$g = ConvertFrom-Json ([IO.File]::ReadAllText($file))
|
||||
if($g.id -and $g.displayName) { $groupsById[$g.id] = $g.displayName }
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to parse group file $file" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $script:IntuneTypes)
|
||||
{
|
||||
Write-Log "Intune types are not initialized. Cannot load policies from folder." 3
|
||||
return $rows
|
||||
}
|
||||
|
||||
foreach($policyType in (Get-IntuneAssignmentPolicyTypes))
|
||||
{
|
||||
$typeFolder = [IO.Path]::Combine($Path, $policyType.Folder)
|
||||
if(-not [IO.Directory]::Exists($typeFolder)) { continue }
|
||||
|
||||
Write-Status "Read $($policyType.Title)" -Force -SkipLog
|
||||
|
||||
$policies = @(Get-PoliciesFromFolder -Path $typeFolder -PolicyTypes @($policyType))
|
||||
foreach($policy in $policies)
|
||||
{
|
||||
$row = Get-IntuneAssignmentRow -Policy $policy -GroupsById $groupsById -FiltersById $filtersById
|
||||
if($row) { $rows += $row }
|
||||
}
|
||||
}
|
||||
|
||||
return $rows
|
||||
}
|
||||
|
||||
function Get-IntuneAssignmentsFromIntune
|
||||
{
|
||||
Write-Status "Loading assignments from Intune"
|
||||
|
||||
$rows = @()
|
||||
if(-not $script:IntuneTypes)
|
||||
{
|
||||
Write-Log "Intune types are not initialized. Cannot load policies from Intune." 3
|
||||
return $rows
|
||||
}
|
||||
|
||||
$tokenId = Get-DefaultTokenId
|
||||
|
||||
# Ask Graph only for the types that can carry assignments. Every policy group
|
||||
# also contains unassignable types (Conditional Access, Terms of Use, ...), so
|
||||
# -PolicyGroup would list those too and we would discard the rows afterwards:
|
||||
# one wasted call each, and some of those endpoints answer 4xx/5xx, which then
|
||||
# shows up as batch errors in the Graph Calls view
|
||||
# (identityGovernance/termsOfUse/agreements returns 500).
|
||||
# Equivalent to the old group expansion: every registered type belongs to a
|
||||
# group, so this is that same set minus the unassignable types.
|
||||
$typeIds = @(Get-IntuneAssignmentPolicyTypes | Where-Object { $_.Id } | ForEach-Object { $_.Id })
|
||||
|
||||
if($typeIds.Count -eq 0) { return $rows }
|
||||
|
||||
$policies = @(Get-GraphPolicies -PolicyType $typeIds -TokenId $tokenId -IncludeAssignments)
|
||||
$groupsById = Get-IntuneAssignmentGroupsFromIntune -GroupIds (Get-IntuneAssignmentGroupIds -Policies $policies) -TokenId $tokenId
|
||||
$filtersById = Get-IntuneAssignmentFiltersFromIntune -TokenId $tokenId
|
||||
|
||||
foreach($policy in $policies)
|
||||
{
|
||||
# Get-GraphPolicies deliberately lists every type (bulk export needs the
|
||||
# unassignable ones too - see Public/Get-GraphPolicies.ps1), so filter here
|
||||
# rather than at the query.
|
||||
if(-not (Test-IntuneAssignmentsSupported $policy.PolicyType)) { continue }
|
||||
|
||||
$row = Get-IntuneAssignmentRow -Policy $policy -GroupsById $groupsById -FiltersById $filtersById -TokenId $tokenId
|
||||
if($row) { $rows += $row }
|
||||
}
|
||||
|
||||
return $rows
|
||||
}
|
||||
|
||||
# Moved from Public/Get-GraphPolicies.ps1 (architecture R11): assignment-
|
||||
# expansion helper for the policy list path. Module-internal, not exported.
|
||||
function Add-GraphPolicyAssignments
|
||||
{
|
||||
param(
|
||||
$Policies,
|
||||
[int]$TokenId
|
||||
)
|
||||
|
||||
if (-not $Policies -or $Policies.Count -eq 0) { return }
|
||||
|
||||
# Build batch for policies whose type didn't get assignments expanded inline.
|
||||
$assignmentBatch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$policyById = @{}
|
||||
$idCounter = 0
|
||||
|
||||
foreach ($policy in $Policies) {
|
||||
if (-not $policy.PolicyType -or $policy.PolicyType.SupportsAssignments -eq $false) { continue }
|
||||
if (-not $policy.Id) { continue }
|
||||
# Already populated by inline $expand=assignments; skip.
|
||||
# An empty array (@()) is also considered populated -- don't re-fetch.
|
||||
if ($null -ne $policy.Object.assignments) { continue }
|
||||
|
||||
# Polymorphic list collections need a per-object base (App protection routes
|
||||
# to the concrete platform collection); $null means this object can't carry
|
||||
# assignments at all. PolicyType is duck-typed in places (tests pass a
|
||||
# PSCustomObject), so fall back to the plain API when the hook is absent.
|
||||
$assignmentBase = if ($policy.PolicyType.PSObject.Methods['GetAssignmentsBaseURL']) {
|
||||
$policy.PolicyType.GetAssignmentsBaseURL($policy)
|
||||
} else {
|
||||
$policy.PolicyType.API
|
||||
}
|
||||
if (-not $assignmentBase) { continue }
|
||||
|
||||
$idCounter++
|
||||
$reqId = "asn_$idCounter"
|
||||
# Some types (policySets) return 400 on the /assignments navigation
|
||||
# GET; only the single-object $expand variant works for them.
|
||||
if ($policy.PolicyType.AssignmentsViaExpand -eq $true) {
|
||||
$apiUrl = "$assignmentBase/$($policy.Id)?`$expand=assignments".TrimStart('/')
|
||||
}
|
||||
else {
|
||||
$apiUrl = "$assignmentBase/$($policy.Id)/assignments".TrimStart('/')
|
||||
}
|
||||
[void]$assignmentBatch.Add([PSCustomObject]@{
|
||||
id = $reqId
|
||||
method = "GET"
|
||||
url = $apiUrl
|
||||
headers = @{ "Accept" = "application/json;odata.metadata=minimal" }
|
||||
})
|
||||
$policyById[$reqId] = $policy
|
||||
}
|
||||
|
||||
if ($assignmentBatch.Count -eq 0) { return }
|
||||
|
||||
Write-Log "Loading assignments for $($assignmentBatch.Count) policies"
|
||||
$batchResults = Invoke-GraphBatchRequest $assignmentBatch "Policy assignments" -TokenId $TokenId -AllPages
|
||||
|
||||
foreach ($result in $batchResults) {
|
||||
$policy = $policyById["$($result.Id)"]
|
||||
if (-not $policy -or -not $policy.Object) { continue }
|
||||
|
||||
# Expand-variant responses carry the assignments on the object body;
|
||||
# navigation GETs return them as the value collection.
|
||||
$value = if ($policy.PolicyType.AssignmentsViaExpand -eq $true) { $result.body.assignments } else { $result.body.value }
|
||||
if ($null -eq $value) { $value = @() } else { $value = @($value) }
|
||||
|
||||
# PSCustomObject from ConvertFrom-Json: assigning a property creates it if missing.
|
||||
$policy.Object | Add-Member -MemberType NoteProperty -Name "assignments" -Value $value -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Moved from Internal/MSGraph.ps1 (architecture R4 - MSGraph.ps1 should hold
|
||||
# only generic Graph helpers; these are assignment-feature functions).
|
||||
#region Assignments functions
|
||||
function Import-GraphObjectAssignment
|
||||
{
|
||||
param($PolicyObject, $SourceObject, [switch]$CopyAssignments)
|
||||
|
||||
# Honour the ImportAssignments setting for normal imports/updates. Explicit
|
||||
# copy paths (Replace) pass -CopyAssignments and always import assignments.
|
||||
if(-not $CopyAssignments -and (Get-SettingValue "ImportAssignments") -ne $true) { return }
|
||||
|
||||
$assignments = $SourceObject.JsonObject.assignments
|
||||
|
||||
if(($assignments | Measure-Object).Count -eq 0) { return }
|
||||
|
||||
$preConfig = $null
|
||||
$clonedAssignments = $assignments | ConvertTo-Json -Depth 50 | ConvertFrom-Json
|
||||
|
||||
$preConfig = $PolicyObject.PolicyType.PreImportAssignmentsCommand($PolicyObject, $SourceObject)
|
||||
|
||||
if($preConfig -isnot [Hashtable]) { $preConfig = @{} }
|
||||
|
||||
if($preConfig["Import"] -eq $false) { return } # Assignment managed manually so skip further processing
|
||||
|
||||
$api = ?? $preConfig["API"] "$($PolicyObject.PolicyType.API)/$($PolicyObject.Id)/$($PolicyObject.PolicyType.AssignAction)"
|
||||
|
||||
$method = ?? $preConfig["Method"] "POST"
|
||||
|
||||
$clonedAssignments = ?? $preConfig["Assignments"] $clonedAssignments
|
||||
|
||||
$keepProperties = ?? $PolicyObject.PolicyType.AssignmentPropertiesToKeep @("target")
|
||||
$keepTargetProperties = ?? $PolicyObject.PolicyType.AssignmentTargetPropertiesToKeep @("@odata.type","groupId","deviceAndAppManagementAssignmentFilterId","deviceAndAppManagementAssignmentFilterType")
|
||||
|
||||
$ObjectAssignments = @()
|
||||
foreach($assignment in $clonedAssignments)
|
||||
{
|
||||
if(($assignment.target.UserId -and $CopyAssignments -ne $true) -or ($assignment.Source -and $assignment.Source -ne "direct"))
|
||||
{
|
||||
# E.g. Source could be PolicySet...so should not be added here
|
||||
continue
|
||||
}
|
||||
|
||||
# Only blank the id when it still exists. Remove-GraphPropertiesForImport
|
||||
# recurses into assignment child objects during import/copy prep and strips
|
||||
# 'id' (it is in the default remove list); the same source object is then
|
||||
# reused here, so 'id' may already be gone. Setting a missing property on a
|
||||
# PSCustomObject throws ("The property 'Id' cannot be found..."), which broke
|
||||
# copy/import of any policy that has assignments when ImportAssignments is on.
|
||||
# (The loop below strips id anyway unless a type keeps it, so this is only to
|
||||
# normalise a kept id to empty.)
|
||||
if($assignment.PSObject.Properties['Id']) { $assignment.Id = "" }
|
||||
foreach($prop in $assignment.PSObject.Properties)
|
||||
{
|
||||
if($prop.Name -in $keepProperties) { continue }
|
||||
Remove-Property $assignment $prop.Name
|
||||
}
|
||||
|
||||
foreach($prop in $assignment.target.PSObject.Properties)
|
||||
{
|
||||
if($prop.Name -in $keepTargetProperties) { continue }
|
||||
Remove-Property $assignment.target $prop.Name
|
||||
}
|
||||
|
||||
$ObjectAssignments += $assignment
|
||||
}
|
||||
|
||||
if($ObjectAssignments.Count -eq 0) { return } # No "Direct" assignments
|
||||
|
||||
$htAssignments = @{}
|
||||
$htAssignments.Add($PolicyObject.PolicyType.AssignmentsType, @($ObjectAssignments))
|
||||
|
||||
$json = $htAssignments | ConvertTo-Json -Depth 50
|
||||
if($CopyAssignments -ne $true)
|
||||
{
|
||||
# Translation context must be the SOURCE object: $PolicyObject here is the
|
||||
# freshly-created target object (TenantId = TARGET tenant, no FileInfo, no
|
||||
# _ClonedFromObject), so Update-JsonForEnvironment could never locate the
|
||||
# export root or detect cross-tenant - group/dependency translation silently
|
||||
# no-oped on the assignment path. $SourceObject is the file-loaded original
|
||||
# with the migration-table TenantID and the export FileInfo.
|
||||
$json = Update-JsonForEnvironment $json $SourceObject $PolicyObject.TokenId
|
||||
}
|
||||
|
||||
$importedAssignments = Invoke-MSGraphAPI $api -HttpMethod $method -Content $json -TokenId $PolicyObject.TokenId
|
||||
|
||||
$PolicyObject.PolicyType.PostImportAssignmentsCommand($PolicyObject, $SourceObject, $importedAssignments)
|
||||
}
|
||||
|
||||
function Add-GraphAssignmentsToExportFile
|
||||
{
|
||||
param($PolicyObject, $FileName)
|
||||
|
||||
$exportAssignments = Get-CacheObject "CurrentExportAssignments" (Get-SettingValue "ExportAssignments")
|
||||
if($exportAssignments -ne $true) { return }
|
||||
|
||||
if([IO.File]::Exists($FileName) -eq $false)
|
||||
{
|
||||
Write-Log "File not found: $FileName. Could not add assignments to file" 3
|
||||
return
|
||||
}
|
||||
|
||||
$tmpObj = Get-GraphObjectFromFile $FileName
|
||||
|
||||
# See Add-GraphPolicyAssignments: polymorphic list collections need a per-object
|
||||
# base URL, and $null means the object cannot carry assignments.
|
||||
$assignmentBase = if($PolicyObject.PolicyType.PSObject.Methods['GetAssignmentsBaseURL']) {
|
||||
$PolicyObject.PolicyType.GetAssignmentsBaseURL($PolicyObject)
|
||||
} else {
|
||||
$PolicyObject.PolicyType.API
|
||||
}
|
||||
if(-not $assignmentBase) { return }
|
||||
|
||||
if($PolicyObject.PolicyType.AssignmentsViaExpand -eq $true) {
|
||||
# Types whose /assignments navigation GET 400s (policySets) - fetch
|
||||
# via the single-object $expand variant instead.
|
||||
$url = "$assignmentBase/$($PolicyObject.id)?`$expand=assignments"
|
||||
$assignments = (Invoke-MSGraphAPI -Url $url -ODataMetadata "Minimal" -TokenId $PolicyObject.TokenId).assignments
|
||||
}
|
||||
else {
|
||||
$url = "$assignmentBase/$($PolicyObject.id)/assignments"
|
||||
$assignments = (Invoke-MSGraphAPI -Url $url -ODataMetadata "Minimal" -TokenId $PolicyObject.TokenId).Value
|
||||
}
|
||||
if($assignments)
|
||||
{
|
||||
if(-not ($tmpObj.PSObject.Properties | Where-Object Name -eq "assignments"))
|
||||
{
|
||||
$tmpObj | Add-Member -MemberType NoteProperty -Name "assignments" -Value $assignments
|
||||
}
|
||||
else
|
||||
{
|
||||
$tmpObj.Assignments = $assignments
|
||||
}
|
||||
Save-GraphObjectToFile $tmpObj $FileName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
# Per-policy Entra group + assignment-filter migration on IMPORT.
|
||||
#
|
||||
# When a policy exported from another tenant is imported, its json still carries the
|
||||
# SOURCE tenant's group ids (assignments, CA include/exclude groups, notification CC
|
||||
# lists, role members, ...), assignment-filter ids (deviceAndAppManagementAssignment-
|
||||
# FilterId on every target type) and - for some payloads - group SIDs. This module
|
||||
# resolves ONLY the references the imported policy actually carries (unlike the 3.x
|
||||
# behavior of bulk-creating every group in the migration table up front):
|
||||
#
|
||||
# 1. Update-JsonForEnvironment scans the policy json for every GUID and SID
|
||||
# (Get-DependencyIDs) and hands them to Resolve-GraphMigrationGroups.
|
||||
# 2. A GUID/SID is only considered when it matches an EXPORTED group - the
|
||||
# Groups\*.json sidecars plus the MigrationTable.json Group entries under the
|
||||
# import folder's export root. Anything else is ignored (no Graph calls).
|
||||
# 3. Each matched group is resolved in the target tenant: by id first (fast path,
|
||||
# also covers already-migrated ids), then by displayName.
|
||||
# 4. Missing groups are CREATED - gated on the CreateGroupOnImport setting - from
|
||||
# the sidecar stripped to cloud-creatable properties (dynamic groups keep their
|
||||
# membershipRule/groupTypes). A group whose sidecar shows onPremisesSyncEnabled
|
||||
# is an AD-synced group: ConvertSyncedGroupOnImport=true recreates it as a
|
||||
# cloud Entra group, false skips it with a warning.
|
||||
# 5. The returned maps translate source id -> target id and source SID -> target
|
||||
# SID in the policy json.
|
||||
#
|
||||
# Results are cached per (export root + target tenant) so a group shared by many
|
||||
# policies in one bulk import is resolved/created exactly once.
|
||||
|
||||
# --- caches -------------------------------------------------------------------
|
||||
# $script:_groupSidecarIndex : exportRoot -> @{ ById = @{ id -> entry }; BySid = @{ sid -> entry } }
|
||||
# entry = @{ Id; Name; Sid; Synced; Sidecar (full object or $null) }
|
||||
# $script:_groupMigrationMap : "exportRoot|targetTenant" -> @{ sourceId -> resolved @{ Id; Sid } or $null (unresolvable) }
|
||||
|
||||
function Reset-GraphGroupMigrationCache
|
||||
{
|
||||
$script:_groupSidecarIndex = @{}
|
||||
$script:_groupMigrationMap = @{}
|
||||
$script:_filterListCache = @{}
|
||||
}
|
||||
|
||||
# Resolve the export root for a policy loaded from file: the folder that holds
|
||||
# MigrationTable.json / Groups\. Probes the file's own folder first (AddObjectType
|
||||
# = false exports) then its parent (the normal per-type subfolder layout) - same
|
||||
# 2-level heuristic as Get-GraphMigrationTableFromPath.
|
||||
function Get-GraphGroupMigrationRoot
|
||||
{
|
||||
param($PolicyObject)
|
||||
|
||||
$fileInfo = ?? $PolicyObject._ClonedFromObject.FileInfo $PolicyObject.FileInfo
|
||||
if(-not $fileInfo) { return $null }
|
||||
|
||||
$folder = [IO.Path]::GetDirectoryName($fileInfo.FullName)
|
||||
for($i = 0; $i -lt 2 -and $folder; $i++)
|
||||
{
|
||||
if([IO.File]::Exists([IO.Path]::Combine($folder, "MigrationTable.json")) -or
|
||||
[IO.Directory]::Exists([IO.Path]::Combine($folder, "Groups")) -or
|
||||
[IO.Directory]::Exists([IO.Path]::Combine($folder, "AssignmentFilters")))
|
||||
{
|
||||
return $folder
|
||||
}
|
||||
$folder = [IO.Path]::GetDirectoryName($folder)
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Build (and cache) the exported-group index for an export root: every group the
|
||||
# export knows about, keyed by source id and by SID. Sidecars win over bare
|
||||
# migration-table entries (they carry the full object for re-creation).
|
||||
function Get-GraphGroupSidecarIndex
|
||||
{
|
||||
param([string]$ExportRoot)
|
||||
|
||||
if(-not $script:_groupSidecarIndex) { $script:_groupSidecarIndex = @{} }
|
||||
if($script:_groupSidecarIndex.ContainsKey($ExportRoot)) { return $script:_groupSidecarIndex[$ExportRoot] }
|
||||
|
||||
$index = @{ ById = @{}; BySid = @{} }
|
||||
|
||||
$groupsPath = [IO.Path]::Combine($ExportRoot, "Groups")
|
||||
if([IO.Directory]::Exists($groupsPath))
|
||||
{
|
||||
foreach($file in [IO.Directory]::EnumerateFiles($groupsPath, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
$g = ConvertFrom-Json ([IO.File]::ReadAllText($file))
|
||||
if(-not $g.id) { continue }
|
||||
$entry = @{
|
||||
Kind = "Group"
|
||||
Id = [string]$g.id
|
||||
Name = [string]$g.displayName
|
||||
Sid = [string]$g.securityIdentifier
|
||||
Synced = ($g.onPremisesSyncEnabled -eq $true)
|
||||
Sidecar = $g
|
||||
}
|
||||
$index.ById[$entry.Id] = $entry
|
||||
if($entry.Sid) { $index.BySid[$entry.Sid] = $entry }
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to parse group sidecar $file" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Assignment-filter sidecars (written by Add-GraphMigrationObject; the folder name
|
||||
# matches the AssignmentFilters type's normal export folder, so a full bulk export
|
||||
# that included the type feeds the same index).
|
||||
$filtersPath = [IO.Path]::Combine($ExportRoot, "AssignmentFilters")
|
||||
if([IO.Directory]::Exists($filtersPath))
|
||||
{
|
||||
foreach($file in [IO.Directory]::EnumerateFiles($filtersPath, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
$f = ConvertFrom-Json ([IO.File]::ReadAllText($file))
|
||||
if(-not $f.id) { continue }
|
||||
$index.ById[[string]$f.id] = @{
|
||||
Kind = "Filter"
|
||||
Id = [string]$f.id
|
||||
Name = [string]$f.displayName
|
||||
Sid = $null
|
||||
Synced = $false
|
||||
Sidecar = $f
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to parse assignment-filter sidecar $file" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Migration-table entries without a sidecar (older exports): name-only.
|
||||
$migFile = [IO.Path]::Combine($ExportRoot, "MigrationTable.json")
|
||||
if([IO.File]::Exists($migFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
$migObj = ConvertFrom-Json ([IO.File]::ReadAllText($migFile))
|
||||
foreach($m in @($migObj.Objects))
|
||||
{
|
||||
if($m.Type -notin @("Group","AssignmentFilter") -or -not $m.Id) { continue }
|
||||
if($index.ById.ContainsKey([string]$m.Id)) { continue }
|
||||
$index.ById[[string]$m.Id] = @{
|
||||
Kind = if($m.Type -eq "AssignmentFilter") { "Filter" } else { "Group" }
|
||||
Id = [string]$m.Id
|
||||
Name = [string]$m.DisplayName
|
||||
Sid = $null
|
||||
Synced = $false
|
||||
Sidecar = $null
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to parse migration table $migFile" 2
|
||||
}
|
||||
}
|
||||
|
||||
$script:_groupSidecarIndex[$ExportRoot] = $index
|
||||
return $index
|
||||
}
|
||||
|
||||
# Create a group in the target tenant from an exported entry. Sidecar-based when
|
||||
# available (stripped to cloud-creatable properties; dynamic groups keep their
|
||||
# rule), else a default cloud security group. Returns the created group or $null.
|
||||
function New-GraphMigrationGroup
|
||||
{
|
||||
param($Entry, [int]$TokenId)
|
||||
|
||||
$keepProps = @("displayName","description","mailEnabled","mailNickname","securityEnabled",
|
||||
"membershipRule","groupTypes","membershipRuleProcessingState")
|
||||
|
||||
$body = [ordered]@{}
|
||||
if($Entry.Sidecar)
|
||||
{
|
||||
foreach($prop in $Entry.Sidecar.PSObject.Properties)
|
||||
{
|
||||
if($prop.Name -notin $keepProps) { continue }
|
||||
if($null -eq $prop.Value) { continue }
|
||||
$body[$prop.Name] = $prop.Value
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $body["displayName"]) { $body["displayName"] = $Entry.Name }
|
||||
if(-not $body["displayName"]) { return $null }
|
||||
$body["displayName"] = ([string]$body["displayName"]).Trim()
|
||||
if(-not $body.Contains("mailEnabled")) { $body["mailEnabled"] = $false }
|
||||
if(-not $body.Contains("securityEnabled")) { $body["securityEnabled"] = $true }
|
||||
# mailNickname is mandatory on POST /groups and often null on synced/security groups.
|
||||
if(-not $body["mailNickname"]) { $body["mailNickname"] = (New-Guid).Guid.SubString(0, 10) }
|
||||
|
||||
Write-Log "Creating Entra group '$($body["displayName"])' in target tenant (referenced by imported policy)"
|
||||
return Invoke-MSGraphAPI -Url "/groups" -HttpMethod "POST" -Content (ConvertTo-Json $body -Depth 10) -TokenId $TokenId
|
||||
}
|
||||
|
||||
# Create an assignment filter in the target tenant from an exported sidecar. Filters
|
||||
# without a sidecar (table-only entries) cannot be created - platform + rule are
|
||||
# mandatory and unknowable from the name alone.
|
||||
function New-GraphMigrationFilter
|
||||
{
|
||||
param($Entry, [int]$TokenId)
|
||||
|
||||
if(-not $Entry.Sidecar) {
|
||||
Write-Log "Assignment filter '$($Entry.Name)' has no exported sidecar (platform/rule unknown) - cannot create it in the target tenant" 2
|
||||
return $null
|
||||
}
|
||||
|
||||
$keepProps = @("displayName","description","platform","rule","assignmentFilterManagementType")
|
||||
$body = [ordered]@{}
|
||||
foreach($prop in $Entry.Sidecar.PSObject.Properties)
|
||||
{
|
||||
if($prop.Name -notin $keepProps) { continue }
|
||||
if($null -eq $prop.Value) { continue }
|
||||
$body[$prop.Name] = $prop.Value
|
||||
}
|
||||
if(-not $body["displayName"] -or -not $body["rule"] -or -not $body["platform"]) {
|
||||
Write-Log "Assignment filter sidecar for '$($Entry.Name)' is missing displayName/platform/rule - cannot create" 2
|
||||
return $null
|
||||
}
|
||||
|
||||
Write-Log "Creating assignment filter '$($body["displayName"])' in target tenant (referenced by imported policy)"
|
||||
return Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters" -HttpMethod "POST" -Content (ConvertTo-Json $body -Depth 10) -TokenId $TokenId
|
||||
}
|
||||
|
||||
# Target-tenant assignment-filter list, cached per map key. Intune endpoints do not
|
||||
# reliably honor server-side displayName $filter, so name matching is client-side.
|
||||
function Get-GraphMigrationTargetFilters
|
||||
{
|
||||
param([string]$MapKey, [int]$TokenId)
|
||||
|
||||
if(-not $script:_filterListCache) { $script:_filterListCache = @{} }
|
||||
if($script:_filterListCache.ContainsKey($MapKey)) { return $script:_filterListCache[$MapKey] }
|
||||
|
||||
$list = @()
|
||||
$resp = Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters?`$select=id,displayName" -ODataMetadata "none" -NoError -TokenId $TokenId
|
||||
if($resp -and $resp.Value) { $list = @($resp.Value) }
|
||||
$script:_filterListCache[$MapKey] = $list
|
||||
return $list
|
||||
}
|
||||
|
||||
# Main entry point - called from Update-JsonForEnvironment with every GUID and SID
|
||||
# found in the imported policy json. Covers BOTH Entra groups and assignment filters
|
||||
# (the sidecar index tags each entry with its Kind). Returns
|
||||
# @{ IdMap = @{src->target}; SidMap = @{src->target} } containing ONLY references
|
||||
# that need rewriting.
|
||||
function Resolve-GraphMigrationGroups
|
||||
{
|
||||
param($Guids, $Sids, $PolicyObject, [int]$TokenId)
|
||||
|
||||
$result = @{ IdMap = @{}; SidMap = @{} }
|
||||
|
||||
# Same-tenant import: source ids are valid as-is, nothing to translate.
|
||||
#
|
||||
# Get-OperationTokenInfo, not Get-TokenInfo: the import path hands its own TokenId
|
||||
# down, and 0 means "every token" to the list accessor. With two tenants signed in,
|
||||
# [string]$tokenInfo.TenantId was "tenant-a tenant-b", so the same-tenant early
|
||||
# return below never fired - a same-tenant import ran the whole cross-tenant group
|
||||
# translation, and cached its results under a map key naming both tenants.
|
||||
$tokenInfo = Get-OperationTokenInfo $TokenId
|
||||
$targetTenant = if($tokenInfo -and $tokenInfo.TenantId) { [string]$tokenInfo.TenantId } else { [string](Get-CurrentTenantId) }
|
||||
if(-not $targetTenant) { return $result }
|
||||
if($PolicyObject.TenantID -and ([string]$PolicyObject.TenantID) -eq $targetTenant) { return $result }
|
||||
|
||||
$exportRoot = Get-GraphGroupMigrationRoot $PolicyObject
|
||||
if(-not $exportRoot) { return $result }
|
||||
|
||||
$index = Get-GraphGroupSidecarIndex $exportRoot
|
||||
if($index.ById.Count -eq 0) { return $result }
|
||||
|
||||
if(-not $script:_groupMigrationMap) { $script:_groupMigrationMap = @{} }
|
||||
$mapKey = "$exportRoot|$targetTenant"
|
||||
if(-not $script:_groupMigrationMap.ContainsKey($mapKey)) { $script:_groupMigrationMap[$mapKey] = @{} }
|
||||
$map = $script:_groupMigrationMap[$mapKey]
|
||||
|
||||
$createEnabled = (Get-SettingValue "CreateGroupOnImport") -ne $false
|
||||
$convertSynced = (Get-SettingValue "ConvertSyncedGroupOnImport") -ne $false
|
||||
|
||||
# Collect the entries this policy actually references (by id or by SID).
|
||||
$wanted = @{}
|
||||
foreach($guid in @($Guids)) {
|
||||
if($guid -and $index.ById.ContainsKey([string]$guid)) { $wanted[[string]$guid] = $index.ById[[string]$guid] }
|
||||
}
|
||||
foreach($sid in @($Sids)) {
|
||||
if($sid -and $index.BySid.ContainsKey([string]$sid)) {
|
||||
$entry = $index.BySid[[string]$sid]
|
||||
$wanted[$entry.Id] = $entry
|
||||
}
|
||||
}
|
||||
if($wanted.Count -eq 0) { return $result }
|
||||
|
||||
foreach($sourceId in $wanted.Keys)
|
||||
{
|
||||
$entry = $wanted[$sourceId]
|
||||
|
||||
# Cached resolution (positive or negative) from an earlier policy in this run.
|
||||
if($map.ContainsKey($sourceId))
|
||||
{
|
||||
$resolved = $map[$sourceId]
|
||||
}
|
||||
else
|
||||
{
|
||||
$resolved = $null
|
||||
$target = $null
|
||||
|
||||
if($entry.Kind -eq "Filter")
|
||||
{
|
||||
# 1. By id - covers same-guid edge cases and pre-migrated environments.
|
||||
$target = Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters/$sourceId" -ODataMetadata "none" -NoError -TokenId $TokenId
|
||||
|
||||
# 2. By display name - client-side match against the cached target list
|
||||
# (Intune endpoints do not reliably honor server-side displayName filters).
|
||||
if(-not $target -and $entry.Name)
|
||||
{
|
||||
$wantName = $entry.Name.Trim()
|
||||
$target = Get-GraphMigrationTargetFilters -MapKey $mapKey -TokenId $TokenId |
|
||||
Where-Object { $_.displayName -and $_.displayName.Trim() -eq $wantName } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# 3. Create (same gate as groups - one 'create referenced objects' toggle).
|
||||
if(-not $target)
|
||||
{
|
||||
if(-not $createEnabled)
|
||||
{
|
||||
Write-Log "Assignment filter '$($entry.Name)' ($sourceId) does not exist in the target tenant and CreateGroupOnImport is disabled - reference not translated" 2
|
||||
}
|
||||
else
|
||||
{
|
||||
$target = New-GraphMigrationFilter -Entry $entry -TokenId $TokenId
|
||||
if(-not $target -and $entry.Name)
|
||||
{
|
||||
# The create can fail on a duplicate name when an
|
||||
# earlier resolution pass already created the filter
|
||||
# but a transient list failure cached an empty
|
||||
# target list - re-list fresh and match once more.
|
||||
if($script:_filterListCache) { $script:_filterListCache.Remove($mapKey) }
|
||||
$wantName = $entry.Name.Trim()
|
||||
$target = Get-GraphMigrationTargetFilters -MapKey $mapKey -TokenId $TokenId |
|
||||
Where-Object { $_.displayName -and $_.displayName.Trim() -eq $wantName } | Select-Object -First 1
|
||||
}
|
||||
if($target -and $script:_filterListCache -and $script:_filterListCache.ContainsKey($mapKey)) {
|
||||
# Keep the cached target list current for later policies.
|
||||
$script:_filterListCache[$mapKey] = @($script:_filterListCache[$mapKey]) + @($target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
# 1. By id - covers same-guid edge cases and pre-migrated environments.
|
||||
$target = Invoke-MSGraphAPI -Url "/groups/$sourceId" -ODataMetadata "none" -NoError -TokenId $TokenId
|
||||
|
||||
# 2. By display name.
|
||||
if(-not $target -and $entry.Name)
|
||||
{
|
||||
# Percent-encoded literal: a raw & or # in the name used to split the
|
||||
# query, Graph 400'd, -NoError hid it, and step 3 created a duplicate.
|
||||
$literal = ConvertTo-ODataStringLiteral $entry.Name.Trim()
|
||||
$resp = Invoke-MSGraphAPI -Url "/groups?`$filter=displayName eq $literal&`$select=id,displayName,securityIdentifier" -ODataMetadata "none" -NoError -TokenId $TokenId
|
||||
if($resp -and $resp.Value) { $target = $resp.Value | Select-Object -First 1 }
|
||||
}
|
||||
|
||||
# 3. Create.
|
||||
if(-not $target)
|
||||
{
|
||||
if(-not $createEnabled)
|
||||
{
|
||||
Write-Log "Group '$($entry.Name)' ($sourceId) does not exist in the target tenant and CreateGroupOnImport is disabled - reference not translated" 2
|
||||
}
|
||||
elseif($entry.Synced -and -not $convertSynced)
|
||||
{
|
||||
Write-Log "Group '$($entry.Name)' ($sourceId) is an AD-synced group and ConvertSyncedGroupOnImport is disabled - reference not translated" 2
|
||||
}
|
||||
else
|
||||
{
|
||||
if($entry.Synced)
|
||||
{
|
||||
Write-Log "Group '$($entry.Name)' is AD-synced in the source tenant - creating it as a cloud Entra group (ConvertSyncedGroupOnImport)" 2
|
||||
}
|
||||
$target = New-GraphMigrationGroup -Entry $entry -TokenId $TokenId
|
||||
if(-not $target) { Write-Log "Failed to create group '$($entry.Name)' in target tenant" 3 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = if($target) { @{ Id = [string]$target.id; Sid = [string]$target.securityIdentifier } } else { $null }
|
||||
$map[$sourceId] = $resolved
|
||||
}
|
||||
|
||||
if(-not $resolved) { continue }
|
||||
|
||||
if($resolved.Id -and $resolved.Id -ne $sourceId) { $result.IdMap[$sourceId] = $resolved.Id }
|
||||
if($entry.Sid -and $resolved.Sid -and $resolved.Sid -ne $entry.Sid) { $result.SidMap[$entry.Sid] = $resolved.Sid }
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,608 @@
|
||||
# Intune cross-tenant migration-table feature (export/import of Groups, ScopeTags
|
||||
# etc. between environments). Moved out of Internal/MSGraph.ps1 (architecture R4 -
|
||||
# MSGraph.ps1 should hold only generic Graph helpers, not Intune feature logic).
|
||||
# All module-internal; shares the $script:_migFile* caches set up by Start-GraphBulkExport.
|
||||
|
||||
#region Migration Table functions
|
||||
function Get-MigrationTableInfo
|
||||
{
|
||||
param($Path, $TenantId)
|
||||
|
||||
$FileName = Get-GraphMigrationTableFromPath $Path
|
||||
|
||||
$str = $null
|
||||
$sameTenant = $false
|
||||
if($FileName -and [IO.File]::Exists($FileName))
|
||||
{
|
||||
$migFileObj = ConvertFrom-Json ([IO.File]::ReadAllText($FileName))
|
||||
if($migFileObj.TenantId -and $migFileObj.TenantId -eq $TenantId)
|
||||
{
|
||||
$sameTenant = $true
|
||||
$str = "Current tenant. Migration table will not be used"
|
||||
}
|
||||
elseif($migFileObj.Organization)
|
||||
{
|
||||
$str = "Objects exported from $($migFileObj.Organization) ($($migFileObj.TenantId))"
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $str)
|
||||
{
|
||||
# Hide controls?
|
||||
$str = "No migration table found"
|
||||
}
|
||||
$str, $sameTenant
|
||||
}
|
||||
|
||||
function Add-GraphMigrationInfo
|
||||
{
|
||||
# MigrationRoot (optional): the export root directory where MigrationTable.json
|
||||
# and Groups/ should live. When supplied, takes precedence over the legacy
|
||||
# ".Parent.FullName" guess in Add-GraphMigrationObject — needed because that
|
||||
# guess assumed $Folder is always a per-type subfolder one level under the
|
||||
# export root, which is wrong when AddObjectType=false (policies written
|
||||
# straight to the export root). Without this, MigrationTable.json + Groups/
|
||||
# ended up one level too high and the "Parent name of the folder is not
|
||||
# organization name" warning fired falsely.
|
||||
#
|
||||
# MaxGroupDepth (optional): depth of group-membership recursion. 1 (default)
|
||||
# exports only the directly-assigned group; >1 walks into the group's member
|
||||
# groups up to MaxGroupDepth levels deep. Cycle-safe via the per-folder dedup
|
||||
# index in Add-GraphMigrationObject.
|
||||
param($PolicyObject, $Folder, [string]$MigrationRoot, [int]$MaxGroupDepth = 1)
|
||||
|
||||
if(-not $PolicyObject) { return }
|
||||
|
||||
foreach($assignment in $PolicyObject.JsonObject.Assignments)
|
||||
{
|
||||
foreach($assignmentTarget in $assignment.target)
|
||||
{
|
||||
if(-not $assignmentTarget."@odata.type") { continue }
|
||||
|
||||
$assignmentTargetType = $assignmentTarget."@odata.type"
|
||||
|
||||
if($assignmentTargetType -eq "#microsoft.graph.groupAssignmentTarget" -or
|
||||
$assignmentTargetType -eq "#microsoft.graph.exclusionGroupAssignmentTarget" -or
|
||||
$assignmentTargetType -eq "#microsoft.graph.cloudPcManagementGroupAssignmentTarget")
|
||||
{
|
||||
Add-GraphMigrationObject $assignmentTarget.groupid "groups" "Group" $Folder $PolicyObject._TokenId -MigrationRoot $MigrationRoot -MaxGroupDepth $MaxGroupDepth
|
||||
}
|
||||
elseif($assignmentTargetType -eq "#microsoft.graph.allLicensedUsersAssignmentTarget" -or
|
||||
$assignmentTargetType -eq "#microsoft.graph.allDevicesAssignmentTarget")
|
||||
{
|
||||
# No need to migrate All Users or All Devices
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Unsupported migration object: $assignmentTargetType" 3
|
||||
}
|
||||
|
||||
# Assignment filters ride on EVERY target type as a sibling property (incl.
|
||||
# All Users / All Devices). Capture the filter to the migration table + an
|
||||
# AssignmentFilters sidecar so cross-tenant import can resolve/create it.
|
||||
$filterId = [string]$assignmentTarget.deviceAndAppManagementAssignmentFilterId
|
||||
if($filterId -and $filterId -ne "00000000-0000-0000-0000-000000000000")
|
||||
{
|
||||
Add-GraphMigrationObject $filterId "deviceManagement/assignmentFilters" "AssignmentFilter" $Folder $PolicyObject._TokenId -MigrationRoot $MigrationRoot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-GraphMigrationObject
|
||||
{
|
||||
# MigrationRoot (optional): explicit export root for MigrationTable.json and
|
||||
# Groups/. Use it when known (Export-GraphPolicy passes $exportFolderRoot).
|
||||
# Otherwise the fallback is the legacy "$Folder.Parent.FullName" heuristic,
|
||||
# which is correct when $Folder is a per-type subfolder under the export root
|
||||
# but wrong when $Folder IS the export root (AddObjectType=false case).
|
||||
#
|
||||
# MaxGroupDepth / CurrentGroupDepth: drive nested-group export. When saving a
|
||||
# Group sidecar, if CurrentGroupDepth < MaxGroupDepth we fetch the group's
|
||||
# members and recurse for any that are themselves groups (CurrentGroupDepth+1).
|
||||
# The per-folder dedup index naturally breaks cycles (a group already added
|
||||
# to MigrationTable.json won't be re-added or re-recursed).
|
||||
param($ObjectId, $GraphAPI, $ObjectTypeName, $Folder, $TokenId = 0, [string]$MigrationRoot,
|
||||
[int]$MaxGroupDepth = 1, [int]$CurrentGroupDepth = 1)
|
||||
|
||||
if(-not $ObjectId) { return }
|
||||
|
||||
# Get-OperationTokenInfo, not Get-TokenInfo: a caller with no token of its own
|
||||
# passes 0, the codebase's spelling for "the default token", and the list
|
||||
# accessor answers 0 with EVERY registered token. With two tenants signed in
|
||||
# $tokeInfo.TenantId was an ARRAY, so the cache key below became
|
||||
# "AADObjectCache_tenant-a tenant-b" - one partition holding two directories,
|
||||
# which then leaked one tenant's objects into the other's migration table. A
|
||||
# token with no TenantId is the same defect spelled differently (every tenant
|
||||
# sharing "AADObjectCache_"), so it stops here too.
|
||||
$tokeInfo = Get-OperationTokenInfo ([int]$TokenId)
|
||||
if(-not $tokeInfo -or -not $tokeInfo.TenantId) { return }
|
||||
|
||||
# Pin this operation to the resolved token. 0 is a moving target: it is stored in
|
||||
# the pending-fetch queue, and Resolve-GraphMigrationObjectsPending drains that
|
||||
# queue much later - after a Login or a tenant switch has possibly made a
|
||||
# DIFFERENT token the default. The queued id, the nested /members GET and the
|
||||
# recursion into child groups all have to name the tenant this export is of, not
|
||||
# whichever tenant is default when the flush happens.
|
||||
if([int]$tokeInfo.Id -gt 0) { $TokenId = [int]$tokeInfo.Id }
|
||||
$cacheTenantId = [string]$tokeInfo.TenantId
|
||||
|
||||
# ----- Per-process MigrationTable.json cache + dedup index -----
|
||||
# This function used to read MigrationTable.json from disk, parse it, append
|
||||
# via `$Objects += @{...}` (O(n²) array rebuild), then write the entire file
|
||||
# back to disk — EVERY single call. For a bulk export of ~400 policies with a
|
||||
# few group assignments each that's ~1200 round-trips through disk + JSON
|
||||
# serialize. With the cache below, each call is an in-memory hashtable lookup +
|
||||
# List<object>.Add; Save-GraphMigrationFilesPending writes each touched file to disk
|
||||
# once at end of bulk export.
|
||||
if(-not $script:_migFileCache) { $script:_migFileCache = @{} }
|
||||
if(-not $script:_migFileObjectsIndex) { $script:_migFileObjectsIndex = @{} }
|
||||
if(-not $script:_migFileDirty) { $script:_migFileDirty = @{} }
|
||||
# Per-$Folder cache of the resolved MigrationTable.json path. Without this,
|
||||
# Get-GraphMigrationTableFromPath fires on every call (~1200× during a bulk
|
||||
# export), each call does Expand-FileName + 2 [IO.File]::Exists + when the
|
||||
# file doesn't exist yet it Write-Log's "Could not find migration table" —
|
||||
# which through Write-Log's ObservableCollection.Add triggers UI marshalling.
|
||||
# Resolve once per folder; on cache hit later we skip the lookup entirely.
|
||||
if(-not $script:_migFilePathCache) { $script:_migFilePathCache = @{} }
|
||||
|
||||
# Key the cache on $Folder (stable across all calls in this bulk export) rather
|
||||
# than on the resolved migration-file path (which is $null on first call when the
|
||||
# file doesn't exist yet, then changes — leading to a cache miss on every call).
|
||||
$cacheKey = $Folder
|
||||
|
||||
if($script:_migFileCache.ContainsKey($cacheKey)) {
|
||||
# Fast path: cache already populated for this folder, skip disk probe + log spam.
|
||||
$migFileObj = $script:_migFileCache[$cacheKey]
|
||||
$migrationFile = $script:_migFilePathCache[$cacheKey] # may be $null until first add resolves it
|
||||
}
|
||||
else {
|
||||
# First call for this folder — resolve the path once and seed the caches.
|
||||
$migrationFile = Get-GraphMigrationTableFromPath $Folder
|
||||
$script:_migFilePathCache[$cacheKey] = $migrationFile
|
||||
|
||||
if($migrationFile -and [IO.File]::Exists($migrationFile)) {
|
||||
$migFileObj = ConvertFrom-Json ([IO.File]::ReadAllText($migrationFile))
|
||||
# Normalise Objects to a List so adds are O(1) instead of O(n) array rebuilds.
|
||||
$list = [System.Collections.Generic.List[object]]::new()
|
||||
if($migFileObj.Objects) { foreach($o in $migFileObj.Objects) { [void]$list.Add($o) } }
|
||||
$migFileObj.Objects = $list
|
||||
$script:_migFileCache[$cacheKey] = $migFileObj
|
||||
|
||||
# Build lookup index for O(1) duplicate-check instead of O(n) Where-Object.
|
||||
$idx = @{}
|
||||
foreach($o in $list) { $idx["$($o.Id)|$($o.Type)"] = $true }
|
||||
$script:_migFileObjectsIndex[$cacheKey] = $idx
|
||||
}
|
||||
else {
|
||||
# No existing file — start with an empty migration object. Path will be
|
||||
# resolved on first object add below.
|
||||
$migrationFile = $null
|
||||
$migFileObj = ([PSCustomObject]@{
|
||||
TenantId = $script:organizationId
|
||||
Organization = $script:organizationName
|
||||
Objects = [System.Collections.Generic.List[object]]::new()
|
||||
})
|
||||
$script:_migFileCache[$cacheKey] = $migFileObj
|
||||
$script:_migFileObjectsIndex[$cacheKey] = @{}
|
||||
}
|
||||
}
|
||||
|
||||
# Check if object is already processed. The cache key is normalised on the resolved
|
||||
# token's tenant id (see $cacheTenantId above) on BOTH read and write - earlier code
|
||||
# accidentally wrote to $script:Organization.Id which could diverge from the token's
|
||||
# tenant id and produce a permanent cache miss.
|
||||
$graphObj = Get-GraphMigrationObject $ObjectId $cacheTenantId
|
||||
|
||||
$AADObjectCache = Get-CacheObject "AADObjectCache_$cacheTenantId" (@{})
|
||||
if(-not $graphObj -and $AADObjectCache.ContainsKey($ObjectId) -eq $false)
|
||||
{
|
||||
# Not in the cache, positively or negatively. This function never calls
|
||||
# Graph itself any more: it used to issue one direct GET per unknown id
|
||||
# right here, which on an export with no prefetch (single-policy export,
|
||||
# a depth bumped at the call site) was a serial round-trip per assignment
|
||||
# group at ~1.6 s each. The miss is queued instead and
|
||||
# Resolve-GraphMigrationObjectsPending fetches every queued id at once -
|
||||
# getByIds for directory objects, $batch for the rest - then replays this
|
||||
# call as a cache hit. Save-GraphMigrationFilesPending drains the queue,
|
||||
# and every export path ends with that.
|
||||
if(-not $script:_migPendingFetch) { $script:_migPendingFetch = [System.Collections.Generic.List[object]]::new() }
|
||||
[void]$script:_migPendingFetch.Add([PSCustomObject]@{
|
||||
ObjectId = $ObjectId
|
||||
GraphAPI = $GraphAPI
|
||||
ObjectTypeName = $ObjectTypeName
|
||||
Folder = $Folder
|
||||
TokenId = $TokenId
|
||||
MigrationRoot = $MigrationRoot
|
||||
MaxGroupDepth = $MaxGroupDepth
|
||||
CurrentGroupDepth = $CurrentGroupDepth
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if($graphObj)
|
||||
{
|
||||
$objectAdded = $false
|
||||
# Add object to cache (positive)
|
||||
if($AADObjectCache -is [Hashtable] -and $AADObjectCache.ContainsKey($ObjectId) -eq $false) { $AADObjectCache.Add($ObjectId, $graphObj) }
|
||||
|
||||
$indexKey = "$ObjectId|$ObjectTypeName"
|
||||
$index = $script:_migFileObjectsIndex[$cacheKey]
|
||||
if(-not $index.ContainsKey($indexKey)) {
|
||||
|
||||
# FIX: was $GraphObject (undefined → wrote {Id=$null, DisplayName=$null} into
|
||||
# every MigrationTable.json entry). The correct variable is $graphObj.
|
||||
[void]$migFileObj.Objects.Add([PSCustomObject]@{
|
||||
Id = $graphObj.Id
|
||||
DisplayName = $graphObj.displayName
|
||||
Type = $ObjectTypeName
|
||||
})
|
||||
$index[$indexKey] = $true
|
||||
$objectAdded = $true
|
||||
|
||||
# First time we add anything against a still-unresolved migration file path:
|
||||
# resolve the canonical location now so the flush writes to the right path.
|
||||
# Cache is keyed on $Folder which is stable across calls in this bulk export,
|
||||
# so no key migration is needed. Save the resolved path back into the path
|
||||
# cache so cache-hit calls later in this run see the right file.
|
||||
if(-not $migrationFile) {
|
||||
if($MigrationRoot) {
|
||||
# Caller passed the export root explicitly — most reliable.
|
||||
$migrationFile = [IO.Path]::Combine($MigrationRoot, "MigrationTable.json")
|
||||
}
|
||||
else {
|
||||
# Legacy heuristic: assume $Folder is a per-type subfolder and the
|
||||
# export root is one level up. Works for the typical AddObjectType=true
|
||||
# path but lands the file one level too high when $Folder is already
|
||||
# the export root.
|
||||
$folderInfo = [IO.DirectoryInfo]$Folder
|
||||
$migrationFile = [IO.Path]::Combine($folderInfo.Parent.FullName, "MigrationTable.json")
|
||||
if($folderInfo.Parent.Name -ne $tokeInfo.TenantName) {
|
||||
Write-Log "Parent name of the folder is not organization name: $($folderInfo.Parent.Name)" 2
|
||||
}
|
||||
}
|
||||
$script:_migFilePathCache[$cacheKey] = $migrationFile
|
||||
Write-Log "Create new Migration file: $migrationFile"
|
||||
}
|
||||
|
||||
# Defer the actual disk write until Save-GraphMigrationFilesPending at end of
|
||||
# bulk export — eliminates ~1200 read+write cycles for a typical run.
|
||||
# Dirty map: cacheKey ($Folder) -> resolved file path.
|
||||
$script:_migFileDirty[$cacheKey] = $migrationFile
|
||||
}
|
||||
|
||||
# Sidecar json — write immediately, only once per new entry. Groups go to
|
||||
# Groups\, assignment filters to AssignmentFilters\ (same name as the type's
|
||||
# normal export folder, so the Assignments viewer finds them either way).
|
||||
if($objectAdded -and $ObjectTypeName -in @("Group","AssignmentFilter") -and $migrationFile)
|
||||
{
|
||||
$sidecarFolder = if($ObjectTypeName -eq "AssignmentFilter") { "AssignmentFilters" } else { "Groups" }
|
||||
$grouspPath = Join-Path ([IO.Path]::GetDirectoryName($migrationFile)) $sidecarFolder
|
||||
# New-Item, not mkdir: on Linux/macOS `mkdir` resolves to the native
|
||||
# binary, which rejects -Path/-Force and cannot be silenced by
|
||||
# -ErrorAction, so the folder was never created and every sidecar
|
||||
# write failed.
|
||||
if(-not (Test-Path $grouspPath)) { New-Item -ItemType Directory -Path $grouspPath -Force -ErrorAction SilentlyContinue | Out-Null }
|
||||
$FileName = [IO.Path]::Combine($grouspPath, "$((Remove-InvalidFileNameChars $graphObj.displayName)).json")
|
||||
|
||||
# Defensive strip before serialising the group sidecar: any literal
|
||||
# `members` array or members nav links should never end up in a
|
||||
# sidecar that could later be re-POSTed to Graph (Graph treats
|
||||
# `members@odata.bind` on POST /groups as "create with these
|
||||
# members"). A group body fetched with $expand or a future Graph
|
||||
# projection change could carry one in without this.
|
||||
foreach ($m in @('members','members@odata.context','members@odata.associationLink','members@odata.navigationLink','members@odata.bind')) {
|
||||
if ($graphObj.PSObject.Properties.Name -contains $m) {
|
||||
$graphObj.PSObject.Properties.Remove($m) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
Save-GraphObjectToFile $graphObj $FileName
|
||||
|
||||
# Nested-group recursion. Only walks deeper when the caller opted in
|
||||
# (MaxGroupDepth > 1) and there's still budget. The dedup index above
|
||||
# ($script:_migFileObjectsIndex) prevents cycles and duplicate work.
|
||||
if($MaxGroupDepth -gt 1 -and $CurrentGroupDepth -lt $MaxGroupDepth)
|
||||
{
|
||||
try {
|
||||
# Bulk export pre-walks the hierarchy in batches and stamps each
|
||||
# parent body with `#NestedChildren = [{id, displayName}, ...]`.
|
||||
# When that's present we skip the inline /members GET entirely —
|
||||
# otherwise the recursion fires one un-batched call per parent
|
||||
# which dominates wall time for tenants with many group
|
||||
# assignments and depth > 1.
|
||||
$members = $null
|
||||
if($graphObj.PSObject.Properties.Name -contains '#NestedChildren') {
|
||||
$members = @($graphObj.'#NestedChildren')
|
||||
}
|
||||
else {
|
||||
# Fallback for callers that didn't pre-walk the hierarchy
|
||||
# (single-policy export, depth bumped at call site, etc.).
|
||||
# Use Graph's type-cast syntax to filter to nested groups
|
||||
# server-side — skips users / service principals / devices
|
||||
# without round-tripping them, and doesn't depend on
|
||||
# @odata.type being present in the response. Smaller payload.
|
||||
$membersResp = Invoke-MSGraphAPI -Url "groups/$ObjectId/members/microsoft.graph.group?`$select=id,displayName" -TokenId $TokenId -AllPages -ODataMetadata "Minimal"
|
||||
$members = @()
|
||||
if($membersResp -and $membersResp.value) { $members = @($membersResp.value) }
|
||||
elseif($membersResp -is [Array]) { $members = @($membersResp) }
|
||||
}
|
||||
|
||||
foreach($m in $members)
|
||||
{
|
||||
if(-not $m -or -not $m.id) { continue }
|
||||
Add-GraphMigrationObject $m.id "groups" "Group" $Folder $TokenId `
|
||||
-MigrationRoot $MigrationRoot `
|
||||
-MaxGroupDepth $MaxGroupDepth `
|
||||
-CurrentGroupDepth ($CurrentGroupDepth + 1)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Nested-group recursion for $ObjectId failed at depth $CurrentGroupDepth/$MaxGroupDepth : $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
# Negative-result cache: remember that this object id doesn't resolve so the next
|
||||
# caller doesn't hit Graph again for the same missing reference.
|
||||
if($AADObjectCache -is [Hashtable] -and $AADObjectCache.ContainsKey($ObjectId) -eq $false) { $AADObjectCache.Add($ObjectId, $null) }
|
||||
Set-CacheObject "AADObjectCache_$cacheTenantId" $AADObjectCache "TenantCache_$cacheTenantId"
|
||||
Write-Log "No $ObjectTypeName found with ID $($ObjectId). It might be deleted." 2
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve every migration object Add-GraphMigrationObject queued as a cache miss,
|
||||
# in as few Graph calls as possible, then replay the queued calls as cache hits
|
||||
# so they write their MigrationTable entry and sidecar exactly as before.
|
||||
#
|
||||
# Directory objects (groups, users, devices, service principals) go through
|
||||
# /directoryObjects/getByIds, a thousand ids per POST. Anything else (assignment
|
||||
# filters, apps) goes through Invoke-GraphBatchRequest, which decides batch versus
|
||||
# direct from the UseBatchAPI setting. Ids are grouped by token first so a
|
||||
# cross-tenant run never resolves one tenant's ids against another's directory.
|
||||
#
|
||||
# A replayed call can queue more work (nested-group recursion finds children that
|
||||
# are not cached), so this loops; ten rounds is far beyond any real nesting depth.
|
||||
# Ids whose fetch failed outright are dropped with a warning rather than retried
|
||||
# every round - and are NOT negative-cached, because that cache persists and a
|
||||
# transient failure must not mark a thousand live groups as deleted.
|
||||
function Resolve-GraphMigrationObjectsPending
|
||||
{
|
||||
if(-not $script:_migPendingFetch -or $script:_migPendingFetch.Count -eq 0) { return }
|
||||
|
||||
$directoryTypes = @{ groups = 'group'; users = 'user'; devices = 'device'; servicePrincipals = 'servicePrincipal' }
|
||||
$round = 0
|
||||
while($script:_migPendingFetch.Count -gt 0 -and $round -lt 10)
|
||||
{
|
||||
$round++
|
||||
$pending = @($script:_migPendingFetch.ToArray())
|
||||
$script:_migPendingFetch.Clear()
|
||||
$unresolvable = [System.Collections.Generic.HashSet[string]]::new()
|
||||
|
||||
$byToken = @{}
|
||||
foreach($p in $pending) {
|
||||
$k = [int]$p.TokenId
|
||||
if(-not $byToken.ContainsKey($k)) { $byToken[$k] = [System.Collections.Generic.List[object]]::new() }
|
||||
[void]$byToken[$k].Add($p)
|
||||
}
|
||||
|
||||
# Raw queued token id -> the positive id it resolved to. The replay loop below
|
||||
# runs over every token at once, outside this loop, so it needs the mapping to
|
||||
# avoid replaying the raw 0 - which would be re-resolved against whatever is
|
||||
# default by then, i.e. possibly another tenant than the one just fetched from.
|
||||
$resolvedToken = @{}
|
||||
|
||||
foreach($rawTokenId in @($byToken.Keys))
|
||||
{
|
||||
# Defensive: Add-GraphMigrationObject pins the id before it queues anything,
|
||||
# so a 0 should no longer reach here. If one does - an entry queued by
|
||||
# something else, or a future caller - resolve it ONCE here and use the
|
||||
# resolved id for both the fetch and the replay. Fetching one tenant's
|
||||
# directory and replaying against "whatever is default now" is the defect.
|
||||
$tokenInfo = Get-OperationTokenInfo $rawTokenId
|
||||
if(-not $tokenInfo -or -not $tokenInfo.TenantId) { continue }
|
||||
$tokenId = if([int]$tokenInfo.Id -gt 0) { [int]$tokenInfo.Id } else { [int]$rawTokenId }
|
||||
$resolvedToken[[int]$rawTokenId] = $tokenId
|
||||
|
||||
$cacheKey = "AADObjectCache_$($tokenInfo.TenantId)"
|
||||
$cacheFile = "TenantCache_$($tokenInfo.TenantId)"
|
||||
$cache = Get-CacheObject $cacheKey @{}
|
||||
if($cache -isnot [Hashtable]) { $cache = @{} }
|
||||
|
||||
$idsByApi = @{}
|
||||
foreach($p in $byToken[$rawTokenId]) {
|
||||
$id = [string]$p.ObjectId
|
||||
if(-not $id -or $cache.ContainsKey($id)) { continue }
|
||||
$api = "$($p.GraphAPI)".Trim('/')
|
||||
if(-not $idsByApi.ContainsKey($api)) { $idsByApi[$api] = [System.Collections.Generic.HashSet[string]]::new() }
|
||||
[void]$idsByApi[$api].Add($id)
|
||||
}
|
||||
|
||||
foreach($api in @($idsByApi.Keys))
|
||||
{
|
||||
$ids = @($idsByApi[$api])
|
||||
if($ids.Count -eq 0) { continue }
|
||||
$leaf = ($api -split '/')[-1]
|
||||
Write-Log "Migration objects: resolving $($ids.Count) queued $leaf id(s) for tenant $($tokenInfo.TenantId)"
|
||||
Write-Status -Detail ("Resolving {0} {1}" -f $ids.Count, $leaf) -SkipLog -Force
|
||||
|
||||
if($directoryTypes.ContainsKey($leaf))
|
||||
{
|
||||
for($i = 0; $i -lt $ids.Count; $i += 1000) {
|
||||
$end = [Math]::Min($i + 999, $ids.Count - 1)
|
||||
$chunk = @($ids[$i..$end])
|
||||
$resp = $null
|
||||
try {
|
||||
$body = (@{ ids = $chunk; types = @($directoryTypes[$leaf]) } | ConvertTo-Json -Compress)
|
||||
$resp = Invoke-MSGraphAPI -Url 'directoryObjects/getByIds' -Content $body -HttpMethod POST -TokenId $tokenId -ODataMetadata 'none'
|
||||
}
|
||||
catch { Write-LogError "Migration objects: getByIds failed for $($chunk.Count) $leaf id(s)" $_.Exception }
|
||||
if($null -eq $resp) {
|
||||
foreach($id in $chunk) { [void]$unresolvable.Add($id) }
|
||||
continue
|
||||
}
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($o in @($resp.value)) {
|
||||
if(-not $o -or -not $o.id) { continue }
|
||||
if($o.PSObject.Properties['@odata.type']) { [void]$o.PSObject.Properties.Remove('@odata.type') }
|
||||
$cache[[string]$o.id] = $o
|
||||
[void]$seen.Add([string]$o.id)
|
||||
}
|
||||
foreach($id in $chunk) { if(-not $seen.Contains($id)) { $cache[$id] = $null } }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$batch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$idByReq = @{}
|
||||
$n = 0
|
||||
foreach($id in $ids) {
|
||||
$n++
|
||||
$reqId = "migobj_$n"
|
||||
[void]$batch.Add([PSCustomObject]@{ id = $reqId; method = 'GET'; url = "$api/$id"; headers = @{ Accept = 'application/json;odata.metadata=none' } })
|
||||
$idByReq[$reqId] = $id
|
||||
}
|
||||
$results = @(Invoke-GraphBatchRequest -BatchObjects $batch -BatchType "Migration objects ($leaf)" -TokenId $tokenId -SkipWarnings -IncludedFailed)
|
||||
$answered = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($r in $results) {
|
||||
$id = $idByReq["$($r.Id)"]
|
||||
if(-not $id) { continue }
|
||||
[void]$answered.Add($id)
|
||||
if($r.body -and $r.Status -ge 200 -and $r.Status -lt 300) { $cache[$id] = $r.body }
|
||||
else { $cache[$id] = $null }
|
||||
}
|
||||
foreach($id in $ids) { if(-not $answered.Contains($id)) { [void]$unresolvable.Add($id) } }
|
||||
}
|
||||
}
|
||||
|
||||
Set-CacheObject $cacheKey $cache $cacheFile
|
||||
}
|
||||
|
||||
if($unresolvable.Count -gt 0) {
|
||||
Write-Log "Migration objects: $($unresolvable.Count) id(s) could not be fetched and are left out of the migration table" 2
|
||||
}
|
||||
|
||||
# Every queued id is now a cache hit, positive or negative: replay.
|
||||
foreach($p in $pending) {
|
||||
if($unresolvable.Contains([string]$p.ObjectId)) { continue }
|
||||
|
||||
# The token the ids were actually fetched with, not the one that was queued.
|
||||
$replayToken = if($resolvedToken.ContainsKey([int]$p.TokenId)) { $resolvedToken[[int]$p.TokenId] } else { [int]$p.TokenId }
|
||||
|
||||
Add-GraphMigrationObject $p.ObjectId $p.GraphAPI $p.ObjectTypeName $p.Folder $replayToken `
|
||||
-MigrationRoot $p.MigrationRoot -MaxGroupDepth $p.MaxGroupDepth -CurrentGroupDepth $p.CurrentGroupDepth
|
||||
}
|
||||
Write-Status -Detail "" -SkipLog -Force
|
||||
}
|
||||
|
||||
if($script:_migPendingFetch.Count -gt 0) {
|
||||
Write-Log "Migration objects: $($script:_migPendingFetch.Count) id(s) still queued after $round round(s) - giving up" 2
|
||||
$script:_migPendingFetch.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
# Persist every MigrationTable.json that was mutated during this bulk export. Called
|
||||
# once at the end of Start-GraphBulkExport so the in-memory cache batches all the
|
||||
# Add-GraphMigrationObject mutations into a single write per file. Drains the
|
||||
# queued cache misses first - on a fresh export nothing is dirty until they are.
|
||||
function Save-GraphMigrationFilesPending
|
||||
{
|
||||
Resolve-GraphMigrationObjectsPending
|
||||
|
||||
if(-not $script:_migFileDirty -or $script:_migFileDirty.Count -eq 0) { return }
|
||||
|
||||
$objectsByFile = @{}
|
||||
foreach($entry in @($script:_migFileDirty.GetEnumerator())) {
|
||||
$cacheKey = $entry.Key
|
||||
$filePath = $entry.Value
|
||||
if(-not $filePath) { continue }
|
||||
$obj = $script:_migFileCache[$cacheKey]
|
||||
if(-not $obj) { continue }
|
||||
|
||||
$fileKey = [IO.Path]::GetFullPath($filePath).ToLowerInvariant()
|
||||
if(-not $objectsByFile.ContainsKey($fileKey)) {
|
||||
$objectsByFile[$fileKey] = [PSCustomObject]@{
|
||||
FilePath = $filePath
|
||||
TenantId = $obj.TenantId
|
||||
Organization = $obj.Organization
|
||||
Objects = [System.Collections.Generic.List[object]]::new()
|
||||
Index = @{}
|
||||
}
|
||||
}
|
||||
|
||||
$target = $objectsByFile[$fileKey]
|
||||
foreach($migrationObject in @($obj.Objects)) {
|
||||
if(-not $migrationObject -or -not $migrationObject.Id -or -not $migrationObject.Type) { continue }
|
||||
$indexKey = "$($migrationObject.Id)|$($migrationObject.Type)"
|
||||
if($target.Index.ContainsKey($indexKey)) { continue }
|
||||
[void]$target.Objects.Add($migrationObject)
|
||||
$target.Index[$indexKey] = $true
|
||||
}
|
||||
}
|
||||
|
||||
foreach($entry in @($objectsByFile.GetEnumerator())) {
|
||||
$filePath = $entry.Value.FilePath
|
||||
$obj = [PSCustomObject]@{
|
||||
TenantId = $entry.Value.TenantId
|
||||
Organization = $entry.Value.Organization
|
||||
Objects = $entry.Value.Objects.ToArray()
|
||||
}
|
||||
try {
|
||||
# Same encoding as the exported policy files - a MigrationTable in a
|
||||
# different encoding to the objects beside it is the same import
|
||||
# hazard.
|
||||
Save-GraphTextToFile (ConvertTo-GraphExportJson $obj -Depth 50) $filePath
|
||||
Write-LogDebug "Migration file flushed: $filePath"
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to flush Migration File $filePath" $_.Exception
|
||||
}
|
||||
}
|
||||
# Clear dirty set so a subsequent bulk export starts clean (cache itself is kept
|
||||
# so re-runs in the same process skip the disk-load cost).
|
||||
$script:_migFileDirty = @{}
|
||||
}
|
||||
|
||||
function Get-GraphMigrationTableFromPath
|
||||
{
|
||||
param($Path)
|
||||
|
||||
# Migration table must be located in the root of the import path
|
||||
$path = Expand-FileName $Path
|
||||
|
||||
for($i = 0;$i -lt 2;$i++)
|
||||
{
|
||||
if($i -gt 0)
|
||||
{
|
||||
# Get parent directory
|
||||
$path = [io.path]::GetDirectoryName($path)
|
||||
}
|
||||
|
||||
$migFileName = Join-Path $path "MigrationTable.json"
|
||||
try
|
||||
{
|
||||
if([IO.File]::Exists($migFileName))
|
||||
{
|
||||
return $migFileName
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
# Downgraded from Write-Log (visible / file-logged / UI-marshalled per call) to
|
||||
# Write-LogDebug — for a fresh bulk export this happens once per per-type folder
|
||||
# and is informational, not a problem. The original Write-Log fired 1000+ times
|
||||
# before path caching landed, contributing ~2s of UI marshalling per run.
|
||||
Write-LogDebug "Could not find migration table for path '$Path'"
|
||||
}
|
||||
|
||||
function Get-GraphMigrationObject
|
||||
{
|
||||
param($ObjectId, $TenantId)
|
||||
|
||||
$AADObjectCache = Get-CacheObject "AADObjectCache_$($TenantId)" @{}
|
||||
|
||||
if($AADObjectCache.ContainsKey($ObjectId)) { return $AADObjectCache[$ObjectId] }
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
# Cross-tenant Policy Set import support (architecture rule R9 - new feature
|
||||
# in a new file; the PolicySet helpers used by Replace-mode import live in
|
||||
# Internal/IntuneManager.ps1).
|
||||
#
|
||||
# A policySet bundles references to other objects via items[].payloadId.
|
||||
# Importing a set exported from ANOTHER tenant carries source-tenant ids that
|
||||
# fail the POST. Resolve-IntunePolicySetItems rewrites each item so the set
|
||||
# imports cleanly:
|
||||
# 1. payloadId still valid in the target tenant -> keep (same-tenant import)
|
||||
# 2. else match the item's displayName against the mapped PolicyType's
|
||||
# target list (client-side - Intune ignores server-side name filters)
|
||||
# 3. else DROP the item with a warning - one dangling reference fails the
|
||||
# whole set.
|
||||
# Items are then stripped to the POST-safe shape (the GET carries per-item
|
||||
# ids, links, status and timestamps that Graph rejects on create) - same rule
|
||||
# as the original project's Start-PreImportPolicySets.
|
||||
|
||||
# policySetItem @odata.type -> owning PolicyType Id(s) for name resolution.
|
||||
# Unmapped item types keep their payloadId untouched.
|
||||
$script:PolicySetItemTypeMap = @{
|
||||
'#microsoft.graph.mobileAppPolicySetItem' = @('Applications')
|
||||
'#microsoft.graph.targetedManagedAppConfigurationPolicySetItem' = @('AppConfigurationManagedApp')
|
||||
'#microsoft.graph.managedAppProtectionPolicySetItem' = @('AppProtection')
|
||||
'#microsoft.graph.mdmWindowsInformationProtectionPolicyPolicySetItem' = @('AppProtection')
|
||||
'#microsoft.graph.windowsManagedAppProtectionPolicySetItem' = @('AppProtection')
|
||||
'#microsoft.graph.managedDeviceMobileAppConfigurationPolicySetItem' = @('AppConfigurationManagedDevice')
|
||||
'#microsoft.graph.iosLobAppProvisioningConfigurationPolicySetItem' = @('IosLobAppProvisioningConfigurations')
|
||||
'#microsoft.graph.deviceCompliancePolicyPolicySetItem' = @('CompliancePolicies')
|
||||
'#microsoft.graph.deviceConfigurationPolicySetItem' = @('DeviceConfiguration')
|
||||
'#microsoft.graph.deviceManagementConfigurationPolicyPolicySetItem' = @('SettingsCatalog', 'EndpointSecuritySettingsCatalog')
|
||||
'#microsoft.graph.groupPolicyConfigurationPolicySetItem' = @('AdministrativeTemplates')
|
||||
'#microsoft.graph.deviceManagementScriptPolicySetItem' = @('PowerShellScripts')
|
||||
'#microsoft.graph.enrollmentRestrictionsConfigurationPolicySetItem' = @('EnrollmentLimit')
|
||||
'#microsoft.graph.windows10EnrollmentCompletionPageConfigurationPolicySetItem' = @('EnrollmentStatusPage')
|
||||
'#microsoft.graph.windowsAutopilotDeploymentProfilePolicySetItem' = @('AutoPilot')
|
||||
}
|
||||
|
||||
# Update-mode import: policySet items are a navigation property Graph refuses
|
||||
# on PATCH - membership changes go through the dedicated /update action with
|
||||
# added/updated/deleted item deltas (same flow as the original project's
|
||||
# Start-PreUpdatePolicySets). The caller's PATCH then carries metadata only
|
||||
# (_PropertiesToRemoveForUpdate strips 'items').
|
||||
function Update-IntunePolicySetItems
|
||||
{
|
||||
param($PolicyObject, $ExistingObject, [int]$TokenId)
|
||||
|
||||
if($TokenId -eq 0) { $TokenId = [int](Get-DefaultTokenId) }
|
||||
|
||||
# Same cross-tenant re-pointing rules as create-mode import.
|
||||
Resolve-IntunePolicySetItems -PolicyObject $PolicyObject -TokenId $TokenId
|
||||
|
||||
$json = $PolicyObject.JsonObject
|
||||
$targetId = [string]$ExistingObject.Id
|
||||
if(-not $targetId) { return }
|
||||
|
||||
$current = Invoke-MSGraphAPI -Url "deviceAppManagement/policySets/$($targetId)?`$expand=items" -TokenId $TokenId
|
||||
$currentItems = @($current.items)
|
||||
|
||||
$addedItems = @()
|
||||
$updatedItems = @()
|
||||
$deletedItems = @()
|
||||
|
||||
foreach($item in @($json.items)) {
|
||||
if(@($currentItems | Where-Object { [string]$_.payloadId -eq [string]$item.payloadId }).Count -gt 0) {
|
||||
$updatedItems += $item
|
||||
}
|
||||
else {
|
||||
$addedItems += $item
|
||||
}
|
||||
}
|
||||
|
||||
foreach($currentItem in $currentItems) {
|
||||
if(@($json.items | Where-Object { [string]$_.payloadId -eq [string]$currentItem.payloadId }).Count -eq 0) {
|
||||
$deletedItems += [string]$currentItem.id
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Policy set '$($json.displayName)': update items. Add: $($addedItems.Count), Update: $($updatedItems.Count), Delete: $($deletedItems.Count)"
|
||||
|
||||
$updateBody = [PSCustomObject]@{
|
||||
addedPolicySetItems = $addedItems
|
||||
updatedPolicySetItems = $updatedItems
|
||||
deletedPolicySetItems = $deletedItems
|
||||
}
|
||||
|
||||
$result = Invoke-MSGraphAPI -Url "deviceAppManagement/policySets/$targetId/update" -HttpMethod "POST" -Content (ConvertTo-Json $updateBody -Depth 15) -TokenId $TokenId -FullResponseObject
|
||||
if(-not $result.Success) {
|
||||
Write-Log "Policy set '$($json.displayName)': item update action failed - $($result.StatusDescription)" 2
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-IntunePolicySetItems
|
||||
{
|
||||
param($PolicyObject, [int]$TokenId)
|
||||
|
||||
# A file-loaded import source has no token; fall back to the default.
|
||||
# (Passing 0 into Get-GraphPolicies resolves ALL registered tokens when
|
||||
# more than one provider is connected.)
|
||||
if($TokenId -eq 0) { $TokenId = [int](Get-DefaultTokenId) }
|
||||
|
||||
$json = $PolicyObject.JsonObject
|
||||
if(-not $json -or -not $json.items) { return }
|
||||
|
||||
Remove-Property $json 'items@odata.context'
|
||||
|
||||
$keepProps = @('@odata.type', 'payloadId', 'intent', 'settings')
|
||||
$typeListCache = @{}
|
||||
$resolvedItems = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach($item in @($json.items)) {
|
||||
$odata = [string]$item.'@odata.type'
|
||||
$itemName = [string]$item.displayName
|
||||
$payloadId = [string]$item.payloadId
|
||||
|
||||
$typeIds = $script:PolicySetItemTypeMap[$odata]
|
||||
if($typeIds) {
|
||||
$candidates = @()
|
||||
foreach($typeId in $typeIds) {
|
||||
if(-not $typeListCache.ContainsKey($typeId)) {
|
||||
$typeListCache[$typeId] = @(Get-GraphPolicies -PolicyType $typeId -TokenId $TokenId)
|
||||
}
|
||||
$candidates += $typeListCache[$typeId]
|
||||
}
|
||||
|
||||
if($payloadId -and @($candidates | Where-Object { [string]$_.Id -eq $payloadId }).Count -gt 0) {
|
||||
# Reference still valid in the target tenant - keep as-is.
|
||||
}
|
||||
else {
|
||||
$byName = @($candidates | Where-Object { $itemName -and [string]$_.Name -eq $itemName })
|
||||
if($byName.Count -eq 1) {
|
||||
Write-Log "Policy set '$($json.displayName)': item '$itemName' re-pointed from '$payloadId' to '$($byName[0].Id)'"
|
||||
$item.payloadId = [string]$byName[0].Id
|
||||
}
|
||||
elseif($byName.Count -gt 1) {
|
||||
Write-Log "Policy set '$($json.displayName)': multiple objects named '$itemName' in the target tenant - dropping item (ambiguous reference)" 2
|
||||
continue
|
||||
}
|
||||
else {
|
||||
Write-Log "Policy set '$($json.displayName)': no object named '$itemName' in the target tenant - dropping item (a dangling reference fails the whole set)" 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach($prop in @($item.PSObject.Properties | Where-Object { $_.Name -notin $keepProps })) {
|
||||
Remove-Property $item $prop.Name
|
||||
}
|
||||
[void]$resolvedItems.Add($item)
|
||||
}
|
||||
|
||||
$json.items = $resolvedItems.ToArray()
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
function Get-GraphBulkScopeTagPatchUrl
|
||||
{
|
||||
param($Policy)
|
||||
|
||||
$objectClass = $null
|
||||
try { $objectClass = [string]$Policy._objectClass } catch { }
|
||||
|
||||
if(-not [string]::IsNullOrWhiteSpace($objectClass)) {
|
||||
return "deviceAppManagement/$objectClass/$($Policy.Id)"
|
||||
}
|
||||
|
||||
return "$($Policy.PolicyType.API)/$($Policy.Id)"
|
||||
}
|
||||
|
||||
# Adjust an imported object's scope-tag IDs based on the ImportScopeTags setting
|
||||
# and same-/cross-tenant detection (ported from the old project's Set-ScopeTags):
|
||||
# - ImportScopeTags off -> reset to the Default scope tag ("0")
|
||||
# - on, same tenant -> keep the source IDs as-is
|
||||
# - on, cross tenant -> remap each ID via the loaded ScopeTags
|
||||
# dependency objects (OriginalId -> Id); keep
|
||||
# "0"; reset to Default if nothing resolves
|
||||
# Uses the per-type scope-tag property (PolicyType.ScopeTagProperty:
|
||||
# roleScopeTagIds / roleScopeTags / $null when the type has no scope tags).
|
||||
# Called from IntuneBaseClasses.ImportObject before the body is serialized.
|
||||
function Set-GraphImportScopeTags
|
||||
{
|
||||
param($PolicyObject, [int]$TokenId)
|
||||
|
||||
$scopeTagProperty = [string]$PolicyObject.PolicyType.ScopeTagProperty
|
||||
if(-not $scopeTagProperty) { return }
|
||||
|
||||
$obj = $PolicyObject.JsonObject
|
||||
if(-not ($obj.PSObject.Properties | Where-Object Name -eq $scopeTagProperty)) { return }
|
||||
|
||||
$scopeIds = @()
|
||||
if((Get-SettingValue "ImportScopeTags") -eq $true)
|
||||
{
|
||||
# Get-OperationTokenInfo, not Get-TokenInfo: ImportObject passes its own
|
||||
# TokenId straight through, and 0 - the caller's spelling for "the default
|
||||
# token" - means "no filter, every token" to Get-TokenInfo. With a second
|
||||
# tenant signed in, $tokenInfo.TenantId was an ARRAY, which stringifies to
|
||||
# "tenant-a tenant-b" and never equals the source tenant: a SAME-tenant
|
||||
# import was classified as cross-tenant, took the remap branch, found no
|
||||
# destination scope tags to map to and reset the policy to the Default scope
|
||||
# tag. Silent loss of the scope tags the policy was imported with.
|
||||
$tokenInfo = Get-OperationTokenInfo $TokenId
|
||||
$sourceTenantId = $PolicyObject._ClonedFromObject.TenantId
|
||||
$crossTenant = ($tokenInfo -and $sourceTenantId -and $sourceTenantId -ne $tokenInfo.TenantId)
|
||||
|
||||
if(-not $crossTenant)
|
||||
{
|
||||
# Same tenant: keep the source scope-tag IDs.
|
||||
$scopeIds += $obj.$scopeTagProperty
|
||||
}
|
||||
else
|
||||
{
|
||||
# Cross tenant: remap via the loaded ScopeTags dependency objects.
|
||||
$usingDefault = (@($obj.$scopeTagProperty).Count -eq 1 -and "$(@($obj.$scopeTagProperty)[0])" -eq "0")
|
||||
if(-not $usingDefault)
|
||||
{
|
||||
$loadedScopeTags = (Get-GraphDependencySourceObjects $PolicyObject)["ScopeTags"]
|
||||
foreach($scopeId in $obj.$scopeTagProperty)
|
||||
{
|
||||
if("$scopeId" -eq "0") { $scopeIds += "0"; continue }
|
||||
$scopeMigObj = $loadedScopeTags | Where-Object OriginalId -eq $scopeId
|
||||
if($scopeMigObj -and $scopeMigObj.Id) { $scopeIds += "$($scopeMigObj.Id)" }
|
||||
elseif($scopeMigObj) { Write-Log "Could not find a destination ScopeTag for '$($scopeMigObj.Name)'. Make sure all ScopeTags are imported into the environment" 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Default scope tag when nothing else applies (off / cross-tenant unresolved / already-default).
|
||||
if(@($scopeIds).Count -eq 0) { $scopeIds += "0" }
|
||||
|
||||
$obj.$scopeTagProperty = @($scopeIds)
|
||||
}
|
||||
|
||||
# Loads + normalizes the tenant scope-tag catalog. Cache lookup first
|
||||
# (DependencyObjects_<TenantId>) with a live Get-GraphPolicies fallback.
|
||||
# Always seeds Default (Id 0) when the live path is taken since the API
|
||||
# does not return it. Returns a sorted list of [PSCustomObject]@{ Id; Name }
|
||||
# de-duped on Id. Shared by both the WPF and Avalonia bulk-scope-tag dialogs.
|
||||
function Get-BulkScopeTagCatalog
|
||||
{
|
||||
# $LoadError (optional [ref]): when the live fetch fails this function still
|
||||
# returns the Default-only fallback and writes the failure message into
|
||||
# $LoadError so the UI layer can present it. Keeps this data function UI-free
|
||||
# (architecture R2) - it no longer pops a dialog of its own.
|
||||
param([int]$TokenId = (Get-DefaultTokenId), [ref]$LoadError)
|
||||
|
||||
$allTags = @()
|
||||
$tokenInfo = Get-TokenInfo $TokenId
|
||||
if($tokenInfo -and $tokenInfo.TenantId) {
|
||||
$dependencyObjects = Get-CacheObject "DependencyObjects_$($tokenInfo.TenantId)" @{}
|
||||
if($dependencyObjects -and $dependencyObjects.ContainsKey("ScopeTags")) {
|
||||
$allTags = @($dependencyObjects["ScopeTags"])
|
||||
}
|
||||
}
|
||||
|
||||
if($allTags.Count -eq 0) {
|
||||
$allTags = @([PSCustomObject]@{ Id = 0; Name = "Default" })
|
||||
try {
|
||||
$allTags += @(Get-GraphPolicies -PolicyType "ScopeTags" -TokenId $TokenId -ErrorAction Stop)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Bulk Scope Tags: failed to load scope tag catalogue" $_.Exception
|
||||
if($LoadError) { $LoadError.Value = $_.Exception.Message }
|
||||
}
|
||||
}
|
||||
|
||||
$normalizedTags = @()
|
||||
$seenIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
foreach($tag in $allTags) {
|
||||
$id = if($null -ne $tag.Id) { [string]$tag.Id } elseif($null -ne $tag.ID) { [string]$tag.ID } else { $null }
|
||||
if([string]::IsNullOrWhiteSpace($id)) { continue }
|
||||
if(-not $seenIds.Add($id)) { continue }
|
||||
$normalizedTags += [PSCustomObject]@{ Id = $id; Name = [string]$tag.Name }
|
||||
}
|
||||
|
||||
return @($normalizedTags | Sort-Object Name)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
function Get-IntuneSettingsCatalogClasses
|
||||
{
|
||||
[CmdLetbinding()]
|
||||
param()
|
||||
|
||||
$settingCatalogClasses = Get-CacheObject "IntuneSettingsCatalogClasses"
|
||||
if(-not $settingCatalogClasses) {
|
||||
$settingCatalogClasses = @()
|
||||
$settingCatalogClasses += Get-SubClasses "SettingsCatalogTypeBase" | ForEach-Object {
|
||||
try { Get-SingletonObject $_.Name } catch {}
|
||||
}
|
||||
Set-CacheObject "IntuneSettingsCatalogClasses" -Value $settingCatalogClasses
|
||||
}
|
||||
|
||||
return $settingCatalogClasses
|
||||
}
|
||||
|
||||
function Set-SettingsCatalogQueryFilter
|
||||
{
|
||||
param($Class, [string[]]$Families)
|
||||
|
||||
# Dedupe the MERGED list, not just the input: a second run (a re-login, or a
|
||||
# test re-seeding the template cache) otherwise appends every family again
|
||||
# and the filter grows an 'x or x' clause per pass.
|
||||
$Class._FamilyTypes = @(@($Class._FamilyTypes) + @($Families) | Where-Object { $_ } | Select-Object -Unique)
|
||||
if($Class._FamilyTypes.Count -gt 0) {
|
||||
$clauses = $Class._FamilyTypes | ForEach-Object { "templateReference/templateFamily eq '$_'" }
|
||||
$filter = "?`$filter=" + ($clauses -join ' or ')
|
||||
Write-Log "$($Class.GetType().Name) settings catalog query filter: $filter"
|
||||
$Class._QueryList = $filter
|
||||
}
|
||||
else {
|
||||
# The tenant reports no template for any family this class claims. Its
|
||||
# startup filter names that family, and the service rejects a
|
||||
# templateFamily value it does not know yet with 400 (live-verified for
|
||||
# 'maintenanceWindows' before Microsoft rolled it out) - so every listing
|
||||
# would log a batch failure. Replace it with a filter that is always valid
|
||||
# and matches nothing.
|
||||
$Class._QueryList = "?`$filter=id eq '00000000-0000-0000-0000-000000000000'"
|
||||
Write-Log "$($Class.GetType().Name): tenant has no template for its family - listing disabled until one appears"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-IntuneSettingsCatalogAuthenticated
|
||||
{
|
||||
[CmdLetbinding()]
|
||||
param($TokenInfo)
|
||||
|
||||
if($script:_intuneSettingsCatalogInitialized) { return }
|
||||
|
||||
$templates = Get-CacheObject "IntuneSettingsCatalogTemplates"
|
||||
if(-not $templates) {
|
||||
$templates = (Invoke-MSGraphAPI "deviceManagement/configurationPolicyTemplates").value
|
||||
Set-CacheObject "IntuneSettingsCatalogTemplates" -Value $templates
|
||||
}
|
||||
|
||||
$settingCatalogClasses = Get-IntuneSettingsCatalogClasses
|
||||
|
||||
# One descriptor per catalog class, evaluated IN ORDER. The last entry
|
||||
# (SettingsCatalogType) is the catch-all: its selector claims every family not
|
||||
# already taken, so it reads $familyTypes and must stay last. Selectors run via
|
||||
# Where-Object and resolve $familyTypes from this function's scope at match time.
|
||||
$familyTypes = @()
|
||||
$specs = @(
|
||||
[PSCustomObject]@{ Type = [EnrollmentSettingsCatalogType]; Extra = @(); Select = { $_.templateFamily -eq 'enrollmentConfiguration' } }
|
||||
[PSCustomObject]@{ Type = [EndpointSecuritySettingsCatalogType]; Extra = @(); Select = { $_.templateFamily -like 'endpointSecurity*' -or $_.templateFamily -eq 'baseline' } }
|
||||
[PSCustomObject]@{ Type = [ScriptSettingsCatalogType]; Extra = @(); Select = { $_.templateFamily -eq 'deviceConfigurationScripts' } }
|
||||
[PSCustomObject]@{ Type = [WindowsUpdateSettingsCatalogType]; Extra = @(); Select = { $_.templateFamily -eq 'maintenanceWindows' } }
|
||||
# This must be the last in the list, as it is the catch-all for any remaining families not already claimed by the other types.
|
||||
[PSCustomObject]@{ Type = [SettingsCatalogType]; Extra = @('none'); Select = { $_.templateFamily -notin $familyTypes } }
|
||||
)
|
||||
|
||||
foreach($spec in $specs) {
|
||||
$class = $settingCatalogClasses | Where-Object { $_ -is $spec.Type }
|
||||
if(-not $class) { continue }
|
||||
|
||||
$selected = @($templates | Where-Object $spec.Select | ForEach-Object { $_.templateFamily } | Select-Object -Unique)
|
||||
Set-SettingsCatalogQueryFilter -Class $class -Families (@($spec.Extra) + $selected)
|
||||
|
||||
$familyTypes += $class._FamilyTypes
|
||||
}
|
||||
|
||||
$script:_intuneSettingsCatalogInitialized = $true
|
||||
}
|
||||
|
||||
Add-AppEventHandler "AuthenticatedNewToken" "Invoke-IntuneSettingsCatalogAuthenticated"
|
||||
#Add-AppEventHandler "AuthenticationTokenRefresh" "Invoke-IntuneSettingsCatalogAuthenticated"
|
||||
@@ -0,0 +1,249 @@
|
||||
# ADMX/ADML data helpers for the Intune Tools ADMX Import + Reg Values tools.
|
||||
# Pure parsing/string/registry-support helpers + the ADMXReg* C# class defs - no
|
||||
# XAML, no UIProvider. Moved out of the per-backend UI files where they were
|
||||
# duplicated (and had drifted in code style) into one shared, module-internal
|
||||
# home. Get-ADMXCategoryOMAURIPath was WPF-only but referenced by the Avalonia
|
||||
# ADMX import; sharing it here also fixes that latent missing-function call.
|
||||
# (architecture R11)
|
||||
|
||||
function Get-ADMXADMLString
|
||||
{
|
||||
# Resolve an XML node's attribute (default: displayName) via the ADML string
|
||||
# table. ADMX uses "$(string.id)" placeholders pointing into the ADML
|
||||
# resources/stringTable; returns the raw attribute if no ADML is loaded or
|
||||
# the placeholder doesn't resolve.
|
||||
param($XmlNode, $Property = "displayName")
|
||||
|
||||
$propValue = $XmlNode.$Property
|
||||
if(-not $script:_admxlngADML -or -not $XmlNode.$Property) { return $propValue }
|
||||
|
||||
if($XmlNode.$Property.StartsWith('$('))
|
||||
{
|
||||
$tmp = $XmlNode.$Property.Substring(2, $XmlNode.$Property.Length - 3)
|
||||
$null, $strId = $tmp.Split('.')
|
||||
}
|
||||
else
|
||||
{
|
||||
$strId = $propValue
|
||||
}
|
||||
|
||||
if($script:_admxStringTable.ContainsKey($strId)) {
|
||||
return $script:_admxStringTable[$strId]
|
||||
}
|
||||
return $propValue
|
||||
}
|
||||
|
||||
function Get-ADMXADMLPresentationString
|
||||
{
|
||||
# Look up an element's label within a presentation node — used when an ADMX
|
||||
# element doesn't have its own displayName attribute.
|
||||
param($PresentationInfo, $XmlNode)
|
||||
|
||||
if(-not $script:_admxlngADML) { return $null }
|
||||
|
||||
$presentationNode = $PresentationInfo.SelectSingleNode("./*[@refId='$($XmlNode.id)']")
|
||||
if($presentationNode) {
|
||||
return (?? $presentationNode.Label.'#text' $presentationNode.'#text')
|
||||
}
|
||||
return $XmlNode.id
|
||||
}
|
||||
|
||||
function Get-ADMXCategoryIdPath
|
||||
{
|
||||
# Walk a category's parentCategory chain to its root and return the
|
||||
# slash-joined path of internal names (used for tree-building dedup).
|
||||
param($CategoryId, $Delimiter = "/")
|
||||
|
||||
$catObj = $script:_admxXML.policyDefinitions.categories.SelectSingleNode("$($script:_admxNSPrefix)category[@name='$CategoryId']", $script:_admxNS)
|
||||
$categories = @()
|
||||
while($catObj)
|
||||
{
|
||||
$categories += $catObj.name
|
||||
if($catObj.parentCategory.ref) {
|
||||
$catObj = $script:_admxXML.policyDefinitions.categories.SelectSingleNode("$($script:_admxNSPrefix)category[@name='$($catObj.parentCategory.ref)']", $script:_admxNS)
|
||||
}
|
||||
else { break }
|
||||
}
|
||||
[array]::Reverse($categories)
|
||||
return ($categories -join $Delimiter)
|
||||
}
|
||||
|
||||
function Get-ADMXCategoryNamePath
|
||||
{
|
||||
# Same chain as Get-ADMXCategoryIdPath but returns the resolved DISPLAY
|
||||
# names (via ADML). Cached per session because the same category-name
|
||||
# path is read for every policy under that category.
|
||||
param($CategoryId, $Delimiter = "/")
|
||||
|
||||
if($script:_admxCategoryPaths.ContainsKey($CategoryId)) {
|
||||
return $script:_admxCategoryPaths[$CategoryId]
|
||||
}
|
||||
|
||||
$catObj = $script:_admxXML.policyDefinitions.categories.SelectSingleNode("$($script:_admxNSPrefix)category[@name='$CategoryId']", $script:_admxNS)
|
||||
$categories = @()
|
||||
while($catObj)
|
||||
{
|
||||
$categories += Get-ADMXADMLString $catObj
|
||||
if($catObj.parentCategory.ref) {
|
||||
$catObj = $script:_admxXML.policyDefinitions.categories.SelectSingleNode("$($script:_admxNSPrefix)category[@name='$($catObj.parentCategory.ref)']", $script:_admxNS)
|
||||
}
|
||||
else { break }
|
||||
}
|
||||
[array]::Reverse($categories)
|
||||
|
||||
$catPath = $categories -join $Delimiter
|
||||
$script:_admxCategoryPaths.Add($CategoryId, $catPath)
|
||||
return $catPath
|
||||
}
|
||||
|
||||
function Get-ADMXCategoryOMAURIPath
|
||||
{
|
||||
# OMA-URI uses ~ as the separator and keeps INTERNAL category names (not
|
||||
# localized display names) — separate function so the OMA-URI format
|
||||
# doesn't get accidentally entangled with the display-path formatting.
|
||||
param($CategoryId)
|
||||
|
||||
$catObj = $script:_admxXML.policyDefinitions.categories.SelectSingleNode("$($script:_admxNSPrefix)category[@name='$CategoryId']", $script:_admxNS)
|
||||
$categories = @()
|
||||
while($catObj)
|
||||
{
|
||||
$categories += $catObj.name
|
||||
$catObj = $script:_admxXML.policyDefinitions.categories.SelectSingleNode("$($script:_admxNSPrefix)category[@name='$($catObj.parentCategory.ref)']", $script:_admxNS)
|
||||
}
|
||||
[array]::Reverse($categories)
|
||||
return ($categories -join "~")
|
||||
}
|
||||
|
||||
function Get-ADMXPresentationNode
|
||||
{
|
||||
# ADML defines a presentation for each policy (where the actual labels
|
||||
# live). Resolve from "$(presentation.id)" or a bare id.
|
||||
param($Item)
|
||||
|
||||
if(-not $Item.Definition.presentation -or -not $script:_admxlngADML) { return $null }
|
||||
|
||||
if($Item.Definition.presentation.StartsWith('$('))
|
||||
{
|
||||
$tmp = $Item.Definition.presentation.Substring(2, $Item.Definition.presentation.Length - 3)
|
||||
$null, $strId = $tmp.Split('.')
|
||||
return ($script:_admxlngADML.policyDefinitionResources.resources.presentationTable.presentation | Where-Object id -eq $strId)
|
||||
}
|
||||
return ($script:_admxlngADML.policyDefinitionResources.resources.presentationTable.presentation | Where-Object id -eq $Item.Definition.presentation)
|
||||
}
|
||||
|
||||
function Set-ADMXSettingStatusText
|
||||
{
|
||||
# Mirror the SettingStatus field as a human-friendly column value.
|
||||
param($SettingObj)
|
||||
|
||||
switch ($SettingObj.SettingStatus)
|
||||
{
|
||||
0 { $SettingObj.SettingStatusText = "Disabled" }
|
||||
1 { $SettingObj.SettingStatusText = "Enabled" }
|
||||
default { $SettingObj.SettingStatusText = "Not Configured" }
|
||||
}
|
||||
}
|
||||
|
||||
function Add-ADMXRegClasses
|
||||
{
|
||||
# Lazy-loaded the first time the Reg Values tool is opened. Same C# defs as
|
||||
# the original IntuneTools.psm1 — kept verbatim so DataContext bindings in
|
||||
# IntuneToolsADMXRegValues.xaml / IntuneToolsADMXAddRegPolicy.xaml don't
|
||||
# need to change.
|
||||
if(("ADMXRegPolicyElement" -as [type])) { return }
|
||||
|
||||
$classDef = @"
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
public class ADMXRegPolicyElement : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
public string DataType { get { return _dataType; } set { _dataType = value; NotifyPropertyChanged("DataType"); NotifyPropertyChanged("DataTypeDisplayString"); } }
|
||||
private string _dataType = null;
|
||||
|
||||
public string DataTypeDisplayString { get {
|
||||
if(DataType == "text") return "String";
|
||||
else if(DataType == "multiText") return "Multi-string";
|
||||
else if(DataType == "list") return "List";
|
||||
else if(DataType == "decimal") return "DWORD (32-bit)";
|
||||
else if(DataType == "longDecimal")return "QWORD (64-bit)";
|
||||
else return DataType;
|
||||
} }
|
||||
|
||||
public string Key { get { return _key; } set { _key = value; NotifyPropertyChanged("Key"); } }
|
||||
private string _key;
|
||||
|
||||
public string ValueName { get { return _valueName; } set { _valueName = value; NotifyPropertyChanged("ValueName"); } }
|
||||
private string _valueName;
|
||||
|
||||
public string Value { get { return _value; } set { _value = value; NotifyPropertyChanged("Value"); } }
|
||||
private string _value;
|
||||
|
||||
public string AttributePrefix { get { return _attributePrefix; } set { _attributePrefix = value; NotifyPropertyChanged("AttributePrefix"); } }
|
||||
private string _attributePrefix;
|
||||
|
||||
public string AttributeSeparator { get { return _attributeSeparator; } set { _attributeSeparator = value; NotifyPropertyChanged("AttributeSeparator"); } }
|
||||
private string _attributeSeparator = ";";
|
||||
|
||||
public bool AttributeSoft { get { return _attributeSoft; } set { _attributeSoft = value; NotifyPropertyChanged("AttributeSoft"); } }
|
||||
private bool _attributeSoft = false;
|
||||
|
||||
public bool AttributeExpandable { get { return _attributeExpandable; } set { _attributeExpandable = value; NotifyPropertyChanged("AttributeExpandable"); } }
|
||||
private bool _attributeExpandable = false;
|
||||
|
||||
public bool AttributeAdditive { get { return _attributeAdditive; } set { _attributeAdditive = value; NotifyPropertyChanged("AttributeAdditive"); } }
|
||||
private bool _attributeAdditive = false;
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
private void NotifyPropertyChanged(string propertyName = "")
|
||||
{
|
||||
if(PropertyChanged != null) { PropertyChanged.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
|
||||
}
|
||||
}
|
||||
|
||||
public class ADMXRegPolicy
|
||||
{
|
||||
public string PolicyName { get; set; }
|
||||
public string PolicyStatus { get; set; }
|
||||
public string Hive { get; set; }
|
||||
public string Key { get; set; }
|
||||
public bool StatusValueEnabled { get; set; }
|
||||
public string StatusValueName { get; set; }
|
||||
public System.Collections.ObjectModel.ObservableCollection<ADMXRegPolicyElement> PolicyElements { get; set; }
|
||||
|
||||
public ADMXRegPolicy()
|
||||
{
|
||||
PolicyElements = new System.Collections.ObjectModel.ObservableCollection<ADMXRegPolicyElement>();
|
||||
Hive = "HKLM";
|
||||
PolicyStatus = "Enabled";
|
||||
StatusValueEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
public class ADMXRegProfile
|
||||
{
|
||||
public string ProfileName { get; set; }
|
||||
public string ProfileDescription { get; set; }
|
||||
public string PolicyType { get; set; }
|
||||
public System.Collections.ObjectModel.ObservableCollection<ADMXRegPolicy> ADMXPolicies { get; set; }
|
||||
public string XmlString { get; set; }
|
||||
|
||||
public ADMXRegProfile()
|
||||
{
|
||||
ADMXPolicies = new System.Collections.ObjectModel.ObservableCollection<ADMXRegPolicy>();
|
||||
PolicyType = "Policy";
|
||||
}
|
||||
}
|
||||
"@
|
||||
Add-NativeClass -ClassName "ADMXRegPolicyElement" -ClassDefinition $classDef
|
||||
}
|
||||
|
||||
function Add-ADMXRegXmlAttribute
|
||||
{
|
||||
param($XmlNode, [string]$Attribute, $Value)
|
||||
$attr = $XmlNode.OwnerDocument.CreateAttribute($Attribute)
|
||||
$attr.Value = $Value
|
||||
[void]$XmlNode.Attributes.Append($attr)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
# Data layer for the Intune Tools view (Filter Usage report + enrollment-config
|
||||
# lookups). Moved out of the per-backend UI files (UI/WPF + UI/Avalonia) where it
|
||||
# was duplicated, into one module-internal home. Pure Graph/data - no XAML, no
|
||||
# UIProvider, no backend row classes; the UI Start-*Load functions project the
|
||||
# returned PSCustomObjects into their own CLR row types. (architecture R11)
|
||||
|
||||
# Map a payload's payloadType to the Graph endpoint that owns that resource +
|
||||
# the display label we'll show in the Type column. Returns $null when the
|
||||
# payloadType isn't one we recognize — caller falls back to a multi-endpoint
|
||||
# probe (legacy behavior). Listed in the same order the old module checked.
|
||||
function Get-IntuneFilterPayloadDispatch
|
||||
{
|
||||
param([string]$PayloadType, [string]$PayloadId)
|
||||
|
||||
if(-not $PayloadId) { return $null }
|
||||
|
||||
switch ($PayloadType)
|
||||
{
|
||||
"application" {
|
||||
return [PSCustomObject]@{
|
||||
Url = "deviceAppManagement/mobileApps/$PayloadId/?`$select=displayName"
|
||||
TypeLabel = "Application"
|
||||
}
|
||||
}
|
||||
"win32app" {
|
||||
# Old comment used "Proactive Remediations" — the endpoint is
|
||||
# /deviceHealthScripts which is exactly that, despite the
|
||||
# payloadType name being "win32app".
|
||||
return [PSCustomObject]@{
|
||||
Url = "deviceManagement/deviceHealthScripts/$PayloadId/?`$select=displayName,isGlobalScript"
|
||||
TypeLabel = "Proactive Remediation"
|
||||
}
|
||||
}
|
||||
"deviceManagmentConfigurationAndCompliancePolicy" {
|
||||
# Yes, the typo "Managment" is upstream from Microsoft — kept verbatim.
|
||||
return [PSCustomObject]@{
|
||||
Url = "deviceManagement/configurationPolicies/$PayloadId/?`$select=name,platforms,technologies,templateReference"
|
||||
TypeLabel = "Settings Catalog"
|
||||
}
|
||||
}
|
||||
"groupPolicyConfiguration" {
|
||||
return [PSCustomObject]@{
|
||||
Url = "deviceManagement/groupPolicyConfigurations/$PayloadId/?`$select=displayName"
|
||||
TypeLabel = "Administrative Templates"
|
||||
}
|
||||
}
|
||||
default { return $null }
|
||||
}
|
||||
}
|
||||
|
||||
# Which endpoints to probe when the payloadType has no dispatch entry.
|
||||
#
|
||||
# The generic set is the legacy three. Two payload types are known to be
|
||||
# CONFIGURATION-OR-COMPLIANCE and never an app configuration, so they skip that
|
||||
# third probe (measured live 2026-09-06: both deviceCompliancePolicies and
|
||||
# deviceConfigurations were observed behind deviceConfigurationAndCompliance, so
|
||||
# neither can become a single-URL dispatch entry - two probes is the floor).
|
||||
function Get-IntuneFilterProbeEndpoints
|
||||
{
|
||||
param([string]$PayloadType, [string]$PayloadId)
|
||||
|
||||
$probes = @(
|
||||
@{ Suffix = "_dcp"; Url = "deviceManagement/deviceCompliancePolicies/$PayloadId/?`$select=displayName"; Type = "Compliance Policy" }
|
||||
@{ Suffix = "_dc"; Url = "deviceManagement/deviceConfigurations/$PayloadId/?`$select=displayName"; Type = "Device Configuration" }
|
||||
)
|
||||
|
||||
if($PayloadType -notin @("deviceConfigurationAndCompliance", "androidEnterpriseConfiguration")) {
|
||||
$probes += @{ Suffix = "_mac"; Url = "deviceAppManagement/mobileAppConfigurations/$PayloadId/?`$select=displayName"; Type = "App Configuration" }
|
||||
}
|
||||
|
||||
return $probes
|
||||
}
|
||||
|
||||
# App Protection / managed-app policies, keyed by the payloadId shape.
|
||||
#
|
||||
# THE TRAP: /assignmentFilters/<id>/payloads reports a managed-app policy with
|
||||
# payloadType "unknown" AND a BARE guid, while the policy's real id carries a
|
||||
# type prefix (T_ targeted app protection, A_ app configuration, I_ Windows app
|
||||
# protection, M_ information protection). Verified live: GET managedAppPolicies/
|
||||
# <bare guid> is a 404, GET managedAppPolicies/T_<same guid> works. So these can
|
||||
# never be resolved by id from the batch - list them once and match on the
|
||||
# prefix-stripped id instead, the same way enrollment configurations are handled.
|
||||
function Get-IntuneManagedAppPolicyLookup
|
||||
{
|
||||
param([int]$TokenId)
|
||||
|
||||
if($script:_intuneManagedAppPolicyCache) { return $script:_intuneManagedAppPolicyCache }
|
||||
|
||||
$lookup = @{}
|
||||
try {
|
||||
$resp = Invoke-MSGraphAPI -Url "deviceAppManagement/managedAppPolicies?`$select=id,displayName" -TokenId $TokenId -AllPages
|
||||
foreach($p in @($resp.value)) {
|
||||
if(-not $p.id) { continue }
|
||||
$bare = "$($p.id)" -replace '^[A-Za-z]+_', ''
|
||||
if($bare) { $lookup[$bare] = $p }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Failed to preload managed app policies: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$script:_intuneManagedAppPolicyCache = $lookup
|
||||
return $lookup
|
||||
}
|
||||
|
||||
# Label for a managed-app policy row. App protection and app configuration both
|
||||
# live under managedAppPolicies; the @odata.type is what separates them.
|
||||
function Get-IntuneManagedAppPolicyTypeLabel
|
||||
{
|
||||
param($Policy)
|
||||
|
||||
$odata = "$($Policy.'@odata.type')"
|
||||
if($odata -match 'ManagedAppConfiguration') { return "App Configuration" }
|
||||
if($odata -match 'InformationProtection') { return "Information Protection" }
|
||||
return "App Protection"
|
||||
}
|
||||
|
||||
# Lazily load the deviceEnrollmentConfigurations list — used for payloads with
|
||||
# payloadType=enrollmentConfiguration. There's no GET-by-id pattern that fits
|
||||
# the batch model cleanly here (the configType discriminator decides what kind
|
||||
# it is), so we fetch the list once and look up by Id in memory.
|
||||
function Get-IntuneEnrollmentConfigurationLookup
|
||||
{
|
||||
param([int]$TokenId)
|
||||
|
||||
if($script:_intuneEnrollmentConfigCache) { return $script:_intuneEnrollmentConfigCache }
|
||||
|
||||
$configs = @()
|
||||
try {
|
||||
$base = (Invoke-MSGraphAPI -Url "deviceManagement/deviceEnrollmentConfigurations?`$select=displayName,id,deviceEnrollmentConfigurationType" -TokenId $TokenId -AllPages)
|
||||
if($base -and $base.value) { $configs += @($base.value) }
|
||||
# The portal also separately enumerates enrollmentNotificationsConfiguration
|
||||
# (it's filtered out of the default list response) — preserve that fetch.
|
||||
$notif = (Invoke-MSGraphAPI -Url "deviceManagement/deviceEnrollmentConfigurations?`$filter=deviceEnrollmentConfigurationType eq 'EnrollmentNotificationsConfiguration'&`$select=displayName,id,deviceEnrollmentConfigurationType" -TokenId $TokenId -AllPages)
|
||||
if($notif -and $notif.value) { $configs += @($notif.value) }
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Failed to preload enrollment configurations: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$lookup = @{}
|
||||
foreach($c in $configs) {
|
||||
if($c.id) { $lookup[$c.id] = $c }
|
||||
}
|
||||
$script:_intuneEnrollmentConfigCache = $lookup
|
||||
return $lookup
|
||||
}
|
||||
|
||||
function Get-IntuneEnrollmentConfigurationTypeLabel
|
||||
{
|
||||
# Friendly label for the Type column based on the discriminator on each
|
||||
# deviceEnrollmentConfiguration subtype.
|
||||
param([string]$ConfigType)
|
||||
|
||||
switch -Regex ($ConfigType)
|
||||
{
|
||||
'(?i)^enrollmentNotificationsConfiguration$' { return "Enrollment notifications" }
|
||||
'(?i)^windows10EnrollmentCompletionPageConfiguration$' { return "Enrollment Status Page" }
|
||||
'(?i)^limit$' { return "Enrollment Limit" }
|
||||
'(?i)^singlePlatformRestriction$' { return "Enrollment Restriction" }
|
||||
'(?i)^platformRestrictions$' { return "Enrollment Restrictions (default)" }
|
||||
'(?i)^windowsHelloForBusiness$' { return "Windows Hello for Business" }
|
||||
'(?i)^deviceComanagementAuthorityConfiguration$' { return "Co-management Authority" }
|
||||
'(?i)^windowsRestore$' { return "Windows Restore" }
|
||||
default { return "Enrollment Configuration" }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-IntuneFilterUsageData
|
||||
{
|
||||
# Pull every assignment filter, then for each one fetch /payloads. Each
|
||||
# payload references one policy by (payloadId, payloadType) — we resolve
|
||||
# the policy display name in a second batch, then resolve group display
|
||||
# names in a third batch. Three round-trip-batched phases instead of
|
||||
# one-per-payload + one-per-group.
|
||||
|
||||
$tokenId = Get-DefaultTokenId
|
||||
|
||||
# Phase 1: list all filters.
|
||||
Write-Status "Loading assignment filters..."
|
||||
$filterResp = Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters" -TokenId $tokenId -AllPages
|
||||
if(-not $filterResp) { return @() }
|
||||
$filters = @()
|
||||
if($filterResp.value) { $filters = @($filterResp.value) }
|
||||
elseif($filterResp -is [Array]) { $filters = @($filterResp) }
|
||||
if($filters.Count -eq 0) { return @() }
|
||||
|
||||
# Phase 2: /payloads per filter in one batch.
|
||||
Write-Status "Fetching payloads for $($filters.Count) filter(s)..."
|
||||
$payloadBatch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach($f in $filters) {
|
||||
[void]$payloadBatch.Add([PSCustomObject]@{
|
||||
id = [string]$f.id
|
||||
method = "GET"
|
||||
url = "deviceManagement/assignmentFilters/$($f.id)/payloads"
|
||||
headers = @{ "Accept" = "application/json" }
|
||||
})
|
||||
}
|
||||
$payloadResults = Invoke-GraphBatchRequest -BatchObjects $payloadBatch -BatchType "FilterPayloads" -TokenId $tokenId
|
||||
|
||||
# Build a (filter, payload) work list from the batch responses.
|
||||
$filterById = @{}
|
||||
foreach($f in $filters) { $filterById[[string]$f.id] = $f }
|
||||
|
||||
$filtersWithPayload = [System.Collections.Generic.HashSet[string]]::new()
|
||||
$work = [System.Collections.Generic.List[object]]::new()
|
||||
foreach($r in $payloadResults) {
|
||||
if(-not $r.body) { continue }
|
||||
$values = @()
|
||||
if($r.body.value) { $values = @($r.body.value) }
|
||||
$filter = $filterById["$($r.Id)"]
|
||||
if(-not $filter) { continue }
|
||||
foreach($p in $values) {
|
||||
[void]$filtersWithPayload.Add([string]$filter.id)
|
||||
[void]$work.Add([PSCustomObject]@{ Filter = $filter; Payload = $p })
|
||||
}
|
||||
}
|
||||
|
||||
if($work.Count -eq 0) {
|
||||
Write-Log "No filter payloads found across $($filters.Count) filter(s). Showing filters as not used."
|
||||
return @($filters | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
FilterName = $_.displayName
|
||||
Platform = [string]$_.platform
|
||||
FilterType = [string]$_.assignmentFilterManagementType
|
||||
PolicyName = "<not used>"
|
||||
PayloadType = "No payloads"
|
||||
Mode = ""
|
||||
GroupId = ""
|
||||
GroupName = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Phase 3: resolve policy display name per payload. We batch by sub-request
|
||||
# id, then post-process — each work item gets a unique GUID prefix so we
|
||||
# can match the response back to its (filter, payload) pair. Three-way
|
||||
# fallback (deviceCompliancePolicies / deviceConfigurations /
|
||||
# mobileAppConfigurations) preserved for unrecognized payloadType values.
|
||||
$policyBatch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$workByGuid = @{}
|
||||
$manualEnrolment = [System.Collections.Generic.List[object]]::new()
|
||||
$manualManagedApp = [System.Collections.Generic.List[object]]::new()
|
||||
$unknownTypeCounts = @{} # diagnostic — surface unrecognized payloadTypes for future dispatch tuning
|
||||
|
||||
foreach($w in $work) {
|
||||
$payload = $w.Payload
|
||||
$guid = [Guid]::NewGuid().Guid
|
||||
$workByGuid[$guid] = $w
|
||||
$w | Add-Member -NotePropertyName "_BatchGuid" -NotePropertyValue $guid -Force
|
||||
|
||||
if($payload.payloadType -eq "enrollmentConfiguration") {
|
||||
# Resolved in-memory from the pre-loaded list — skip the batch.
|
||||
[void]$manualEnrolment.Add($w)
|
||||
continue
|
||||
}
|
||||
|
||||
# Managed-app policies report payloadType "unknown" with a prefix-less id
|
||||
# (see Get-IntuneManagedAppPolicyLookup). Resolve those in memory; anything
|
||||
# else calling itself "unknown" still falls through to the probe below.
|
||||
if($payload.payloadType -eq "unknown") {
|
||||
$mamLookup = Get-IntuneManagedAppPolicyLookup -TokenId $tokenId
|
||||
if($mamLookup -and $mamLookup.ContainsKey([string]$payload.payloadId)) {
|
||||
[void]$manualManagedApp.Add($w)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
$dispatch = Get-IntuneFilterPayloadDispatch -PayloadType $payload.payloadType -PayloadId $payload.payloadId
|
||||
if($dispatch) {
|
||||
[void]$policyBatch.Add([PSCustomObject]@{
|
||||
id = $guid
|
||||
method = "GET"
|
||||
url = $dispatch.Url
|
||||
headers = @{ "Accept" = "application/json" }
|
||||
})
|
||||
$w | Add-Member -NotePropertyName "_TypeLabel" -NotePropertyValue $dispatch.TypeLabel -Force
|
||||
}
|
||||
else {
|
||||
# Unknown payloadType — try the three common endpoints in
|
||||
# parallel and pick the one that returns 200. Track the unrecognized
|
||||
# type so we can extend Get-IntuneFilterPayloadDispatch later (cuts
|
||||
# the batch sub-request count from 3 to 1 for known types).
|
||||
$ptKey = if([string]::IsNullOrEmpty($payload.payloadType)) { "<null>" } else { [string]$payload.payloadType }
|
||||
if(-not $unknownTypeCounts.ContainsKey($ptKey)) { $unknownTypeCounts[$ptKey] = 0 }
|
||||
$unknownTypeCounts[$ptKey]++
|
||||
|
||||
foreach($probe in (Get-IntuneFilterProbeEndpoints -PayloadType $payload.payloadType -PayloadId $payload.payloadId)) {
|
||||
$subId = "$guid$($probe.Suffix)"
|
||||
[void]$policyBatch.Add([PSCustomObject]@{
|
||||
id = $subId
|
||||
method = "GET"
|
||||
url = $probe.Url
|
||||
headers = @{ "Accept" = "application/json" }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($unknownTypeCounts.Count -gt 0) {
|
||||
$summary = ($unknownTypeCounts.GetEnumerator() | Sort-Object Key | ForEach-Object { "$($_.Key):$($_.Value)" }) -join ", "
|
||||
# Informational, not a warning: nothing is wrong and the end user can do
|
||||
# nothing about it. It is a note for whoever maintains the dispatch table.
|
||||
Write-Log "Intune Filter Usage: payloadType(s) resolved by endpoint probe rather than a direct lookup (extend Get-IntuneFilterPayloadDispatch to optimize): $summary"
|
||||
}
|
||||
|
||||
$policyResults = @()
|
||||
if($policyBatch.Count -gt 0) {
|
||||
Write-Status "Resolving $($policyBatch.Count) policy reference(s)..."
|
||||
$policyResults = @(Invoke-GraphBatchRequest -BatchObjects $policyBatch -BatchType "FilterPayloadNames" -TokenId $tokenId -SkipWarnings -IncludedFailed)
|
||||
}
|
||||
|
||||
# Bucket policy results by base GUID; pick the first success per work item.
|
||||
$resolvedByGuid = @{}
|
||||
foreach($r in $policyResults) {
|
||||
$baseGuid = "$($r.Id)"
|
||||
# Strip any trailing "_dcp" / "_dc" / "_mac" suffix to get the work-item key.
|
||||
$baseGuid = $baseGuid -replace '_(dcp|dc|mac)$', ''
|
||||
if(-not $workByGuid.ContainsKey($baseGuid)) { continue }
|
||||
if($r.Status -ge 300 -or -not $r.body) { continue }
|
||||
if(-not $resolvedByGuid.ContainsKey($baseGuid)) {
|
||||
# For unknown-payloadType probes, derive the type label from which
|
||||
# endpoint actually answered (suffix tells us).
|
||||
$derivedType = $null
|
||||
if("$($r.Id)" -match '_(dcp|dc|mac)$') {
|
||||
$derivedType = switch ($matches[1]) {
|
||||
'dcp' { "Compliance Policy" }
|
||||
'dc' { "Device Configuration" }
|
||||
'mac' { "App Configuration" }
|
||||
}
|
||||
}
|
||||
$resolvedByGuid[$baseGuid] = [PSCustomObject]@{ Body = $r.body; DerivedType = $derivedType }
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve enrollment-configuration payloads from the in-memory list.
|
||||
$enrollmentLookup = $null
|
||||
if($manualEnrolment.Count -gt 0) {
|
||||
$enrollmentLookup = Get-IntuneEnrollmentConfigurationLookup -TokenId $tokenId
|
||||
}
|
||||
|
||||
# Same for managed-app policies (already loaded above if any matched).
|
||||
$managedAppLookup = $null
|
||||
if($manualManagedApp.Count -gt 0) {
|
||||
$managedAppLookup = Get-IntuneManagedAppPolicyLookup -TokenId $tokenId
|
||||
}
|
||||
|
||||
# Build the row list. Defer group-name resolution to Phase 4.
|
||||
$rows = [System.Collections.Generic.List[object]]::new()
|
||||
$allGroupIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
|
||||
foreach($w in $work) {
|
||||
$filter = $w.Filter
|
||||
$payload = $w.Payload
|
||||
|
||||
$policyName = $null
|
||||
$typeLabel = $w._TypeLabel
|
||||
|
||||
if($payload.payloadType -eq "enrollmentConfiguration" -and $enrollmentLookup) {
|
||||
$cfg = $enrollmentLookup[$payload.payloadId]
|
||||
if($cfg) {
|
||||
$policyName = $cfg.displayName
|
||||
$typeLabel = Get-IntuneEnrollmentConfigurationTypeLabel $cfg.deviceEnrollmentConfigurationType
|
||||
}
|
||||
}
|
||||
elseif($managedAppLookup -and $managedAppLookup.ContainsKey([string]$payload.payloadId)) {
|
||||
$mam = $managedAppLookup[[string]$payload.payloadId]
|
||||
$policyName = $mam.displayName
|
||||
$typeLabel = Get-IntuneManagedAppPolicyTypeLabel $mam
|
||||
}
|
||||
else {
|
||||
$resolved = $resolvedByGuid[$w._BatchGuid]
|
||||
if($resolved) {
|
||||
$body = $resolved.Body
|
||||
$policyName = if($body.name) { $body.name } else { $body.displayName }
|
||||
if($resolved.DerivedType) { $typeLabel = $resolved.DerivedType }
|
||||
# Settings Catalog templateReference can carry a richer label.
|
||||
if($payload.payloadType -eq "deviceManagmentConfigurationAndCompliancePolicy" -and $body.templateReference -and $body.templateReference.templateDisplayName) {
|
||||
$typeLabel = "Settings Catalog ($($body.templateReference.templateDisplayName))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $policyName) {
|
||||
# Couldn't resolve. Keep the row so the filter itself is still visible
|
||||
# and the user can see the stale/unsupported payload reference.
|
||||
Write-Log "Filter '$($filter.displayName)': failed to resolve payload $($payload.payloadId) (type: $($payload.payloadType))" 2
|
||||
$policyName = "<unresolved: $($payload.payloadId)>"
|
||||
if(-not $typeLabel) {
|
||||
$typeLabel = if($payload.payloadType) { $payload.payloadType } else { "Unknown payload" }
|
||||
}
|
||||
}
|
||||
|
||||
$mode = if($payload.assignmentFilterType -eq "Include") { "Include" } else { "Exclude" }
|
||||
|
||||
if($payload.groupId) { [void]$allGroupIds.Add([string]$payload.groupId) }
|
||||
|
||||
[void]$rows.Add([PSCustomObject]@{
|
||||
FilterName = $filter.displayName
|
||||
Platform = [string]$filter.platform
|
||||
FilterType = [string]$filter.assignmentFilterManagementType
|
||||
PolicyName = $policyName
|
||||
PayloadType = if($typeLabel) { $typeLabel } else { $payload.payloadType }
|
||||
Mode = $mode
|
||||
GroupId = $payload.groupId
|
||||
GroupName = $payload.groupId # placeholder; resolved below
|
||||
})
|
||||
}
|
||||
|
||||
foreach($filter in $filters) {
|
||||
if($filtersWithPayload.Contains([string]$filter.id)) { continue }
|
||||
[void]$rows.Add([PSCustomObject]@{
|
||||
FilterName = $filter.displayName
|
||||
Platform = [string]$filter.platform
|
||||
FilterType = [string]$filter.assignmentFilterManagementType
|
||||
PolicyName = "<not used>"
|
||||
PayloadType = "No payloads"
|
||||
Mode = ""
|
||||
GroupId = ""
|
||||
GroupName = ""
|
||||
})
|
||||
}
|
||||
|
||||
# Phase 4: resolve group names in one batch. Pre-seed the well-known
|
||||
# virtual groups that don't resolve via /groups (these are baked into the
|
||||
# Intune assignment model). Cache otherwise.
|
||||
$groupNames = @{
|
||||
"adadadad-808e-44e2-905a-0b7873a8a531" = "All Devices"
|
||||
"acacacac-9df4-4c7d-9d50-4ef0226f57a9" = "All Users"
|
||||
}
|
||||
$toLookup = @($allGroupIds | Where-Object { -not $groupNames.ContainsKey($_) })
|
||||
|
||||
if($toLookup.Count -gt 0) {
|
||||
Write-Status "Resolving $($toLookup.Count) group(s)..."
|
||||
$groupBatch = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach($gid in $toLookup) {
|
||||
[void]$groupBatch.Add([PSCustomObject]@{
|
||||
id = [string]$gid
|
||||
method = "GET"
|
||||
url = "groups/$gid/?`$select=displayName,id"
|
||||
headers = @{ "Accept" = "application/json" }
|
||||
})
|
||||
}
|
||||
$groupResults = @(Invoke-GraphBatchRequest -BatchObjects $groupBatch -BatchType "FilterGroupNames" -TokenId $tokenId -SkipWarnings -IncludedFailed)
|
||||
foreach($r in $groupResults) {
|
||||
if($r.Status -ge 300 -or -not $r.body) { continue }
|
||||
$name = if($r.body.displayName) { $r.body.displayName } else { "$($r.Id)" }
|
||||
$groupNames["$($r.Id)"] = $name
|
||||
}
|
||||
}
|
||||
|
||||
foreach($row in $rows) {
|
||||
if(-not $row.GroupId) {
|
||||
$row.GroupName = ""
|
||||
continue
|
||||
}
|
||||
$key = [string]$row.GroupId
|
||||
if($groupNames.ContainsKey($key)) {
|
||||
$row.GroupName = $groupNames[$key]
|
||||
}
|
||||
else {
|
||||
# Group didn't resolve via /groups (deleted, throttled, or denied) —
|
||||
# mark explicitly so the user doesn't mistake the GUID for a name.
|
||||
# Short-form GUID keeps the column narrow.
|
||||
$short = if($key.Length -ge 8) { $key.Substring(0, 8) } else { $key }
|
||||
$row.GroupName = "<unresolved: $short...>"
|
||||
}
|
||||
}
|
||||
|
||||
return $rows
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# Default grid columns for the Intune Manager view, derived from what a policy
|
||||
# type or group supports instead of a hand-typed list per class.
|
||||
#
|
||||
# Every group and type used to carry its own _ViewProperties string list. With no
|
||||
# rule behind them the lists drifted: "Policy type" repeated itself on type views,
|
||||
# Platform sat blank on types that have none and was missing on Settings Catalog
|
||||
# where it matters most, two views bound properties that do not exist on a row,
|
||||
# and Description - the second thing anyone searches for - was nowhere. The grid
|
||||
# filter matches only the bound columns, so a column that is not shown is a field
|
||||
# that cannot be searched.
|
||||
#
|
||||
# A class now declares capabilities (see IntunePolicyTypeBase / IntunePolicyGroupBase
|
||||
# in Classes/IntuneBaseClasses.ps1) and the ViewProperties accessor calls
|
||||
# Get-IntuneDefaultColumns. An explicit _ViewProperties still wins if a class sets
|
||||
# one, so the escape hatch exists - nothing in the tree uses it, and
|
||||
# Tests/ViewColumns.Tests.ps1 pins that.
|
||||
#
|
||||
# Column entry syntax is unchanged: "<bindingPath>[=<Header>]".
|
||||
|
||||
function Get-IntuneDefaultColumns
|
||||
{
|
||||
param([Parameter(Mandatory = $true)]$Target)
|
||||
|
||||
if($Target -is [IntunePolicyGroupBase]) { return ,(Get-IntuneGroupDefaultColumns $Target) }
|
||||
return ,(Get-IntuneTypeDefaultColumns $Target)
|
||||
}
|
||||
|
||||
# Type view - every row is one type, so the generic Policy type column would
|
||||
# repeat itself; the type's _SubTypeColumn takes its place when rows differ.
|
||||
function Get-IntuneTypeDefaultColumns
|
||||
{
|
||||
param([Parameter(Mandatory = $true)]$PolicyType)
|
||||
|
||||
$cols = [System.Collections.Generic.List[string]]::new()
|
||||
$cols.Add("Name")
|
||||
if($PolicyType._SubTypeColumn) { $cols.Add($PolicyType._SubTypeColumn) }
|
||||
foreach($c in @($PolicyType._ExtraColumns)) { if($c) { $cols.Add($c) } }
|
||||
if($PolicyType._HasPlatform) { $cols.Add("Platform") }
|
||||
$cols.Add("Description")
|
||||
if($PolicyType._HasModified) { $cols.Add("LastModified=Modified") }
|
||||
$cols.Add("ID")
|
||||
return ,$cols.ToArray()
|
||||
}
|
||||
|
||||
# Group view - rows of several types. A column earns its width only if most rows
|
||||
# can fill it, so the decisions read the member types' flags.
|
||||
function Get-IntuneGroupDefaultColumns
|
||||
{
|
||||
param([Parameter(Mandatory = $true)]$PolicyGroup)
|
||||
|
||||
$members = @($PolicyGroup._PolicyTypes | Where-Object { $null -ne $_ })
|
||||
|
||||
# One member type: the group view IS that type's view.
|
||||
if($members.Count -eq 1) { return ,@($members[0].ViewProperties) }
|
||||
|
||||
$cols = [System.Collections.Generic.List[string]]::new()
|
||||
$cols.Add("Name")
|
||||
|
||||
# Policy type is the row's PolicyName: per row where the object class sets one
|
||||
# (Device Configuration "iOS Wi-Fi", Endpoint Security "Antivirus"), else the
|
||||
# member type's own name. Shown when there is more than one member, unless the
|
||||
# group says otherwise (Applications: Type carries it).
|
||||
$showType = if($null -ne $PolicyGroup._ShowPolicyTypeColumn) { [bool]$PolicyGroup._ShowPolicyTypeColumn } else { $members.Count -gt 1 }
|
||||
if($showType) { $cols.Add("PolicyName=Policy type") }
|
||||
|
||||
foreach($c in @($PolicyGroup._ExtraColumns)) { if($c) { $cols.Add($c) } }
|
||||
|
||||
# Platform: the group can force or hide it; otherwise at least half the members
|
||||
# resolve one, and it is not constant - every member carrying the same
|
||||
# type-level platform would read "Windows" on every row.
|
||||
if($null -ne $PolicyGroup._ShowPlatformColumn)
|
||||
{
|
||||
if([bool]$PolicyGroup._ShowPlatformColumn) { $cols.Add("Platform") }
|
||||
}
|
||||
elseif($members.Count -gt 0)
|
||||
{
|
||||
$withPlatform = @($members | Where-Object { $_._HasPlatform }).Count
|
||||
if(($withPlatform * 2) -ge $members.Count)
|
||||
{
|
||||
$typeLevel = @($members | ForEach-Object { [string]$_._PlatformName })
|
||||
$allTyped = (@($typeLevel | Where-Object { -not $_ }).Count -eq 0)
|
||||
$constant = $allTyped -and (@($typeLevel | Select-Object -Unique).Count -eq 1)
|
||||
if(-not $constant) { $cols.Add("Platform") }
|
||||
}
|
||||
}
|
||||
|
||||
$cols.Add("Description")
|
||||
|
||||
if(@($members | Where-Object { $_._HasModified }).Count -gt 0) { $cols.Add("LastModified=Modified") }
|
||||
|
||||
$cols.Add("ID")
|
||||
return ,$cols.ToArray()
|
||||
}
|
||||
|
||||
# Graph enum values as grid text: "boundAndValidated" -> "Bound and validated",
|
||||
# "superseded" -> "Superseded". Used by the row properties that normalise
|
||||
# template lifecycle / token state across types so a group column reads evenly.
|
||||
function ConvertTo-DisplayWords
|
||||
{
|
||||
param([string]$Value)
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($Value)) { return $null }
|
||||
$spaced = [regex]::Replace($Value.Trim(), '(?<=[a-z0-9])([A-Z])', ' $1')
|
||||
return $spaced.Substring(0, 1).ToUpperInvariant() + $spaced.Substring(1).ToLowerInvariant()
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
function Get-LanguageString
|
||||
{
|
||||
param($String, $DefaultValue = $null, [switch]$IgnoreMissing)
|
||||
|
||||
$lng = ?? $script:CurrentLanguage "en"
|
||||
|
||||
$languageStrings = Get-CacheObject "LanguageStrings_$lng"
|
||||
|
||||
if(-not $languageStrings)
|
||||
{
|
||||
try {
|
||||
# ReadAllText, not Get-Content: it defaults to UTF8 with BOM detection on
|
||||
# both PS5.1 and PS7, so there is no -Encoding flag to forget. Bare
|
||||
# Get-Content reads these as the ANSI codepage on 5.1 and mangles every
|
||||
# non-Latin string. It is also ~3x faster on these 1-2 MB files.
|
||||
$languageStrings = [IO.File]::ReadAllText(([IO.Path]::Combine($script:AppRootFolder, "Config", "LanguageStrings", "Strings-$($lng).json"))) | ConvertFrom-Json
|
||||
Set-CacheObject "LanguageStrings_$lng" $languageStrings
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to load language string for $lng" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
if(!$String) { return }
|
||||
|
||||
$arrParts = $String.Split('.')
|
||||
|
||||
if([String]::IsNullOrEmpty($arrParts[-1])) { return }
|
||||
|
||||
$languageStringObject = $languageStrings
|
||||
foreach($part in $arrParts.Split('.'))
|
||||
{
|
||||
if($languageStringObject.$part -or $part -eq "Empty")
|
||||
{
|
||||
$languageStringObject = $languageStringObject.$part
|
||||
}
|
||||
else
|
||||
{
|
||||
if($part -and $IgnoreMissing -ne $true)
|
||||
{
|
||||
# A segment starting with an uppercase letter is expected noise, not
|
||||
# a defect. The language pipeline forbids sibling keys that differ
|
||||
# only by case and resolves the clash by deleting the uppercase-
|
||||
# leading one, subtree included. The lookup above is case-insensitive,
|
||||
# so any uppercase key whose lowercase twin survived has already
|
||||
# matched - what reaches here is a key Microsoft declares in setting
|
||||
# metadata but never publishes in a resource file. Nothing we or the
|
||||
# language project can fix, so keep it out of the normal log.
|
||||
#
|
||||
# -cmatch, not -match: PowerShell's default comparison is
|
||||
# case-INsensitive, so -match '^[A-Z]' would silence every miss.
|
||||
if($part -cmatch '^[A-Z]')
|
||||
{
|
||||
Write-LogDebug "Could not find string $String. Part '$part' was not found"
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Log "Could not find string $String. Part '$part' was not found"
|
||||
}
|
||||
}
|
||||
return $DefaultValue
|
||||
}
|
||||
}
|
||||
|
||||
# A key can land on a CONTAINER rather than a leaf string - the portal ships
|
||||
# both 'ScheduledAction.notification' (the action label) and a
|
||||
# 'ScheduledAction.Notification' object of email-picker sub-strings, and the
|
||||
# lookup above is case-insensitive. Calling .Trim() on that object threw and
|
||||
# took the whole policy's documentation down with it. Callers that build a
|
||||
# key from Graph data (an enum member, an @odata.type) cannot know in advance
|
||||
# which they will hit, so treat a container as "not found".
|
||||
if($languageStringObject -isnot [String])
|
||||
{
|
||||
if($IgnoreMissing -ne $true)
|
||||
{
|
||||
Write-Log "String $String resolves to a container, not a value. Using the default" 2
|
||||
}
|
||||
return $DefaultValue
|
||||
}
|
||||
|
||||
return $languageStringObject.Trim("`n")
|
||||
}
|
||||
|
||||
# Renamed from Get-TranslationFiles
|
||||
function Get-PolicyObjectCategoryInfo
|
||||
{
|
||||
param($ODataType)
|
||||
|
||||
$policyObjectCategories = Get-CacheObject "PolicyObjectCategories"
|
||||
|
||||
if(-not $policyObjectCategories)
|
||||
{
|
||||
$policyObjectCategories = [IO.File]::ReadAllText(([IO.Path]::Combine($script:AppRootFolder, "Config", "ObjectCategories.json"))) | ConvertFrom-Json
|
||||
Set-CacheObject "PolicyObjectCategories" $policyObjectCategories
|
||||
}
|
||||
|
||||
return $policyObjectCategories | Where-Object ObjectType -eq $ODataType
|
||||
}
|
||||
|
||||
function Get-PolicyObjectCategoryString
|
||||
{
|
||||
param($CategoryId)
|
||||
|
||||
if($CategoryId -is [String])
|
||||
{
|
||||
return Get-LanguageString (?: $CategoryId.Contains(".") $CategoryId "Category.$($CategoryId)")
|
||||
}
|
||||
|
||||
$policyObjectCategoryIds = Get-CacheObject "PolicyObjectCategoryIds"
|
||||
|
||||
if(-not $policyObjectCategoryIds)
|
||||
{
|
||||
$policyObjectCategoryIds = [IO.File]::ReadAllText(([IO.Path]::Combine($script:AppRootFolder, "Config", "CategoryId.json"))) | ConvertFrom-Json
|
||||
# Was never cached, so the file was re-read on every category lookup - once
|
||||
# per documented row. The other three Config readers here all cache.
|
||||
Set-CacheObject "PolicyObjectCategoryIds" $policyObjectCategoryIds
|
||||
}
|
||||
|
||||
Get-LanguageString "Category.$($policyObjectCategoryIds."$($CategoryId)")"
|
||||
}
|
||||
|
||||
function Get-ApplicationType
|
||||
{
|
||||
param($PolicyObject)
|
||||
|
||||
$appTypes = Get-CacheObject "ApplicationTypes"
|
||||
|
||||
if(-not $appTypes)
|
||||
{
|
||||
$fi = [IO.FileInfo]([IO.Path]::Combine($script:AppRootFolder, "Config", "AppTypes.json"))
|
||||
if(!$fi.Exists)
|
||||
{
|
||||
return $false
|
||||
}
|
||||
$appTypes = [IO.File]::ReadAllText($fi.FullName) | ConvertFrom-Json
|
||||
Set-CacheObject "ApplicationTypes" $appTypes
|
||||
}
|
||||
|
||||
foreach($appType in ($appTypes | Where-Object ODataType -eq $PolicyObject.JsonObject.'@OData.Type'))
|
||||
{
|
||||
if($appType.Condition)
|
||||
{
|
||||
if($PolicyObject.JsonObject."$($appType.Condition.Property)" -eq $appType.Condition.Value)
|
||||
{
|
||||
return $appType
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return $appType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-TemplatePolicyTypeName
|
||||
{
|
||||
param($ODataType, $Default = $null)
|
||||
|
||||
$categoryObject = Get-PolicyObjectCategoryInfo $ODataType
|
||||
|
||||
if($null -eq $categoryObject) { return $Default }
|
||||
|
||||
$policyName = Get-LanguageString "PolicyType.$($categoryObject.PolicyTypeLanguageId)"
|
||||
|
||||
if($policyName) { return $policyName }
|
||||
|
||||
return $Default
|
||||
}
|
||||
|
||||
# Graph enum members and the portal's language ids do not always agree for the
|
||||
# same platform. Where they differ, the portal ships the string under ITS id and
|
||||
# will never publish one under the Graph name, so the mapping belongs here rather
|
||||
# than in a request to the IntuneLanuageAndObjects pipeline.
|
||||
#
|
||||
# aosp deviceManagementConfigurationPlatforms member (Settings Catalog).
|
||||
# The portal files it as androidAOSP - "Android (AOSP)" - and uses the
|
||||
# same wording elsewhere (PolicyType.aospDeviceOwnerCompliancePolicy is
|
||||
# "Android (AOSP) compliance policy").
|
||||
#
|
||||
# Only add an entry when the target key genuinely describes the SAME platform.
|
||||
# Members with no equivalent string at all (windowsPhone81, the
|
||||
# *MobileApplicationManagement pseudo-platforms, none / unknownFutureValue) are
|
||||
# left to render blank and are reported by Tools/Audit-PolicyPlatforms.ps1.
|
||||
$script:PlatformLanguageIdAliases = @{
|
||||
'aosp' = 'androidAOSP'
|
||||
}
|
||||
|
||||
# Platform label for an @odata.type that Config/ObjectCategories.json has no row
|
||||
# for. Consulted ONLY as a fallback, so a row shipped by the language pipeline
|
||||
# later automatically wins and this table quietly becomes redundant.
|
||||
#
|
||||
# The app / app-protection entries use the generic AppResources.AppTypePlatform
|
||||
# namespace rather than Platform.*, on purpose: Platform.* renders enrollment
|
||||
# flavours ("Android device administrator", "Android Enterprise"), which is the
|
||||
# wording the portal uses on the CREATE blade. When LISTING these policies the
|
||||
# portal says plain "Android" - and a list is what this feeds.
|
||||
$script:PolicyPlatformOverrides = @{
|
||||
# Apps. Get-GraphApplicationPlatform matches a platform token inside the
|
||||
# @odata.type; these two carry none.
|
||||
'#microsoft.graph.officeSuiteApp' = 'AppResources.AppTypePlatform.windows'
|
||||
'#microsoft.graph.microsoftStoreForBusinessApp' = 'AppResources.AppTypePlatform.windows'
|
||||
|
||||
# App configuration for managed devices. androidManagedStoreAppConfiguration
|
||||
# is deliberately absent - it HAS an ObjectCategories row and already
|
||||
# resolves (as "Android Enterprise", from the generated data).
|
||||
'#microsoft.graph.iosMobileAppConfiguration' = 'AppResources.AppTypePlatform.ios'
|
||||
'#microsoft.graph.androidForWorkMobileAppConfiguration' = 'AppResources.AppTypePlatform.android'
|
||||
|
||||
# App protection. managedAppPolicies is polymorphic - one PolicyType serving
|
||||
# several platforms - so this cannot be expressed as a type-level default.
|
||||
'#microsoft.graph.androidManagedAppProtection' = 'AppResources.AppTypePlatform.android'
|
||||
'#microsoft.graph.iosManagedAppProtection' = 'AppProtection.iOSPlatformLabel'
|
||||
'#microsoft.graph.windowsManagedAppProtection' = 'AppResources.AppTypePlatform.windows'
|
||||
'#microsoft.graph.mdmWindowsInformationProtectionPolicy' = 'AppResources.AppTypePlatform.windows'
|
||||
|
||||
# MAM app configuration targets iOS and Android apps from one policy, so no
|
||||
# single platform applies.
|
||||
'#microsoft.graph.targetedManagedAppConfiguration' = 'Platform.multiplePlatforms'
|
||||
}
|
||||
|
||||
# Keys the portal does not publish. The Platform column is UI, and this project
|
||||
# keeps UI text in English (documentation is what gets localized), so an English
|
||||
# literal is the convention - same shape as the iOS/iPadOS fallback in
|
||||
# IntuneApplicationClasses. If the pipeline ever ships the key, it wins.
|
||||
$script:PlatformStringFallbacks = @{
|
||||
'Platform.multiplePlatforms' = 'Multiple platforms'
|
||||
}
|
||||
|
||||
function Get-PlatformStringByKey
|
||||
{
|
||||
param([string]$Key)
|
||||
|
||||
if([String]::IsNullOrWhiteSpace($Key)) { return $null }
|
||||
|
||||
$name = Get-LanguageString $Key -IgnoreMissing
|
||||
if($name) { return $name }
|
||||
|
||||
if($script:PlatformStringFallbacks.ContainsKey($Key)) { return $script:PlatformStringFallbacks[$Key] }
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# The override for one @odata.type, or $null when there is none.
|
||||
function Get-PolicyPlatformOverride
|
||||
{
|
||||
param([string]$ODataType)
|
||||
|
||||
if([String]::IsNullOrWhiteSpace($ODataType)) { return $null }
|
||||
if(-not $script:PolicyPlatformOverrides.ContainsKey($ODataType)) { return $null }
|
||||
|
||||
return Get-PlatformStringByKey $script:PolicyPlatformOverrides[$ODataType]
|
||||
}
|
||||
|
||||
# Translate a platform enum value returned by Graph.
|
||||
#
|
||||
# Several endpoints return a comma-separated flags value rather than a single
|
||||
# member - configurationPolicies hands back "android,iOS" for a policy that
|
||||
# targets both - and there is no language key for the combined string, so the
|
||||
# naive lookup returned nothing and the Platform column rendered blank.
|
||||
# Translate each member and join.
|
||||
#
|
||||
# Returns $null unless EVERY member translates: a partially resolved value would
|
||||
# render as a misleadingly narrow platform (showing just "iOS/iPadOS" for an
|
||||
# android+iOS policy), and a blank is honest. Tools/Audit-PolicyPlatforms.ps1
|
||||
# reports what is still blank and why.
|
||||
function Get-PlatformDisplayName
|
||||
{
|
||||
param([string]$Value)
|
||||
|
||||
if([String]::IsNullOrWhiteSpace($Value)) { return $null }
|
||||
|
||||
$parts = @($Value.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
if($parts.Count -eq 0) { return $null }
|
||||
|
||||
$names = @()
|
||||
foreach($part in $parts)
|
||||
{
|
||||
# Hashtable lookup is case-insensitive, which is what we want - the same
|
||||
# platform appears as both 'aosp' and 'AOSP' across Graph payloads.
|
||||
$key = if($script:PlatformLanguageIdAliases.ContainsKey($part)) { $script:PlatformLanguageIdAliases[$part] } else { $part }
|
||||
|
||||
$name = Get-LanguageString "Platform.$key" -IgnoreMissing
|
||||
if(-not $name) { return $null }
|
||||
$names += $name
|
||||
}
|
||||
|
||||
return ($names -join ", ")
|
||||
}
|
||||
|
||||
function Get-PolicyPlatformName
|
||||
{
|
||||
param($ODataType, $Default = $null)
|
||||
|
||||
$categoryObject = Get-PolicyObjectCategoryInfo $ODataType
|
||||
|
||||
if($null -eq $categoryObject)
|
||||
{
|
||||
# No generated row - fall back to the hand-maintained overrides.
|
||||
$override = Get-PolicyPlatformOverride $ODataType
|
||||
if($override) { return $override }
|
||||
|
||||
return $Default
|
||||
}
|
||||
|
||||
$platformName = Get-LanguageString "Platform.$($categoryObject.PlatformLanguageId)"
|
||||
|
||||
if($platformName) { return $platformName }
|
||||
|
||||
# Row exists but its PlatformLanguageId does not translate.
|
||||
$override = Get-PolicyPlatformOverride $ODataType
|
||||
if($override) { return $override }
|
||||
|
||||
return $Default
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
# MS Graph error-response + request helpers extracted from Public/Invoke-MSGraphAPI.ps1
|
||||
# (architecture R11 - keep the public cmdlet file to just the public function).
|
||||
# Module-internal, not exported.
|
||||
|
||||
# Wraps Invoke-MgGraphRequest so its response looks like Invoke-WebRequest's response —
|
||||
# letting Invoke-MSGraphAPI use either backend without different downstream handling.
|
||||
# Used when MgGraph is the active provider but the raw bearer token couldn't be
|
||||
# extracted (SDK v2 in-memory cache is opaque to us). The SDK handles auth itself.
|
||||
function Invoke-MgGraphRequestAsWebResponse {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Url,
|
||||
[string]$Method = 'GET',
|
||||
$Body,
|
||||
[hashtable]$Headers
|
||||
)
|
||||
|
||||
$mgParams = @{
|
||||
Method = $Method
|
||||
Uri = $Url
|
||||
OutputType = 'Json'
|
||||
ErrorAction = 'Stop'
|
||||
}
|
||||
|
||||
if ($Body) {
|
||||
$mgParams['Body'] = $Body
|
||||
if ($Method -ne 'GET') { $mgParams['ContentType'] = 'application/json' }
|
||||
}
|
||||
|
||||
# Drop headers the SDK manages itself (Authorization is added by the auth
|
||||
# handler; Content-Type is set above). Pass through the rest so things like
|
||||
# x-ms-client-request-id survive for trace correlation.
|
||||
if ($Headers) {
|
||||
$cleanHeaders = @{}
|
||||
foreach ($k in $Headers.Keys) {
|
||||
if ($k -notin 'Authorization', 'Content-Type', 'Content-Length') {
|
||||
$cleanHeaders[$k] = $Headers[$k]
|
||||
}
|
||||
}
|
||||
if ($cleanHeaders.Count -gt 0) { $mgParams['Headers'] = $cleanHeaders }
|
||||
}
|
||||
|
||||
$mgStatusCode = $null
|
||||
$mgParams['StatusCodeVariable'] = 'mgStatusCode'
|
||||
|
||||
$contentStr = Invoke-MgGraphRequest @mgParams
|
||||
|
||||
$sc = if ($mgStatusCode) { [int]$mgStatusCode } else { 200 }
|
||||
[PSCustomObject]@{
|
||||
StatusCode = $sc
|
||||
StatusDescription = if ($sc -ge 200 -and $sc -lt 300) { 'OK' } else { 'Error' }
|
||||
Content = [string]$contentStr
|
||||
RawContentLength = if ($contentStr) { [long]([string]$contentStr).Length } else { [long]0 }
|
||||
Headers = @{}
|
||||
}
|
||||
}
|
||||
|
||||
function Read-MSGraphErrorResponseContent {
|
||||
param($ErrorRecord)
|
||||
|
||||
$response = $ErrorRecord.Exception.Response
|
||||
if($response) {
|
||||
try {
|
||||
if($response.PSObject.Methods['GetResponseStream']) {
|
||||
$reader = New-Object System.IO.StreamReader($response.GetResponseStream())
|
||||
try {
|
||||
$reader.BaseStream.Position = 0
|
||||
$reader.DiscardBufferedData()
|
||||
return $reader.ReadToEnd()
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try {
|
||||
if($response.Content -and $response.Content.PSObject.Methods['ReadAsStringAsync']) {
|
||||
return [string]$response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
return [string]$ErrorRecord.ErrorDetails.Message
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-MSGraphErrorHeaderValue {
|
||||
param(
|
||||
$Response,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if(-not $Response -or -not $Name) { return $null }
|
||||
|
||||
try {
|
||||
if($Response.Headers) {
|
||||
if($Response.Headers -is [System.Collections.IDictionary] -and $Response.Headers.Contains($Name)) {
|
||||
return [string]$Response.Headers[$Name]
|
||||
}
|
||||
|
||||
# PS5.1's WebException exposes a WebHeaderCollection, which derives from
|
||||
# NameValueCollection - neither IDictionary nor TryGetValues. Without this
|
||||
# branch every header lookup silently returned $null on 5.1.
|
||||
if($Response.Headers -is [System.Collections.Specialized.NameValueCollection]) {
|
||||
$nvValue = $Response.Headers[$Name]
|
||||
if($null -ne $nvValue) { return [string]$nvValue }
|
||||
}
|
||||
|
||||
$values = $null
|
||||
if($Response.Headers.PSObject.Methods['TryGetValues'] -and $Response.Headers.TryGetValues($Name, [ref]$values)) {
|
||||
return [string](@($values) -join ',')
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Multi Admin Approval (MAA) support.
|
||||
#
|
||||
# Since June 2026 MAA also intercepts app-auth (client-credentials) calls, not just
|
||||
# interactive admin actions. When the target resource is covered by an access policy,
|
||||
# any POST/PATCH/PUT/DELETE is held for a second admin's approval. GET is never
|
||||
# affected. The round trip Microsoft documents is:
|
||||
#
|
||||
# 1. Send the write with an 'x-msft-approval-justification' header (base64).
|
||||
# 2. Graph answers HTTP 412 Precondition Failed, outer error code "BadRequest",
|
||||
# and an 'x-msft-approval-code' header. That is NOT a failure - it means the
|
||||
# approval request was created and is waiting for a human.
|
||||
# 3. Poll deviceManagement/operationApprovalRequests until status is 'approved'.
|
||||
# 4. Re-send the identical request with 'x-msft-approval-code' instead of the
|
||||
# justification header.
|
||||
#
|
||||
# Omitting the justification header entirely gives HTTP 400 instead. A plain 403 is
|
||||
# an ordinary permission problem and is classified separately so we don't blame MAA
|
||||
# for a missing Graph scope.
|
||||
#
|
||||
# The approval code is returned in three places and PS5.1's WebException header
|
||||
# access is unreliable, so probe all of them: the response header, the "HttpHeaders"
|
||||
# field inside the nested error body, then the message text itself.
|
||||
$script:MSGraphApprovalCodeHeader = 'x-msft-approval-code'
|
||||
$script:MSGraphApprovalJustifyHeader = 'x-msft-approval-justification'
|
||||
|
||||
function ConvertTo-MSGraphApprovalJustification
|
||||
{
|
||||
param([string]$Justification)
|
||||
|
||||
if([String]::IsNullOrWhiteSpace($Justification)) { return $null }
|
||||
return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Justification))
|
||||
}
|
||||
|
||||
function Get-MSGraphApprovalCodeFromError
|
||||
{
|
||||
param($Response, $GraphError)
|
||||
|
||||
$code = Get-MSGraphErrorHeaderValue $Response $script:MSGraphApprovalCodeHeader
|
||||
if($code) { return $code.Trim() }
|
||||
|
||||
# The nested body carries the same header as an escaped JSON string, so the
|
||||
# quotes arrive as \" rather than " - the pattern has to accept both forms.
|
||||
if($GraphError -and $GraphError.Content)
|
||||
{
|
||||
$raw = $null
|
||||
try { $raw = $GraphError.Content.error.message } catch { }
|
||||
if(-not $raw) { $raw = $GraphError.RawContent }
|
||||
if($raw)
|
||||
{
|
||||
$m = [regex]::Match([string]$raw, '\\?"x-msft-approval-code\\?"\s*:\s*\\?"([0-9a-fA-F-]{36})')
|
||||
if($m.Success) { return $m.Groups[1].Value }
|
||||
}
|
||||
}
|
||||
|
||||
foreach($text in @($GraphError.Message, $GraphError.RawContent))
|
||||
{
|
||||
if(-not $text) { continue }
|
||||
$m = [regex]::Match([string]$text, 'x-msft-approval-code:\s*([0-9a-fA-F-]{36})')
|
||||
if($m.Success) { return $m.Groups[1].Value }
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-MSGraphApprovalInfo
|
||||
{
|
||||
param($ErrorRecord, $GraphError, [string]$HttpMethod, [string]$Url)
|
||||
|
||||
$info = [PSCustomObject]@{
|
||||
StatusCode = $null
|
||||
IsApprovalPending = $false
|
||||
IsJustificationNeeded = $false
|
||||
IsForbidden = $false
|
||||
ApprovalCode = $null
|
||||
Advice = $null
|
||||
}
|
||||
|
||||
$response = $null
|
||||
try { $response = $ErrorRecord.Exception.Response } catch { }
|
||||
|
||||
$status = $null
|
||||
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { }
|
||||
if($null -eq $status) { return $info }
|
||||
$info.StatusCode = $status
|
||||
|
||||
if($status -notin @(400, 403, 412)) { return $info }
|
||||
|
||||
# GET is never gated by MAA, so anything here is an ordinary error.
|
||||
$isWrite = $HttpMethod -and $HttpMethod.ToUpperInvariant() -in @('POST','PATCH','PUT','DELETE')
|
||||
|
||||
if($status -eq 412)
|
||||
{
|
||||
$code = Get-MSGraphApprovalCodeFromError $response $GraphError
|
||||
if($code)
|
||||
{
|
||||
$info.IsApprovalPending = $true
|
||||
$info.ApprovalCode = $code
|
||||
$info.Advice = "Multi Admin Approval: the request was accepted and is waiting for another administrator to approve it. Approval code $code. Approve it in the Intune portal under Tenant administration > Multi Admin Approval, then run the operation again."
|
||||
}
|
||||
return $info
|
||||
}
|
||||
|
||||
if($status -eq 403)
|
||||
{
|
||||
$info.IsForbidden = $true
|
||||
$info.Advice = "Access denied. The signed-in identity lacks the Graph permission or Intune role needed for this operation. If the tenant uses Multi Admin Approval, check whether this application is excluded from the access policy."
|
||||
return $info
|
||||
}
|
||||
|
||||
# 400 - only treat as MAA when the body actually names the justification header.
|
||||
$body = "$($GraphError.Message) $($GraphError.RawContent)"
|
||||
if($isWrite -and $body -match [regex]::Escape($script:MSGraphApprovalJustifyHeader))
|
||||
{
|
||||
$info.IsJustificationNeeded = $true
|
||||
$info.Advice = "Multi Admin Approval requires a justification for this change. Set the 'Multi Admin Approval justification' setting and retry - the request is then held for a second administrator to approve."
|
||||
}
|
||||
|
||||
return $info
|
||||
}
|
||||
|
||||
function ConvertFrom-MSGraphErrorResponse {
|
||||
param($ErrorRecord)
|
||||
|
||||
$response = $ErrorRecord.Exception.Response
|
||||
$rawContent = Read-MSGraphErrorResponseContent $ErrorRecord
|
||||
$parsedContent = $null
|
||||
$graphError = $null
|
||||
$innerError = $null
|
||||
$message = $null
|
||||
$code = $null
|
||||
$requestId = $null
|
||||
$clientRequestId = $null
|
||||
$date = $null
|
||||
|
||||
if($rawContent) {
|
||||
try {
|
||||
$parsedContent = $rawContent | ConvertFrom-Json -ErrorAction Stop
|
||||
if($parsedContent.error) {
|
||||
$graphError = $parsedContent.error
|
||||
$code = $graphError.code
|
||||
$message = $graphError.message
|
||||
$innerError = $graphError.innerError
|
||||
}
|
||||
elseif($parsedContent.code -or $parsedContent.message) {
|
||||
$graphError = $parsedContent
|
||||
$code = $parsedContent.code
|
||||
$message = $parsedContent.message
|
||||
$innerError = $parsedContent.innerError
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$message = $rawContent
|
||||
}
|
||||
}
|
||||
|
||||
if($message -and $message.StartsWith("{") -and $message.EndsWith("}")) {
|
||||
try {
|
||||
$nested = $message | ConvertFrom-Json -ErrorAction Stop
|
||||
if($nested.Message) { $message = $nested.Message }
|
||||
elseif($nested.message) { $message = $nested.message }
|
||||
if(-not $code -and $nested.Code) { $code = $nested.Code }
|
||||
if(-not $code -and $nested.code) { $code = $nested.code }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if($innerError) {
|
||||
$requestId = $innerError.'request-id'
|
||||
if(-not $requestId) { $requestId = $innerError.requestId }
|
||||
$clientRequestId = $innerError.'client-request-id'
|
||||
if(-not $clientRequestId) { $clientRequestId = $innerError.clientRequestId }
|
||||
$date = $innerError.date
|
||||
}
|
||||
|
||||
if(-not $requestId) { $requestId = Get-MSGraphErrorHeaderValue $response 'request-id' }
|
||||
if(-not $clientRequestId) { $clientRequestId = Get-MSGraphErrorHeaderValue $response 'client-request-id' }
|
||||
if(-not $date) { $date = Get-MSGraphErrorHeaderValue $response 'Date' }
|
||||
|
||||
[PSCustomObject]@{
|
||||
Code = $code
|
||||
Message = $message
|
||||
RequestId = $requestId
|
||||
ClientRequestId = $clientRequestId
|
||||
Date = $date
|
||||
RawContent = $rawContent
|
||||
Content = $parsedContent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
# Minimal Markdown reader for the Release Notes dialog.
|
||||
#
|
||||
# Deliberately NOT a Markdown implementation. It handles exactly what
|
||||
# ReleaseNotes.md actually contains, which is: one '#' heading, 42 '##' headings,
|
||||
# 555 bullets nested up to three levels, 247 '**bold**' spans, 163 '[text](url)'
|
||||
# links, 652 '<br />' tags and 6 '<b>' pairs. No numbered lists, code fences,
|
||||
# tables, blockquotes or rules appear in the file - numbered lists are supported
|
||||
# anyway because it costs one regex and future notes may use them.
|
||||
#
|
||||
# Output is UI-toolkit-free so WPF and Avalonia can share it (R10/R12): a flat
|
||||
# list of blocks, each carrying inline runs. Both backends render into a
|
||||
# TextBlock, which supports per-run FontSize/FontWeight, so a single control can
|
||||
# present the whole document.
|
||||
#
|
||||
# Block : Kind = Heading | ListItem | Paragraph | Blank
|
||||
# Level (Heading: 1-6)
|
||||
# Indent (ListItem: nesting depth, 0-based)
|
||||
# Marker (ListItem: '-' for bullets, or the literal '1.' style number)
|
||||
# Inlines
|
||||
# Inline: Text, Bold, LineBreak
|
||||
|
||||
# Split one line of markdown into inline runs.
|
||||
function ConvertFrom-SimpleMarkdownInline {
|
||||
param([string]$Text)
|
||||
|
||||
$inlines = [System.Collections.Generic.List[object]]::new()
|
||||
if ($null -eq $Text) { return $inlines }
|
||||
|
||||
# One pass over the constructs that can appear mid-line. Alternation order
|
||||
# matters: '<br />' before '<b>' so the break is not mistaken for bold, and
|
||||
# `code` before the rest so markup inside a code span stays literal.
|
||||
$pattern = '(?<br><br\s*/?>)|(?<code>`(?<ctext>[^`]+)`)|(?<bold>\*\*(?<b1>.+?)\*\*)|(?<htmlbold><b>(?<b2>.*?)</b>)|(?<link>\[(?<ltext>[^\]]+)\]\((?<lurl>[^)]*)\))'
|
||||
$pos = 0
|
||||
|
||||
foreach ($m in [regex]::Matches($Text, $pattern)) {
|
||||
if ($m.Index -gt $pos) {
|
||||
$inlines.Add((New-SimpleMarkdownInline -Text $Text.Substring($pos, $m.Index - $pos)))
|
||||
}
|
||||
|
||||
if ($m.Groups['br'].Success) {
|
||||
$inlines.Add((New-SimpleMarkdownInline -LineBreak))
|
||||
}
|
||||
elseif ($m.Groups['code'].Success) {
|
||||
# Code spans are literal: no recursion, so **stars** and [links] inside
|
||||
# them survive as typed.
|
||||
$inlines.Add((New-SimpleMarkdownInline -Text $m.Groups['ctext'].Value -Code))
|
||||
}
|
||||
elseif ($m.Groups['bold'].Success -or $m.Groups['htmlbold'].Success) {
|
||||
# Recurse into the bold text so constructs nested inside it are still
|
||||
# handled - the file has a bold bullet containing a link, which would
|
||||
# otherwise render with its raw [text](url) markup showing. The inner
|
||||
# match is non-greedy and cannot contain another bold opener, so this
|
||||
# terminates after one level.
|
||||
$boldText = if ($m.Groups['bold'].Success) { $m.Groups['b1'].Value } else { $m.Groups['b2'].Value }
|
||||
foreach ($inner in (ConvertFrom-SimpleMarkdownInline $boldText)) {
|
||||
$inner.Bold = $true
|
||||
$inlines.Add($inner)
|
||||
}
|
||||
}
|
||||
elseif ($m.Groups['link'].Success) {
|
||||
$inlines.Add((New-SimpleMarkdownInline -Text $m.Groups['ltext'].Value -Url $m.Groups['lurl'].Value))
|
||||
}
|
||||
|
||||
$pos = $m.Index + $m.Length
|
||||
}
|
||||
|
||||
if ($pos -lt $Text.Length) {
|
||||
$inlines.Add((New-SimpleMarkdownInline -Text $Text.Substring($pos)))
|
||||
}
|
||||
|
||||
return $inlines
|
||||
}
|
||||
|
||||
# One inline run. Kept as a factory so every producer emits the same shape and a
|
||||
# renderer can rely on every field existing.
|
||||
function New-SimpleMarkdownInline {
|
||||
param(
|
||||
[string]$Text = '',
|
||||
[switch]$Bold,
|
||||
[switch]$Code,
|
||||
[switch]$LineBreak,
|
||||
[string]$Url = $null
|
||||
)
|
||||
return [PSCustomObject]@{
|
||||
Text = $Text
|
||||
Bold = [bool]$Bold
|
||||
Code = [bool]$Code
|
||||
LineBreak = [bool]$LineBreak
|
||||
Url = $Url
|
||||
}
|
||||
}
|
||||
|
||||
# Parse a markdown document into renderable blocks.
|
||||
function ConvertFrom-SimpleMarkdown {
|
||||
[CmdletBinding()]
|
||||
param([string]$Markdown)
|
||||
|
||||
$blocks = [System.Collections.Generic.List[object]]::new()
|
||||
if ([string]::IsNullOrEmpty($Markdown)) { return $blocks }
|
||||
|
||||
$inCodeFence = $false
|
||||
|
||||
foreach ($line in ($Markdown -split "`r?`n")) {
|
||||
|
||||
# Fenced code. The opening fence may carry a language tag; it is read and
|
||||
# discarded - no syntax handling, the block is styled as plain monospace.
|
||||
$fence = [regex]::Match($line, '^\s*(```|~~~)\s*(\S*)\s*$')
|
||||
if ($fence.Success) {
|
||||
$inCodeFence = -not $inCodeFence
|
||||
continue
|
||||
}
|
||||
if ($inCodeFence) {
|
||||
# Literal: no inline parsing, so markup inside code stays as typed.
|
||||
$blocks.Add([PSCustomObject]@{
|
||||
Kind = 'CodeBlock'; Level = 0; Indent = 0; Marker = ''
|
||||
Inlines = @((New-SimpleMarkdownInline -Text $line -Code))
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||
$blocks.Add([PSCustomObject]@{ Kind = 'Blank'; Level = 0; Indent = 0; Marker = ''; Inlines = @() })
|
||||
continue
|
||||
}
|
||||
|
||||
$quote = [regex]::Match($line, '^\s*>\s?(.*)$')
|
||||
if ($quote.Success) {
|
||||
$blocks.Add([PSCustomObject]@{
|
||||
Kind = 'Quote'; Level = 0; Indent = 0; Marker = ''
|
||||
Inlines = (ConvertFrom-SimpleMarkdownInline $quote.Groups[1].Value)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
$heading = [regex]::Match($line, '^(#{1,6})\s+(.*)$')
|
||||
if ($heading.Success) {
|
||||
$blocks.Add([PSCustomObject]@{
|
||||
Kind = 'Heading'
|
||||
Level = $heading.Groups[1].Value.Length
|
||||
Indent = 0
|
||||
Marker = ''
|
||||
Inlines = (ConvertFrom-SimpleMarkdownInline $heading.Groups[2].Value)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
$bullet = [regex]::Match($line, '^(\s*)[-*+]\s+(.*)$')
|
||||
if ($bullet.Success) {
|
||||
# The file indents nested bullets by 2 spaces (with one stray 1-space
|
||||
# and a 4-space third level), so integer-divide by 2 and let the odd
|
||||
# one fall to level 0.
|
||||
$blocks.Add([PSCustomObject]@{
|
||||
Kind = 'ListItem'
|
||||
Level = 0
|
||||
Indent = [int]([Math]::Floor($bullet.Groups[1].Value.Length / 2))
|
||||
Marker = '-'
|
||||
Inlines = (ConvertFrom-SimpleMarkdownInline $bullet.Groups[2].Value)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
$numbered = [regex]::Match($line, '^(\s*)(\d+)[\.\)]\s+(.*)$')
|
||||
if ($numbered.Success) {
|
||||
$blocks.Add([PSCustomObject]@{
|
||||
Kind = 'ListItem'
|
||||
Level = 0
|
||||
Indent = [int]([Math]::Floor($numbered.Groups[1].Value.Length / 2))
|
||||
Marker = "$($numbered.Groups[2].Value)."
|
||||
Inlines = (ConvertFrom-SimpleMarkdownInline $numbered.Groups[3].Value)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
$blocks.Add([PSCustomObject]@{
|
||||
Kind = 'Paragraph'
|
||||
Level = 0
|
||||
Indent = 0
|
||||
Marker = ''
|
||||
Inlines = (ConvertFrom-SimpleMarkdownInline $line.Trim())
|
||||
})
|
||||
}
|
||||
|
||||
return $blocks
|
||||
}
|
||||
|
||||
# Presentation constants shared by both renderers, so WPF and Avalonia produce
|
||||
# the same look. Colours are given as ARGB hex.
|
||||
#
|
||||
# CodeBackground is a low-alpha grey rather than a fixed light or dark colour:
|
||||
# both backends have light and dark themes, and a translucent grey reads as a
|
||||
# "slightly different background" over either without needing theme lookups.
|
||||
function Get-SimpleMarkdownStyle {
|
||||
return @{
|
||||
CodeBackground = '#22808080'
|
||||
CodeFontFamily = 'Consolas, Cascadia Mono, Courier New, monospace'
|
||||
QuoteMarker = '| '
|
||||
}
|
||||
}
|
||||
|
||||
# Font size for a heading level, relative to the control's base size. Shared so
|
||||
# both backends size headings identically.
|
||||
function Get-SimpleMarkdownHeadingSize {
|
||||
param([int]$Level, [double]$BaseSize = 12)
|
||||
|
||||
switch ($Level) {
|
||||
1 { return $BaseSize * 1.6 }
|
||||
2 { return $BaseSize * 1.3 }
|
||||
3 { return $BaseSize * 1.15 }
|
||||
default { return $BaseSize * 1.05 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
# Offline Graph for the mock tenant.
|
||||
#
|
||||
# Serves Microsoft Graph requests from JSON files on disk so the whole
|
||||
# application can run - sign in, browse, view, copy, import, delete, document -
|
||||
# with no tenant and no network. Used for screenshots, demos and UI work on a
|
||||
# train. Wired in through the AuthenticationMock provider
|
||||
# (Classes/AuthenticationMock.ps1), which hands every request here via the
|
||||
# RoutesAllRequests capability; nothing else in the product knows about it.
|
||||
#
|
||||
# Data folder layout (IM_MOCK_DATA):
|
||||
#
|
||||
# settings.json settings store for the session (IM_SETTINGS_FILE)
|
||||
# tenant.json TenantId, TenantName, UPN, DisplayName, UserId
|
||||
# graph/<path>.json one file per Graph path, e.g.
|
||||
# graph/organization.json { "value": [ {...} ] } a collection
|
||||
# graph/me.json { ... } a single object
|
||||
# graph/deviceManagement/deviceConfigurations.json
|
||||
#
|
||||
# A collection file's items are addressable as <path>/<id> and <path>/<id>/<prop>
|
||||
# (assignments, settings, ...), $filter / $expand / $top are honoured on list
|
||||
# calls, $batch is unpacked, and POST / PATCH / DELETE mutate the in-memory copy
|
||||
# so Copy, Import and Delete work for the length of the session. The files on
|
||||
# disk are never written to.
|
||||
#
|
||||
# Everything unknown answers 200 with an empty collection rather than 404:
|
||||
# the product treats an empty list as "nothing here", which is the right
|
||||
# rendering for a path the mock data simply does not cover.
|
||||
|
||||
$script:MockGraphStore = $null
|
||||
|
||||
# Load (or reload) the data folder. Keys are Graph paths without a leading
|
||||
# slash: 'deviceManagement/deviceConfigurations'.
|
||||
function Initialize-MockGraphStore {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][string]$Root)
|
||||
|
||||
$graphRoot = Join-Path $Root "graph"
|
||||
$store = @{
|
||||
Root = $Root
|
||||
Collections = @{} # path -> List[object]
|
||||
Singles = @{} # path -> object
|
||||
}
|
||||
if(Test-Path -LiteralPath $graphRoot -PathType Container) {
|
||||
foreach($file in (Get-ChildItem -LiteralPath $graphRoot -Filter *.json -Recurse -File)) {
|
||||
$rel = $file.FullName.Substring($graphRoot.Length).TrimStart('\', '/')
|
||||
$key = ($rel -replace '\.json$', '') -replace '\\', '/'
|
||||
try {
|
||||
$data = [IO.File]::ReadAllText($file.FullName) | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
Write-Log "Mock Graph: cannot parse $($file.FullName): $($_.Exception.Message)" 3
|
||||
continue
|
||||
}
|
||||
if($data.PSObject.Properties['value'] -and $data.value -is [array]) {
|
||||
$list = [System.Collections.Generic.List[object]]::new()
|
||||
foreach($item in $data.value) { $list.Add($item) }
|
||||
$store.Collections[$key] = $list
|
||||
}
|
||||
else {
|
||||
$store.Singles[$key] = $data
|
||||
}
|
||||
}
|
||||
}
|
||||
$script:MockGraphStore = $store
|
||||
Write-Log "Mock Graph: loaded $($store.Collections.Count) collections and $($store.Singles.Count) objects from $graphRoot"
|
||||
return $store
|
||||
}
|
||||
|
||||
function Get-MockGraphStore {
|
||||
return $script:MockGraphStore
|
||||
}
|
||||
|
||||
# Split an absolute or relative Graph URL into its path (no host, no version,
|
||||
# no leading slash) and a query hashtable with unescaped values.
|
||||
function Split-MockGraphUrl {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][string]$Url)
|
||||
|
||||
$path = $Url
|
||||
$query = ""
|
||||
$q = $path.IndexOf('?')
|
||||
if($q -ge 0) { $query = $path.Substring($q + 1); $path = $path.Substring(0, $q) }
|
||||
$path = $path -replace '^https?://[^/]+/', ''
|
||||
$path = $path -replace '^(beta|v1\.0)/', ''
|
||||
$path = $path.Trim('/')
|
||||
$path = [Uri]::UnescapeDataString($path)
|
||||
|
||||
$params = @{}
|
||||
if($query) {
|
||||
foreach($pair in $query.Split('&')) {
|
||||
if(-not $pair) { continue }
|
||||
$eq = $pair.IndexOf('=')
|
||||
if($eq -lt 0) { $params[[Uri]::UnescapeDataString($pair)] = ""; continue }
|
||||
$name = [Uri]::UnescapeDataString($pair.Substring(0, $eq))
|
||||
$value = [Uri]::UnescapeDataString($pair.Substring($eq + 1).Replace('+', ' '))
|
||||
$params[$name] = $value
|
||||
}
|
||||
}
|
||||
return [PSCustomObject]@{ Path = $path; Query = $params }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OData $filter - the subset the policy types actually send:
|
||||
# a eq 'x' a ne 'x' a eq true isof('microsoft.graph.t')
|
||||
# contains(tolower(a),'x') startswith(a,'x') endswith(a,'x')
|
||||
# and / or / not, parentheses, cast segments (microsoft.graph.t/prop)
|
||||
# Anything the parser cannot read matches everything (logged once), which
|
||||
# keeps a list visible rather than empty when a new clause shape appears.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function ConvertTo-MockFilterTokens {
|
||||
param([string]$Filter)
|
||||
$tokens = [System.Collections.Generic.List[object]]::new()
|
||||
$i = 0
|
||||
$n = $Filter.Length
|
||||
while($i -lt $n) {
|
||||
$c = $Filter[$i]
|
||||
if([char]::IsWhiteSpace($c)) { $i++; continue }
|
||||
if($c -eq '(' -or $c -eq ')' -or $c -eq ',') {
|
||||
$tokens.Add([PSCustomObject]@{ Type = 'punct'; Value = [string]$c }); $i++; continue
|
||||
}
|
||||
if($c -eq "'") {
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
$i++
|
||||
while($i -lt $n) {
|
||||
if($Filter[$i] -eq "'") {
|
||||
if($i + 1 -lt $n -and $Filter[$i + 1] -eq "'") { [void]$sb.Append("'"); $i += 2; continue }
|
||||
break
|
||||
}
|
||||
[void]$sb.Append($Filter[$i]); $i++
|
||||
}
|
||||
$i++
|
||||
$tokens.Add([PSCustomObject]@{ Type = 'string'; Value = $sb.ToString() }); continue
|
||||
}
|
||||
$start = $i
|
||||
while($i -lt $n -and -not [char]::IsWhiteSpace($Filter[$i]) -and $Filter[$i] -notin @('(', ')', ',', "'")) { $i++ }
|
||||
$word = $Filter.Substring($start, $i - $start)
|
||||
$lower = $word.ToLowerInvariant()
|
||||
if($lower -in @('and', 'or', 'not', 'eq', 'ne', 'in')) { $tokens.Add([PSCustomObject]@{ Type = 'op'; Value = $lower }) }
|
||||
elseif($lower -eq 'true' -or $lower -eq 'false') { $tokens.Add([PSCustomObject]@{ Type = 'bool'; Value = ($lower -eq 'true') }) }
|
||||
elseif($lower -eq 'null') { $tokens.Add([PSCustomObject]@{ Type = 'null'; Value = $null }) }
|
||||
elseif($word -match '^-?\d+(\.\d+)?$') { $tokens.Add([PSCustomObject]@{ Type = 'number'; Value = [double]$word }) }
|
||||
else { $tokens.Add([PSCustomObject]@{ Type = 'ident'; Value = $word }) }
|
||||
}
|
||||
return ,$tokens
|
||||
}
|
||||
|
||||
# Resolve 'a/b/c' against an item. A segment that names a type
|
||||
# ('microsoft.graph.androidManagedStoreAppConfiguration') is a cast: it yields
|
||||
# $null unless the item is of that type.
|
||||
function Get-MockFilterPropertyValue {
|
||||
param($Item, [string]$Path)
|
||||
$current = $Item
|
||||
foreach($segment in $Path.Split('/')) {
|
||||
if($null -eq $current) { return $null }
|
||||
if($segment -like 'microsoft.graph.*') {
|
||||
$type = [string]$current.'@odata.type'
|
||||
if($type -ne "#$segment") { return $null }
|
||||
continue
|
||||
}
|
||||
$prop = $current.PSObject.Properties[$segment]
|
||||
if(-not $prop) { return $null }
|
||||
$current = $prop.Value
|
||||
}
|
||||
return $current
|
||||
}
|
||||
|
||||
# Recursive-descent evaluator. $State is @{ Tokens; Pos; Item }.
|
||||
function Invoke-MockFilterOr {
|
||||
param($State)
|
||||
$left = Invoke-MockFilterAnd $State
|
||||
while($State.Pos -lt $State.Tokens.Count -and $State.Tokens[$State.Pos].Type -eq 'op' -and $State.Tokens[$State.Pos].Value -eq 'or') {
|
||||
$State.Pos++
|
||||
$right = Invoke-MockFilterAnd $State
|
||||
$left = ($left -or $right)
|
||||
}
|
||||
return $left
|
||||
}
|
||||
|
||||
function Invoke-MockFilterAnd {
|
||||
param($State)
|
||||
$left = Invoke-MockFilterNot $State
|
||||
while($State.Pos -lt $State.Tokens.Count -and $State.Tokens[$State.Pos].Type -eq 'op' -and $State.Tokens[$State.Pos].Value -eq 'and') {
|
||||
$State.Pos++
|
||||
$right = Invoke-MockFilterNot $State
|
||||
$left = ($left -and $right)
|
||||
}
|
||||
return $left
|
||||
}
|
||||
|
||||
function Invoke-MockFilterNot {
|
||||
param($State)
|
||||
if($State.Pos -lt $State.Tokens.Count -and $State.Tokens[$State.Pos].Type -eq 'op' -and $State.Tokens[$State.Pos].Value -eq 'not') {
|
||||
$State.Pos++
|
||||
return (-not (Invoke-MockFilterNot $State))
|
||||
}
|
||||
return (Invoke-MockFilterComparison $State)
|
||||
}
|
||||
|
||||
# A value expression: literal, property path, or a function call. Returns the
|
||||
# value (not a boolean) - comparisons happen one level up.
|
||||
function Invoke-MockFilterValue {
|
||||
param($State)
|
||||
$token = $State.Tokens[$State.Pos]
|
||||
$State.Pos++
|
||||
switch($token.Type) {
|
||||
'string' { return $token.Value }
|
||||
'bool' { return $token.Value }
|
||||
'null' { return $null }
|
||||
'number' { return $token.Value }
|
||||
}
|
||||
if($token.Type -ne 'ident') { throw "Unexpected token '$($token.Value)'" }
|
||||
|
||||
$name = $token.Value
|
||||
$isCall = ($State.Pos -lt $State.Tokens.Count -and $State.Tokens[$State.Pos].Type -eq 'punct' -and $State.Tokens[$State.Pos].Value -eq '(')
|
||||
if(-not $isCall) { return (Get-MockFilterPropertyValue $State.Item $name) }
|
||||
|
||||
$State.Pos++ # (
|
||||
$args = [System.Collections.Generic.List[object]]::new()
|
||||
while($true) {
|
||||
$args.Add((Invoke-MockFilterValue $State))
|
||||
$next = $State.Tokens[$State.Pos]
|
||||
$State.Pos++
|
||||
if($next.Type -eq 'punct' -and $next.Value -eq ')') { break }
|
||||
if(-not ($next.Type -eq 'punct' -and $next.Value -eq ',')) { throw "Expected ',' or ')' in $name(...)" }
|
||||
}
|
||||
switch($name.ToLowerInvariant()) {
|
||||
'tolower' { return ([string]$args[0]).ToLowerInvariant() }
|
||||
'toupper' { return ([string]$args[0]).ToUpperInvariant() }
|
||||
'contains' { return (([string]$args[0]).IndexOf([string]$args[1], [StringComparison]::OrdinalIgnoreCase) -ge 0) }
|
||||
'startswith' { return (([string]$args[0]).StartsWith([string]$args[1], [StringComparison]::OrdinalIgnoreCase)) }
|
||||
'endswith' { return (([string]$args[0]).EndsWith([string]$args[1], [StringComparison]::OrdinalIgnoreCase)) }
|
||||
'isof' {
|
||||
$type = [string]$args[-1]
|
||||
return (([string]$State.Item.'@odata.type') -eq "#$type")
|
||||
}
|
||||
}
|
||||
throw "Unsupported filter function '$name'"
|
||||
}
|
||||
|
||||
function Invoke-MockFilterComparison {
|
||||
param($State)
|
||||
$token = $State.Tokens[$State.Pos]
|
||||
if($token.Type -eq 'punct' -and $token.Value -eq '(') {
|
||||
$State.Pos++
|
||||
$inner = Invoke-MockFilterOr $State
|
||||
$State.Pos++ # )
|
||||
return $inner
|
||||
}
|
||||
$left = Invoke-MockFilterValue $State
|
||||
if($State.Pos -lt $State.Tokens.Count -and $State.Tokens[$State.Pos].Type -eq 'op' -and $State.Tokens[$State.Pos].Value -eq 'in') {
|
||||
# id in ('a','b') - the shape the group / filter name lookups send.
|
||||
$State.Pos += 2 # in (
|
||||
$found = $false
|
||||
while($State.Pos -lt $State.Tokens.Count) {
|
||||
$token = $State.Tokens[$State.Pos]
|
||||
$State.Pos++
|
||||
if($token.Type -eq 'punct' -and $token.Value -eq ')') { break }
|
||||
if($token.Type -eq 'punct' -and $token.Value -eq ',') { continue }
|
||||
if([string]$token.Value -ieq [string]$left) { $found = $true }
|
||||
}
|
||||
return $found
|
||||
}
|
||||
if($State.Pos -lt $State.Tokens.Count -and $State.Tokens[$State.Pos].Type -eq 'op' -and $State.Tokens[$State.Pos].Value -in @('eq', 'ne')) {
|
||||
$op = $State.Tokens[$State.Pos].Value
|
||||
$State.Pos++
|
||||
$right = Invoke-MockFilterValue $State
|
||||
$equal = if($null -eq $left -or $null -eq $right) { ($null -eq $left) -and ($null -eq $right) }
|
||||
elseif($left -is [bool] -or $right -is [bool]) { [bool]$left -eq [bool]$right }
|
||||
elseif($left -is [double] -or $right -is [double]) { [double]$left -eq [double]$right }
|
||||
else { [string]$left -ieq [string]$right }
|
||||
if($op -eq 'eq') { return $equal } else { return (-not $equal) }
|
||||
}
|
||||
return [bool]$left
|
||||
}
|
||||
|
||||
$script:MockGraphFilterWarned = @{}
|
||||
|
||||
function Test-MockGraphFilter {
|
||||
[CmdletBinding()]
|
||||
param($Item, [string]$Filter)
|
||||
|
||||
if([string]::IsNullOrWhiteSpace($Filter)) { return $true }
|
||||
try {
|
||||
$state = @{ Tokens = (ConvertTo-MockFilterTokens $Filter); Pos = 0; Item = $Item }
|
||||
return [bool](Invoke-MockFilterOr $state)
|
||||
}
|
||||
catch {
|
||||
if(-not $script:MockGraphFilterWarned.ContainsKey($Filter)) {
|
||||
$script:MockGraphFilterWarned[$Filter] = $true
|
||||
Write-Log "Mock Graph: cannot evaluate `$filter '$Filter' ($($_.Exception.Message)) - returning every item" 2
|
||||
}
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Deep copy through JSON so a caller mutating the response never touches the store.
|
||||
function Copy-MockGraphObject {
|
||||
param($Object)
|
||||
if($null -eq $Object) { return $null }
|
||||
return ($Object | ConvertTo-Json -Depth 50 -Compress | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function New-MockGraphResult {
|
||||
param([int]$Status, $Body)
|
||||
return [PSCustomObject]@{ Status = $Status; Body = $Body }
|
||||
}
|
||||
|
||||
function Get-MockGraphItem {
|
||||
param($Collection, [string]$Id)
|
||||
foreach($item in $Collection) {
|
||||
if([string]$item.id -eq $Id) { return $item }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Longest known collection or single-object key that prefixes the path.
|
||||
# Returns @{ Key; Kind ('collection'|'single'); Rest (remaining segments) }.
|
||||
function Resolve-MockGraphPath {
|
||||
param([string]$Path)
|
||||
$store = $script:MockGraphStore
|
||||
$segments = @($Path.Split('/') | Where-Object { $_ -ne '' })
|
||||
for($take = $segments.Count; $take -ge 1; $take--) {
|
||||
$key = ($segments[0..($take - 1)] -join '/')
|
||||
$rest = if($take -lt $segments.Count) { @($segments[$take..($segments.Count - 1)]) } else { @() }
|
||||
if($store.Collections.ContainsKey($key)) { return @{ Key = $key; Kind = 'collection'; Rest = $rest } }
|
||||
if($store.Singles.ContainsKey($key)) { return @{ Key = $key; Kind = 'single'; Rest = $rest } }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Add-MockGraphExpansion {
|
||||
param($Item, [string]$Expand)
|
||||
if(-not $Expand) { return $Item }
|
||||
foreach($clause in $Expand.Split(',')) {
|
||||
$name = ($clause.Trim() -split '\(')[0].Trim()
|
||||
if(-not $name) { continue }
|
||||
if(-not $Item.PSObject.Properties[$name]) {
|
||||
$Item | Add-Member -NotePropertyName $name -NotePropertyValue @() -Force
|
||||
}
|
||||
}
|
||||
return $Item
|
||||
}
|
||||
|
||||
function Invoke-MockGraphList {
|
||||
param($Collection, [hashtable]$Query)
|
||||
$filter = $Query['$filter']
|
||||
$expand = $Query['$expand']
|
||||
$top = 0
|
||||
if($Query['$top']) { try { $top = [int]$Query['$top'] } catch { } }
|
||||
|
||||
$items = [System.Collections.Generic.List[object]]::new()
|
||||
foreach($item in $Collection) {
|
||||
if(-not (Test-MockGraphFilter -Item $item -Filter $filter)) { continue }
|
||||
$items.Add((Add-MockGraphExpansion (Copy-MockGraphObject $item) $expand))
|
||||
if($top -gt 0 -and $items.Count -ge $top) { break }
|
||||
}
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{ value = $items.ToArray() }))
|
||||
}
|
||||
|
||||
# Stamp the properties Graph fills in on create.
|
||||
function Set-MockGraphCreatedProperties {
|
||||
param($Item)
|
||||
$now = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ")
|
||||
if(-not $Item.PSObject.Properties['id'] -or -not $Item.id) {
|
||||
$Item | Add-Member -NotePropertyName id -NotePropertyValue ([guid]::NewGuid().ToString()) -Force
|
||||
}
|
||||
foreach($name in 'createdDateTime', 'lastModifiedDateTime') {
|
||||
$Item | Add-Member -NotePropertyName $name -NotePropertyValue $now -Force
|
||||
}
|
||||
return $Item
|
||||
}
|
||||
|
||||
function Invoke-MockGraphCollectionRequest {
|
||||
param([string]$Method, [string]$Key, [string[]]$Rest, [hashtable]$Query, $Body)
|
||||
$collection = $script:MockGraphStore.Collections[$Key]
|
||||
|
||||
if($Rest.Count -eq 0) {
|
||||
switch($Method) {
|
||||
'GET' { return (Invoke-MockGraphList -Collection $collection -Query $Query) }
|
||||
'POST' {
|
||||
$item = Set-MockGraphCreatedProperties (Copy-MockGraphObject $Body)
|
||||
$collection.Add($item)
|
||||
return (New-MockGraphResult 201 (Copy-MockGraphObject $item))
|
||||
}
|
||||
}
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{}))
|
||||
}
|
||||
|
||||
$id = $Rest[0]
|
||||
$item = Get-MockGraphItem -Collection $collection -Id $id
|
||||
if(-not $item) {
|
||||
Write-LogDebug "Mock Graph: $Method $Key/$id - no such item"
|
||||
if($Method -eq 'GET' -and $Rest.Count -gt 1) { return (New-MockGraphResult 200 ([PSCustomObject]@{ value = @() })) }
|
||||
return (New-MockGraphResult 404 ([PSCustomObject]@{ error = [PSCustomObject]@{ code = 'ResourceNotFound'; message = "Mock tenant has no $Key with id $id" } }))
|
||||
}
|
||||
|
||||
if($Rest.Count -eq 1) {
|
||||
switch($Method) {
|
||||
'GET' { return (New-MockGraphResult 200 (Add-MockGraphExpansion (Copy-MockGraphObject $item) $Query['$expand'])) }
|
||||
'PATCH' {
|
||||
foreach($prop in $Body.PSObject.Properties) {
|
||||
$item | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force
|
||||
}
|
||||
$item | Add-Member -NotePropertyName lastModifiedDateTime -NotePropertyValue ([DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ")) -Force
|
||||
return (New-MockGraphResult 200 (Copy-MockGraphObject $item))
|
||||
}
|
||||
'PUT' {
|
||||
foreach($prop in $Body.PSObject.Properties) {
|
||||
$item | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force
|
||||
}
|
||||
return (New-MockGraphResult 200 (Copy-MockGraphObject $item))
|
||||
}
|
||||
'DELETE' { [void]$collection.Remove($item); return (New-MockGraphResult 204 $null) }
|
||||
}
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{}))
|
||||
}
|
||||
|
||||
# <collection>/<id>/<segment>: a navigation property or an action.
|
||||
$segment = $Rest[1]
|
||||
switch($segment) {
|
||||
'assign' {
|
||||
# POST .../assign { assignments: [...] } replaces the assignments.
|
||||
$assignments = @()
|
||||
if($Body -and $Body.PSObject.Properties['assignments']) { $assignments = @($Body.assignments) }
|
||||
foreach($a in $assignments) {
|
||||
if(-not $a.PSObject.Properties['id'] -or -not $a.id) { $a | Add-Member -NotePropertyName id -NotePropertyValue ([guid]::NewGuid().ToString()) -Force }
|
||||
}
|
||||
$item | Add-Member -NotePropertyName assignments -NotePropertyValue $assignments -Force
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{ value = (Copy-MockGraphObject $assignments) }))
|
||||
}
|
||||
'assignments' {
|
||||
if($Method -eq 'POST') {
|
||||
$existing = @()
|
||||
if($item.PSObject.Properties['assignments']) { $existing = @($item.assignments) }
|
||||
$new = Copy-MockGraphObject $Body
|
||||
if(-not $new.PSObject.Properties['id'] -or -not $new.id) { $new | Add-Member -NotePropertyName id -NotePropertyValue ([guid]::NewGuid().ToString()) -Force }
|
||||
$item | Add-Member -NotePropertyName assignments -NotePropertyValue (@($existing) + @($new)) -Force
|
||||
return (New-MockGraphResult 201 (Copy-MockGraphObject $new))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$prop = $item.PSObject.Properties[$segment]
|
||||
if($Method -eq 'GET') {
|
||||
if(-not $prop) { return (New-MockGraphResult 200 ([PSCustomObject]@{ value = @() })) }
|
||||
$value = Copy-MockGraphObject $prop.Value
|
||||
if($null -eq $value) { return (New-MockGraphResult 200 ([PSCustomObject]@{ value = @() })) }
|
||||
if($prop.Value -is [array] -or $prop.Value -is [System.Collections.IList]) {
|
||||
$items = @($value)
|
||||
if($Query['$filter']) { $items = @($items | Where-Object { Test-MockGraphFilter -Item $_ -Filter $Query['$filter'] }) }
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{ value = $items }))
|
||||
}
|
||||
return (New-MockGraphResult 200 $value)
|
||||
}
|
||||
if($Method -eq 'POST' -and $Rest.Count -eq 2) {
|
||||
# Action on the item (e.g. .../updateDefinitionValues, .../createCopy): accept.
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{}))
|
||||
}
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{}))
|
||||
}
|
||||
|
||||
# One request in, one @{ Status; Body } out. The body is an object, not JSON.
|
||||
function Resolve-MockGraphRequest {
|
||||
[CmdletBinding()]
|
||||
param([string]$Method, [string]$Url, $Body)
|
||||
|
||||
if(-not $script:MockGraphStore) { throw "Mock Graph store is not initialized" }
|
||||
$Method = $Method.ToUpperInvariant()
|
||||
$parts = Split-MockGraphUrl $Url
|
||||
$path = $parts.Path
|
||||
|
||||
if($path -eq '$batch') {
|
||||
$responses = [System.Collections.Generic.List[object]]::new()
|
||||
foreach($request in @($Body.requests)) {
|
||||
$sub = Resolve-MockGraphRequest -Method ([string]$request.method) -Url ([string]$request.url) -Body $request.body
|
||||
$responses.Add([PSCustomObject]@{
|
||||
id = $request.id
|
||||
status = $sub.Status
|
||||
headers = [PSCustomObject]@{ 'Content-Type' = 'application/json' }
|
||||
body = $sub.Body
|
||||
})
|
||||
}
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{ responses = $responses.ToArray() }))
|
||||
}
|
||||
|
||||
$resolved = Resolve-MockGraphPath $path
|
||||
if(-not $resolved) {
|
||||
Write-LogDebug "Mock Graph: no data for $Method $path - answering with an empty collection"
|
||||
if($Method -eq 'GET') { return (New-MockGraphResult 200 ([PSCustomObject]@{ value = @() })) }
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{}))
|
||||
}
|
||||
|
||||
if($resolved.Kind -eq 'single') {
|
||||
$obj = $script:MockGraphStore.Singles[$resolved.Key]
|
||||
if($resolved.Rest.Count -eq 0) {
|
||||
if($Method -eq 'PATCH') {
|
||||
foreach($prop in $Body.PSObject.Properties) { $obj | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force }
|
||||
}
|
||||
return (New-MockGraphResult 200 (Copy-MockGraphObject $obj))
|
||||
}
|
||||
$prop = $obj.PSObject.Properties[$resolved.Rest[0]]
|
||||
if($prop) {
|
||||
$value = Copy-MockGraphObject $prop.Value
|
||||
if($prop.Value -is [array]) { return (New-MockGraphResult 200 ([PSCustomObject]@{ value = @($value) })) }
|
||||
return (New-MockGraphResult 200 $value)
|
||||
}
|
||||
return (New-MockGraphResult 200 ([PSCustomObject]@{ value = @() }))
|
||||
}
|
||||
|
||||
return (Invoke-MockGraphCollectionRequest -Method $Method -Key $resolved.Key -Rest $resolved.Rest -Query $parts.Query -Body $Body)
|
||||
}
|
||||
|
||||
# Shape a result like Invoke-WebRequest's response, which is what
|
||||
# Invoke-MSGraphAPI reads (StatusCode / StatusDescription / Headers / Content /
|
||||
# RawContentLength). A 4xx throws the way Invoke-WebRequest does, with the
|
||||
# response on the exception, so the product's error handling runs unchanged
|
||||
# and reports the status, error code and message of a mock failure.
|
||||
function ConvertTo-MockWebResponse {
|
||||
param($Result)
|
||||
$json = if($null -eq $Result.Body) { "" } else { ($Result.Body | ConvertTo-Json -Depth 50 -Compress) }
|
||||
$description = switch($Result.Status) { 200 { 'OK' } 201 { 'Created' } 204 { 'No Content' } 400 { 'Bad Request' } 403 { 'Forbidden' } 404 { 'Not Found' } default { 'OK' } }
|
||||
$response = [PSCustomObject]@{
|
||||
StatusCode = [int]$Result.Status
|
||||
StatusDescription = $description
|
||||
Headers = @{ 'Content-Type' = 'application/json'; 'request-id' = [guid]::NewGuid().ToString() }
|
||||
Content = $json
|
||||
RawContentLength = [long][System.Text.Encoding]::UTF8.GetByteCount($json)
|
||||
}
|
||||
if($Result.Status -ge 400) {
|
||||
# Read-MSGraphErrorResponseContent takes the body from
|
||||
# Response.GetResponseStream(), as it does for an HttpWebResponse.
|
||||
$response | Add-Member -NotePropertyName ContentBytes -NotePropertyValue ([System.Text.Encoding]::UTF8.GetBytes($json))
|
||||
$response | Add-Member -MemberType ScriptMethod -Name GetResponseStream -Value { [System.IO.MemoryStream]::new([byte[]]$this.ContentBytes) }
|
||||
throw [MockGraphResponseException]::new("The remote server returned an error: ($($Result.Status)) $description.", $response)
|
||||
}
|
||||
return $response
|
||||
}
|
||||
|
||||
# Entry point used by AuthenticationMock.InvokeWebRequest.
|
||||
function Invoke-MockGraphRequest {
|
||||
[CmdletBinding()]
|
||||
param([string]$Url, [string]$Method, $Body)
|
||||
|
||||
$bodyObject = $null
|
||||
if($null -ne $Body) {
|
||||
if($Body -is [string]) {
|
||||
if($Body.Trim()) { try { $bodyObject = $Body | ConvertFrom-Json } catch { $bodyObject = $null } }
|
||||
}
|
||||
elseif($Body -is [byte[]]) {
|
||||
try { $bodyObject = [System.Text.Encoding]::UTF8.GetString($Body) | ConvertFrom-Json } catch { $bodyObject = $null }
|
||||
}
|
||||
else { $bodyObject = $Body }
|
||||
}
|
||||
$result = Resolve-MockGraphRequest -Method $Method -Url $Url -Body $bodyObject
|
||||
return (ConvertTo-MockWebResponse $result)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Opening a link in the default browser - shared by both UI backends.
|
||||
#
|
||||
# Start-Process <url> happens to work on Windows because the cmdlet falls
|
||||
# back to ShellExecute for non-executables, but raw Process.Start(url) fails
|
||||
# on .NET Core (UseShellExecute defaults to false there) and Start-Process
|
||||
# itself fails off-Windows where a URL is not an executable file.
|
||||
# ProcessStartInfo with UseShellExecute = true is the one form that opens the
|
||||
# default browser on every supported host (PS 5.1, PS 7, and - once the
|
||||
# Avalonia UI goes cross-platform - macOS/Linux via the OS launcher).
|
||||
#
|
||||
# Being a real module function also matters for the Avalonia side: event
|
||||
# handlers there cannot rely on closures (ConvertTo-AvaloniaEventScriptBlock
|
||||
# strips them), so link handlers must call a module-scope function by name.
|
||||
function Open-ExternalUri
|
||||
{
|
||||
param([Parameter(Mandatory)][string]$Uri)
|
||||
|
||||
try {
|
||||
if($IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop') {
|
||||
# Windows: ShellExecute opens the default handler for a URL or file.
|
||||
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$startInfo.FileName = $Uri
|
||||
$startInfo.UseShellExecute = $true
|
||||
[void][System.Diagnostics.Process]::Start($startInfo)
|
||||
return
|
||||
}
|
||||
|
||||
if($IsMacOS) {
|
||||
[void][System.Diagnostics.Process]::Start((New-OpenProcessInfo 'open' @($Uri)))
|
||||
return
|
||||
}
|
||||
|
||||
# Linux. UseShellExecute is unreliable on .NET here (it can hang), so we
|
||||
# invoke the desktop's launcher directly. XFCE ignores xdg-settings and
|
||||
# routes links through exo-open -> ~/.config/xfce4/helpers.rc (WebBrowser),
|
||||
# so prefer `exo-open --launch WebBrowser` when present (it works for both
|
||||
# URLs and local file paths); otherwise fall back to xdg-open.
|
||||
#
|
||||
# Do NOT wrap the launch in `systemd-run --user --scope`: that puts a
|
||||
# snap-packaged browser (Firefox) in a generic run-<id>.scope which
|
||||
# snap-confine rejects ("... is not a snap cgroup"). What actually lets the
|
||||
# snap start is the correct DBUS_SESSION_BUS_ADDRESS + XDG_RUNTIME_DIR,
|
||||
# which the launchers (Start.ps1 / Start-Avalonia.ps1) export.
|
||||
if(Get-Command exo-open -ErrorAction SilentlyContinue) {
|
||||
[void][System.Diagnostics.Process]::Start((New-OpenProcessInfo 'exo-open' @('--launch','WebBrowser',$Uri)))
|
||||
}
|
||||
else {
|
||||
[void][System.Diagnostics.Process]::Start((New-OpenProcessInfo 'xdg-open' @($Uri)))
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to open $Uri" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
# ArgumentList passes each argument verbatim (spaces in a file path are safe) and
|
||||
# UseShellExecute=false keeps Process.Start non-blocking without the ShellExecute
|
||||
# hang seen on Linux .NET.
|
||||
function New-OpenProcessInfo
|
||||
{
|
||||
param([string]$FileName, [string[]]$Arguments)
|
||||
|
||||
$si = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
$si.FileName = $FileName
|
||||
foreach($a in $Arguments) { $si.ArgumentList.Add($a) }
|
||||
$si.UseShellExecute = $false
|
||||
return $si
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
# Unified policy hydration orchestrator.
|
||||
#
|
||||
# Every caller that needs a fully populated IntunePolicyBase — bulk export,
|
||||
# single-policy export, UI list-row open, Compare, Copy — funnels through
|
||||
# Invoke-PolicyHydrate. The orchestrator owns:
|
||||
# 1. Body fetch — N=1 direct GET, N>1 parallel $batch (chunks of 20).
|
||||
# 2. Nav properties — Add-GraphNavigationProperties per row that needs it.
|
||||
# 3. Sub-resources — Invoke-PolicySubresourceFetch drives the per-class
|
||||
# GetSubResourceBatchRequests / ApplySubResourceBatchResult
|
||||
# / FinalizeSubResources contract (branding images, target
|
||||
# apps, role assignments, reusable settings, ToU files, …).
|
||||
# This replaced the old Invoke-PolicyExtraData /
|
||||
# Sync-BulkExport* fan-out helpers (deleted with
|
||||
# Internal/PolicyHydrateExtras.ps1).
|
||||
|
||||
function Invoke-PolicyHydrate
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)] $Policies,
|
||||
[int]$TokenId = 0
|
||||
)
|
||||
|
||||
$list = [System.Collections.Generic.List[object]]::new()
|
||||
foreach($p in @($Policies)) {
|
||||
if(-not $p) { continue }
|
||||
if(-not $p.PolicyType) { continue }
|
||||
if($p._IsFullObject) { continue }
|
||||
# Single-object endpoints return the object itself from the list call, so
|
||||
# the body is already complete and there is no per-id URL to fetch -
|
||||
# appending .id would duplicate the last path segment
|
||||
# (androidManagedStoreAccountEnterpriseSettings/androidManagedStore...)
|
||||
# and Graph 400s. Mark it full so GetFullObject()/Get() do not retry
|
||||
# forever, but keep it in the list so the nav-property and sub-resource
|
||||
# stages still run. This has to sit BEFORE the Id guard: Tenant Settings
|
||||
# (deviceManagement/settings) returns an EMPTY id, so an Id check first
|
||||
# would drop it here and leave it permanently un-hydrated.
|
||||
if($p.PolicyType.SingleObject -eq $true) {
|
||||
$p._IsFullObject = $true
|
||||
[void]$list.Add($p)
|
||||
continue
|
||||
}
|
||||
if(-not $p.Id) { continue }
|
||||
[void]$list.Add($p)
|
||||
}
|
||||
if($list.Count -eq 0) { return }
|
||||
|
||||
# Cross-tenant Compare / Copy can hand us policies from different tokens in
|
||||
# one call. Body fetch + sub-resource fetch + Phase-A wrapper all reach
|
||||
# Graph with a single TokenId per chunk, so a mixed-token batch would
|
||||
# auth half the requests under the wrong tenant. Group by effective
|
||||
# _TokenId (fall back to the caller-supplied $TokenId) and dispatch one
|
||||
# per-token hydrate pass; the common single-token case takes the fast
|
||||
# path with no extra dictionary work.
|
||||
$byToken = $null
|
||||
$firstEff = $null
|
||||
foreach($p in $list) {
|
||||
$eff = if($null -ne $p._TokenId) { [int]$p._TokenId } else { [int]$TokenId }
|
||||
if($null -eq $firstEff) { $firstEff = $eff; continue }
|
||||
if($eff -ne $firstEff) {
|
||||
# Plain hashtable, not [ordered]: keys are integer token ids and an ordered
|
||||
# dictionary treats an integer indexer as a positional index, so $byToken[7]
|
||||
# would throw instead of addressing the token-7 bucket. Dispatch order does
|
||||
# not matter here.
|
||||
if($null -eq $byToken) { $byToken = @{} }
|
||||
}
|
||||
}
|
||||
if($null -ne $byToken) {
|
||||
foreach($p in $list) {
|
||||
$eff = if($null -ne $p._TokenId) { [int]$p._TokenId } else { [int]$TokenId }
|
||||
if(-not $byToken.Contains($eff)) {
|
||||
$byToken[$eff] = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
[void]$byToken[$eff].Add($p)
|
||||
}
|
||||
foreach($key in $byToken.Keys) {
|
||||
Invoke-PolicyHydrate -Policies $byToken[$key].ToArray() -TokenId $key
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Every policy left here shares one effective token (the multi-token case was split
|
||||
# and recursed above). Adopt it so the batch body fetch and the sub-resource fetch
|
||||
# authenticate under the policies' own token even when the caller passed the default
|
||||
# (0) -TokenId. Invoke-PolicyHydrateBodySingle already re-derives per policy, but the
|
||||
# batch and sub-resource helpers use $TokenId verbatim.
|
||||
if($null -ne $firstEff) { $TokenId = [int]$firstEff }
|
||||
|
||||
if($list.Count -eq 1) {
|
||||
Invoke-PolicyHydrateBodySingle -Policy $list[0] -TokenId $TokenId
|
||||
}
|
||||
else {
|
||||
Invoke-PolicyHydrateBodyBatch -Policies $list -TokenId $TokenId
|
||||
}
|
||||
|
||||
$hydrated = [System.Collections.Generic.List[object]]::new()
|
||||
foreach($p in $list) {
|
||||
if(-not $p._IsFullObject) { continue }
|
||||
if($p.PolicyType.NavigationProperties -ne $true) {
|
||||
Add-GraphNavigationProperties $p
|
||||
}
|
||||
[void]$hydrated.Add($p)
|
||||
}
|
||||
if($hydrated.Count -eq 0) { return }
|
||||
|
||||
Invoke-PolicySubresourceFetch -Policies $hydrated -TokenId $TokenId
|
||||
}
|
||||
|
||||
function Invoke-PolicyHydrateBodySingle
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]$Policy,
|
||||
[int]$TokenId = 0
|
||||
)
|
||||
|
||||
$url = Get-PolicyHydrateBodyUrl -Policy $Policy
|
||||
if(-not $url) { return }
|
||||
|
||||
$effTokenId = if($null -ne $Policy._TokenId) { $Policy._TokenId } else { $TokenId }
|
||||
|
||||
$body = Invoke-MSGraphAPI -Url $url -TokenId $effTokenId
|
||||
if($body) {
|
||||
Merge-PolicyHydrateBody -Policy $Policy -Body $body
|
||||
$Policy._IsFullObject = $true
|
||||
}
|
||||
else {
|
||||
Write-Warning "Failed to get full object for $($Policy.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
# Merge the per-id body response into the existing JsonObject instead of
|
||||
# replacing it wholesale. Properties present in $Body win; properties only on
|
||||
# the existing JsonObject (typically `assignments` placed there by the list-
|
||||
# stage $expand or by Add-GraphPolicyAssignments) are preserved.
|
||||
#
|
||||
# Without this merge, a body URL that doesn't include `$expand=assignments`
|
||||
# silently drops the assignment rows the caller already populated — see the
|
||||
# regression spotted on Manged App Test.json during the 2026-06-06 bulk-
|
||||
# export diff session.
|
||||
function Merge-PolicyHydrateBody
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)]$Policy,
|
||||
[Parameter(Mandatory)]$Body
|
||||
)
|
||||
|
||||
if($null -eq $Policy.JsonObject) {
|
||||
$Policy.JsonObject = $Body
|
||||
return
|
||||
}
|
||||
|
||||
foreach($prop in $Body.PSObject.Properties) {
|
||||
if($Policy.JsonObject.PSObject.Properties[$prop.Name]) {
|
||||
$Policy.JsonObject.($prop.Name) = $prop.Value
|
||||
}
|
||||
else {
|
||||
Add-Member -InputObject $Policy.JsonObject -MemberType NoteProperty -Name $prop.Name -Value $prop.Value -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PolicyHydrateBodyBatch
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)] $Policies,
|
||||
[int]$TokenId = 0
|
||||
)
|
||||
|
||||
$batchObjects = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$policyById = @{}
|
||||
$idx = 0
|
||||
foreach($p in $Policies) {
|
||||
$url = Get-PolicyHydrateBodyUrl -Policy $p
|
||||
if(-not $url) { continue }
|
||||
$idx++
|
||||
$reqId = "hyd_$idx"
|
||||
[void]$batchObjects.Add([PSCustomObject]@{
|
||||
id = $reqId
|
||||
method = "GET"
|
||||
url = $url.TrimStart('/')
|
||||
headers = @{ "Accept" = "application/json;odata.metadata=$($p.PolicyType.ODataMetadata)" }
|
||||
})
|
||||
$policyById[$reqId] = $p
|
||||
}
|
||||
if($batchObjects.Count -eq 0) { return }
|
||||
|
||||
$throttle = 4
|
||||
try {
|
||||
$cfg = Get-SettingValue "ParallelBatchThrottle"
|
||||
if($cfg) { $throttle = [int]$cfg }
|
||||
} catch {}
|
||||
if($throttle -lt 1) { $throttle = 1 }
|
||||
$chunkSize = $throttle * 20
|
||||
|
||||
$total = $batchObjects.Count
|
||||
$processed = 0
|
||||
$batchNum = 0
|
||||
$totalBatches = [Math]::Ceiling($total / [double]$chunkSize)
|
||||
|
||||
Write-Log "Policy hydrate: fetching $total full policy object(s) via parallel batch (chunk=$chunkSize)"
|
||||
|
||||
while($processed -lt $total) {
|
||||
$take = [Math]::Min($chunkSize, $total - $processed)
|
||||
$chunk = [System.Collections.Generic.List[PSCustomObject]]::new($batchObjects.GetRange($processed, $take))
|
||||
$batchNum++
|
||||
|
||||
Write-Status -Detail ("Fetching item {0} of {1} · batch {2} of {3}" -f ($processed + $take), $total, $batchNum, $totalBatches) -SkipLog -Force
|
||||
|
||||
$results = Invoke-GraphBatchRequest -BatchObjects $chunk -BatchType "Hydrate:Body" -TokenId $TokenId
|
||||
|
||||
foreach($r in $results) {
|
||||
$policy = $policyById["$($r.Id)"]
|
||||
if(-not $policy -or -not $r.body) { continue }
|
||||
Merge-PolicyHydrateBody -Policy $policy -Body $r.body
|
||||
$policy._IsFullObject = $true
|
||||
}
|
||||
|
||||
$processed += $take
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve the Graph URL for hydrating a policy's main body. Most types use
|
||||
# IntunePolicyBase.GetObjectURL() unchanged. AppConfigurationManagedAppObject
|
||||
# / AppProtectionPolicyObject route through deviceAppManagement/<_objectClass>
|
||||
# instead of their type's _API.
|
||||
#
|
||||
# The expand list for these wrappers is hardcoded per _objectClass because the
|
||||
# list-stage JsonObject for these endpoints does NOT include the
|
||||
# `apps@odata.navigationLink` / `settings@odata.navigationLink` / similar
|
||||
# hints that IntunePolicyBase.GetObjectURL relies on. Without the hints, the
|
||||
# generic expand-builder produces no $expand clause and the per-id GET
|
||||
# returns a body missing the embedded collections — silently dropping
|
||||
# $.apps and $.settings on every targetedManagedAppConfiguration export.
|
||||
function Get-PolicyHydrateBodyUrl
|
||||
{
|
||||
param([Parameter(Mandatory)]$Policy)
|
||||
|
||||
if(-not $Policy.Id) { return $null }
|
||||
|
||||
# A single-object endpoint IS the object. Its `id` can equal the last path
|
||||
# segment (androidManagedStoreAccountEnterpriseSettings), so the generic
|
||||
# "$API/$id" build would request …/x/x and fail. Nothing to fetch here.
|
||||
if($Policy.PolicyType.SingleObject -eq $true) { return $null }
|
||||
|
||||
$typeName = $Policy.GetType().Name
|
||||
if($typeName -in @('AppConfigurationManagedAppObject', 'AppProtectionPolicyObject')) {
|
||||
if($Policy._objectClass) {
|
||||
$expand = $null
|
||||
switch($Policy._objectClass) {
|
||||
'windowsInformationProtectionPolicies' {
|
||||
$expand = "?`$expand=protectedAppLockerFiles,exemptAppLockerFiles"
|
||||
}
|
||||
'targetedManagedAppConfigurations' {
|
||||
# apps = the policy's app references; settings = the configured
|
||||
# key/value pairs; assignments = the target groups. All three are
|
||||
# missing from a plain per-id GET (list-stage nav-link hints absent).
|
||||
# assignments MUST be expanded here: this type sets
|
||||
# _ExpandAssignmentsList=$false, so the list stage never fetched
|
||||
# assignments and Merge-PolicyHydrateBody has nothing to preserve —
|
||||
# without this expand the documented policy shows no assignments.
|
||||
# $expand=assignments on the body URL is the supported path for these
|
||||
# managed-app types (their /assignments sub-resource returns 400).
|
||||
$expand = "?`$expand=apps,settings,assignments"
|
||||
}
|
||||
default {
|
||||
$url = ([IntunePolicyBase]$Policy).GetObjectURL()
|
||||
$parts = $url.Split('?')
|
||||
if($parts.Length -gt 1) { $expand = '?' + $parts[1] }
|
||||
}
|
||||
}
|
||||
return "deviceAppManagement/$($Policy._objectClass)/$($Policy.Id)$expand"
|
||||
}
|
||||
}
|
||||
|
||||
return ([IntunePolicyBase]$Policy).GetObjectURL()
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
# Shared plumbing behind the policy-picker dialogs in both UI backends.
|
||||
#
|
||||
# The picker used to work off a full-tenant sweep: every Add Pair click ran
|
||||
# Get-GraphPolicies once per policy group, which took minutes on a real tenant.
|
||||
# Instead the user now picks a scope (one policy group or one policy type) and
|
||||
# types a name; the search goes to Graph as a server-side prefix filter and
|
||||
# comes back with just the matches.
|
||||
#
|
||||
# Lives in Internal/ rather than UI/ so WPF and Avalonia agree on the scope
|
||||
# list and the search call, and so the UI stays a thin caller (R9/R10).
|
||||
|
||||
# Minimum characters before a search is sent. Below this the prefix matches
|
||||
# most of the tenant, which is the slow full sweep this exists to avoid. Users
|
||||
# who really want everything in the scope click "Load all in scope" instead.
|
||||
$script:PolicySearchMinLength = 2
|
||||
|
||||
function Get-PolicySearchMinLength
|
||||
{
|
||||
return $script:PolicySearchMinLength
|
||||
}
|
||||
|
||||
# The scope list for the picker's "Search in" dropdown: every policy group,
|
||||
# each followed by its own policy types. Groups are listed first so picking
|
||||
# "everything in Configuration" is one click; the indented type rows narrow it
|
||||
# to a single API.
|
||||
#
|
||||
# Key is what the UI binds SelectedValue to - Id alone is not unique because a
|
||||
# group and a type can carry the same Id (e.g. Applications).
|
||||
function Get-PolicySearchScopes
|
||||
{
|
||||
param(
|
||||
# Marks the scope for this policy type as the default selection. The
|
||||
# compare flows pass the source policy's type, which is the scope the
|
||||
# user wants almost every time.
|
||||
[string]$DefaultPolicyTypeId
|
||||
)
|
||||
|
||||
$scopes = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
foreach($group in @($script:IntuneGroups | Where-Object { $_.Title } | Sort-Object Title))
|
||||
{
|
||||
[void]$scopes.Add([PSCustomObject]@{
|
||||
Key = "Group|$($group.Id)"
|
||||
Id = $group.Id
|
||||
Kind = "Group"
|
||||
Title = "$($group.Title) (all types)"
|
||||
GroupId = $group.Id
|
||||
IsDefault = $false
|
||||
})
|
||||
|
||||
foreach($type in @($group.PolicyTypes | Where-Object { $_.Title -and $_.API } | Sort-Object Title))
|
||||
{
|
||||
[void]$scopes.Add([PSCustomObject]@{
|
||||
Key = "Type|$($type.Id)"
|
||||
Id = $type.Id
|
||||
Kind = "Type"
|
||||
# Leading spaces indent the type under its group row. ASCII
|
||||
# only - see the string-literal gate in Static.Tests.ps1.
|
||||
Title = " $($type.Title)"
|
||||
GroupId = $group.Id
|
||||
IsDefault = ($DefaultPolicyTypeId -and $type.Id -eq $DefaultPolicyTypeId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return $scopes.ToArray()
|
||||
}
|
||||
|
||||
# The tenants the picker can search, one entry per live token - same source and
|
||||
# labelling as the Copy dialog's destination-tenant combo (Get-TokenInfo, which
|
||||
# is provider-agnostic, so MSAL / OAuth / MgGraph tokens all appear). Returns an
|
||||
# empty array when only one tenant is signed in; callers hide the combo then.
|
||||
#
|
||||
# Searching another tenant works because Get-GraphPolicies stamps each policy
|
||||
# with the TokenId it came from, and GetFullObject / Invoke-PolicyHydrate resolve
|
||||
# against that stamp - so a policy picked from tenant B still hydrates and
|
||||
# compares against tenant B.
|
||||
function Get-PolicySearchTenants
|
||||
{
|
||||
$tokens = @(Get-TokenInfo)
|
||||
if($tokens.Count -le 1) { return @() }
|
||||
|
||||
$tenants = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach($token in $tokens)
|
||||
{
|
||||
if(-not $token) { continue }
|
||||
|
||||
$title = Get-StringOrDefault $token.TenantName $token.TenantID
|
||||
if([String]::IsNullOrWhiteSpace($title)) { $title = "Tenant $($token.Id)" }
|
||||
if($token.IsDefault -eq $true) { $title = "$title (Current)" }
|
||||
|
||||
[void]$tenants.Add([PSCustomObject]@{
|
||||
Key = "Token|$($token.Id)"
|
||||
TokenId = [int]$token.Id
|
||||
TenantId = $token.TenantID
|
||||
Title = $title
|
||||
IsDefault = ($token.IsDefault -eq $true)
|
||||
})
|
||||
}
|
||||
|
||||
return $tenants.ToArray()
|
||||
}
|
||||
|
||||
# Key of the tenant to preselect: the current one, else the first.
|
||||
function Get-PolicySearchDefaultTenantKey
|
||||
{
|
||||
param($Tenants)
|
||||
|
||||
$all = @($Tenants)
|
||||
if($all.Count -eq 0) { return $null }
|
||||
|
||||
$current = $all | Where-Object { $_.IsDefault } | Select-Object -First 1
|
||||
if($current) { return $current.Key }
|
||||
|
||||
return $all[0].Key
|
||||
}
|
||||
|
||||
# Default scope key for a policy type id: the type's own row when it is in the
|
||||
# list, otherwise its group, otherwise the first scope. Returns $null when
|
||||
# there are no scopes at all.
|
||||
function Get-PolicySearchDefaultScopeKey
|
||||
{
|
||||
param(
|
||||
$Scopes,
|
||||
[string]$PolicyTypeId
|
||||
)
|
||||
|
||||
$all = @($Scopes)
|
||||
if($all.Count -eq 0) { return $null }
|
||||
|
||||
if($PolicyTypeId)
|
||||
{
|
||||
$typeScope = $all | Where-Object { $_.Kind -eq "Type" -and $_.Id -eq $PolicyTypeId } | Select-Object -First 1
|
||||
if($typeScope) { return $typeScope.Key }
|
||||
}
|
||||
|
||||
return $all[0].Key
|
||||
}
|
||||
|
||||
# Run one scoped search. $Scope is an entry from Get-PolicySearchScopes; an
|
||||
# empty $SearchText returns everything in the scope (the "Load all in scope"
|
||||
# path). Types whose endpoint rejects a server-side filter are handled inside
|
||||
# Get-GraphPolicies, which always re-checks names client-side.
|
||||
function Search-IntunePolicies
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Scope,
|
||||
[string]$SearchText,
|
||||
# Ids to drop from the result - used to keep a policy from being
|
||||
# compared against itself.
|
||||
[string[]]$ExcludeIds,
|
||||
[Int]$TokenId = (Get-DefaultTokenId)
|
||||
)
|
||||
|
||||
if(-not $Scope) { return @() }
|
||||
|
||||
$params = @{ TokenId = $TokenId }
|
||||
if($Scope.Kind -eq "Group") { $params.Add("PolicyGroup", $Scope.Id) }
|
||||
else { $params.Add("PolicyType", $Scope.Id) }
|
||||
if($SearchText) { $params.Add("NameFilter", $SearchText) }
|
||||
|
||||
$found = @()
|
||||
try {
|
||||
$found = @(Get-GraphPolicies @params)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Search-IntunePolicies failed for scope $($Scope.Key)" $_.Exception
|
||||
return @()
|
||||
}
|
||||
|
||||
if($ExcludeIds -and $ExcludeIds.Count -gt 0) {
|
||||
$found = @($found | Where-Object { $ExcludeIds -notcontains $_.Id })
|
||||
}
|
||||
|
||||
return $found
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
#region Policy sub-resource orchestrator
|
||||
#
|
||||
# Drives the per-class GetSubResourceBatchRequests / ApplySubResourceBatchResult
|
||||
# contract defined on IntunePolicyBase. The contract lets a policy class declare
|
||||
# the extra Graph GETs it needs to be fully hydrated beyond the main object body
|
||||
# (relationships, script content, etc.) so the hydrator pipeline can fan them
|
||||
# out via $batch in parallel instead of paying one round-trip per policy.
|
||||
#
|
||||
# Phase loop:
|
||||
# 1. Phase 1 requests come from policy.GetSubResourceBatchRequests(1).
|
||||
# 2. Identical URLs across policies are coalesced into ONE batch sub-request
|
||||
# and the single response is fanned out to every (policy, key) that asked
|
||||
# for it (e.g. two AppConfig policies referencing the same mobileApp fetch
|
||||
# it once). Method + Headers must also match for coalescing.
|
||||
# 3. Each phase batch is dispatched via Invoke-GraphBatchRequest.
|
||||
# 4. Each response is routed back to its owning policy(ies) via
|
||||
# ApplySubResourceBatchResult($phase, $key, $body), which returns any
|
||||
# follow-up requests for phase+1.
|
||||
# 5. Loop continues until no policy returns more requests, or until $maxPhases.
|
||||
# 6. After all phases, FinalizeSubResources() runs once per opted-in policy so
|
||||
# classes can assemble derived properties from the applied responses.
|
||||
#
|
||||
# Opted-in policies are flagged via $_HasSubResourceBatch on the class. Default
|
||||
# is $false, so unchanged classes stay on the inline path until they migrate.
|
||||
|
||||
function Invoke-PolicySubresourceFetch
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)] $Policies,
|
||||
[int] $TokenId = 0
|
||||
)
|
||||
|
||||
if(-not $Policies) { return }
|
||||
$list = @($Policies)
|
||||
if($list.Count -eq 0) { return }
|
||||
|
||||
$optIn = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach($p in $list) {
|
||||
if($null -eq $p) { continue }
|
||||
if($p._HasSubResourceBatch -ne $true) { continue }
|
||||
if(-not $p.Id) { continue }
|
||||
# Clear any per-phase state from a previous hydrate of the same wrapper
|
||||
# (e.g. UI list-row re-open). Without this, partial state from a failed
|
||||
# phase-2 in a prior run would silently leak into the next hydrate's
|
||||
# ApplySubResourceBatchResult lookups.
|
||||
if($p.PSObject.Properties['_SubResourceState']) {
|
||||
$p._SubResourceState = $null
|
||||
}
|
||||
[void]$optIn.Add($p)
|
||||
}
|
||||
if($optIn.Count -eq 0) { return }
|
||||
|
||||
# Phase 1: ask every opted-in policy what it needs.
|
||||
$pending = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach($policy in $optIn) {
|
||||
try {
|
||||
$reqs = $policy.GetSubResourceBatchRequests(1)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Policy sub-resource: GetSubResourceBatchRequests on $($policy.GetType().Name) ($($policy.Name)) threw" $_.Exception
|
||||
continue
|
||||
}
|
||||
foreach($r in @($reqs)) {
|
||||
if($r -and $r.Url -and $r.Key) {
|
||||
[void]$pending.Add([PSCustomObject]@{ Policy = $policy; Request = $r })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($pending.Count -eq 0) {
|
||||
foreach($p in $optIn) { $p._IsFullObject = $true }
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Policy sub-resource: $($pending.Count) phase-1 request(s) across $($optIn.Count) policy(ies)"
|
||||
|
||||
$maxPhases = 5
|
||||
$globalIdx = 0
|
||||
|
||||
for($phase = 1; $phase -le $maxPhases -and $pending.Count -gt 0; $phase++) {
|
||||
|
||||
$batchObjects = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
# reqId -> list of { Policy; Key } so one coalesced response fans out to
|
||||
# every requester. urlKey -> reqId so identical requests reuse one slot.
|
||||
$routing = @{}
|
||||
$reqIdByUrl = @{}
|
||||
|
||||
foreach($entry in $pending) {
|
||||
$req = $entry.Request
|
||||
$headers = if($req.Headers) { $req.Headers } else { @{ Accept = 'application/json;odata.metadata=minimal' } }
|
||||
$method = if($req.Method) { $req.Method } else { 'GET' }
|
||||
$url = ([string]$req.Url).TrimStart('/')
|
||||
|
||||
# Coalesce identical (method + url + headers) requests so a shared
|
||||
# sub-resource is fetched once. Headers are serialized into the key
|
||||
# so requests differing only by Accept aren't wrongly merged.
|
||||
$hdrKey = ($headers.GetEnumerator() | Sort-Object Name | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join ';'
|
||||
$urlKey = "$method`n$url`n$hdrKey"
|
||||
|
||||
if($reqIdByUrl.ContainsKey($urlKey)) {
|
||||
$reqId = $reqIdByUrl[$urlKey]
|
||||
}
|
||||
else {
|
||||
$globalIdx++
|
||||
$reqId = "psrf_${phase}_${globalIdx}"
|
||||
$reqIdByUrl[$urlKey] = $reqId
|
||||
[void]$batchObjects.Add([PSCustomObject]@{
|
||||
id = $reqId
|
||||
method = $method
|
||||
url = $url
|
||||
headers = $headers
|
||||
})
|
||||
$routing[$reqId] = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
}
|
||||
[void]$routing[$reqId].Add([PSCustomObject]@{ Policy = $entry.Policy; Key = [string]$req.Key })
|
||||
}
|
||||
|
||||
$batchType = "Hydrate:Subresource:Phase$phase"
|
||||
$results = @(Invoke-GraphBatchRequest -BatchObjects $batchObjects -BatchType $batchType -TokenId $TokenId -SkipWarnings -IncludedFailed)
|
||||
|
||||
$next = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
foreach($result in $results) {
|
||||
$routes = $routing["$($result.Id)"]
|
||||
if(-not $routes) { continue }
|
||||
|
||||
$body = $null
|
||||
if($result.Status -ge 200 -and $result.Status -lt 300) {
|
||||
$body = $result.body
|
||||
}
|
||||
|
||||
foreach($route in $routes) {
|
||||
if($null -eq $body) {
|
||||
Write-Log "Policy sub-resource: $($route.Policy.GetType().Name) ($($route.Policy.Name)) phase $phase key '$($route.Key)' returned HTTP $($result.Status)" 2
|
||||
}
|
||||
try {
|
||||
$followups = $route.Policy.ApplySubResourceBatchResult($phase, $route.Key, $body)
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Policy sub-resource: ApplySubResourceBatchResult on $($route.Policy.GetType().Name) (phase=$phase, key=$($route.Key)) threw" $_.Exception
|
||||
continue
|
||||
}
|
||||
|
||||
foreach($f in @($followups)) {
|
||||
if($f -and $f.Url -and $f.Key) {
|
||||
[void]$next.Add([PSCustomObject]@{ Policy = $route.Policy; Request = $f })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pending = $next
|
||||
}
|
||||
|
||||
if($pending.Count -gt 0) {
|
||||
# Hitting the phase cap means a class's ApplySubResourceBatchResult keeps
|
||||
# returning follow-ups indefinitely. Always a bug in the class — emit at
|
||||
# error level so it surfaces in the log filter, not just verbose noise.
|
||||
Write-Log "Policy sub-resource: phase cap ($maxPhases) reached with $($pending.Count) request(s) still pending - runaway in a class implementation; data is incomplete" 3
|
||||
}
|
||||
|
||||
# Finalize pass: let each opted-in policy assemble derived properties from
|
||||
# the responses already applied (e.g. AppConfiguration's #CustomRefTargetedApps).
|
||||
# Every IntunePolicyBase inherits FinalizeSubResources; the method-existence
|
||||
# guard keeps the orchestrator tolerant of stand-in objects that only
|
||||
# implement the request/apply half of the contract.
|
||||
foreach($p in $optIn) {
|
||||
if(-not $p.PSObject.Methods['FinalizeSubResources']) { continue }
|
||||
try {
|
||||
$p.FinalizeSubResources()
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Policy sub-resource: FinalizeSubResources on $($p.GetType().Name) ($($p.Name)) threw" $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
foreach($p in $optIn) {
|
||||
$p._IsFullObject = $true
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,178 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Startup-time sanity check for IntunePolicyType classes against Graph metadata.
|
||||
|
||||
.DESCRIPTION
|
||||
Cross-references each registered IntunePolicyTypeBase subclass against the
|
||||
cached Graph Beta CSDL (%LOCALAPPDATA%\IntuneManagement\GraphMetaData.xml).
|
||||
Catches three classes of registration bug that the framework cannot detect
|
||||
at runtime:
|
||||
|
||||
1. _API points to an endpoint that does not exist in the public schema
|
||||
(typo, portal-only API, removed endpoint).
|
||||
2. _QueryList references an isof('microsoft.graph.X') type that does not
|
||||
exist as an EntityType in the schema (misspelled type name).
|
||||
3. Two PolicyTypes share the same _API + _QueryList — a sibling-type
|
||||
filter swap (e.g. an iOS type accidentally filtering on a macOS type).
|
||||
|
||||
Issues are written to the log at warning level. The validation is best-effort
|
||||
and gracefully skipped if metadata is unavailable.
|
||||
|
||||
.NOTES
|
||||
Sibling of Tools\Audit-PolicyTypeFlags.ps1, but runs in-process at startup
|
||||
against the live $script:IntuneTypes registry rather than scraping source.
|
||||
#>
|
||||
|
||||
# PolicyType IDs whose _API is known to be absent from the public Graph metadata
|
||||
# but is still in active use (portal-only first-party endpoints, etc.).
|
||||
# Keep this list small and document the reason next to each entry.
|
||||
$script:PolicyTypeMetadataApiAllowlist = @(
|
||||
# Portal-only endpoint per project_known_bugs_from_review.md (item 5).
|
||||
# User decision 2026-05-18: leave registration as-is, don't re-flag.
|
||||
"InventoryPolicies"
|
||||
)
|
||||
|
||||
function Test-PolicyTypeMetadata
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[object[]]$PolicyTypes,
|
||||
[xml]$MetadataXml
|
||||
)
|
||||
|
||||
if(-not $PolicyTypes) { $PolicyTypes = $script:IntuneTypes }
|
||||
if(-not $PolicyTypes -or $PolicyTypes.Count -eq 0) { return @() }
|
||||
|
||||
if(-not $MetadataXml)
|
||||
{
|
||||
# -NoDownload: this is a diagnostic sanity check that runs on AppInitialized,
|
||||
# i.e. inside Import-Module. It must never be the reason a 7-8 MB metadata
|
||||
# download happens at load. Cached metadata validates; no cache just skips
|
||||
# (handled immediately below).
|
||||
Get-GraphMetaData -NoDownload
|
||||
$MetadataXml = $script:GraphMetaDataXML
|
||||
}
|
||||
|
||||
if(-not $MetadataXml)
|
||||
{
|
||||
Write-Log "Test-PolicyTypeMetadata: Graph metadata not available; skipping validation" 2
|
||||
return @()
|
||||
}
|
||||
|
||||
$nsm = New-Object System.Xml.XmlNamespaceManager $MetadataXml.NameTable
|
||||
$nsm.AddNamespace("e", "http://docs.oasis-open.org/odata/ns/edm")
|
||||
|
||||
$entityNames = @{}
|
||||
foreach($et in $MetadataXml.SelectNodes("//e:EntityType", $nsm))
|
||||
{
|
||||
$entityNames[$et.Name] = $true
|
||||
}
|
||||
|
||||
$navPropNames = @{}
|
||||
foreach($np in $MetadataXml.SelectNodes("//e:NavigationProperty", $nsm))
|
||||
{
|
||||
$navPropNames[$np.Name] = $true
|
||||
}
|
||||
|
||||
$issues = @()
|
||||
$apiQueryGroups = @{}
|
||||
|
||||
foreach($pt in $PolicyTypes)
|
||||
{
|
||||
$api = $pt._API
|
||||
$id = $pt._ID
|
||||
$qList = $pt._QueryList
|
||||
|
||||
if(-not $api) { continue }
|
||||
|
||||
# --- Rule 1: API last segment must exist as a NavigationProperty somewhere ---
|
||||
$apiPath = $api -replace '%[^%]+%', 'x' # strip placeholders like %OrganizationId%
|
||||
$segments = @($apiPath -split '/' | Where-Object { $_ })
|
||||
$lastSegment = if($segments.Count) { $segments[-1] } else { $null }
|
||||
|
||||
if($lastSegment -and -not $navPropNames.ContainsKey($lastSegment) -and $id -notin $script:PolicyTypeMetadataApiAllowlist)
|
||||
{
|
||||
$issues += [PSCustomObject]@{
|
||||
Severity = "Warning"
|
||||
PolicyTypeId = $id
|
||||
Issue = "API endpoint not in Graph metadata"
|
||||
Detail = "_API='$api' - last segment '$lastSegment' is not a navigation property in GraphMetaData.xml. Likely portal-only, typo, or removed."
|
||||
}
|
||||
}
|
||||
|
||||
# --- Rule 2: every isof('microsoft.graph.X') must reference a known EntityType ---
|
||||
if($qList)
|
||||
{
|
||||
$decoded = [uri]::UnescapeDataString($qList)
|
||||
foreach($m in [regex]::Matches($decoded, "isof\(\s*'(?:microsoft\.graph\.|graph\.)?([A-Za-z0-9_]+)'\s*\)"))
|
||||
{
|
||||
$typeName = $m.Groups[1].Value
|
||||
if(-not $entityNames.ContainsKey($typeName))
|
||||
{
|
||||
$issues += [PSCustomObject]@{
|
||||
Severity = "Warning"
|
||||
PolicyTypeId = $id
|
||||
Issue = "isof() type not in Graph metadata"
|
||||
Detail = "_QueryList references microsoft.graph.$typeName which is not an EntityType in GraphMetaData.xml."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Rule 3: collect (API, QueryList) pairs for duplicate detection ---
|
||||
if($qList)
|
||||
{
|
||||
$key = "$api||$qList"
|
||||
if(-not $apiQueryGroups.ContainsKey($key))
|
||||
{
|
||||
$apiQueryGroups[$key] = @()
|
||||
}
|
||||
$apiQueryGroups[$key] += $id
|
||||
}
|
||||
}
|
||||
|
||||
foreach($key in $apiQueryGroups.Keys)
|
||||
{
|
||||
$ids = $apiQueryGroups[$key]
|
||||
if($ids.Count -lt 2) { continue }
|
||||
|
||||
$sepIdx = $key.IndexOf("||")
|
||||
$apiPart = $key.Substring(0, $sepIdx)
|
||||
$queryPart = $key.Substring($sepIdx + 2)
|
||||
|
||||
$issues += [PSCustomObject]@{
|
||||
Severity = "Warning"
|
||||
PolicyTypeId = ($ids -join ", ")
|
||||
Issue = "Duplicate API+QueryList across PolicyTypes"
|
||||
Detail = "PolicyTypes [$($ids -join ', ')] all use _API='$apiPart' with identical _QueryList='$queryPart'. One is likely mis-filtered (sibling type swap)."
|
||||
}
|
||||
}
|
||||
|
||||
return $issues
|
||||
}
|
||||
|
||||
function Invoke-PolicyTypeMetadataValidation
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
try
|
||||
{
|
||||
$issues = Test-PolicyTypeMetadata
|
||||
if(-not $issues -or $issues.Count -eq 0)
|
||||
{
|
||||
Write-LogDebug "PolicyType metadata validation: no issues found"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "PolicyType metadata validation: $($issues.Count) issue(s) found" 2
|
||||
foreach($i in $issues)
|
||||
{
|
||||
Write-Log " [$($i.PolicyTypeId)] $($i.Issue) - $($i.Detail)" 2
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-LogError "PolicyType metadata validation failed" $_.Exception
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
# Settings store management.
|
||||
#
|
||||
# Settings are three layers, and this file is the middle one:
|
||||
#
|
||||
# storage Internal/Core.ps1 - Get/Save/Remove-SettingStoreValue. PATH
|
||||
# addressed: the caller supplies the SubPath, and whether that path
|
||||
# means a registry key, a node in a JSON tree or a node in an
|
||||
# in-memory tree is decided here.
|
||||
# resolver this file - KEY addressed. Get-SettingValue (in Core.ps1, because
|
||||
# Write-Log needs it during preload) and its write-side counterparts
|
||||
# look the key up in the registered definitions and derive the
|
||||
# SubPath from it, so no caller has to know a path.
|
||||
# public Public/*-IMSetting*.ps1 - the exported api.
|
||||
#
|
||||
# The store has three modes, resolved once at the top of Internal/Core.ps1:
|
||||
#
|
||||
# Registry HKCU:\Software\IntuneManagement. Windows only.
|
||||
# Json a settings file: IM_SETTINGS_FILE if set, else
|
||||
# LocalApplicationData/IntuneManagement/Settings.json. The default
|
||||
# off Windows.
|
||||
# Memory a settings tree with no file behind it. Reads and writes work
|
||||
# normally and nothing touches disk. For automation (a runbook on a
|
||||
# shared worker must not write to that worker's HKCU) and for tests.
|
||||
#
|
||||
# Memory mode is not a fourth code path: the storage primitives take their JSON
|
||||
# branch whenever a settings object exists, and persist only when a settings FILE
|
||||
# exists as well. Memory mode is an object with no file - see the condition in
|
||||
# Save-SettingStoreValue.
|
||||
#
|
||||
# See Docs/Settings.md.
|
||||
|
||||
# IM_SETTINGS_STORE was set to something unrecognized - Core.ps1 fell back to the
|
||||
# platform default, silently, because logging does not work that early. Say so now
|
||||
# rather than have a runbook believe it opted out of disk writes.
|
||||
if($script:SettingsStoreModeEnvRequest -and
|
||||
$script:SettingsStoreModeEnvRequest -notin @("Memory", "Json", "Registry"))
|
||||
{
|
||||
Write-Log "IM_SETTINGS_STORE='$script:SettingsStoreModeEnvRequest' is not a valid store (Memory, Json, Registry). Using '$script:SettingsStoreMode'." 2
|
||||
}
|
||||
|
||||
# What the store actually is right now. Derived from the state variables rather
|
||||
# than reported from $script:SettingsStoreMode alone, because the mode is a
|
||||
# request and the state is the outcome: Initialize-JsonSettings falls back to the
|
||||
# registry when the file cannot be read, and off Windows that leaves no store at
|
||||
# all. Callers (and Get-IMSettingsStore) need the outcome.
|
||||
function Get-SettingsStoreInfo
|
||||
{
|
||||
$persisted = $true
|
||||
$path = $null
|
||||
|
||||
if($script:JsonSettingsObj -and $script:JSonSettingFile)
|
||||
{
|
||||
$mode = "Json"
|
||||
$path = $script:JSonSettingFile
|
||||
}
|
||||
elseif($script:JsonSettingsObj)
|
||||
{
|
||||
$mode = "Memory"
|
||||
$persisted = $false
|
||||
}
|
||||
elseif($script:IsWindowsOS)
|
||||
{
|
||||
$mode = "Registry"
|
||||
$path = Get-RegPath
|
||||
}
|
||||
else
|
||||
{
|
||||
# No settings file and no registry provider: reads fall back to the
|
||||
# registered defaults and writes go nowhere. Only reachable when the JSON
|
||||
# file failed to load off Windows.
|
||||
$mode = "None"
|
||||
$persisted = $false
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
Mode = $mode
|
||||
Path = $path
|
||||
Persisted = $persisted
|
||||
ValueCount = @(Get-SettingsStoreEntries).Count
|
||||
# The store that was asked for, NOT $script:SettingsStoreMode: the JSON
|
||||
# fallback rewrites that variable to "Registry", so reporting it here made
|
||||
# Mode and RequestedMode agree in exactly the case a caller needs them to
|
||||
# differ - the one where the requested store failed to load.
|
||||
RequestedMode = $script:SettingsStoreModeRequested
|
||||
}
|
||||
}
|
||||
|
||||
# Every value in the store as flat SubPath/Key/Value rows. Reading the registry
|
||||
# recursively is the same walk Export-Settings already does, so both directions
|
||||
# reuse Add-RegKeyToSettings and this only has to flatten one shape.
|
||||
function Get-SettingsStoreEntries
|
||||
{
|
||||
$tree = $script:JsonSettingsObj
|
||||
if(-not $tree) { $tree = Get-PersistedSettingsTree }
|
||||
if(-not $tree) { return @() }
|
||||
|
||||
Get-SettingsTreeEntries $tree ""
|
||||
}
|
||||
|
||||
# Recursive half of Get-SettingsStoreEntries. A nested object is a SubPath level;
|
||||
# anything else is a value.
|
||||
function Get-SettingsTreeEntries
|
||||
{
|
||||
param($Node, [string]$SubPath)
|
||||
|
||||
$entries = @()
|
||||
foreach($prop in $Node.PSObject.Properties)
|
||||
{
|
||||
# Exact type name, not `-is [PSCustomObject]`: that accelerator resolves to
|
||||
# PSObject, which almost everything satisfies once PowerShell has wrapped
|
||||
# it, so every leaf value would be walked as a subpath.
|
||||
if($null -ne $prop.Value -and $prop.Value.GetType().FullName -eq "System.Management.Automation.PSCustomObject")
|
||||
{
|
||||
$child = if($SubPath) { "$SubPath\$($prop.Name)" } else { $prop.Name }
|
||||
$entries += Get-SettingsTreeEntries $prop.Value $child
|
||||
}
|
||||
else
|
||||
{
|
||||
$entries += [PSCustomObject]@{
|
||||
SubPath = $SubPath
|
||||
Key = $prop.Name
|
||||
Value = $prop.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $entries
|
||||
}
|
||||
|
||||
# The persisted store as a nested PSCustomObject tree, whatever the platform.
|
||||
# ConvertTo-Json/ConvertFrom-Json is the conversion, not a shortcut: it is exactly
|
||||
# how Initialize-JsonSettings builds its object, so a seeded memory tree and a
|
||||
# loaded file tree have the same shape and the same walk works on both.
|
||||
function Get-PersistedSettingsTree
|
||||
{
|
||||
if($script:JSonSettingFile -and [IO.File]::Exists($script:JSonSettingFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
return (ConvertFrom-Json ([IO.File]::ReadAllText($script:JSonSettingFile)))
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-LogError "Failed to read settings file $script:JSonSettingFile" $_.Exception
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
if($script:IsWindowsOS)
|
||||
{
|
||||
$settingObj = [ordered]@{}
|
||||
Add-RegKeyToSettings $settingObj (Get-RegPath)
|
||||
return ($settingObj | ConvertTo-Json -Depth 20 | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Switch to memory mode. -Seed copies the persisted values in first, so a session
|
||||
# starts from the user's real configuration and then diverges without writing
|
||||
# back; without it the store starts empty and every unset key resolves to its
|
||||
# registered default.
|
||||
function Initialize-MemorySettings
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param([switch]$Seed)
|
||||
|
||||
$tree = $null
|
||||
if($Seed)
|
||||
{
|
||||
$tree = Get-PersistedSettingsTree
|
||||
if(-not $tree) { Write-Log "No persisted settings to seed the in-memory store from" 2 }
|
||||
}
|
||||
|
||||
if(-not $tree) { $tree = [PSCustomObject]@{} }
|
||||
|
||||
# Order matters: null the file first so a write between the two assignments
|
||||
# cannot persist into the store we are leaving.
|
||||
$script:JSonSettingFile = $null
|
||||
$script:JsonSettingsObj = $tree
|
||||
$script:SettingsStoreMode = "Memory"
|
||||
$script:SettingsStoreModeRequested = "Memory"
|
||||
|
||||
Write-Log "Settings store is now in memory$(if($Seed) { " (seeded with $(@(Get-SettingsStoreEntries).Count) values)" }). Nothing will be written to disk."
|
||||
}
|
||||
|
||||
# Change the store at runtime. The public Use-IMSettingsStore is a thin wrapper.
|
||||
function Set-SettingsStoreMode
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("Memory", "Json", "Registry")]
|
||||
[string]$Mode,
|
||||
[string]$Path,
|
||||
[switch]$Seed
|
||||
)
|
||||
|
||||
if($Mode -eq "Memory")
|
||||
{
|
||||
Initialize-MemorySettings -Seed:$Seed
|
||||
return
|
||||
}
|
||||
|
||||
if($Mode -eq "Registry")
|
||||
{
|
||||
if(-not $script:IsWindowsOS)
|
||||
{
|
||||
Write-LogError "The registry settings store is only available on Windows"
|
||||
return
|
||||
}
|
||||
$script:JsonSettingsObj = $null
|
||||
$script:JSonSettingFile = $null
|
||||
$script:SettingsStoreMode = "Registry"
|
||||
$script:SettingsStoreModeRequested = "Registry"
|
||||
Write-Log "Settings store is now the registry: $(Get-RegPath)"
|
||||
return
|
||||
}
|
||||
|
||||
# Json. A caller-supplied path replaces whatever file is loaded;
|
||||
# Initialize-JsonSettings creates it when it does not exist.
|
||||
$script:JsonSettingsObj = $null
|
||||
$script:JSonSettingFile = $Path
|
||||
$script:SettingsStoreMode = "Json"
|
||||
# Recorded BEFORE Initialize-JsonSettings, so a load failure - which calls
|
||||
# Clear-JsonSettingsValues and reverts the mode - still leaves "Json" as the
|
||||
# answer to "what did the caller ask for?".
|
||||
$script:SettingsStoreModeRequested = "Json"
|
||||
Initialize-JsonSettings
|
||||
|
||||
if(-not $script:JsonSettingsObj)
|
||||
{
|
||||
# Initialize-JsonSettings already logged, and Clear-JsonSettingsValues has
|
||||
# reverted the mode - do not claim success.
|
||||
return
|
||||
}
|
||||
|
||||
if($Seed -and $script:IsWindowsOS)
|
||||
{
|
||||
Write-Log "Seeding a settings file from the registry is what Export-Settings does; -Seed is ignored for the Json store" 2
|
||||
}
|
||||
}
|
||||
|
||||
#region Resolver - address settings by KEY, never by path
|
||||
|
||||
# Does a value EXIST at this path? Deliberately not Get-SettingStoreValue: that
|
||||
# reports an empty string as missing, so that clearing a text box in the settings
|
||||
# form restores the registered default. "Configured" has to mean "written", which
|
||||
# is a different question. Generalizes Get-IsTenantSettingConfigured, which could
|
||||
# only ever ask about the current tenant's path.
|
||||
function Test-SettingStoreValue
|
||||
{
|
||||
param($SubPath = "", $Key = "")
|
||||
|
||||
if(-not $Key) { return $false }
|
||||
|
||||
if($script:JsonSettingsObj)
|
||||
{
|
||||
$node = Get-SettingsTreeNode $SubPath
|
||||
if(-not $node) { return $false }
|
||||
return ($null -ne ($node.PSObject.Properties | Where-Object Name -eq $Key))
|
||||
}
|
||||
|
||||
if($script:IsWindowsOS)
|
||||
{
|
||||
try
|
||||
{
|
||||
$item = Get-Item -LiteralPath (Get-RegPath $SubPath) -ErrorAction Stop
|
||||
return ($item.GetValueNames() -contains $Key)
|
||||
}
|
||||
catch
|
||||
{
|
||||
# Missing key. Not an error - the value simply is not configured.
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# The store path a key lives at. This function is the whole point of the resolver
|
||||
# layer: no caller outside it should ever spell a SubPath.
|
||||
#
|
||||
# A registered key takes its SubPath from its definition, which is what makes the
|
||||
# write side agree with Get-SettingValue by construction. Every SubPath-mismatch
|
||||
# bug so far (GraphPageSize, the bulk-export round-trip, DefaultCloud) was a hand-
|
||||
# written path on one side only.
|
||||
#
|
||||
# $SubPath defaults to $null rather than "" because "" is a real path - the root of
|
||||
# the store - so "not supplied" and "the root" have to stay distinguishable.
|
||||
function Resolve-SettingStorePath
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Key,
|
||||
$Definition,
|
||||
[switch]$Tenant,
|
||||
[string]$TenantID,
|
||||
$SubPath = $null
|
||||
)
|
||||
|
||||
if($Definition)
|
||||
{
|
||||
# A REGISTERED setting is stored where its registration says, always - a
|
||||
# caller-supplied -SubPath is reported and ignored rather than honoured.
|
||||
# Honouring it wrote the value to a path the application never reads, which
|
||||
# is precisely the path drift this layer exists to remove:
|
||||
# `Set-IMSetting GraphPageSize 100 -SubPath Wrong` looked like it worked and
|
||||
# changed nothing. -SubPath is for UNREGISTERED keys, where it is the only
|
||||
# way to name the path - both to write and (Resolve-SettingValue) to read.
|
||||
if($null -ne $SubPath -and "$SubPath" -ne "$($Definition.SubPath)")
|
||||
{
|
||||
Write-Log "Ignoring -SubPath '$SubPath' for '$Key': it is a registered setting and is always stored at '$($Definition.SubPath)'" 2
|
||||
}
|
||||
$SubPath = $Definition.SubPath
|
||||
}
|
||||
elseif($null -eq $SubPath)
|
||||
{
|
||||
Write-LogError "'$Key' is not a registered setting, so its storage path cannot be resolved. Pass -SubPath to read or write it anyway."
|
||||
return $null
|
||||
}
|
||||
|
||||
# A registered setting with no -SubPath (EnvironmentText, AppTheme, every
|
||||
# General entry) stores at the ROOT of the tree, and its definition carries
|
||||
# $null rather than "". Returning that $null straight through would be read as
|
||||
# this function's failure sentinel by every caller, so a root-stored key could
|
||||
# never be written - Set-SettingValue returned quietly and the value vanished.
|
||||
if($null -eq $SubPath) { $SubPath = "" }
|
||||
|
||||
if(-not $Tenant) { return $SubPath }
|
||||
|
||||
if(-not $TenantID) { $TenantID = $script:OrganizationId }
|
||||
if(-not $TenantID)
|
||||
{
|
||||
# Silently writing the global value instead would be the worst outcome: the
|
||||
# caller asked for one tenant and would have changed every tenant.
|
||||
Write-LogError "Cannot resolve a tenant-specific path for '$Key': no tenant id was supplied and no tenant is connected."
|
||||
return $null
|
||||
}
|
||||
|
||||
if($SubPath) { return "$TenantID\$SubPath" }
|
||||
return $TenantID
|
||||
}
|
||||
|
||||
# A value in the shape the store has always held it in.
|
||||
#
|
||||
# Everything on disk is a string: the settings form calls Save-SettingStoreValue
|
||||
# with no -Type, so it takes the "String" branch and writes $Value.ToString().
|
||||
# Reproducing that exactly is a hard requirement - a value written through this
|
||||
# layer has to be indistinguishable from one written by the form, or the two
|
||||
# disagree about the same key.
|
||||
function Format-SettingStoreValue
|
||||
{
|
||||
param($Value, $Definition)
|
||||
|
||||
# $null means "remove the value" to Save-SettingStoreValue. Pass it through.
|
||||
if($null -eq $Value) { return $null }
|
||||
|
||||
if($Definition.Type -eq "Boolean")
|
||||
{
|
||||
# PascalCase "True"/"False" - that is what $true.ToString() produces and
|
||||
# what every existing store contains. Coerce first so $true, "true" and
|
||||
# "TRUE" all land in the one canonical shape.
|
||||
#
|
||||
# A string is compared to "true" rather than cast: [bool]"False" is $true
|
||||
# in PowerShell (any non-empty string is), which would turn every attempt
|
||||
# to store False into True. This is the same test Get-SettingValue uses to
|
||||
# read the value back, so writer and reader cannot disagree.
|
||||
$bool = if($Value -is [string]) { $Value -eq "true" } else { [bool]$Value }
|
||||
return $bool.ToString()
|
||||
}
|
||||
|
||||
return $Value.ToString()
|
||||
}
|
||||
|
||||
# Write a setting by key. The counterpart to Get-SettingValue, and the reason no
|
||||
# caller needs to know that "GraphPageSize" lives under "IntuneManager".
|
||||
#
|
||||
# -Tenant writes the value for one tenant only, which is the same precedence
|
||||
# Get-SettingValue reads with (tenant first, then global).
|
||||
function Set-SettingValue
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Key,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowNull()]
|
||||
[AllowEmptyString()]
|
||||
$Value,
|
||||
[switch]$Tenant,
|
||||
[string]$TenantID,
|
||||
$SubPath = $null,
|
||||
[switch]$PassThru
|
||||
)
|
||||
|
||||
$definition = Get-SettingDefinitionByKey $Key
|
||||
$path = Resolve-SettingStorePath -Key $Key -Definition $definition -Tenant:$Tenant -TenantID $TenantID -SubPath $SubPath
|
||||
if($null -eq $path) { return }
|
||||
|
||||
$stored = Format-SettingStoreValue $Value $definition
|
||||
|
||||
# Always "String": see Format-SettingStoreValue. The registered Types
|
||||
# (Boolean, Int, File, List, ...) are UI editor hints, not storage types, and
|
||||
# are not valid RegistryValueKind names - passing one through would throw on
|
||||
# Windows.
|
||||
Save-SettingStoreValue $path $Key $stored "String"
|
||||
Write-LogDebug "Setting '$Key' set to '$stored' at '$path'"
|
||||
|
||||
# -SubPath forwarded, or -PassThru on an unregistered key writes the value and
|
||||
# then fails to read back the very path it just wrote to.
|
||||
if($PassThru) { Resolve-SettingValue -Key $Key -TenantID $TenantID -SubPath $SubPath }
|
||||
}
|
||||
|
||||
# Remove a setting by key so it falls back to the next level: a tenant value
|
||||
# reverts to the global one, a global value to the registered default.
|
||||
function Remove-SettingValue
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Key,
|
||||
[switch]$Tenant,
|
||||
[string]$TenantID,
|
||||
$SubPath = $null
|
||||
)
|
||||
|
||||
$definition = Get-SettingDefinitionByKey $Key
|
||||
$path = Resolve-SettingStorePath -Key $Key -Definition $definition -Tenant:$Tenant -TenantID $TenantID -SubPath $SubPath
|
||||
if($null -eq $path) { return }
|
||||
|
||||
Remove-SettingStoreValue $path $Key
|
||||
Write-LogDebug "Setting '$Key' removed from '$path'"
|
||||
}
|
||||
|
||||
# Is this key written at the level asked about? Not "does it have a value" - an
|
||||
# unconfigured key always has a value, its registered default.
|
||||
function Test-SettingValueConfigured
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Key,
|
||||
[switch]$Tenant,
|
||||
[string]$TenantID,
|
||||
$SubPath = $null
|
||||
)
|
||||
|
||||
$definition = Get-SettingDefinitionByKey $Key
|
||||
$path = Resolve-SettingStorePath -Key $Key -Definition $definition -Tenant:$Tenant -TenantID $TenantID -SubPath $SubPath
|
||||
if($null -eq $path) { return $false }
|
||||
|
||||
Test-SettingStoreValue $path $Key
|
||||
}
|
||||
|
||||
# The value AND where it came from. Get-SettingValue answers "what is it"; this
|
||||
# answers "and why", which is what a runbook needs before it decides to change
|
||||
# something and what the settings form needs to show a tenant override.
|
||||
#
|
||||
# Computed fresh from the store every call. Get-SettingValue caches its last read
|
||||
# on the definition object as .Value, which is a per-session artifact of whoever
|
||||
# read it last (and with which -TenantID) - not a fact about the store.
|
||||
function Resolve-SettingValue
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Key,
|
||||
[string]$TenantID,
|
||||
[switch]$GlobalOnly,
|
||||
[switch]$TenantOnly,
|
||||
$SubPath = $null
|
||||
)
|
||||
|
||||
$definition = Get-SettingDefinitionByKey $Key
|
||||
$registered = $null -ne $definition
|
||||
if(-not $registered)
|
||||
{
|
||||
# No registration means no SubPath, no Type and no default. The caller can
|
||||
# supply the path instead, which is what makes a hidden key (ExportReplaceTokens,
|
||||
# per-feature state) readable through the same resolver - and therefore through
|
||||
# Get-IMSetting - rather than only writable. Without this the write side accepted
|
||||
# -SubPath and the read side had no way to address what it had just written.
|
||||
if($null -eq $SubPath)
|
||||
{
|
||||
Write-LogError "'$Key' is not a registered setting, so it has no definition to resolve against. Pass -SubPath to read it anyway."
|
||||
return
|
||||
}
|
||||
|
||||
# Stands in for a registration so the rest of this function, and
|
||||
# Resolve-SettingStorePath, need no unregistered-key branch. Type and
|
||||
# DefaultValue stay $null: an unregistered key has no declared type to
|
||||
# normalize to and no default to fall back on.
|
||||
$definition = [PSCustomObject]@{
|
||||
Key = $Key
|
||||
Title = $null
|
||||
Type = $null
|
||||
DefaultValue = $null
|
||||
SubPath = $SubPath
|
||||
Section = $null
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $TenantID -and $script:OrganizationId) { $TenantID = $script:OrganizationId }
|
||||
|
||||
# A tenant-scoped read with no tenant to read against cannot be answered.
|
||||
# Falling through would have reported the registered default as though it were
|
||||
# the tenant's value, so a caller checking "does this tenant override the
|
||||
# setting?" got a confident answer while signed out. The write side already
|
||||
# refuses this case (Resolve-SettingStorePath), so refusing it here keeps the
|
||||
# two consistent.
|
||||
if($TenantOnly -eq $true -and -not $TenantID)
|
||||
{
|
||||
Write-LogError "Cannot resolve the tenant value of '$Key': no tenant id was supplied and no tenant is connected."
|
||||
return
|
||||
}
|
||||
|
||||
$value = $null
|
||||
$source = "Default"
|
||||
|
||||
# Both paths come from Resolve-SettingStorePath rather than being spelled here,
|
||||
# so this reader cannot drift from the writer - including for a root-stored key,
|
||||
# where composing the tenant path by hand leaves a trailing separator.
|
||||
if($GlobalOnly -ne $true -and $TenantID)
|
||||
{
|
||||
$tenantPath = Resolve-SettingStorePath -Key $Key -Definition $definition -Tenant -TenantID $TenantID -SubPath $SubPath
|
||||
if($null -ne $tenantPath)
|
||||
{
|
||||
$value = Get-SettingStoreValue $tenantPath $definition.Key
|
||||
if($null -ne $value) { $source = "Tenant" }
|
||||
}
|
||||
}
|
||||
|
||||
if($null -eq $value -and $TenantOnly -ne $true)
|
||||
{
|
||||
$value = Get-SettingStoreValue (Resolve-SettingStorePath -Key $Key -Definition $definition -SubPath $SubPath) $definition.Key
|
||||
if($null -ne $value) { $source = "Global" }
|
||||
}
|
||||
|
||||
# A scoped read reports the ABSENCE of a value at that scope as $null/"NotSet",
|
||||
# never as the registered default: "this tenant does not override the setting"
|
||||
# and "this tenant overrides it to the same value as the default" are different
|
||||
# facts, and substituting the default made them indistinguishable. Only an
|
||||
# unscoped (Effective) read falls back to the default, which is what the
|
||||
# application itself resolves.
|
||||
#
|
||||
# An unregistered key reports NotSet at every scope, Effective included: it has
|
||||
# no registered default, so "Default" there would name a fallback that does not
|
||||
# exist and report $null as though the value had been resolved.
|
||||
if($null -eq $value -and (-not $registered -or $TenantOnly -eq $true -or $GlobalOnly -eq $true))
|
||||
{
|
||||
$source = "NotSet"
|
||||
}
|
||||
elseif($null -eq $value)
|
||||
{
|
||||
$value = $definition.DefaultValue
|
||||
}
|
||||
|
||||
# Same normalization as Get-SettingValue, so both agree on a stored "False".
|
||||
if($definition.Type -eq "Boolean" -and $null -ne $value)
|
||||
{
|
||||
$value = $value -eq $true -or $value -eq "true"
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
Key = $definition.Key
|
||||
Value = $value
|
||||
Source = $source
|
||||
Type = $definition.Type
|
||||
SubPath = $definition.SubPath
|
||||
Section = $definition.Section
|
||||
Default = $definition.DefaultValue
|
||||
TenantID = $TenantID
|
||||
Title = $definition.Title
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Portable settings - a store as a file, independent of where it lives
|
||||
|
||||
# Write the whole current store to a JSON file, whatever store it is. This is the
|
||||
# other half of the runbook story: check a settings file into source control, then
|
||||
# load it into an in-memory store at the start of a run so the worker's own
|
||||
# registry and settings file are neither read nor written.
|
||||
function Export-SettingsStoreToFile
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param([string]$Path)
|
||||
|
||||
$tree = $script:JsonSettingsObj
|
||||
if(-not $tree) { $tree = Get-PersistedSettingsTree }
|
||||
if(-not $tree) { $tree = [PSCustomObject]@{} }
|
||||
|
||||
try
|
||||
{
|
||||
$fi = [IO.FileInfo]$Path
|
||||
if($fi.Directory -and -not $fi.Directory.Exists) { $fi.Directory.Create() }
|
||||
|
||||
$tree | ConvertTo-Json -Depth 20 | Out-File -LiteralPath $Path -Force -Encoding utf8
|
||||
$count = @(Get-SettingsTreeEntries $tree "").Count
|
||||
Write-Log "Exported $count settings to $Path"
|
||||
return $true
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-LogError "Failed to export settings to $Path" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Load a settings file into the ACTIVE store, value by value through
|
||||
# Save-SettingStoreValue rather than by swapping the tree. That is what makes one
|
||||
# implementation correct for all three modes: the values persist in Json mode, land
|
||||
# in the registry in Registry mode, and stay in memory in Memory mode - and an
|
||||
# imported file merges into what is already there instead of replacing it.
|
||||
#
|
||||
# For a clean slate, switch to an unseeded memory store first
|
||||
# (Use-IMSettingsStore -Memory) and import into that.
|
||||
function Import-SettingsStoreFromFile
|
||||
{
|
||||
[CmdletBinding()]
|
||||
param([string]$Path)
|
||||
|
||||
if(-not [IO.File]::Exists($Path))
|
||||
{
|
||||
Write-LogError "Settings file '$Path' does not exist"
|
||||
return
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$tree = ConvertFrom-Json ([IO.File]::ReadAllText($Path))
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-LogError "Failed to read settings file '$Path'" $_.Exception
|
||||
return
|
||||
}
|
||||
|
||||
$entries = @(Get-SettingsTreeEntries $tree "")
|
||||
$unknown = @()
|
||||
|
||||
foreach($entry in $entries)
|
||||
{
|
||||
# A key with no registration is not an error - the hidden keys
|
||||
# (ExportReplaceTokens) and the per-feature state namespaces are real and
|
||||
# deliberately unregistered. A typo looks exactly the same though, and
|
||||
# would silently do nothing, so say which ones they were.
|
||||
if(-not (Get-SettingDefinitionByKey $entry.Key)) { $unknown += $entry.Key }
|
||||
|
||||
Save-SettingStoreValue $entry.SubPath $entry.Key $entry.Value "String"
|
||||
}
|
||||
|
||||
if($unknown.Count -gt 0)
|
||||
{
|
||||
Write-Log "Imported $($unknown.Count) value(s) for keys that are not registered settings: $(($unknown | Sort-Object -Unique) -join ', ')" 2
|
||||
}
|
||||
|
||||
Write-Log "Imported $($entries.Count) settings from $Path into the $((Get-SettingsStoreInfo).Mode) store"
|
||||
return $entries.Count
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Cancel affordance for the status overlay.
|
||||
#
|
||||
# The app is effectively single-threaded: a long wait (device-code sign-in, a
|
||||
# browser round trip) runs a loop on the UI thread and keeps the window alive by
|
||||
# pumping messages (Invoke-UIPump). Because that pump already runs inside both
|
||||
# login wait loops, a button on the status overlay DOES get its click dispatched -
|
||||
# which is what makes a Cancel button possible at all without a threading rewrite.
|
||||
#
|
||||
# This file owns only the state and the contract. It deliberately holds no UI
|
||||
# types (R12): the button lives in each backend, and its click handler calls
|
||||
# Request-StatusCancel by NAME - which also satisfies the Avalonia rule that an
|
||||
# event handler may not rely on captured locals.
|
||||
#
|
||||
# Two separate things are recorded:
|
||||
#
|
||||
# the ACTION what actually stops the pending operation (cancel a
|
||||
# CancellationTokenSource, stop an HttpListener). Supplied by the
|
||||
# caller that knows how, via Write-Status -OnCancel.
|
||||
# the REQUEST a sticky flag the waiting loop polls, so it can break out
|
||||
# promptly instead of running to its timeout.
|
||||
#
|
||||
# Both matter. Without the action the operation keeps running invisibly to its
|
||||
# timeout; without the flag the loop would not notice for up to its poll interval
|
||||
# (or at all, if the underlying wait is not cancellable).
|
||||
|
||||
$script:StatusCancelAction = $null
|
||||
$script:StatusCancelRequested = $false
|
||||
|
||||
# Arm the overlay's cancel button. Called from Write-Status -OnCancel; also
|
||||
# callable directly by code that wants the action armed without touching the
|
||||
# status text.
|
||||
function Set-StatusCancelAction
|
||||
{
|
||||
param([scriptblock]$Action)
|
||||
|
||||
$script:StatusCancelAction = $Action
|
||||
# Arming starts a NEW cancellable operation, so a request left over from a
|
||||
# previous one must not immediately cancel it.
|
||||
$script:StatusCancelRequested = $false
|
||||
}
|
||||
|
||||
# Disarm. Called when the status overlay is cleared, and from the finally block of
|
||||
# whatever armed it, so a stale action can never fire against a finished operation.
|
||||
function Clear-StatusCancelAction
|
||||
{
|
||||
$script:StatusCancelAction = $null
|
||||
$script:StatusCancelRequested = $false
|
||||
}
|
||||
|
||||
# True once the user has asked to cancel. Waiting loops poll this.
|
||||
function Test-StatusCancelRequested
|
||||
{
|
||||
return ($script:StatusCancelRequested -eq $true)
|
||||
}
|
||||
|
||||
# Sleep for up to -Seconds, in slices, pumping the UI between them so the overlay's
|
||||
# Cancel button stays clickable, and return as soon as cancel is requested.
|
||||
# Returns $true when the wait ended because of a cancel.
|
||||
#
|
||||
# A plain Start-Sleep of several seconds blocks the single UI thread outright: no
|
||||
# repaint, no click dispatch, so a Cancel button would be dead for the whole nap.
|
||||
# Any polling loop that wants to be cancellable has to wait THIS way.
|
||||
function Wait-StatusCancel
|
||||
{
|
||||
param([int]$Seconds = 1, [int]$SliceMilliseconds = 100)
|
||||
|
||||
if($Seconds -le 0) { return (Test-StatusCancelRequested) }
|
||||
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
|
||||
while([DateTime]::UtcNow -lt $deadline)
|
||||
{
|
||||
if(Test-StatusCancelRequested) { return $true }
|
||||
Invoke-UIPump
|
||||
Start-Sleep -Milliseconds $SliceMilliseconds
|
||||
}
|
||||
return (Test-StatusCancelRequested)
|
||||
}
|
||||
|
||||
# Invoked by the overlay's Cancel button. Sets the flag first so the flag is
|
||||
# observable even if the action throws, then runs the action.
|
||||
function Request-StatusCancel
|
||||
{
|
||||
$script:StatusCancelRequested = $true
|
||||
|
||||
$action = $script:StatusCancelAction
|
||||
if(-not $action) { return }
|
||||
|
||||
# One shot: a second click must not run the action again (stopping an already
|
||||
# stopped listener throws).
|
||||
$script:StatusCancelAction = $null
|
||||
|
||||
Write-Log "Cancel requested from the status window"
|
||||
try { & $action }
|
||||
catch { Write-LogError "Status cancel action failed" $_.Exception }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# A wait that keeps the window alive.
|
||||
#
|
||||
# The engine runs on the UI thread in both backends. Write-Status pumps the UI
|
||||
# once when it sets a message, but a plain Start-Sleep afterwards blocks that
|
||||
# thread for the whole wait: no repaint, no input, and on Avalonia - which
|
||||
# renders on the UI thread - not even the layout pass for the text just set.
|
||||
# A Graph 429 back-off of 10 seconds, retried ten times, looked like a hang
|
||||
# with the throttle message painted over the line above it.
|
||||
#
|
||||
# Wait-UIAware sleeps in slices, pumps the message loop between them, and can
|
||||
# refresh a status detail with the seconds remaining so the wait visibly
|
||||
# counts down. Headless (no UI provider) it is just a sliced sleep.
|
||||
|
||||
function Wait-UIAware
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][double]$Seconds,
|
||||
# Status detail with {0} for the whole seconds remaining, refreshed on
|
||||
# every change. Omit to leave the status line alone.
|
||||
[string]$DetailFormat,
|
||||
[int]$SliceMilliseconds = 250
|
||||
)
|
||||
|
||||
if($Seconds -le 0) { return }
|
||||
if($SliceMilliseconds -lt 50) { $SliceMilliseconds = 50 }
|
||||
|
||||
$slices = [int][Math]::Ceiling(($Seconds * 1000) / $SliceMilliseconds)
|
||||
$lastShown = -1
|
||||
|
||||
for($i = 0; $i -lt $slices; $i++)
|
||||
{
|
||||
if($DetailFormat)
|
||||
{
|
||||
$remaining = [int][Math]::Ceiling($Seconds - (($i * $SliceMilliseconds) / 1000))
|
||||
if($remaining -ne $lastShown)
|
||||
{
|
||||
$lastShown = $remaining
|
||||
Write-Status -Detail ($DetailFormat -f $remaining) -SkipLog -Force
|
||||
}
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds $SliceMilliseconds
|
||||
|
||||
if($script:UIProvider)
|
||||
{
|
||||
try { $script:UIProvider.InvokeUIMessagePump() } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# The Graph throttle wording, shared by the batch and single-request 429 paths.
|
||||
function Wait-GraphThrottle
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][double]$Seconds,
|
||||
[string]$BatchType = 'Graph',
|
||||
[int]$Queued = 1
|
||||
)
|
||||
|
||||
Wait-UIAware -Seconds $Seconds -DetailFormat ("{0}: throttled by Graph - waiting {{0}}s ({1} request(s) queued)" -f $BatchType, $Queued)
|
||||
}
|
||||
Reference in New Issue
Block a user