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,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()
|
||||
}
|
||||
Reference in New Issue
Block a user