Public/Get-NSPADUserAddressPlan.ps1
|
function Get-NSPADUserAddressPlan { <# .SYNOPSIS Plans UPN, mail, primary SMTP, alias and optional sAMAccountName migration without directory access. .DESCRIPTION Supply a complete forest inventory of NSP.ActiveDirectory.User objects for collision review. Identity selects GUIDs to change; other users remain collision candidates. The input is never mutated. Existing aliases and X400/X500 addresses are preserved unless explicitly removed. This plan does not verify UPN suffix configuration, Exchange policy or directory permissions. .EXAMPLE $plan = Get-NSPADUserAddressPlan -User $inventory -OldDomain old.example -NewDomain new.example -Format First.Last #> [CmdletBinding()] [OutputType('NSP.ActiveDirectory.UserAddressPlan')] param( [Parameter(Mandatory)][ValidateNotNullOrEmpty()][object[]]$User, [guid[]]$Identity, [guid[]]$SkipIdentity = @(), [Parameter(Mandatory)][ValidateScript({ Test-NSPADAddressDomain $_ })][string]$OldDomain, [Parameter(Mandatory)][ValidateScript({ Test-NSPADAddressDomain $_ })][string]$NewDomain, [ValidateSet('ReplaceSuffix','SamAccountName','FLast','First.Last','LastF')][string]$Format = 'ReplaceSuffix', [switch]$ChangeSamAccountName, [switch]$CreateMailIfEmpty, [bool]$UpdateUPN = $true, [bool]$UpdateMail = $true, [bool]$UpdatePrimarySmtp = $true, [bool]$PreserveOldAddress = $true, [switch]$ConvertAliasDomain, [string[]]$RemoveAliasDomain = @(), [ValidateSet('X400','X500')][string[]]$RemoveAddressType = @() ) foreach ($domain in $RemoveAliasDomain) { if (-not (Test-NSPADAddressDomain $domain)) { throw 'RemoveAliasDomain must contain DNS domains without @.' } if ($domain -ieq $NewDomain) { throw 'Cannot remove aliases from the target domain.' } } if ($OldDomain -ieq $NewDomain) { throw 'OldDomain and NewDomain must differ.' } $byId = @{} foreach ($item in $User) { $id = [guid]$item.ObjectGUID if ($id -eq [guid]::Empty -or -not $item.ObjectSID -or -not $item.DomainDNSRoot -or -not $item.Server) { throw 'Inventory requires nonempty GUID, SID, domain and server provenance.' } if ($byId.ContainsKey($id.ToString())) { throw 'Duplicate GUID in inventory.' } $byId[$id.ToString()] = $item } foreach ($id in @(@($Identity) + @($SkipIdentity) | Where-Object { $null -ne $_ })) { if (-not $byId.ContainsKey($id.ToString())) { throw 'Selected or skipped GUID is absent from inventory.' } } $policy = [ordered]@{ OldDomain = $OldDomain; NewDomain = $NewDomain; Format = $Format ChangeSamAccountName = [bool]$ChangeSamAccountName; CreateMailIfEmpty = [bool]$CreateMailIfEmpty UpdateUPN = $UpdateUPN; UpdateMail = $UpdateMail; UpdatePrimarySmtp = $UpdatePrimarySmtp PreserveOldAddress = $PreserveOldAddress; ConvertAliasDomain = [bool]$ConvertAliasDomain RemoveAliasDomain = [string[]]$RemoveAliasDomain; RemoveAddressType = [string[]]$RemoveAddressType } $plans = @{} foreach ($item in $User) { $id = [guid]$item.ObjectGUID if (($PSBoundParameters.ContainsKey('Identity') -and $id -notin $Identity) -or $id -in $SkipIdentity) { continue } $before = Get-NSPADAddressState $item $after = Get-NSPADAddressState $item $reasons = [Collections.Generic.List[string]]::new() $local = '' if ($Format -eq 'ReplaceSuffix') { $parts = $before.UserPrincipalName.Split('@') if ($parts.Count -ne 2 -or ($parts[1] -ine $OldDomain -and $parts[1] -ine $NewDomain)) { $reasons.Add('UPN has an unexpected suffix or is missing.') } else { $local = $parts[0] } } elseif ($Format -eq 'SamAccountName') { $local = $before.SamAccountName } elseif ([string]::IsNullOrWhiteSpace($item.GivenName) -or [string]::IsNullOrWhiteSpace($item.Surname)) { $reasons.Add('Name generation requires both given name and surname.') } else { $local = switch ($Format) { FLast { $item.GivenName.Substring(0,1) + $item.Surname } 'First.Last' { $item.GivenName + '.' + $item.Surname } LastF { $item.Surname + $item.GivenName.Substring(0,1) } } } $target = ($local + '@' + $NewDomain).ToLowerInvariant() if (-not (Test-NSPADSimpleMailAddress $target)) { $reasons.Add('Generated address requires manual review; no automatic transliteration or truncation.') } if ($reasons.Count -eq 0) { if ($UpdateUPN) { $after.UserPrincipalName = $target } if ($ChangeSamAccountName) { if ($local.Length -gt 20 -or $local -match '["/\\\[\]:;|=,+*?<>\s]' -or $local.EndsWith('.')) { $reasons.Add('Generated sAMAccountName is invalid or exceeds twenty characters.') } else { $after.SamAccountName = $local.ToLowerInvariant() } } if (-not $UpdateMail) { # Deliberate preservation is independent of the UPN and primary SMTP choices. } elseif (-not $before.Mail) { if ($CreateMailIfEmpty) { $after.Mail = $target } } elseif ($before.Mail -ieq $target -or $before.Mail.EndsWith('@' + $OldDomain, [StringComparison]::OrdinalIgnoreCase)) { $after.Mail = $target } else { $reasons.Add('Mail is outside the old domain and differs from the target.') } $primaries = @($before.ProxyAddresses | Where-Object { $_.StartsWith('SMTP:', [StringComparison]::Ordinal) }) if ($primaries.Count -gt 1) { $reasons.Add('Multiple primary SMTP addresses require review.') } foreach ($primary in $primaries) { if ($primary.Substring(5) -ine $target -and -not $primary.EndsWith('@' + $OldDomain, [StringComparison]::OrdinalIgnoreCase)) { $reasons.Add('Primary SMTP is outside the old domain and differs from the target.') } } $proxies = [Collections.Generic.List[string]]::new() if ($UpdatePrimarySmtp) { $proxies.Add('SMTP:' + $target) } else { foreach ($primary in $primaries) { $proxies.Add($primary) } } foreach ($proxy in $before.ProxyAddresses) { if ($proxy -imatch '^smtp:') { $address = $proxy.Substring(5) if (-not (Test-NSPADSimpleMailAddress $address)) { $reasons.Add('An existing SMTP proxy requires manual review.'); continue } if (($UpdatePrimarySmtp -and $address -ieq $target) -or (-not $UpdatePrimarySmtp -and $proxy -cin $primaries)) { continue } $domain = $address.Split('@')[1] if ($RemoveAliasDomain -icontains $domain) { continue } if ($ConvertAliasDomain -and $domain -ieq $OldDomain) { $proxies.Add('smtp:' + $address.Split('@')[0] + '@' + $NewDomain) if (-not $PreserveOldAddress) { continue } } if (-not $PreserveOldAddress -and ($domain -ieq $OldDomain -or $proxy -cin $primaries)) { continue } $proxies.Add('smtp:' + $address) } else { $type = $proxy.Split(':')[0] if ($type -notin $RemoveAddressType) { $proxies.Add($proxy) } } } if ($PreserveOldAddress -and $before.UserPrincipalName -ine $target -and (Test-NSPADSimpleMailAddress $before.UserPrincipalName)) { $oldUpnDomain = $before.UserPrincipalName.Split('@')[1] if ($RemoveAliasDomain -inotcontains $oldUpnDomain) { $proxies.Add('smtp:' + $before.UserPrincipalName) } } $unique = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $after.ProxyAddresses = [string[]]@($proxies | Where-Object { $unique.Add($_) }) } $plans[$id.ToString()] = [pscustomobject]@{ PSTypeName = 'NSP.ActiveDirectory.UserAddressPlan'; ObjectGUID = $id ObjectSID = [string]$item.ObjectSID; DomainDNSRoot = [string]$item.DomainDNSRoot Server = [string]$item.Server; Before = $before; After = $after Policy = [pscustomobject]$policy; Reasons = $reasons; Blocked = $false; Changed = $false } } # Reserve both existing and proposed claims. Even another selected user cannot free an # address during this batch: the writer never depends on a particular execution order. $claims = @{} foreach ($item in $User) { $id = ([guid]$item.ObjectGUID).ToString() $states = @($item) if ($plans.ContainsKey($id)) { $state = $plans[$id].After | Select-Object *, @{n='DomainDNSRoot';e={$item.DomainDNSRoot}} $states += $state } foreach ($state in $states) { foreach ($claim in @(Get-NSPADAddressClaim $state)) { if (-not $claims.ContainsKey($claim)) { $claims[$claim] = @{} } $claims[$claim][$id] = $true } } } foreach ($item in $User) { $id = ([guid]$item.ObjectGUID).ToString() if (-not $plans.ContainsKey($id)) { continue } $plan = $plans[$id] $state = $plan.After | Select-Object *, @{n='DomainDNSRoot';e={$plan.DomainDNSRoot}} foreach ($claim in @(Get-NSPADAddressClaim $state)) { if ($claims[$claim].Count -gt 1) { $plan.Reasons.Add('Address or account-name collision in supplied inventory.'); break } } $plan.Blocked = $plan.Reasons.Count -gt 0 $plan.Changed = (ConvertTo-NSPADAddressStateKey $plan.Before) -cne (ConvertTo-NSPADAddressStateKey $plan.After) $plan.Reasons = [string[]]@($plan.Reasons) $plan } } |