Public/Set-NSPEntraUserHardMatch.ps1

function Set-NSPEntraUserHardMatch {
    <#
    .SYNOPSIS
        Previews hard matches by default. With -Apply, sets each confirmed user's immutable ID.
    .DESCRIPTION
        Without -Apply this is a plan: it changes nothing, not even the queue. -WhatIf does the same.
 
        With -Apply, the whole queue is preflighted, then each Ready pair is shown and needs its own
        yes. There is no way to skip that answer, and a host that cannot ask stops the run. Just
        before each write the user and the anchor are read again and judged again. The queue is saved
        before the write and after it, and an append-only audit file sits beside the queue, so a stop
        at any point loses nothing and is never replayed blindly: the next run looks at what the
        cloud user holds. A failed PATCH is not retried, because its outcome may be unknown.
 
        Read-back reports applied, unconfirmed (still reads as before, which may be lag), different
        or unknown. Reads lag writes, so unconfirmed is not a failure.
 
        This command only stages the match. The link happens at the next sync, when Entra compares
        the AD user's anchor, and Entra can still refuse it there. At that point cloud attributes
        with an AD value are overwritten from AD, and password hash sync replaces the password. Run
        Sync-NSPEntraPairingQueue afterwards: only then does a pair read linked.
 
        Needs a session from Connect-NSPEntraPairingGraph -Purpose HardMatch. -CloudObjectId limits
        the run to named users, which is how a first run should go.
    .EXAMPLE
        Set-NSPEntraUserHardMatch -Queue $queue
    .EXAMPLE
        Set-NSPEntraUserHardMatch -Queue $queue -Path $path -CloudObjectId $id -Apply
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('NSP.M365.UserPairing.HardMatchResult')]
    param(
        [Parameter(Mandatory)]$Queue,
        [string]$Path,
        [string[]]$CloudObjectId,
        [switch]$Apply,
        [ValidateRange(1, 1440)][int]$MaxRevalidationAgeMinutes = 60,
        [switch]$AssumeNoRoleEligibility
    )

    $writing = $Apply -and -not $WhatIfPreference
    if ($writing -and -not $Path) { throw '-Apply needs -Path, so the queue is saved around every write.' }

    Import-NSPM365Graph
    if ($writing) {
        Import-NSPToolkitModule -Name 'NSP.Console' -MinimumVersion '0.1.1'
        Assert-NSPEntraPairingStatePath -Path $Path
        Assert-NSPEntraPairingTenant -ExpectedTenantId $Queue.tenantId | Out-Null
        Assert-NSPEntraPairingSession -Purpose HardMatch -TenantId $Queue.tenantId
    }

    $preflight = Test-NSPEntraUserHardMatch -Queue $Queue -MaxRevalidationAgeMinutes $MaxRevalidationAgeMinutes -AssumeNoRoleEligibility:$AssumeNoRoleEligibility
    $targets = @($preflight.Items)
    if ($PSBoundParameters.ContainsKey('CloudObjectId')) {
        $unknown = @($CloudObjectId | Where-Object { $_ -notin @($targets | ForEach-Object CloudObjectId) })
        if ($unknown.Count) { throw "Not selected in this queue: $($unknown -join ', '). Nothing was changed." }
        $targets = @($targets | Where-Object CloudObjectId -in $CloudObjectId)
    }

    $newResult = {
        param($Item, [string]$Result, [string]$Detail)
        [pscustomobject]@{
            PSTypeName = 'NSP.M365.UserPairing.HardMatchResult'
            CloudObjectId = $Item.CloudObjectId
            CloudUserPrincipalName = $Item.CloudUserPrincipalName
            AdObjectGuid = $Item.AdObjectGuid
            AdDistinguishedName = $Item.AdDistinguishedName
            Before = $Item.CurrentImmutableId
            Desired = $Item.DesiredImmutableId
            Result = $Result
            Detail = $Detail
            Blocks = @($Item.Blocks)
            Warnings = @($Item.Warnings)
        }
    }

    if (-not $writing) {
        foreach ($item in $targets) {
            $result = if ($item.Outcome -eq 'Ready') { 'WouldSet' } else { $item.Outcome }
            & $newResult $item $result ''
        }
        return
    }

    $auditPath = "$Path.audit.jsonl"
    $delays = @{}
    if ($null -ne $script:HardMatchReadBackDelaySeconds) { $delays.DelaySeconds = @($script:HardMatchReadBackDelaySeconds) }

    foreach ($item in $targets) {
        $pair = @($Queue.pairs | Where-Object cloudObjectId -eq $item.CloudObjectId)[0]

        if ($item.Outcome -in @('AlreadySet', 'Linked')) {
            $pair.executionState = if ($item.Outcome -eq 'Linked') { 'linked' } else { 'applied' }
            Export-NSPEntraPairingQueue -Queue $Queue -Path $Path | Out-Null
            & $newResult $item $item.Outcome 'Nothing to write.'
            continue
        }
        if ($item.Outcome -ne 'Ready') {
            & $newResult $item 'Blocked' ''
            continue
        }

        Write-NSPConsoleRule
        Write-NSPConsoleLine -Message "Cloud user: $($item.CloudUserPrincipalName) ($($item.CloudObjectId))"
        Write-NSPConsoleLine -Message "AD user: $($item.AdDistinguishedName)"
        Write-NSPConsoleLine -Message "Immutable ID: (none) -> $($item.DesiredImmutableId)"
        Write-NSPConsoleLine -Message 'At the next sync, cloud attributes with an AD value are overwritten, and password hash sync replaces the password.' -Role Warning
        foreach ($warning in @($item.Warnings)) { Write-NSPConsoleLine -Message $warning -Role Warning }
        if (-not (Read-NSPConfirm -Prompt 'Set this immutable ID?')) {
            & $newResult $item 'Declined' ''
            continue
        }
        if (-not $PSCmdlet.ShouldProcess($item.CloudUserPrincipalName, 'Set onPremisesImmutableId')) {
            & $newResult $item 'Declined' ''
            continue
        }

        # Time has passed since the preflight, so judge this user again on fresh reads.
        Assert-NSPEntraPairingTenant -ExpectedTenantId $Queue.tenantId | Out-Null
        $desired = [string]$pair.desiredImmutableId
        $filter = [uri]::EscapeDataString("onPremisesImmutableId eq '$desired'")
        $check = @{
            Pair = $pair
            Queue = $Queue
            QueueBlock = @(Get-NSPEntraHardMatchQueueBlock -Queue $Queue -MaxRevalidationAgeMinutes $MaxRevalidationAgeMinutes)
            TenantState = $preflight.TenantState
            CloudUser = Read-NSPEntraHardMatchUser -CloudObjectId $item.CloudObjectId
            OtherAnchorHolder = @(@(Invoke-NSPGraphCollection -Uri "users?`$filter=$filter&`$select=id") | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_['id'] })
            ObjectIdentifier = Get-NSPEntraObjectIdentifierState -CloudObjectId $item.CloudObjectId
        }
        $fresh = Test-NSPEntraHardMatchPair @check
        if ($fresh.Outcome -ne 'Ready') {
            & $newResult $fresh $fresh.Outcome 'Changed since the preflight. Nothing was written.'
            continue
        }

        # The intent reaches disk first. If either save fails, nothing is sent.
        $before = $fresh.CurrentImmutableId
        $audit = @{ tenantId = [string]$Queue.tenantId; cloudObjectId = $fresh.CloudObjectId; adObjectGuid = $fresh.AdObjectGuid; adSid = [string]$pair.adSid; before = $before; desired = $desired }
        Write-NSPEntraHardMatchAudit -Path $auditPath -Entry ($audit + @{ event = 'intent' })
        $pair.executionState = 'writing'
        Export-NSPEntraPairingQueue -Queue $Queue -Path $Path | Out-Null

        $writeError = $null
        try {
            Invoke-NSPGraphRequest -Method PATCH -Uri "users/$([uri]::EscapeDataString($fresh.CloudObjectId))" -Body @{ onPremisesImmutableId = $desired } | Out-Null
        } catch { $writeError = $_.Exception.Message }

        # After a failed PATCH one read is enough: it says what the cloud user holds now.
        $readDelays = if ($writeError) { @{ DelaySeconds = @() } } else { $delays }
        $read = Wait-NSPEntraHardMatchReadBack -CloudObjectId $fresh.CloudObjectId -Desired $desired -Before $before @readDelays
        $state = $read.State
        if ($writeError -and $state -eq 'unconfirmed') { $state = 'failed' }

        $pair.executionState = $state
        $pair.execution = [pscustomobject]@{ state = $state; before = $before; desired = $desired; readBack = $read.Value; error = $writeError; atUtc = [datetime]::UtcNow.ToString('o'); by = [Environment]::UserName }
        Write-NSPEntraHardMatchAudit -Path $auditPath -Entry ($audit + @{ event = 'result'; state = $state; readBack = $read.Value; error = $writeError })
        Export-NSPEntraPairingQueue -Queue $Queue -Path $Path | Out-Null

        & $newResult $fresh $state $writeError
    }
}