Private/Merge-CaSessionControl.ps1
|
function Merge-CaSessionControl { <# .SYNOPSIS Merges the session controls of every applying policy into one set, reporting disagreements .DESCRIPTION Session controls from separate policies stack: a sign-in frequency from one and a persistent browser setting from another both apply. The interesting case is two policies setting the same control to different values, which is common and easy to create by accident - the tenant this was built against does it today, with an "always" persistent browser from the tenant-wide MFA policy meeting a "never" from the unjoined-device policy on the same sign-in. Microsoft documents that the aggregate of matching policies is the most restrictive combination, and that is applied here for the two controls where restrictiveness has a defensible ordering. It is deliberately not guessed at for the rest: Microsoft does not document a precedence for every session control, and inventing one would produce an outcome that looks authoritative and is not. So every disagreement is reported in Conflicts whether or not it could be resolved. A resolved conflict still matters, because it means the outcome depends on precedence rather than on intent, and that is worth a human deciding about. An unresolved one keeps the first value seen and marks Resolved false, so a caller can fail a test on it rather than trust a coin toss. .PARAMETER Contribution The contributions from Resolve-CaSessionControl across all applying policies. .OUTPUTS PSCustomObject with Controls (one property per control, holding the winning value) and Conflicts (one entry per control more than one policy disagreed about). .EXAMPLE $merged = Merge-CaSessionControl -Contribution $allContributions $merged.Conflicts | Where-Object { -not $_.Resolved } .NOTES Author: Jeffrey Stuhr Version: 0.1.0 Last Updated: 2026-08-17 #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter()] [AllowNull()] [object[]]$Contribution ) $controls = [ordered]@{} $conflicts = New-Object System.Collections.Generic.List[object] $grouped = $Contribution | Where-Object { $null -ne $_ } | Group-Object -Property Control foreach ($group in ($grouped | Sort-Object Name)) { $items = @($group.Group) $distinctValues = @($items | Select-Object -ExpandProperty Value -Unique) if ($distinctValues.Count -le 1) { $controls[$group.Name] = $items[0].Value continue } # Every contributor can be ranked, so most restrictive wins and the conflict is # resolved. Value breaks a tie, so two equally restrictive settings do not pick a # winner from whatever order Graph happened to return the policies in. $ranked = @($items | Where-Object { $null -ne $_.RestrictivenessRank }) $resolved = $false # Sorted rather than first-seen, and that is not cosmetic. Graph does not guarantee # the order of policies in a response, so first-seen makes the winner - and # therefore the stored baseline - non-deterministic, and a run against an unchanged # tenant reports drift. Found exactly that way: two policies disagreeing about # cloudAppSecurity flagged 2 of 24 scenarios as changed when nothing had changed. $winner = @($items | Sort-Object -Property Value)[0] if ($ranked.Count -eq $items.Count) { $winner = @($ranked | Sort-Object -Property RestrictivenessRank, Value)[0] $resolved = $true } $controls[$group.Name] = $winner.Value $conflicts.Add([PSCustomObject]@{ PSTypeName = 'CaOutcome.SessionConflict' Control = $group.Name Values = @($distinctValues | Sort-Object) Winner = $winner.Value Resolved = $resolved Policies = @($items | ForEach-Object { [PSCustomObject]@{ PolicyId = $_.PolicyId PolicyName = $_.PolicyName Value = $_.Value } }) }) } return [PSCustomObject]@{ PSTypeName = 'CaOutcome.SessionControlSet' Controls = [PSCustomObject]$controls Conflicts = $conflicts.ToArray() } } |