Private/Invoke-IntuneCategoryScan.ps1

function Invoke-IntuneCategoryScan {
    [CmdletBinding()]
    # PSReviewUnusedParameter cannot trace usage inside the nested helper functions below
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ProcessEntity')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'EntityPreFilter')]
    param (
        [Parameter(Mandatory = $true)]
        [object[]]$Categories,

        # Per-cmdlet plugin. Receives one context object per entity:
        # {Category, Entity, Assignments (normalized), RawAssignments, Buckets}.
        # Invoked also for zero-assignment entities.
        [Parameter(Mandatory = $true)]
        [scriptblock]$ProcessEntity,

        [Parameter(Mandatory = $false)]
        [string[]]$AssignmentGroupIds = @(),

        # Invoked as: & $EntityPreFilter $entity $category. Entities that do not pass
        # never trigger an assignment fetch.
        [Parameter(Mandatory = $false)]
        [scriptblock]$EntityPreFilter,

        [Parameter(Mandatory = $false)]
        [switch]$ShowProgress,

        [Parameter(Mandatory = $false)]
        [string]$ProgressVerb = 'Fetching',

        # Caller-owned cache keyed by EntityType string; lets multi-target loops fetch
        # each entity set once per run.
        [Parameter(Mandatory = $false)]
        [hashtable]$EntityCache
    )

    if ($null -eq $EntityCache) { $EntityCache = @{} }

    $buckets = @{}
    foreach ($category in $Categories) {
        foreach ($bucketKey in $category.BucketKeys) {
            if (-not $buckets.ContainsKey($bucketKey)) {
                $buckets[$bucketKey] = [System.Collections.Generic.List[object]]::new()
            }
        }
    }

    $scanErrors = [System.Collections.Generic.List[object]]::new()

    function Get-CachedEntitySet {
        param([string]$EntityType)
        if (-not $EntityCache.ContainsKey($EntityType)) {
            $EntityCache[$EntityType] = @(Get-IntuneEntities -EntityType $EntityType)
        }
        return , @($EntityCache[$EntityType])
    }

    function Get-PagedGraphValue {
        param([string]$Uri)
        $items = [System.Collections.Generic.List[object]]::new()
        $currentUri = $Uri
        do {
            $response = Invoke-MgGraphRequest -Uri $currentUri -Method Get
            if ($response -and $null -ne $response.value) {
                $items.AddRange(@($response.value))
            }
            $currentUri = $response.'@odata.nextLink'
        } while (![string]::IsNullOrEmpty($currentUri))
        return , $items
    }

    # Normalizes raw Graph assignment objects to the standard record shape, reproducing
    # Get-IntuneAssignments' label duality: with AssignmentGroupIds only Direct Assignment /
    # Direct Exclusion for matching groups (All Users/All Devices suppressed); without them
    # Group Assignment / Group Exclusion plus All Users / All Devices.
    function ConvertTo-NormalizedAssignment {
        param([object[]]$RawAssignments)
        $normalized = [System.Collections.Generic.List[object]]::new()
        foreach ($assignment in $RawAssignments) {
            $reason = $null
            $targetGroupId = $null
            $odataType = if ($assignment.target) { $assignment.target.'@odata.type' } else { $null }

            if ($odataType -eq '#microsoft.graph.groupAssignmentTarget') {
                $targetGroupId = $assignment.target.groupId
                if ($AssignmentGroupIds.Count -gt 0) {
                    if ($AssignmentGroupIds -contains $targetGroupId) { $reason = 'Direct Assignment' }
                }
                else {
                    $reason = 'Group Assignment'
                }
            }
            elseif ($odataType -eq '#microsoft.graph.exclusionGroupAssignmentTarget') {
                $targetGroupId = $assignment.target.groupId
                if ($AssignmentGroupIds.Count -gt 0) {
                    if ($AssignmentGroupIds -contains $targetGroupId) { $reason = 'Direct Exclusion' }
                }
                else {
                    $reason = 'Group Exclusion'
                }
            }
            elseif ($AssignmentGroupIds.Count -eq 0) {
                $reason = switch ($odataType) {
                    '#microsoft.graph.allLicensedUsersAssignmentTarget' { 'All Users' }
                    '#microsoft.graph.allDevicesAssignmentTarget' { 'All Devices' }
                    default { $null }
                }
            }

            if ($reason) {
                $filterId = $null
                $filterType = $null
                if ($assignment.target) {
                    $rawFilterId = $assignment.target.deviceAndAppManagementAssignmentFilterId
                    $rawFilterType = $assignment.target.deviceAndAppManagementAssignmentFilterType
                    if ($rawFilterType -and $rawFilterType -ne 'none' -and $rawFilterId -and $rawFilterId -ne '00000000-0000-0000-0000-000000000000') {
                        $filterId = $rawFilterId
                        $filterType = $rawFilterType
                    }
                }

                $normalized.Add([PSCustomObject]@{
                        Reason     = $reason
                        GroupId    = $targetGroupId
                        FilterId   = $filterId
                        FilterType = $filterType
                    })
            }
        }
        return , $normalized
    }

    function Test-CategoryEntityPreFilter {
        param($Entity, $Category)
        if ($null -eq $EntityPreFilter) { return $true }
        return [bool](& $EntityPreFilter $Entity $Category)
    }

    function Invoke-ProcessEntityCallback {
        param($Category, $Entity, $Assignments, $RawAssignments)
        $context = [PSCustomObject]@{
            Category       = $Category
            Entity         = $Entity
            Assignments    = $Assignments
            RawAssignments = $RawAssignments
            Buckets        = $buckets
        }
        & $ProcessEntity $context
    }

    $fetchableCategories = @($Categories | Where-Object { -not $_.BucketOnly })
    $totalCategories = $fetchableCategories.Count
    $currentCategory = 0

    foreach ($category in $fetchableCategories) {
        $currentCategory++
        if ($ShowProgress) {
            Write-Host "[$currentCategory/$totalCategories] $ProgressVerb $($category.DisplayName)..." -ForegroundColor Yellow
        }

        try {
            switch ($category.Kind) {
                'Entity' {
                    $entities = Get-CachedEntitySet -EntityType $category.EntityType
                    if ($category.EntityFilter) {
                        $entities = @($entities | Where-Object $category.EntityFilter)
                    }
                    foreach ($entity in $entities) {
                        if (-not (Test-CategoryEntityPreFilter -Entity $entity -Category $category)) { continue }
                        $assignments = @(Get-IntuneAssignments -EntityType $category.AssignmentEntityType -EntityId $entity.id -GroupIds $AssignmentGroupIds)
                        Invoke-ProcessEntityCallback -Category $category -Entity $entity -Assignments $assignments -RawAssignments $null
                    }
                }
                'EndpointSecurity' {
                    # Two-phase lookup is retained intentionally: some assigned intents (e.g. a macOS
                    # FileVault intent) have no configurationPolicies counterpart because the MC955748
                    # migration was Windows-only.
                    $processedIds = [System.Collections.Generic.HashSet[string]]::new()

                    # Phase 1: Settings Catalog style ES policies from configurationPolicies
                    $configPolicies = Get-CachedEntitySet -EntityType $category.EntityType
                    $matchingConfigPolicies = @($configPolicies | Where-Object { $_.templateReference -and $_.templateReference.templateFamily -eq $category.TemplateFamily })
                    foreach ($policy in $matchingConfigPolicies) {
                        if (-not $processedIds.Add([string]$policy.id)) { continue }
                        if (-not (Test-CategoryEntityPreFilter -Entity $policy -Category $category)) { continue }
                        $assignments = @(Get-IntuneAssignments -EntityType $category.AssignmentEntityType -EntityId $policy.id -GroupIds $AssignmentGroupIds)
                        Invoke-ProcessEntityCallback -Category $category -Entity $policy -Assignments $assignments -RawAssignments $null
                    }

                    # Phase 2: legacy template style ES policies from deviceManagement/intents
                    $intents = Get-CachedEntitySet -EntityType 'deviceManagement/intents'
                    Add-IntentTemplateFamilyInfo -IntentPolicies $intents
                    $matchingIntents = @($intents | Where-Object { $_.templateReference -and $_.templateReference.templateFamily -eq $category.TemplateFamily })
                    foreach ($policy in $matchingIntents) {
                        if (-not $processedIds.Add([string]$policy.id)) { continue }
                        if (-not (Test-CategoryEntityPreFilter -Entity $policy -Category $category)) { continue }
                        $rawAssignments = Get-PagedGraphValue -Uri "$script:GraphEndpoint/beta/deviceManagement/intents/$($policy.id)/assignments"
                        $assignments = ConvertTo-NormalizedAssignment -RawAssignments $rawAssignments
                        Invoke-ProcessEntityCallback -Category $category -Entity $policy -Assignments $assignments -RawAssignments $rawAssignments
                    }
                }
                'AppProtection' {
                    $policies = Get-CachedEntitySet -EntityType $category.EntityType
                    if ($category.EntityFilter) {
                        $policies = @($policies | Where-Object $category.EntityFilter)
                    }
                    foreach ($policy in $policies) {
                        if (-not (Test-CategoryEntityPreFilter -Entity $policy -Category $category)) { continue }
                        $assignmentsUri = Get-AppProtectionAssignmentUri -Policy $policy
                        $rawAssignments = if ($assignmentsUri) {
                            Get-PagedGraphValue -Uri $assignmentsUri
                        }
                        else {
                            [System.Collections.Generic.List[object]]::new()
                        }
                        $assignments = ConvertTo-NormalizedAssignment -RawAssignments $rawAssignments
                        Invoke-ProcessEntityCallback -Category $category -Entity $policy -Assignments $assignments -RawAssignments $rawAssignments
                    }
                }
                'MobileApps' {
                    if (-not $EntityCache.ContainsKey($category.EntityType)) {
                        $EntityCache[$category.EntityType] = Get-PagedGraphValue -Uri "$script:GraphEndpoint/beta/deviceAppManagement/mobileApps?`$filter=isAssigned eq true"
                    }
                    $allApps = @($EntityCache[$category.EntityType])
                    if ($category.EntityFilter) {
                        $allApps = @($allApps | Where-Object $category.EntityFilter)
                    }
                    foreach ($app in $allApps) {
                        if (-not (Test-CategoryEntityPreFilter -Entity $app -Category $category)) { continue }
                        $rawAssignments = Get-PagedGraphValue -Uri "$script:GraphEndpoint/beta/deviceAppManagement/mobileApps('$($app.id)')/assignments"
                        $assignments = ConvertTo-NormalizedAssignment -RawAssignments $rawAssignments
                        Invoke-ProcessEntityCallback -Category $category -Entity $app -Assignments $assignments -RawAssignments $rawAssignments
                    }
                }
                default {
                    throw "Unknown category kind '$($category.Kind)' for category '$($category.Id)'."
                }
            }
        }
        catch {
            if ($category.OptionalFeature) {
                # Optional features (e.g. Windows 365) fail quietly when the tenant is not licensed
                Write-Verbose "Skipping optional category '$($category.DisplayName)': $($_.Exception.Message)"
                continue
            }
            $scanErrors.Add([PSCustomObject]@{
                    CategoryId  = $category.Id
                    DisplayName = $category.DisplayName
                    Message     = $_.Exception.Message
                    ErrorRecord = $_
                })
            Write-Host "Error processing category '$($category.DisplayName)': $($_.Exception.Message)" -ForegroundColor Red
            Write-Error -Message "Category '$($category.Id)' failed: $($_.Exception.Message)"
            continue
        }
    }

    return [PSCustomObject]@{
        Buckets       = $buckets
        Errors        = $scanErrors
        CategoryCount = $totalCategories
    }
}