Private/Test-NSPEntraHardMatchPair.ps1
|
function Test-NSPEntraHardMatchPair { <# .SYNOPSIS Judges one selected pair against live cloud state. Reads nothing itself. .DESCRIPTION Outcomes: Ready, AlreadySet (the cloud user holds the desired value and is waiting for sync), Linked (it holds it and is synced), or Blocked, with reasons. Warnings never block. A privileged account has no override here. Entra refuses that match itself, and Microsoft's documented route is to remove the role or eligibility first, which this module does not do. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Pair, [Parameter(Mandatory)]$Queue, [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$QueueBlock, [Parameter(Mandatory)]$TenantState, [AllowNull()]$CloudUser, [AllowEmptyCollection()][string[]]$OtherAnchorHolder = @(), [ValidateSet('Set', 'Clear', 'Unknown')][string]$ObjectIdentifier = 'Unknown' ) $blocks = [System.Collections.Generic.List[string]]::new() $warnings = [System.Collections.Generic.List[string]]::new() foreach ($reason in @($QueueBlock) + @($TenantState.Blocks)) { if ($reason) { $blocks.Add($reason) } } foreach ($reason in @($TenantState.Warnings)) { if ($reason) { $warnings.Add($reason) } } $id = [string]$Pair.cloudObjectId $desired = [string]$Pair.desiredImmutableId $current = $null $upn = [string]$Pair.cloudUpnAtReview $outcome = $null if ($null -eq $CloudUser) { $blocks.Add('The cloud user no longer exists, or is soft-deleted.') } else { $current = $CloudUser['onPremisesImmutableId'] $upn = [string]$CloudUser['userPrincipalName'] $synced = $CloudUser['onPremisesSyncEnabled'] if ($desired -and [string]$current -ceq $desired) { $outcome = if ($synced -eq $true) { 'Linked' } else { 'AlreadySet' } } else { # False is not the same as null: false means it was synced once. if ($null -ne $synced) { $blocks.Add('The cloud user is, or has been, synced. Graph must not change it.') } if ($current) { $blocks.Add('The cloud user already has a different immutable ID.') } if ([string]$CloudUser['userType'] -ne 'Member') { $blocks.Add('The cloud user is a guest.') } } } if (-not $outcome) { if ($null -eq $Pair.revalidation -or $null -eq $Pair.revalidation.PSObject.Properties['adRecipientTypeDetails']) { $blocks.Add('AD recipient metadata has not been revalidated. Run Sync-NSPEntraPairingQueue against a fresh inventory from NSP.ActiveDirectory 0.1.12 or later.') } elseif ($null -eq $Pair.revalidation.adRecipientTypeDetails) { $warnings.Add('AD recipient type is unknown. No recipient classification is inferred, and the source-anchor rule exclusion for msExchRecipientTypeDetails 2 could not be checked.') } elseif ($Pair.revalidation.adRecipientTypeDetails -eq 2) { $blocks.Add('The AD user has msExchRecipientTypeDetails 2. Recognized stock source-anchor rules can exclude this user; resolve the effective sync flow before hard match.') } if ($Pair.revalidation.state -ne 'Current') { $blocks.Add('The pair is not Current in the last revalidation.') } elseif (-not $Pair.decidedAtUtc -or (ConvertTo-NSPEntraPairingUtcTime $Pair.revalidation.checkedAtUtc) -lt (ConvertTo-NSPEntraPairingUtcTime $Pair.decidedAtUtc)) { $blocks.Add('The pair was decided after the last revalidation.') } if (-not $desired) { $blocks.Add('The pair has no desired immutable ID.') } else { $bytes = $null try { $bytes = [Convert]::FromBase64String($desired) } catch { $bytes = $null } if ($null -eq $bytes -or $bytes.Length -ne 16) { $blocks.Add('The desired immutable ID is not 16 bytes of Base64.') } if ($TenantState.DeletedAnchors.ContainsKey($desired)) { $blocks.Add("Soft-deleted user $($TenantState.DeletedAnchors[$desired]) holds this anchor.") } } if ($Pair.adObjectGuid -in @($Queue.linkedAdObjectGuids)) { $blocks.Add('The AD user is already linked to a cloud account.') } $others = @($Queue.pairs | Where-Object { $_.decision -eq 'selected' -and $_.cloudObjectId -ne $id -and ($_.adObjectGuid -eq $Pair.adObjectGuid -or ($desired -and $_.desiredImmutableId -ceq $desired)) }) if ($others.Count) { $blocks.Add("One AD user or anchor is selected for several cloud users, including $($others[0].cloudObjectId).") } $holders = @($OtherAnchorHolder | Where-Object { $_ -and $_ -ne $id }) if ($holders.Count) { $blocks.Add("Cloud user $($holders[0]) already holds this anchor.") } if ($TenantState.RoleHolders.ContainsKey($id)) { $blocks.Add('The cloud user holds a directory role. Entra refuses this match; the role has to come off first.') } if ($TenantState.EligibleHolders.ContainsKey($id)) { $blocks.Add('The cloud user is eligible for a directory role. Entra refuses this match; the eligibility has to come off first.') } if ($ObjectIdentifier -eq 'Set') { $blocks.Add('The cloud user already has onPremisesObjectIdentifier set, so Entra refuses this match until it is cleared.') } if ($ObjectIdentifier -eq 'Unknown' -and $null -ne $CloudUser) { $warnings.Add('onPremisesObjectIdentifier could not be read. If it is set, Entra refuses the match at sync.') } if ($Pair.adEnabledAtReview -eq $false) { $warnings.Add('The AD user is disabled. After sync, the cloud account is too.') } if ($Pair.adUserPrincipalNameAtReview -and [string]$Pair.adUserPrincipalNameAtReview -ne $upn) { $warnings.Add("The cloud UPN becomes AD's $($Pair.adUserPrincipalNameAtReview) at sync.") } $outcome = if ($blocks.Count) { 'Blocked' } else { 'Ready' } } [pscustomobject]@{ PSTypeName = 'NSP.M365.UserPairing.HardMatchCheck' CloudObjectId = $id CloudUserPrincipalName = $upn AdObjectGuid = [string]$Pair.adObjectGuid AdDistinguishedName = [string]$Pair.adDistinguishedNameAtReview CurrentImmutableId = $current DesiredImmutableId = $Pair.desiredImmutableId Outcome = $outcome Blocks = @($(if ($outcome -eq 'Blocked') { $blocks })) Warnings = @($warnings) } } |