Private/Get-MsecSettingsCatalogStatusReport.ps1

function Get-MsecSettingsCatalogStatusReport {
    <#
    .SYNOPSIS
        Returns a hashtable keyed by PolicyId with device check-in status counts for ALL
        Settings Catalog configuration policies in the tenant, via the Intune Reports API.

    .DESCRIPTION
        Settings Catalog policies don't expose per-policy device status via a navigation
        property the way Templates and Compliance policies do. The Intune portal sources
        this data via the Reports API asynchronously.

        This function:
          1. POSTs to /beta/deviceManagement/reports/exportJobs with
             reportName=ConfigurationPolicyAggregate, format=json.
          2. Polls /reports/exportJobs('{id}') every $PollIntervalSeconds until
             status='completed' (typically 5-15s).
          3. Downloads the result zip from the presigned blob URL in job.url. No auth
             header is required for that URL - the SAS signature embedded in the URL
             grants short-lived access.
          4. Unzips and parses the single JSON file inside.
          5. Returns a hashtable keyed by PolicyId with SuccessCount / ErrorCount /
             ConflictCount / NotApplicableCount / PendingCount.

        Required Graph permission: DeviceManagementConfiguration.Read.All (the same
        permission already needed by Get-MsecIntuneConfigurationProfile).

    .PARAMETER TimeoutSeconds
        Maximum time to wait for the report job to complete. Default 120.

    .PARAMETER PollIntervalSeconds
        How often to poll job status. Default 2.

    .OUTPUTS
        Hashtable keyed by PolicyId (string), values are PSCustomObjects with
        SuccessCount / ErrorCount / ConflictCount / NotApplicableCount / PendingCount.
        Returns an empty hashtable on timeout or download failure (and writes a warning).
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter()]
        [int] $TimeoutSeconds = 120,

        [Parameter()]
        [int] $PollIntervalSeconds = 2
    )

    Assert-MsecSession

    Write-Verbose 'Creating Intune Reports exportJob (ConfigurationPolicyAggregate)'
    $job = Invoke-MsecGraphRequest -Method POST `
        -Path '/beta/deviceManagement/reports/exportJobs' `
        -Body @{ reportName = 'ConfigurationPolicyAggregate'; format = 'json' }

    $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
    while ($job.status -ne 'completed' -and [DateTime]::UtcNow -lt $deadline) {
        Start-Sleep -Seconds $PollIntervalSeconds
        $job = Invoke-MsecGraphRequest -Path "/beta/deviceManagement/reports/exportJobs('$($job.id)')"
        Write-Verbose " exportJob status=$($job.status)"
    }
    if ($job.status -ne 'completed') {
        Write-Warning "Intune Reports exportJob did not complete in ${TimeoutSeconds}s (last status: $($job.status))."
        return @{}
    }

    $tmpZip = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName() + '.zip')
    $tmpDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName())
    try {
        # The presigned URL has its own SAS - don't add our bearer token (would 400).
        Invoke-WebRequest -Uri $job.url -OutFile $tmpZip -ErrorAction Stop | Out-Null
        New-Item -ItemType Directory -Path $tmpDir | Out-Null
        Expand-Archive -Path $tmpZip -DestinationPath $tmpDir -ErrorAction Stop

        $jsonFile = Get-ChildItem $tmpDir -Filter '*.json' -Recurse | Select-Object -First 1
        if (-not $jsonFile) {
            Write-Warning 'Reports export zip did not contain a JSON file.'
            return @{}
        }
        $data = Get-Content -LiteralPath $jsonFile.FullName -Raw | ConvertFrom-Json
    }
    catch {
        Write-Warning "Failed to download / parse Reports export: $_"
        return @{}
    }
    finally {
        if (Test-Path -LiteralPath $tmpZip) { Remove-Item -LiteralPath $tmpZip -Force -ErrorAction SilentlyContinue }
        if (Test-Path -LiteralPath $tmpDir) { Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue }
    }

    # The downloaded JSON has shape { columns: [...], values: [ {col=val,...}, ... ] }.
    # Each entry in values is already a hydrated object - we don't need to index by columns.
    $byId = @{}
    foreach ($v in @($data.values)) {
        if (-not $v.PolicyId) { continue }
        $byId[$v.PolicyId] = [PSCustomObject]@{
            # Mapping notes:
            # - SC reports "Compliant" rather than "Success"; treat as the success bucket.
            # - SC reports "NonCompliant" for devices that haven't applied yet OR are out
            # of state; closest equivalent in our schema is PendingCount.
            SuccessCount       = [int]$v.NumberOfCompliantDevices
            ErrorCount         = [int]$v.NumberOfErrorDevices
            ConflictCount      = [int]$v.NumberOfConflictDevices
            NotApplicableCount = [int]$v.NumberOfNotApplicableDevices
            PendingCount       = [int]$v.NumberOfNonCompliantDevices
        }
    }
    $byId
}