Public/New-NSPConditionalAccessCoveragePlan.ps1

function New-NSPConditionalAccessCoveragePlan {
    <#
    .SYNOPSIS
        Offline: turns a coverage result into one reviewable item per user to add to its target group (MFACatchAll, or EnableMFA at onboarding).
    .DESCRIPTION
        Adding a user to MFACatchAll or EnableMFA restricts their access, so each add is its own
        item. The target is the coverage result's TargetSlot:
          - AddCloudMember: a cloud-only user, added to the target slot's cloud-only group.
          - AddOnPremMember: an AD user (from the ActiveDirectory side), or a synced user the Cloud
            side found uncovered when the ActiveDirectory side was not run. Added to the
            target slot's on-premises group: directly when the run host has the ActiveDirectory module,
            otherwise through a generated script.
 
        Rails. The whole plan is held (every item CoverageHeld, nothing decidable) when:
          - a side could not be evaluated (a group or the user list could not be read), or
          - the number of adds exceeds the profile's MaxAdds, or MaxAddsPercent of evaluated users.
        A sudden large number of "uncovered" users usually means a failed group read or a missing
        group, not a real change. -AllowLargeChange lifts only the size rail, for a first run in a
        tenant after the numbers were checked. It is never used by the scheduled job.
    .EXAMPLE
        $coverage = Get-NSPConditionalAccessCoverage -TenantProfile $profilePath -Save
        $plan = New-NSPConditionalAccessCoveragePlan -Coverage $coverage -Save
        Get-NSPConditionalAccessPlanSummary -Plan $plan.Path | Format-Table -Wrap
        Set-NSPConditionalAccessPlanDecision -Plan $plan.Path -ItemId 'cloud:*', 'onprem:*' -Decision Approve
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]$Coverage,
        [switch]$AllowLargeChange,
        # Lowers the profile's MaxAdds for this plan (the scheduled job's stricter cap); never raises it.
        [ValidateRange(1, 100000)][int]$MaxAdds,
        [switch]$Save,
        [string]$ConfigRoot
    )

    if ($Coverage -is [string]) { $Coverage = Get-Content -LiteralPath $Coverage -Raw | ConvertFrom-Json }
    $settings = $Coverage.Settings
    $targetSlot = if ($Coverage.PSObject.Properties['TargetSlot'] -and $Coverage.TargetSlot) { [string]$Coverage.TargetSlot } else { 'MFACatchAll' }
    # What being in the target group means for the user, for each item's reason.
    $effect = switch ($targetSlot) {
        'EnableMFA' { 'They must use MFA when signing in away from the office (NSP-BASE-MFA-01).' }
        'MFAExempt' { 'No MFA policy applies to them, and coverage stops reporting them. Only for accounts that cannot do MFA (kiosks, scanners, shared devices).' }
        default { 'MFA on mobile devices and for Intune (the INTUNE policies).' }
    }
    $items = [System.Collections.Generic.List[object]]::new()
    $held = [System.Collections.Generic.List[string]]::new()
    function Add-PlanItem {
        param([string]$ItemId, [string]$Action, [string]$DisplayName, $Body, [string]$Reason)
        $items.Add([pscustomobject][ordered]@{
                ItemId = $ItemId; Kind = 'Coverage'; Action = $Action; Decision = 'Pending'
                DefinitionId = $null; DisplayName = $DisplayName; Confidence = $null; Score = $null
                LiveObjectId = $null; LiveDisplayName = $null; LiveState = $null; DependsOn = @()
                Reason = $Reason; Differences = @(); Warnings = @(); DesiredBody = $Body; DesiredHash = $null; LiveHash = $null
            })
    }

    $cloud = $Coverage.Cloud
    $ad = $Coverage.ActiveDirectory
    $evaluated = 0
    if ($cloud) {
        if (-not $cloud.Evaluated) { $held.Add("The Cloud side could not be evaluated: $(@($cloud.Errors) -join ' ')") }
        $evaluated = [math]::Max($evaluated, [int]$cloud.UsersEvaluated)
        $cloudUsers = @($cloud.Uncovered)
        if ($cloudUsers.Count -and -not $cloud.TargetGroup) {
            if ($targetSlot -eq 'MFACatchAll') { $held.Add("Cloud-only users are uncovered, but: $($cloud.TargetProblem)") }
            else {
                # A bulk add to an on-premises EnableMFA cannot take cloud-only users; list them
                # instead of holding everyone else.
                foreach ($user in $cloudUsers) {
                    $items.Add([pscustomobject][ordered]@{
                            ItemId = "cloud:$($user.UserPrincipalName)"; Kind = 'Coverage'; Action = 'CoverageSkipped'; Decision = 'NotActionable'
                            DefinitionId = $null; DisplayName = $user.UserPrincipalName; Confidence = $null; Score = $null
                            LiveObjectId = $null; LiveDisplayName = $null; LiveState = $null; DependsOn = @()
                            Reason = "Cloud-only user '$($user.DisplayName)' was not added: $($cloud.TargetProblem) Add them by hand, or to MFACatchAll."
                            Differences = @(); Warnings = @(); DesiredBody = [ordered]@{ userPrincipalName = $user.UserPrincipalName }; DesiredHash = $null; LiveHash = $null
                        })
                }
                $cloudUsers = @()
            }
        }
        foreach ($user in $cloudUsers) {
            Add-PlanItem "cloud:$($user.UserPrincipalName)" 'AddCloudMember' "$($user.UserPrincipalName) -> $($cloud.TargetGroup.DisplayName)" ([ordered]@{
                    groupId = $cloud.TargetGroup.Id; groupName = $cloud.TargetGroup.DisplayName; userId = $user.Id; userPrincipalName = $user.UserPrincipalName
                    coverageGroupIds = @($cloud.CoverageGroupIds)
                }) "Cloud-only user '$($user.DisplayName)' is in no coverage group. Adding to $($cloud.TargetGroup.DisplayName): $effect"
        }
    }
    if ($ad) {
        if (-not $ad.Evaluated) { $held.Add("The ActiveDirectory side could not be evaluated: $(@($ad.Errors) -join ' ')") }
        $evaluated = [math]::Max($evaluated, [int]$ad.UsersEvaluated)
        $adUsers = @($ad.Uncovered)
        if ($adUsers.Count -and -not $ad.TargetGroup) {
            if ($targetSlot -eq 'MFACatchAll') { $held.Add("AD users are uncovered, but the on-premises $targetSlot group could not be found.") }
            else {
                foreach ($user in $adUsers) {
                    $items.Add([pscustomobject][ordered]@{
                            ItemId = "onprem:$($user.SamAccountName)"; Kind = 'Coverage'; Action = 'CoverageSkipped'; Decision = 'NotActionable'
                            DefinitionId = $null; DisplayName = $user.SamAccountName; Confidence = $null; Score = $null
                            LiveObjectId = $null; LiveDisplayName = $null; LiveState = $null; DependsOn = @()
                            Reason = "AD user '$($user.DisplayName)' was not added: there is no on-premises $targetSlot group. Create it in AD (and bind it in the tenant profile), or set the exempt group with Set-NSPConditionalAccessCoverageSetting -ExemptGroup."
                            Differences = @(); Warnings = @(); DesiredBody = [ordered]@{ userPrincipalName = $user.UserPrincipalName }; DesiredHash = $null; LiveHash = $null
                        })
                }
                $adUsers = @()
            }
        }
        foreach ($user in $adUsers) {
            Add-PlanItem "onprem:$($user.SamAccountName)" 'AddOnPremMember' "$($user.SamAccountName) -> $($ad.TargetGroup.Name) (on-premises)" ([ordered]@{
                    groupIdentity = $ad.TargetGroup.Identity; groupName = $ad.TargetGroup.Name; samAccountName = $user.SamAccountName; objectGuid = $user.ObjectGuid; userPrincipalName = $user.UserPrincipalName
                }) "AD user '$($user.DisplayName)' is in no coverage group and not exempt. Adding to $($ad.TargetGroup.Name) (once synced): $effect"
        }
    } elseif ($cloud -and @($cloud.SyncedUncovered).Count) {
        foreach ($user in @($cloud.SyncedUncovered)) {
            Add-PlanItem "onprem:$($user.SamAccountName)" 'AddOnPremMember' "$($user.SamAccountName) -> $($cloud.OnPremTargetGroup.Name) (on-premises)" ([ordered]@{
                    groupIdentity = $cloud.OnPremTargetGroup.Identity; groupName = $cloud.OnPremTargetGroup.Name; samAccountName = $user.SamAccountName; objectGuid = $null; userPrincipalName = $user.UserPrincipalName
                }) "Synced user '$($user.DisplayName)' is in no coverage group (seen from the cloud; the ActiveDirectory side was not run). Their membership comes from AD."
        }
    }

    $count = @($items | Where-Object { $_.Action -ne 'CoverageSkipped' }).Count
    $limit = [int]$settings.MaxAdds
    if ($PSBoundParameters.ContainsKey('MaxAdds') -and $MaxAdds -lt $limit) { $limit = $MaxAdds }
    $percent = if ($evaluated) { [math]::Round(100 * $count / $evaluated, 1) } else { 0 }
    if (-not $AllowLargeChange -and ($count -gt $limit -or $percent -gt $settings.MaxAddsPercent)) {
        $held.Add("$count add(s) is $percent% of $evaluated evaluated user(s), over the rail ($limit adds or $($settings.MaxAddsPercent)%). This usually means a group read failed or a group is missing. Check the coverage result; for a first run, re-plan with -AllowLargeChange.")
    }
    if ($held.Count) {
        foreach ($item in $items) { $item.Action = 'CoverageHeld'; $item.Decision = 'NotActionable'; $item.Warnings = @($held) }
    }

    $plan = [pscustomobject][ordered]@{
        SchemaVersion = 1
        PlanKind = 'Coverage'
        PlanId = [guid]::NewGuid().ToString()
        CreatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        TenantId = $Coverage.TenantId
        TenantDomain = $Coverage.TenantDomain
        CoverageCapturedAtUtc = $Coverage.CapturedAtUtc
        TargetSlot = $targetSlot
        Held = @($held)
        Items = $items.ToArray()
        Path = $null
    }
    if ($Save -and $PSCmdlet.ShouldProcess($plan.TenantId, 'Save coverage plan')) {
        $plan.Path = Join-Path (Join-Path (Get-NSPConditionalAccessLocalPath -Kind Plans -ConfigRoot $ConfigRoot) $plan.TenantId) ('coverage-' + (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json')
        Save-NSPConditionalAccessJson -InputObject $plan -Path $plan.Path
    }
    $plan
}