Public/New-NSPConditionalAccessDeploymentPlan.ps1
|
function New-NSPConditionalAccessDeploymentPlan { <# .SYNOPSIS Offline: compares definitions with a tenant's live policies and proposes one reviewed action per item. .DESCRIPTION Inputs are an inventory and its tenant profile (objects or saved JSON paths). No Graph calls, no writes to the tenant. -Save writes the plan under .nsp-conditionalaccess\plans (Git-ignored). Prerequisites come first: - CreateGroup / CreateOnPremGroup for a missing group slot that a plannable definition needs. In a hybrid tenant, the on-premises group (synced later) and a cloud-only '<name>_azure' companion that policies can use right away; in a cloud tenant, one cloud group. - With -GroupSlot (the bulk add asks for MFAExempt), the same for a slot no definition needs; a hybrid slot holding only its synced group gets a cloud-only companion. - CreateNamedLocation for a missing country location. - CreateAuthenticationStrength, or BindAuthenticationStrength when a custom strength with the same allowed combinations exists, for an unbound {Strength:Name}. Definitions the tenant's options do not select (Option, see Set-NSPConditionalAccessTenantOption) are left out. One this tool owns shows as Deselected, to turn off with a Disabled promotion once its replacement is On. Then one item per definition: - BlockedPrerequisite: needs something the tool cannot create (an application). - PendingPrerequisite: waits on prerequisite items; re-plan after they run. A live policy with one of its LegacyNames is reserved for it and noted. - For resolvable definitions, the matching order is: the ownership record (object ID), exact canonical content, a LegacyNames display-name match (compared by name key, so "NSP - " and "Pre - " prefixes and parenthetical notes do not matter), then similarity (score of at least 0.75 and at least 0.1 ahead of the runner-up). Each live policy is claimed at most once. Template-based (Microsoft-managed) policies are never candidates. - Owned: NoChange, UpdateInPlace (definition changed, live unchanged since applied), or Drifted (live changed outside this tool), or Create if the owned object is gone. - Unowned match: AdoptOrReview, with confidence Exact / LegacyName / Similar and the field differences. Approving it updates the policy to the definition and renames it to the naming standard. - Ambiguous match: Conflict. Otherwise: Create. State is never changed by a plan. Create uses the definition's DefaultState (report-only for every shipped definition); updates and adoptions keep the live state. Enforcement is a separate PromoteState plan. One exception: while the owned NSP-BASE-GEO-01 is On, travel policies are created On, and NSP-BASE-GEO-01 excludes their groups after them in the same plan (a travel policy blocks its members less than the baseline does). Every tenant-changing item starts with Decision 'Pending'. Nothing runs until it is approved with Set-NSPConditionalAccessPlanDecision. .EXAMPLE $plan = New-NSPConditionalAccessDeploymentPlan -Inventory $inventory -TenantProfile $tenantProfile.Path -Save $plan.Items | Format-Table ItemId, Action, Confidence, Decision, Reason -Wrap #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)]$Inventory, [Parameter(Mandatory)]$TenantProfile, [object[]]$Definition, # Group slots to create or bind even though no definition references them (MFAExempt). [string[]]$GroupSlot, [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 } # Definitions the tenant's options do not select (Option = 'Registration=...') are left out; one # this tool already owns is listed as Deselected, to be turned off once its replacement is On. $deselected = @() if (-not $Definition) { $shipped = @(Get-NSPConditionalAccessDefinition) $Definition = @($shipped | Where-Object { Test-NSPConditionalAccessDefinitionSelected -Definition $_ -TenantProfile $TenantProfile }) + @(Get-NSPConditionalAccessTravelDefinition -TenantProfile $TenantProfile) $deselected = @($shipped | Where-Object { -not (Test-NSPConditionalAccessDefinitionSelected -Definition $_ -TenantProfile $TenantProfile) }) } function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key } function Get-List { param($Object, [string]$Key) @(Get-NSPConditionalAccessMapValue $Object $Key | Where-Object { $null -ne $_ }) } $tenantId = Get-Value $Inventory 'TenantId' if ($tenantId -ne (Get-Value $TenantProfile 'TenantId')) { throw "The tenant profile is for $(Get-Value $TenantProfile 'TenantId') but the inventory is from $tenantId." } if (-not @(Get-List (Get-Value $TenantProfile 'BreakGlass') 'Ids').Count) { throw 'The tenant profile has no breakglass account. Rebuild it with New-NSPConditionalAccessTenantProfile.' } $hybrid = [bool](Get-Value $TenantProfile 'Hybrid') $directory = Get-Value $Inventory 'Directory' $applications = Get-Value $directory 'Applications' $groups = Get-Value $TenantProfile 'Groups' $countries = Get-Value (Get-Value $TenantProfile 'NamedLocations') 'Countries' $slotDefaults = @{} foreach ($slot in Get-NSPConditionalAccessGroupSlot) { $slotDefaults[$slot.Slot] = $slot.DefaultName } # --- Live policies ----------------------------------------------------------------------- $livePolicies = @(Get-List $Inventory 'Policies') $liveById = @{} $candidates = [System.Collections.Generic.List[object]]::new() foreach ($policy in $livePolicies) { $canonical = ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $policy $entry = [pscustomobject]@{ Id = Get-Value $policy 'id' DisplayName = Get-Value $policy 'displayName' State = Get-Value $policy 'state' TemplateId = Get-Value $policy 'templateId' NameKey = ConvertTo-NSPConditionalAccessNameKey ([string](Get-Value $policy 'displayName')) Canonical = $canonical Hash = Get-NSPConditionalAccessPolicyHash -Canonical $canonical ClaimedBy = $null } $liveById[$entry.Id] = $entry if (-not $entry.TemplateId) { $candidates.Add($entry) } } # A policy this tool owns is never a candidate for another definition, even when its own # definition is not in this plan (for example an ended trip's policy awaiting retirement). $ownership = Get-NSPConditionalAccessOwnership -TenantId $tenantId -ConfigRoot $ConfigRoot foreach ($ownedId in $ownership.Keys) { $owned = $liveById[[string]$ownership[$ownedId].ObjectId] if ($owned) { $owned.ClaimedBy = $ownedId } } # --- Travel ---------------------------------------------------------------------------------- # Active: every current travel group. Enforced: those whose travel policy is owned and On; only # these are excluded from NSP-BASE-GEO-01, so a traveler always has a country restriction. $travelEntries = @(Get-NSPConditionalAccessTravelEntry -TenantProfile $TenantProfile) $travelByKey = @{} foreach ($travel in $travelEntries) { $travelByKey[$travel.Key] = $travel } $activeTravelIds = @($travelEntries | Where-Object Status -eq 'Active' | ForEach-Object { $_.Ids }) $enforcedTravelIds = @(foreach ($travel in @($travelEntries | Where-Object Status -eq 'Active')) { $record = $ownership[$travel.PolicyId] $live = if ($record) { $liveById[[string]$record.ObjectId] } else { $null } if ($live -and $live.State -eq 'enabled') { $travel.Ids } }) # Travel policies found by Import-NSPConditionalAccessTravel are reserved for their travel # definition until it owns them. An enabled imported policy already restricts its group. $imports = @{} $importIds = @(foreach ($travel in $travelEntries) { if ($travel.ImportedPolicyId) { @{ Id = $travel.PolicyId; LiveId = $travel.ImportedPolicyId } } }) $sessionImport = Get-Value (Get-Value $TenantProfile 'Travel') 'ImportedSessionPolicyId' if ($sessionImport) { $importIds += @{ Id = 'NSP-TRAVEL-SESSION-01'; LiveId = $sessionImport } } foreach ($import in $importIds) { if ($ownership.ContainsKey($import.Id)) { continue } $live = $liveById[[string]$import.LiveId] if ($live -and -not $live.ClaimedBy) { $live.ClaimedBy = $import.Id; $imports[$import.Id] = $live } } $enforcedTravelIds = @($enforcedTravelIds) + @(foreach ($travel in @($travelEntries | Where-Object Status -eq 'Active')) { $live = $imports[$travel.PolicyId] if ($live -and $live.State -eq 'enabled') { $travel.Ids } }) # While the owned NSP-BASE-GEO-01 is On, new travel policies are created On (see Actions). $geoRecord = $ownership['NSP-BASE-GEO-01'] $geoLive = if ($geoRecord) { $liveById[[string]$geoRecord.ObjectId] } else { $null } $travelCreatedOn = [bool]($geoLive -and $geoLive.State -eq 'enabled') $resolveDirectory = @{ Applications = $applications TravelGroups = @{ Active = $activeTravelIds; Enforced = $enforcedTravelIds } } # --- Prerequisites ----------------------------------------------------------------------- $prerequisites = [ordered]@{} $items = [System.Collections.Generic.List[object]]::new() function ConvertTo-PlanItem { param([string]$ItemId, [string]$Kind, [string]$Action, [hashtable]$Extra = @{}) $actionable = $Action -in 'Create', 'UpdateInPlace', 'AdoptOrReview', 'Drifted', 'CreateGroup', 'CreateOnPremGroup', 'CreateNamedLocation', 'Retire', 'BindSyncedGroup', 'DeleteNamedLocation', 'CreateAuthenticationStrength', 'BindAuthenticationStrength' $item = [ordered]@{ ItemId = $ItemId Kind = $Kind Action = $Action Decision = if ($actionable) { 'Pending' } else { 'NotActionable' } DefinitionId = $null DisplayName = $null Confidence = $null Score = $null LiveObjectId = $null LiveDisplayName = $null LiveState = $null DependsOn = @() Reason = $null Differences = @() DesiredBody = $null DesiredHash = $null LiveHash = $null } foreach ($key in $Extra.Keys) { $item[$key] = $Extra[$key] } [pscustomobject]$item } # Synced groups by display name: once the on-premises group a CreateOnPremGroup item asked for # has synced, it is bound into its slot (or travel entry) instead of anything being created. $syncedByName = @{} $groupSynced = @{} foreach ($group in Get-List $Inventory 'Groups') { $groupSynced[[string](Get-Value $group 'id')] = [bool](Get-Value $group 'onPremisesSyncEnabled') if (Get-Value $group 'onPremisesSyncEnabled') { $name = [string](Get-Value $group 'displayName') if (-not $syncedByName.ContainsKey($name)) { $syncedByName[$name] = [System.Collections.Generic.List[object]]::new() } $syncedByName[$name].Add($group) } } function Get-BindPrerequisite { # Returns the bind item's Id, or $null when no single synced group has the expected name. param([string]$Key, [string]$Prefix, [string]$Name, [string]$Why) $found = if ($syncedByName.ContainsKey($Name)) { @($syncedByName[$Name]) } else { @() } if ($found.Count -ne 1) { return $null } $id = "bind$($Prefix):$Key" if (-not $prerequisites.Contains($id)) { $prerequisites[$id] = ConvertTo-PlanItem $id 'Prerequisite' 'BindSyncedGroup' @{ DisplayName = $Name DesiredBody = [ordered]@{ bindKind = $(if ($Prefix -eq 'travelgroup') { 'TravelGroup' } else { 'Group' }); key = $Key; groupId = Get-Value $found[0] 'id'; label = $Name } Reason = "The synced group '$Name' exists now. $Why" } } $id } function Get-GroupPrerequisite { # A slot group ('group:<Slot>') or a travel group ('travelgroup:<Key>'); same shape either way. param([string]$Slot, [string]$Prefix = 'group', [string]$Name) $cloudId = "$($Prefix):$Slot" $name = if ($Name) { $Name } elseif ($slotDefaults.ContainsKey($Slot)) { $slotDefaults[$Slot] } else { "Group-$Slot" } if ($hybrid) { $bind = Get-BindPrerequisite -Key $Slot -Prefix $Prefix -Name $name -Why 'Binding it fills the empty slot; no group needs creating.' if ($bind) { return $bind } } if (-not $prerequisites.Contains($cloudId)) { if ($hybrid) { $prerequisites["$Prefix-onprem:$Slot"] = ConvertTo-PlanItem "$Prefix-onprem:$Slot" 'Prerequisite' 'CreateOnPremGroup' @{ DisplayName = $name DesiredBody = [ordered]@{ name = $name; groupScope = 'Global'; groupCategory = 'Security' } Reason = "Slot $Slot has no group. Hybrid tenant: create '$name' in Active Directory (a script is generated at execution) so on-premises membership syncs; re-plan after sync to add it to the slot." } $cloudName = "$($name)_azure" $reason = "Cloud-only companion for slot $Slot, so policies can ship before '$name' syncs; cloud-only users are managed here." } else { $cloudName = $name $reason = "Slot $Slot has no group." } $prerequisites[$cloudId] = ConvertTo-PlanItem $cloudId 'Prerequisite' 'CreateGroup' @{ DisplayName = $cloudName DesiredBody = [ordered]@{ displayName = $cloudName; mailEnabled = $false; mailNickname = ($cloudName -replace '[^A-Za-z0-9]', ''); securityEnabled = $true } Reason = $reason } } $cloudId } function Get-CountryPrerequisite { param([string]$Iso) $id = "location:$Iso" if (-not $prerequisites.Contains($id)) { $prerequisites[$id] = ConvertTo-PlanItem $id 'Prerequisite' 'CreateNamedLocation' @{ DisplayName = "NSP-LOC-$Iso" DesiredBody = [ordered]@{ '@odata.type' = '#microsoft.graph.countryNamedLocation' displayName = "NSP-LOC-$Iso" countriesAndRegions = @($Iso) includeUnknownCountriesAndRegions = $false } Reason = "No named location is bound for country $Iso." } } $id } function Get-StrengthPrerequisite { # A custom strength with exactly the catalog's combinations is bound; otherwise one is created. param([string]$Name) $spec = (Get-NSPConditionalAccessStrengthCatalog)[$Name] $wanted = (@($spec.AllowedCombinations) | Sort-Object) -join ',' $existing = @(Get-List $Inventory 'AuthenticationStrengths' | Where-Object { ((@(Get-List $_ 'allowedCombinations') | Sort-Object) -join ',') -eq $wanted }) if ($existing.Count -ge 1) { $id = "bindstrength:$Name" if (-not $prerequisites.Contains($id)) { $prerequisites[$id] = ConvertTo-PlanItem $id 'Prerequisite' 'BindAuthenticationStrength' @{ DisplayName = Get-Value $existing[0] 'displayName' DesiredBody = [ordered]@{ bindKind = 'Strength'; key = $Name; strengthId = Get-Value $existing[0] 'id'; label = Get-Value $existing[0] 'displayName'; allowedCombinations = @($spec.AllowedCombinations) } Reason = "The custom authentication strength '$(Get-Value $existing[0] 'displayName')' already allows exactly $wanted. Binding it as $Name creates nothing.$(if ($existing.Count -gt 1) { " $($existing.Count) strengths match; the first was chosen." })" } } return $id } $id = "strength:$Name" if (-not $prerequisites.Contains($id)) { $prerequisites[$id] = ConvertTo-PlanItem $id 'Prerequisite' 'CreateAuthenticationStrength' @{ DisplayName = $spec.DisplayName DesiredBody = [ordered]@{ displayName = $spec.DisplayName; description = $spec.Description; allowedCombinations = @($spec.AllowedCombinations) } Reason = "No custom authentication strength allows exactly $wanted. Creates '$($spec.DisplayName)'; it changes nothing until a policy uses it." } } $id } # A bound slot or travel entry that still has only its cloud-only companion. if ($hybrid) { $slotNames = if ($groups -is [System.Collections.IDictionary]) { @($groups.Keys) } else { @($groups.PSObject.Properties.Name) } foreach ($slot in $slotNames) { $ids = @(Get-List (Get-Value $groups $slot) 'Ids') if ($ids.Count -and -not @($ids | Where-Object { $groupSynced[[string]$_] }).Count) { $name = Get-Value (Get-Value $groups $slot) 'DefaultName' if ($name) { Get-BindPrerequisite -Key $slot -Prefix 'group' -Name $name -Why 'Binding adds it next to the cloud-only companion; the policies using the slot then update on the next plan.' | Out-Null } } } foreach ($travel in @($travelEntries | Where-Object Status -eq 'Active')) { if ($travel.Ids.Count -and -not @($travel.Ids | Where-Object { $groupSynced[[string]$_] }).Count -and $travel.DefaultName) { Get-BindPrerequisite -Key $travel.Key -Prefix 'travelgroup' -Name $travel.DefaultName -Why 'Binding adds it next to the cloud-only companion; the travel policies then update on the next plan.' | Out-Null } } } # Slots asked for by name (-GroupSlot), which no definition may reference: MFAExempt for the # bulk add. An empty slot is created (or bound) like any other. A hybrid slot with only its # synced group gets a cloud-only '_azure' companion, so cloud-only users can be added too. foreach ($wantedSlot in @($GroupSlot | Where-Object { $_ })) { $ids = @(Get-List (Get-Value $groups $wantedSlot) 'Ids') if (-not $ids.Count) { Get-GroupPrerequisite $wantedSlot | Out-Null; continue } if ($hybrid -and -not @($ids | Where-Object { -not $groupSynced[[string]$_] }).Count) { $label = @(Get-List (Get-Value $groups $wantedSlot) 'Labels')[0] $cloudName = "$($label)_azure" $cloudId = "group:$wantedSlot" if (-not $prerequisites.Contains($cloudId)) { $prerequisites[$cloudId] = ConvertTo-PlanItem $cloudId 'Prerequisite' 'CreateGroup' @{ DisplayName = $cloudName DesiredBody = [ordered]@{ displayName = $cloudName; mailEnabled = $false; mailNickname = ($cloudName -replace '[^A-Za-z0-9]', ''); securityEnabled = $true } Reason = "Slot $wantedSlot has only the synced group '$label'. Cloud-only users can't be added to it, so this creates the cloud-only companion '$cloudName' next to it." } } } } $hasTrustedLocation = [bool]@(Get-List $Inventory 'NamedLocations' | Where-Object { Get-Value $_ 'isTrusted' }).Count # --- Classify definitions ------------------------------------------------------------------ $resolved = [System.Collections.Generic.List[object]]::new() $policyItems = [ordered]@{} foreach ($def in $Definition) { $id = $def['Id'] $displayName = "$id $($def['Name'])" $legacyNames = @($def['LegacyNames'] | Where-Object { $_ }) $legacyKeys = @($legacyNames | ForEach-Object { ConvertTo-NSPConditionalAccessNameKey $_ }) $blocked = [System.Collections.Generic.List[string]]::new() $missing = [System.Collections.Generic.List[object]]::new() foreach ($reference in Get-NSPConditionalAccessDefinitionReference -Definition $def) { switch ($reference.Kind) { 'App' { if (-not (Get-Value $applications $reference.Name)) { $blocked.Add("{App:$($reference.Name)} is not in the tenant") } } 'Group' { $slot = Get-Value $groups $reference.Name if (-not $slot -or -not @(Get-List $slot 'Ids').Count) { $missing.Add(@{ Kind = 'Group'; Name = $reference.Name }) } } 'Country' { $iso = if ($reference.Name -eq 'Home') { Get-Value $TenantProfile 'HomeCountry' } else { $reference.Name } if (-not $iso) { $blocked.Add('{Country:Home} needs HomeCountry in the tenant profile') } elseif (-not (Get-Value $countries $iso)) { $missing.Add(@{ Kind = 'Country'; Name = $iso }) } } 'TravelGroup' { $travel = $travelByKey[$reference.Name] if (-not $travel) { $blocked.Add("{TravelGroup:$($reference.Name)} has no travel entry in the tenant profile") } elseif (-not $travel.Ids.Count) { $missing.Add(@{ Kind = 'TravelGroup'; Name = $reference.Name }) } } 'Strength' { $builtIn = (Get-NSPConditionalAccessStrengthCatalog)[$reference.Name] if (-not ($builtIn -and $builtIn.BuiltInId) -and -not (Get-Value (Get-Value $TenantProfile 'AuthenticationStrengths') $reference.Name)) { $missing.Add(@{ Kind = 'Strength'; Name = $reference.Name }) } } 'TravelGroups' { # The session policy needs at least one travel group to exist. if ($reference.Name -eq 'Active' -and -not $activeTravelIds.Count) { foreach ($travel in @($travelEntries | Where-Object Status -eq 'Active')) { $missing.Add(@{ Kind = 'TravelGroup'; Name = $travel.Key }) } } } } } # What would make the policy block more than it means to. if (@(Get-List (Get-Value $def 'Users') 'IncludeUsers') -contains '{ServiceAccounts}' -and -not @(Get-List (Get-Value $TenantProfile 'ServiceAccounts') 'Ids').Count) { $blocked.Add('It applies to the service accounts, and the tenant profile has none (add them with Change who a policy applies to)') } if (@(Get-List (Get-Value $def 'Grant') 'BuiltInControls') -contains 'block' -and @(Get-List (Get-Value $def 'Locations') 'Exclude') -contains 'AllTrusted' -and -not $hasTrustedLocation) { $blocked.Add('It blocks everywhere except trusted locations, and the tenant has none, so it would block everywhere (add one with Update trusted locations)') } if ($blocked.Count) { $policyItems[$id] = ConvertTo-PlanItem $id 'Policy' 'BlockedPrerequisite' @{ DefinitionId = $id; DisplayName = $displayName; Reason = ($blocked -join '; ') + '.' } continue } if ($missing.Count) { $dependsOn = @($missing | ForEach-Object { $need = $_ # inside switch, $_ is the switch value switch ($need.Kind) { 'Group' { Get-GroupPrerequisite $need.Name } 'TravelGroup' { Get-GroupPrerequisite -Slot $need.Name -Prefix 'travelgroup' -Name $travelByKey[$need.Name].DefaultName } 'Strength' { Get-StrengthPrerequisite $need.Name } default { Get-CountryPrerequisite $need.Name } } } | Select-Object -Unique) $item = ConvertTo-PlanItem $id 'Policy' 'PendingPrerequisite' @{ DefinitionId = $id; DisplayName = $displayName; DependsOn = $dependsOn Reason = "Waits on $($dependsOn -join ', '). Re-plan after they run." } $legacy = @($candidates | Where-Object { -not $_.ClaimedBy -and $_.NameKey -in $legacyKeys }) $how = 'has a legacy name for this definition' if ($imports.ContainsKey($id)) { $legacy = @($imports[$id]); $how = 'was imported for this definition' } if ($legacy.Count -eq 1) { $legacy[0].ClaimedBy = $id $item.LiveObjectId = $legacy[0].Id; $item.LiveDisplayName = $legacy[0].DisplayName; $item.LiveState = $legacy[0].State $item.Reason += " Live policy '$($legacy[0].DisplayName)' $how and is reserved for adoption." } $policyItems[$id] = $item continue } $body = Resolve-NSPConditionalAccessPolicy -Definition $def -TenantProfile $TenantProfile -Directory $resolveDirectory $canonical = ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $body $resolved.Add([pscustomobject]@{ Id = $id; DisplayName = $displayName; LegacyNames = $legacyNames; LegacyKeys = $legacyKeys; Definition = $def Body = $body; Canonical = $canonical; Hash = Get-NSPConditionalAccessPolicyHash -Canonical $canonical Match = $null; Confidence = $null; Score = $null; Conflict = $null }) } # --- Match resolved definitions to live policies ------------------------------------------- foreach ($entry in $resolved) { if ($ownership.ContainsKey($entry.Id)) { $live = $liveById[[string]$ownership[$entry.Id].ObjectId] if ($live) { $live.ClaimedBy = $entry.Id; $entry.Match = $live; $entry.Confidence = 'Owned' } else { $entry.Confidence = 'OwnedMissing' } } elseif ($imports.ContainsKey($entry.Id)) { $entry.Match = $imports[$entry.Id]; $entry.Confidence = 'Imported' } } foreach ($stage in 'Exact', 'LegacyName') { foreach ($entry in @($resolved | Where-Object { -not $_.Confidence })) { $found = @($candidates | Where-Object { -not $_.ClaimedBy -and $(if ($stage -eq 'Exact') { $_.Hash -eq $entry.Hash } else { $_.NameKey -in $entry.LegacyKeys }) }) if ($found.Count -eq 1) { $found[0].ClaimedBy = $entry.Id; $entry.Match = $found[0]; $entry.Confidence = $stage } elseif ($found.Count -gt 1) { $entry.Confidence = 'Conflict'; $entry.Conflict = "Several live policies match ($stage): $(($found | ForEach-Object DisplayName) -join ', ')." } } } $pairs = foreach ($entry in @($resolved | Where-Object { -not $_.Confidence })) { foreach ($live in @($candidates | Where-Object { -not $_.ClaimedBy })) { $comparison = Compare-NSPConditionalAccessCanonicalPolicy -Live $live.Canonical -Desired $entry.Canonical [pscustomobject]@{ Entry = $entry; Live = $live; Score = $comparison.Score } } } foreach ($pair in @($pairs | Where-Object Score -ge 0.75 | Sort-Object Score -Descending)) { if ($pair.Entry.Confidence -or $pair.Live.ClaimedBy) { continue } $rivals = @($pairs | Where-Object { $_.Entry -eq $pair.Entry -and $_.Live -ne $pair.Live -and -not $_.Live.ClaimedBy } | Sort-Object Score -Descending) if ($rivals.Count -and ($pair.Score - $rivals[0].Score) -lt 0.1) { $pair.Entry.Confidence = 'Conflict' $pair.Entry.Conflict = "Similar live policies are too close to call: '$($pair.Live.DisplayName)' ($($pair.Score)) and '$($rivals[0].Live.DisplayName)' ($($rivals[0].Score))." continue } $pair.Live.ClaimedBy = $pair.Entry.Id $pair.Entry.Match = $pair.Live; $pair.Entry.Confidence = 'Similar'; $pair.Entry.Score = $pair.Score } # --- Actions ------------------------------------------------------------------------------- $schema = Get-NSPConditionalAccessDefinitionSchema $livePolicyById = @{} foreach ($policy in $livePolicies) { $livePolicyById[[string](Get-Value $policy 'id')] = $policy } $groupNames = @{} foreach ($group in Get-List $Inventory 'Groups') { $groupNames[[string](Get-Value $group 'id')] = [string](Get-Value $group 'displayName') } $userNames = @{} foreach ($user in Get-List $Inventory 'ReferencedUsers') { if (Get-Value $user 'UserPrincipalName') { $userNames[[string](Get-Value $user 'Id')] = [string](Get-Value $user 'UserPrincipalName') } } function ConvertTo-PolicyItem { param($entry) $live = $entry.Match $extra = @{ DefinitionId = $entry.Id; DisplayName = $entry.DisplayName; Confidence = $entry.Confidence; DesiredHash = $entry.Hash } if ($live) { $comparison = Compare-NSPConditionalAccessCanonicalPolicy -Live $live.Canonical -Desired $entry.Canonical $body = [ordered]@{} foreach ($key in $entry.Body.Keys) { $body[$key] = $entry.Body[$key] } $body['state'] = $live.State $extra += @{ LiveObjectId = $live.Id; LiveDisplayName = $live.DisplayName; LiveState = $live.State; LiveHash = $live.Hash Differences = $comparison.Differences; Score = $comparison.Score; DesiredBody = $body } $renamed = $live.DisplayName -cne $entry.DisplayName if ($entry.Confidence -eq 'Owned') { $record = $ownership[$entry.Id] $action = if ($live.Hash -eq $entry.Hash -and -not $renamed) { 'NoChange' } elseif ($live.Hash -eq $record.AppliedHash) { 'UpdateInPlace' } else { 'Drifted' } $extra.Reason = switch ($action) { 'NoChange' { 'Live policy matches the definition.' } 'UpdateInPlace' { "The definition changed since it was applied$(if ($renamed) { ', or the policy was renamed' }); the live policy was not changed outside this tool." } 'Drifted' { 'The live policy was changed outside this tool since it was applied. Review the differences; approving reapplies the definition.' } } } else { $action = 'AdoptOrReview' $extra.Reason = "Existing policy '$($live.DisplayName)' matches by $($entry.Confidence). Approving adopts it, updates it to the definition$(if ($renamed) { ", and renames it to '$($entry.DisplayName)'" }). State stays $($live.State)." } # An On policy that becomes a block is enforced the moment the update runs, with no # report-only stage (a template that changed its grant, such as NSP-BASE-REG-01 v2). $liveGrant = @(Get-List (Get-Value $livePolicyById[$live.Id] 'grantControls') 'builtInControls') if ($action -ne 'NoChange' -and $live.State -eq 'enabled' -and $liveGrant -notcontains 'block' -and @(Get-List (Get-Value $entry.Body 'grantControls') 'builtInControls') -contains 'block') { $extra.Reason += ' This policy is On and the update makes it a block, enforced as soon as it runs. To watch it first, roll it back to report-only (Enforce > rollback), update it, and turn it on again later.' } # Removing an exclusion from a block policy blocks people; name them in the reason. if (@(Get-List (Get-Value $entry.Body 'grantControls') 'builtInControls') -contains 'block' -and $action -ne 'NoChange') { $liveUsers = Get-Value (Get-Value $livePolicyById[$live.Id] 'conditions') 'users' $desiredUsers = Get-Value (Get-Value $entry.Body 'conditions') 'users' $lost = @( @(Get-List $liveUsers 'excludeGroups' | Where-Object { $_ -notin @(Get-List $desiredUsers 'excludeGroups') } | ForEach-Object { if ($groupNames.ContainsKey([string]$_)) { "group '$($groupNames[[string]$_])'" } else { "group $_" } }) @(Get-List $liveUsers 'excludeUsers' | Where-Object { $_ -notin @(Get-List $desiredUsers 'excludeUsers') } | ForEach-Object { if ($userNames.ContainsKey([string]$_)) { "user '$($userNames[[string]$_])'" } else { "user $_" } }) ) if ($lost.Count) { $extra.Reason += " Approving removes these exclusions from a block policy, so they become blocked by it: $($lost -join ', ')." } } } elseif ($entry.Confidence -eq 'Conflict') { $action = 'Conflict' $extra.Reason = $entry.Conflict } else { $action = 'Create' $extra.DesiredBody = $entry.Body $extra.Reason = if ($entry.Confidence -eq 'OwnedMissing') { "The owned policy $($ownership[$entry.Id].ObjectId) no longer exists in the tenant. Creating it again." } else { "No existing policy matches. Created in state $($schema.States[$entry.Definition['DefaultState']])." } if ($travelCreatedOn -and $entry.Id -like 'NSP-TRAVEL-*') { $body = [ordered]@{} foreach ($key in $entry.Body.Keys) { $body[$key] = $entry.Body[$key] } $body['state'] = 'enabled' $extra.DesiredBody = $body $extra.Reason = if ($entry.Id -eq 'NSP-TRAVEL-SESSION-01') { 'Created On, with the travel policies: it reaches only travel group members.' } else { 'Created On, because NSP-BASE-GEO-01 is On: for the travel group it blocks less than the baseline already does, so it blocks no one new. The baseline then excludes the group, after this policy, in the same run.' } } } ConvertTo-PlanItem $entry.Id 'Policy' $action $extra } foreach ($entry in $resolved) { $policyItems[$entry.Id] = ConvertTo-PolicyItem $entry } # Travel policies this plan creates On: NSP-BASE-GEO-01 excludes their groups in the same run, # after them (DependsOn), so the traveler can sign in abroad as soon as the run finishes. $geoEntry = @($resolved | Where-Object { $_.Id -eq 'NSP-BASE-GEO-01' -and $_.Confidence -eq 'Owned' }) $createdOn = @($policyItems.Values | Where-Object { $_.DefinitionId -like 'NSP-TRAVEL-*' -and $_.DefinitionId -ne 'NSP-TRAVEL-SESSION-01' -and $_.Action -eq 'Create' -and (Get-Value $_.DesiredBody 'state') -eq 'enabled' }) if ($geoEntry.Count -and $createdOn.Count) { $travelByPolicy = @{} foreach ($travel in $travelEntries) { $travelByPolicy[$travel.PolicyId] = $travel } $resolveDirectory.TravelGroups.Enforced = @(@($enforcedTravelIds) + @($createdOn | ForEach-Object { $travelByPolicy[$_.DefinitionId].Ids }) | Select-Object -Unique) $geo = $geoEntry[0] $geo.Body = Resolve-NSPConditionalAccessPolicy -Definition $geo.Definition -TenantProfile $TenantProfile -Directory $resolveDirectory $geo.Canonical = ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $geo.Body $geo.Hash = Get-NSPConditionalAccessPolicyHash -Canonical $geo.Canonical $item = ConvertTo-PolicyItem $geo if ($item.Action -ne 'NoChange') { $item.DependsOn = @($createdOn | ForEach-Object { $_.ItemId }) $item.Reason += " It excludes the travel group(s) of $(@($createdOn | ForEach-Object { $_.DefinitionId }) -join ', '), which this run creates On first." } $policyItems['NSP-BASE-GEO-01'] = $item } # --- Travel retirement -------------------------------------------------------------------- # Reverse of the create order: the group must leave every other policy's exclusions (in # practice NSP-BASE-GEO-01, which this same plan updates) before its travel policy is deleted. $retireItems = foreach ($travel in @($travelEntries | Where-Object Status -ne 'Active')) { $record = $ownership[$travel.PolicyId] $live = if ($record) { $liveById[[string]$record.ObjectId] } else { $null } $why = if ($travel.Status -eq 'Expired') { "The trip ended $($travel.EndDate)." } else { 'Retirement was requested.' } $excludedBy = @(foreach ($policy in $livePolicies) { if ($live -and (Get-Value $policy 'id') -eq $live.Id) { continue } $excluded = @(Get-List (Get-Value (Get-Value $policy 'conditions') 'users') 'excludeGroups') if (@($travel.Ids | Where-Object { $_ -in $excluded }).Count) { Get-Value $policy 'displayName' } }) $extra = @{ DefinitionId = $travel.PolicyId; DisplayName = if ($live) { $live.DisplayName } else { $travel.PolicyId }; DesiredBody = [ordered]@{ travelKey = $travel.Key } } if ($live) { $extra += @{ LiveObjectId = $live.Id; LiveDisplayName = $live.DisplayName; LiveState = $live.State; LiveHash = $live.Hash; Confidence = 'Owned' } } if ($excludedBy.Count) { $extra.Reason = "$why Its group is still excluded by: $($excludedBy -join ', '). Approve and run that update first (it restores the country restriction), then re-plan to retire." $extra.DependsOn = @($policyItems.Values | Where-Object { $_.LiveDisplayName -in $excludedBy } | ForEach-Object ItemId) ConvertTo-PlanItem "retire:$($travel.PolicyId)" 'Policy' 'RetirePending' $extra } else { $extra.Reason = "$why $(if ($live) { "Deletes '$($live.DisplayName)', then" } else { 'No live policy remains;' }) removes the entry from the tenant profile. The group '$($travel.DefaultName)' is left for you to delete." ConvertTo-PlanItem "retire:$($travel.PolicyId)" 'Policy' 'Retire' $extra } } # The shared session policy goes once no travel entry is active. $sessionRecord = $ownership['NSP-TRAVEL-SESSION-01'] $sessionLive = if ($sessionRecord) { $liveById[[string]$sessionRecord.ObjectId] } else { $null } if ($sessionLive -and -not @($travelEntries | Where-Object Status -eq 'Active').Count) { $retireItems = @($retireItems) + (ConvertTo-PlanItem 'retire:NSP-TRAVEL-SESSION-01' 'Policy' 'Retire' @{ DefinitionId = 'NSP-TRAVEL-SESSION-01'; DisplayName = $sessionLive.DisplayName; Confidence = 'Owned' LiveObjectId = $sessionLive.Id; LiveDisplayName = $sessionLive.DisplayName; LiveState = $sessionLive.State; LiveHash = $sessionLive.Hash DesiredBody = [ordered]@{ travelKey = $null } Reason = "No travel entry is active. Deletes '$($sessionLive.DisplayName)'." }) } foreach ($item in $prerequisites.Values) { $items.Add($item) } foreach ($def in $Definition) { $items.Add($policyItems[$def['Id']]) } foreach ($def in $deselected) { $record = $ownership[$def['Id']] $live = if ($record) { $liveById[[string]$record.ObjectId] } else { $null } if (-not $live) { continue } $name = ([string]$def['Option'] -split '=', 2)[0] $chosen = Get-Value (Get-Value $TenantProfile 'Options') $name if (-not $chosen) { $chosen = (Get-NSPConditionalAccessOption)[$name].Default } $replacement = @($Definition | Where-Object { $_['Option'] -like "$name=*" } | ForEach-Object { $_['Id'] }) $state = if ($live.State -eq 'disabled') { 'It is already off.' } else { "Once $($replacement -join ', ') is On, turn this one off: New-NSPConditionalAccessPromotionPlan -TargetState Disabled -DefinitionId $($def['Id'])." } $items.Add((ConvertTo-PlanItem $def['Id'] 'Policy' 'Deselected' @{ DefinitionId = $def['Id']; DisplayName = $live.DisplayName; Confidence = 'Owned' LiveObjectId = $live.Id; LiveDisplayName = $live.DisplayName; LiveState = $live.State; LiveHash = $live.Hash Reason = "Not used with $name = $chosen. $state" })) } foreach ($item in @($retireItems)) { $items.Add($item) } $findings = @(Test-NSPConditionalAccessTenant -Inventory $Inventory -TenantProfile $TenantProfile) # Optional cleanup: unreferenced duplicates of a bound country location. Skip them freely. foreach ($finding in @($findings | Where-Object Rule -eq 'OrphanNamedLocation')) { if ($finding.Subject -match '^(?<name>.*) \[(?<id>[^\]]+)\]$') { $items.Add((ConvertTo-PlanItem "location-delete:$($Matches.id)" 'Cleanup' 'DeleteNamedLocation' @{ DisplayName = $Matches.name; LiveObjectId = $Matches.id; DesiredBody = [ordered]@{ id = $Matches.id; displayName = $Matches.name } Reason = "$($finding.Message) Deleting it changes no policy; the run re-checks that nothing references it." })) } } # A run goes in plan order, so every item comes after what it depends on (NSP-BASE-GEO-01 can # depend on a travel policy defined after it). Otherwise the order is kept. $itemById = @{} foreach ($item in $items) { $itemById[$item.ItemId] = $item } $ordered = [System.Collections.Generic.List[object]]::new() $placed = [System.Collections.Generic.HashSet[string]]::new() function Add-InOrder { param($Item) if (-not $placed.Add($Item.ItemId)) { return } foreach ($dependency in @($Item.DependsOn | Where-Object { $_ -and $itemById.ContainsKey($_) })) { Add-InOrder $itemById[$dependency] } $ordered.Add($Item) } foreach ($item in $items) { Add-InOrder $item } $items = $ordered $plan = [pscustomobject][ordered]@{ SchemaVersion = 1 PlanId = [guid]::NewGuid().ToString() CreatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') TenantId = $tenantId TenantDomain = Get-Value $Inventory 'TenantDomain' InventoryCapturedAtUtc = Get-Value $Inventory 'CapturedAtUtc' ProfileCreatedAtUtc = Get-Value $TenantProfile 'CreatedAtUtc' Items = $items.ToArray() Unmanaged = @($candidates | Where-Object { -not $_.ClaimedBy } | ForEach-Object { [pscustomobject]@{ Id = $_.Id; DisplayName = $_.DisplayName; State = $_.State } }) TemplatePolicies = @($liveById.Values | Where-Object TemplateId | Sort-Object DisplayName | ForEach-Object { [pscustomobject]@{ Id = $_.Id; DisplayName = $_.DisplayName; State = $_.State } }) PreflightErrors = @($findings | Where-Object Severity -eq 'Error' | Select-Object Rule, Subject, Message) Path = $null } if ($Save -and $PSCmdlet.ShouldProcess($tenantId, 'Save deployment plan')) { $folder = Join-Path (Get-NSPConditionalAccessLocalPath -Kind Plans -ConfigRoot $ConfigRoot) $tenantId New-Item -ItemType Directory -Path $folder -Force | Out-Null $plan.Path = Join-Path $folder ((Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json') ConvertTo-Json -InputObject $plan -Depth 32 | Set-Content -LiteralPath $plan.Path -Encoding UTF8 } $plan } |