Public/Sync-NSPEntraPairingQueue.ps1
|
function Sync-NSPEntraPairingQueue { <# .SYNOPSIS Revalidates a queue against a fresh inventory, so stale choices are never replayed. Offline. .DESCRIPTION Refuses another tenant, or an inventory that shares no domain with the queue. Each pair gets a revalidation of Current, Changed or Gone, checked by stable IDs: the Graph object ID, and the AD objectGUID with its SID. A selected pair that is not Current becomes needs-review, and the old choice moves to its history. A moved distinguished name or a changed display name is noted without a state change, because neither identifies anyone. Selected pairs also need the verified anchor they were reviewed under. If the anchor is now Unverified or its strategy changed, they go back to needs-review. Candidate anchors are refreshed, so a queue reviewed before the anchor was verified can then be decided again. A pair whose write was attempted is judged by what the cloud user now holds. Holding the desired immutable ID is the expected result, not a change: such a pair reads applied, and linked once sync has taken it over. Otherwise it goes back to needs-review and pending. .EXAMPLE $queue = Sync-NSPEntraPairingQueue -Queue $queue -Inventory $inventory -SourceAnchor $anchor #> [CmdletBinding()] [OutputType('NSP.M365.UserPairing.Queue')] param( [Parameter(Mandatory)]$Queue, [Parameter(Mandatory)]$Inventory, [Parameter(Mandatory)]$SourceAnchor ) if ([string]$Inventory.TenantId -ne [string]$Queue.tenantId) { throw "The inventory is from tenant $($Inventory.TenantId), not the queue's $($Queue.tenantId). Nothing was changed." } if (-not @($Inventory.Domains | Where-Object { $_ -in @($Queue.domains) }).Count) { throw 'The inventory shares no domain with the queue. Nothing was changed.' } $anchorVerified = $SourceAnchor.Provenance -eq 'Verified' -and $SourceAnchor.Strategy -ne 'Unknown' $anchorHeld = $anchorVerified -and $Queue.sourceAnchor.status -eq 'verified' -and [string]$SourceAnchor.Strategy -eq [string]$Queue.sourceAnchor.strategy $cloudById = @{} foreach ($user in @($Inventory.CloudUsers)) { $cloudById[[string]$user['id']] = $user } $adByGuid = @{} foreach ($user in @($Inventory.ADUsers)) { $adByGuid[[string]$user.ObjectGUID] = $user } $freshRecords = @{} foreach ($record in @(Get-NSPEntraUserPairCandidate -Inventory $Inventory -SourceAnchor $SourceAnchor -IncludeGuests)) { $freshRecords[[string]$record.CloudObjectId] = $record } $freshQueue = ConvertTo-NSPEntraPairingQueue -Candidate @($freshRecords.Values) -TenantId $Queue.tenantId -Forest $Queue.forest -Domain $Queue.domains -SourceAnchor $SourceAnchor $Queue.linkedAdObjectGuids = @($freshQueue.linkedAdObjectGuids) $snapshots = @{} foreach ($snapshot in @($freshQueue.pairs)) { $snapshots[[string]$snapshot.cloudObjectId] = $snapshot } foreach ($pair in @($Queue.pairs)) { $record = $freshRecords[[string]$pair.cloudObjectId] if ($record) { $pair.candidates = @($snapshots[[string]$pair.cloudObjectId].candidates) $pair.status = [string]$record.Status $pair | Add-Member -NotePropertyName cloudLastPasswordChangeUtc -NotePropertyValue $record.CloudLastPasswordChangeUtc -Force $pair | Add-Member -NotePropertyName roleReview -NotePropertyValue $record.RoleReview -Force } $gone = [System.Collections.Generic.List[string]]::new() $changed = [System.Collections.Generic.List[string]]::new() $notes = [System.Collections.Generic.List[string]]::new() $written = $false $recipientType = $null $cloud = $cloudById[[string]$pair.cloudObjectId] if ($null -eq $cloud) { $gone.Add('The cloud user no longer exists.') } else { $written = $pair.executionState -ne 'pending' -and $pair.desiredImmutableId -and [string]$cloud['onPremisesImmutableId'] -ceq [string]$pair.desiredImmutableId if ($written) { # Sync overwrites the labels, so they prove nothing here. if ($cloud['onPremisesSyncEnabled'] -eq $true) { $pair.executionState = 'linked' $notes.Add('Linked: the cloud user holds the anchor and is synced.') } else { $pair.executionState = 'applied' $notes.Add('The immutable ID is set; waiting for sync.') } } else { if ([string]$cloud['userPrincipalName'] -ne [string]$pair.cloudUpnAtReview) { $changed.Add('The cloud UPN changed; check that it is still the same person.') } if ([string]$cloud['onPremisesImmutableId'] -ne [string]$pair.cloudImmutableIdAtReview) { $changed.Add('The cloud immutable ID changed.') } if ($cloud['onPremisesSyncEnabled'] -eq $true -and $pair.cloudStateAtReview -ne 'Synced') { $changed.Add('The cloud user is now synced.') } } if ([string]$cloud['displayName'] -ne [string]$pair.cloudDisplayNameAtReview) { $notes.Add('The cloud display name changed.') } $pair.cloudUpnAtReview = [string]$cloud['userPrincipalName'] $pair.cloudDisplayNameAtReview = [string]$cloud['displayName'] } if ($pair.adObjectGuid) { $ad = $adByGuid[[string]$pair.adObjectGuid] if ($null -eq $ad) { $gone.Add('The AD user no longer exists under this objectGUID.') } else { # Optional public AD metadata; null remains unknown, never a recipient category. $rawRecipientType = $ad.MsExchRecipientTypeDetails if ($null -ne $rawRecipientType) { $parsedRecipientType = 0L if ([long]::TryParse([string]$rawRecipientType, [ref]$parsedRecipientType)) { $recipientType = $parsedRecipientType } } if ([string]$ad.ObjectSID -ne [string]$pair.adSid) { $changed.Add('The AD SID does not match the review.') } if ([string]$ad.DistinguishedName -ne [string]$pair.adDistinguishedNameAtReview) { $notes.Add('The AD user moved.') } if ([string]$ad.UserPrincipalName -ne [string]$pair.adUserPrincipalNameAtReview) { $changed.Add('The AD UPN changed; review the prepared addresses before hard match.') } $pair.adUserPrincipalNameAtReview = [string]$ad.UserPrincipalName $pair.adEnabledAtReview = [bool]$ad.Enabled $anchorChange = $null if (-not $anchorHeld) { $anchorChange = 'The source anchor is no longer the verified one from the review.' } elseif ([string](ConvertTo-NSPEntraImmutableId -ADUser $ad -Strategy $SourceAnchor.Strategy) -ne [string]$pair.desiredImmutableId) { $anchorChange = 'The AD user''s anchor value changed.' } if ($anchorChange) { if ($written) { $notes.Add($anchorChange) } else { $changed.Add($anchorChange) } } } } foreach ($candidate in @($pair.candidates)) { $fresh = $adByGuid[[string]$candidate.adObjectGuid] $candidate.desiredImmutableId = if ($anchorVerified -and $null -ne $fresh) { ConvertTo-NSPEntraImmutableId -ADUser $fresh -Strategy $SourceAnchor.Strategy } else { $null } } $state = if ($gone.Count) { 'Gone' } elseif ($changed.Count) { 'Changed' } else { 'Current' } if ($pair.decision -eq 'selected' -and $state -ne 'Current') { $pair.history = @($pair.history) + [pscustomobject]@{ decision = 'selected'; adObjectGuid = $pair.adObjectGuid; decidedAtUtc = $pair.decidedAtUtc; decidedBy = $pair.decidedBy; note = $pair.note } $pair.decision = 'needs-review' $pair.desiredImmutableId = $null $pair.executionState = 'pending' } $pair.revalidation = [pscustomobject]@{ state = $state; reasons = @($gone) + @($changed); notes = @($notes); checkedAtUtc = [datetime]::UtcNow.ToString('o'); adRecipientTypeDetails = $recipientType } } $Queue.sourceAnchor.strategy = [string]$SourceAnchor.Strategy $Queue.sourceAnchor.status = if ($anchorVerified) { 'verified' } else { 'unverified' } $Queue.sourceAnchor.syncServer = [string]$SourceAnchor.ComputerName $Queue.sourceAnchor.collectedAtUtc = [string]$SourceAnchor.CollectedAtUtc $Queue.revalidatedAtUtc = [datetime]::UtcNow.ToString('o') $Queue.needsRevalidation = $false $Queue } |