Private/Get-NSPConditionalAccessProfileProposal.ps1
|
function Get-NSPConditionalAccessProfileProposal { <# .SYNOPSIS Offline: turns an inventory into tenant-profile suggestions. Suggests; never decides. .DESCRIPTION Accepts the live inventory object or one reloaded from its saved JSON. Accounts: every user excluded by an existing policy, every sync-role member, and every breakglass-named user, each with a suggested category. Sync-role members are ServiceAccount. Names like breakglass or emergency are BreakGlass. Anything else is Review. Groups: per slot, candidates matching a slot pattern (excluding _azure companions), plus the group a live policy uses for that slot (the policy carrying one of the definition's legacy names, compared by name key). That used group is the proposal; otherwise a single candidate is. The '<name>_azure' companion is attached when present. Countries: for the home country and every {Country:<ISO>} in the definitions, the single-country named locations. The proposal is the one policies reference. If none is referenced, the only candidate. Otherwise there is no proposal. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Inventory, [object[]]$Definition = @(Get-NSPConditionalAccessDefinition) ) 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 $_ }) } $organization = Get-Value $Inventory 'Organization' $homeCountry = Get-Value $organization 'countryLetterCode' # Accounts $accounts = [ordered]@{} function Add-Account { param([string]$Id, [string]$DisplayName, [string]$UserPrincipalName, $Synced, $Enabled, [int]$ExcludedByCount, [bool]$IsSync) if ($accounts.Contains($Id)) { if ($IsSync) { $accounts[$Id].SuggestedCategory = 'ServiceAccount' } return } $category = if ($IsSync) { 'ServiceAccount' } elseif ("$DisplayName $UserPrincipalName" -match '(?i)break.?glass|emergency') { 'BreakGlass' } else { 'Review' } $accounts[$Id] = [pscustomobject]@{ Id = $Id DisplayName = $DisplayName UserPrincipalName = $UserPrincipalName Synced = $Synced AccountEnabled = $Enabled ExcludedByCount = $ExcludedByCount SuggestedCategory = $category } } $stale = [System.Collections.Generic.List[object]]::new() foreach ($user in Get-List $Inventory 'ReferencedUsers') { if ((Get-Value $user 'Status') -ne 'Found') { $stale.Add($user); continue } $excludedBy = @(Get-List $user 'ExcludedBy').Count if ($excludedBy -eq 0) { continue } Add-Account (Get-Value $user 'Id') (Get-Value $user 'DisplayName') (Get-Value $user 'UserPrincipalName') (Get-Value $user 'Synced') (Get-Value $user 'AccountEnabled') $excludedBy ([bool](Get-Value $user 'IsSyncAccount')) } foreach ($user in Get-List $Inventory 'SyncAccounts') { if ((Get-Value $user 'Status') -ne 'Found') { continue } Add-Account (Get-Value $user 'Id') (Get-Value $user 'DisplayName') (Get-Value $user 'UserPrincipalName') (Get-Value $user 'Synced') (Get-Value $user 'AccountEnabled') 0 $true } foreach ($user in Get-List $Inventory 'BreakGlassCandidates') { Add-Account (Get-Value $user 'id') (Get-Value $user 'displayName') (Get-Value $user 'userPrincipalName') ([bool](Get-Value $user 'onPremisesSyncEnabled')) (Get-Value $user 'accountEnabled') 0 $false } # Groups $groups = @(Get-List $Inventory 'Groups') $groupById = @{} foreach ($group in $groups) { $groupById[[string](Get-Value $group 'id')] = $group } # Evidence from the live policies: when a definition's only group in a list is a slot, and the # live policy with one of its legacy names has exactly one group there (not counting its # _azure companion), that group is what the tenant uses for the slot, whatever its name. $evidence = @{} $livePolicies = @(Get-List $Inventory 'Policies' | Where-Object { -not (Get-Value $_ 'templateId') }) foreach ($def in $Definition) { $keys = @($def['LegacyNames'] | Where-Object { $_ } | ForEach-Object { ConvertTo-NSPConditionalAccessNameKey $_ }) if (-not $keys.Count -or $def['Users'] -isnot [System.Collections.IDictionary]) { continue } $live = @($livePolicies | Where-Object { (ConvertTo-NSPConditionalAccessNameKey ([string](Get-Value $_ 'displayName'))) -in $keys }) if ($live.Count -ne 1) { continue } foreach ($pair in @(@('IncludeGroups', 'includeGroups'), @('ExcludeGroups', 'excludeGroups'))) { $references = @($def['Users'][$pair[0]] | Where-Object { $_ }) if ($references.Count -ne 1 -or $references[0] -notmatch '^\{Group:(?<slot>[^}]+)\}$') { continue } $slotName = $Matches.slot $ids = @(Get-List (Get-Value (Get-Value $live[0] 'conditions') 'users') $pair[1] | Where-Object { $group = $groupById[[string]$_] -not $group -or [string](Get-Value $group 'displayName') -notlike '*_azure' }) if ($ids.Count -eq 1 -and $groupById.ContainsKey([string]$ids[0])) { $evidence[$slotName] = [pscustomobject]@{ Group = $groupById[[string]$ids[0]]; Policy = Get-Value $live[0] 'displayName' } } } } $slots = foreach ($slot in Get-NSPConditionalAccessGroupSlot) { $matched = @($groups | Where-Object { $name = [string](Get-Value $_ 'displayName') $name -notlike '*_azure' -and @($slot.Patterns | Where-Object { $name -like $_ }).Count }) $proof = $evidence[$slot.Slot] if ($proof -and -not @($matched | Where-Object { (Get-Value $_ 'id') -eq (Get-Value $proof.Group 'id') }).Count) { $matched += $proof.Group } $candidates = @($matched | ForEach-Object { $name = Get-Value $_ 'displayName' $companions = @($groups | Where-Object { (Get-Value $_ 'displayName') -eq "$($name)_azure" }) [pscustomobject]@{ Id = Get-Value $_ 'id' DisplayName = $name Synced = [bool](Get-Value $_ 'onPremisesSyncEnabled') UsedBy = if ($proof -and (Get-Value $_ 'id') -eq (Get-Value $proof.Group 'id')) { $proof.Policy } else { $null } Companion = if ($companions.Count -eq 1) { [pscustomobject]@{ Id = Get-Value $companions[0] 'id'; DisplayName = Get-Value $companions[0] 'displayName' } } else { $null } } }) # The group a matching live policy uses wins over name matches. $used = @($candidates | Where-Object UsedBy) $proposal = if ($used.Count -eq 1) { $used[0] } elseif ($candidates.Count -eq 1) { $candidates[0] } else { $null } [pscustomobject]@{ Slot = $slot.Slot DefaultName = $slot.DefaultName Candidates = $candidates Proposal = $proposal Status = if (-not $candidates.Count) { 'Missing' } elseif ($proposal) { 'Proposed' } else { 'Ambiguous' } } } # Countries $locationUse = @{} foreach ($policy in Get-List $Inventory 'Policies') { $locations = Get-Value (Get-Value $policy 'conditions') 'locations' foreach ($key in 'includeLocations', 'excludeLocations') { foreach ($id in Get-List $locations $key) { $locationUse[$id] = 1 + [int]$locationUse[$id] } } } $needed = @(@($homeCountry) + @($Definition | ForEach-Object { $locations = $_['Locations'] if ($locations -is [System.Collections.IDictionary]) { @($locations['Include']) + @($locations['Exclude']) } } | Where-Object { $_ -match '^\{Country:([A-Z]{2})\}$' } | ForEach-Object { $Matches[1] }) | Where-Object { $_ } | Sort-Object -Unique) $countryLocations = @(Get-List $Inventory 'NamedLocations' | Where-Object { (Get-Value $_ '@odata.type') -eq '#microsoft.graph.countryNamedLocation' }) $countries = foreach ($iso in $needed) { $candidates = @($countryLocations | Where-Object { $codes = @(Get-List $_ 'countriesAndRegions') $codes.Count -eq 1 -and $codes[0] -eq $iso } | ForEach-Object { $id = Get-Value $_ 'id' [pscustomobject]@{ Id = $id DisplayName = Get-Value $_ 'displayName' ReferencedBy = [int]$locationUse[$id] Created = Get-Value $_ 'createdDateTime' } } | Sort-Object { - $_.ReferencedBy }, Created) $referenced = @($candidates | Where-Object ReferencedBy -gt 0) $proposal = if ($referenced.Count -eq 1) { $referenced[0] } elseif ($referenced.Count -eq 0 -and $candidates.Count -eq 1) { $candidates[0] } else { $null } [pscustomobject]@{ Country = $iso Candidates = $candidates Proposal = $proposal Status = if ($candidates.Count -eq 0) { 'Missing' } elseif ($proposal) { 'Proposed' } else { 'Ambiguous' } } } [pscustomobject]@{ TenantId = Get-Value $Inventory 'TenantId' TenantDomain = Get-Value $Inventory 'TenantDomain' Hybrid = [bool](Get-Value $Inventory 'Hybrid') HomeCountry = $homeCountry InventoryCapturedAtUtc = Get-Value $Inventory 'CapturedAtUtc' Accounts = @($accounts.Values | Sort-Object { - $_.ExcludedByCount }, DisplayName) StaleExclusions = $stale.ToArray() GroupSlots = @($slots) Countries = @($countries) } } |