Private/Get-NSPConditionalAccessDashboardTask.ps1
|
function Get-NSPConditionalAccessDashboardTask { <# .SYNOPSIS The dashboard's main screen: "What do you want to do?" Each task explains itself, then runs the whole chain. .DESCRIPTION A task takes the operator from the goal (add travel, trust a new office address) through everything it needs: an explanation, the questions, the plan limited to that task's items, the review, the run, and a fresh inventory, with a second pass when a policy had to wait for a group or location made in the first. Each task is Id, Label (short, shown on the main screen), Help (one line), and Action (a scriptblock given the dashboard session). Actions call only the module's commands, so the dashboard adds no write path. The category menus remain under All tools. #> [CmdletBinding()] param() @( [pscustomobject]@{ Id = 'Deploy'; Label = 'Deploy policies from the templates'; Help = 'Create or take over the standard policies, in report-only.'; Action = { param($s) Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Deploy policies from the templates' -Text @( 'Creates the standard policies in this tenant, or takes over ones that already exist under their old names.' 'Everything new starts in report-only: it records what it would have done and blocks or prompts no one.' '- Groups, locations, or strengths a policy needs come first. You approve those too.' '- Taking over an existing policy shows every difference before anything changes.' '> After about a week in report-only, use "Turn policies on".' ) Assert-NSPConditionalAccessDashboardReady $s -Inventory -TenantProfile $insight = Get-NSPConditionalAccessDashboardInsight -State $s.State -Cache $s.Cache -ConfigRoot $s.ConfigRoot if ($insight.Problem) { throw $insight.Problem } $rows = @($insight.Policies | Where-Object { $_.Family -ne 'TRAVEL' }) $number = 0 foreach ($row in $rows) { $number++ Write-NSPConsoleSegment -Segment @(@((' {0,3}. ' -f $number), 'Key'), @($row.Id, 'Strong', 22), @($row.Status, $row.Role, 20), @($row.Name, 'Normal')) } Write-NSPConsoleLine '' Write-NSPConsoleLine ' "Not deployed" and "Found, not adopted" are the ones to deploy. "Replaced (turn off)" belongs to Turn policies on.' -Role Muted $answer = Read-NSPConditionalAccessText -Prompt 'Which? Numbers separated by commas, A for all that need something, Enter to go back' if (-not $answer) { return } $chosen = if ($answer -ieq 'A') { @($rows | Where-Object { $_.Action -notin 'NoChange', 'Deselected' } | ForEach-Object { $_.Id }) } else { @($answer -split '[,\s]+' | Where-Object { $_ -match '^\d+$' -and [int]$_ -ge 1 -and [int]$_ -le $rows.Count } | ForEach-Object { $rows[[int]$_ - 1].Id }) } if (-not $chosen.Count) { Write-NSPConsoleLine ' Nothing chosen.' -Role Muted; return } Invoke-NSPConditionalAccessGuidedDeployment -Session $s -Title 'Deploy policies' -Focus { param($item) $item.Kind -eq 'Policy' -and $item.ItemId -in $chosen }.GetNewClosure() } } [pscustomobject]@{ Id = 'Enforce'; Label = 'Turn policies on'; Help = 'Move report-only policies to On, after the safety checks.'; Action = { param($s) Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Turn policies on' -Text @( 'Moves policies from report-only to On, one approval each. A policy can be turned on only when:' '- it still matches its template (what was tested is what gets enforced)' '- breakglass is excluded and enabled, so no one can be locked out' 'It warns when a policy has been in report-only for less than 7 days, and shows its report-only sign-in results: who it would have blocked or prompted.' '! If Security Defaults is on, the plan turns it off first in the same run, moments before the first policy goes On.' '> To go back, use Undo or resume a run (or All tools > Enforce > rollback).' ) Assert-NSPConditionalAccessDashboardReady $s -Inventory -TenantProfile $promoteArgs = @{ Inventory = $s.State.Inventory.FullName; TenantProfile = $s.State.ProfilePath; Save = $true; ConfigRoot = $s.ConfigRoot } if ($s.State.Connected -and (Read-NSPConfirm -Prompt 'Read the report-only sign-in results first? (recommended; needs Entra ID P1)' -DefaultYes)) { $days = [int](Read-NSPConditionalAccessText -Prompt 'Days of sign-ins to read' -Default '7') $impact = Get-NSPConditionalAccessReportOnlyImpact -Days $days -Save -ConfigRoot $s.ConfigRoot if (-not $impact.Available) { Write-NSPConsoleLine " $($impact.Reason)" -Role Warning } else { $impact.Policies | Format-Table DisplayName, WouldBlock, WouldInterrupt, Satisfied, UsersAffected -Wrap | Out-Host $promoteArgs.Impact = $impact.Path } } elseif ($s.State.Impact) { $promoteArgs.Impact = $s.State.Impact.FullName Write-NSPConsoleLine " Using the sign-in results saved $($s.State.Impact.LastWriteTime)." -Role Muted } $plan = New-NSPConditionalAccessPromotionPlan @promoteArgs if ((Invoke-NSPConditionalAccessPlanReview -PlanPath $plan.Path -Title 'Turn policies on' -TenantProfile $s.State.ProfilePath) -and (Read-NSPConfirm -Prompt 'Run the approved items now?')) { if (Invoke-NSPConditionalAccessDashboardRun -PlanPath $plan.Path -ConfigRoot $s.ConfigRoot) { $s.InventoryWanted = $true } } } } [pscustomobject]@{ Id = 'Travel'; Label = 'Add travel'; Help = 'Let someone sign in from another country, for a while or for good.'; Action = { param($s) Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Travel' -Text @( 'The baseline blocks sign-ins from outside the home country. Travel makes an exception for a group of people:' '- A country: a standing group (Group-Travel-<country>, for example Group-Travel-Croatia) that may sign in from that country until it is retired.' '- A trip: every country on the route (layovers too), with an end date; it is retired after.' 'Travelers must use MFA abroad and sign in again regularly. Add people to the travel group in AD or Entra; that is all they need.' '> While the baseline country block is On, the travel policies are created On and the baseline lets the group out in the same run.' '> Hybrid: the AD group is created by a script you run on-premises. After the next sync, choose "Update travel policies" to add it.' ) Assert-NSPConditionalAccessDashboardReady $s -TenantProfile $what = Read-NSPMenu -Prompt 'Travel' -CancelLabel 'Back' -Choices @( [pscustomobject]@{ Label = 'Allow a country (standing)'; Value = 'Country' } [pscustomobject]@{ Label = 'Add a trip (countries and an end date)'; Value = 'Trip' } [pscustomobject]@{ Label = 'Update travel policies (after an AD sync, or to finish a step)'; Value = 'Update' } [pscustomobject]@{ Label = 'Import travel policies this tenant already has'; Value = 'Import' } [pscustomobject]@{ Label = 'List travel entries'; Value = 'List' } [pscustomobject]@{ Label = 'Retire a travel entry early'; Value = 'Retire' } ) # bindtravelgroup:<Key> adds the synced AD group once it exists. $focus = { param($item) $item.ItemId -like 'NSP-TRAVEL-*' -or $item.ItemId -like '*travelgroup*' -or $item.ItemId -like 'location:*' -or $item.ItemId -eq 'NSP-BASE-GEO-01' -or $item.Action -eq 'Retire' } switch ($what) { 'Country' { $iso = @(Read-NSPConditionalAccessCountry -Prompt 'Country code') if (-not $iso.Count) { return } Add-NSPConditionalAccessTravel -TenantProfile $s.State.ProfilePath -Country $iso[0] -Confirm:$false | Format-Table Key, PolicyId, DefaultName | Out-Host } 'Trip' { $name = Read-NSPConditionalAccessText -Prompt 'Trip name (for example Smith Germany May)' if (-not $name) { return } $countries = @(Read-NSPConditionalAccessCountry -Prompt 'Country codes, comma separated (include layovers)' -Multiple) if (-not $countries.Count) { return } $end = $null while (-not $end) { $text = Read-NSPConditionalAccessText -Prompt 'Last day of the trip (yyyy-MM-dd)' if (-not $text) { return } $parsed = [datetime]::MinValue if ([datetime]::TryParseExact($text, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$parsed) -and $parsed -ge (Get-Date).Date) { $end = $parsed } else { Write-NSPConsoleLine ' Enter a date like 2026-10-15, today or later.' -Role Warning } } Add-NSPConditionalAccessTravel -TenantProfile $s.State.ProfilePath -TripName $name -Country $countries -EndDate $end -Confirm:$false | Format-Table Key, PolicyId, DefaultName, EndDate | Out-Host } 'Import' { Assert-NSPConditionalAccessDashboardReady $s -Inventory $wanted = @(Import-NSPConditionalAccessTravel -Inventory $s.State.Inventory.FullName -TenantProfile $s.State.ProfilePath -WhatIf) if (-not $wanted.Count) { Write-NSPConsoleLine ' No travel policies found to import.' -Role Muted; return } $wanted | Format-Table Result, Policy, Key, Group, Notes -Wrap | Out-Host if (-not @($wanted | Where-Object Result -eq 'Imported').Count -or -not (Read-NSPConfirm -Prompt 'Record the imported entries in the tenant profile?')) { return } Import-NSPConditionalAccessTravel -Inventory $s.State.Inventory.FullName -TenantProfile $s.State.ProfilePath -Confirm:$false | Out-Null } 'List' { $entries = @(Get-NSPConditionalAccessTravel -TenantProfile $s.State.ProfilePath) if ($entries.Count) { $entries | Format-Table -Wrap | Out-Host } else { Write-NSPConsoleLine ' No travel entries.' -Role Muted } return } 'Retire' { $entries = @(Get-NSPConditionalAccessTravel -TenantProfile $s.State.ProfilePath | Where-Object Status -eq 'Active') if (-not $entries.Count) { Write-NSPConsoleLine ' No active travel entries.' -Role Muted; return } $key = Read-NSPMenu -Prompt 'Entry to retire' -CancelLabel 'Back' -Choices @($entries | ForEach-Object { [pscustomobject]@{ Label = "$($_.Key) $($_.Name) ($($_.Countries))"; Value = $_.Key } }) if (-not $key) { return } Remove-NSPConditionalAccessTravel -TenantProfile $s.State.ProfilePath -Key $key -Confirm:$false | Out-Null Write-NSPConsoleLine ' Retiring takes two runs: first the baseline blocks the group abroad again, then the travel policy is deleted. Run this again for the second.' -Role Muted } 'Update' { Assert-NSPConditionalAccessDashboardReady $s -Inventory Invoke-NSPConditionalAccessGuidedDeployment -Session $s -Title 'Travel' -Focus $focus return } default { return } } Read-NSPConditionalAccessText -Prompt 'Recorded. Press Enter to plan and review the changes' | Out-Null $s.State = Get-NSPConditionalAccessDashboardState -TenantId $s.TenantId -ConfigRoot $s.ConfigRoot Invoke-NSPConditionalAccessGuidedDeployment -Session $s -Title 'Travel' -Focus $focus } } [pscustomobject]@{ Id = 'Targets'; Label = 'Change who a policy applies to'; Help = 'Leave a user or group out of one policy, bring one in, or manage the service accounts every policy leaves out.'; Action = { param($s) Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Change who a policy applies to' -Text @( 'Each policy targets who its template says. For one policy, you can add to that:' '- Leave out a user or group. From a block policy that lets them through; from an MFA policy they no longer need MFA from it.' '- Bring in a user or group, for a policy that does not already apply to everyone. That restricts them.' '- Service accounts: left out of every policy that leaves out service accounts (most of them).' 'The change is recorded in the tenant profile, then planned, reviewed, and run like any other, and can be undone.' '> The template''s own targets (breakglass, the MFA groups) are not changed here.' ) Assert-NSPConditionalAccessDashboardReady $s -Inventory -TenantProfile $insight = Get-NSPConditionalAccessDashboardInsight -State $s.State -Cache $s.Cache -ConfigRoot $s.ConfigRoot if ($insight.Problem) { throw $insight.Problem } $choices = @($insight.Policies | ForEach-Object { [pscustomobject]@{ Label = "$($_.Id) $($_.Name)"; Value = $_.Id; Help = $_.Status } }) $choices += [pscustomobject]@{ Key = 'S'; Label = 'Service accounts (every policy)'; Value = '<service>' } $policyId = Read-NSPMenu -Prompt 'Which policy?' -CancelLabel 'Back' -Choices $choices if (-not $policyId) { return } $definitions = @(Get-NSPConditionalAccessDefinition) + @(Get-NSPConditionalAccessTravelDefinition -TenantProfile $s.State.Profile) $definition = @($definitions | Where-Object { $_['Id'] -eq $policyId })[0] $everyone = $definition -and @(Get-NSPConditionalAccessMapValue $definition['Users'] 'IncludeUsers') -contains 'All' $changed = $false while ($true) { $profileNow = Get-Content -LiteralPath $s.State.ProfilePath -Raw | ConvertFrom-Json Write-NSPConsoleLine '' if ($policyId -eq '<service>') { Write-NSPConsoleRule -Title 'Service accounts, left out of every policy that leaves them out' $rows = @(Get-NSPConditionalAccessPolicyTarget -TenantProfile $profileNow | Where-Object Field -eq 'ServiceAccounts') $actions = @( [pscustomobject]@{ Label = 'Add a service account'; Value = 'AddService' } [pscustomobject]@{ Label = 'Remove a service account'; Value = 'Remove' } ) } else { Write-NSPConsoleRule -Title $policyId foreach ($pair in (Get-NSPConditionalAccessDefinitionSummary -Definition $definition -TenantProfile $profileNow).GetEnumerator()) { Write-NSPConsoleSegment -Segment @(@((' {0,-12}' -f $pair.Key), 'Muted'), [string]$pair.Value) } $rows = @(Get-NSPConditionalAccessPolicyTarget -TenantProfile $profileNow -PolicyId $policyId | Where-Object Field -ne 'ServiceAccounts') $actions = @( [pscustomobject]@{ Label = 'Leave out a user'; Value = 'ExcludeUsers' } [pscustomobject]@{ Label = 'Leave out a group'; Value = 'ExcludeGroups' } ) if (-not $everyone) { $actions += [pscustomobject]@{ Label = 'Bring in a user'; Value = 'IncludeUsers' } $actions += [pscustomobject]@{ Label = 'Bring in a group'; Value = 'IncludeGroups' } } $actions += [pscustomobject]@{ Label = 'Remove one of the additions'; Value = 'Remove' } } Write-NSPConsoleLine '' if ($rows.Count) { $words = @{ IncludeUsers = 'brought in'; IncludeGroups = 'brought in'; ExcludeUsers = 'left out'; ExcludeGroups = 'left out'; ServiceAccounts = 'service account' } foreach ($row in $rows) { Write-NSPConsoleSegment -Segment @(@((' {0,-18}' -f $words[$row.Field]), 'Accent'), $row.Label) } } else { Write-NSPConsoleLine ' No additions yet.' -Role Muted } $actions += [pscustomobject]@{ Key = 'P'; Label = $(if ($changed) { 'Done: plan and review the change' } else { 'Plan and review' }); Value = 'Plan' } $action = Read-NSPMenu -Prompt 'What now?' -CancelLabel 'Back' -Choices $actions if (-not $action) { if ($changed) { Write-NSPConsoleLine ' Recorded in the tenant profile. It reaches the tenant when planned and run (P here, or Deploy).' -Role Muted } return } if ($action -eq 'Plan') { break } if ($action -eq 'Remove') { if (-not $rows.Count) { continue } $row = Read-NSPMenu -Prompt 'Remove which?' -CancelLabel 'Back' -Choices @($rows | ForEach-Object { [pscustomobject]@{ Label = "$($_.Label) ($($_.Field))"; Value = $_ } }) if (-not $row) { continue } if ($row.Field -eq 'ServiceAccounts') { Remove-NSPConditionalAccessPolicyTarget -TenantProfile $s.State.ProfilePath -ServiceAccount -Id $row.Id -Confirm:$false } else { Remove-NSPConditionalAccessPolicyTarget -TenantProfile $s.State.ProfilePath -PolicyId $policyId -Field $row.Field -Id $row.Id -Confirm:$false } $changed = $true continue } $kind = if ($action -like '*Groups') { 'Group' } else { 'User' } $inventory = Get-Content -LiteralPath $s.State.Inventory.FullName -Raw | ConvertFrom-Json $picked = Select-NSPConditionalAccessDirectoryObject -Session $s -Kind $kind -Inventory $inventory if (-not $picked) { continue } try { if ($action -eq 'AddService') { Add-NSPConditionalAccessPolicyTarget -TenantProfile $s.State.ProfilePath -ServiceAccount -Id $picked.Id -Label $picked.Label -Confirm:$false | Out-Null } else { Add-NSPConditionalAccessPolicyTarget -TenantProfile $s.State.ProfilePath -PolicyId $policyId -Field $action -Id $picked.Id -Label $picked.Label -Confirm:$false | Out-Null } $changed = $true } catch { Write-NSPConsoleLine " $_" -Role Warning } } $s.State = Get-NSPConditionalAccessDashboardState -TenantId $s.TenantId -ConfigRoot $s.ConfigRoot $focus = if ($policyId -eq '<service>') { { param($item) $item.Kind -eq 'Policy' -and $item.Action -eq 'UpdateInPlace' } } else { { param($item) $item.ItemId -eq $policyId }.GetNewClosure() } Invoke-NSPConditionalAccessGuidedDeployment -Session $s -Title 'Change who a policy applies to' -Focus $focus } } [pscustomobject]@{ Id = 'TrustedLocations'; Label = 'Update trusted locations'; Help = 'The office public IP addresses the policies treat as the office.'; Action = { param($s) Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Trusted locations (office IP addresses)' -Text @( 'A trusted location is a named list of the office''s public IP addresses. The policies treat sign-ins from them as "at the office":' '- NSP-BASE-MFA-01 asks for MFA only away from the office' '- NSP-BASE-BLOCK-01 lets office-only staff sign in only from there' '- NSP-BASE-REG-01 and REG-02 let people set up MFA there' '! Use the PUBLIC address. From a PC in the office, search the web for "what is my IP". 10.x, 172.16-31.x, and 192.168.x addresses never reach Entra.' ) Assert-NSPConditionalAccessDashboardReady $s -Inventory $inventory = Get-Content -LiteralPath $s.State.Inventory.FullName -Raw | ConvertFrom-Json $locations = @($inventory.NamedLocations | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.ipNamedLocation' } | Sort-Object displayName) $choices = @($locations | ForEach-Object { $ranges = @($_.ipRanges | ForEach-Object { $_.cidrAddress }) -join ', ' [pscustomobject]@{ Label = "$($_.displayName) $(if ($_.isTrusted) { '[trusted]' } else { '[not trusted]' })"; Value = $_.displayName; Help = $ranges } }) # This computer's public address, for the common case of adding where the tool runs. $here = Get-NSPConditionalAccessPublicAddress $hereIn = @() if ($here) { $hereIn = @($locations | Where-Object { @($_.ipRanges | ForEach-Object { $_.cidrAddress }) -contains "$($here.IP)/32" } | ForEach-Object { $_.displayName }) $line = @(, @(' This computer''s public IP: ', 'Muted')) + @(, @($here.IP, 'Accent')) + @(, @(" (via $($here.Source))", 'Muted')) if ($hereIn.Count) { $line += , @(" already in: $($hereIn -join ', ')", 'Success') } Write-NSPConsoleSegment -Segment $line } else { Write-NSPConsoleLine ' This computer''s public IP could not be looked up (no lookup service answered); type addresses instead.' -Role Muted } Write-NSPConsoleLine '' $choices += [pscustomobject]@{ Key = 'N'; Label = 'New location (another office)'; Value = '<new>' } if ($here -and -not $hereIn.Count) { $choices += [pscustomobject]@{ Key = 'A'; Label = "Add this computer's public IP ($($here.IP))"; Value = '<here>' Help = "Run at NetSys, this is NetSys's address: trusting it makes sign-ins from the NetSys office count as this client's office. Over a VPN it is the VPN's exit address." } } $name = Read-NSPMenu -Prompt 'Which location?' -CancelLabel 'Back' -Choices $choices if (-not $name) { return } $planArgs = @{ Inventory = $inventory; Save = $true; ConfigRoot = $s.ConfigRoot } if ($name -eq '<here>') { if ($locations.Count) { Write-NSPConsoleLine " Existing locations: $(@($locations | ForEach-Object { $_.displayName }) -join ', '). Use one of these names to add the address to it." -Role Muted } $name = Read-NSPConditionalAccessText -Prompt "Name of the location for $($here.IP) (for example NetSys office)" if (-not $name) { return } $planArgs.AddRange = @($here.IP) } elseif ($name -eq '<new>') { $name = Read-NSPConditionalAccessText -Prompt 'Name for the location (for example Main office)' if (-not $name) { return } $add = Read-NSPConditionalAccessText -Prompt 'Public IP addresses or ranges, comma separated' if (-not $add) { return } $planArgs.AddRange = @($add -split '[,;\s]+' | Where-Object { $_ }) } else { $change = Read-NSPMenu -Prompt 'Change' -CancelLabel 'Back' -Choices @( [pscustomobject]@{ Label = 'Add addresses'; Value = 'Add' } [pscustomobject]@{ Label = 'Remove addresses'; Value = 'Remove' } [pscustomobject]@{ Label = 'Replace one address with another (a new office IP)'; Value = 'Replace' } [pscustomobject]@{ Label = 'Stop trusting this location'; Value = 'Untrust' } ) if (-not $change) { return } if ($change -in 'Add', 'Replace') { $planArgs.AddRange = @((Read-NSPConditionalAccessText -Prompt 'Addresses to add, comma separated') -split '[,;\s]+' | Where-Object { $_ }) } if ($change -in 'Remove', 'Replace') { $planArgs.RemoveRange = @((Read-NSPConditionalAccessText -Prompt 'Addresses to remove, exactly as listed, comma separated') -split '[,;\s]+' | Where-Object { $_ }) } if ($change -eq 'Untrust') { $planArgs.Untrust = $true } } $plan = New-NSPConditionalAccessTrustedLocationPlan -Name $name @planArgs if ((Invoke-NSPConditionalAccessPlanReview -PlanPath $plan.Path -Title 'Trusted locations' -TenantProfile $s.State.ProfilePath) -and (Read-NSPConfirm -Prompt 'Run it now?')) { if (Invoke-NSPConditionalAccessDashboardRun -PlanPath $plan.Path -TenantProfile $s.State.ProfilePath -ConfigRoot $s.ConfigRoot) { $s.InventoryWanted = $true } } } } [pscustomobject]@{ Id = 'ProtectRegistration'; Label = 'Protect MFA setup'; Help = 'Choose how people may register MFA: at the office, or with a Temporary Access Pass.'; Action = { param($s) Assert-NSPConditionalAccessDashboardReady $s -TenantProfile $current = (Get-NSPConditionalAccessOption).Registration.Default $options = Get-NSPConditionalAccessMapValue $s.State.Profile 'Options' if ($options -and (Get-NSPConditionalAccessMapValue $options 'Registration')) { $current = Get-NSPConditionalAccessMapValue $options 'Registration' } Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Protect MFA setup (security-info registration)' -Text @( 'Whoever registers MFA first owns the account. These policies decide where and how people may register (or change) their MFA methods.' "This tenant uses: $current" ) $mode = Read-NSPMenu -Prompt 'Choose' -CancelLabel 'Back' -Choices @( [pscustomobject]@{ Label = "TrustedLocations$(if ($current -eq 'TrustedLocations') { ' (current)' })"; Value = 'TrustedLocations'; Help = 'At the office with current MFA (new users without MFA may register there); blocked elsewhere unless in ExternalRegistration (NSP-BASE-REG-01, REG-04). Needs a static office IP.' } [pscustomobject]@{ Label = "TapOutsideTrusted$(if ($current -eq 'TapOutsideTrusted') { ' (current)' })"; Value = 'TapOutsideTrusted'; Help = 'At the office with current MFA (new users without MFA may register there); elsewhere only with a Temporary Access Pass from the helpdesk (NSP-BASE-REG-02, REG-04).' } [pscustomobject]@{ Label = "TapEverywhere$(if ($current -eq 'TapEverywhere') { ' (current)' })"; Value = 'TapEverywhere'; Help = 'Always a Temporary Access Pass, even at the office. For offices without a static IP (NSP-BASE-REG-03).' } ) if (-not $mode) { return } if ($mode -ne $current) { Set-NSPConditionalAccessTenantOption -TenantProfile $s.State.ProfilePath -Registration $mode -Confirm:$false | Out-Null Write-NSPConsoleLine " Registration = $mode." -Role Success } if ($mode -ne 'TrustedLocations') { Write-NSPConsoleLine ' A Temporary Access Pass must be enabled in Entra (Protection > Authentication methods > Temporary Access Pass) before the policy can be turned on. The plan shows whether it is.' -Role Muted } Write-NSPConsoleLine ' The policy for the old choice keeps working until the new one is On; then turn the old one off (Turn policies on shows it as "Replaced").' -Role Muted Read-NSPConditionalAccessText -Prompt 'Press Enter to plan and review' | Out-Null $s.State = Get-NSPConditionalAccessDashboardState -TenantId $s.TenantId -ConfigRoot $s.ConfigRoot Invoke-NSPConditionalAccessGuidedDeployment -Session $s -Title 'Protect MFA setup' -Focus { param($item) $item.ItemId -like 'NSP-BASE-REG-*' -or $item.ItemId -like '*strength:*' -or $item.ItemId -eq 'group:ExternalRegistration' } } } [pscustomobject]@{ Id = 'Coverage'; Label = 'Find users without MFA coverage'; Help = 'Users in none of the MFA groups; review and add them to MFACatchAll.'; Action = { param($s) Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Users without MFA coverage' -Text @( 'Every enabled user should be in one of EnableMFA, RestrictExternal, or MFACatchAll; otherwise no MFA policy applies to them.' 'This lists the ones who are not, and offers to add them to MFACatchAll. Cloud-only admins are left out: NSP-ADMIN-MFA-01 covers them through their role.' '! Adding someone to MFACatchAll restricts their access (MFA on mobile and for Intune). Read the list before approving.' ) Assert-NSPConditionalAccessDashboardReady $s -TenantProfile $coverage = Get-NSPConditionalAccessCoverage -TenantProfile $s.State.ProfilePath -Save -ConfigRoot $s.ConfigRoot foreach ($side in 'Cloud', 'ActiveDirectory') { $result = $coverage.$side if ($result) { Write-NSPConsoleLine " $($side): $($result.UsersEvaluated) evaluated, $(@($result.Uncovered).Count) without coverage$(if (-not $result.Evaluated) { " - NOT EVALUATED: $(@($result.Errors) -join ' ')" })" } } Write-NSPConditionalAccessAdminNote -Session $s -Coverage $coverage $plan = New-NSPConditionalAccessCoveragePlan -Coverage $coverage -Save -ConfigRoot $s.ConfigRoot foreach ($reason in @($plan.Held)) { Write-NSPConsoleLine " Held: $reason" -Role Warning } if ((Invoke-NSPConditionalAccessPlanReview -PlanPath $plan.Path -Title 'Add users to MFACatchAll' -TenantProfile $s.State.ProfilePath) -and (Read-NSPConfirm -Prompt 'Add the approved users now? This restricts their access.')) { Invoke-NSPConditionalAccessDashboardRun -PlanPath $plan.Path -TenantProfile $s.State.ProfilePath -ConfigRoot $s.ConfigRoot | Out-Null } } } [pscustomobject]@{ Id = 'BulkAdd'; Label = 'Bulk-add users to an MFA group'; Help = 'Once at onboarding, or after an acquisition or a new site: everyone in no MFA group, into EnableMFA.'; Action = { param($s) Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Bulk-add users to an MFA group' -Text @( 'For the first setup of a tenant, or after many people arrive at once (an acquisition, a new site): every enabled user who is in none of EnableMFA, RestrictExternal, or MFACatchAll is added to one group in a single reviewed run.' '- EnableMFA (usual): staff who must use MFA away from the office.' '- MFACatchAll: the safety net; only mobile and Intune MFA apply. The scheduled coverage job uses this one.' '- MFAExempt: accounts that cannot do MFA (kiosks, scanners, shared devices); coverage then leaves them alone. Created first if this tenant does not have it, in the cloud and (hybrid) on-premises.' 'Breakglass, service accounts, sync accounts, and guests are never included. Nor are cloud-only admins: NSP-ADMIN-MFA-01 covers them through their role. Synced admins are treated like any synced user. You can limit it to some email domains (a new company usually has its own).' '! Adding people restricts their access. Anyone who belongs in RestrictExternal (office-only) instead: skip them in the review and add them there.' ) Assert-NSPConditionalAccessDashboardReady $s -TenantProfile $groups = $s.State.Profile.Groups $slot = Read-NSPMenu -Prompt 'Add them to' -CancelLabel 'Back' -Choices @( [pscustomobject]@{ Label = "EnableMFA ($(@($groups.EnableMFA.Labels) -join ', '))"; Value = 'EnableMFA'; Help = 'Usual for staff.' } [pscustomobject]@{ Label = "MFACatchAll ($(@($groups.MFACatchAll.Labels) -join ', '))"; Value = 'MFACatchAll'; Help = 'The safety net group.' } [pscustomobject]@{ Label = "MFAExempt ($(if ($groups.PSObject.Properties['MFAExempt'] -and @($groups.MFAExempt.Labels).Count) { @($groups.MFAExempt.Labels) -join ', ' } else { 'not created yet' }))"; Value = 'MFAExempt' Help = 'Accounts that cannot do MFA (kiosks, scanners, shared devices): no MFA policy applies to them, and coverage stops reporting them. Usually only a few users; pick them one by one in the review.' } ) if (-not $slot) { return } if ($slot -eq 'MFAExempt') { # The exempt group must exist where the users are: in the cloud (a cloud-only # company, or cloud-only users) and, in a hybrid tenant, on-premises too. # Up to two passes: an existing AD exempt group is bound first, and its cloud-only # twin (for cloud-only accounts) is added in the second. Assert-NSPConditionalAccessDashboardReady $s -Inventory for ($round = 1; $round -le 2; $round++) { $inventory = Get-Content -LiteralPath $s.State.Inventory.FullName -Raw | ConvertFrom-Json $synced = @{} foreach ($group in @($inventory.Groups)) { $synced[[string]$group.id] = [bool]$group.onPremisesSyncEnabled } $ids = @(if ($groups.PSObject.Properties['MFAExempt']) { @($groups.MFAExempt.Ids) | Where-Object { $_ } }) $needsGroup = -not $ids.Count -or ($s.State.Profile.Hybrid -and -not @($ids | Where-Object { -not $synced[[string]$_] }).Count) if (-not $needsGroup) { break } $what = if ($ids.Count) { 'The exempt group has no cloud-only twin yet, so cloud-only accounts could not be added. Creating it comes next' } else { 'The exempt group is not in the cloud yet. Creating (or binding) it comes first' } Write-NSPConsoleLine " $what (reviewed like any change); the bulk add continues after." -Role Heading Read-NSPConditionalAccessText -Prompt 'Press Enter to plan it' | Out-Null Invoke-NSPConditionalAccessGuidedDeployment -Session $s -Title 'Create the exempt group' -PlanArguments @{ GroupSlot = 'MFAExempt' } -Focus { param($item) $item.ItemId -like '*:MFAExempt' } $s.State = Get-NSPConditionalAccessDashboardState -TenantId $s.TenantId -ConfigRoot $s.ConfigRoot $groups = $s.State.Profile.Groups if (-not ($groups.PSObject.Properties['MFAExempt'] -and @($groups.MFAExempt.Ids).Count)) { Write-NSPConsoleLine ' The exempt group was not created, so the bulk add stops here.' -Role Warning return } } } $coverage = Get-NSPConditionalAccessCoverage -TenantProfile $s.State.ProfilePath -TargetSlot $slot -Save -ConfigRoot $s.ConfigRoot foreach ($side in 'Cloud', 'ActiveDirectory') { $result = $coverage.$side if ($result) { Write-NSPConsoleLine " $($side): $($result.UsersEvaluated) users evaluated, $(@($result.Uncovered).Count + @($result.SyncedUncovered).Count) in no MFA group$(if (-not $result.Evaluated) { " - NOT EVALUATED: $(@($result.Errors) -join ' ')" })" } } Write-NSPConditionalAccessAdminNote -Session $s -Coverage $coverage # A bulk add is expected to be large, so only the size rail is lifted; a failed read still holds everything. $plan = New-NSPConditionalAccessCoveragePlan -Coverage $coverage -AllowLargeChange -Save -ConfigRoot $s.ConfigRoot if (@($plan.Held).Count) { foreach ($reason in $plan.Held) { Write-NSPConsoleLine " Held: $reason" -Role Warning }; return } $items = @($plan.Items | Where-Object Decision -eq 'Pending') if (-not $items.Count) { Write-NSPConsoleLine ' Everyone is already in an MFA group.' -Role Success; return } # By email domain, to limit the add to (for example) an acquired company. $domainOf = { param($item) $upn = [string]$item.DesiredBody.userPrincipalName; if ($upn -match '@(.+)$') { $Matches[1].ToLowerInvariant() } else { '(no sign-in name)' } } $domains = @($items | Group-Object -Property { & $domainOf $_ } | Sort-Object Count -Descending) Write-NSPConsoleRule -Title "$($items.Count) user(s) in no MFA group, by email domain" $number = 0 foreach ($domain in $domains) { $number++ Write-NSPConsoleSegment -Segment @(@((' {0,3}. ' -f $number), 'Key'), @($domain.Name, 'Strong', 40), @("$($domain.Count) user(s)", 'Accent')) } Write-NSPConsoleLine '' $answer = Read-NSPConditionalAccessText -Prompt 'Which domains? Numbers separated by commas, A for all, Enter to go back' if (-not $answer) { return } $chosen = if ($answer -ieq 'A') { @($domains | ForEach-Object { $_.Name }) } else { @($answer -split '[,\s]+' | Where-Object { $_ -match '^\d+$' -and [int]$_ -ge 1 -and [int]$_ -le $domains.Count } | ForEach-Object { $domains[[int]$_ - 1].Name }) } $focus = @($items | Where-Object { (& $domainOf $_) -in $chosen } | ForEach-Object { $_.ItemId }) if (-not $focus.Count) { Write-NSPConsoleLine ' Nothing chosen.' -Role Muted; return } $target = @($items | Where-Object { $_.ItemId -in $focus })[0].DesiredBody.groupName Read-NSPConditionalAccessText -Prompt "$($focus.Count) user(s) to add to $target. Press Enter to review (A approves every listed user at once)" | Out-Null if ((Invoke-NSPConditionalAccessPlanReview -PlanPath $plan.Path -Focus $focus -Title "Bulk add to $slot" -TenantProfile $s.State.ProfilePath) -and (Read-NSPConfirm -Prompt 'Add the approved users now? This restricts their access.')) { Invoke-NSPConditionalAccessDashboardRun -PlanPath $plan.Path -TenantProfile $s.State.ProfilePath -ConfigRoot $s.ConfigRoot | Out-Null } } } [pscustomobject]@{ Id = 'Health';Label = 'Check tenant health'; Help = 'Lockout risks and setup gaps, each explained, from the latest inventory.'; Action = { param($s) Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Tenant health' -Text @( 'Checks the latest inventory against the tenant profile (read-only, no sign-in needed): lockout risks, breakglass, and what a plan would need.' ) Assert-NSPConditionalAccessDashboardReady $s -Inventory -TenantProfile $hints = @{ LockoutRisk = 'An On block policy reaches a breakglass account. Exclude breakglass from it now, in the Entra admin center or by adopting it.' BreakGlassDisabled = 'Enable the breakglass account. No policy is turned on while it is disabled.' BreakGlassNotExcluded = 'An On policy applies to breakglass. Adopting the policy adds the exclusion.' BreakGlassSynced = 'Breakglass should be a cloud-only account, so an AD or sync outage cannot lock it out.' BreakGlassUnverified = 'Take a new inventory so group and role memberships can be checked.' ServiceAccountNotExcluded = 'A service account would be prompted for MFA it cannot do. Adopting the policy adds the exclusion.' TapMethodDisabled = 'Enable Temporary Access Pass under Entra > Protection > Authentication methods before turning the registration policy on.' TapOneTimeOnly = 'Allow multi-use passes in the Temporary Access Pass settings if the helpdesk needs them.' TapNotForAllUsers = 'Target the Temporary Access Pass method at all users, or some users cannot register.' SecurityDefaultsEnabled = 'Nothing to do now. "Turn policies on" turns it off at the right moment.' SecurityDefaultsUnverified = 'Take a new inventory.' MissingApplication = 'An app a policy names is not in this tenant. Fine if the client does not use it; the policy waits.' MissingGroupSlot = 'Deploying the policies that need it creates the group.' MissingCountry = 'Deploying creates the country location.' MissingStrength = 'Deploying the registration policy creates it.' OrphanNamedLocation = 'An unused duplicate location. All tools > Deploy offers to delete it.' StaleExclusion = 'A policy excludes a deleted user. Adopting the policy cleans it up.' TemplatePolicy = 'Microsoft-managed; left alone.' } $findings = @(Test-NSPConditionalAccessTenant -Inventory $s.State.Inventory.FullName -TenantProfile $s.State.ProfilePath) if (-not $findings.Count) { Write-NSPConsoleLine ' Nothing found.' -Role Success; return } foreach ($severity in 'Error', 'Warning', 'Info') { $group = @($findings | Where-Object Severity -eq $severity) if (-not $group.Count) { continue } $title = switch ($severity) { 'Error' { 'Problems (fix before turning policies on)' } 'Warning' { 'Warnings' } default { 'For information' } } $role = switch ($severity) { 'Error' { 'Error' } 'Warning' { 'Warning' } default { 'Muted' } } Write-NSPConsoleRule -Title "$title ($($group.Count))" -Role $role foreach ($finding in $group) { Write-NSPConsoleSegment -Segment @(@(" $($finding.Rule)", $role), @(" $($finding.Subject)", 'Strong')) Write-NSPConsoleLine " $($finding.Message)" if ($hints.ContainsKey($finding.Rule)) { Write-NSPConsoleSegment -Segment @(@(' What to do: ', 'Key'), $hints[$finding.Rule]) } } } } } [pscustomobject]@{ Id = 'UndoResume'; Label = 'Undo or resume a run'; Help = 'Every run is journaled; finish an interrupted one, or reverse one.'; Action = { param($s) Write-NSPConditionalAccessTaskIntro -Session $s -Title 'Undo or resume a run' -Text @( 'Every run is saved step by step. A run that stopped can be resumed where it left off.' 'Undo makes a plan that reverses a run, item by item, and refuses anything changed since.' ) if (-not @($s.State.Runs).Count) { Write-NSPConsoleLine ' No runs yet.' -Role Muted; return } $choices = @($s.State.Runs | Select-Object -First 15 | ForEach-Object { $note = if ($_.Status -eq 'Pending') { 'approved, never run' } else { $_.Status } [pscustomobject]@{ Label = "$($_.RunId) $note, $($_.Items) item(s)"; Value = $_ } }) $run = Read-NSPMenu -Prompt 'Which run?' -CancelLabel 'Back' -Choices $choices if (-not $run) { return } $journal = Get-Content -LiteralPath $run.Path -Raw | ConvertFrom-Json $journal.Items | Format-Table ItemId, Action, Result -Wrap | Out-Host $actions = @() if ($run.Status -in 'Failed', 'Paused', 'InProgress', 'Pending') { $actions += [pscustomobject]@{ Key = 'R'; Label = 'Resume or run it'; Value = 'Resume' } } $actions += [pscustomobject]@{ Key = 'U'; Label = 'Undo it (reviewed like any plan)'; Value = 'Undo' } $what = Read-NSPMenu -Prompt 'Do what?' -CancelLabel 'Back' -Choices $actions if ($what -eq 'Resume') { if (Invoke-NSPConditionalAccessDashboardRun -RunPath $run.Path -ConfigRoot $s.ConfigRoot) { $s.InventoryWanted = $true } } elseif ($what -eq 'Undo') { $plan = New-NSPConditionalAccessUndoPlan -Run $run.Path -Save -ConfigRoot $s.ConfigRoot if ((Invoke-NSPConditionalAccessPlanReview -PlanPath $plan.Path -Title 'Undo' -TenantProfile $s.State.ProfilePath) -and (Read-NSPConfirm -Prompt 'Run the approved undo items now?')) { if (Invoke-NSPConditionalAccessDashboardRun -PlanPath $plan.Path -ConfigRoot $s.ConfigRoot) { $s.InventoryWanted = $true } } } } } ) } |