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
}