Private/Resolve-CaSessionControl.ps1
|
function Resolve-CaSessionControl { <# .SYNOPSIS Extracts the session controls one policy actually turns on .DESCRIPTION Graph returns the sessionControls object fully populated with nulls - a policy that sets only a persistent browser session still comes back carrying nine other properties set to null. Counting those as controls would report every policy as configuring everything, so the extraction here keeps only what the policy really asserts. Two kinds of "off" have to be told apart. A null property is a control the policy never configured. A property present with isEnabled false is a control someone configured and then switched off, which is also not in force. Both are dropped, but only the second is a deliberate act, and dropping it is what stops a disabled sign-in frequency from appearing to compete with a live one from another policy. Each surviving control is rendered to a canonical string so that two policies asserting the same thing compare equal by value, and so that a conflict between two policies is detectable without walking object graphs. The raw object is carried alongside for callers that need the detail. RestrictivenessRank is a number, lower being more restrictive, and only set for the two controls where Microsoft's own semantics give an ordering that can be defended: sign-in frequency, where a shorter interval is stricter and every time is strictest, and persistent browser, where never is stricter than always. Every other control gets null, which is the signal to Merge-CaSessionControl that a disagreement cannot be resolved and must be reported instead. .PARAMETER Policy A single whatIfAnalysisResult, or anything else carrying id, displayName and sessionControls. .OUTPUTS Object[] of PSCustomObject with Control, Value, Raw, RestrictivenessRank, PolicyId and PolicyName. Empty when the policy sets no session controls. .EXAMPLE $contributions = Resolve-CaSessionControl -Policy $policyResult .NOTES Author: Jeffrey Stuhr Version: 0.1.0 Last Updated: 2026-08-17 #> [CmdletBinding()] [OutputType([System.Object[]])] param( [Parameter(Mandatory)] [AllowNull()] [object]$Policy ) $sessionControls = Get-CaProperty -InputObject $Policy -Name 'sessionControls' if ($null -eq $sessionControls) { return @() } $policyId = [string](Get-CaProperty -InputObject $Policy -Name 'id') $policyName = [string](Get-CaProperty -InputObject $Policy -Name 'displayName') $names = @() if ($sessionControls -is [System.Collections.IDictionary]) { $names = @($sessionControls.Keys) } elseif ($sessionControls.PSObject) { $names = @($sessionControls.PSObject.Properties | Select-Object -ExpandProperty Name) } $contributions = New-Object System.Collections.Generic.List[object] foreach ($name in $names) { if ($name -like '@odata*') { continue } $value = Get-CaProperty -InputObject $sessionControls -Name $name if ($null -eq $value) { continue } # A control switched off is not in force, and must not compete with a live one $isEnabled = Get-CaProperty -InputObject $value -Name 'isEnabled' if ($null -ne $isEnabled -and -not $isEnabled) { continue } $rendered = ConvertTo-CaSessionControlValue -Control $name -Value $value if ($null -eq $rendered) { continue } $contributions.Add([PSCustomObject]@{ PSTypeName = 'CaOutcome.SessionContribution' Control = $name Value = $rendered.Value Raw = $value RestrictivenessRank = $rendered.RestrictivenessRank PolicyId = $policyId PolicyName = $policyName }) } # ToArray rather than @(), which throws "Argument types do not match" on a # List[object] under PowerShell 7.6.4 return $contributions.ToArray() } function ConvertTo-CaSessionControlValue { <# .SYNOPSIS Renders one session control to a comparable string and, where it can be defended, a restrictiveness rank .DESCRIPTION Split out from Resolve-CaSessionControl so the per-control knowledge sits in one place and can be tested directly. Controls this module has no specific knowledge of still get a value, from a compact JSON rendering of their non-null properties, so a new control Microsoft adds is surfaced rather than silently dropped - it simply arrives without a restrictiveness rank and so is reported as an unresolved conflict if two policies disagree about it. .PARAMETER Control The session control property name, for example signInFrequency. .PARAMETER Value The control's value object. .OUTPUTS PSCustomObject with Value and RestrictivenessRank, or null if there is nothing to say. .EXAMPLE ConvertTo-CaSessionControlValue -Control 'persistentBrowser' -Value @{ mode = 'never' } .NOTES Author: Jeffrey Stuhr Version: 0.1.0 Last Updated: 2026-08-17 #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string]$Control, [Parameter(Mandatory)] [AllowNull()] [object]$Value ) if ($null -eq $Value) { return $null } switch ($Control) { 'signInFrequency' { $interval = [string](Get-CaProperty -InputObject $Value -Name 'frequencyInterval') $type = [string](Get-CaProperty -InputObject $Value -Name 'type') $number = Get-CaProperty -InputObject $Value -Name 'value' $authType = [string](Get-CaProperty -InputObject $Value -Name 'authenticationType') $rank = $null if ($interval -eq 'everyTime') { $text = 'everyTime' $rank = 0 } elseif ($null -ne $number -and -not [string]::IsNullOrWhiteSpace($type)) { $text = "$number $type" switch ($type) { 'hours' { $rank = [double]$number * 60 } 'days' { $rank = [double]$number * 1440 } default { $rank = $null } } } else { return $null } if (-not [string]::IsNullOrWhiteSpace($authType)) { $text = "$text ($authType)" } return [PSCustomObject]@{ Value = $text; RestrictivenessRank = $rank } } 'persistentBrowser' { $mode = [string](Get-CaProperty -InputObject $Value -Name 'mode') if ([string]::IsNullOrWhiteSpace($mode)) { return $null } # never keeps nothing across a browser restart, always keeps the session alive $rank = $null if ($mode -eq 'never') { $rank = 0 } elseif ($mode -eq 'always') { $rank = 1 } return [PSCustomObject]@{ Value = $mode; RestrictivenessRank = $rank } } 'cloudAppSecurity' { $type = [string](Get-CaProperty -InputObject $Value -Name 'cloudAppSecurityType') if ([string]::IsNullOrWhiteSpace($type)) { $type = 'enabled' } return [PSCustomObject]@{ Value = $type; RestrictivenessRank = $null } } 'continuousAccessEvaluation' { $mode = [string](Get-CaProperty -InputObject $Value -Name 'mode') if ([string]::IsNullOrWhiteSpace($mode)) { return $null } return [PSCustomObject]@{ Value = $mode; RestrictivenessRank = $null } } } # A bare scalar, such as disableResilienceDefaults $isScalar = $Value -is [bool] -or $Value -is [string] -or $Value -is [int] -or $Value -is [long] -or $Value -is [double] if ($isScalar) { if ($Value -is [bool] -and -not $Value) { return $null } return [PSCustomObject]@{ Value = [string]$Value; RestrictivenessRank = $null } } # An object this module has no specific knowledge of. isEnabled alone means "on"; anything # richer is rendered from its non-null properties so the detail is not lost. $detail = @{} $names = @() if ($Value -is [System.Collections.IDictionary]) { $names = @($Value.Keys) } elseif ($Value.PSObject) { $names = @($Value.PSObject.Properties | Select-Object -ExpandProperty Name) } foreach ($name in $names) { if ($name -eq 'isEnabled' -or $name -like '@odata*') { continue } $inner = Get-CaProperty -InputObject $Value -Name $name if ($null -ne $inner) { $detail[$name] = $inner } } if ($detail.Count -eq 0) { return [PSCustomObject]@{ Value = 'enabled'; RestrictivenessRank = $null } } $text = ($detail.Keys | Sort-Object | ForEach-Object { "$_=$($detail[$_])" }) -join ';' return [PSCustomObject]@{ Value = $text; RestrictivenessRank = $null } } |