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

