Private/Compare-CaEffectiveControl.ps1

function Compare-CaEffectiveControl {
    <#
    .SYNOPSIS
        Diffs two effective outcomes and describes what changed between them

    .DESCRIPTION
        The whole point of holding two worlds is this comparison. Being told that six policies
        apply is not actionable; being told that promoting the three report-only ones turns a
        granted sign-in into a blocked one, for this user on this platform, is.

        The diff is written against effective outcomes rather than against policy lists on
        purpose. Two policies can change while the outcome does not - a second policy requiring
        MFA when MFA is already required changes nothing the user will notice - and an outcome
        can change while the policy set looks stable, which is what happens when a group
        membership moves someone into scope. Diffing outcomes keeps the noise out and catches
        the second case, which is the one config comparison cannot see at all.

        Direction matters and is not symmetric in meaning: From is the baseline, To is the
        candidate. Promoting report-only policies can only add policies to the applying set, so
        Removed collections are normally empty in that direction. They are computed anyway,
        because the same function is what compares a stored baseline against a later run, and
        there requirements genuinely do disappear.

    .PARAMETER From
        The baseline outcome from Get-CaEffectiveControl.

    .PARAMETER To
        The candidate outcome from Get-CaEffectiveControl.

    .OUTPUTS
        PSCustomObject describing the change, with HasChange as the single field a test would
        assert on.

    .EXAMPLE
        $delta = Compare-CaEffectiveControl -From $current -To $projected
        if ($delta.HasChange) { $delta.Summary }

    .NOTES
        Author: Jeffrey Stuhr
        Version: 0.1.0
        Last Updated: 2026-08-17
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object]$From,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object]$To
    )

    $fromRequired = @($From.RequiredControls)
    $toRequired = @($To.RequiredControls)

    $addedRequired = @($toRequired | Where-Object { $fromRequired -notcontains $_ })
    $removedRequired = @($fromRequired | Where-Object { $toRequired -notcontains $_ })

    $fromBlockIds = @($From.BlockedBy | ForEach-Object { $_.PolicyId })
    $newlyBlockedBy = @($To.BlockedBy | Where-Object { $fromBlockIds -notcontains $_.PolicyId })

    # A choice is identified by its policy and its option set, so reordering the options is not
    # mistaken for a different choice
    $choiceKey = { param($Choice) "$($Choice.PolicyId)|$(($Choice.AnyOf | Sort-Object) -join ',')" }
    $fromChoiceKeys = @($From.OptionalChoices | ForEach-Object { & $choiceKey $_ })
    $addedChoices = @($To.OptionalChoices | Where-Object {
        $fromChoiceKeys -notcontains (& $choiceKey $_)
    })

    # Session controls are compared by name so that a control appearing, disappearing or
    # changing value are three distinguishable events rather than one opaque "session differs"
    $fromSession = @{}
    if ($From.SessionControls) {
        foreach ($property in $From.SessionControls.PSObject.Properties) {
            $fromSession[$property.Name] = $property.Value
        }
    }
    $toSession = @{}
    if ($To.SessionControls) {
        foreach ($property in $To.SessionControls.PSObject.Properties) {
            $toSession[$property.Name] = $property.Value
        }
    }

    $addedSession = New-Object System.Collections.Generic.List[object]
    $changedSession = New-Object System.Collections.Generic.List[object]
    $removedSession = New-Object System.Collections.Generic.List[object]

    foreach ($name in ($toSession.Keys | Sort-Object)) {
        if (-not $fromSession.ContainsKey($name)) {
            $addedSession.Add([PSCustomObject]@{ Control = $name; Value = $toSession[$name] })
        } elseif ($fromSession[$name] -ne $toSession[$name]) {
            $changedSession.Add([PSCustomObject]@{
                Control = $name; From = $fromSession[$name]; To = $toSession[$name]
            })
        }
    }
    foreach ($name in ($fromSession.Keys | Sort-Object)) {
        if (-not $toSession.ContainsKey($name)) {
            $removedSession.Add([PSCustomObject]@{ Control = $name; Value = $fromSession[$name] })
        }
    }

    # A custom authentication strength is editable, and editing one changes what every policy
    # referencing it requires while every policy document stays identical. Comparing the
    # strength's name would see nothing, so its combinations are compared instead. Combinations
    # added is a weakening - more ways to get in - and removed is a tightening.
    $fromStrengths = @{}
    foreach ($strength in @($From.AuthenticationStrengths)) {
        if ($strength) { $fromStrengths[[string]$strength.Control] = $strength }
    }

    $weakened = New-Object System.Collections.Generic.List[object]
    $strengthened = New-Object System.Collections.Generic.List[object]

    foreach ($strength in @($To.AuthenticationStrengths)) {
        if (-not $strength) { continue }
        $key = [string]$strength.Control
        if (-not $fromStrengths.ContainsKey($key)) { continue }

        $before = @($fromStrengths[$key].AllowedCombinations)
        $after = @($strength.AllowedCombinations)

        $added = @($after | Where-Object { $before -notcontains $_ })
        $removed = @($before | Where-Object { $after -notcontains $_ })
        $configBefore = [int]$fromStrengths[$key].CombinationConfigurationCount
        $configAfter = [int]$strength.CombinationConfigurationCount

        if ($added.Count -gt 0 -or $configAfter -lt $configBefore) {
            $weakened.Add([PSCustomObject]@{
                Strength                = $strength.DisplayName
                Control                 = $key
                AddedCombinations       = $added
                RemovedConfigurations   = [math]::Max(0, $configBefore - $configAfter)
            })
        }
        if ($removed.Count -gt 0 -or $configAfter -gt $configBefore) {
            $strengthened.Add([PSCustomObject]@{
                Strength                = $strength.DisplayName
                Control                 = $key
                RemovedCombinations     = $removed
                AddedConfigurations     = [math]::Max(0, $configAfter - $configBefore)
            })
        }
    }

    $fromPolicyIds = @($From.AppliedPolicies | ForEach-Object { $_.Id })
    $addedPolicies = @($To.AppliedPolicies | Where-Object { $fromPolicyIds -notcontains $_.Id })
    $toPolicyIds = @($To.AppliedPolicies | ForEach-Object { $_.Id })
    $removedPolicies = @($From.AppliedPolicies | Where-Object { $toPolicyIds -notcontains $_.Id })

    # The headline finding when satisfiability was evaluated: the sign-in gets in today and
    # would not after the promotion. Kept separate from AccessChanged because Access can stay
    # GrantedWithControls in both worlds while the user is locked out in one of them.
    $fromUnsatisfiableIds = @($From.UnsatisfiableRequirements | ForEach-Object { $_.PolicyId })
    $newlyUnsatisfiable = @($To.UnsatisfiableRequirements | Where-Object {
        $fromUnsatisfiableIds -notcontains $_.PolicyId
    })
    $becomesEffectivelyBlocked = ((-not $From.IsEffectivelyBlocked) -and $To.IsEffectivelyBlocked)

    $accessChanged = $From.Access -ne $To.Access

    $hasChange = $accessChanged -or $newlyUnsatisfiable.Count -gt 0 -or
        $weakened.Count -gt 0 -or $strengthened.Count -gt 0 -or
        $addedRequired.Count -gt 0 -or $removedRequired.Count -gt 0 -or
        $addedChoices.Count -gt 0 -or $newlyBlockedBy.Count -gt 0 -or
        $addedSession.Count -gt 0 -or $changedSession.Count -gt 0 -or $removedSession.Count -gt 0

    $parts = New-Object System.Collections.Generic.List[string]
    if ($becomesEffectivelyBlocked) {
        $parts.Add('LOCKS OUT this sign-in')
    }
    if ($accessChanged) { $parts.Add("access $($From.Access) -> $($To.Access)") }
    if ($newlyBlockedBy.Count -gt 0) {
        # A new blocker on an already-blocked sign-in changes nothing the user sees, so it is
        # worded to say so rather than reading like a fresh denial
        $blockWord = 'blocked by'
        if (-not $accessChanged -and $From.Access -eq 'Blocked') {
            $blockWord = 'additionally blocked by'
        }
        $names = ($newlyBlockedBy | ForEach-Object { $_.PolicyName }) -join ', '
        $parts.Add("$blockWord $names")
    }
    if ($newlyUnsatisfiable.Count -gt 0) {
        # The reason is what makes this actionable - "cannot satisfy X (compliantDevice)" says
        # what is required, where "the simulated device is not compliant" says why it fails
        $detail = ($newlyUnsatisfiable | ForEach-Object {
            $why = @($_.Reasons) -join ', '
            if ([string]::IsNullOrWhiteSpace($why)) { $why = @($_.Blockers) -join ', ' }
            "$($_.PolicyName) - $why"
        }) -join '; '
        $parts.Add("cannot satisfy $detail")
    }
    foreach ($change in $weakened) {
        $detail = @($change.AddedCombinations) -join ', '
        if ([string]::IsNullOrWhiteSpace($detail)) {
            $detail = "$($change.RemovedConfigurations) restriction(s) removed"
        }
        $parts.Add("WEAKENED authentication strength '$($change.Strength)' now also allows $detail")
    }
    foreach ($change in $strengthened) {
        $detail = @($change.RemovedCombinations) -join ', '
        if ([string]::IsNullOrWhiteSpace($detail)) {
            $detail = "$($change.AddedConfigurations) restriction(s) added"
        }
        $parts.Add("authentication strength '$($change.Strength)' no longer allows $detail")
    }
    if ($addedRequired.Count -gt 0) {
        $parts.Add("now requires $($addedRequired -join ', ')")
    }
    if ($removedRequired.Count -gt 0) {
        $parts.Add("no longer requires $($removedRequired -join ', ')")
    }
    if ($addedChoices.Count -gt 0) {
        $detail = ($addedChoices | ForEach-Object { $_.AnyOf -join ' or ' }) -join '; '
        $parts.Add("new choice of $detail")
    }
    if ($addedSession.Count -gt 0) {
        $detail = ($addedSession | ForEach-Object { "$($_.Control)=$($_.Value)" }) -join ', '
        $parts.Add("session adds $detail")
    }
    if ($changedSession.Count -gt 0) {
        $detail = ($changedSession | ForEach-Object {
            "$($_.Control) $($_.From)->$($_.To)"
        }) -join ', '
        $parts.Add("session changes $detail")
    }
    if ($removedSession.Count -gt 0) {
        $detail = ($removedSession | ForEach-Object { $_.Control }) -join ', '
        $parts.Add("session drops $detail")
    }

    $summary = 'No change'
    if ($parts.Count -gt 0) { $summary = $parts -join '; ' }

    return [PSCustomObject]@{
        PSTypeName                = 'CaOutcome.Delta'
        HasChange                 = $hasChange
        AccessChanged             = $accessChanged
        AccessFrom                = $From.Access
        AccessTo                  = $To.Access
        BecomesEffectivelyBlocked = $becomesEffectivelyBlocked
        NewlyUnsatisfiable        = $newlyUnsatisfiable
        WeakenedStrengths         = $weakened.ToArray()
        StrengthenedStrengths     = $strengthened.ToArray()
        NewlyBlockedBy            = $newlyBlockedBy
        AddedRequiredControls     = $addedRequired
        RemovedRequiredControls   = $removedRequired
        AddedChoices              = $addedChoices
        AddedSessionControls      = $addedSession.ToArray()
        ChangedSessionControls    = $changedSession.ToArray()
        RemovedSessionControls    = $removedSession.ToArray()
        AddedPolicies             = $addedPolicies
        RemovedPolicies           = $removedPolicies
        Summary                   = $summary
    }
}