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')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'BuildRecords')]
    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,

        [Parameter(Mandatory = $false)]
        [switch]$BuildRecords
    )

    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()
    $skippedCategories = [System.Collections.Generic.List[object]]::new()
    $records = [System.Collections.Generic.List[object]]::new()

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

    function Get-PagedGraphValue {
        param([string]$Uri)
        return , @((Invoke-IACGraphRequest -Uri $Uri -Method Get).value)
    }

    # 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) {
            $item = ConvertTo-IACNormalizedAssignment -Assignment $assignment -GroupIds $AssignmentGroupIds
            if ($item) { $normalized.Add($item) }
        }
        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)
        $entityRecords = [System.Collections.Generic.List[object]]::new()
        if ($BuildRecords) {
            if ($Assignments.Count -eq 0) {
                $noneAssignment = Get-IACNoAssignmentPlaceholder
                $entityRecords.Add((ConvertTo-IACAssignmentRecord -Category $Category -Entity $Entity -Assignment $noneAssignment))
            }
            else {
                foreach ($assignment in $Assignments) {
                    $entityRecords.Add((ConvertTo-IACAssignmentRecord -Category $Category -Entity $Entity -Assignment $assignment))
                }
            }
            foreach ($record in $entityRecords) { $records.Add($record) }
        }
        $context = [PSCustomObject]@{
            Category       = $Category
            Entity         = $Entity
            Assignments    = $Assignments
            RawAssignments = $RawAssignments
            Records        = $entityRecords
            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 -Quiet:$category.OptionalFeature
                    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)) {
                        # roleScopeTagIds is not reliably returned by the collection endpoint
                        # unless it is requested explicitly.
                        $EntityCache[$category.EntityType] = Get-PagedGraphValue -Uri "$script:GraphEndpoint/beta/deviceAppManagement/mobileApps?`$filter=isAssigned eq true&`$select=id,displayName,roleScopeTagIds"
                    }
                    $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
                $skippedCategories.Add([PSCustomObject]@{
                        CategoryId  = $category.Id
                        DisplayName = $category.DisplayName
                        Message     = $_.Exception.Message
                    })
                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
        Records       = $records
        Errors        = $scanErrors
        Skipped       = $skippedCategories
        CategoryCount = $totalCategories
    }
}