Public/Get-NSPEntraUserPairCandidate.ps1
|
function Get-NSPEntraUserPairCandidate { <# .SYNOPSIS Suggests AD users for each cloud user, with reasons. Offline, and decides nothing. .DESCRIPTION Returns one record per cloud user. Decision is always Undecided: a person accepts a pair, however strong the evidence. Names and account-name look-alikes are hints, so a candidate with only those is Low confidence. Status: - Suggested: one best candidate. - Ambiguous: several candidates tied for best. - OneToMany: this user's best candidate is also another cloud user's. - LinkedElsewhere: this user's best candidate is already linked to another cloud user. - NotFound: no candidate. - Linked: an immutable ID already equals this AD user's anchor. Diagnostic. - AnchorConflict: an immutable ID is set and equals no AD user's anchor. Diagnostic. - SyncedUnmatched: synced, and no AD user here has that anchor. Diagnostic. - DuplicateAnchor: two cloud users hold one immutable ID, or two AD users have the anchor a cloud user holds. Diagnostic. - Excluded: a guest; shown only with -IncludeGuests. Without a Verified -SourceAnchor, immutable IDs are compared against both objectGUID and mS-DS-ConsistencyGuid, and AnchorBasis says Unverified. That helps diagnosis and proves nothing about the strategy. .EXAMPLE Get-NSPEntraUserPairCandidate -Inventory $inventory -SourceAnchor $anchor | Group-Object Status #> [CmdletBinding(DefaultParameterSetName = 'Inventory')] param( [Parameter(Mandatory, ParameterSetName = 'Inventory')]$Inventory, [Parameter(Mandatory, ParameterSetName = 'Supplied')][AllowEmptyCollection()][object[]]$CloudUser, [Parameter(Mandatory, ParameterSetName = 'Supplied')][AllowEmptyCollection()][object[]]$ADUser, $SourceAnchor, [switch]$IncludeGuests ) if ($PSCmdlet.ParameterSetName -eq 'Inventory') { $CloudUser = @($Inventory.CloudUsers) $ADUser = @($Inventory.ADUsers) } $verified = $SourceAnchor -and $SourceAnchor.Provenance -eq 'Verified' -and $SourceAnchor.Strategy -ne 'Unknown' $anchorBasis = if ($verified) { 'Verified' } else { 'Unverified' } function Get-NormalizedName { param([string]$Value) (($Value -replace '\s+', ' ').Trim()).ToLowerInvariant() } function Get-SmtpAddress { # proxyAddresses entries with their type removed; primary is the uppercase SMTP: entry. param([object[]]$Address, [switch]$PrimaryOnly) foreach ($entry in @($Address | Where-Object { $_ })) { $text = [string]$entry if ($PrimaryOnly) { if ($text.StartsWith('SMTP:', [StringComparison]::Ordinal)) { $text.Substring(5) } } elseif ($text -match '^smtp:(?<address>.+)$') { $Matches.address } } } # Lookups by value, so a tenant of thousands is not compared pair by pair. @{} ignores case. $byUpn = @{}; $bySmtp = @{}; $byPrimarySmtp = @{}; $bySam = @{}; $byDisplay = @{}; $byFullName = @{}; $byAnchor = @{} function Add-Lookup { param([hashtable]$Map, [string]$Key, [int]$Index) if ([string]::IsNullOrWhiteSpace($Key)) { return } if (-not $Map.ContainsKey($Key)) { $Map[$Key] = [System.Collections.Generic.List[int]]::new() } if (-not $Map[$Key].Contains($Index)) { $Map[$Key].Add($Index) } } for ($i = 0; $i -lt $ADUser.Count; $i++) { $user = $ADUser[$i] Add-Lookup $byUpn ([string]$user.UserPrincipalName) $i Add-Lookup $bySam ([string]$user.SamAccountName) $i Add-Lookup $bySmtp ([string]$user.Mail) $i Add-Lookup $byPrimarySmtp ([string]$user.Mail) $i foreach ($address in Get-SmtpAddress -Address $user.ProxyAddresses) { Add-Lookup $bySmtp $address $i } foreach ($address in Get-SmtpAddress -Address $user.ProxyAddresses -PrimaryOnly) { Add-Lookup $byPrimarySmtp $address $i } Add-Lookup $byDisplay (Get-NormalizedName $user.DisplayName) $i if ($user.GivenName -and $user.Surname) { Add-Lookup $byFullName (Get-NormalizedName "$($user.GivenName) $($user.Surname)") $i } $strategies = if ($verified) { @($SourceAnchor.Strategy) } else { @('ObjectGuid', 'ConsistencyGuid') } foreach ($strategy in $strategies) { Add-Lookup $byAnchor (ConvertTo-NSPEntraImmutableId -ADUser $user -Strategy $strategy) $i } } $weights = @{ 'exact-upn' = 50; 'primary-smtp' = 40; 'proxy-address' = 30; 'upn-local-sam' = 15; 'display-name' = 5; 'given-surname' = 5 } $records = foreach ($cloud in $CloudUser) { $isGuest = [string]$cloud['userType'] -eq 'Guest' if ($isGuest -and -not $IncludeGuests) { continue } $immutableId = [string]$cloud['onPremisesImmutableId'] $synced = $cloud['onPremisesSyncEnabled'] -eq $true $upn = [string]$cloud['userPrincipalName'] $evidence = @{} $addEvidence = { param([hashtable]$Map, [string]$Key, [string]$Reason) if ([string]::IsNullOrWhiteSpace($Key) -or -not $Map.ContainsKey($Key)) { return } foreach ($index in $Map[$Key]) { if (-not $evidence.ContainsKey($index)) { $evidence[$index] = [System.Collections.Generic.List[string]]::new() } if (-not $evidence[$index].Contains($Reason)) { $evidence[$index].Add($Reason) } } } & $addEvidence $byUpn $upn 'exact-upn' & $addEvidence $byPrimarySmtp ([string]$cloud['mail']) 'primary-smtp' foreach ($address in Get-SmtpAddress -Address $cloud['proxyAddresses'] -PrimaryOnly) { & $addEvidence $byPrimarySmtp $address 'primary-smtp' } foreach ($address in Get-SmtpAddress -Address $cloud['proxyAddresses']) { & $addEvidence $bySmtp $address 'proxy-address' } & $addEvidence $bySam ($upn -split '@')[0] 'upn-local-sam' & $addEvidence $byDisplay (Get-NormalizedName $cloud['displayName']) 'display-name' if ($cloud['givenName'] -and $cloud['surname']) { & $addEvidence $byFullName (Get-NormalizedName "$($cloud['givenName']) $($cloud['surname'])") 'given-surname' } $linkedIndexes = @(if ($immutableId -and $byAnchor.ContainsKey($immutableId)) { $byAnchor[$immutableId] }) & $addEvidence $byAnchor $immutableId 'existing-anchor' $candidates = foreach ($index in $evidence.Keys) { $reasons = @($evidence[$index]) $strong = @($reasons | Where-Object { $_ -in @('exact-upn', 'primary-smtp', 'proxy-address') }) $confidence = if ($reasons -contains 'existing-anchor') { 'Linked' } elseif ($reasons -contains 'exact-upn' -and $strong.Count -ge 2) { 'High' } elseif ($strong.Count) { 'Medium' } else { 'Low' } $score = 0 foreach ($reason in $reasons) { $score += if ($reason -eq 'existing-anchor') { 100 } else { $weights[$reason] } } $user = $ADUser[$index] [pscustomobject]@{ ADIndex = $index ObjectGUID = $user.ObjectGUID ObjectSID = $user.ObjectSID SamAccountName = $user.SamAccountName UserPrincipalName = $user.UserPrincipalName DisplayName = $user.DisplayName DistinguishedName = $user.DistinguishedName DomainDNSRoot = $user.DomainDNSRoot Enabled = $user.Enabled PasswordLastSetUtc = if ($user.PasswordLastSetUtc) { $user.PasswordLastSetUtc } elseif ($user.PasswordLastSetState -eq 'Zero') { if ($user.PasswordMustChangeAtNextLogon -eq $true) { 'pwdLastSet=0; must change at next logon' } elseif ($user.PasswordMustChangeAtNextLogon -eq $false) { 'pwdLastSet=0; next-logon change not required' } else { 'pwdLastSet=0; next-logon requirement unknown' } } else { $null } DesiredImmutableId = if ($verified) { ConvertTo-NSPEntraImmutableId -ADUser $user -Strategy $SourceAnchor.Strategy } else { $null } Score = $score Confidence = $confidence Evidence = $reasons } } $candidates = @($candidates | Sort-Object @{ Expression = 'Score'; Descending = $true }, SamAccountName) $best = @(if ($candidates.Count) { $candidates | Where-Object Score -eq $candidates[0].Score }) $cloudState = if ($isGuest) { 'Guest' } elseif ($synced) { 'Synced' } elseif ($immutableId) { 'AnchorSet' } else { 'CloudOnly' } $status = if ($isGuest) { 'Excluded' } elseif ($linkedIndexes.Count -gt 1) { 'DuplicateAnchor' } elseif ($linkedIndexes.Count) { 'Linked' } elseif ($synced) { 'SyncedUnmatched' } elseif ($immutableId) { 'AnchorConflict' } elseif (-not $candidates.Count) { 'NotFound' } elseif ($best.Count -gt 1) { 'Ambiguous' } else { 'Suggested' } [pscustomobject]@{ PSTypeName = 'NSP.M365.UserPairing.Candidate' CloudLastPasswordChangeUtc = $cloud['lastPasswordChangeDateTime'] RoleReview = if ($PSCmdlet.ParameterSetName -eq 'Inventory' -and $Inventory.RoleState) { if ($Inventory.RoleState.RoleHolders.ContainsKey([string]$cloud['id'])) { 'ASSIGNED DIRECTORY ROLE - privileged account' } elseif ($Inventory.RoleState.EligibleHolders.ContainsKey([string]$cloud['id'])) { 'ELIGIBLE DIRECTORY ROLE - privileged account' } elseif (@($Inventory.RoleState.Blocks).Count) { 'UNKNOWN role eligibility - preflight blocked' } elseif (@($Inventory.RoleState.Warnings | Where-Object { $_ -like 'Role eligibility was not read*' })) { 'Eligibility not read - operator assumption; assigned roles checked' } else { 'No assigned or eligible directory role observed' } } else { 'UNKNOWN directory roles - not read; preflight required' } CloudObjectId = [string]$cloud['id'] CloudUserPrincipalName = $upn CloudDisplayName = [string]$cloud['displayName'] CloudAccountEnabled = $cloud['accountEnabled'] CloudState = $cloudState CloudImmutableId = $immutableId Status = $status AnchorBasis = $anchorBasis Candidates = $candidates Decision = 'Undecided' } } $records = @($records) # Conflicts only show across records. $holders = @{} foreach ($record in $records) { if (-not $record.CloudImmutableId) { continue } if (-not $holders.ContainsKey($record.CloudImmutableId)) { $holders[$record.CloudImmutableId] = 0 } $holders[$record.CloudImmutableId]++ } $linkedAD = @{} $claims = @{} foreach ($record in $records) { if ($record.CloudImmutableId -and $holders[$record.CloudImmutableId] -gt 1) { $record.Status = 'DuplicateAnchor'; continue } if ($record.Status -eq 'Linked') { foreach ($c in $record.Candidates) { if ($c.Confidence -eq 'Linked') { $linkedAD[$c.ADIndex] = $true } } } if ($record.Status -eq 'Suggested') { $top = $record.Candidates[0].ADIndex if (-not $claims.ContainsKey($top)) { $claims[$top] = 0 } $claims[$top]++ } } foreach ($record in $records) { if ($record.Status -ne 'Suggested') { continue } $top = $record.Candidates[0].ADIndex if ($linkedAD.ContainsKey($top)) { $record.Status = 'LinkedElsewhere' } elseif ($claims[$top] -gt 1) { $record.Status = 'OneToMany' } } $records } |