Public/Invoke-NSPConditionalAccessDeploymentRun.ps1

function Invoke-NSPConditionalAccessDeploymentRun {
    <#
    .SYNOPSIS
        Performs a run journal's approved items against the tenant, one item and one stage at a time.
    .DESCRIPTION
        Without -Execute, lists what would run next and changes nothing.
 
        With -Execute:
          - the Graph session must be this tool's app, signed in to the run's tenant;
          - each item asks for confirmation (ConfirmImpact High) naming the tenant, action, and
            target. Declining pauses the run there; later items are never run out of order;
          - every stage transition is saved to the journal immediately;
          - the first failure stops the run with the item and stage named. Fix the cause and
            run again: it resumes at the failed stage;
          - items already Succeeded, Skipped, NeedsReview, or OperatorActionRequired are not repeated.
            Skipped means a re-check just before writing found nothing to do (for example, a user
            since disabled or covered).
 
        Run status afterwards: Completed, CompletedWithReview (Graph stored something other than
        planned; see the item Notes), OperatorActionRequired (an on-premises script to run),
        Paused, or Failed.
    .EXAMPLE
        Invoke-NSPConditionalAccessDeploymentRun -Run $run.Path
    .EXAMPLE
        Invoke-NSPConditionalAccessDeploymentRun -Run $run.Path -Execute
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)][string]$Run,
        [switch]$Execute,
        [string]$ConfigRoot
    )

    $journal = Get-Content -LiteralPath $Run -Raw | ConvertFrom-Json
    if (-not $journal.RunId) { throw "$Run is not a run journal." }
    $journal.Path = (Resolve-Path -LiteralPath $Run).Path
    $done = @('Succeeded', 'Skipped', 'NeedsReview', 'OperatorActionRequired')

    if (-not $Execute) {
        return $journal.Items | Where-Object { $_.Result -notin $done } | ForEach-Object {
            [pscustomobject]@{
                ItemId = $_.ItemId
                Action = $_.Action
                Target = $_.DisplayName
                NextStage = @($_.Stages | Where-Object Status -ne 'Succeeded')[0].Name
                Result = $_.Result
            }
        }
    }

    if ($script:UnattendedCoverage -and @($journal.Items | Where-Object Kind -ne 'Coverage').Count) {
        throw 'An unattended session runs coverage items only. This run contains other items.'
    }
    Import-NSPM365Graph
    $context = Assert-NSPConditionalAccessSession -ConfigRoot $ConfigRoot
    if ($context.TenantId -ne $journal.TenantId) {
        throw "The Graph session is for tenant $($context.TenantId), but this run is for $($journal.TenantId) ($($journal.TenantDomain))."
    }
    function Save-Journal { Save-NSPConditionalAccessJson -InputObject $journal -Path $journal.Path }
    function Get-Now { (Get-Date).ToUniversalTime().ToString('o') }

    $journal.Status = 'InProgress'
    Save-Journal
    $paused = $false
    $position = 0
    $total = @($journal.Items).Count
    # Plain words for the progress bar; anything not listed shows its stage name.
    $stageWords = @{
        VerifyTarget = 're-reading the tenant to check nothing changed since the plan'
        VerifyState = 're-reading the policy to check nothing changed since the plan'
        CreatePolicy = 'creating the policy (report-only)'; PatchPolicy = 'updating the policy'; PatchState = 'changing the policy state'
        ConfirmApplied = 'reading back what Graph stored'; ConfirmState = 'reading back the state'
        RecordOwnership = 'recording ownership'; BindProfile = 'recording it in the tenant profile'
        CreateGroup = 'creating the group'; CreateNamedLocation = 'creating the named location'; DeleteNamedLocation = 'deleting the named location'
        PatchNamedLocation = 'updating the named location'; VerifyTrustedLocation = 're-reading the location'; ConfirmTrustedLocation = 'reading back the location'
        VerifySecurityDefaults = 'reading Security Defaults'; PatchSecurityDefaults = 'changing Security Defaults'; ConfirmSecurityDefaults = 'reading back Security Defaults'
        AddMember = 'adding the user'; VerifyMember = 'checking the user'; DeletePolicy = 'deleting the policy'; WriteOnPremScript = 'writing the on-premises script'
    }
    foreach ($item in $journal.Items) {
        $position++
        if ($item.Result -in $done) { continue }
        if (-not $PSCmdlet.ShouldProcess("tenant $($journal.TenantDomain) ($($journal.TenantId))", "$($item.Action): $($item.DisplayName)")) {
            $paused = $true
            break
        }
        foreach ($stage in $item.Stages) {
            if ($stage.Status -eq 'Succeeded') { continue }
            $words = if ($stageWords.ContainsKey($stage.Name)) { $stageWords[$stage.Name] } else { $stage.Name }
            Write-NSPConditionalAccessProgress -Activity Run -Status "$($item.ItemId): $words" -Step $position -Of $total
            $stage.Status = 'Running'; $stage.StartedAtUtc = Get-Now; $stage.Message = $null
            Save-Journal
            try {
                $message = Invoke-NSPConditionalAccessRunStage -Run $journal -Item $item -Stage $stage.Name -ConfigRoot $ConfigRoot
                $stage.Status = 'Succeeded'; $stage.Message = $message; $stage.FinishedAtUtc = Get-Now
                Save-Journal
            } catch {
                $stage.Status = 'Failed'; $stage.Message = "$_"; $stage.FinishedAtUtc = Get-Now
                $item.Result = 'Failed'
                $journal.Status = 'Failed'
                Save-Journal
                Write-NSPConditionalAccessProgress -Activity Run -Completed
                throw "Run stopped: $($item.ItemId) failed at $($stage.Name): $_ Fix the cause and run again to resume from this stage."
            }
        }
        if ($item.Result -notin 'Skipped', 'NeedsReview', 'OperatorActionRequired') { $item.Result = 'Succeeded' }
        Save-Journal
    }

    Write-NSPConditionalAccessProgress -Activity Run -Completed
    $journal.Status = if ($paused) { 'Paused' }
    elseif (@($journal.Items | Where-Object Result -eq 'NeedsReview').Count) { 'CompletedWithReview' }
    elseif (@($journal.Items | Where-Object Result -eq 'OperatorActionRequired').Count) { 'OperatorActionRequired' }
    else { 'Completed' }
    Save-Journal
    Get-NSPConditionalAccessRunSummary -Run $journal.Path
}