Public/New-NSPConditionalAccessPromotionPlan.ps1

function New-NSPConditionalAccessPromotionPlan {
    <#
    .SYNOPSIS
        Offline: plans moving owned policies from report-only to On (or back), each gated and approved separately.
    .DESCRIPTION
        Only policies this tool owns are candidates, and only when the live policy already matches
        its definition (the deployment plan would say NoChange). A policy with pending changes
        must have them applied first, so what was soaking in report-only is exactly what gets
        enforced.
 
        Gates for -TargetState Enabled (the default):
          - Blocking: the preflight is run as if the policy were already On. Any error for it
            (for example LockoutRisk) or a disabled breakglass account blocks promotion.
          - Warnings, shown on the item for the approver: other preflight warnings for it
            (breakglass or service accounts not excluded); less than -MinimumSoakDays in
            report-only since it was created, last modified, or last applied; and the report-only
            sign-in results from -Impact (Get-NSPConditionalAccessReportOnlyImpact), including
            how many sign-ins it would have blocked. Missing or unavailable impact data is a warning.
 
        -TargetState ReportOnly plans the reverse (a rollback from On) and has no gates.
        -TargetState Disabled turns a policy off from either state, also without gates: for a
        definition the tenant's options no longer select (Deselected in the deployment plan),
        once its replacement is On.
 
        A policy that requires the TAP-only authentication strength is also blocked while the
        Temporary Access Pass method is off, and warned about its other TAP findings.
 
        While Security Defaults is on, the plan starts with a DisableSecurityDefaults item and
        every promotion to On depends on it: Graph refuses to turn a policy On otherwise, and
        turning it off only now (not when the report-only policies were created) never leaves
        the tenant without MFA enforcement.
 
        The result has the deployment plan's shape, so Set-NSPConditionalAccessPlanDecision,
        Get-NSPConditionalAccessPlanSummary, and the run commands work on it unchanged.
    .EXAMPLE
        $impact = Get-NSPConditionalAccessReportOnlyImpact -Days 14 -Save
        $plan = New-NSPConditionalAccessPromotionPlan -Inventory $inventory -TenantProfile $profilePath -Impact $impact -Save
        Get-NSPConditionalAccessPlanSummary -Plan $plan.Path | Format-Table -Wrap
        Set-NSPConditionalAccessPlanDecision -Plan $plan.Path -ItemId 'NSP-ADMIN-MFA-01' -Decision Approve
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]$Inventory,
        [Parameter(Mandatory)]$TenantProfile,
        [string[]]$DefinitionId,
        [ValidateSet('Enabled', 'ReportOnly', 'Disabled')][string]$TargetState = 'Enabled',
        $Impact,
        [ValidateRange(0, 365)][int]$MinimumSoakDays = 7,
        [switch]$Save,
        [string]$ConfigRoot
    )

    if ($Inventory -is [string]) { $Inventory = Get-Content -LiteralPath $Inventory -Raw | ConvertFrom-Json }
    if ($TenantProfile -is [string]) { $TenantProfile = Get-Content -LiteralPath $TenantProfile -Raw | ConvertFrom-Json }
    if ($Impact -is [string]) { $Impact = Get-Content -LiteralPath $Impact -Raw | ConvertFrom-Json }
    function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key }

    $tenantId = Get-Value $Inventory 'TenantId'
    if ($Impact -and (Get-Value $Impact 'TenantId') -ne $tenantId) {
        throw "The impact data is from tenant $(Get-Value $Impact 'TenantId') but the inventory is from $tenantId."
    }
    $deployment = New-NSPConditionalAccessDeploymentPlan -Inventory $Inventory -TenantProfile $TenantProfile -ConfigRoot $ConfigRoot -WhatIf:$false
    $ownership = Get-NSPConditionalAccessOwnership -TenantId $tenantId -ConfigRoot $ConfigRoot
    $fromStates = switch ($TargetState) { 'Enabled' { @('enabledForReportingButNotEnforced') } 'ReportOnly' { @('enabled') } 'Disabled' { @('enabled', 'enabledForReportingButNotEnforced') } }
    $toState = switch ($TargetState) { 'Enabled' { 'enabled' } 'ReportOnly' { 'enabledForReportingButNotEnforced' } 'Disabled' { 'disabled' } }
    $fromState = $fromStates -join ' or '
    $now = (Get-Date).ToUniversalTime()

    $requested = if ($DefinitionId) { @($DefinitionId) } else { @($ownership.Keys | Sort-Object) }
    $items = foreach ($id in $requested) {
        $record = if ($ownership.ContainsKey($id)) { $ownership[$id] } else { $null }
        $deployItem = @($deployment.Items | Where-Object ItemId -eq $id)[0]
        $live = if ($record) { @(Get-Value $Inventory 'Policies' | Where-Object { (Get-Value $_ 'id') -eq $record.ObjectId })[0] } else { $null }
        $blockers = [System.Collections.Generic.List[string]]::new()
        $warnings = [System.Collections.Generic.List[string]]::new()
        $impactSummary = $null
        $liveState = if ($live) { Get-Value $live 'state' } else { $null }

        if (-not $record) { $blockers.Add('Not owned by this tool. Adopt or create it with a deployment plan first.') }
        elseif (-not $live) { $blockers.Add("The owned policy $($record.ObjectId) is not in the inventory. Re-inventory and re-plan.") }
        elseif ($liveState -eq $toState) { }
        elseif ($liveState -notin $fromStates) { $blockers.Add("State is $liveState. Only $fromState policies can move to $toState.") }
        elseif ($TargetState -ne 'Disabled' -and (-not $deployItem -or $deployItem.Action -ne 'NoChange')) {
            $blockers.Add("The live policy does not match its definition (deployment plan: $(if ($deployItem) { $deployItem.Action } else { 'not planned' })). Apply that first, so what soaked is what gets enforced.")
        }

        if ($live -and -not $blockers.Count -and $liveState -ne $toState -and $TargetState -eq 'Enabled') {
            $name = Get-Value $live 'displayName'
            # Preflight as if this one policy were already On.
            $simulated = $Inventory | ConvertTo-Json -Depth 32 | ConvertFrom-Json
            foreach ($policy in @($simulated.Policies)) { if ($policy.id -eq $record.ObjectId) { $policy.state = 'enabled' } }
            # A policy requiring the TAP-only strength also answers for the Temporary Access Pass method.
            $definition = @(Get-NSPConditionalAccessDefinition -Id $id)[0]
            $usesTap = $definition -and @(Get-NSPConditionalAccessDefinitionReference -Definition $definition | Where-Object { $_.Kind -eq 'Strength' -and $_.Name -eq 'TapOnly' }).Count
            foreach ($finding in Test-NSPConditionalAccessTenant -Inventory $simulated -TenantProfile $TenantProfile) {
                $concerns = $finding.Subject -eq $name -or ($usesTap -and $finding.Subject -eq 'Temporary Access Pass')
                if ($concerns -and $finding.Severity -eq 'Error') { $blockers.Add("$($finding.Rule): $($finding.Message)") }
                elseif ($concerns -and $finding.Severity -eq 'Warning') { $warnings.Add("$($finding.Rule): $($finding.Message)") }
                elseif ($finding.Rule -eq 'BreakGlassDisabled') { $blockers.Add("$($finding.Rule) ($($finding.Subject)): $($finding.Message)") }
            }

            $stamps = @((Get-Value $live 'createdDateTime'), (Get-Value $live 'modifiedDateTime'), $record.AppliedAtUtc) | Where-Object { $_ } |
                ForEach-Object { ([datetime]$_).ToUniversalTime() }
            if ($stamps) {
                $since = ($stamps | Sort-Object -Descending)[0]
                $soaked = [math]::Floor(($now - $since).TotalDays)
                if ($soaked -lt $MinimumSoakDays) { $warnings.Add("In report-only for $soaked day(s) since its last change; the minimum is $MinimumSoakDays.") }
            }

            if (-not $Impact) {
                $warnings.Add('No report-only sign-in results were supplied. Run Get-NSPConditionalAccessReportOnlyImpact and pass it with -Impact.')
            } elseif (-not (Get-Value $Impact 'Available')) {
                $warnings.Add("Report-only sign-in results are unavailable: $(Get-Value $Impact 'Reason')")
            } else {
                $days = Get-Value $Impact 'Days'
                $entry = @(Get-Value $Impact 'Policies' | Where-Object { (Get-Value $_ 'PolicyId') -eq $record.ObjectId })[0]
                if (-not $entry) {
                    $impactSummary = "No sign-in in the last $days day(s) was evaluated by this policy."
                    $warnings.Add($impactSummary)
                } else {
                    $impactSummary = "Last $days day(s): would block $(Get-Value $entry 'WouldBlock'), would prompt $(Get-Value $entry 'WouldInterrupt'), already satisfied $(Get-Value $entry 'Satisfied'); $(Get-Value $entry 'UsersAffected') user(s) affected."
                    if ((Get-Value $entry 'WouldBlock') -gt 0) { $warnings.Add("Would have blocked $(Get-Value $entry 'WouldBlock') sign-in(s). Review them in the sign-in logs before enforcing.") }
                }
                if (Get-Value $Impact 'Truncated') { $warnings.Add("The impact read stopped at $(Get-Value $Impact 'SignInsRead') sign-ins; counts are partial.") }
            }
        }

        $action = if ($blockers.Count) { 'PromotionBlocked' } elseif ($live -and $liveState -eq $toState) { 'NoChange' } else { 'PromoteState' }
        $reason = switch ($action) {
            'PromotionBlocked' { $blockers -join ' ' }
            'NoChange' { "Already $toState." }
            'PromoteState' { "Moves '$(Get-Value $live 'displayName')' from $fromState to $toState. Only the state changes." }
        }
        $hash = if ($live) { Get-NSPConditionalAccessPolicyHash -Canonical (ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $live) } else { $null }
        [pscustomobject][ordered]@{
            ItemId = $id
            Kind = 'Promotion'
            Action = $action
            Decision = if ($action -eq 'PromoteState') { 'Pending' } else { 'NotActionable' }
            DefinitionId = $id
            DisplayName = if ($live) { Get-Value $live 'displayName' } else { $id }
            Confidence = if ($record) { 'Owned' } else { $null }
            Score = $null
            LiveObjectId = if ($record) { $record.ObjectId } else { $null }
            LiveDisplayName = if ($live) { Get-Value $live 'displayName' } else { $null }
            LiveState = $liveState
            DependsOn = @()
            Reason = $reason
            Differences = @()
            Warnings = @($warnings)
            Impact = $impactSummary
            DesiredBody = [ordered]@{ state = $toState }
            DesiredHash = $hash
            LiveHash = $hash
        }
    }

    # Security Defaults on: Graph refuses to turn any policy On. Turning it off is planned here,
    # first in the run, so it happens moments before the first promotion and never during the
    # report-only weeks (when it is still the tenant's only MFA). Every promotion depends on it.
    $items = @($items)
    $defaults = Get-Value $Inventory 'SecurityDefaults'
    $promotions = @($items | Where-Object Action -eq 'PromoteState')
    if ($TargetState -eq 'Enabled' -and $promotions.Count) {
        if ($null -eq $defaults -or -not (Get-Value $defaults 'Available')) {
            foreach ($item in $promotions) { $item.Warnings = @(@($item.Warnings) + 'Whether Security Defaults is on is unknown (take a new inventory). If it is on, Graph refuses the promotion.') }
        } elseif (Get-Value $defaults 'IsEnabled') {
            foreach ($item in $promotions) { $item.DependsOn = @(@($item.DependsOn | Where-Object { $_ }) + 'securitydefaults') }
            $items = @([pscustomobject][ordered]@{
                    ItemId = 'securitydefaults'
                    Kind = 'TenantSetting'
                    Action = 'DisableSecurityDefaults'
                    Decision = 'Pending'
                    DefinitionId = $null
                    DisplayName = 'Security Defaults'
                    Confidence = $null
                    Score = $null
                    LiveObjectId = $null
                    LiveDisplayName = 'Security Defaults'
                    LiveState = 'enabled'
                    DependsOn = @()
                    Reason = 'Turns Security Defaults off, immediately before the promotions in this run. Graph refuses to turn any Conditional Access policy On while it is on. Every promotion here depends on it.'
                    Differences = @()
                    Warnings = @(
                        'Security Defaults also requires every user to register for MFA and blocks legacy authentication. After this, only the Conditional Access policies that are On do that. Approve the baseline MFA policies in the same run.'
                        'Undo turns it back on, but only once every Conditional Access policy is back in report-only or off.'
                    )
                    Impact = $null
                    DesiredBody = [ordered]@{ isEnabled = $false }
                    DesiredHash = $null
                    LiveHash = $null
                }) + $items
        }
    }

    $plan = [pscustomobject][ordered]@{
        SchemaVersion = 1
        PlanKind = 'Promotion'
        PlanId = [guid]::NewGuid().ToString()
        CreatedAtUtc = $now.ToString('o')
        TenantId = $tenantId
        TenantDomain = Get-Value $Inventory 'TenantDomain'
        InventoryCapturedAtUtc = Get-Value $Inventory 'CapturedAtUtc'
        TargetState = $toState
        ImpactCapturedAtUtc = if ($Impact) { Get-Value $Impact 'CapturedAtUtc' } else { $null }
        Items = @($items)
        Path = $null
    }
    if ($Save -and $PSCmdlet.ShouldProcess($tenantId, 'Save promotion plan')) {
        $plan.Path = Join-Path (Join-Path (Get-NSPConditionalAccessLocalPath -Kind Plans -ConfigRoot $ConfigRoot) $tenantId) ('promote-' + $now.ToString('yyyyMMdd-HHmmss') + '.json')
        Save-NSPConditionalAccessJson -InputObject $plan -Path $plan.Path
    }
    $plan
}