Blog Post Title Three

<#

.SYNOPSIS

Interactive hybrid user rename tool (AD is source of authority).

.DESCRIPTION

Updates on-prem AD attributes for a dirsynced user:

- displayName

- userPrincipalName (UPN)

- sAMAccountName

- mail

- proxyAddresses (primary SMTP + aliases, preserving all other proxy types)

Creates a rollback backup (CLIXML + JSON) and a transcript.

.NOTES

Run on Windows PowerShell 5.1+ with RSAT ActiveDirectory module.

#>

#Requires -Version 5.1

#Requires -Modules ActiveDirectory

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]

param(

[Parameter(Mandatory = $false)]

[string]$DomainController,

[Parameter(Mandatory = $false)]

[string]$LogDirectory = (Join-Path -Path $PSScriptRoot -ChildPath "HybridRenameLogs"),

[Parameter(Mandatory = $false)]

[switch]$NoTranscript

)

Set-StrictMode -Version Latest

$ErrorActionPreference = 'Stop'

function New-Timestamp { (Get-Date).ToString("yyyyMMdd-HHmmss") }

function Ensure-Directory {

param([Parameter(Mandatory)][string]$Path)

if (-not (Test-Path -LiteralPath $Path)) {

New-Item -ItemType Directory -Path $Path | Out-Null

}

}

function Escape-LdapFilterValue {

param([Parameter(Mandatory)][string]$Value)

# RFC 4515 escaping for LDAP filters

$Value.Replace('\','\5c').Replace('*','\2a').Replace('(','\28').Replace(')','\29').Replace([char]0,'\00')

}

function Test-MailFormat {

param([Parameter(Mandatory)][string]$Address)

try { [void][System.Net.Mail.MailAddress]::new($Address); return $true } catch { return $false }

}

function Normalize-Email {

param([Parameter(Mandatory)][string]$Address)

$Address.Trim().ToLowerInvariant()

}

function Get-PrimarySmtpFromProxyAddresses {

param([string[]]$ProxyAddresses)

if (-not $ProxyAddresses) { return $null }

$primary = @($ProxyAddresses | Where-Object { $_ -match '^SMTP:' })

if ($primary.Count -gt 1) {

throw "More than one primary SMTP found in proxyAddresses: $($primary -join '; ')"

}

if ($primary.Count -eq 1) { return $primary[0].Substring(5) } # after 'SMTP:'

return $null

}

function Build-UpdatedProxyAddresses {

param(

[Parameter(Mandatory)][string[]]$Existing,

[Parameter(Mandatory)][string]$NewPrimarySmtp,

[Parameter(Mandatory)][bool]$KeepOldPrimaryAsAlias

)

$existingList = @($Existing)

$oldPrimary = Get-PrimarySmtpFromProxyAddresses -ProxyAddresses $existingList

$newPrimaryNorm = Normalize-Email $NewPrimarySmtp

$oldPrimaryNorm = if ($oldPrimary) { Normalize-Email $oldPrimary } else { $null }

# Preserve all non-SMTP proxy addresses verbatim (X500:, SIP:, etc.)

$nonSmtp = @($existingList | Where-Object { $_ -notmatch '^(?i)smtp:' })

# Normalize existing SMTP entries to address-only values

$smtpValues = foreach ($p in ($existingList | Where-Object { $_ -match '^(?i)smtp:' })) {

$addr = $p.Substring($p.IndexOf(':') + 1)

Normalize-Email $addr

}

# Remove any occurrences of old/new primary; we’ll re-add in correct form

$smtpValues = @($smtpValues | Where-Object {

($_ -ne $newPrimaryNorm) -and (-not $oldPrimaryNorm -or $_ -ne $oldPrimaryNorm)

})

$rebuilt = New-Object System.Collections.Generic.List[string]

[void]$rebuilt.Add("SMTP:$newPrimaryNorm")

if ($KeepOldPrimaryAsAlias -and $oldPrimaryNorm -and ($oldPrimaryNorm -ne $newPrimaryNorm)) {

[void]$rebuilt.Add("smtp:$oldPrimaryNorm")

}

foreach ($addr in ($smtpValues | Sort-Object -Unique)) {

if ($addr -ne $newPrimaryNorm -and (-not $oldPrimaryNorm -or $addr -ne $oldPrimaryNorm)) {

[void]$rebuilt.Add("smtp:$addr")

}

}

$final = @()

$final += $rebuilt.ToArray()

$final += $nonSmtp

$primaries = @($final | Where-Object { $_ -match '^SMTP:' })

if ($primaries.Count -ne 1) {

throw "Post-build proxyAddresses does not contain exactly one primary SMTP. Found: $($primaries -join '; ')"

}

[pscustomobject]@{

OldPrimarySmtp = $oldPrimary

NewProxyAddresses = $final

}

}

function Find-ADUsersByEmail {

param([Parameter(Mandatory)][string]$Email)

$emailNorm = Normalize-Email $Email

$escaped = Escape-LdapFilterValue $emailNorm

$ldap = "(&(objectCategory=person)(objectClass=user)(|(mail=$escaped)(userPrincipalName=$escaped)(proxyAddresses=smtp:$escaped)(proxyAddresses=SMTP:$escaped)))"

$params = @{

LDAPFilter = $ldap

Properties = @('displayName','givenName','surname','mail','userPrincipalName','sAMAccountName','proxyAddresses','mailNickname','targetAddress')

}

if ($DomainController) { $params.Server = $DomainController }

@(Get-ADUser @params)

}

function Test-ValueInUse {

param(

[Parameter(Mandatory)][Guid]$CurrentUserGuid,

[Parameter(Mandatory)][string]$LdapFilter,

[Parameter(Mandatory)][string]$Description

)

$params = @{

LDAPFilter = $LdapFilter

Properties = @('objectGuid','distinguishedName','sAMAccountName','userPrincipalName','mail','displayName')

}

if ($DomainController) { $params.Server = $DomainController }

$hits = @(Get-ADUser @params) | Where-Object { $_.ObjectGuid -ne $CurrentUserGuid }

if ($hits.Count -gt 0) {

$examples = $hits | Select-Object -First 5 | ForEach-Object {

"$($_.SamAccountName) / $($_.UserPrincipalName) / $($_.Mail) / $($_.DistinguishedName)"

}

throw "$Description is already in use by another AD user. Examples:`n - $($examples -join "`n - ")"

}

}

function Show-ProxyDiff {

param([string[]]$Before, [string[]]$After)

$beforeSet = New-Object System.Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase)

$afterSet = New-Object System.Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase)

foreach ($b in @($Before | Where-Object { $_ })) { [void]$beforeSet.Add($b) }

foreach ($a in @($After | Where-Object { $_ })) { [void]$afterSet.Add($a) }

$removed = @($Before | Where-Object { -not $afterSet.Contains($_) })

$added = @($After | Where-Object { -not $beforeSet.Contains($_) })

Write-Host ""

Write-Host "Proxy address changes:"

if ($added.Count -eq 0 -and $removed.Count -eq 0) { Write-Host " (no changes)"; return }

if ($removed.Count -gt 0) {

Write-Host " Removed:"

$removed | ForEach-Object { Write-Host " - $_" }

}

if ($added.Count -gt 0) {

Write-Host " Added:"

$added | ForEach-Object { Write-Host " + $_" }

}

}

# -------------------- Main --------------------

Ensure-Directory -Path $LogDirectory

$transcriptStarted = $false

if (-not $NoTranscript) {

$transcriptPath = Join-Path $LogDirectory ("HybridRename-{0}.txt" -f (New-Timestamp))

Start-Transcript -Path $transcriptPath | Out-Null

$transcriptStarted = $true

}

try {

Write-Host ""

Write-Host "Hybrid AD user rename (on-prem AD is source of authority)"

Write-Host "--------------------------------------------------------"

$lookupEmail = Read-Host "Email address of the user you would like to update"

if (-not $lookupEmail) { throw "No email address entered." }

if (-not (Test-MailFormat -Address $lookupEmail)) { throw "Input does not look like a valid email address: '$lookupEmail'" }

$candidates = Find-ADUsersByEmail -Email $lookupEmail

if ($candidates.Count -eq 0) { throw "No AD user found matching mail, UPN, or proxyAddresses for '$lookupEmail'." }

$user = $null

if ($candidates.Count -eq 1) {

$user = $candidates[0]

} else {

Write-Host ""

Write-Host "Multiple users matched. Select one:"

for ($i = 0; $i -lt $candidates.Count; $i++) {

$c = $candidates[$i]

Write-Host ("[{0}] {1} | {2} | {3}" -f ($i+1), $c.SamAccountName, $c.UserPrincipalName, $c.DistinguishedName)

}

$choice = Read-Host "Enter selection number"

[int]$idx = 0

if (-not [int]::TryParse($choice, [ref]$idx)) { throw "Selection must be a number." }

if ($idx -lt 1 -or $idx -gt $candidates.Count) { throw "Selection out of range." }

$user = $candidates[$idx-1]

}

# Refresh full object

$getParams = @{

Identity = $user.ObjectGuid

Properties = @('displayName','givenName','surname','mail','userPrincipalName','sAMAccountName','proxyAddresses','mailNickname','targetAddress')

}

if ($DomainController) { $getParams.Server = $DomainController }

$user = Get-ADUser @getParams

$currentPrimary = Get-PrimarySmtpFromProxyAddresses -ProxyAddresses $user.ProxyAddresses

if (-not $currentPrimary -and $user.Mail) { $currentPrimary = $user.Mail }

Write-Host ""

Write-Host "Current values:"

Write-Host (" DisplayName : {0}" -f $user.DisplayName)

Write-Host (" Given/Surname : {0} {1}" -f $user.GivenName, $user.Surname)

Write-Host (" sAMAccountName : {0}" -f $user.SamAccountName)

Write-Host (" UPN : {0}" -f $user.UserPrincipalName)

Write-Host (" mail : {0}" -f $user.Mail)

Write-Host (" Primary SMTP : {0}" -f $currentPrimary)

Write-Host (" mailNickname : {0}" -f $user.mailNickname)

Write-Host (" targetAddress : {0}" -f $user.targetAddress)

Write-Host " proxyAddresses :"

foreach ($p in ($user.ProxyAddresses | Sort-Object)) { Write-Host " - $p" }

Write-Host ""

$newDisplayName = Read-Host ("User's new display name (Enter to keep '{0}')" -f $user.DisplayName)

if (-not $newDisplayName) { $newDisplayName = $user.DisplayName }

$newLogonInput = Read-Host ("User's new logon name (UPN or username portion; Enter to keep '{0}')" -f $user.UserPrincipalName)

if (-not $newLogonInput) { $newLogonInput = $user.UserPrincipalName }

$newEmailInput = Read-Host ("User's new email address / primary SMTP (Enter to keep '{0}')" -f $currentPrimary)

if (-not $newEmailInput) { $newEmailInput = $currentPrimary }

if (-not (Test-MailFormat -Address $newEmailInput)) { throw "New email address does not look valid: '$newEmailInput'" }

# Interpret logon name: allow full UPN or prefix

if ($newLogonInput -like '*@*') {

$newUPN = $newLogonInput.Trim()

} else {

$suffix = ($user.UserPrincipalName.Split('@') | Select-Object -Last 1)

$newUPN = ("{0}@{1}" -f $newLogonInput.Trim(), $suffix)

}

if (-not (Test-MailFormat -Address $newUPN)) { throw "New UPN does not look valid: '$newUPN'" }

$newSam = $newUPN.Split('@')[0]

if ($newSam.Length -gt 20) {

Write-Host ""

Write-Host ("Derived sAMAccountName '{0}' exceeds 20 characters." -f $newSam)

$samOverride = Read-Host ("Enter a shorter sAMAccountName (<=20) or press Enter to keep existing '{0}'" -f $user.SamAccountName)

if ($samOverride) {

if ($samOverride.Length -gt 20) { throw "sAMAccountName must be <= 20 characters." }

$newSam = $samOverride

} else {

$newSam = $user.SamAccountName

}

}

$newPrimarySmtp = Normalize-Email $newEmailInput

$keepOldAsAlias = $true

if ($currentPrimary -and (Normalize-Email $currentPrimary) -ne $newPrimarySmtp) {

$ans = Read-Host ("Keep the old primary SMTP '{0}' as a secondary alias? (Y/N) [Y]" -f $currentPrimary)

if ($ans -and $ans.Trim().ToUpperInvariant() -eq 'N') { $keepOldAsAlias = $false }

}

$ansCn = Read-Host ("Rename the AD object (CN) to match the new display name '{0}'? (Y/N) [Y]" -f $newDisplayName)

$renameCn = (-not $ansCn) -or ($ansCn.Trim().ToUpperInvariant() -eq 'Y')

$ansTa = Read-Host "Normalize targetAddress prefix to lowercase 'smtp:'? (Y/N) [Y]"

$normalizeTargetAddress = (-not $ansTa) -or ($ansTa.Trim().ToUpperInvariant() -eq 'Y')

$ansMn = Read-Host ("Update mailNickname (alias) to '{0}'? (Y/N) [N]" -f $newSam)

$updateMailNickName = ($ansMn -and $ansMn.Trim().ToUpperInvariant() -eq 'Y')

$proxyResult = Build-UpdatedProxyAddresses -Existing $user.ProxyAddresses -NewPrimarySmtp $newPrimarySmtp -KeepOldPrimaryAsAlias $keepOldAsAlias

$newProxy = $proxyResult.NewProxyAddresses

$newTargetAddress = $user.targetAddress

if ($normalizeTargetAddress -and $user.targetAddress) {

if ($user.targetAddress -match '^(SMTP|smtp):') {

$newTargetAddress = 'smtp:' + (Normalize-Email ($user.targetAddress.Substring($user.targetAddress.IndexOf(':') + 1)))

} else {

$newTargetAddress = Normalize-Email $user.targetAddress

}

}

# Minimal collision checks

$guid = [Guid]$user.ObjectGuid

$escapedUpn = Escape-LdapFilterValue (Normalize-Email $newUPN)

Test-ValueInUse -CurrentUserGuid $guid -LdapFilter "(&(objectCategory=person)(objectClass=user)(userPrincipalName=$escapedUpn))" -Description "UPN '$newUPN'"

$escapedSam = Escape-LdapFilterValue $newSam

Test-ValueInUse -CurrentUserGuid $guid -LdapFilter "(&(objectCategory=person)(objectClass=user)(sAMAccountName=$escapedSam))" -Description "sAMAccountName '$newSam'"

$escapedMail = Escape-LdapFilterValue $newPrimarySmtp

$emailFilter = "(&(objectCategory=person)(objectClass=user)(|(mail=$escapedMail)(proxyAddresses=smtp:$escapedMail)(proxyAddresses=SMTP:$escapedMail)))"

Test-ValueInUse -CurrentUserGuid $guid -LdapFilter $emailFilter -Description "Email address '$newPrimarySmtp'"

Write-Host ""

Write-Host "Planned changes:"

Write-Host (" DisplayName : {0} -> {1}" -f $user.DisplayName, $newDisplayName)

Write-Host (" sAMAccountName : {0} -> {1}" -f $user.SamAccountName, $newSam)

Write-Host (" UPN : {0} -> {1}" -f $user.UserPrincipalName, $newUPN)

Write-Host (" mail : {0} -> {1}" -f $user.Mail, $newPrimarySmtp)

Write-Host (" Primary SMTP : {0} -> {1}" -f $currentPrimary, $newPrimarySmtp)

if ($user.targetAddress) { Write-Host (" targetAddress : {0} -> {1}" -f $user.targetAddress, $newTargetAddress) }

if ($updateMailNickName) { Write-Host (" mailNickname : {0} -> {1}" -f $user.mailNickname, $newSam) }

Write-Host (" CN rename : {0}" -f ($(if ($renameCn) { "YES" } else { "NO" })))

Show-ProxyDiff -Before $user.ProxyAddresses -After $newProxy

Write-Host ""

Write-Host "Propagation reminder:"

Write-Host " - AD replication happens per your topology"

Write-Host " - Entra Connect Sync commonly runs every ~30 minutes by default (or you can run a delta sync on the sync server)."

$confirm = Read-Host "Type YES to apply these changes (anything else aborts)"

if ($confirm.Trim().ToUpperInvariant() -ne 'YES') {

Write-Host "Aborted. No changes made."

return

}

# Backup

$backupBase = Join-Path $LogDirectory ("Backup-{0}-{1}" -f $user.SamAccountName, (New-Timestamp))

$backup = [pscustomobject]@{

ObjectGuid = $user.ObjectGuid

DistinguishedName = $user.DistinguishedName

DisplayName = $user.DisplayName

GivenName = $user.GivenName

Surname = $user.Surname

SamAccountName = $user.SamAccountName

UserPrincipalName = $user.UserPrincipalName

Mail = $user.Mail

MailNickName = $user.MailNickName

TargetAddress = $user.TargetAddress

ProxyAddresses = $user.ProxyAddresses

}

$backup | Export-Clixml -Path ($backupBase + ".clixml")

$backup | ConvertTo-Json -Depth 6 | Out-File -FilePath ($backupBase + ".json") -Encoding UTF8

$identity = $user.ObjectGuid # stable identity

$setParams = @{

Identity = $identity

ErrorAction = 'Stop'

}

if ($DomainController) { $setParams.Server = $DomainController }

if ($newDisplayName -ne $user.DisplayName) { $setParams.DisplayName = $newDisplayName }

if (Normalize-Email $newUPN -ne Normalize-Email $user.UserPrincipalName) { $setParams.UserPrincipalName = $newUPN }

if ($newSam -ne $user.SamAccountName) { $setParams.SamAccountName = $newSam }

if (Normalize-Email $newPrimarySmtp -ne Normalize-Email ($user.Mail | ForEach-Object { $_ } )) { $setParams.EmailAddress = $newPrimarySmtp }

$replace = @{}

$replace.proxyAddresses = $newProxy

if ($updateMailNickName) { $replace.mailNickname = $newSam }

if ($normalizeTargetAddress -and $user.targetAddress) { $replace.targetAddress = $newTargetAddress }

$setParams.Replace = $replace

if ($PSCmdlet.ShouldProcess($user.DistinguishedName, "Update AD attributes (displayName/UPN/sAM/mail/proxyAddresses)")) {

Set-ADUser @setParams

}

if ($renameCn -and $PSCmdlet.ShouldProcess($user.DistinguishedName, "Rename AD object CN to '$newDisplayName'")) {

$rnParams = @{

Identity = $identity

NewName = $newDisplayName

ErrorAction = 'Stop'

}

if ($DomainController) { $rnParams.Server = $DomainController }

Rename-ADObject @rnParams

}

# Verify

$verifyParams = @{

Identity = $identity

Properties = @('displayName','mail','userPrincipalName','sAMAccountName','proxyAddresses','mailNickname','targetAddress')

}

if ($DomainController) { $verifyParams.Server = $DomainController }

$after = Get-ADUser @verifyParams

Write-Host ""

Write-Host "Updated values (AD):"

Write-Host (" DisplayName : {0}" -f $after.DisplayName)

Write-Host (" sAMAccountName : {0}" -f $after.SamAccountName)

Write-Host (" UPN : {0}" -f $after.UserPrincipalName)

Write-Host (" mail : {0}" -f $after.Mail)

Write-Host (" mailNickname : {0}" -f $after.mailNickname)

Write-Host (" targetAddress : {0}" -f $after.targetAddress)

Write-Host " proxyAddresses :"

foreach ($p in ($after.ProxyAddresses | Sort-Object)) { Write-Host " - $p" }

Write-Host ""

Write-Host "Backup saved to:"

Write-Host (" {0}.clixml" -f $backupBase)

Write-Host (" {0}.json" -f $backupBase)

} finally {

if ($transcriptStarted) { Stop-Transcript | Out-Null }

}

Previous
Previous

Blog Post Title Two

Next
Next

Blog Post Title Four