Private/Resolve-CaGrantRequirement.ps1

function Resolve-CaGrantRequirement {
    <#
    .SYNOPSIS
        Turns one policy's grantControls into a requirement clause

    .DESCRIPTION
        A policy's grant controls are a clause, not a list: the operator says whether the user
        must satisfy all of the controls or any one of them. Flattening that to a bare list of
        strings is the mistake that makes every naive What If summary wrong, because
        "MFA or compliant device" and "MFA and compliant device" collapse to the same thing.
        The clause is kept intact here and only reduced later, where the reduction can be
        explained.

        Four kinds of control live in different properties of the same object and all four
        count towards the clause:

        - builtInControls, the familiar mfa, compliantDevice, domainJoinedDevice, block
        - authenticationStrength, an object rather than a string, which is how a modern MFA
          policy expresses itself - the tenant this was built against has a policy whose
          builtInControls array is empty and whose only requirement is a Passwordless MFA
          strength, so treating an empty builtInControls as "no requirement" would report that
          policy as toothless
        - termsOfUse, a list of agreement ids
        - customAuthenticationFactors, a list of factor ids

        Controls are rendered as strings so that two worlds can be compared by value. The
        prefixed forms - authenticationStrength:, termsOfUse:, customAuthenticationFactor: -
        keep them from colliding with a built-in control of the same name.

        A string is not enough for an authentication strength, though, and ControlDetail exists
        for that. A custom strength is editable, and editing one silently changes what every
        policy referencing it requires: add "password plus something the user has" to a custom
        phishing-resistant strength and every policy using it weakens at once, without
        anyone editing a policy, and the requirement still names the same strength.
        Comparing display names would see nothing.

        Graph does inline the strength object inside each referencing policy, so a policy
        export is not byte-identical afterwards - it shows N policies whose embedded blob
        moved, rather than one strength weakened and who it reaches.

        So the allowed combinations travel with the control, which is what lets a baseline
        notice.

        combinationConfigurations are counted rather than expanded. They carry the FIDO2 AAGUID
        allowlists and the certificate issuer and policy OID restrictions, which also change
        what satisfies the strength without touching a policy - so their presence has to be
        visible, but reproducing their contents here would put a second copy of somebody else's
        schema in this module.

        Block is reported separately rather than as a control, because block is not something a
        user can satisfy. A policy carrying block ends the evaluation.

    .PARAMETER Policy
        A single whatIfAnalysisResult, or anything else carrying id, displayName and
        grantControls.

    .OUTPUTS
        PSCustomObject with PolicyId, PolicyName, Operator, Controls, ControlDetail and IsBlock.
        Null when the policy has no grant controls at all, which is the normal case for a
        session-only policy.

    .EXAMPLE
        $clause = Resolve-CaGrantRequirement -Policy $policyResult
        if ($clause.IsBlock) { 'access denied' }

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


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [object]$Policy
    )

    $grantControls = Get-CaProperty -InputObject $Policy -Name 'grantControls'
    if ($null -eq $grantControls) {
        return $null
    }

    $controls = New-Object System.Collections.Generic.List[string]
    $controlDetail = [ordered]@{}
    $isBlock = $false

    foreach ($builtIn in @(Get-CaProperty -InputObject $grantControls -Name 'builtInControls')) {
        if ([string]::IsNullOrWhiteSpace($builtIn)) { continue }
        if ($builtIn -eq 'block') {
            $isBlock = $true
            continue
        }
        $controls.Add([string]$builtIn)
    }

    $strength = Get-CaProperty -InputObject $grantControls -Name 'authenticationStrength'
    if ($null -ne $strength) {
        $strengthName = Get-CaProperty -InputObject $strength -Name 'displayName'
        if ([string]::IsNullOrWhiteSpace($strengthName)) {
            $strengthName = Get-CaProperty -InputObject $strength -Name 'id'
        }
        if (-not [string]::IsNullOrWhiteSpace($strengthName)) {
            $control = "authenticationStrength:$strengthName"
            $controls.Add($control)

            # Sorted so that Graph reordering the array is not mistaken for the strength being
            # edited, which is the whole thing this detail exists to detect
            $combinations = @(Get-CaProperty -InputObject $strength -Name 'allowedCombinations' |
                Where-Object { $_ } | Sort-Object)
            $configurations = @(Get-CaProperty -InputObject $strength `
                -Name 'combinationConfigurations' | Where-Object { $null -ne $_ })

            $controlDetail[$control] = [PSCustomObject]@{
                PSTypeName                    = 'CaOutcome.AuthenticationStrength'
                Control                       = $control
                Id                            = [string](Get-CaProperty -InputObject $strength -Name 'id')
                DisplayName                   = [string]$strengthName
                PolicyType                    = [string](Get-CaProperty -InputObject $strength -Name 'policyType')
                AllowedCombinations           = $combinations
                CombinationConfigurationCount = $configurations.Count
            }
        }
    }

    foreach ($terms in @(Get-CaProperty -InputObject $grantControls -Name 'termsOfUse')) {
        if ([string]::IsNullOrWhiteSpace($terms)) { continue }
        $controls.Add("termsOfUse:$terms")
    }

    foreach ($factor in @(Get-CaProperty -InputObject $grantControls -Name 'customAuthenticationFactors')) {
        if ([string]::IsNullOrWhiteSpace($factor)) { continue }
        $controls.Add("customAuthenticationFactor:$factor")
    }

    if (-not $isBlock -and $controls.Count -eq 0) {
        return $null
    }

    $operator = Get-CaProperty -InputObject $grantControls -Name 'operator'
    if ([string]::IsNullOrWhiteSpace($operator)) {
        # Graph omits the operator on a block-only policy. AND is the safe reading: with one
        # control it means the same as OR, and it never understates what the user must satisfy.
        $operator = 'AND'
    }

    return [PSCustomObject]@{
        PSTypeName    = 'CaOutcome.GrantRequirement'
        PolicyId      = [string](Get-CaProperty -InputObject $Policy -Name 'id')
        PolicyName    = [string](Get-CaProperty -InputObject $Policy -Name 'displayName')
        Operator      = ([string]$operator).ToUpperInvariant()
        Controls      = $controls.ToArray()
        ControlDetail = $controlDetail
        IsBlock       = $isBlock
    }
}