Public/Resolve-NSPConditionalAccessPolicy.ps1

function Resolve-NSPConditionalAccessPolicy {
    <#
    .SYNOPSIS
        Builds the Graph request body for a policy definition from a tenant profile. Runs offline.
    .DESCRIPTION
        Makes no Graph calls and no writes. The definition is validated first; any error finding
        stops resolution. Every {Kind:Name} reference must resolve to at least one object ID or
        resolution throws, so an assignment or exclusion is never silently dropped.
 
        The body is in the Graph REST JSON shape (camelCase) for
        POST/PATCH identity/conditionalAccess/policies. The display name is '<Id> <Name>'.
        The state comes from the definition's DefaultState unless -State overrides it
        (reportOnly maps to enabledForReportingButNotEnforced).
 
        -TenantProfile binds breakglass, groups, the home country, and named locations. Its
        PolicyTargets section adds users and groups to this policy's includes and excludes, on
        top of the definition's own (Add-NSPConditionalAccessPolicyTarget).
        -Directory supplies lookups that are not tenant configuration, currently
        Applications (display name to appId).
    .EXAMPLE
        $tenantProfile = Get-Content .\Config\Local\TenantProfiles\contoso.json -Raw | ConvertFrom-Json
        Get-NSPConditionalAccessDefinition -Id 'NSP-BASE-MFA-01' |
            Resolve-NSPConditionalAccessPolicy -TenantProfile $tenantProfile -Directory $directory
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)][System.Collections.IDictionary]$Definition,
        [Parameter(Mandatory)]$TenantProfile,
        $Directory,
        [ValidateSet('reportOnly', 'disabled', 'enabled')][string]$State
    )

    begin {
        $schema = Get-NSPConditionalAccessDefinitionSchema
    }

    process {
        $errors = @(Test-NSPConditionalAccessDefinition -Definition $Definition | Where-Object Severity -eq 'Error')
        if ($errors.Count) {
            throw "Definition $($Definition['Id']) is invalid: $(($errors | ForEach-Object { "[$($_.Rule)] $($_.Message)" }) -join ' ')"
        }

        $conditions = [ordered]@{}
        foreach ($spec in $schema.Lists) {
            $container = if ($spec.Section) { $Definition[$spec.Section] } else { $Definition }
            if ($container -isnot [System.Collections.IDictionary] -or -not $container.Contains($spec.Key)) { continue }

            $field = if ($spec.Section) { "$($spec.Section).$($spec.Key)" } else { $spec.Key }
            $resolved = [System.Collections.Generic.List[string]]::new()
            foreach ($item in @($container[$spec.Key] | Where-Object { $null -ne $_ })) {
                $values = if (-not $spec.Contains('Values') -and $item -cmatch $schema.ReferencePattern) {
                    Resolve-NSPConditionalAccessReference -Kind $Matches['kind'] -Name $Matches['name'] `
                        -TenantProfile $TenantProfile -Directory $Directory -Context "$($Definition['Id']) $field"
                } else {
                    $item
                }
                foreach ($value in @($values | Where-Object { $_ })) {
                    if (-not $resolved.Contains($value)) { $resolved.Add($value) }
                }
            }
            if ($resolved.Count -eq 0) { continue }

            # Assigned in each branch: an if-expression would unroll a one-item list.
            if ($spec.Shape -eq 'Csv') { $value = $resolved.ToArray() -join ',' }
            elseif ($spec.Shape -eq 'GuestTypes') {
                $value = [ordered]@{
                    guestOrExternalUserTypes = $resolved.ToArray() -join ','
                    externalTenants = [ordered]@{ '@odata.type' = '#microsoft.graph.conditionalAccessAllExternalTenants'; membershipKind = 'all' }
                }
            } else { $value = $resolved.ToArray() }
            if ($spec.Target.Count -eq 1) {
                $conditions[$spec.Target[0]] = $value
            } else {
                if (-not $conditions.Contains($spec.Target[0])) { $conditions[$spec.Target[0]] = [ordered]@{} }
                $conditions[$spec.Target[0]][$spec.Target[1]] = $value
            }
        }

        # Users and groups the operator added to this policy (Add-NSPConditionalAccessPolicyTarget).
        $added = Get-NSPConditionalAccessMapValue (Get-NSPConditionalAccessMapValue $TenantProfile 'PolicyTargets') ([string]$Definition['Id'])
        foreach ($field in 'IncludeUsers', 'IncludeGroups', 'ExcludeUsers', 'ExcludeGroups') {
            $ids = @(Get-NSPConditionalAccessMapValue $added $field | ForEach-Object { Get-NSPConditionalAccessMapValue $_ 'Id' } | Where-Object { $_ })
            if (-not $ids.Count) { continue }
            if (-not $conditions.Contains('users')) { $conditions['users'] = [ordered]@{} }
            $users = $conditions['users']
            if ($field -like 'Include*' -and @($users['includeUsers']) -contains 'All') {
                throw "$($Definition['Id']): the tenant profile adds $field, but the policy already includes every user. Remove the addition."
            }
            $key = $field.Substring(0, 1).ToLowerInvariant() + $field.Substring(1)
            $merged = [System.Collections.Generic.List[string]]::new()
            foreach ($value in @(@($users[$key]) + $ids | Where-Object { $_ })) { if (-not $merged.Contains([string]$value)) { $merged.Add([string]$value) } }
            $users[$key] = $merged.ToArray()
        }

        $stateName = if ($State) { $State } else { $Definition['DefaultState'] }
        $body = [ordered]@{
            displayName = "$($Definition['Id']) $($Definition['Name'])"
            state = $schema.States[$stateName]
            conditions = $conditions
        }
        if ($Definition['Grant']) {
            $body['grantControls'] = [ordered]@{
                operator = $Definition['Grant']['Operator']
                builtInControls = @($Definition['Grant']['BuiltInControls'] | Where-Object { $_ })
            }
            $strength = $Definition['Grant']['AuthenticationStrength']
            if ($strength -and $strength -cmatch $schema.ReferencePattern) {
                $strengthId = Resolve-NSPConditionalAccessReference -Kind $Matches['kind'] -Name $Matches['name'] `
                    -TenantProfile $TenantProfile -Directory $Directory -Context "$($Definition['Id']) Grant.AuthenticationStrength"
                $body['grantControls']['authenticationStrength'] = [ordered]@{ id = [string]$strengthId }
            }
        }
        $session = $Definition['Session']
        $frequency = if ($session) { $session['SignInFrequency'] } else { $null }
        $persistent = if ($session) { $session['PersistentBrowser'] } else { $null }
        if ($frequency -or $persistent) { $body['sessionControls'] = [ordered]@{} }
        if ($frequency -and $frequency['EveryTime']) {
            $body['sessionControls']['signInFrequency'] = [ordered]@{
                frequencyInterval = 'everyTime'
                authenticationType = 'primaryAndSecondaryAuthentication'
                isEnabled = $true
            }
        } elseif ($frequency) {
            $body['sessionControls']['signInFrequency'] = [ordered]@{
                value = [int]$frequency['Value']
                type = $frequency['Type']
                isEnabled = $true
            }
        }
        if ($persistent) { $body['sessionControls']['persistentBrowser'] = [ordered]@{ mode = $persistent; isEnabled = $true } }
        $body
    }
}