Public/Get-NSPConditionalAccessCoverage.ps1

function Get-NSPConditionalAccessCoverage {
    <#
    .SYNOPSIS
        Read-only: finds enabled users who are in none of the Conditional Access coverage groups.
    .DESCRIPTION
        Coverage groups are the EnableMFA, RestrictExternal, and MFACatchAll slots. A user in none
        of them is uncovered; the coverage plan proposes adding them to the -TargetSlot group
        (MFACatchAll by default; EnableMFA for the initial bulk add at onboarding). Either
        restricts their access, so every add is reviewed (or held back by the rails, see
        New-NSPConditionalAccessCoveragePlan).
 
        Sides (default: both in a hybrid tenant, Cloud otherwise):
          - Cloud: enabled cloud-only users (Graph), checked against the transitive members of every
            group in each slot (synced and _azure). Their target is the slot's cloud-only group.
            In a hybrid tenant, synced users are also checked here; when the ActiveDirectory side
            is not run they are reported as SyncedUncovered, for an on-premises add.
          - ActiveDirectory: enabled AD users, minus members of the exempt group (on-premises
            accounts that are not synced), checked against the on-premises coverage groups. Their
            target is the target slot's on-premises group. Needs the ActiveDirectory module and a reachable DC.
 
        Cloud-only holders of the roles NSP-ADMIN-MFA-01 targets are covered by their role (MFA
        at every sign-in) and listed in Cloud.Admins instead of Uncovered, so no group add ever
        restricts an admin. Synced admins are covered like any synced user. If role assignments
        cannot be read, the Cloud side is not evaluated.
 
        Never evaluated as uncovered: breakglass and service accounts from the tenant profile,
        directory-sync accounts (MSOL_*, AAD_*, Sync_*), and guests unless the profile's Coverage
        settings say IncludeGuests. A coverage group that cannot be read is an error on that side,
        never an empty group.
        -Save writes the result under .nsp-conditionalaccess\coverage (Git-ignored).
    .EXAMPLE
        $coverage = Get-NSPConditionalAccessCoverage -TenantProfile $profilePath -Save
        $coverage.Cloud.Uncovered | Format-Table UserPrincipalName, DisplayName
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]$TenantProfile,
        [ValidateSet('Cloud', 'ActiveDirectory')][string[]]$Side,
        # The group uncovered users are proposed for. EnableMFA is for the initial bulk add; the
        # scheduled job always uses MFACatchAll.
        [ValidateSet('MFACatchAll', 'EnableMFA', 'MFAExempt')][string]$TargetSlot = 'MFACatchAll',
        [switch]$Save,
        [string]$ConfigRoot
    )

    if ($TenantProfile -is [string]) { $TenantProfile = Get-Content -LiteralPath $TenantProfile -Raw | ConvertFrom-Json }
    function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key }
    function Get-List { param($Object, [string]$Key) @(Get-NSPConditionalAccessMapValue $Object $Key | Where-Object { $null -ne $_ }) }

    $hybrid = [bool](Get-Value $TenantProfile 'Hybrid')
    if (-not $Side) { $Side = if ($hybrid) { @('Cloud', 'ActiveDirectory') } else { @('Cloud') } }
    $settings = Get-NSPConditionalAccessCoverageSetting -TenantProfile $TenantProfile
    $groups = Get-Value $TenantProfile 'Groups'
    # MFAExempt members count as covered once the slot is bound (cloud and synced), in addition to
    # the on-premises ExemptGroup setting; it is also a bulk-add target.
    $coverageSlots = @('EnableMFA', 'RestrictExternal', 'MFACatchAll')
    if (@(Get-List (Get-Value $groups 'MFAExempt') 'Ids').Count -or $TargetSlot -eq 'MFAExempt') { $coverageSlots += 'MFAExempt' }
    $excludedIds = @(@(Get-List (Get-Value $TenantProfile 'BreakGlass') 'Ids') + @(Get-List (Get-Value $TenantProfile 'ServiceAccounts') 'Ids'))
    $syncAccountPattern = '^(MSOL_|AAD_|Sync_)'

    $result = [pscustomobject][ordered]@{
        SchemaVersion = 1
        TenantId = Get-Value $TenantProfile 'TenantId'
        TenantDomain = Get-Value $TenantProfile 'TenantDomain'
        CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        Hybrid = $hybrid
        Sides = @($Side)
        TargetSlot = $TargetSlot
        Settings = $settings
        Cloud = $null
        ActiveDirectory = $null
        Path = $null
    }

    # Slot groups as Graph sees them: needed by the Cloud side, and for on-premises identities (SIDs).
    $slotGroups = @{}
    $excludedUpns = @()
    $context = $null
    if ($Side -contains 'Cloud') {
        Import-NSPM365Graph
        $context = Assert-NSPConditionalAccessSession -ConfigRoot $ConfigRoot
    } elseif ($hybrid) {
        # ActiveDirectory only (for example on a domain controller): Graph is optional and only
        # supplies the synced groups' SIDs. Without it, the profile's group names are used.
        try { Import-NSPM365Graph; $context = Assert-NSPConditionalAccessSession -ConfigRoot $ConfigRoot } catch { Write-Verbose "No Graph session; using group names from the profile. $_" }
    }
    if ($context) {
        if ($context.TenantId -ne $result.TenantId) { throw "The Graph session is for tenant $($context.TenantId), but the profile is for $($result.TenantId)." }
        Write-NSPConditionalAccessProgress -Activity Coverage -Status 'Coverage groups (EnableMFA, RestrictExternal, MFACatchAll)' -Step 1 -Of 4
        foreach ($slot in $coverageSlots) {
            $slotGroups[$slot] = @(foreach ($id in Get-List (Get-Value $groups $slot) 'Ids') {
                    Invoke-NSPGraphRequest -Uri "groups/$id`?`$select=id,displayName,onPremisesSyncEnabled,onPremisesSecurityIdentifier,onPremisesSamAccountName"
                })
        }
        $excludedUpns = @(foreach ($id in $excludedIds) {
                try { Get-Value (Invoke-NSPGraphRequest -Uri "users/$id`?`$select=userPrincipalName") 'userPrincipalName' } catch { Write-Verbose "Excluded account $id not found: $_" }
            })
    }

    if ($Side -contains 'Cloud') {
        $cloud = [ordered]@{
            Evaluated = $false; Errors = @(); UsersEvaluated = 0; TargetGroup = $null; TargetProblem = $null
            CoverageGroupIds = @($coverageSlots | ForEach-Object { $slotGroups[$_] } | ForEach-Object { Get-Value $_ 'id' })
            OnPremTargetGroup = $null; Uncovered = @(); SyncedUncovered = @(); Admins = @()
        }
        $syncedTarget = @($slotGroups[$TargetSlot] | Where-Object { Get-Value $_ 'onPremisesSyncEnabled' })[0]
        $cloud.OnPremTargetGroup = if ($syncedTarget) {
            [pscustomobject]@{ Identity = Get-Value $syncedTarget 'onPremisesSecurityIdentifier'; Name = Get-Value $syncedTarget 'onPremisesSamAccountName' }
        } else {
            $name = if ($TargetSlot -eq 'MFAExempt' -and $settings.ExemptGroup) { $settings.ExemptGroup } else { Get-Value (Get-Value $groups $TargetSlot) 'DefaultName' }
            [pscustomobject]@{ Identity = $name; Name = $name }
        }
        $covered = @{}
        $errors = [System.Collections.Generic.List[string]]::new()
        foreach ($slot in $coverageSlots) {
            foreach ($group in $slotGroups[$slot]) {
                Write-NSPConditionalAccessProgress -Activity Coverage -Status "Members of $(Get-Value $group 'displayName')" -Step 2 -Of 4
                try {
                    foreach ($member in Invoke-NSPGraphCollection -Uri "groups/$(Get-Value $group 'id')/transitiveMembers/microsoft.graph.user?`$select=id") { $covered[(Get-Value $member 'id')] = $true }
                } catch { $errors.Add("Members of $(Get-Value $group 'displayName') ($slot) could not be read: $_") }
            }
        }
        $target = @($slotGroups[$TargetSlot] | Where-Object { -not (Get-Value $_ 'onPremisesSyncEnabled') })[0]
        if ($target) { $cloud.TargetGroup = [pscustomobject]@{ Id = Get-Value $target 'id'; DisplayName = Get-Value $target 'displayName' } }
        elseif (-not @($slotGroups[$TargetSlot]).Count) { $cloud.TargetProblem = "The $TargetSlot slot has no group. Create it with a deployment plan first." }
        else { $cloud.TargetProblem = "The $TargetSlot slot has no cloud-only group (its _azure companion). Create it with a deployment plan to cover cloud-only users." }

        $filter = if ($settings.IncludeGuests) { 'accountEnabled eq true' } else { "accountEnabled eq true and userType eq 'Member'" }
        Write-NSPConditionalAccessProgress -Activity Coverage -Status 'Enabled users in Entra' -Step 3 -Of 4
        $users = @(Invoke-NSPGraphCollection -Uri "users?`$filter=$([uri]::EscapeDataString($filter))&`$select=id,userPrincipalName,displayName,onPremisesSyncEnabled,onPremisesSamAccountName,userType")
        $cloud.UsersEvaluated = $users.Count
        $uncovered = @($users | Where-Object {
                -not $covered.ContainsKey((Get-Value $_ 'id')) -and (Get-Value $_ 'id') -notin $excludedIds -and
                (Get-Value $_ 'userPrincipalName') -notmatch $syncAccountPattern -and (Get-Value $_ 'onPremisesSamAccountName') -notmatch $syncAccountPattern
            })
        # Cloud-only admins are covered by their role (NSP-ADMIN-MFA-01: MFA at every sign-in), not a
        # group. Synced admins are covered like any synced user. Unread roles hold the plan: an admin
        # must never land in MFACatchAll because the read failed.
        Write-NSPConditionalAccessProgress -Activity Coverage -Status 'Admin role holders' -Step 3 -Of 4
        $admins = @{}
        try { $admins = Get-NSPConditionalAccessAdminRoleHolder } catch { $errors.Add("Admin role holders could not be read, so admins cannot be told apart: $_") }
        $cloudOnly = @($uncovered | Where-Object { -not (Get-Value $_ 'onPremisesSyncEnabled') })
        $cloud.Admins = @($cloudOnly | Where-Object { $admins.ContainsKey((Get-Value $_ 'id')) } | ForEach-Object {
                [pscustomobject]@{ Id = Get-Value $_ 'id'; UserPrincipalName = Get-Value $_ 'userPrincipalName'; DisplayName = Get-Value $_ 'displayName'; Roles = @($admins[(Get-Value $_ 'id')].Via) }
            })
        $cloud.Uncovered = @($cloudOnly | Where-Object { -not $admins.ContainsKey((Get-Value $_ 'id')) } | ForEach-Object {
                [pscustomobject]@{ Id = Get-Value $_ 'id'; UserPrincipalName = Get-Value $_ 'userPrincipalName'; DisplayName = Get-Value $_ 'displayName' }
            })
        $cloud.SyncedUncovered = @($uncovered | Where-Object { Get-Value $_ 'onPremisesSyncEnabled' } | ForEach-Object {
                [pscustomobject]@{ Id = Get-Value $_ 'id'; UserPrincipalName = Get-Value $_ 'userPrincipalName'; DisplayName = Get-Value $_ 'displayName'; SamAccountName = Get-Value $_ 'onPremisesSamAccountName' }
            })
        if (-not $users.Count) { $errors.Add('No enabled users were returned; refusing to evaluate an empty directory.') }
        $cloud.Errors = @($errors)
        $cloud.Evaluated = -not $errors.Count
        $result.Cloud = [pscustomobject]$cloud
    }

    if ($Side -contains 'ActiveDirectory') {
        $ad = [ordered]@{ Evaluated = $false; Errors = @(); UsersEvaluated = 0; TargetGroup = $null; Uncovered = @(); SkippedSyncAccounts = @() }
        # On-premises identity per slot: the synced group's SID from Graph, else the profile's name for it.
        $identities = @{}
        foreach ($slot in $coverageSlots) {
            $synced = @($slotGroups[$slot] | Where-Object { Get-Value $_ 'onPremisesSyncEnabled' })[0]
            $identities[$slot] = if ($synced -and (Get-Value $synced 'onPremisesSecurityIdentifier')) { Get-Value $synced 'onPremisesSecurityIdentifier' }
            elseif ($slot -eq 'MFAExempt') { $settings.ExemptGroup }
            else { Get-Value (Get-Value $groups $slot) 'DefaultName' }
            # No on-premises exempt group (ExemptGroup is empty): leave it out rather than look up nothing.
            if (-not $identities[$slot]) { $identities.Remove($slot) }
        }
        Write-NSPConditionalAccessProgress -Activity Coverage -Status 'Users and groups in Active Directory' -Step 4 -Of 4
        try {
            $data = Get-NSPConditionalAccessAdCoverageData -CoverageGroups $identities -ExemptGroup $settings.ExemptGroup
            $covered = @{}
            foreach ($slot in $data.Groups.Keys) { foreach ($guid in $data.Groups[$slot].Members) { $covered[$guid] = $true } }
            foreach ($guid in $data.Exempt) { $covered[$guid] = $true }
            $ad.UsersEvaluated = @($data.Users).Count
            $sync = @($data.Users | Where-Object { $_.SamAccountName -match $syncAccountPattern })
            $ad.SkippedSyncAccounts = @($sync | ForEach-Object SamAccountName)
            $ad.Uncovered = @($data.Users | Where-Object {
                    -not $covered.ContainsKey($_.ObjectGuid) -and $_.SamAccountName -notmatch $syncAccountPattern -and
                    (-not $_.UserPrincipalName -or $_.UserPrincipalName -notin $excludedUpns)
                })
            $adTarget = $data.Groups[$TargetSlot]
            if ($adTarget) { $ad.TargetGroup = [pscustomobject]@{ Identity = $adTarget.Identity; Name = $adTarget.Name } }
            $ad.Errors = @($data.Errors)
            if (-not $ad.UsersEvaluated) { $ad.Errors += 'No enabled AD users were returned; refusing to evaluate an empty directory.' }
        } catch {
            $ad.Errors = @("$_")
        }
        $ad.Evaluated = -not @($ad.Errors).Count
        $result.ActiveDirectory = [pscustomobject]$ad
    }

    Write-NSPConditionalAccessProgress -Activity Coverage -Completed
    if ($Save -and $PSCmdlet.ShouldProcess($result.TenantId, 'Save coverage result')) {
        $result.Path = Join-Path (Join-Path (Get-NSPConditionalAccessLocalPath -Kind Coverage -ConfigRoot $ConfigRoot) $result.TenantId) ((Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json')
        Save-NSPConditionalAccessJson -InputObject $result -Path $result.Path
    }
    $result
}