Private/ConvertTo-NSPConditionalAccessCanonicalPolicy.ps1

function ConvertTo-NSPConditionalAccessCanonicalPolicy {
    <#
    .SYNOPSIS
        Reduces a live Graph policy or a resolved body to a flat, sorted set of comparable fields.
    .DESCRIPTION
        Both sides of every comparison go through this, so equal meaning gives equal output:
          - keys are flat paths ('users.excludeUsers', 'grant.builtInControls', ...);
          - lists are sets: sorted, de-duplicated, and omitted when empty;
          - Graph defaults are made explicit: clientAppTypes defaults to 'all', and sign-in
            frequency ignores its default interval and authentication type;
          - displayName, state, IDs, timestamps, and @odata annotations are not part of a policy's
            content. State is managed separately (report-only on create, PromoteState to enforce).
 
        An authentication strength compares by its ID ('grant.authenticationStrength').
        Anything Graph returns that the definition format cannot express (device filters,
        terms of use, ...) is kept as
        'unsupported.<path>' with its JSON. A definition never produces these, so they always
        show as differences, and adopting a policy cannot silently remove them.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory)]$Policy
    )

    function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key }
    function Test-Empty {
        param($Value)
        if ($null -eq $Value) { return $true }
        if ($Value -is [string]) { return -not $Value }
        if ($Value -is [System.Collections.IDictionary]) {
            foreach ($key in $Value.Keys) { if (-not (Test-Empty $Value[$key])) { return $false } }
            return $true
        }
        if ($Value -is [System.Management.Automation.PSCustomObject]) {
            foreach ($p in $Value.PSObject.Properties) { if (-not (Test-Empty $p.Value)) { return $false } }
            return $true
        }
        if ($Value -is [System.Collections.IEnumerable]) { return @($Value | Where-Object { -not (Test-Empty $_) }).Count -eq 0 }
        $false
    }
    function Get-Keys {
        param($Object)
        if ($null -eq $Object) { return @() }
        if ($Object -is [System.Collections.IDictionary]) { return @($Object.Keys) }
        @($Object.PSObject.Properties.Name)
    }

    $fields = @{}
    function Add-CanonicalField {
        param([string]$Path, $Value, [switch]$Scalar)
        if ($Scalar) {
            if ($null -ne $Value -and "$Value") { $fields[$Path] = [string]$Value }
            return
        }
        $items = @($Value | Where-Object { $null -ne $_ -and "$_" } | ForEach-Object { [string]$_ } | Sort-Object -Unique -CaseSensitive)
        if ($items.Count) { $fields[$Path] = $items }
    }
    function Add-Unsupported {
        param([string]$Path, $Value)
        if (-not (Test-Empty $Value)) { $fields["unsupported.$Path"] = ConvertTo-Json -InputObject $Value -Depth 16 -Compress }
    }

    # Graph property -> canonical path, per condition section. Everything else in a section is unsupported.
    $known = @{
        users = @{ includeUsers = 'users.includeUsers'; excludeUsers = 'users.excludeUsers'; includeGroups = 'users.includeGroups'; excludeGroups = 'users.excludeGroups'; includeRoles = 'users.includeRoles'; excludeRoles = 'users.excludeRoles' }
        applications = @{ includeApplications = 'applications.includeApplications'; excludeApplications = 'applications.excludeApplications'; includeUserActions = 'applications.includeUserActions' }
        platforms = @{ includePlatforms = 'platforms.includePlatforms'; excludePlatforms = 'platforms.excludePlatforms' }
        locations = @{ includeLocations = 'locations.includeLocations'; excludeLocations = 'locations.excludeLocations' }
    }
    # Graph's flag enums come back as one comma-separated string; compare them as sets.
    function Split-Flag { param($Value) @(([string]$Value) -split '\s*,\s*' | Where-Object { $_ }) }
    $conditions = Get-Value $Policy 'conditions'
    foreach ($section in Get-Keys $conditions) {
        if ($section -like '*@odata*') { continue }
        $value = Get-Value $conditions $section
        if ($known.ContainsKey($section)) {
            foreach ($key in Get-Keys $value) {
                if ($key -like '*@odata*') { continue }
                $item = Get-Value $value $key
                if ($known[$section].ContainsKey($key)) { Add-CanonicalField $known[$section][$key] $item }
                elseif ($section -eq 'users' -and $key -in 'includeGuestsOrExternalUsers', 'excludeGuestsOrExternalUsers') {
                    # Guest and external user types, across all external tenants (the only scope a definition sets).
                    if (Test-Empty $item) { continue }
                    Add-CanonicalField "users.$key" (Split-Flag (Get-Value $item 'guestOrExternalUserTypes'))
                    $tenants = Get-Value $item 'externalTenants'
                    if (-not (Test-Empty $tenants) -and (Get-Value $tenants 'membershipKind') -ne 'all') { Add-Unsupported "conditions.users.$key.externalTenants" $tenants }
                }
                else { Add-Unsupported "conditions.$section.$key" $item }
            }
        } elseif ($section -in 'clientAppTypes', 'signInRiskLevels', 'userRiskLevels') {
            Add-CanonicalField $section $value
        } elseif ($section -eq 'authenticationFlows') {
            foreach ($key in Get-Keys $value) {
                if ($key -like '*@odata*') { continue }
                if ($key -eq 'transferMethods') { Add-CanonicalField 'authenticationFlows.transferMethods' (Split-Flag (Get-Value $value $key)) }
                else { Add-Unsupported "conditions.authenticationFlows.$key" (Get-Value $value $key) }
            }
        } else {
            Add-Unsupported "conditions.$section" $value
        }
    }
    if (-not $fields.ContainsKey('clientAppTypes')) { $fields['clientAppTypes'] = @('all') }

    $grant = Get-Value $Policy 'grantControls'
    foreach ($key in Get-Keys $grant) {
        if ($key -like '*@odata*') { continue }
        switch ($key) {
            'builtInControls' { Add-CanonicalField 'grant.builtInControls' (Get-Value $grant $key) }
            'operator' { }
            # Graph returns the whole strength object; a body sends only its id. Either way the id counts.
            'authenticationStrength' { Add-CanonicalField 'grant.authenticationStrength' (Get-Value (Get-Value $grant $key) 'id') -Scalar }
            default { Add-Unsupported "grantControls.$key" (Get-Value $grant $key) }
        }
    }
    # The operator only means something when there are controls to combine.
    if ($fields.ContainsKey('grant.builtInControls') -or $fields.ContainsKey('grant.authenticationStrength')) { Add-CanonicalField 'grant.operator' (Get-Value $grant 'operator') -Scalar }

    $session = Get-Value $Policy 'sessionControls'
    foreach ($key in Get-Keys $session) {
        if ($key -like '*@odata*') { continue }
        $value = Get-Value $session $key
        if ($key -eq 'signInFrequency') {
            if (-not (Test-Empty $value) -and (Get-Value $value 'isEnabled') -ne $false) {
                $interval = Get-Value $value 'frequencyInterval'
                if ($interval -eq 'everyTime') { Add-CanonicalField 'session.signInFrequency' 'everyTime' -Scalar }
                else {
                    Add-CanonicalField 'session.signInFrequency' "$(Get-Value $value 'value') $(Get-Value $value 'type')" -Scalar
                    if ($interval -and $interval -ne 'timeBased') { Add-Unsupported 'sessionControls.signInFrequency.frequencyInterval' $interval }
                }
                $authType = Get-Value $value 'authenticationType'
                if ($authType -and $authType -ne 'primaryAndSecondaryAuthentication') { Add-Unsupported 'sessionControls.signInFrequency.authenticationType' $authType }
            }
        } elseif ($key -eq 'persistentBrowser') {
            if (-not (Test-Empty $value) -and (Get-Value $value 'isEnabled') -ne $false -and (Get-Value $value 'mode')) {
                Add-CanonicalField 'session.persistentBrowser' (Get-Value $value 'mode') -Scalar
            }
        } else {
            Add-Unsupported "sessionControls.$key" $value
        }
    }

    $canonical = [ordered]@{}
    foreach ($path in @($fields.Keys) | Sort-Object) { $canonical[$path] = $fields[$path] }
    $canonical
}