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 } |