Public/Export-IntuneAssignmentSnapshot.ps1

function Export-IntuneAssignmentSnapshot {
    <#
    .SYNOPSIS
    Captures Intune assignment state in a deterministic, schema-versioned JSON file.

    .DESCRIPTION
    With no pipeline input, scans the connected tenant through the shared category
    engine. Canonical assignment records are sorted by stable identity and projected
    through a fixed allowlist so credentials, tokens, and arbitrary extra properties
    cannot enter the snapshot.

    .PARAMETER Path
    Destination .json file.

    .PARAMETER InputObject
    Optional canonical assignment records to snapshot instead of scanning the tenant.

    .PARAMETER CoverageCategory
    Optional category IDs describing the coverage of supplied InputObject records.

    .PARAMETER CoverageComplete
    Declares supplied InputObject coverage complete. Supplied records default to
    incomplete because the cmdlet cannot infer whether an upstream scan failed.

    .PARAMETER CoverageError
    Optional objects with CategoryId and Message describing upstream scan failures.

    .PARAMETER CapturedAtUtc
    Capture timestamp. Defaults to the current UTC time; it can be fixed for
    reproducible builds and tests.

    .PARAMETER Force
    Overwrites an existing snapshot file.

    .PARAMETER PassThru
    Returns the in-memory snapshot document after writing it.

    .EXAMPLE
    Export-IntuneAssignmentSnapshot -Path './snapshots/intune.json'

    .EXAMPLE
    Get-IntuneAllPolicies -PassThru | Export-IntuneAssignmentSnapshot -Path './snapshot.json' -CoverageComplete
    #>

    [CmdletBinding(DefaultParameterSetName = 'Tenant')]
    [OutputType('IntuneAssignmentChecker.AssignmentSnapshot')]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Records')]
        [AllowEmptyCollection()]
        [object[]]$InputObject,

        [Parameter(ParameterSetName = 'Records')]
        [string[]]$CoverageCategory = @(),

        [Parameter(ParameterSetName = 'Records')]
        [switch]$CoverageComplete,

        [Parameter(ParameterSetName = 'Records')]
        [object[]]$CoverageError = @(),

        [Parameter()]
        [datetimeoffset]$CapturedAtUtc = [datetimeoffset]::UtcNow,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$PassThru
    )

    begin {
        $inputRecords = [System.Collections.Generic.List[object]]::new()
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'Records') {
            foreach ($record in @($InputObject)) {
                if ($null -ne $record) { [void]$inputRecords.Add($record) }
            }
        }
    }

    end {
        if ([System.IO.Path]::GetExtension($Path) -ine '.json') {
            throw 'Path must be a .json file.'
        }
        $resolvedPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path)
        if ((Test-Path -LiteralPath $resolvedPath) -and -not $Force) {
            throw "Assignment snapshot '$resolvedPath' already exists; use -Force to overwrite it."
        }
        $parentPath = Split-Path -Parent $resolvedPath
        if ($parentPath -and -not (Test-Path -LiteralPath $parentPath)) {
            New-Item -ItemType Directory -Path $parentPath -Force | Out-Null
        }

        $coverageErrors = @()
        if ($PSCmdlet.ParameterSetName -eq 'Tenant') {
            if ([string]::IsNullOrWhiteSpace($script:GraphEndpoint)) {
                throw 'Connect first with Connect-IntuneAssignmentChecker.'
            }
            if ($null -eq $script:AssignmentFilterLookup) {
                $script:AssignmentFilterLookup = Get-AssignmentFilterLookup
            }
            $categories = @(Get-IntuneCategoryDefinition -Audience Effective)
            $scan = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity {} -EntityCache @{} `
                -ShowProgress -ProgressVerb 'Capturing' -BuildRecords
            $records = @($scan.Records | Where-Object { $null -ne $_ })
            $coverageErrors = @($scan.Errors | Where-Object { $null -ne $_ } | ForEach-Object {
                    if ([string]::IsNullOrWhiteSpace("$($_.CategoryId)") -or [string]::IsNullOrWhiteSpace("$($_.Message)")) {
                        throw 'The category scanner returned an error without CategoryId or Message.'
                    }
                    [PSCustomObject][ordered]@{
                        CategoryId = $_.CategoryId
                        Message    = $_.Message
                    }
                })
            $coverageSkipped = @($scan.Skipped | Where-Object { $null -ne $_ } | ForEach-Object {
                    if ([string]::IsNullOrWhiteSpace("$($_.CategoryId)") -or [string]::IsNullOrWhiteSpace("$($_.Message)")) {
                        throw 'The category scanner returned a skipped category without CategoryId or Message.'
                    }
                    [PSCustomObject][ordered]@{
                        CategoryId = $_.CategoryId
                        Message    = $_.Message
                    }
                })
            $declaredCategoryIds = foreach ($category in $categories) {
                if ($null -eq $category -or [string]::IsNullOrWhiteSpace("$($category.Id)")) {
                    throw 'The category scanner returned a category definition without Id.'
                }
                "$($category.Id)"
            }
            $declaredCategoryIds = @($declaredCategoryIds)
            $uniqueDeclaredCategoryIds = @(Get-IACOrdinalSortedUniqueString -InputObject $declaredCategoryIds)
            if ($declaredCategoryIds.Count -ne $uniqueDeclaredCategoryIds.Count) {
                throw 'The category scanner returned duplicate category definitions.'
            }
            $recordCategoryIds = @(Get-IACOrdinalSortedUniqueString -InputObject @(
                    $records.CategoryId | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") }
                ))
            $missingCoverage = @($recordCategoryIds | Where-Object { $_ -cnotin $uniqueDeclaredCategoryIds })
            if ($missingCoverage.Count -gt 0) {
                throw "The category scanner returned records outside declared coverage: $($missingCoverage -join ', ')."
            }
            $errorsOutsideCoverage = @($coverageErrors | Where-Object { $_.CategoryId -cnotin $uniqueDeclaredCategoryIds } |
                    ForEach-Object { $_.CategoryId })
            if ($errorsOutsideCoverage.Count -gt 0) {
                throw "The category scanner returned errors outside declared coverage: $($errorsOutsideCoverage -join ', ')."
            }
            $skipsOutsideCoverage = @($coverageSkipped | Where-Object { $_.CategoryId -cnotin $uniqueDeclaredCategoryIds } |
                    ForEach-Object { $_.CategoryId })
            if ($skipsOutsideCoverage.Count -gt 0) {
                throw "The category scanner returned skipped categories outside declared coverage: $($skipsOutsideCoverage -join ', ')."
            }
            $coverageCategories = foreach ($category in $categories) {
                $categoryError = $coverageErrors | Where-Object CategoryId -CEQ $category.Id | Select-Object -First 1
                $categorySkipped = $coverageSkipped | Where-Object CategoryId -CEQ $category.Id | Select-Object -First 1
                [PSCustomObject][ordered]@{
                    CategoryId  = $category.Id
                    DisplayName = $category.DisplayName
                    Status      = if ($categoryError) { 'Failed' } elseif ($categorySkipped) { 'Skipped' } else { 'Captured' }
                    RecordCount = @($records | Where-Object CategoryId -CEQ $category.Id).Count
                }
            }
            $coverageErrors = @($coverageErrors) + @($coverageSkipped)
            $coverageMode = 'TenantScan'
            $resolvedCoverageComplete = @($coverageErrors).Count -eq 0
        }
        else {
            $records = @($inputRecords)
            $recordCategoryIds = @(Get-IACOrdinalSortedUniqueString -InputObject @(
                    $records.CategoryId | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") }
                ))
            $declaredCategoryIds = if ($CoverageCategory.Count -gt 0) {
                @(Get-IACOrdinalSortedUniqueString -InputObject @(
                        $CoverageCategory | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
                    ))
            }
            else {
                $recordCategoryIds
            }
            $coverageErrors = foreach ($coverageErrorItem in @($CoverageError)) {
                $hasCategoryId = if ($coverageErrorItem -is [System.Collections.IDictionary]) {
                    $coverageErrorItem.Contains('CategoryId')
                }
                else { $null -ne $coverageErrorItem.PSObject.Properties['CategoryId'] }
                $hasMessage = if ($coverageErrorItem -is [System.Collections.IDictionary]) {
                    $coverageErrorItem.Contains('Message')
                }
                else { $null -ne $coverageErrorItem.PSObject.Properties['Message'] }
                if (-not $hasCategoryId -or -not $hasMessage) {
                    throw 'Every CoverageError must contain CategoryId and Message.'
                }
                $errorCategoryId = "$($coverageErrorItem.CategoryId)"
                $errorMessage = "$($coverageErrorItem.Message)"
                if ([string]::IsNullOrWhiteSpace($errorCategoryId) -or [string]::IsNullOrWhiteSpace($errorMessage)) {
                    throw 'Every CoverageError requires non-empty CategoryId and Message.'
                }
                [PSCustomObject][ordered]@{
                    CategoryId = $errorCategoryId
                    Message = $errorMessage
                }
            }
            $categoryIds = @(Get-IACOrdinalSortedUniqueString -InputObject @(
                    $declaredCategoryIds
                    $coverageErrors.CategoryId
                ))
            $missingCoverage = @($recordCategoryIds | Where-Object { $_ -cnotin $categoryIds })
            if ($missingCoverage.Count -gt 0) {
                throw "CoverageCategory does not include record categories: $($missingCoverage -join ', ')."
            }
            $coverageCategories = foreach ($categoryId in $categoryIds) {
                $categoryError = $coverageErrors | Where-Object CategoryId -CEQ $categoryId | Select-Object -First 1
                [PSCustomObject][ordered]@{
                    CategoryId  = "$categoryId"
                    DisplayName = $null
                    Status      = if ($categoryError) { 'Failed' } elseif ($CoverageComplete) { 'Provided' } else { 'Unknown' }
                    RecordCount = @($records | Where-Object CategoryId -CEQ $categoryId).Count
                }
            }
            $coverageMode = 'ProvidedRecords'
            $resolvedCoverageComplete = [bool]$CoverageComplete -and @($coverageErrors).Count -eq 0
        }

        $snapshot = New-IACAssignmentSnapshot -Records $records -CapturedAtUtc $CapturedAtUtc `
            -CoverageCategories @($coverageCategories) -CoverageErrors @($coverageErrors) `
            -CoverageComplete $resolvedCoverageComplete -CoverageMode $coverageMode
        $snapshot.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentSnapshot')
        Write-IACAssignmentSnapshot -Snapshot $snapshot -Path $resolvedPath
        Write-Verbose "Assignment snapshot written to '$resolvedPath'."

        if ($PassThru) { return $snapshot }
    }
}