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 } |