Public/Test-NSPConditionalAccessTenant.ps1

function Test-NSPConditionalAccessTenant {
    <#
    .SYNOPSIS
        Offline preflight of a tenant's live Conditional Access state against its tenant profile. Returns findings, errors first.
    .DESCRIPTION
        Reads an inventory and a tenant profile (objects, or paths to their saved JSON). No Graph calls.
 
        Error:
          - ProfileTenantMismatch: the profile and inventory are from different tenants.
          - LockoutRisk: an enabled block policy applies to a breakglass account and does not exclude it.
          - BreakGlassDisabled: a breakglass account is disabled.
          - TapMethodDisabled: a selected definition needs the TAP-only strength, and the
            Temporary Access Pass method is off.
        Warning:
          - TapMethodUnverified / TapOneTimeOnly / TapNotForAllUsers: the Temporary Access Pass
            settings could not be read, allow only one-time passes, or target only some groups.
          - StrengthNotFound: a bound authentication strength is gone from the tenant.
          - BreakGlassNotExcluded: an enabled non-block policy applies to breakglass and does not exclude it.
          - BreakGlassUnverified: a breakglass account is absent from the inventory, or a policy is
            scoped by groups or roles and the inventory has no memberships to decide it.
          - BreakGlassSynced: a breakglass account is synced from on-premises (it should be cloud-only).
          - ServiceAccountNotExcluded: an enabled policy that includes All users does not exclude a service account.
          - StaleExclusion: a policy excludes a user that no longer exists.
          - MissingApplication / AmbiguousApplication: an application named by a definition is not resolvable.
          - SecurityDefaultsEnabled: Security Defaults is on, so no policy can be turned On yet. The
            promotion plan turns it off just before the first promotion.
          - SecurityDefaultsUnverified: the setting could not be read (Info when the inventory predates it).
          - AdminMfaNotEnforced: an admin role holder (not breakglass or a service account) is reached
            by no enforced policy that requires MFA for all apps at any location.
          - AdminRolesUnverified: admin role holders could not be read (Info when the inventory predates it).
          - LicensesUnverified: RiskPolicies is On and the licenses could not be read (Info when the inventory predates it).
        Error, also:
          - RiskPoliciesUnlicensed: RiskPolicies is On and the tenant has no Entra ID P2.
        Info:
          - SyncedAdmin: admin accounts synced from on-premises (they should be cloud-only).
          - OrphanNamedLocation: an unreferenced duplicate of a bound country location (cleanup candidate).
          - MissingGroupSlot / MissingCountry / MissingStrength: the planner will propose creating it.
          - TemplatePolicy: Microsoft-managed or template-based; excluded from adoption and drift.
 
          - BreakGlassExcludedByGroup: breakglass is excluded only through a group or role.
 
        A policy reaches breakglass through All users, the account itself, or a group or active
        role it belongs to (the inventory's transitive Memberships). A policy scoped only to
        groups that breakglass is not in cannot lock it out and is not reported. Eligible (PIM)
        roles are not memberships until activated.
    .EXAMPLE
        Test-NSPConditionalAccessTenant -Inventory $inventory -TenantProfile .\Config\Local\TenantProfiles\<tenant>.json
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Inventory,
        [Parameter(Mandatory)]$TenantProfile
    )

    if ($Inventory -is [string]) { $Inventory = Get-Content -LiteralPath $Inventory -Raw | ConvertFrom-Json }
    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 $_ }) }

    $findings = [System.Collections.Generic.List[object]]::new()
    function Add-Finding {
        param([string]$Severity, [string]$Rule, [string]$Subject, [string]$Message)
        $findings.Add([pscustomobject]@{ Severity = $Severity; Rule = $Rule; Subject = $Subject; Message = $Message })
    }

    if ((Get-Value $Inventory 'TenantId') -ne (Get-Value $TenantProfile 'TenantId')) {
        Add-Finding Error ProfileTenantMismatch (Get-Value $TenantProfile 'TenantId') "The profile is for tenant $(Get-Value $TenantProfile 'TenantId') but the inventory is from $(Get-Value $Inventory 'TenantId')."
        return $findings.ToArray()
    }

    $breakGlassIds = @(Get-List (Get-Value $TenantProfile 'BreakGlass') 'Ids')
    $serviceIds = @(Get-List (Get-Value $TenantProfile 'ServiceAccounts') 'Ids')
    $labels = @{}
    foreach ($kind in 'BreakGlass', 'ServiceAccounts') {
        $entry = Get-Value $TenantProfile $kind
        $ids = @(Get-List $entry 'Ids')
        $names = @(Get-List $entry 'Labels')
        for ($i = 0; $i -lt $ids.Count; $i++) { if ($i -lt $names.Count -and $names[$i]) { $labels[$ids[$i]] = $names[$i] } }
    }
    function Get-Label { param([string]$Id) if ($labels.ContainsKey($Id)) { $labels[$Id] } else { $Id } }

    # Known user facts from every user list in the inventory.
    $known = @{}
    foreach ($user in @(Get-List $Inventory 'ReferencedUsers') + @(Get-List $Inventory 'SyncAccounts')) {
        if ((Get-Value $user 'Status') -eq 'Found') {
            $known[(Get-Value $user 'Id')] = @{ Enabled = Get-Value $user 'AccountEnabled'; Synced = Get-Value $user 'Synced' }
        }
    }
    foreach ($user in Get-List $Inventory 'BreakGlassCandidates') {
        $known[(Get-Value $user 'id')] = @{ Enabled = Get-Value $user 'accountEnabled'; Synced = [bool](Get-Value $user 'onPremisesSyncEnabled') }
    }
    for ($i = 0; $i -lt $breakGlassIds.Count; $i++) {
        $id = $breakGlassIds[$i]
        $label = Get-Label $id
        if (-not $known.ContainsKey($id)) {
            Add-Finding Warning BreakGlassUnverified $label 'Breakglass account does not appear in the inventory; its state could not be checked.'
        } else {
            if ($known[$id].Enabled -eq $false) { Add-Finding Error BreakGlassDisabled $label 'Breakglass account is disabled.' }
            if ($known[$id].Synced) { Add-Finding Warning BreakGlassSynced $label 'Breakglass account is synced from on-premises; emergency access should not depend on AD or sync.' }
        }
    }

    # Whether a policy reaches a breakglass account: directly, or through a group or an active role
    # (inventory Memberships). Without membership data, group and role scopes cannot be decided.
    $memberships = Get-Value $Inventory 'Memberships'
    function Test-Reach {
        # Returns 'Yes', 'No', or 'Unknown' for one account and one side (include or exclude).
        param([string]$Id, $Users, [string]$Side)
        $direct = @(Get-List $Users "$($Side)Users")
        if ($direct -contains $Id -or ($Side -eq 'include' -and $direct -contains 'All')) { return 'Yes' }
        $groupIds = @(Get-List $Users "$($Side)Groups")
        $roleIds = @(Get-List $Users "$($Side)Roles")
        if (-not ($groupIds.Count + $roleIds.Count)) { return 'No' }
        $member = Get-Value $memberships $Id
        if ($null -eq $member) { return 'Unknown' }
        if (@($groupIds | Where-Object { $_ -in @(Get-List $member 'Groups') }).Count -or
            @($roleIds | Where-Object { $_ -in @(Get-List $member 'Roles') }).Count) { return 'Yes' }
        'No'
    }

    foreach ($policy in Get-List $Inventory 'Policies') {
        $name = Get-Value $policy 'displayName'
        if (Get-Value $policy 'templateId') {
            Add-Finding Info TemplatePolicy $name 'Microsoft-managed or template-based policy; excluded from adoption and drift matching.'
            continue
        }
        if ((Get-Value $policy 'state') -ne 'enabled') { continue }
        $users = Get-Value (Get-Value $policy 'conditions') 'users'
        $excluded = @(Get-List $users 'excludeUsers')
        $isBlock = @(Get-List (Get-Value $policy 'grantControls') 'builtInControls') -contains 'block'
        $reached = [System.Collections.Generic.List[string]]::new()
        $unknown = [System.Collections.Generic.List[string]]::new()
        $byGroup = [System.Collections.Generic.List[string]]::new()
        foreach ($id in $breakGlassIds) {
            $include = Test-Reach $id $users 'include'
            if ($include -eq 'No') { continue }
            $exclude = Test-Reach $id $users 'exclude'
            if ($exclude -eq 'Yes') {
                if ($excluded -notcontains $id) { $byGroup.Add((Get-Label $id)) }
                continue
            }
            if ($include -eq 'Yes' -and $exclude -eq 'No') { $reached.Add((Get-Label $id)) } else { $unknown.Add((Get-Label $id)) }
        }
        if ($reached.Count) {
            if ($isBlock) { Add-Finding Error LockoutRisk $name "Enabled block policy applies to breakglass and does not exclude it: $($reached -join ', ')." }
            else { Add-Finding Warning BreakGlassNotExcluded $name "Enabled policy applies to breakglass and does not exclude it: $($reached -join ', ')." }
        }
        if ($unknown.Count) {
            Add-Finding Warning BreakGlassUnverified $name "Policy is scoped by groups or roles and the inventory has no memberships for $($unknown -join ', '); take a new inventory to check whether it applies."
        }
        if ($byGroup.Count) {
            Add-Finding Info BreakGlassExcludedByGroup $name "Breakglass is excluded only through a group or role ($($byGroup -join ', ')); removing it from that group would expose it."
        }
        if (@(Get-List $users 'includeUsers') -contains 'All') {
            $missingService = @($serviceIds | Where-Object { $_ -notin $excluded })
            if ($missingService.Count) {
                Add-Finding Warning ServiceAccountNotExcluded $name "Policy applies to All users but does not exclude service account(s): $(($missingService | ForEach-Object { Get-Label $_ }) -join ', ')."
            }
        }
    }

    foreach ($user in Get-List $Inventory 'ReferencedUsers') {
        if ((Get-Value $user 'Status') -eq 'NotFound' -and @(Get-List $user 'ExcludedBy').Count) {
            Add-Finding Warning StaleExclusion (Get-Value $user 'Id') "Excluded user no longer exists; still referenced by: $(@(Get-List $user 'ExcludedBy') -join ', ')."
        }
    }

    $applicationUsers = @{}
    foreach ($definition in Get-NSPConditionalAccessDefinition) {
        foreach ($section in 'Include', 'Exclude') {
            if ($definition['Applications'] -isnot [System.Collections.IDictionary]) { continue }
            foreach ($item in @($definition['Applications'][$section])) {
                if ($item -match '^\{App:(.+)\}$') {
                    if (-not $applicationUsers.ContainsKey($Matches[1])) { $applicationUsers[$Matches[1]] = [System.Collections.Generic.List[string]]::new() }
                    $applicationUsers[$Matches[1]].Add($definition['Id'])
                }
            }
        }
    }
    foreach ($app in Get-List $Inventory 'MissingApplications') {
        Add-Finding Warning MissingApplication $app "Not found in the tenant; these definitions cannot be planned: $(@($applicationUsers[$app] | Sort-Object -Unique) -join ', ')."
    }
    foreach ($app in Get-List $Inventory 'AmbiguousApplications') {
        Add-Finding Warning AmbiguousApplication (Get-Value $app 'Name') "Several service principals share this name: $(@(Get-List $app 'AppIds') -join ', ')."
    }

    $countryMap = Get-Value (Get-Value $TenantProfile 'NamedLocations') 'Countries'
    $bound = if ($countryMap -is [System.Collections.IDictionary]) { @($countryMap.Values) }
    elseif ($countryMap) { @($countryMap.PSObject.Properties | ForEach-Object { $_.Value }) }
    else { @() }
    $referenced = @{}
    foreach ($policy in Get-List $Inventory 'Policies') {
        $locations = Get-Value (Get-Value $policy 'conditions') 'locations'
        foreach ($key in 'includeLocations', 'excludeLocations') { foreach ($id in Get-List $locations $key) { $referenced[$id] = $true } }
    }
    $countryLocations = @(Get-List $Inventory 'NamedLocations' | Where-Object { (Get-Value $_ '@odata.type') -eq '#microsoft.graph.countryNamedLocation' })
    foreach ($boundId in $bound) {
        $boundLocation = @($countryLocations | Where-Object { (Get-Value $_ 'id') -eq $boundId })[0]
        if (-not $boundLocation) { continue }
        $signature = (@(Get-List $boundLocation 'countriesAndRegions') | Sort-Object) -join ','
        foreach ($location in $countryLocations) {
            $id = Get-Value $location 'id'
            if ($id -eq $boundId -or $referenced.ContainsKey($id)) { continue }
            if (((@(Get-List $location 'countriesAndRegions') | Sort-Object) -join ',') -eq $signature) {
                Add-Finding Info OrphanNamedLocation "$(Get-Value $location 'displayName') [$id]" "Unreferenced duplicate of the bound location for $signature; candidate for an approved cleanup."
            }
        }
    }

    $groups = Get-Value $TenantProfile 'Groups'
    $slotNames = if ($groups -is [System.Collections.IDictionary]) { @($groups.Keys) } else { @($groups.PSObject.Properties.Name) }
    foreach ($slot in $slotNames) {
        $entry = Get-Value $groups $slot
        # MFAExempt is optional: no policy needs it; the bulk add creates it when it is wanted.
        if ((Get-Value $entry 'Missing') -and $slot -ne 'MFAExempt') {
            $how = if (Get-Value $TenantProfile 'Hybrid') { 'on-premises (synced), with a cloud-only _azure companion to ship policies before sync' } else { 'in the cloud' }
            Add-Finding Info MissingGroupSlot $slot "No group bound; the planner will propose creating $(Get-Value $entry 'DefaultName') $how."
        }
    }
    foreach ($iso in Get-List $TenantProfile 'MissingCountries') {
        Add-Finding Info MissingCountry $iso 'No named location bound; the planner will propose creating one.'
    }

    # Authentication strengths the selected definitions use, and the Temporary Access Pass method
    # the TAP-only strength depends on.
    $selected = @(Get-NSPConditionalAccessDefinition | Where-Object { Test-NSPConditionalAccessDefinitionSelected -Definition $_ -TenantProfile $TenantProfile })
    $strengthUse = @{}
    foreach ($definition in $selected) {
        foreach ($reference in @(Get-NSPConditionalAccessDefinitionReference -Definition $definition | Where-Object Kind -eq 'Strength')) {
            if ((Get-NSPConditionalAccessStrengthCatalog)[$reference.Name].BuiltInId) { continue }   # built in: nothing to bind
            if (-not $strengthUse.ContainsKey($reference.Name)) { $strengthUse[$reference.Name] = [System.Collections.Generic.List[string]]::new() }
            $strengthUse[$reference.Name].Add($definition['Id'])
        }
    }
    $boundStrengths = Get-Value $TenantProfile 'AuthenticationStrengths'
    $liveStrengthIds = @(Get-List $Inventory 'AuthenticationStrengths' | ForEach-Object { Get-Value $_ 'id' })
    foreach ($name in $strengthUse.Keys) {
        $id = Get-Value $boundStrengths $name
        if (-not $id) { Add-Finding Info MissingStrength $name "No authentication strength bound; the planner will propose creating or binding one for $($strengthUse[$name] -join ', ')." }
        elseif ((Get-Value $Inventory 'AuthenticationStrengths') -and $id -notin $liveStrengthIds) {
            Add-Finding Warning StrengthNotFound $name "The bound authentication strength $id is not in the tenant. Remove the binding from the tenant profile and re-plan."
        }
    }
    if ($strengthUse.ContainsKey('TapOnly')) {
        $tap = Get-Value $Inventory 'TemporaryAccessPass'
        $users = $strengthUse['TapOnly'] -join ', '
        if ($null -eq $tap) { Add-Finding Warning TapMethodUnverified 'Temporary Access Pass' "The inventory predates Temporary Access Pass checks; take a new inventory. Used by: $users." }
        elseif (-not (Get-Value $tap 'Available')) { Add-Finding Warning TapMethodUnverified 'Temporary Access Pass' "The method settings could not be read ($(Get-Value $tap 'Reason')). Used by: $users." }
        else {
            if ((Get-Value $tap 'State') -ne 'enabled') {
                Add-Finding Error TapMethodDisabled 'Temporary Access Pass' "The Temporary Access Pass method is $(Get-Value $tap 'State'). Enforcing $users would block every registration. Enable it under Authentication methods first."
            }
            if ((Get-Value $tap 'IsUsableOnce') -eq $true) {
                Add-Finding Warning TapOneTimeOnly 'Temporary Access Pass' 'Every pass is one-time (isUsableOnce): multi-use passes cannot be issued. Clear "Require one-time use" if multi-use passes are wanted.'
            }
            $targets = @(Get-List $tap 'IncludeTargets' | ForEach-Object { Get-Value $_ 'Id' })
            if ($targets -notcontains 'all_users') {
                Add-Finding Warning TapNotForAllUsers 'Temporary Access Pass' "The method targets only some groups ($($targets -join ', ')); users outside them cannot get a pass and so cannot register under $users."
            }
        }
    }

    $defaults = Get-Value $Inventory 'SecurityDefaults'
    if ($null -eq $defaults) { Add-Finding Info SecurityDefaultsUnverified 'Security Defaults' 'The inventory predates the Security Defaults check; take a new inventory.' }
    elseif (-not (Get-Value $defaults 'Available')) { Add-Finding Warning SecurityDefaultsUnverified 'Security Defaults' "Whether Security Defaults is on could not be read ($(Get-Value $defaults 'Reason'))." }
    elseif (Get-Value $defaults 'IsEnabled') {
        Add-Finding Warning SecurityDefaultsEnabled 'Security Defaults' ('Security Defaults is on. No Conditional Access policy can be turned On while it is. ' +
            'Policies can still be deployed in report-only. The promotion plan turns Security Defaults off immediately before the first promotion, so the tenant is never left without MFA.')
    }

    # Risk policies (RiskPolicies = On) need Entra ID P2: without it, Graph refuses them.
    $riskOn = @($selected | Where-Object { $_['Option'] -eq 'RiskPolicies=On' })
    if ($riskOn.Count) {
        $licenses = Get-Value $Inventory 'Licenses'
        $what = "RiskPolicies is On ($(@($riskOn | ForEach-Object { $_['Id'] }) -join ', '))"
        if ($null -eq $licenses) { Add-Finding Info LicensesUnverified 'Licenses' "$what; the inventory predates the license check, so Entra ID P2 is unconfirmed. Take a new inventory." }
        elseif (-not (Get-Value $licenses 'Available')) { Add-Finding Warning LicensesUnverified 'Licenses' "$what, but the licenses could not be read ($(Get-Value $licenses 'Reason'))." }
        elseif (@(Get-List $licenses 'ServicePlans') -notcontains 'AAD_PREMIUM_P2') {
            Add-Finding Error RiskPoliciesUnlicensed 'Entra ID P2' "$what, but the tenant has no Entra ID P2 (for example Microsoft 365 E5, or Entra ID P2 on its own). Set RiskPolicies back to Off, or license it."
        }
    }

    # Admins are covered by their role, not a group: an enforced policy must require MFA from them
    # at every sign-in (all apps, any location). NSP-ADMIN-MFA-01 does; so does Microsoft's own
    # admin MFA policy. Breakglass and service accounts are left out.
    $adminRoles = Get-Value $Inventory 'AdminRoles'
    if ($null -eq $adminRoles) { Add-Finding Info AdminRolesUnverified 'Admin roles' 'The inventory predates the admin role check; take a new inventory.' }
    elseif (-not (Get-Value $adminRoles 'Available')) { Add-Finding Warning AdminRolesUnverified 'Admin roles' "Admin role holders could not be read ($(Get-Value $adminRoles 'Reason'))." }
    else {
        $admins = @(Get-List $adminRoles 'Users' | Where-Object {
                (Get-Value $_ 'Status') -eq 'Found' -and (Get-Value $_ 'AccountEnabled') -ne $false -and (Get-Value $_ 'Id') -notin @($breakGlassIds + $serviceIds)
            })
        $mfaPolicies = @(Get-List $Inventory 'Policies' | Where-Object {
                $grant = Get-Value $_ 'grantControls'
                $conditions = Get-Value $_ 'conditions'
                $locations = Get-Value $conditions 'locations'
                $clientApps = @(Get-List $conditions 'clientAppTypes')
                (Get-Value $_ 'state') -in 'enabled', 'enabledForReportingButNotEnforced' -and
                (@(Get-List $grant 'builtInControls') -contains 'mfa' -or (Get-Value $grant 'authenticationStrength')) -and
                @(Get-List (Get-Value $conditions 'applications') 'includeApplications') -contains 'All' -and
                -not @(Get-List $locations 'excludeLocations').Count -and -not @(Get-List $locations 'includeLocations' | Where-Object { $_ -ne 'All' }).Count -and
                -not (Get-Value $conditions 'platforms') -and (-not $clientApps.Count -or $clientApps -contains 'all')
            })
        $notEnforced = [System.Collections.Generic.List[string]]::new()
        $reportOnly = $false
        foreach ($admin in $admins) {
            $id = Get-Value $admin 'Id'
            $state = 'None'
            foreach ($policy in $mfaPolicies) {
                $users = Get-Value (Get-Value $policy 'conditions') 'users'
                if ((Test-Reach $id $users 'include') -eq 'No' -or (Test-Reach $id $users 'exclude') -ne 'No') { continue }
                if ((Get-Value $policy 'state') -eq 'enabled') { $state = 'Enforced'; break }
                $state = 'ReportOnly'
            }
            if ($state -ne 'Enforced') {
                if ($state -eq 'ReportOnly') { $reportOnly = $true }
                $notEnforced.Add("$(Get-Value $admin 'UserPrincipalName') ($(@(Get-List $admin 'Roles') -join ', '))")
            }
        }
        if ($notEnforced.Count) {
            # The admin MFA policy the AdminMfa option selects (NSP-ADMIN-MFA-01 or -02).
            $adminPolicy = @($selected | Where-Object { $_['Id'] -like 'NSP-ADMIN-MFA-*' } | ForEach-Object { $_['Id'] })[0]
            if (-not $adminPolicy) { $adminPolicy = 'NSP-ADMIN-MFA-01' }
            $fix = if ($reportOnly) { "A report-only policy covers some of them: turn $adminPolicy on." } else { "Deploy $adminPolicy and turn it on." }
            Add-Finding Warning AdminMfaNotEnforced 'Admin roles' "$($notEnforced.Count) admin(s) are not required to use MFA at every sign-in: $($notEnforced -join '; '). $fix Coverage leaves cloud-only admins out of the MFA groups, so their role policy is what protects them."
        }
        $synced = @($admins | Where-Object { Get-Value $_ 'Synced' } | ForEach-Object { Get-Value $_ 'UserPrincipalName' })
        if ($synced.Count) {
            Add-Finding Info SyncedAdmin 'Admin roles' "$($synced.Count) admin account(s) are synced from on-premises: $($synced -join ', '). Admin accounts should be cloud-only. Until then, coverage treats them like any synced user (they can land in MFACatchAll)."
        }
    }

    $order = @{ Error = 0; Warning = 1; Info = 2 }
    $findings | Sort-Object { $order[$_.Severity] }, Rule, Subject
}