PowerShell Script - Restore-ADUserAttributes.ps1

What it is

A PowerShell script that compares current AD user objects against a snapshot taken with Backup-ADUserAttributes.ps1 and reports every difference. With -Apply, it writes the snapshot values back. Users are matched on objectGUID, so renamed or moved accounts are still found.

It is report-only by default. Nothing changes in AD unless you pass -Apply.

What it does

  • Loads a .clixml snapshot and looks up each user by GUID.
  • Compares each captured attribute (or the subset in -Attributes) and builds a per-attribute diff.
  • For each difference, plans either Replace (set to the snapshot value) or Clear (snapshot value was empty).
  • Handles manager by GUID, not DN, so a manager who was renamed or moved still resolves.
  • Optionally restores CN, OU and enabled state, each behind its own switch.
  • Writes a CSV report of every difference and what happened to it, and prints a count per attribute.
  • Users that no longer exist are reported as MISSING - check AD Recycle Bin. Other read failures are reported per user and skipped.

Requirements

  • PowerShell 5.1+
  • ActiveDirectory module (RSAT, or run on a DC/management server)
  • A snapshot produced by Backup-ADUserAttributes.ps1
  • For -Apply: write permission on the target user objects (and, for -RestoreOU, permission to move into the original OU, which must still exist)

Before you run it with -Apply

  1. Stop whatever caused the change. If a directory sync, identity provider or provisioning job writes to these attributes, disable that write path first. Otherwise it can overwrite your restored values on its next run.
  2. Run the report-only pass first and read the CSV.
  3. Start narrow. Use -Identity and/or -Attributes on a test user before restoring everyone.

Parameters

ParameterRequiredDescription
-SnapshotPathYesPath to the .clixml from the backup script.
-AttributesNoOnly compare/restore these attributes. Default: everything captured.
-IdentityNoLimit to these sAMAccountNames or objectGUIDs.
-RestoreNameNoRename the CN back to the snapshot value (changes the DN).
-RestoreOUNoMove the object back to its original OU.
-RestoreEnabledNoEnable/disable the account to match the snapshot.
-ApplyNoActually write. Without it, report only.
-ServerNoDC to read from and write to.
-ReportPathNoOutput CSV. Default: .\ad-restore-report-<timestamp>.csv

CN, OU and enabled-state differences are always listed in the report. They show as Rename (needs -RestoreName), Move (needs -RestoreOU) and SetEnabled (needs -RestoreEnabled) until you opt in.

Usage

# 1. Diff only: changes nothing
.\Restore-ADUserAttributes.ps1 -SnapshotPath .\ad-snapshot-20261014-090000.clixml

# 2. Restore one user, including CN
.\Restore-ADUserAttributes.ps1 -SnapshotPath .\snap.clixml -Identity jdoe -RestoreName -Apply

# 3. Restore only specific attributes for everyone
.\Restore-ADUserAttributes.ps1 -SnapshotPath .\snap.clixml -Attributes title,department,manager -Apply

Report

One row per difference:

ColumnMeaning
SamAccountName, ObjectGUIDThe user.
AttributeAttribute name, or cn, OU, Enabled, (object) for non-attribute differences.
SnapshotValue, CurrentValueNormalized values. Multi-valued attributes are sorted and joined with a pipe character.
ActionReplace, Clear, Rename, Move, SetEnabled, or a note such as MISSING / Manager object not found.
AppliedTrue only if the write for that user succeeded.
ErrorError text, if any.

Behavior worth knowing

  • Order of operations per user: attribute Replace/Clear via Set-ADUser, then rename, then move, then enable/disable. If any step fails, the user’s rows are marked with the error and Applied stays False.
  • Comparison is case-sensitive for values. Multi-valued attributes compare as sorted sets, so reordering alone isn’t a difference.
  • The default capture list includes identity attributes (sAMAccountName, userPrincipalName, mail, proxyAddresses). Restoring those can affect sign-in and mail routing. If that isn’t what you want, restrict with -Attributes.
  • Not restored: passwords, group membership, attributes that weren’t captured, and deleted objects (use the AD Recycle Bin for those).

License

Copyright © 2026 Steve LaPorte. Released under the GNU General Public License v3.0 or later . Provided as-is, with no warranty. Test in a lab before running against production.

Script

View on GitHub . Source also provided below:

<#
.SYNOPSIS
    Compares current AD users to a Backup-ADUserAttributes.ps1 snapshot and optionally restores them.

.DESCRIPTION
    Default is REPORT ONLY: writes a per-attribute diff to CSV and changes nothing.
    Add -Apply to write. Users are matched on objectGUID, so renamed/moved objects are still found.
    Name (CN), OU and enabled state are only compared/restored when the matching switch is used.
    Manager is compared and restored by GUID.

    Before applying, stop whatever caused the change (e.g. a directory sync or identity provider
    provisioning job that writes attributes to AD), or it may overwrite the restored values on its next run.

.PARAMETER SnapshotPath
    Path to the .clixml file produced by Backup-ADUserAttributes.ps1.

.PARAMETER Attributes
    Restore only these attributes. Default: every attribute captured in the snapshot.

.PARAMETER Identity
    Limit to these sAMAccountNames or objectGUIDs.

.PARAMETER RestoreName
    Rename the CN back to the snapshot value (changes the DN).

.PARAMETER RestoreOU
    Move the object back to its original OU.

.PARAMETER RestoreEnabled
    Enable/disable the account to match the snapshot.

.PARAMETER Apply
    Actually write changes. Without it, the script only reports.

.PARAMETER Server
    DC to read from and write to.

.PARAMETER ReportPath
    Output CSV for the diff/results. Default: .\ad-restore-report-<timestamp>.csv

.EXAMPLE
    .\Restore-ADUserAttributes.ps1 -SnapshotPath .\ad-snapshot-20261014-090000.clixml
    Diff only.

.EXAMPLE
    .\Restore-ADUserAttributes.ps1 -SnapshotPath .\snap.clixml -Identity jdoe -RestoreName -Apply
    Restore one user, including CN.

.EXAMPLE
    .\Restore-ADUserAttributes.ps1 -SnapshotPath .\snap.clixml -Attributes title,department,manager -Apply
    Restore only specific attributes for everyone.

.NOTES
    Author:   Steve LaPorte (stevelaporte.net | github.com/stevelaporte)
    Requires: PowerShell 5.1+, ActiveDirectory module (RSAT), write access to the target user objects
    License:  GPL-3.0-or-later
              Copyright (C) 2026 Steve LaPorte
              This program is free software: you can redistribute it and/or modify it under the
              terms of the GNU General Public License as published by the Free Software Foundation,
              either version 3 of the License, or (at your option) any later version.
              This program is distributed in the hope that it will be useful, but WITHOUT ANY
              WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
              PARTICULAR PURPOSE. See the GNU General Public License for more details.
              You should have received a copy of the GNU General Public License along with this
              program. If not, see <https://www.gnu.org/licenses/>.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$SnapshotPath,
    [string[]]$Attributes,          # subset to restore; default = all captured
    [string[]]$Identity,            # limit to these sAMAccountNames or GUIDs
    [switch]$RestoreName,           # rename CN back (changes DN)
    [switch]$RestoreOU,             # move back to original OU
    [switch]$RestoreEnabled,        # re-enable/disable to match snapshot
    [switch]$Apply,
    [string]$Server,
    [string]$ReportPath = ".\ad-restore-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv"
)

Import-Module ActiveDirectory -ErrorAction Stop
$common = @{}
if ($Server) { $common.Server = $Server }

$snapshot = Import-Clixml $SnapshotPath
if ($Identity) {
    $snapshot = $snapshot | Where-Object { $Identity -contains $_.SamAccountName -or $Identity -contains "$($_.ObjectGUID)" }
}
if (-not $snapshot) { throw "No snapshot entries matched." }
$captured = @(@($snapshot)[0].Attributes.Keys)
# @() forces an array: a single attribute would otherwise be a string, and string + 'cn' concatenates
$toCheck  = @(if ($Attributes) { $Attributes | Where-Object { $captured -contains $_ } } else { $captured })
if (-not $toCheck.Count) { throw "None of the requested attributes are in the snapshot." }
$missingAttrs = $Attributes | Where-Object { $captured -notcontains $_ }
if ($missingAttrs) { Write-Warning "Not in snapshot, ignored: $($missingAttrs -join ', ')" }

function Get-Norm($v) {
    if ($null -eq $v) { return '' }
    if ($v -is [System.Collections.IEnumerable] -and $v -isnot [string]) {
        return (@($v | ForEach-Object { "$_" }) | Sort-Object) -join "`n"
    }
    "$v"
}
function Get-ParentDN([string]$dn) { $dn -replace '^CN=.+?(?<!\\),', '' }

$report = [System.Collections.Generic.List[object]]::new()
function Add-Row($s, $attr, $snapVal, $curVal, $action, $applied, $err) {
    $row = [pscustomobject]@{
        SamAccountName = $s.SamAccountName
        ObjectGUID     = $s.ObjectGUID
        Attribute      = $attr
        SnapshotValue  = (Get-Norm $snapVal) -replace "`n", ' | '
        CurrentValue   = (Get-Norm $curVal) -replace "`n", ' | '
        Action         = $action
        Applied        = $applied
        Error          = $err
    }
    $report.Add($row)
}

foreach ($s in $snapshot) {
    try {
        $cur = Get-ADUser @common -Identity $s.ObjectGUID -Properties (@($toCheck) + 'cn')
    } catch {
        $action = if ($_.Exception.GetType().Name -eq 'ADIdentityNotFoundException') { 'MISSING - check AD Recycle Bin' } else { 'READ ERROR - user skipped' }
        Add-Row $s '(object)' $s.DistinguishedName $null $action $false $_.Exception.Message
        continue
    }

    $replace = @{}
    $clear   = [System.Collections.Generic.List[string]]::new()

    foreach ($a in $toCheck) {
        $snapVal = $s.Attributes[$a]
        $curVal  = $cur.$a

        if ($a -eq 'manager') {
            $curMgrGuid = $null
            if ($curVal) { try { $curMgrGuid = (Get-ADUser @common -Identity $curVal).ObjectGUID } catch {} }
            if ("$curMgrGuid" -eq "$($s.ManagerGuid)") { continue }
            if (-not $s.ManagerGuid) { $clear.Add('manager'); Add-Row $s $a $snapVal $curVal 'Clear' $false $null; continue }
            try {
                $replace['manager'] = (Get-ADUser @common -Identity $s.ManagerGuid).DistinguishedName
                Add-Row $s $a $snapVal $curVal 'Replace' $false $null
            } catch { Add-Row $s $a $snapVal $curVal 'Manager object not found' $false $_.Exception.Message }
            continue
        }

        if ((Get-Norm $snapVal) -ceq (Get-Norm $curVal)) { continue }
        if ($null -eq $snapVal -or (Get-Norm $snapVal) -eq '') {
            $clear.Add($a); Add-Row $s $a $snapVal $curVal 'Clear' $false $null
        } else {
            # Clixml returns multi-valued attributes as ArrayList; hand AD a string[]
            if ($snapVal -is [System.Collections.IEnumerable] -and $snapVal -isnot [string]) {
                $replace[$a] = [string[]]@($snapVal | ForEach-Object { "$_" })
            } else {
                $replace[$a] = $snapVal
            }
            Add-Row $s $a $snapVal $curVal 'Replace' $false $null
        }
    }

    $nameChanged = $cur.cn -cne $s.CN
    $ouChanged   = (Get-ParentDN $cur.DistinguishedName) -ne $s.ParentDN
    $enChanged   = $cur.Enabled -ne $s.Enabled
    if ($nameChanged) { Add-Row $s 'cn' $s.CN $cur.cn $(if ($RestoreName) { 'Rename' } else { 'Rename (needs -RestoreName)' }) $false $null }
    if ($ouChanged)   { Add-Row $s 'OU' $s.ParentDN (Get-ParentDN $cur.DistinguishedName) $(if ($RestoreOU) { 'Move' } else { 'Move (needs -RestoreOU)' }) $false $null }
    if ($enChanged)   { Add-Row $s 'Enabled' $s.Enabled $cur.Enabled $(if ($RestoreEnabled) { 'SetEnabled' } else { 'SetEnabled (needs -RestoreEnabled)' }) $false $null }

    if (-not $Apply) { continue }

    $userRows = $report | Where-Object { $_.ObjectGUID -eq $s.ObjectGUID -and $_.Action -notmatch 'needs|MISSING|not found' }
    try {
        $set = @{}
        if ($replace.Count) { $set.Replace = $replace }
        if ($clear.Count)   { $set.Clear   = $clear.ToArray() }
        if ($set.Count)     { Set-ADUser @common -Identity $s.ObjectGUID @set -ErrorAction Stop }
        if ($RestoreName -and $nameChanged) { Rename-ADObject @common -Identity $s.ObjectGUID -NewName $s.CN -ErrorAction Stop }
        if ($RestoreOU -and $ouChanged)     { Move-ADObject   @common -Identity $s.ObjectGUID -TargetPath $s.ParentDN -ErrorAction Stop }
        if ($RestoreEnabled -and $enChanged) {
            if ($s.Enabled) { Enable-ADAccount @common -Identity $s.ObjectGUID -ErrorAction Stop }
            else            { Disable-ADAccount @common -Identity $s.ObjectGUID -ErrorAction Stop }
        }
        $userRows | ForEach-Object { $_.Applied = $true }
    } catch {
        $msg = $_.Exception.Message
        $userRows | ForEach-Object { $_.Error = "$($_.Error)$msg" }
        Write-Warning "$($s.SamAccountName): $msg"
    }
}

$report | Export-Csv $ReportPath -NoTypeInformation
$mode = if ($Apply) { 'APPLIED' } else { 'REPORT ONLY (use -Apply to write)' }
Write-Host "$mode - $(@($report).Count) differences across $(@($report | Select-Object -Unique ObjectGUID).Count) users -> $ReportPath"
$report | Group-Object Attribute | Sort-Object Count -Descending | Format-Table Name, Count -AutoSize