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

        $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 {
                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') }
    }

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