Files
IntuneManagement/Public/Get-GraphPolicies.ps1
T

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()
}