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