Public/Test-NSPConditionalAccessBackendParity.ps1

function Test-NSPConditionalAccessBackendParity {
    <#
    .SYNOPSIS
        Reads every policy through both the REST transport and the Microsoft.Graph cmdlets and checks their canonical hashes agree.
    .DESCRIPTION
        Read-only. Proves the canonical form is transport-independent before the cmdlet path is
        relied on. Uses the current session (Connect-NSPConditionalAccessGraph).
 
        Cmdlet objects are converted to the REST JSON shape with their own ToJsonString()
        serializer (Microsoft.Graph PowerShell SDK v2). Without it, the conversion falls back to
        ConvertTo-Json with camelCased keys.
 
        One row per policy: Match is $true when both hashes agree. Otherwise Differences lists
        the canonical fields that disagree.
    .EXAMPLE
        Connect-NSPConditionalAccessGraph -Tenant 'contoso.onmicrosoft.com'
        Test-NSPConditionalAccessBackendParity | Where-Object { -not $_.Match }
    #>

    [CmdletBinding()]
    param(
        [string]$ConfigRoot
    )

    Import-NSPM365Graph
    Assert-NSPConditionalAccessSession -ConfigRoot $ConfigRoot | Out-Null
    if (-not (Get-Command Get-MgIdentityConditionalAccessPolicy -ErrorAction SilentlyContinue)) {
        Import-Module Microsoft.Graph.Identity.SignIns -ErrorAction Stop
    }

    function ConvertTo-CamelCase {
        param($Value)
        if ($null -eq $Value) { return $null }
        if ($Value -is [System.Collections.IDictionary] -or $Value -is [System.Management.Automation.PSCustomObject]) {
            $out = [ordered]@{}
            $pairs = if ($Value -is [System.Collections.IDictionary]) { $Value.GetEnumerator() | ForEach-Object { @{ Name = [string]$_.Key; Value = $_.Value } } }
            else { $Value.PSObject.Properties | ForEach-Object { @{ Name = $_.Name; Value = $_.Value } } }
            foreach ($pair in $pairs) {
                if ($pair.Name -eq 'AdditionalProperties') { continue }
                $name = $pair.Name.Substring(0, 1).ToLowerInvariant() + $pair.Name.Substring(1)
                $out[$name] = ConvertTo-CamelCase $pair.Value
            }
            return $out
        }
        if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { return , @($Value | ForEach-Object { ConvertTo-CamelCase $_ }) }
        $Value
    }
    function ConvertTo-RestShape {
        param($CmdletObject)
        if ($CmdletObject.PSObject.Methods['ToJsonString']) { return $CmdletObject.ToJsonString() | ConvertFrom-Json }
        ConvertTo-CamelCase (ConvertTo-Json -InputObject $CmdletObject -Depth 32 | ConvertFrom-Json)
    }
    function Get-Hashed {
        param($Policy)
        $canonical = ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $Policy
        [pscustomobject]@{ Canonical = $canonical; Hash = Get-NSPConditionalAccessPolicyHash -Canonical $canonical }
    }

    $rest = @{}
    foreach ($policy in Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies') {
        $rest[[string](Get-NSPConditionalAccessMapValue $policy 'id')] = @{ Name = Get-NSPConditionalAccessMapValue $policy 'displayName'; Result = Get-Hashed $policy }
    }
    $viaCmdlets = @{}
    foreach ($policy in Get-MgIdentityConditionalAccessPolicy -All -ErrorAction Stop) {
        $viaCmdlets[[string]$policy.Id] = Get-Hashed (ConvertTo-RestShape $policy)
    }

    foreach ($id in @($rest.Keys) + @($viaCmdlets.Keys) | Sort-Object -Unique) {
        $a = $rest[$id]
        $b = $viaCmdlets[$id]
        $differences = if ($a -and $b -and $a.Result.Hash -ne $b.Hash) {
            @((Compare-NSPConditionalAccessCanonicalPolicy -Live $b.Canonical -Desired $a.Result.Canonical).Differences | ForEach-Object Field)
        } elseif (-not $a) { @('missing from REST') } elseif (-not $b) { @('missing from cmdlets') } else { @() }
        [pscustomobject]@{
            Id = $id
            DisplayName = if ($a) { $a.Name } else { $null }
            Match = [bool]($a -and $b -and $a.Result.Hash -eq $b.Hash)
            Differences = $differences
        }
    }
}