Private/Get-CaEffectiveControl.ps1
|
function Get-CaEffectiveControl { <# .SYNOPSIS Folds the policies that apply in one world into the single outcome the sign-in would meet .DESCRIPTION This is the piece the What If API stops short of. Graph reports, policy by policy, whether each one applies. It does not say what the user actually experiences when six of them apply at once, and that is the only thing an administrator wants to know. The fold follows Entra's documented evaluation. Policies are not processed in order; every matching policy is evaluated and the aggregate is the most restrictive combination. So: - Block wins outright. One applying policy carrying the block control ends it, and no combination of grants from other policies changes that. - Otherwise every applying policy's grant clause must be satisfied. Policies are ANDed with each other even though the controls inside one policy may be ORed. RequiredControls and OptionalChoices exist because that distinction is what makes the result readable. A clause is unambiguously required when its operator is AND, or when it offers only one control - in both cases the user has no choice about it, so it can be stated flatly. A clause offering several alternatives under OR cannot be flattened without lying, so it is kept whole as a choice. Comparing two worlds then means comparing a set of flat requirements plus a set of choices, rather than trying to diff arbitrary boolean expressions. A world is a set of policy states. Passing only 'enabled' gives what the tenant enforces today; adding 'enabledForReportingButNotEnforced' gives what it would enforce if the report-only policies were promoted. Both come out of one API response, because the response carries every policy's state alongside its verdict. .PARAMETER Policy The whatIfAnalysisResult collection, all policies, applying or not. .PARAMETER State The policy states that make up this world. .PARAMETER World A label carried onto the output, for example Current or Projected. .PARAMETER SignInCondition The signInConditions that were simulated. Optional. When supplied, each clause is checked against them and the ones the simulated sign-in could not possibly satisfy are reported, which is what separates "this policy adds a requirement" from "this policy locks this persona out". See Get-CaControlSatisfiability for the rules and the documentation each rests on. .OUTPUTS PSCustomObject with World, Access, BlockedBy, Requirements, RequiredControls, OptionalChoices, UnsatisfiableRequirements, IsEffectivelyBlocked, SessionControls, SessionConflicts and AppliedPolicies. .EXAMPLE $current = Get-CaEffectiveControl -Policy $results -State 'enabled' -World 'Current' $current.Access .NOTES Author: Jeffrey Stuhr Version: 0.1.0 Last Updated: 2026-08-17 #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [object[]]$Policy, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]]$State, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$World, [Parameter()] [AllowNull()] [object]$SignInCondition ) $applied = @(@($Policy) | Where-Object { $null -ne $_ -and (Get-CaProperty -InputObject $_ -Name 'policyApplies') -eq $true -and $State -contains [string](Get-CaProperty -InputObject $_ -Name 'state') }) $requirements = New-Object System.Collections.Generic.List[object] $blockedBy = New-Object System.Collections.Generic.List[object] $requiredControls = New-Object System.Collections.Generic.List[string] $optionalChoices = New-Object System.Collections.Generic.List[object] $sessionContributions = New-Object System.Collections.Generic.List[object] $unsatisfiable = New-Object System.Collections.Generic.List[object] $strengths = [ordered]@{} foreach ($item in $applied) { $clause = Resolve-CaGrantRequirement -Policy $item if ($null -ne $clause) { $requirements.Add($clause) # Keyed by control so that two policies naming the same strength collapse to one # entry - the strength is a tenant object, not a property of the policy if ($clause.ControlDetail) { foreach ($key in $clause.ControlDetail.Keys) { if (-not $strengths.Contains($key)) { $strengths[$key] = $clause.ControlDetail[$key] } } } if ($clause.IsBlock) { $blockedBy.Add([PSCustomObject]@{ PolicyId = $clause.PolicyId PolicyName = $clause.PolicyName }) } elseif ($clause.Operator -eq 'AND' -or $clause.Controls.Count -eq 1) { foreach ($control in $clause.Controls) { if (-not $requiredControls.Contains($control)) { $requiredControls.Add($control) } } } else { $optionalChoices.Add([PSCustomObject]@{ PSTypeName = 'CaOutcome.GrantChoice' PolicyId = $clause.PolicyId PolicyName = $clause.PolicyName AnyOf = @($clause.Controls) }) } if (-not $clause.IsBlock -and $null -ne $SignInCondition) { $verdicts = @($clause.Controls | ForEach-Object { Get-CaControlSatisfiability -Control $_ -SignInCondition $SignInCondition ` -ControlDetail $clause.ControlDetail[$_] }) $blocked = @($verdicts | Where-Object { $_.Verdict -eq 'Unsatisfiable' }) $blockers = @($blocked | ForEach-Object { $_.Control }) # An AND clause dies on one impossible control; an OR clause only dies when # every alternative is impossible, which is why the clause is kept whole $clauseFails = $false if ($blockers.Count -gt 0) { if ($clause.Operator -eq 'AND') { $clauseFails = $true } elseif ($blockers.Count -eq $clause.Controls.Count) { $clauseFails = $true } } if ($clauseFails) { # Reasons are deduplicated because one condition commonly rules out every # control in a clause at once - a legacy client defeats MFA and device # state together - and repeating the sentence per control reads as several # findings when it is one $unsatisfiable.Add([PSCustomObject]@{ PSTypeName = 'CaOutcome.UnsatisfiableRequirement' PolicyId = $clause.PolicyId PolicyName = $clause.PolicyName Operator = $clause.Operator Controls = @($clause.Controls) Blockers = $blockers Reasons = @($blocked | ForEach-Object { $_.Reason } | Where-Object { $_ } | Select-Object -Unique) }) } } } foreach ($contribution in (Resolve-CaSessionControl -Policy $item)) { $sessionContributions.Add($contribution) } } $session = Merge-CaSessionControl -Contribution $sessionContributions.ToArray() if ($blockedBy.Count -gt 0) { $access = 'Blocked' } elseif ($requiredControls.Count -gt 0 -or $optionalChoices.Count -gt 0) { $access = 'GrantedWithControls' } else { $access = 'Granted' } return [PSCustomObject]@{ PSTypeName = 'CaOutcome.EffectiveControl' World = $World Access = $access BlockedBy = $blockedBy.ToArray() Requirements = $requirements.ToArray() RequiredControls = @($requiredControls | Sort-Object) OptionalChoices = $optionalChoices.ToArray() UnsatisfiableRequirements = $unsatisfiable.ToArray() AuthenticationStrengths = @($strengths.Values) # Blocked outright, or granted subject to something this sign-in demonstrably cannot # produce. Either way the user does not get in. IsEffectivelyBlocked = ($access -eq 'Blocked' -or $unsatisfiable.Count -gt 0) SessionControls = $session.Controls SessionConflicts = @($session.Conflicts) AppliedPolicies = @($applied | ForEach-Object { [PSCustomObject]@{ Id = [string](Get-CaProperty -InputObject $_ -Name 'id') DisplayName = [string](Get-CaProperty -InputObject $_ -Name 'displayName') State = [string](Get-CaProperty -InputObject $_ -Name 'state') } }) } } |