Public/Invoke-NSPConditionalAccessCoverageJob.ps1

function Invoke-NSPConditionalAccessCoverageJob {
    <#
    .SYNOPSIS
        Unattended coverage: evaluates, plans, and adds uncovered users to MFACatchAll within the rails.
    .DESCRIPTION
        The scheduled task's entry point (Install-NSPConditionalAccessCoverageTask). Adding to
        MFACatchAll restricts access, so the job acts only when every rail holds:
          - the tenant profile opts in (Coverage.Scheduled.Enabled). Setting it to false stops
            the next run with no other change (Set-NSPConditionalAccessCoverageSetting -ScheduledEnabled $false);
          - the Cloud side signs in as the recorded coverage app with its certificate, app-only;
            that session can run coverage items and nothing else;
          - the plan is not held: every side was evaluated, and the adds are within the
            scheduled cap (Coverage.Scheduled.MaxAdds, default 10, never above MaxAdds) and MaxAddsPercent.
            -AllowLargeChange does not exist here;
          - the executor re-checks each user just before adding them.
        A held plan writes nothing and is reported. Each run writes a report under
        .nsp-conditionalaccess\coverage\<TenantId>\job-*.json. Held and Failed runs return a
        report the task wrapper turns into a non-zero exit code.
    .EXAMPLE
        Invoke-NSPConditionalAccessCoverageJob -TenantProfile 'C:\NSP\Config\Local\TenantProfiles\<tenant>.json'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$TenantProfile,
        [string]$ConfigRoot
    )

    function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key }
    $content = Get-Content -LiteralPath $TenantProfile -Raw | ConvertFrom-Json
    $tenantId = $content.TenantId
    $scheduled = (Get-NSPConditionalAccessCoverageSetting -TenantProfile $content).Scheduled
    $report = [ordered]@{
        SchemaVersion = 1
        TenantId = $tenantId
        StartedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        FinishedAtUtc = $null
        Status = $null
        Messages = [System.Collections.Generic.List[string]]::new()
        Warnings = [System.Collections.Generic.List[string]]::new()
        CoveragePath = $null
        PlanPath = $null
        RunPath = $null
        Results = @()
        Mailed = $false
        Path = $null
    }

    $jobState = @{ Connected = $false }
    function Invoke-JobStep {
        if (-not $scheduled -or -not (Get-Value $scheduled 'Enabled')) {
            $report.Status = 'Disabled'
            $report.Messages.Add('Scheduled coverage is not enabled in the tenant profile (Coverage.Scheduled.Enabled). Nothing was evaluated.')
            return
        }
        $sides = @(Get-Value $scheduled 'Sides' | Where-Object { $_ })
        if (-not $sides.Count) { $sides = if ($content.Hybrid) { @('Cloud', 'ActiveDirectory') } else { @('Cloud') } }
        $maxAdds = Get-Value $scheduled 'MaxAdds'
        if (-not $maxAdds) { $maxAdds = 10 }

        if ($sides -contains 'Cloud') {
            $recordPath = Join-Path (Get-NSPConditionalAccessLocalPath -Kind CoverageApps -ConfigRoot $ConfigRoot) "$tenantId.json"
            if (-not (Test-Path -LiteralPath $recordPath)) { throw "No coverage app is registered for tenant $tenantId on this host. Run Register-NSPConditionalAccessCoverageApp -Execute." }
            $record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json
            if ($record.CertificateNotAfter -and ([datetime]$record.CertificateNotAfter).ToUniversalTime() -lt (Get-Date).ToUniversalTime().AddDays(30)) {
                $report.Warnings.Add("The coverage app certificate expires $($record.CertificateNotAfter). Renew it with Register-NSPConditionalAccessCoverageApp -Execute on this host.")
            }
            Import-NSPM365Graph
            Connect-NSPGraphAppOnly -ClientId $record.ClientId -TenantId $tenantId -CertificateThumbprint $record.CertificateThumbprint | Out-Null
            $jobState.Connected = $true
            $script:UnattendedCoverage = @{ ClientId = $record.ClientId; TenantId = $tenantId }
        }

        if ($sides -contains 'Cloud') {
            try {
                foreach ($overlap in @(Test-NSPConditionalAccessTravelOverlap -TenantProfile $content -ConfigRoot $ConfigRoot)) {
                    $report.Warnings.Add("Travel overlap: $($overlap.UserPrincipalName). $($overlap.Message)")
                }
            } catch { $report.Warnings.Add("The travel overlap check could not run: $_") }
        }
        # Unattended adds go only to MFACatchAll; EnableMFA bulk adds are interactive.
        $coverage = Get-NSPConditionalAccessCoverage -TenantProfile $TenantProfile -Side $sides -TargetSlot MFACatchAll -Save -ConfigRoot $ConfigRoot
        $report.CoveragePath = $coverage.Path
        $plan = New-NSPConditionalAccessCoveragePlan -Coverage $coverage -MaxAdds $maxAdds -Save -ConfigRoot $ConfigRoot
        $report.PlanPath = $plan.Path
        if (@($plan.Held).Count) {
            $report.Status = 'Held'
            foreach ($reason in $plan.Held) { $report.Messages.Add($reason) }
            return
        }
        if (-not @($plan.Items).Count) {
            $report.Status = 'NothingToDo'
            $report.Messages.Add('Every evaluated user is covered.')
            return
        }
        $patterns = @('cloud:*', 'onprem:*' | Where-Object { $prefix = $_; @($plan.Items | Where-Object ItemId -like $prefix).Count })
        Set-NSPConditionalAccessPlanDecision -Plan $plan.Path -ItemId $patterns -Decision Approve -Confirm:$false `
            -Note 'Approved by the scheduled coverage job under Coverage.Scheduled in the tenant profile.' | Out-Null
        $run = New-NSPConditionalAccessDeploymentRun -Plan $plan.Path -TenantProfile $TenantProfile -ConfigRoot $ConfigRoot
        $report.RunPath = $run.Path
        $summary = @(Invoke-NSPConditionalAccessDeploymentRun -Run $run.Path -Execute -Confirm:$false -ConfigRoot $ConfigRoot)
        $report.Results = @($summary | ForEach-Object { [pscustomobject]@{ ItemId = $_.ItemId; Result = $_.Result; Message = $_.Message } })
        $report.Status = @($summary)[0].RunStatus
        if ($report.Status -eq 'OperatorActionRequired') {
            $report.Messages.Add('On-premises adds were written to a script because this host has no ActiveDirectory module. Run it on a domain controller.')
        }
    }

    try {
        Invoke-JobStep
    } catch {
        $report.Status = 'Failed'
        $report.Messages.Add("$_")
    } finally {
        $script:UnattendedCoverage = $null
        $report.FinishedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        $report.Path = Join-Path (Join-Path (Get-NSPConditionalAccessLocalPath -Kind Coverage -ConfigRoot $ConfigRoot) $tenantId) ('job-' + (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json')
        $report.Messages = @($report.Messages)
        $report.Warnings = @($report.Warnings)
        Save-NSPConditionalAccessJson -InputObject ([pscustomobject]$report) -Path $report.Path
    }

    # E-mail (optional, Coverage.Scheduled.Mail). Problems: Held, Failed, operator action, review, or
    # warnings. Changes: those, or any user added. Always: every run. A failed send is a warning.
    $mail = if ($scheduled) { Get-Value $scheduled 'Mail' } else { $null }
    $from = Get-Value $mail 'From'
    if ($from -and $report.Status -ne 'Disabled') {
        $when = Get-Value $mail 'When'
        if (-not $when) { $when = 'Problems' }
        $problem = $report.Status -in 'Held', 'Failed', 'OperatorActionRequired', 'CompletedWithReview' -or @($report.Warnings).Count
        $added = @($report.Results | Where-Object Result -eq 'Succeeded').Count
        if ($when -eq 'Always' -or $problem -or ($when -eq 'Changes' -and $added)) {
            try {
                if (-not $jobState.Connected) {
                    $recordPath = Join-Path (Get-NSPConditionalAccessLocalPath -Kind CoverageApps -ConfigRoot $ConfigRoot) "$tenantId.json"
                    if (-not (Test-Path -LiteralPath $recordPath)) { throw 'no coverage app is registered on this host to send it.' }
                    $record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json
                    Import-NSPM365Graph
                    Connect-NSPGraphAppOnly -ClientId $record.ClientId -TenantId $tenantId -CertificateThumbprint $record.CertificateThumbprint | Out-Null
                }
                $label = if ($content.TenantDomain) { $content.TenantDomain } else { $tenantId }
                Send-NSPConditionalAccessCoverageMail -Report ([pscustomobject]$report) -From $from -To @(Get-Value $mail 'To') -TenantLabel $label -Confirm:$false
                $report.Mailed = $true
            } catch {
                $report.Warnings = @($report.Warnings) + "The report could not be e-mailed: $_"
            }
            Save-NSPConditionalAccessJson -InputObject ([pscustomobject]$report) -Path $report.Path
        }
    }
    [pscustomobject]$report
}