Private/Invoke-NSPConditionalAccessPlanReview.ps1
|
function Invoke-NSPConditionalAccessPlanReview { <# .SYNOPSIS Dashboard: walks the operator through a saved plan's decisions, explaining each item in plain words. .DESCRIPTION Lists the decidable items with what each does. Picking one shows, for a policy, what the policy does (who, which apps, where, what it requires) from its definition, then the plan's reason, field differences, and warnings, and asks Approve, Skip, or Back. A approves every listed Pending item at once, after one confirmation. Decisions go through Set-NSPConditionalAccessPlanDecision, exactly as from the command line. -Focus limits the review to those item IDs (a task's own items); the rest of the plan stays as it is and is counted, not shown. Returns the number of approved items in the review. #> [CmdletBinding()] [OutputType([int])] param( [Parameter(Mandatory)][string]$PlanPath, [string[]]$Focus, [string]$Title = 'Review the plan', [string]$TenantProfile ) $definitions = @{} foreach ($definition in Get-NSPConditionalAccessDefinition) { $definitions[$definition['Id']] = $definition } $actionWords = @{ Create = 'create it (report-only)'; AdoptOrReview = 'take it over and align it'; UpdateInPlace = 'update it to the template' Drifted = 'put it back to the template'; CreateGroup = 'create the group'; CreateOnPremGroup = 'write a script to create the AD group' CreateNamedLocation = 'create the country location'; DeleteNamedLocation = 'delete an unused duplicate location' CreateAuthenticationStrength = 'create the authentication strength'; BindAuthenticationStrength = 'use the existing strength' BindSyncedGroup = 'use the synced AD group'; Retire = 'delete the retired travel policy'; PromoteState = 'change its state' DisableSecurityDefaults = 'turn Security Defaults off'; EnableSecurityDefaults = 'turn Security Defaults back on' CreateTrustedLocation = 'create the trusted location'; UpdateTrustedLocation = 'change the trusted ranges' UndoUpdateTrustedLocation = 'restore the earlier ranges'; UndoCreateTrustedLocation = 'delete the location' AddCloudMember = 'add to the group'; AddOnPremMember = 'add to the AD group' } # The profile names the tenant's groups in each policy's summary. Plans live under # <root>\.nsp-conditionalaccess\plans\<tenant>, so the profile can also be found from there. $profilePath = $TenantProfile while ($true) { $plan = Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json if (-not $profilePath -and $plan.TenantId) { $candidate = Join-Path (Get-NSPConditionalAccessLocalPath -Kind TenantProfiles -ConfigRoot (Split-Path (Split-Path (Split-Path (Split-Path $PlanPath)))) ) "$($plan.TenantId).json" if (Test-Path -LiteralPath $candidate) { $profilePath = $candidate } } $boundProfile = if ($profilePath) { Get-Content -LiteralPath $profilePath -Raw | ConvertFrom-Json } else { $null } $shown = @($plan.Items | Where-Object { -not $Focus -or $_.ItemId -in $Focus }) $decidable = @($shown | Where-Object Decision -ne 'NotActionable') $others = @($plan.Items | Where-Object { $Focus -and $_.ItemId -notin $Focus -and $_.Decision -ne 'NotActionable' }).Count Clear-NSPConsole Write-NSPConsoleHeader -Title $Title -Subtitle ([string]$plan.TenantDomain) Write-NSPConsoleLine ' Nothing has changed in the tenant yet. Approve what should happen; the next step runs only what you approved.' -Role Muted foreach ($item in @($shown | Where-Object Decision -eq 'NotActionable')) { $why = if ($item.Action -eq 'NoChange') { 'already as it should be' } elseif ($item.Action -eq 'PendingPrerequisite') { "waits for $(@($item.DependsOn) -join ', '); it comes in the next pass" } else { $item.Reason } Write-NSPConsoleSegment -Segment @(@(' - ', 'Muted'), @("$($item.ItemId) ", 'Muted'), @($why, 'Muted')) } if ($others) { Write-NSPConsoleLine " $others other item(s) in this plan belong to other tasks and are not shown; they stay undecided." -Role Muted } Write-NSPConsoleLine '' if (-not $decidable.Count) { Write-NSPConsoleLine ' Nothing here needs a decision.' -Role Success return 0 } $choices = @($decidable | ForEach-Object { $item = $_ $what = if ($actionWords.ContainsKey($item.Action)) { $actionWords[$item.Action] } else { $item.Action } $name = if ($item.DefinitionId -and $definitions.ContainsKey($item.DefinitionId)) { $definitions[$item.DefinitionId]['Name'] } else { $item.DisplayName } $flag = if (@($item.Warnings | Where-Object { $_ }).Count) { ' (!)' } else { '' } [pscustomobject]@{ Label = "[$($item.Decision)] $($item.ItemId): $what$flag"; Value = $item.ItemId; Help = $name } }) $choices += [pscustomobject]@{ Key = 'A'; Label = 'Approve every Pending item listed'; Value = '<all>' } $picked = Read-NSPMenu -Prompt 'Pick an item to read and decide (Q when done)' -Choices $choices -CancelLabel 'Done reviewing' if ($null -eq $picked -or $picked -eq '<done>') { break } if ($picked -eq '<all>') { $pendingIds = @($decidable | Where-Object Decision -eq 'Pending' | ForEach-Object { $_.ItemId }) if ($pendingIds.Count -and (Read-NSPConfirm -Prompt "Approve all $($pendingIds.Count) Pending item(s) without reading each one?")) { Set-NSPConditionalAccessPlanDecision -Plan $PlanPath -ItemId $pendingIds -Decision Approve -Confirm:$false | Out-Null } continue } $item = @($plan.Items | Where-Object ItemId -eq $picked)[0] Clear-NSPConsole Write-NSPConsoleHeader -Title "$($item.ItemId): $($item.Action)" -Subtitle ([string]$plan.TenantDomain) if ($item.DefinitionId -and $definitions.ContainsKey($item.DefinitionId)) { $definition = $definitions[$item.DefinitionId] Write-NSPConsoleLine " $($definition['Name'])" -Role Strong Write-NSPConsoleLine " $($definition['Description'])" $summary = Get-NSPConditionalAccessDefinitionSummary -Definition $definition -TenantProfile $boundProfile foreach ($key in $summary.Keys) { Write-NSPConsoleSegment -Segment @(@((' {0,-10}' -f $key), 'Muted'), [string]$summary[$key]) } Write-NSPConsoleLine '' } Write-NSPConsoleRule -Title 'What approving does' Write-NSPConsoleLine " $($item.Reason)" foreach ($difference in @($item.Differences)) { if ($difference) { Write-NSPConsoleSegment -Segment @(@(" $($difference.Field): ", 'Muted'), @((@($difference.Live) -join ', '), 'Error'), @(' -> ', 'Muted'), @((@($difference.Desired) -join ', '), 'Success')) } } foreach ($warning in @($item.Warnings)) { if ($warning) { Write-NSPConsoleLine " Warning: $warning" -Role Warning } } if ($item.Impact) { Write-NSPConsoleLine " $($item.Impact)" -Role Muted } if (@($item.DependsOn | Where-Object { $_ }).Count) { Write-NSPConsoleLine " Needs, approved in the same plan: $(@($item.DependsOn) -join ', ')" -Role Muted } Write-NSPConsoleLine '' $decision = Read-NSPMenu -Prompt 'Decision' -CancelLabel 'Back' -Choices @( [pscustomobject]@{ Key = 'A'; Label = 'Approve'; Value = 'Approve' } [pscustomobject]@{ Key = 'S'; Label = 'Skip (leave it as it is)'; Value = 'Skip' } ) if ($decision -in 'Approve', 'Skip') { $note = if ($decision -eq 'Skip') { Read-NSPConditionalAccessText -Prompt 'Note (optional)' } else { $null } Set-NSPConditionalAccessPlanDecision -Plan $PlanPath -ItemId $item.ItemId -Decision $decision -Note $note -Confirm:$false | Out-Null } } @((Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json).Items | Where-Object { $_.Decision -eq 'Approved' -and (-not $Focus -or $_.ItemId -in $Focus) }).Count } |