Public/New-NSPConditionalAccessCoveragePlan.ps1
|
function New-NSPConditionalAccessCoveragePlan { <# .SYNOPSIS Offline: turns a coverage result into one reviewable item per user to add to its target group (MFACatchAll, or EnableMFA at onboarding). .DESCRIPTION Adding a user to MFACatchAll or EnableMFA restricts their access, so each add is its own item. The target is the coverage result's TargetSlot: - AddCloudMember: a cloud-only user, added to the target slot's cloud-only group. - AddOnPremMember: an AD user (from the ActiveDirectory side), or a synced user the Cloud side found uncovered when the ActiveDirectory side was not run. Added to the target slot's on-premises group: directly when the run host has the ActiveDirectory module, otherwise through a generated script. Rails. The whole plan is held (every item CoverageHeld, nothing decidable) when: - a side could not be evaluated (a group or the user list could not be read), or - the number of adds exceeds the profile's MaxAdds, or MaxAddsPercent of evaluated users. A sudden large number of "uncovered" users usually means a failed group read or a missing group, not a real change. -AllowLargeChange lifts only the size rail, for a first run in a tenant after the numbers were checked. It is never used by the scheduled job. .EXAMPLE $coverage = Get-NSPConditionalAccessCoverage -TenantProfile $profilePath -Save $plan = New-NSPConditionalAccessCoveragePlan -Coverage $coverage -Save Get-NSPConditionalAccessPlanSummary -Plan $plan.Path | Format-Table -Wrap Set-NSPConditionalAccessPlanDecision -Plan $plan.Path -ItemId 'cloud:*', 'onprem:*' -Decision Approve #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)]$Coverage, [switch]$AllowLargeChange, # Lowers the profile's MaxAdds for this plan (the scheduled job's stricter cap); never raises it. [ValidateRange(1, 100000)][int]$MaxAdds, [switch]$Save, [string]$ConfigRoot ) if ($Coverage -is [string]) { $Coverage = Get-Content -LiteralPath $Coverage -Raw | ConvertFrom-Json } $settings = $Coverage.Settings $targetSlot = if ($Coverage.PSObject.Properties['TargetSlot'] -and $Coverage.TargetSlot) { [string]$Coverage.TargetSlot } else { 'MFACatchAll' } # What being in the target group means for the user, for each item's reason. $effect = switch ($targetSlot) { 'EnableMFA' { 'They must use MFA when signing in away from the office (NSP-BASE-MFA-01).' } 'MFAExempt' { 'No MFA policy applies to them, and coverage stops reporting them. Only for accounts that cannot do MFA (kiosks, scanners, shared devices).' } default { 'MFA on mobile devices and for Intune (the INTUNE policies).' } } $items = [System.Collections.Generic.List[object]]::new() $held = [System.Collections.Generic.List[string]]::new() function Add-PlanItem { param([string]$ItemId, [string]$Action, [string]$DisplayName, $Body, [string]$Reason) $items.Add([pscustomobject][ordered]@{ ItemId = $ItemId; Kind = 'Coverage'; Action = $Action; Decision = 'Pending' DefinitionId = $null; DisplayName = $DisplayName; Confidence = $null; Score = $null LiveObjectId = $null; LiveDisplayName = $null; LiveState = $null; DependsOn = @() Reason = $Reason; Differences = @(); Warnings = @(); DesiredBody = $Body; DesiredHash = $null; LiveHash = $null }) } $cloud = $Coverage.Cloud $ad = $Coverage.ActiveDirectory $evaluated = 0 if ($cloud) { if (-not $cloud.Evaluated) { $held.Add("The Cloud side could not be evaluated: $(@($cloud.Errors) -join ' ')") } $evaluated = [math]::Max($evaluated, [int]$cloud.UsersEvaluated) $cloudUsers = @($cloud.Uncovered) if ($cloudUsers.Count -and -not $cloud.TargetGroup) { if ($targetSlot -eq 'MFACatchAll') { $held.Add("Cloud-only users are uncovered, but: $($cloud.TargetProblem)") } else { # A bulk add to an on-premises EnableMFA cannot take cloud-only users; list them # instead of holding everyone else. foreach ($user in $cloudUsers) { $items.Add([pscustomobject][ordered]@{ ItemId = "cloud:$($user.UserPrincipalName)"; Kind = 'Coverage'; Action = 'CoverageSkipped'; Decision = 'NotActionable' DefinitionId = $null; DisplayName = $user.UserPrincipalName; Confidence = $null; Score = $null LiveObjectId = $null; LiveDisplayName = $null; LiveState = $null; DependsOn = @() Reason = "Cloud-only user '$($user.DisplayName)' was not added: $($cloud.TargetProblem) Add them by hand, or to MFACatchAll." Differences = @(); Warnings = @(); DesiredBody = [ordered]@{ userPrincipalName = $user.UserPrincipalName }; DesiredHash = $null; LiveHash = $null }) } $cloudUsers = @() } } foreach ($user in $cloudUsers) { Add-PlanItem "cloud:$($user.UserPrincipalName)" 'AddCloudMember' "$($user.UserPrincipalName) -> $($cloud.TargetGroup.DisplayName)" ([ordered]@{ groupId = $cloud.TargetGroup.Id; groupName = $cloud.TargetGroup.DisplayName; userId = $user.Id; userPrincipalName = $user.UserPrincipalName coverageGroupIds = @($cloud.CoverageGroupIds) }) "Cloud-only user '$($user.DisplayName)' is in no coverage group. Adding to $($cloud.TargetGroup.DisplayName): $effect" } } if ($ad) { if (-not $ad.Evaluated) { $held.Add("The ActiveDirectory side could not be evaluated: $(@($ad.Errors) -join ' ')") } $evaluated = [math]::Max($evaluated, [int]$ad.UsersEvaluated) $adUsers = @($ad.Uncovered) if ($adUsers.Count -and -not $ad.TargetGroup) { if ($targetSlot -eq 'MFACatchAll') { $held.Add("AD users are uncovered, but the on-premises $targetSlot group could not be found.") } else { foreach ($user in $adUsers) { $items.Add([pscustomobject][ordered]@{ ItemId = "onprem:$($user.SamAccountName)"; Kind = 'Coverage'; Action = 'CoverageSkipped'; Decision = 'NotActionable' DefinitionId = $null; DisplayName = $user.SamAccountName; Confidence = $null; Score = $null LiveObjectId = $null; LiveDisplayName = $null; LiveState = $null; DependsOn = @() Reason = "AD user '$($user.DisplayName)' was not added: there is no on-premises $targetSlot group. Create it in AD (and bind it in the tenant profile), or set the exempt group with Set-NSPConditionalAccessCoverageSetting -ExemptGroup." Differences = @(); Warnings = @(); DesiredBody = [ordered]@{ userPrincipalName = $user.UserPrincipalName }; DesiredHash = $null; LiveHash = $null }) } $adUsers = @() } } foreach ($user in $adUsers) { Add-PlanItem "onprem:$($user.SamAccountName)" 'AddOnPremMember' "$($user.SamAccountName) -> $($ad.TargetGroup.Name) (on-premises)" ([ordered]@{ groupIdentity = $ad.TargetGroup.Identity; groupName = $ad.TargetGroup.Name; samAccountName = $user.SamAccountName; objectGuid = $user.ObjectGuid; userPrincipalName = $user.UserPrincipalName }) "AD user '$($user.DisplayName)' is in no coverage group and not exempt. Adding to $($ad.TargetGroup.Name) (once synced): $effect" } } elseif ($cloud -and @($cloud.SyncedUncovered).Count) { foreach ($user in @($cloud.SyncedUncovered)) { Add-PlanItem "onprem:$($user.SamAccountName)" 'AddOnPremMember' "$($user.SamAccountName) -> $($cloud.OnPremTargetGroup.Name) (on-premises)" ([ordered]@{ groupIdentity = $cloud.OnPremTargetGroup.Identity; groupName = $cloud.OnPremTargetGroup.Name; samAccountName = $user.SamAccountName; objectGuid = $null; userPrincipalName = $user.UserPrincipalName }) "Synced user '$($user.DisplayName)' is in no coverage group (seen from the cloud; the ActiveDirectory side was not run). Their membership comes from AD." } } $count = @($items | Where-Object { $_.Action -ne 'CoverageSkipped' }).Count $limit = [int]$settings.MaxAdds if ($PSBoundParameters.ContainsKey('MaxAdds') -and $MaxAdds -lt $limit) { $limit = $MaxAdds } $percent = if ($evaluated) { [math]::Round(100 * $count / $evaluated, 1) } else { 0 } if (-not $AllowLargeChange -and ($count -gt $limit -or $percent -gt $settings.MaxAddsPercent)) { $held.Add("$count add(s) is $percent% of $evaluated evaluated user(s), over the rail ($limit adds or $($settings.MaxAddsPercent)%). This usually means a group read failed or a group is missing. Check the coverage result; for a first run, re-plan with -AllowLargeChange.") } if ($held.Count) { foreach ($item in $items) { $item.Action = 'CoverageHeld'; $item.Decision = 'NotActionable'; $item.Warnings = @($held) } } $plan = [pscustomobject][ordered]@{ SchemaVersion = 1 PlanKind = 'Coverage' PlanId = [guid]::NewGuid().ToString() CreatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') TenantId = $Coverage.TenantId TenantDomain = $Coverage.TenantDomain CoverageCapturedAtUtc = $Coverage.CapturedAtUtc TargetSlot = $targetSlot Held = @($held) Items = $items.ToArray() Path = $null } if ($Save -and $PSCmdlet.ShouldProcess($plan.TenantId, 'Save coverage plan')) { $plan.Path = Join-Path (Join-Path (Get-NSPConditionalAccessLocalPath -Kind Plans -ConfigRoot $ConfigRoot) $plan.TenantId) ('coverage-' + (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json') Save-NSPConditionalAccessJson -InputObject $plan -Path $plan.Path } $plan } |