mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
358 lines
18 KiB
PowerShell
358 lines
18 KiB
PowerShell
function Get-GraphPolicies {
|
|
[CmdletBinding(DefaultParameterSetName = 'PolicyType')]
|
|
[OutputType([IntunePolicyBase[]])]
|
|
param(
|
|
[Parameter(Mandatory = $true, ParameterSetName = 'PolicyType', Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
|
|
# Tab-completion for registered PolicyType IDs. Deliberately NOT a
|
|
# [ValidateSet([IntunePolicyTypeValues])]: a generator-backed ValidateSet
|
|
# (1) makes the cmdlet uncallable when no types are loaded yet (the
|
|
# generator returns an empty set and binding throws "validValues out of
|
|
# range"), and (2) cannot be mocked by Pester 3.4. Unknown ids are already
|
|
# filtered out below (Where-Object Id -eq), so completion is enough.
|
|
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) @(& (Get-Module IntuneManagement) { Get-IntunePolicyTypeValues }) | Where-Object { $_ -like "$wordToComplete*" } })]
|
|
[string[]]$PolicyType,
|
|
[Parameter(Mandatory = $true, ParameterSetName = 'PolicyGroup', Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
|
|
[ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) @(& (Get-Module IntuneManagement) { Get-IntunePolicyGroupValues }) | Where-Object { $_ -like "$wordToComplete*" } })]
|
|
[string[]]$PolicyGroup,
|
|
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyType')]
|
|
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyGroup')]
|
|
[switch]
|
|
$SinglePage,
|
|
[string]
|
|
[ValidateSet("NextPage", "AllRemainingPages")]
|
|
[Parameter(Mandatory = $true, ParameterSetName = 'ObjectsPaging')]
|
|
$Paging,
|
|
|
|
# When set, the returned policies will have their .Object.assignments populated.
|
|
# Policy types that don't support assignments (SupportsAssignments = $false) are filtered out.
|
|
# For types where assignments cannot be expanded inline, /assignments is batch-fetched after listing.
|
|
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyType')]
|
|
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyGroup')]
|
|
[switch]
|
|
$IncludeAssignments,
|
|
|
|
# Name prefix to search for. Sent to Graph as startswith() on the type's
|
|
# name property when the endpoint supports it (PolicyType.SupportsNameFilter);
|
|
# the result set is always re-checked client-side, so types whose endpoint
|
|
# rejects the filter still return only matching policies.
|
|
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyType')]
|
|
[Parameter(Mandatory = $false, ParameterSetName = 'PolicyGroup')]
|
|
[string]
|
|
$NameFilter,
|
|
|
|
# Specifies environment to get policies from. Default is current logged on environment.
|
|
[Int]
|
|
$TokenId = (Get-DefaultTokenId)
|
|
)
|
|
|
|
$params = @{}
|
|
$graphPolicies = [System.Collections.Generic.List[IntunePolicyBase]]::new()
|
|
$newPageObject = $false
|
|
|
|
# URL-keyed coalescing: when N policy types resolve to the same listing URL
|
|
# (e.g. all 6 deviceEnrollmentConfigurations subtypes), we issue ONE list request
|
|
# and run each row through every consumer's CheckPolicy in registration order —
|
|
# first non-null match wins. Without this, group bulk-exports were issuing 5+
|
|
# identical sub-requests in the $batch and discarding 80% of each response.
|
|
#
|
|
# Entry shapes:
|
|
# batch entry : { BatchObject = <{id,method,url,headers}>, Types = [PolicyType...] }
|
|
# api entry : { ListURL = <string>, Types = [PolicyType...] }
|
|
# The Types list preserves registration order so CheckPolicy ties are deterministic.
|
|
$batchEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$apiEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
|
|
if ($PSCmdlet.ParameterSetName -ne "ObjectsPaging") {
|
|
|
|
$policyTypes = [System.Collections.Generic.List[object]]::new()
|
|
|
|
# -PolicyType and -PolicyGroup are separate parameter sets, so only one is
|
|
# ever populated. An unknown id is logged and raised as a non-terminating
|
|
# error by the resolver - the same contract as the bulk drivers, which this
|
|
# cmdlet used to fall short of by dropping it in silence.
|
|
$selection = Resolve-IntuneTargetSelectors -PolicyType $PolicyType -PolicyGroup $PolicyGroup -Caller 'Get-GraphPolicies'
|
|
$policyTypes.AddRange([object[]]$selection.Types)
|
|
|
|
# IMPORTANT: -IncludeAssignments controls whether assignments get fetched
|
|
# alongside the policies, NOT which types get listed. Types with
|
|
# SupportsAssignments=$false (Conditional Access, Named Locations, Terms of
|
|
# Use, Filters, Role Definitions, ADMX Files, Reusable Settings, several
|
|
# Tenant-Admin extras, etc.) still need to be listed — the assignment-fetch
|
|
# in Add-GraphPolicyAssignments already skips them at the per-policy level.
|
|
# Filtering them out here used to drop the whole type silently from bulk
|
|
# export, which is why those folders were empty.
|
|
|
|
# Every type that can describe its list call as a batch sub-request does so,
|
|
# whatever the batching setting says: Invoke-GraphBatchRequest decides whether
|
|
# those go out as one $batch or as direct calls, and either way the sub-
|
|
# request carries the type's own Accept header. Choosing the plain-URL branch
|
|
# here when batching was off sent the list call with the wrapper's default
|
|
# metadata instead, so an Applications export with batching off carried
|
|
# @odata annotations and navigation links the batched export never had.
|
|
$useBatchAPI = $true
|
|
|
|
# Coalesce by URL only for types that verify each returned row. Types that
|
|
# accept every row must keep their own request; otherwise the first broad
|
|
# type on a shared endpoint can absorb siblings and silently misclassify
|
|
# policies.
|
|
$batchByUrl = @{}
|
|
$apiByUrl = @{}
|
|
|
|
foreach ($policyTypeObj in $policyTypes) {
|
|
$batchObject = $null
|
|
if ($useBatchAPI) { $batchObject = $policyTypeObj.GetListBatchObject($NameFilter) }
|
|
if ($batchObject) {
|
|
$key = if($policyTypeObj.VerifyObject) { $batchObject.url } else { "$($batchObject.url)|$($policyTypeObj.Id)" }
|
|
if ($batchByUrl.ContainsKey($key)) {
|
|
[void]$batchByUrl[$key].Types.Add($policyTypeObj)
|
|
}
|
|
else {
|
|
$entry = [PSCustomObject]@{
|
|
BatchObject = $batchObject
|
|
Types = [System.Collections.Generic.List[object]]@($policyTypeObj)
|
|
}
|
|
$batchByUrl[$key] = $entry
|
|
[void]$batchEntries.Add($entry)
|
|
}
|
|
continue
|
|
}
|
|
|
|
$listURL = $policyTypeObj.GetListURL($NameFilter)
|
|
if (-not $listURL) { continue }
|
|
$listKey = if($policyTypeObj.VerifyObject) { $listURL } else { "$listURL|$($policyTypeObj.Id)" }
|
|
if ($apiByUrl.ContainsKey($listKey)) {
|
|
[void]$apiByUrl[$listKey].Types.Add($policyTypeObj)
|
|
}
|
|
else {
|
|
$entry = [PSCustomObject]@{
|
|
ListURL = $listURL
|
|
Types = [System.Collections.Generic.List[object]]@($policyTypeObj)
|
|
}
|
|
$apiByUrl[$listKey] = $entry
|
|
[void]$apiEntries.Add($entry)
|
|
}
|
|
}
|
|
|
|
if ($SinglePage -eq $true) { $newPageObject = $true }
|
|
else { $params.Add("AllPages", $true) }
|
|
}
|
|
elseif ($script:GraphPagingCache) {
|
|
# Resume paging: cache stores the same {BatchObject,Types} / {ListURL,Types}
|
|
# entries we built above, with BatchObject.url already set to the next-page link.
|
|
foreach ($e in $script:GraphPagingCache.BatchTypes) { [void]$batchEntries.Add($e) }
|
|
foreach ($e in $script:GraphPagingCache.APITypes) { [void]$apiEntries.Add($e) }
|
|
# The nextLink carries any server-side filter, but the client-side
|
|
# re-check below needs the original search text too.
|
|
$NameFilter = $script:GraphPagingCache.NameFilter
|
|
if ($Paging -eq "AllRemainingPages") { $params.Add("AllPages", $true) }
|
|
$newPageObject = $true
|
|
}
|
|
else {
|
|
Write-LogDebug "No more pages"
|
|
return
|
|
}
|
|
|
|
$params.Add("TokenId", $TokenId)
|
|
|
|
if ($newPageObject -eq $true) {
|
|
$script:GraphPagingCache = [PSCustomObject]@{
|
|
BatchObjects = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
BatchTypes = [System.Collections.Generic.List[object]]::new()
|
|
APITypes = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
NameFilter = $NameFilter
|
|
}
|
|
}
|
|
else {
|
|
$script:GraphPagingCache = $null
|
|
}
|
|
|
|
# A name filter can be rejected by an endpoint we have not probed (several
|
|
# Intune endpoints answer 400 or even 500 to any $filter). Retrying that
|
|
# request unfiltered turns "the search silently found nothing" into "the
|
|
# search worked, just slower" - the client-side re-check at the end still
|
|
# narrows the result. Only the first attempt retries, and never while
|
|
# resuming paging (those URLs are nextLinks, not ones we can rebuild).
|
|
$canRetryUnfiltered = ($NameFilter -and $PSCmdlet.ParameterSetName -ne "ObjectsPaging")
|
|
|
|
$pendingBatchEntries = $batchEntries
|
|
$batchAttempt = 0
|
|
while ($pendingBatchEntries.Count -gt 0) {
|
|
# Flatten entries into the batch-objects list passed to Invoke-GraphBatchRequest,
|
|
# and build a sub-request-id -> entry lookup so we can fan rows back out.
|
|
$batchObjects = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$entryById = @{}
|
|
foreach ($entry in $pendingBatchEntries) {
|
|
[void]$batchObjects.Add($entry.BatchObject)
|
|
$entryById["$($entry.BatchObject.id)"] = $entry
|
|
}
|
|
|
|
# -IncludedFailed: a rejected name filter comes back as a failed sub-result
|
|
# (a 400 body from $batch, or status 0 with no body from a direct call) and
|
|
# the retry below needs to see it. Without it the dispatcher dropped failed
|
|
# results before this loop, so the unfiltered retry never fired.
|
|
$batchResults = Invoke-GraphBatchRequest $batchObjects "Policy objects" @params -IncludedFailed
|
|
$retryBatchEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
|
|
foreach ($batchResult in $batchResults) {
|
|
$entry = $entryById["$($batchResult.ID)"]
|
|
if (-not $entry) { continue }
|
|
|
|
# Failed = no body at all (a direct call that returned nothing), an error body
|
|
# (a $batch sub-result), or an explicit non-2xx status. A body with no status
|
|
# property is a success - some result shapes never carried one.
|
|
$listFailed = ((-not $batchResult.body) -or
|
|
($batchResult.body.PSObject.Properties['error']) -or
|
|
($batchResult.PSObject.Properties['Status'] -and [int]$batchResult.Status -ge 300))
|
|
if ($canRetryUnfiltered -and $batchAttempt -eq 0 -and $listFailed) {
|
|
$unfilteredUrl = $entry.Types[0].GetListBatchObject().url
|
|
if ($unfilteredUrl -ne $entry.BatchObject.url) {
|
|
Write-Log "$($entry.Types[0].ID): endpoint rejected the name filter - retrying without it and filtering locally" 2
|
|
$entry.BatchObject.url = $unfilteredUrl
|
|
[void]$retryBatchEntries.Add($entry)
|
|
continue
|
|
}
|
|
}
|
|
if ($listFailed) {
|
|
Write-Log "$($entry.Types[0].ID): list request failed (status $($batchResult.Status)) - no objects for this type" 2
|
|
continue
|
|
}
|
|
|
|
if ($batchResult.body.value) {
|
|
foreach ($v in $batchResult.body.value) {
|
|
# Walk consumers in registration order; first CheckPolicy
|
|
# that accepts the row (GetObject returns non-null) wins.
|
|
foreach ($pt in $entry.Types) {
|
|
$tmpPolicy = $pt.GetObject($v)
|
|
if ($tmpPolicy) {
|
|
[void]$graphPolicies.Add($tmpPolicy)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
elseif ($entry.Types[0].SingleObject -and $batchResult.body -and -not $batchResult.body.PSObject.Properties['error']) {
|
|
# Single-object endpoint: the body IS the object — no value array.
|
|
foreach ($pt in $entry.Types) {
|
|
$tmpPolicy = $pt.GetObject($batchResult.body)
|
|
if ($tmpPolicy) {
|
|
[void]$graphPolicies.Add($tmpPolicy)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($batchResult.body.'@odata.nextLink' -and -not $params.Contains("AllPages")) {
|
|
$nextLink = $batchResult.body.'@odata.nextLink'
|
|
# Use the first sibling's API for the offset slice — siblings share _API by definition.
|
|
$apiPrefix = $entry.Types[0].API
|
|
$entry.BatchObject.url = $nextLink.Substring($nextLink.IndexOf($apiPrefix))
|
|
[void]$script:GraphPagingCache.BatchObjects.Add($entry.BatchObject)
|
|
[void]$script:GraphPagingCache.BatchTypes.Add($entry)
|
|
}
|
|
}
|
|
|
|
$pendingBatchEntries = $retryBatchEntries
|
|
$batchAttempt++
|
|
}
|
|
|
|
# For APIs not supported in batch requests
|
|
foreach ($entry in $apiEntries) {
|
|
$responseContent = Invoke-MSGraphAPI -Url $entry.ListURL @params
|
|
|
|
if ($canRetryUnfiltered -and -not $responseContent) {
|
|
$unfilteredUrl = $entry.Types[0].GetListURL()
|
|
if ($unfilteredUrl -ne $entry.ListURL) {
|
|
Write-Log "$($entry.Types[0].ID): endpoint rejected the name filter - retrying without it and filtering locally" 2
|
|
$responseContent = Invoke-MSGraphAPI -Url $unfilteredUrl @params
|
|
}
|
|
}
|
|
|
|
$primaryId = $entry.Types[0].ID
|
|
$extra = if ($entry.Types.Count -gt 1) { " (+$($entry.Types.Count - 1) sibling type(s))" } else { "" }
|
|
Write-Log "Value return count for $primaryId$extra $(($responseContent.value | Measure-Object).Count)"
|
|
foreach ($listObject in $responseContent.value) {
|
|
foreach ($pt in $entry.Types) {
|
|
$tmpPolicy = $pt.GetObject($listObject)
|
|
if ($tmpPolicy) {
|
|
[void]$graphPolicies.Add($tmpPolicy)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (-not $responseContent.value -and $entry.Types[0].SingleObject -and $responseContent) {
|
|
# Single-object endpoint: the response IS the object — no value array.
|
|
foreach ($pt in $entry.Types) {
|
|
$tmpPolicy = $pt.GetObject($responseContent)
|
|
if ($tmpPolicy) {
|
|
[void]$graphPolicies.Add($tmpPolicy)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if ($responseContent.'@odata.nextLink' -and -not $params.Contains("AllPages")) {
|
|
$entry.ListURL = $responseContent.'@odata.nextLink'
|
|
[void]$script:GraphPagingCache.APITypes.Add($entry)
|
|
}
|
|
}
|
|
|
|
if ($script:GraphPagingCache -and $script:GraphPagingCache.BatchObjects.Count -eq 0 -and $script:GraphPagingCache.APITypes.Count -eq 0) {
|
|
$script:GraphPagingCache = $null
|
|
}
|
|
|
|
if ($NameFilter) {
|
|
# Re-check every row client-side. Not every endpoint honours the
|
|
# server-side clause (PolicyType.SupportsNameFilter is $false for
|
|
# deviceCompliancePolicies v1 and assignmentFilters), and a type whose
|
|
# CheckPolicy claims a row from a coalesced sibling request may not have
|
|
# been filtered at all.
|
|
$matching = [System.Collections.Generic.List[IntunePolicyBase]]::new()
|
|
foreach ($p in $graphPolicies) {
|
|
# Same semantics as the server-side clause: case-insensitive substring.
|
|
if ("$($p.Name)".IndexOf($NameFilter, [System.StringComparison]::InvariantCultureIgnoreCase) -ge 0) {
|
|
[void]$matching.Add($p)
|
|
}
|
|
}
|
|
$graphPolicies = $matching
|
|
}
|
|
|
|
if ($graphPolicies.Count -gt 0) {
|
|
# Resolve the tenant id provider-agnostically. Get-TokenInfo only sees the
|
|
# MSAL token registry, so on the MgGraph provider $tokenInfo is $null and
|
|
# every policy ended up with TenantID = $null — which Export-GraphPolicy
|
|
# then silently skipped at the `-not $policyObject.TenantID` guard, leading
|
|
# to "Exported N policies" telemetry with zero files on disk.
|
|
#
|
|
# Get-OperationTokenInfo, not Get-TokenInfo: Graph runs this call against ONE
|
|
# token, and 0 (the default parameter value, and the caller's spelling for
|
|
# "the default token") means "no filter, every token" to Get-TokenInfo. With a
|
|
# second tenant signed in, every listed policy was stamped with an ARRAY of
|
|
# tenant ids and an array _TokenID, so the export masked the wrong tenant and
|
|
# the per-tenant settings lookups keyed off a joined string.
|
|
$tokenInfo = Get-OperationTokenInfo $TokenId
|
|
$tenantId = if ($tokenInfo) { $tokenInfo.TenantID } else { $null }
|
|
$tokenIdVal = if ($tokenInfo) { $tokenInfo.Id } else { 0 }
|
|
if (-not $tenantId) {
|
|
try {
|
|
$provider = Get-AuthProvider
|
|
if ($provider) {
|
|
$userInfo = $provider.GetUserInfo($TokenId)
|
|
if ($userInfo -and $userInfo.TenantId) { $tenantId = $userInfo.TenantId }
|
|
}
|
|
} catch { }
|
|
}
|
|
if (-not $tenantId) {
|
|
Write-Log "Get-GraphPolicies: could not resolve TenantID for export - policies will be missing this property" 2
|
|
}
|
|
foreach ($p in $graphPolicies) {
|
|
$p.TenantID = $tenantId
|
|
$p._TokenID = $tokenIdVal
|
|
}
|
|
|
|
if ($IncludeAssignments -eq $true) {
|
|
Add-GraphPolicyAssignments -Policies $graphPolicies -TokenId $TokenId
|
|
}
|
|
}
|
|
|
|
return $graphPolicies.ToArray()
|
|
}
|