Public/Set-NSPADUserAddress.ps1
|
function Set-NSPADUserAddress { <# .SYNOPSIS Previews address plans; explicitly applies reviewed plans with forest refresh and read-back. .DESCRIPTION Preview is the default. Apply requires RSAT and reads the seed forest again, regenerating every proposal and checking GUID, SID, domain, server, source values and collisions before the first write. One policy per batch. Each user is confirmed, read again on the pinned server, written by GUID and read back. No sync cycle is started. Results can contain PII; store them outside source control. This is not a transaction; retain every result. .EXAMPLE Set-NSPADUserAddress -Plan $plan .EXAMPLE Set-NSPADUserAddress -Plan $plan -Apply #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType('NSP.ActiveDirectory.UserAddressResult')] param( [Parameter(Mandatory)][ValidateNotNullOrEmpty()][object[]]$Plan, [switch]$Apply ) $ids = @{} foreach ($entry in $Plan) { $id = [guid]$entry.ObjectGUID if ($id -eq [guid]::Empty -or $ids.ContainsKey($id.ToString())) { throw 'Empty or duplicate plan GUID.' } $ids[$id.ToString()] = $true if ($entry.Blocked) { throw 'A plan is blocked; resolve its reasons and regenerate the batch.' } } if (-not $Apply -or $WhatIfPreference) { foreach ($entry in $Plan) { if ($Apply) { [void]$PSCmdlet.ShouldProcess($entry.ObjectGUID, 'Apply user address plan') } [pscustomobject]@{ PSTypeName='NSP.ActiveDirectory.UserAddressResult'; ObjectGUID=$entry.ObjectGUID; Result='Preview'; Plan=$entry } } return } Assert-NSPADCommand -Name Set-ADUser $policyKey = ConvertTo-Json -InputObject $Plan[0].Policy -Depth 4 -Compress foreach ($entry in $Plan) { if ((ConvertTo-Json -InputObject $entry.Policy -Depth 4 -Compress) -cne $policyKey) { throw 'Use one policy per batch.' } } $options = @{} foreach ($property in $Plan[0].Policy.PSObject.Properties) { $options[$property.Name] = $property.Value } # A terminating forest read invalidates the entire inventory; no writes have begun. $inventory = @(Get-NSPADUser -All -Forest -Server $Plan[0].Server -ErrorAction Stop) $fresh = @(Get-NSPADUserAddressPlan -User $inventory -Identity @($Plan | ForEach-Object { [guid]$_.ObjectGUID }) @options) $currentPlans = @{} foreach ($entry in $fresh) { $currentPlans[$entry.ObjectGUID.ToString()] = $entry } foreach ($entry in $Plan) { $current = $currentPlans[([guid]$entry.ObjectGUID).ToString()] if ($null -eq $current -or $current.Blocked -or $entry.ObjectSID -cne $current.ObjectSID -or $entry.DomainDNSRoot -ine $current.DomainDNSRoot -or $entry.Server -ine $current.Server -or (ConvertTo-NSPADAddressStateKey $entry.Before) -cne (ConvertTo-NSPADAddressStateKey $current.Before) -or (ConvertTo-NSPADAddressStateKey $entry.After) -cne (ConvertTo-NSPADAddressStateKey $current.After)) { throw 'Plan is stale, modified or blocked by the refreshed inventory; regenerate and review it.' } } $upnChanges = @($fresh | Where-Object { $_.Before.UserPrincipalName -cne $_.After.UserPrincipalName }) if ($upnChanges.Count) { $forestInfo = Get-NSPADForestInfo -Server $Plan[0].Server -ErrorAction Stop foreach ($change in $upnChanges) { $suffix = $change.After.UserPrincipalName.Split('@')[-1] if ($forestInfo.AvailableUPNSuffixes -inotcontains $suffix) { throw 'Target UPN suffix is unavailable in the forest. Separately plan and explicitly add it, then refresh and review the user plan.' } } } foreach ($entry in $Plan) { $result = 'Unchanged' $message = '' $observed = $null $current = $currentPlans[([guid]$entry.ObjectGUID).ToString()] if ($current.Changed) { if ($PSCmdlet.ShouldProcess("$($entry.ObjectGUID) on $($entry.Server)", 'Apply reviewed UPN, mail, proxy and account-name changes')) { $writeStarted = $false try { $before = Read-NSPADAddressState -ObjectGUID $entry.ObjectGUID -Server $entry.Server if ($before.ObjectSID -cne $entry.ObjectSID -or (ConvertTo-NSPADAddressStateKey $before.State) -cne (ConvertTo-NSPADAddressStateKey $entry.Before)) { throw 'User changed after preflight; regenerate the stale plan.' } $replace = @{} $clear = @() foreach ($name in 'SamAccountName','UserPrincipalName','Mail') { if ($current.Before.$name -cne $current.After.$name) { if ($current.After.$name) { $replace[$name.ToLowerInvariant()] = $current.After.$name } else { $clear += $name.ToLowerInvariant() } } } $oldProxies = ConvertTo-Json -InputObject @($current.Before.ProxyAddresses | Sort-Object -CaseSensitive) -Compress $newProxies = ConvertTo-Json -InputObject @($current.After.ProxyAddresses | Sort-Object -CaseSensitive) -Compress if ($oldProxies -cne $newProxies) { if ($current.After.ProxyAddresses.Count) { $replace.proxyaddresses = [string[]]$current.After.ProxyAddresses } else { $clear += 'proxyaddresses' } } $write = @{ Identity=[guid]$entry.ObjectGUID; Server=$entry.Server; ErrorAction='Stop' } if ($replace.Count) { $write.Replace = $replace } if ($clear.Count) { $write.Clear = $clear } $writeStarted = $true Set-ADUser @write $observed = Read-NSPADAddressState -ObjectGUID $entry.ObjectGUID -Server $entry.Server if ($observed.ObjectSID -ceq $entry.ObjectSID -and (ConvertTo-NSPADAddressStateKey $observed.State) -ceq (ConvertTo-NSPADAddressStateKey $current.After)) { $result = 'Verified' } else { $result = 'VerificationFailed'; $message = 'Read-back differs; inspect this user before retrying.' } } catch { $result = if ($writeStarted) { 'WriteUnverified' } else { 'Failed' } $message = $_.Exception.Message } } else { $result = 'Skipped' } } [pscustomobject]@{ PSTypeName='NSP.ActiveDirectory.UserAddressResult'; ObjectGUID=$entry.ObjectGUID Server=$entry.Server; Result=$result; Message=$message Before=$entry.Before; After=$current.After; Observed=$observed TimestampUtc=[datetime]::UtcNow } # Stop the batch after any uncertain outcome. Already emitted results remain available. if ($result -in 'Failed','WriteUnverified','VerificationFailed') { return } } } |