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