Public/New-NSPConditionalAccessUndoPlan.ps1
|
function New-NSPConditionalAccessUndoPlan { <# .SYNOPSIS Offline: plans reversing a run, item by item in reverse order, from what its journal recorded. .DESCRIPTION Each completed item of the run becomes an undo item, reviewed and approved like any plan: - created policy -> UndoCreatePolicy: delete it, forget ownership; - adopted policy -> UndoAdopt: restore its earlier content and name, forget ownership; - updated policy -> UndoUpdate: restore its earlier content and record that as applied; - promotion -> PromoteState back to the earlier state; - created group -> UndoCreateGroup: delete it (only when no policy uses it), unbind it; - created location -> UndoCreateNamedLocation: delete it (only when unreferenced), unbind it; - bound synced group -> UndoBind: unbind it (the group itself is on-premises and stays); - created strength -> UndoCreateAuthenticationStrength: delete it (only when unused), unbind it; - bound strength -> UndoBind: unbind it (the strength existed before and stays); - deleted location -> UndoDeleteNamedLocation: recreate it from its recorded content; - coverage adds -> remove the user again, only when this run added them. Every undo re-checks the tenant first. A policy changed since the run is refused, so undo never overwrites someone else's later change. Not undoable, and listed with the reason: retired travel policies (their content is in the journal's PreviousBody), on-premises group scripts, items without a recorded earlier state (runs from before undo support), and items that did not complete. .EXAMPLE $undo = New-NSPConditionalAccessUndoPlan -Run $run.Path -Save Get-NSPConditionalAccessPlanSummary -Plan $undo.Path | Format-Table -Wrap Set-NSPConditionalAccessPlanDecision -Plan $undo.Path -ItemId 'undo:*' -Decision Approve #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string]$Run, [switch]$Save, [string]$ConfigRoot ) $journal = Get-Content -LiteralPath $Run -Raw | ConvertFrom-Json if (-not $journal.RunId) { throw "$Run is not a run journal." } function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key } function Get-CleanBody { # A read-back object as a writable body: no IDs, timestamps, state, or annotations (except the type). param($Value, [switch]$Top) if ($null -eq $Value) { return $null } if ($Value -is [System.Collections.IDictionary] -or $Value -is [System.Management.Automation.PSCustomObject]) { $pairs = if ($Value -is [System.Collections.IDictionary]) { @($Value.Keys | ForEach-Object { [pscustomobject]@{ Name = [string]$_; Value = $Value[$_] } }) } else { @($Value.PSObject.Properties | ForEach-Object { [pscustomobject]@{ Name = $_.Name; Value = $_.Value } }) } $copy = [ordered]@{} foreach ($pair in $pairs) { if ($pair.Name -like '@odata.*' -and $pair.Name -ne '@odata.type') { continue } if ($Top -and $pair.Name -in 'id', 'createdDateTime', 'modifiedDateTime', 'templateId', 'state') { continue } if ($pair.Name -eq 'authenticationStrength' -and $pair.Value) { $copy[$pair.Name] = [ordered]@{ id = Get-Value $pair.Value 'id' }; continue } $copy[$pair.Name] = Get-CleanBody $pair.Value } return $copy } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { return , @($Value | ForEach-Object { Get-CleanBody $_ }) } $Value } function Get-BindingKey { # Which profile binding a create or bind item wrote: Group/TravelGroup/Country and its key. param($Item) $kind, $key = $Item.ItemId -split ':', 2 if (Get-Value $Item.DesiredBody 'bindKind') { return @((Get-Value $Item.DesiredBody 'bindKind'), (Get-Value $Item.DesiredBody 'key')) } switch ($kind) { 'group' { @('Group', $key) } 'travelgroup' { @('TravelGroup', $key) } 'location' { @('Country', $key) } default { @($null, $key) } } } $items = foreach ($item in @($journal.Items)[(@($journal.Items).Count - 1)..0]) { if ($null -eq $item) { continue } $base = [ordered]@{ ItemId = "undo:$($item.ItemId)"; Kind = $item.Kind; Action = 'NotUndoable'; Decision = 'NotActionable' DefinitionId = $item.DefinitionId; DisplayName = $item.DisplayName; Confidence = $null; Score = $null LiveObjectId = $null; LiveDisplayName = $null; LiveState = $null; DependsOn = @(); Reason = $null Differences = @(); Warnings = @(); DesiredBody = $null; DesiredHash = $null; LiveHash = $null } $completed = $item.Result -in 'Succeeded', 'NeedsReview' $previous = if ($item.PSObject.Properties['PreviousBody']) { $item.PreviousBody } else { $null } switch ($item.Action) { 'Create' { if (-not $completed -or -not $item.ObjectId) { $base.Reason = 'The create did not complete.'; break } $base.Action = 'UndoCreatePolicy'; $base.Kind = 'Policy' $base.LiveObjectId = $item.ObjectId; $base.LiveHash = $item.AppliedHash $base.Reason = "Deletes the policy this run created ($($item.ObjectId)), if it is unchanged since." } { $_ -in 'AdoptOrReview', 'UpdateInPlace', 'Drifted' } { if (-not $completed) { $base.Reason = 'The update did not complete.'; break } if (-not $previous) { $base.Reason = 'The journal has no earlier content for it (a run from before undo support). Restore it from the inventory snapshot taken before the run.'; break } $body = Get-CleanBody $previous -Top $body['state'] = Get-Value $previous 'state' $base.Action = if ($item.Action -eq 'AdoptOrReview') { 'UndoAdopt' } else { 'UndoUpdate' } $base.Kind = 'Policy' $base.LiveObjectId = $item.ObjectId; $base.LiveHash = $item.AppliedHash $base.DesiredBody = $body $base.DesiredHash = Get-NSPConditionalAccessPolicyHash -Canonical (ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $previous) $base.Reason = "Restores '$(Get-Value $previous 'displayName')' as it was before the run$(if ($item.Action -eq 'AdoptOrReview') { ' and releases it from this tool' }), if it is unchanged since." } 'PromoteState' { if (-not $completed) { $base.Reason = 'The promotion did not complete.'; break } $base.Action = 'PromoteState'; $base.Kind = 'Promotion' $base.LiveObjectId = $item.LiveObjectId; $base.LiveHash = $item.LiveHash $base.LiveState = Get-Value $item.DesiredBody 'state' $base.DesiredBody = [ordered]@{ state = $item.LiveState } $base.Reason = "Sets the state back to $($item.LiveState)." } 'CreateTrustedLocation' { if (-not $completed -or -not $item.ObjectId) { $base.Reason = 'The trusted location was not created.'; break } $base.Action = 'UndoCreateTrustedLocation'; $base.Kind = 'NamedLocation' $base.DesiredBody = [ordered]@{ id = $item.ObjectId } $base.Reason = "Deletes the trusted location '$($item.DisplayName)' this run created, only if no policy names it directly." $base.Warnings = @('Policies that use trusted locations (AllTrusted) stop treating these addresses as the office.') } 'UpdateTrustedLocation' { if (-not $completed) { $base.Reason = 'The update did not complete.'; break } if (-not $previous) { $base.Reason = 'The journal has no earlier content for it.'; break } $base.Action = 'UndoUpdateTrustedLocation'; $base.Kind = 'NamedLocation' $base.LiveObjectId = $item.ObjectId $base.LiveHash = $item.AppliedHash $base.DesiredBody = [ordered]@{ '@odata.type' = '#microsoft.graph.ipNamedLocation' displayName = Get-Value $previous 'displayName' isTrusted = [bool](Get-Value $previous 'isTrusted') ipRanges = @(Get-Value $previous 'ipRanges' | Where-Object { $_ } | ForEach-Object { [ordered]@{ '@odata.type' = Get-Value $_ '@odata.type'; cidrAddress = Get-Value $_ 'cidrAddress' } }) } $base.DesiredHash = Get-NSPConditionalAccessIpLocationKey -Location $base.DesiredBody $base.Reason = "Restores '$(Get-Value $previous 'displayName')' to its earlier ranges ($(@(Get-Value $previous 'ipRanges' | ForEach-Object { Get-Value $_ 'cidrAddress' }) -join ', ')), if it is unchanged since." } 'DisableSecurityDefaults' { if (-not $completed) { $base.Reason = 'Security Defaults was not turned off.'; break } if ($item.PSObject.Properties['AlreadyApplied'] -and $item.AlreadyApplied) { $base.Reason = 'Security Defaults was already off before this run.'; break } $base.Action = 'EnableSecurityDefaults'; $base.Kind = 'TenantSetting' $base.DisplayName = 'Security Defaults'; $base.LiveState = 'disabled' $base.DesiredBody = [ordered]@{ isEnabled = $true } $base.Reason = 'Turns Security Defaults back on. Undone last, after the promotions: Graph refuses while any Conditional Access policy is On, and the run checks first.' $base.Warnings = @('Security Defaults then requires every user to register for MFA again, and blocks legacy authentication.') } 'CreateGroup' { if (-not $completed -or -not $item.ObjectId) { $base.Reason = 'The group was not created.'; break } $binding = Get-BindingKey $item $base.Action = 'UndoCreateGroup'; $base.Kind = 'Prerequisite' $base.DesiredBody = [ordered]@{ id = $item.ObjectId; bindKind = $binding[0]; key = $binding[1] } $base.Reason = "Deletes the group this run created and unbinds it, only if no policy uses it by then." } 'CreateNamedLocation' { if (-not $completed -or -not $item.ObjectId) { $base.Reason = 'The named location was not created.'; break } $binding = Get-BindingKey $item $base.Action = 'UndoCreateNamedLocation'; $base.Kind = 'Prerequisite' $base.DesiredBody = [ordered]@{ id = $item.ObjectId; bindKind = $binding[0]; key = $binding[1] } $base.Reason = 'Deletes the named location this run created and unbinds it, only if no policy uses it by then.' } 'CreateAuthenticationStrength' { if (-not $completed -or -not $item.ObjectId) { $base.Reason = 'The authentication strength was not created.'; break } $base.Kind = 'Prerequisite' $base.DesiredBody = [ordered]@{ id = $item.ObjectId; bindKind = 'Strength'; key = ($item.ItemId -split ':', 2)[1] } if ($item.PSObject.Properties['Reused'] -and $item.Reused) { # It existed before the run (found at run time): only the binding is this run's. $base.Action = 'UndoBind' $base.Reason = 'Unbinds the authentication strength from the tenant profile. It existed before the run and stays.' } else { $base.Action = 'UndoCreateAuthenticationStrength' $base.Reason = 'Deletes the authentication strength this run created and unbinds it, only if no policy uses it by then.' } } 'BindAuthenticationStrength' { if (-not $completed) { $base.Reason = 'The binding did not complete.'; break } $base.Action = 'UndoBind'; $base.Kind = 'Prerequisite' $base.DesiredBody = [ordered]@{ id = Get-Value $item.DesiredBody 'strengthId'; bindKind = 'Strength'; key = Get-Value $item.DesiredBody 'key' } $base.Reason = 'Unbinds the authentication strength from the tenant profile. The strength itself existed before and stays.' } 'BindSyncedGroup' { if (-not $completed) { $base.Reason = 'The binding did not complete.'; break } $binding = Get-BindingKey $item $base.Action = 'UndoBind'; $base.Kind = 'Prerequisite' $base.DesiredBody = [ordered]@{ id = Get-Value $item.DesiredBody 'groupId'; bindKind = $binding[0]; key = $binding[1] } $base.Reason = 'Unbinds the synced group from the tenant profile. The group itself is on-premises and stays.' } 'DeleteNamedLocation' { if (-not $completed -or -not $previous) { $base.Reason = 'The deletion did not complete, or its content was not recorded.'; break } $base.Action = 'UndoDeleteNamedLocation'; $base.Kind = 'Prerequisite' $base.DesiredBody = Get-CleanBody $previous -Top $base.Reason = "Recreates '$(Get-Value $previous 'displayName')' from its recorded content (with a new ID)." } 'AddCloudMember' { if (-not ($item.PSObject.Properties['Added'] -and $item.Added)) { $base.Reason = 'This run did not add the user (skipped, or already a member).'; break } $base.Action = 'UndoAddMember' $base.DesiredBody = $item.DesiredBody $base.Reason = "Removes $(Get-Value $item.DesiredBody 'userPrincipalName') from $(Get-Value $item.DesiredBody 'groupName') again." } 'AddOnPremMember' { if (-not ($item.PSObject.Properties['Added'] -and $item.Added)) { $base.Reason = 'This run did not add the user in AD itself; if the generated script was run, remove them with Remove-ADGroupMember.'; break } $base.Action = 'UndoAddOnPremMember' $base.DesiredBody = $item.DesiredBody $base.Reason = "Removes $(Get-Value $item.DesiredBody 'samAccountName') from $(Get-Value $item.DesiredBody 'groupName') again." } 'Retire' { $base.Reason = 'A deleted policy cannot be restored with its ID. Its content is in this run''s journal (PreviousBody) to recreate by hand.' } 'CreateOnPremGroup' { $base.Reason = 'The group was created on-premises from a generated script; delete it there if needed.' } default { $base.Reason = "$($item.Action) has no undo." } } if ($base.Action -ne 'NotUndoable') { $base.Decision = 'Pending' } [pscustomobject]$base } $plan = [pscustomobject][ordered]@{ SchemaVersion = 1 PlanKind = 'Undo' PlanId = [guid]::NewGuid().ToString() CreatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') TenantId = $journal.TenantId TenantDomain = $journal.TenantDomain SourceRunId = $journal.RunId Items = @($items) Path = $null } if ($Save -and $PSCmdlet.ShouldProcess($plan.TenantId, 'Save undo plan')) { $plan.Path = Join-Path (Join-Path (Get-NSPConditionalAccessLocalPath -Kind Plans -ConfigRoot $ConfigRoot) $plan.TenantId) ("undo-$($journal.RunId).json") Save-NSPConditionalAccessJson -InputObject $plan -Path $plan.Path } $plan } |