Files
IntuneManagement/Internal/PolicyTypeMetadataValidation.ps1

179 lines
6.2 KiB
PowerShell

<#
.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
}
}