Private/Invoke-NSPConditionalAccessRunStage.ps1

function Invoke-NSPConditionalAccessRunStage {
    <#
    .SYNOPSIS
        Performs one stage of one run item. Returns a short message; throws on failure. Mutates the item.
    .DESCRIPTION
        Safety properties:
          - VerifyTarget re-reads the tenant immediately before writing. A policy changed since the
            plan (canonical hash differs from what the plan saw) stops the run. Nothing is written
            over a change the operator did not review.
          - Create stages look the target up again themselves before creating, and reuse an object
            an earlier interrupted attempt already made, so resuming a run never creates duplicates.
          - PATCH bodies state every managed field explicitly (empty lists, nulls for absent sections,
            clientAppTypes 'all'), so an approved removal really removes. State is never sent.
          - ConfirmApplied re-reads what Graph stored. The ownership record holds that hash. If it
            differs from the plan's desired hash, the item is marked NeedsReview with the fields.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]$Run,
        [Parameter(Mandatory)]$Item,
        [Parameter(Mandatory)][string]$Stage,
        [string]$ConfigRoot
    )

    function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key }
    function Add-ItemValue {
        param([string]$Name, $Value)
        $Item | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force
    }
    function Test-NotFound { param($ErrorRecord) "$ErrorRecord" -match 'Request_ResourceNotFound|ResourceNotFound|NotFound|404' }
    function Get-LivePolicy {
        param([string]$Id)
        try { Invoke-NSPGraphRequest -Uri "identity/conditionalAccess/policies/$Id" }
        catch { if (Test-NotFound $_) { $null } else { throw } }
    }
    function Get-PolicyHash {
        param($Policy)
        Get-NSPConditionalAccessPolicyHash -Canonical (ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $Policy)
    }
    function Get-Filtered {
        param([string]$Path, [string]$Filter, [string]$Select)
        @(Invoke-NSPGraphCollection -Uri "$Path`?`$filter=$([uri]::EscapeDataString($Filter))&`$select=$Select")
    }
    function ConvertTo-Hashtable {
        param($Value)
        if ($null -eq $Value) { return $null }
        if ($Value -is [System.Collections.IDictionary]) {
            $copy = [ordered]@{}
            foreach ($key in $Value.Keys) { $copy[[string]$key] = ConvertTo-Hashtable $Value[$key] }
            return $copy
        }
        if ($Value -is [System.Management.Automation.PSCustomObject]) {
            $copy = [ordered]@{}
            foreach ($p in $Value.PSObject.Properties) { $copy[$p.Name] = ConvertTo-Hashtable $p.Value }
            return $copy
        }
        if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { return , @($Value | ForEach-Object { ConvertTo-Hashtable $_ }) }
        $Value
    }
    function Get-WriteBody {
        # Every managed field explicit; for PATCH, sections the definition does not use are nulled.
        # List fields are assigned directly, never through `= if {...}`: a statement's output is
        # unrolled, so @('All') would be sent as "All" and @() as null. Graph rejects both (1007).
        param([switch]$ForPatch)
        $desired = ConvertTo-Hashtable $Item.DesiredBody
        $conditions = if ($desired.Contains('conditions')) { $desired['conditions'] } else { [ordered]@{} }
        $shape = [ordered]@{
            users = @('includeUsers', 'excludeUsers', 'includeGroups', 'excludeGroups', 'includeRoles', 'excludeRoles')
            applications = @('includeApplications', 'excludeApplications', 'includeUserActions')
            platforms = @('includePlatforms', 'excludePlatforms')
            locations = @('includeLocations', 'excludeLocations')
        }
        $out = [ordered]@{}
        foreach ($section in $shape.Keys) {
            if ($conditions.Contains($section) -and $conditions[$section]) {
                $block = [ordered]@{}
                foreach ($key in $shape[$section]) {
                    $block[$key] = @()
                    if ($conditions[$section].Contains($key)) { $block[$key] = @($conditions[$section][$key]) }
                }
                $out[$section] = $block
            } elseif ($section -in 'users', 'applications') {
                $block = [ordered]@{}
                foreach ($key in $shape[$section]) { $block[$key] = @() }
                $out[$section] = $block
            } elseif ($ForPatch) {
                $out[$section] = $null
            }
        }
        # Guest and external user types are objects, sent as planned; a PATCH clears an unused one.
        $users = if ($conditions.Contains('users')) { $conditions['users'] } else { $null }
        foreach ($key in 'includeGuestsOrExternalUsers', 'excludeGuestsOrExternalUsers') {
            if ($users -and $users.Contains($key)) { $out['users'][$key] = $users[$key] }
            elseif ($ForPatch) { $out['users'][$key] = $null }
        }
        $out['clientAppTypes'] = @('all')
        if ($conditions.Contains('clientAppTypes')) { $out['clientAppTypes'] = @($conditions['clientAppTypes']) }
        $out['signInRiskLevels'] = @()
        if ($conditions.Contains('signInRiskLevels')) { $out['signInRiskLevels'] = @($conditions['signInRiskLevels']) }
        $out['userRiskLevels'] = @()
        if ($conditions.Contains('userRiskLevels')) { $out['userRiskLevels'] = @($conditions['userRiskLevels']) }
        # Authentication flows (device code, authentication transfer): a PATCH clears them when unused.
        if ($conditions.Contains('authenticationFlows')) { $out['authenticationFlows'] = $conditions['authenticationFlows'] }
        elseif ($ForPatch) { $out['authenticationFlows'] = $null }
        if ($ForPatch) {
            # Definitions have no device filter, so a PATCH clears it, unless the body carries one
            # (an undo restoring a policy's earlier state).
            $out['devices'] = $null
            if ($conditions.Contains('devices')) { $out['devices'] = $conditions['devices'] }
        }

        $body = [ordered]@{ displayName = $desired['displayName'] }
        if (-not $ForPatch) { $body['state'] = $desired['state'] }
        $body['conditions'] = $out
        $body['grantControls'] = if ($desired.Contains('grantControls')) { $desired['grantControls'] } else { $null }
        $body['sessionControls'] = if ($desired.Contains('sessionControls')) { $desired['sessionControls'] } elseif ($ForPatch) { $null } else { $null }
        if (-not $ForPatch -and $null -eq $body['sessionControls']) { $body.Remove('sessionControls') }
        if (-not $ForPatch -and $null -eq $body['grantControls']) { $body.Remove('grantControls') }
        $body
    }
    function Find-PolicyByName {
        param([string]$Name)
        @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object { (Get-Value $_ 'displayName') -ceq $Name })
    }

    $desiredName = Get-Value $Item.DesiredBody 'displayName'
    $desiredState = Get-Value $Item.DesiredBody 'state'
    $isPolicy = $Item.Kind -eq 'Policy'

    switch ($Stage) {
        'VerifyTarget' {
            if ($isPolicy -and $Item.Action -eq 'Create') {
                if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." }
                $same = @(Find-PolicyByName $desiredName)
                if (-not $same.Count) { return 'No policy with this name exists.' }
                if ($same.Count -eq 1 -and (Get-PolicyHash $same[0]) -eq $Item.DesiredHash) {
                    Add-ItemValue ObjectId (Get-Value $same[0] 'id')
                    return "Already present with the desired content (an earlier attempt): $($Item.ObjectId). It will be recorded, not recreated."
                }
                $detail = if ($same.Count -gt 1) { "$($same.Count) policies have that name" } else {
                    $fields = (Compare-NSPConditionalAccessCanonicalPolicy -Live (ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $same[0]) -Desired (ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $Item.DesiredBody)).Differences | ForEach-Object Field
                    "it differs in: $($fields -join ', ')"
                }
                throw "A policy named '$desiredName' already exists ($detail). Re-plan."
            }
            if ($isPolicy) {
                $live = Get-LivePolicy $Item.LiveObjectId
                if (-not $live) { throw "Policy $($Item.LiveObjectId) no longer exists. Re-plan." }
                $hash = Get-PolicyHash $live
                Add-ItemValue LiveDisplayName (Get-Value $live 'displayName')
                # The policy as it was before this run touched it, for New-NSPConditionalAccessUndoPlan.
                if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody $live }
                if ($hash -eq $Item.LiveHash) { return 'Live policy is unchanged since the plan.' }
                if ($hash -eq $Item.DesiredHash -and (Get-Value $live 'displayName') -ceq $desiredName) {
                    Add-ItemValue AlreadyApplied $true
                    return 'Live policy already matches the definition (an earlier attempt); no update needed.'
                }
                throw "Policy '$(Get-Value $live 'displayName')' changed since the plan was made. Nothing was written. Re-plan and review the new differences."
            }
            if ($Item.Action -eq 'CreateGroup') {
                if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." }
                $same = @(Get-Filtered 'groups' "displayName eq '$($desiredName -replace "'", "''")'" 'id,displayName,onPremisesSyncEnabled')
                $cloud = @($same | Where-Object { -not (Get-Value $_ 'onPremisesSyncEnabled') })
                if ($same.Count -gt 1 -or ($same.Count -eq 1 -and -not $cloud.Count)) { throw "Group name '$desiredName' is already used ($($same.Count) match(es)). Resolve it, then re-plan." }
                if ($cloud.Count -eq 1) {
                    Add-ItemValue ObjectId (Get-Value $cloud[0] 'id')
                    return "Cloud group '$desiredName' already exists ($($Item.ObjectId)); it will be bound, not recreated."
                }
                return 'No group with this name exists.'
            }
            if ($Item.Action -eq 'CreateAuthenticationStrength') {
                if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." }
                $wanted = (@(Get-Value $Item.DesiredBody 'allowedCombinations') | Sort-Object) -join ','
                $custom = @(Invoke-NSPGraphCollection -Uri 'policies/authenticationStrengthPolicies' | Where-Object { (Get-Value $_ 'policyType') -eq 'custom' })
                $same = @($custom | Where-Object { ((@(Get-Value $_ 'allowedCombinations') | Sort-Object) -join ',') -eq $wanted })
                if ($same.Count) {
                    Add-ItemValue ObjectId (Get-Value $same[0] 'id')
                    Add-ItemValue Reused $true
                    return "Strength '$(Get-Value $same[0] 'displayName')' already allows exactly these combinations ($($Item.ObjectId)); it will be bound, not created."
                }
                if (@($custom | Where-Object { (Get-Value $_ 'displayName') -ceq $desiredName }).Count) { throw "A different authentication strength is already named '$desiredName'. Rename or remove it, then re-plan." }
                return 'No matching authentication strength exists.'
            }
            if ($Item.Action -eq 'UndoDeleteNamedLocation') {
                # Restoring a deleted duplicate recreates a duplicate by design; only a resumed run is checked.
                if ($Item.ObjectId) { return "Recreated earlier in this run: $($Item.ObjectId)." }
                return "Will recreate '$desiredName'."
            }
            if ($Item.Action -eq 'CreateNamedLocation') {
                if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." }
                $countries = @(Get-Value $Item.DesiredBody 'countriesAndRegions')
                $same = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/namedLocations' | Where-Object {
                        (Get-Value $_ 'displayName') -ceq $desiredName -and ((@(Get-Value $_ 'countriesAndRegions') | Sort-Object) -join ',') -eq (($countries | Sort-Object) -join ',')
                    })
                if ($same.Count -eq 1) {
                    Add-ItemValue ObjectId (Get-Value $same[0] 'id')
                    return "Named location '$desiredName' already exists ($($Item.ObjectId)); it will be bound, not recreated."
                }
                if ($same.Count -gt 1) { throw "Several named locations are called '$desiredName'. Resolve them, then re-plan." }
                return 'No matching named location exists.'
            }
        }
        'CreatePolicy' {
            if ($Item.ObjectId) { return "Not created again; using $($Item.ObjectId)." }
            # Re-check here too: after an interruption, VerifyTarget may already be marked done.
            $same = @(Find-PolicyByName $desiredName)
            if ($same.Count) {
                if ($same.Count -eq 1 -and (Get-PolicyHash $same[0]) -eq $Item.DesiredHash) {
                    Add-ItemValue ObjectId (Get-Value $same[0] 'id')
                    return "Found the policy an interrupted attempt created: $($Item.ObjectId)."
                }
                throw "A policy named '$desiredName' appeared since verification. Nothing was created. Re-plan."
            }
            $created = Invoke-NSPGraphRequest -Method POST -Uri 'identity/conditionalAccess/policies' -Body (Get-WriteBody)
            Add-ItemValue ObjectId (Get-Value $created 'id')
            return "Created $($Item.ObjectId) in state $(Get-Value $Item.DesiredBody 'state')."
        }
        'PatchPolicy' {
            Add-ItemValue ObjectId $Item.LiveObjectId
            if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped: already applied.' }
            Invoke-NSPGraphRequest -Method PATCH -Uri "identity/conditionalAccess/policies/$($Item.LiveObjectId)" -Body (Get-WriteBody -ForPatch) | Out-Null
            return "Updated $($Item.LiveObjectId) and named it '$desiredName'. State unchanged."
        }
        'ConfirmApplied' {
            # Conditional Access writes take seconds to become visible: a GET right after a PATCH can
            # still return the old policy. Re-read with growing waits before drawing any conclusion.
            $delays = @(2, 4, 8, 15, 30)
            if ($null -ne $script:ConfirmAppliedDelaySeconds) { $delays = @($script:ConfirmAppliedDelaySeconds) }
            $attempt = 0
            while ($true) {
                $live = Get-LivePolicy $Item.ObjectId
                $canonical = if ($live) { ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $live } else { $null }
                $hash = if ($live) { Get-NSPConditionalAccessPolicyHash -Canonical $canonical } else { $null }
                $settled = $live -and $hash -eq $Item.DesiredHash -and (Get-Value $live 'displayName') -ceq $desiredName
                if ($settled -or $attempt -ge $delays.Count) { break }
                Write-NSPConditionalAccessProgress -Activity Run -Status "$($Item.ItemId): waiting $($delays[$attempt]) s for Graph to show the change (re-read $($attempt + 1) of $($delays.Count))"
                Start-Sleep -Seconds $delays[$attempt]
                $attempt++
            }
            if (-not $live) { throw "Policy $($Item.ObjectId) could not be read back." }
            Add-ItemValue AppliedHash $hash
            $notes = [System.Collections.Generic.List[string]]::new()
            if ($hash -ne $Item.DesiredHash) {
                $desired = ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $Item.DesiredBody
                $fields = (Compare-NSPConditionalAccessCanonicalPolicy -Live $canonical -Desired $desired).Differences | ForEach-Object Field
                $notes.Add("Graph stored different content than planned in: $($fields -join ', ').")
            }
            if ((Get-Value $live 'displayName') -cne $desiredName) { $notes.Add("Display name is '$(Get-Value $live 'displayName')'.") }
            if ($Item.Action -eq 'Create' -and (Get-Value $live 'state') -ne (Get-Value $Item.DesiredBody 'state')) {
                $notes.Add("State is $(Get-Value $live 'state'), expected $(Get-Value $Item.DesiredBody 'state').")
            }
            $waited = if ($attempt) { " after $attempt re-read(s)" } else { '' }
            if ($notes.Count) {
                if ($Item.Action -ne 'Create' -and $hash -eq $Item.LiveHash -and $Item.PSObject.Properties['LiveDisplayName'] -and (Get-Value $live 'displayName') -ceq $Item.LiveDisplayName) {
                    $notes.Insert(0, 'The policy still reads exactly as before the update: the change may not have propagated yet, or was not applied. Re-inventory and re-plan to check.')
                }
                Add-ItemValue Result 'NeedsReview'
                Add-ItemValue Notes @(@($Item.Notes) + $notes)
                return "Applied, with differences to review$($waited): $($notes -join ' ')"
            }
            return "Read back$($waited): matches the definition."
        }
        'VerifyState' {
            $live = Get-LivePolicy $Item.LiveObjectId
            if (-not $live) { throw "Policy $($Item.LiveObjectId) no longer exists. Re-plan." }
            if ((Get-PolicyHash $live) -ne $Item.LiveHash) {
                throw "Policy '$(Get-Value $live 'displayName')' changed since the promotion plan. Nothing was written. Re-inventory and plan again, so the new content soaks first."
            }
            $state = Get-Value $live 'state'
            if ($state -eq $desiredState) {
                Add-ItemValue AlreadyApplied $true
                return "Already $desiredState; no update needed."
            }
            if ($state -ne $Item.LiveState) { throw "Policy state is $state; the plan expected $($Item.LiveState). Re-plan." }
            return "Live policy is unchanged and $state."
        }
        'PatchState' {
            if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped: already applied.' }
            Invoke-NSPGraphRequest -Method PATCH -Uri "identity/conditionalAccess/policies/$($Item.LiveObjectId)" -Body @{ state = $desiredState } | Out-Null
            return "Set $($Item.LiveObjectId) to $desiredState."
        }
        'ConfirmState' {
            # Same propagation lag as ConfirmApplied: re-read before concluding.
            $delays = @(2, 4, 8, 15, 30)
            if ($null -ne $script:ConfirmAppliedDelaySeconds) { $delays = @($script:ConfirmAppliedDelaySeconds) }
            $attempt = 0
            while ($true) {
                $live = Get-LivePolicy $Item.LiveObjectId
                if (($live -and (Get-Value $live 'state') -eq $desiredState) -or $attempt -ge $delays.Count) { break }
                Write-NSPConditionalAccessProgress -Activity Run -Status "$($Item.ItemId): waiting $($delays[$attempt]) s for Graph to show the change (re-read $($attempt + 1) of $($delays.Count))"
                Start-Sleep -Seconds $delays[$attempt]
                $attempt++
            }
            if (-not $live) { throw "Policy $($Item.LiveObjectId) could not be read back." }
            $notes = [System.Collections.Generic.List[string]]::new()
            if ((Get-Value $live 'state') -ne $desiredState) { $notes.Add("State reads $(Get-Value $live 'state'), expected $desiredState. It may not have propagated yet; re-inventory to check.") }
            if ((Get-PolicyHash $live) -ne $Item.LiveHash) { $notes.Add('The policy content changed during promotion. Re-plan and review.') }
            if ($notes.Count) {
                Add-ItemValue Result 'NeedsReview'
                Add-ItemValue Notes @(@($Item.Notes) + $notes)
                return "Promoted, with differences to review: $($notes -join ' ')"
            }
            return "Read back: $desiredState$(if ($attempt) { " after $attempt re-read(s)" })."
        }
        'VerifyTrustedLocation' {
            if ($Item.Action -eq 'CreateTrustedLocation') {
                if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." }
                $same = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/namedLocations' | Where-Object { (Get-Value $_ 'displayName') -ceq $desiredName })
                if (-not $same.Count) { return 'No named location has this name.' }
                if ($same.Count -eq 1 -and (Get-NSPConditionalAccessIpLocationKey -Location $same[0]) -eq $Item.DesiredHash) {
                    Add-ItemValue ObjectId (Get-Value $same[0] 'id')
                    return "Already present with these ranges (an earlier attempt): $($Item.ObjectId). It will not be recreated."
                }
                throw "A named location called '$desiredName' now exists. Nothing was written. Take a new inventory and plan again."
            }
            $location = try { Invoke-NSPGraphRequest -Uri "identity/conditionalAccess/namedLocations/$($Item.LiveObjectId)" } catch { if (Test-NotFound $_) { $null } else { throw } }
            if (-not $location) { throw "Named location $($Item.LiveObjectId) no longer exists. Re-plan." }
            if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody $location }
            $key = Get-NSPConditionalAccessIpLocationKey -Location $location
            if ($key -eq $Item.LiveHash) { return 'Named location is unchanged since the plan.' }
            if ($key -eq $Item.DesiredHash) { Add-ItemValue AlreadyApplied $true; return 'Named location already has the planned ranges; no update needed.' }
            throw "Named location '$(Get-Value $location 'displayName')' changed since the plan. Nothing was written. Take a new inventory and plan again."
        }
        'PatchNamedLocation' {
            if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped: already applied.' }
            # Graph needs the type on a named-location PATCH.
            Invoke-NSPGraphRequest -Method PATCH -Uri "identity/conditionalAccess/namedLocations/$($Item.LiveObjectId)" -Body (ConvertTo-Hashtable $Item.DesiredBody) | Out-Null
            Add-ItemValue ObjectId $Item.LiveObjectId
            return "Updated named location '$desiredName'."
        }
        'ConfirmTrustedLocation' {
            $delays = @(2, 4, 8, 15)
            if ($null -ne $script:ConfirmAppliedDelaySeconds) { $delays = @($script:ConfirmAppliedDelaySeconds) }
            $attempt = 0
            while ($true) {
                $location = Invoke-NSPGraphRequest -Uri "identity/conditionalAccess/namedLocations/$($Item.LiveObjectId)"
                $key = Get-NSPConditionalAccessIpLocationKey -Location $location
                if ($key -eq $Item.DesiredHash -or $attempt -ge $delays.Count) { break }
                Write-NSPConditionalAccessProgress -Activity Run -Status "$($Item.ItemId): waiting $($delays[$attempt]) s for Graph to show the change (re-read $($attempt + 1) of $($delays.Count))"
                Start-Sleep -Seconds $delays[$attempt]
                $attempt++
            }
            Add-ItemValue AppliedHash $key
            if ($key -ne $Item.DesiredHash) {
                Add-ItemValue Result 'NeedsReview'
                Add-ItemValue Notes @(@($Item.Notes) + "Read back as $key, expected $($Item.DesiredHash).")
                return 'Updated, but the read-back differs; review it in the Entra admin center.'
            }
            return "Read back: matches the plan$(if ($attempt) { " after $attempt re-read(s)" })."
        }
        'VerifySecurityDefaults' {
            $wanted = [bool](Get-Value $Item.DesiredBody 'isEnabled')
            $current = [bool](Get-Value (Invoke-NSPGraphRequest -Uri 'policies/identitySecurityDefaultsEnforcementPolicy') 'isEnabled')
            if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody ([ordered]@{ isEnabled = $current }) }
            if ($current -eq $wanted) {
                Add-ItemValue AlreadyApplied $true
                return "Security Defaults is already $(if ($wanted) { 'on' } else { 'off' }); no update needed."
            }
            if ($wanted) {
                # Graph refuses to turn Security Defaults on while any policy is On; say which.
                $on = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object { (Get-Value $_ 'state') -eq 'enabled' } | ForEach-Object { Get-Value $_ 'displayName' })
                if ($on.Count) { throw "Security Defaults cannot be turned on while $($on.Count) Conditional Access policies are On ($($on -join ', ')). Roll them back to report-only first. Nothing was written." }
            }
            return "Security Defaults is $(if ($current) { 'on' } else { 'off' })."
        }
        'PatchSecurityDefaults' {
            if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped: already applied.' }
            $wanted = [bool](Get-Value $Item.DesiredBody 'isEnabled')
            Invoke-NSPGraphRequest -Method PATCH -Uri 'policies/identitySecurityDefaultsEnforcementPolicy' -Body @{ isEnabled = $wanted } | Out-Null
            return "Turned Security Defaults $(if ($wanted) { 'on' } else { 'off' })."
        }
        'ConfirmSecurityDefaults' {
            $wanted = [bool](Get-Value $Item.DesiredBody 'isEnabled')
            $delays = @(2, 4, 8, 15)
            if ($null -ne $script:ConfirmAppliedDelaySeconds) { $delays = @($script:ConfirmAppliedDelaySeconds) }
            $attempt = 0
            while ($true) {
                $current = [bool](Get-Value (Invoke-NSPGraphRequest -Uri 'policies/identitySecurityDefaultsEnforcementPolicy') 'isEnabled')
                if ($current -eq $wanted -or $attempt -ge $delays.Count) { break }
                Write-NSPConditionalAccessProgress -Activity Run -Status "$($Item.ItemId): waiting $($delays[$attempt]) s for Graph to show the change (re-read $($attempt + 1) of $($delays.Count))"
                Start-Sleep -Seconds $delays[$attempt]
                $attempt++
            }
            if ($current -ne $wanted) {
                # The promotions after this would be refused; stop the run here.
                throw "Security Defaults still reads $(if ($current) { 'on' } else { 'off' }) after the update. Check it in the Entra admin center, then resume the run."
            }
            return "Read back: Security Defaults is $(if ($wanted) { 'on' } else { 'off' })."
        }
        'VerifySyncedGroup' {
            $groupId = Get-Value $Item.DesiredBody 'groupId'
            $group = try { Invoke-NSPGraphRequest -Uri "groups/$groupId`?`$select=id,displayName,onPremisesSyncEnabled" } catch { if (Test-NotFound $_) { $null } else { throw } }
            if (-not $group) { throw "Group $groupId no longer exists. Re-plan." }
            if (-not (Get-Value $group 'onPremisesSyncEnabled')) { throw "Group '$(Get-Value $group 'displayName')' is not synced from on-premises. Re-plan." }
            Add-ItemValue ObjectId $groupId
            return "Synced group '$(Get-Value $group 'displayName')' is present."
        }
        'VerifyUnreferenced' {
            $locationId = Get-Value $Item.DesiredBody 'id'
            $location = try { Invoke-NSPGraphRequest -Uri "identity/conditionalAccess/namedLocations/$locationId" } catch { if (Test-NotFound $_) { $null } else { throw } }
            if (-not $location) { Add-ItemValue AlreadyApplied $true; return 'The named location is already gone.' }
            $users = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object {
                    $locations = Get-Value (Get-Value $_ 'conditions') 'locations'
                    $locationId -in @(@(Get-Value $locations 'includeLocations') + @(Get-Value $locations 'excludeLocations'))
                } | ForEach-Object { Get-Value $_ 'displayName' })
            if ($users.Count) { throw "Named location '$(Get-Value $location 'displayName')' is used by: $($users -join ', '). Nothing was deleted." }
            if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody $location }
            return 'No policy references it.'
        }
        'DeleteNamedLocation' {
            if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Nothing to delete.' }
            $locationId = Get-Value $Item.DesiredBody 'id'
            try { Invoke-NSPGraphRequest -Method DELETE -Uri "identity/conditionalAccess/namedLocations/$locationId" | Out-Null }
            catch { if (-not (Test-NotFound $_)) { throw } }
            return "Deleted named location $locationId."
        }
        'VerifyRetire' {
            # The safety order, re-checked at run time: no other policy may still exclude the
            # travel group, or deleting its travel policy would leave travelers unrestricted.
            $travelKey = Get-Value $Item.DesiredBody 'travelKey'
            $entry = @(Get-NSPConditionalAccessTravelEntry -TenantProfile (Get-Content -LiteralPath $Run.ProfilePath -Raw | ConvertFrom-Json) | Where-Object Key -eq $travelKey)[0]
            $groupIds = if ($entry) { @($entry.Ids) } else { @() }
            $blocking = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object {
                    (Get-Value $_ 'id') -ne $Item.LiveObjectId -and
                    @(@(Get-Value (Get-Value (Get-Value $_ 'conditions') 'users') 'excludeGroups') | Where-Object { $_ -in $groupIds }).Count
                } | ForEach-Object { Get-Value $_ 'displayName' })
            if ($blocking.Count) { throw "The travel group is still excluded by: $($blocking -join ', '). Nothing was deleted. Update that policy first, then re-plan." }
            if (-not $Item.LiveObjectId) { return 'No live travel policy to delete.' }
            $live = Get-LivePolicy $Item.LiveObjectId
            if (-not $live) { Add-ItemValue AlreadyApplied $true; return 'The travel policy is already gone.' }
            if ((Get-PolicyHash $live) -ne $Item.LiveHash) { throw "Policy '$(Get-Value $live 'displayName')' changed since the plan. Nothing was deleted. Re-plan." }
            if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody $live }
            return 'No other policy excludes the travel group; the travel policy is unchanged.'
        }
        'DeletePolicy' {
            if (-not $Item.LiveObjectId -or ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied)) { return 'Nothing to delete.' }
            try { Invoke-NSPGraphRequest -Method DELETE -Uri "identity/conditionalAccess/policies/$($Item.LiveObjectId)" | Out-Null }
            catch { if (-not (Test-NotFound $_)) { throw } }
            return "Deleted $($Item.LiveObjectId)."
        }
        'ForgetTravel' {
            $travelKey = Get-Value $Item.DesiredBody 'travelKey'
            Remove-NSPConditionalAccessTravelRecord -TenantId $Run.TenantId -ProfilePath $Run.ProfilePath -DefinitionId $Item.DefinitionId -TravelKey $travelKey -ConfigRoot $ConfigRoot -Confirm:$false
            if (-not $travelKey) { return "Removed the ownership record for $($Item.DefinitionId)." }
            Add-ItemValue Notes @(@($Item.Notes) + "Delete the travel group for '$travelKey' when it is no longer needed (on-premises too, in a hybrid tenant).")
            return "Removed travel entry '$travelKey' and its ownership record."
        }
        'ForgetOwnership' {
            Remove-NSPConditionalAccessTravelRecord -TenantId $Run.TenantId -ProfilePath $Run.ProfilePath -DefinitionId $Item.DefinitionId -ConfigRoot $ConfigRoot -Confirm:$false
            return "$($Item.DefinitionId) no longer owns a policy; the next plan matches it afresh."
        }
        'VerifyUnusedGroup' {
            $groupId = Get-Value $Item.DesiredBody 'id'
            $group = try { Invoke-NSPGraphRequest -Uri "groups/$groupId`?`$select=id,displayName" } catch { if (Test-NotFound $_) { $null } else { throw } }
            if (-not $group) { Add-ItemValue AlreadyApplied $true; return 'The group is already gone.' }
            $users = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object {
                    $userCondition = Get-Value (Get-Value $_ 'conditions') 'users'
                    $groupId -in @(@(Get-Value $userCondition 'includeGroups') + @(Get-Value $userCondition 'excludeGroups'))
                } | ForEach-Object { Get-Value $_ 'displayName' })
            if ($users.Count) { throw "Group '$(Get-Value $group 'displayName')' is used by: $($users -join ', '). Undo or re-plan those first. Nothing was deleted." }
            return 'No policy references the group.'
        }
        'DeleteGroup' {
            if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Nothing to delete.' }
            $groupId = Get-Value $Item.DesiredBody 'id'
            try { Invoke-NSPGraphRequest -Method DELETE -Uri "groups/$groupId" | Out-Null } catch { if (-not (Test-NotFound $_)) { throw } }
            return "Deleted group $groupId (it stays restorable from deleted items for 30 days)."
        }
        'UnbindProfile' {
            Remove-NSPConditionalAccessProfileBinding -ProfilePath $Run.ProfilePath -Kind (Get-Value $Item.DesiredBody 'bindKind') -Key (Get-Value $Item.DesiredBody 'key') -ObjectId (Get-Value $Item.DesiredBody 'id') -Confirm:$false
            return "Removed $(Get-Value $Item.DesiredBody 'id') from $(Get-Value $Item.DesiredBody 'bindKind') $(Get-Value $Item.DesiredBody 'key') in the tenant profile."
        }
        'RemoveMember' {
            $groupId = Get-Value $Item.DesiredBody 'groupId'
            $userId = Get-Value $Item.DesiredBody 'userId'
            try { Invoke-NSPGraphRequest -Method DELETE -Uri "groups/$groupId/members/$userId/`$ref" | Out-Null } catch { if (-not (Test-NotFound $_)) { throw } }
            return "Removed from $(Get-Value $Item.DesiredBody 'groupName')."
        }
        'RemoveOnPremMember' {
            $groupIdentity = Get-Value $Item.DesiredBody 'groupIdentity'
            $userIdentity = Get-Value $Item.DesiredBody 'objectGuid'
            if (-not $userIdentity) { $userIdentity = Get-Value $Item.DesiredBody 'samAccountName' }
            if (Test-NSPConditionalAccessAdModule) {
                Remove-NSPConditionalAccessAdGroupMember -GroupIdentity $groupIdentity -UserIdentity $userIdentity -Confirm:$false | Out-Null
                return "Removed from $(Get-Value $Item.DesiredBody 'groupName') in Active Directory. The cloud follows at the next sync."
            }
            $folder = Join-Path (Split-Path -Parent $Run.Path) "$($Run.RunId)-onprem"
            $script = Join-Path $folder "Remove-$((Get-Value $Item.DesiredBody 'groupName') -replace '[^A-Za-z0-9_-]', '_')-Members.ps1"
            if (-not (Test-Path -LiteralPath $script)) {
                New-Item -ItemType Directory -Path $folder -Force | Out-Null
                @('# Generated by NSP.M365.ConditionalAccess (undo). Run on a domain controller or a host with the ActiveDirectory module.', 'Import-Module ActiveDirectory -ErrorAction Stop') |
                    Set-Content -LiteralPath $script -Encoding UTF8
            }
            "Remove-ADGroupMember -Identity '$($groupIdentity -replace "'", "''")' -Members '$($userIdentity -replace "'", "''")' -Confirm:`$false # $(Get-Value $Item.DesiredBody 'samAccountName')" |
                Add-Content -LiteralPath $script -Encoding UTF8
            Add-ItemValue Result 'OperatorActionRequired'
            Add-ItemValue Notes @(@($Item.Notes) + "Run $script on-premises.")
            return "No ActiveDirectory module on this host. Added to $script for the operator to run."
        }
        'VerifyMember' {
            # Re-check just before writing: the user may have been disabled or covered since the plan.
            $userId = Get-Value $Item.DesiredBody 'userId'
            $user = try { Invoke-NSPGraphRequest -Uri "users/$userId`?`$select=id,accountEnabled" } catch { if (Test-NotFound $_) { $null } else { throw } }
            if (-not $user -or -not (Get-Value $user 'accountEnabled')) {
                Add-ItemValue AlreadyApplied $true; Add-ItemValue Result 'Skipped'
                return 'The user no longer exists or is disabled; not added.'
            }
            $groupIds = @(Get-Value $Item.DesiredBody 'coverageGroupIds')
            if ($groupIds.Count) {
                $memberOf = @(Get-Value (Invoke-NSPGraphRequest -Method POST -Uri "users/$userId/checkMemberGroups" -Body @{ groupIds = $groupIds }) 'value')
                if ($memberOf.Count) {
                    Add-ItemValue AlreadyApplied $true; Add-ItemValue Result 'Skipped'
                    return 'The user is in a coverage group now; not added.'
                }
            }
            return 'User is enabled and still uncovered.'
        }
        'AddMember' {
            if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped.' }
            $groupId = Get-Value $Item.DesiredBody 'groupId'
            $userId = Get-Value $Item.DesiredBody 'userId'
            try {
                Invoke-NSPGraphRequest -Method POST -Uri "groups/$groupId/members/`$ref" -Body @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$userId" } | Out-Null
            } catch {
                if ("$_" -notmatch 'already exist') { throw }
                return "Already a member of $(Get-Value $Item.DesiredBody 'groupName')."
            }
            Add-ItemValue Added $true
            return "Added to $(Get-Value $Item.DesiredBody 'groupName')."
        }
        'AddOnPremMember' {
            $groupIdentity = Get-Value $Item.DesiredBody 'groupIdentity'
            $userIdentity = Get-Value $Item.DesiredBody 'objectGuid'
            if (-not $userIdentity) { $userIdentity = Get-Value $Item.DesiredBody 'samAccountName' }
            if (Test-NSPConditionalAccessAdModule) {
                $outcome = Add-NSPConditionalAccessAdGroupMember -GroupIdentity $groupIdentity -UserIdentity $userIdentity -Confirm:$false
                if ($outcome -eq 'Added') { Add-ItemValue Added $true }
                return "$(if ($outcome -eq 'AlreadyMember') { 'Already a member of' } else { 'Added to' }) $(Get-Value $Item.DesiredBody 'groupName') in Active Directory. It reaches the cloud at the next sync."
            }
            # No ActiveDirectory module here: one script per run collects every on-premises add.
            $folder = Join-Path (Split-Path -Parent $Run.Path) "$($Run.RunId)-onprem"
            $script = Join-Path $folder "Add-$((Get-Value $Item.DesiredBody 'groupName') -replace '[^A-Za-z0-9_-]', '_')-Members.ps1"
            if (-not (Test-Path -LiteralPath $script)) {
                New-Item -ItemType Directory -Path $folder -Force | Out-Null
                @(
                    '# Generated by NSP.M365.ConditionalAccess. Run on a domain controller or a host with the ActiveDirectory module.'
                    "# Each line adds one reviewed, approved user to $(Get-Value $Item.DesiredBody 'groupName'). It restricts their access once synced."
                    'Import-Module ActiveDirectory -ErrorAction Stop'
                ) | Set-Content -LiteralPath $script -Encoding UTF8
            }
            "Add-ADGroupMember -Identity '$($groupIdentity -replace "'", "''")' -Members '$($userIdentity -replace "'", "''")' # $(Get-Value $Item.DesiredBody 'samAccountName')" |
                Add-Content -LiteralPath $script -Encoding UTF8
            Add-ItemValue Result 'OperatorActionRequired'
            Add-ItemValue Notes @(@($Item.Notes) + "Run $script on-premises.")
            return "No ActiveDirectory module on this host. Added to $script for the operator to run."
        }
        'RecordOwnership' {
            Write-NSPConditionalAccessOwnership -TenantId $Run.TenantId -DefinitionId $Item.DefinitionId -ObjectId $Item.ObjectId `
                -AppliedHash $Item.AppliedHash -AppliedVersion $Item.DefinitionVersion -RunId $Run.RunId -ConfigRoot $ConfigRoot
            return "Recorded: $($Item.DefinitionId) owns $($Item.ObjectId)."
        }
        'CreateGroup' {
            if ($Item.ObjectId) { return "Not created again; using $($Item.ObjectId)." }
            $same = @(Get-Filtered 'groups' "displayName eq '$($desiredName -replace "'", "''")'" 'id,displayName,onPremisesSyncEnabled' |
                    Where-Object { -not (Get-Value $_ 'onPremisesSyncEnabled') })
            if ($same.Count -eq 1) {
                Add-ItemValue ObjectId (Get-Value $same[0] 'id')
                return "Found the group an interrupted attempt created: $($Item.ObjectId)."
            }
            $created = Invoke-NSPGraphRequest -Method POST -Uri 'groups' -Body (ConvertTo-Hashtable $Item.DesiredBody)
            Add-ItemValue ObjectId (Get-Value $created 'id')
            return "Created cloud group '$desiredName' ($($Item.ObjectId))."
        }
        'CreateNamedLocation' {
            if ($Item.ObjectId) { return "Not created again; using $($Item.ObjectId)." }
            $created = Invoke-NSPGraphRequest -Method POST -Uri 'identity/conditionalAccess/namedLocations' -Body (ConvertTo-Hashtable $Item.DesiredBody)
            Add-ItemValue ObjectId (Get-Value $created 'id')
            return "Created named location '$desiredName' ($($Item.ObjectId))."
        }
        'CreateAuthenticationStrength' {
            if ($Item.ObjectId) { return "Not created again; using $($Item.ObjectId)." }
            $created = Invoke-NSPGraphRequest -Method POST -Uri 'policies/authenticationStrengthPolicies' -Body ([ordered]@{
                    displayName = $desiredName
                    description = Get-Value $Item.DesiredBody 'description'
                    allowedCombinations = @(Get-Value $Item.DesiredBody 'allowedCombinations')
                })
            Add-ItemValue ObjectId (Get-Value $created 'id')
            return "Created authentication strength '$desiredName' ($($Item.ObjectId))."
        }
        'VerifyStrength' {
            $strengthId = Get-Value $Item.DesiredBody 'strengthId'
            $strength = try { Invoke-NSPGraphRequest -Uri "policies/authenticationStrengthPolicies/$strengthId" } catch { if (Test-NotFound $_) { $null } else { throw } }
            if (-not $strength) { throw "Authentication strength $strengthId no longer exists. Re-plan." }
            $wanted = (@(Get-Value $Item.DesiredBody 'allowedCombinations') | Sort-Object) -join ','
            if (((@(Get-Value $strength 'allowedCombinations') | Sort-Object) -join ',') -ne $wanted) {
                throw "Authentication strength '$(Get-Value $strength 'displayName')' no longer allows exactly $wanted. Nothing was bound. Re-plan."
            }
            Add-ItemValue ObjectId $strengthId
            return "Authentication strength '$(Get-Value $strength 'displayName')' is present and unchanged."
        }
        'VerifyUnusedStrength' {
            $strengthId = Get-Value $Item.DesiredBody 'id'
            $strength = try { Invoke-NSPGraphRequest -Uri "policies/authenticationStrengthPolicies/$strengthId" } catch { if (Test-NotFound $_) { $null } else { throw } }
            if (-not $strength) { Add-ItemValue AlreadyApplied $true; return 'The authentication strength is already gone.' }
            $users = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object {
                    (Get-Value (Get-Value (Get-Value $_ 'grantControls') 'authenticationStrength') 'id') -eq $strengthId
                } | ForEach-Object { Get-Value $_ 'displayName' })
            if ($users.Count) { throw "Authentication strength '$(Get-Value $strength 'displayName')' is used by: $($users -join ', '). Undo or re-plan those first. Nothing was deleted." }
            return 'No policy uses the authentication strength.'
        }
        'DeleteAuthenticationStrength' {
            if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Nothing to delete.' }
            $strengthId = Get-Value $Item.DesiredBody 'id'
            try { Invoke-NSPGraphRequest -Method DELETE -Uri "policies/authenticationStrengthPolicies/$strengthId" | Out-Null } catch { if (-not (Test-NotFound $_)) { throw } }
            return "Deleted authentication strength $strengthId."
        }
        'BindProfile' {
            $kind, $key = $Item.ItemId -split ':', 2
            $bindKind = switch ($kind) { 'group' { 'Group' } 'travelgroup' { 'TravelGroup' } 'strength' { 'Strength' } default { 'Country' } }
            if (Get-Value $Item.DesiredBody 'bindKind') { $bindKind = Get-Value $Item.DesiredBody 'bindKind'; $key = Get-Value $Item.DesiredBody 'key' }
            $label = if ($desiredName) { $desiredName } else { Get-Value $Item.DesiredBody 'label' }
            Write-NSPConditionalAccessProfileBinding -ProfilePath $Run.ProfilePath -Kind $bindKind -Key $key -ObjectId $Item.ObjectId -Label $label
            return "Bound $($Item.ObjectId) to $bindKind $key in the tenant profile. Re-plan to use it."
        }
        'WriteOnPremScript' {
            $name = Get-Value $Item.DesiredBody 'name'
            $folder = Join-Path (Split-Path -Parent $Run.Path) "$($Run.RunId)-onprem"
            New-Item -ItemType Directory -Path $folder -Force | Out-Null
            $script = Join-Path $folder "New-$($name -replace '[^A-Za-z0-9-]', '').ps1"
            Get-NSPConditionalAccessOnPremGroupScript -Name $name | Set-Content -LiteralPath $script -Encoding UTF8
            Add-ItemValue Result 'OperatorActionRequired'
            Add-ItemValue Notes @(@($Item.Notes) + "Run $script on a domain controller (right-click > Run with PowerShell; it asks for the OU), then sync and re-plan.")
            return "Wrote $script. Operator action required."
        }
        default { throw "Unknown stage '$Stage'." }
    }
}