[{"content":"\nWhat it is A small self-hosted Flask app that renders your RetroAchievements\rprofile as a 768x192 PNG at an unchanging URL, like https://ra.example.com/users/YourRAUsername.png. Every time the image is requested, it shows your current point totals, games mastered, the game you\u0026rsquo;re playing and its rich presence line, so a forum signature stays up to date without you touching it.\nTypical use: embed it in a retro gaming forum signature, a Discord profile, or anywhere else that accepts an image URL.\nWhat it does Pulls hardcore points, softcore points and games mastered from the RetroAchievements Web API. Shows the current game, platform, rich presence (\u0026ldquo;Currently: \u0026hellip;\u0026rdquo;) line and game icon. Long titles drop to a smaller font, and long rich presence messages are truncated with an ellipsis. Draws the console\u0026rsquo;s system icon inline on the Platform line, sourced from RetroAchievements\u0026rsquo; own icons. Renders emoji in titles and rich presence in full color (Noto Color Emoji), with a monochrome fallback (OpenMoji) instead of tofu boxes. Swaps characters the pixel font can\u0026rsquo;t render (bullets, en/em dashes, curly quotes) for ASCII lookalikes. Picks a random background from ./backgrounds/ on each render. Applies a warning overlay if the account has any softcore points. Supports per-user overrides that fake the \u0026ldquo;now playing\u0026rdquo; section while real stats stay live. Mostly useful for pranking friends. Caches rendered images and API responses on disk, with ETag and Last-Modified support, to keep API usage low. Refuses to start if its API credentials are missing, instead of failing on every request. How it works A request hits /users/\u0026lt;username\u0026gt;.png. If a rendered image younger than CACHE_TTL (default 5 minutes) exists, it\u0026rsquo;s served, or 304 Not Modified is returned if the client\u0026rsquo;s copy matches. Otherwise, profile and awards data are fetched (or read from the API cache), then current-game data. Overridden users skip that last call. Pillow composes the image, writes it to ./cache/, and serves it. All API calls go through a single lock with retry/backoff, and identify themselves with a ra-signatures/\u0026lt;version\u0026gt; User-Agent.\nRequirements Python 3 with venv and pip Python packages: flask, gunicorn, pillow, python-dotenv, requests A RetroAchievements account and its Web API key (in your account settings) Optional: Nginx and Certbot for HTTPS, and a CDN such as Cloudflare in front Configuration Credentials go in .env, copied from the included .env.example:\nVariable Required Description RA_API_USERNAME Yes Your RetroAchievements username. RA_API_KEY Yes Your RetroAchievements Web API key. Treat it like a password. Everything else is a constant at the top of app.py: cache TTLs, console icon size and spacing, emoji scaling, and the character substitution map.\nLocal customizations are gitignored, so they never end up in a commit:\nFile Purpose backgrounds/*.png Your own 768x192 backgrounds. Once any exist, the bundled placeholders are left out of the rotation. softcore.png Your own softcore overlay. Falls back to the bundled softcore_default.png. overrides.py Per-user \u0026ldquo;now playing\u0026rdquo; overrides, copied from overrides.example.py. Usage # Install sudo git clone https://github.com/stevelaporte/ra-signatures.git /opt/ra-signatures cd /opt/ra-signatures sudo python3 -m venv venv sudo venv/bin/pip install -r requirements.txt sudo cp .env.example .env \u0026amp;\u0026amp; sudo chmod 600 .env # then add your credentials # Run (the README has a full systemd unit and Nginx config) venv/bin/gunicorn --workers=1 --threads=1 --bind 127.0.0.1:5000 app:app Embed it anywhere that takes an image URL, e.g. as forum BBCode:\n[img]https://ra.example.com/users/YourRAUsername.png[/img] Fake a friend\u0026rsquo;s \u0026ldquo;now playing\u0026rdquo; section with an override:\n# overrides.py OVERRIDES = { \u0026#34;someusername\u0026#34;: { \u0026#34;Title\u0026#34;: \u0026#34;Barbie Dreamhouse Adventures\u0026#34;, \u0026#34;ConsoleName\u0026#34;: \u0026#34;PC\u0026#34;, \u0026#34;RichPresenceMsg\u0026#34;: \u0026#34;Decorating the Dreamhouse\u0026#34;, }, } Restart the service after editing overrides.py, then clear that user\u0026rsquo;s cache:\ncurl -X POST http://127.0.0.1:5000/invalidate/someusername Endpoints Route Method Purpose /users/\u0026lt;username\u0026gt;.png GET The signature image. /invalidate/\u0026lt;username\u0026gt; GET, POST Deletes that user\u0026rsquo;s cached image and API data. /invalidate_all GET, POST Clears all cached images and API data. Notes The invalidation endpoints are unauthenticated. The example Nginx config only proxies /users/, so call them from the server itself. Run Gunicorn with one worker and one thread so API requests stay serialized, and put a CDN in front if you can. Both keep you well clear of RetroAchievements\u0026rsquo; rate limits. The bundled backgrounds are generic placeholders. The app looks best with real 8- and 16-bit game backgrounds, which can\u0026rsquo;t be redistributed, so bring your own. Bundled fonts keep their own licenses (Pixellari, Noto Color Emoji under OFL 1.1, OpenMoji under CC BY-SA 4.0). Full texts are in the repo\u0026rsquo;s licenses/ folder. Not affiliated with RetroAchievements. Game and console icons are fetched at runtime and not redistributed. Source The code is on GitHub at stevelaporte/ra-signatures\r, with release notes on the releases page\rand a full history in CHANGELOG.md\r. It\u0026rsquo;s licensed under GPL-3.0.\n","permalink":"https://stevelaporte.net/projects/ra-signatures/","summary":"\u003cp\u003e\u003cimg alt=\"ra-signatures example\" loading=\"lazy\" src=\"https://raw.githubusercontent.com/stevelaporte/ra-signatures/main/example.png#center\"\u003e\u003c/p\u003e\n\u003ch2 id=\"what-it-is\"\u003eWhat it is\u003c/h2\u003e\n\u003cp\u003eA small self-hosted Flask app that renders your \u003ca href=\"https://retroachievements.org\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eRetroAchievements\u003c/a\u003e\r\n profile as a 768x192 PNG at an unchanging URL, like \u003ccode\u003ehttps://ra.example.com/users/YourRAUsername.png\u003c/code\u003e. Every time the image is requested, it shows your current point totals, games mastered, the game you\u0026rsquo;re playing and its rich presence line, so a forum signature stays up to date without you touching it.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eTypical use:\u003c/strong\u003e embed it in a retro gaming forum signature, a Discord profile, or anywhere else that accepts an image URL.\u003c/p\u003e","title":"ra-signatures: dynamic RetroAchievements signature images"},{"content":"\nWhat it is A PowerShell script that compares current AD user objects against a snapshot taken with Backup-ADUserAttributes.ps1\rand reports every difference. With -Apply, it writes the snapshot values back. Users are matched on objectGUID, so renamed or moved accounts are still found.\nIt is report-only by default. Nothing changes in AD unless you pass -Apply.\nWhat 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 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. Run the report-only pass first and read the CSV. Start narrow. Use -Identity and/or -Attributes on a test user before restoring everyone. Parameters Parameter Required Description -SnapshotPath Yes Path to the .clixml from the backup script. -Attributes No Only compare/restore these attributes. Default: everything captured. -Identity No Limit to these sAMAccountNames or objectGUIDs. -RestoreName No Rename the CN back to the snapshot value (changes the DN). -RestoreOU No Move the object back to its original OU. -RestoreEnabled No Enable/disable the account to match the snapshot. -Apply No Actually write. Without it, report only. -Server No DC to read from and write to. -ReportPath No Output CSV. Default: .\\ad-restore-report-\u0026lt;timestamp\u0026gt;.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.\nUsage # 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:\nColumn Meaning SamAccountName, ObjectGUID The user. Attribute Attribute name, or cn, OU, Enabled, (object) for non-attribute differences. SnapshotValue, CurrentValue Normalized values. Multi-valued attributes are sorted and joined with a pipe character. Action Replace, Clear, Rename, Move, SetEnabled, or a note such as MISSING / Manager object not found. Applied True only if the write for that user succeeded. Error Error 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\u0026rsquo;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\u0026rsquo;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\u0026rsquo;t what you want, restrict with -Attributes. Not restored: passwords, group membership, attributes that weren\u0026rsquo;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\r. Provided as-is, with no warranty. Test in a lab before running against production.\nScript View on GitHub\r. Source also provided below:\n\u0026lt;# .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-\u0026lt;timestamp\u0026gt;.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 \u0026lt;https://www.gnu.org/licenses/\u0026gt;. #\u0026gt; [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 = \u0026#34;.\\ad-restore-report-$(Get-Date -Format \u0026#39;yyyyMMdd-HHmmss\u0026#39;).csv\u0026#34; ) 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 \u0026#34;$($_.ObjectGUID)\u0026#34; } } if (-not $snapshot) { throw \u0026#34;No snapshot entries matched.\u0026#34; } $captured = @(@($snapshot)[0].Attributes.Keys) # @() forces an array: a single attribute would otherwise be a string, and string + \u0026#39;cn\u0026#39; concatenates $toCheck = @(if ($Attributes) { $Attributes | Where-Object { $captured -contains $_ } } else { $captured }) if (-not $toCheck.Count) { throw \u0026#34;None of the requested attributes are in the snapshot.\u0026#34; } $missingAttrs = $Attributes | Where-Object { $captured -notcontains $_ } if ($missingAttrs) { Write-Warning \u0026#34;Not in snapshot, ignored: $($missingAttrs -join \u0026#39;, \u0026#39;)\u0026#34; } function Get-Norm($v) { if ($null -eq $v) { return \u0026#39;\u0026#39; } if ($v -is [System.Collections.IEnumerable] -and $v -isnot [string]) { return (@($v | ForEach-Object { \u0026#34;$_\u0026#34; }) | Sort-Object) -join \u0026#34;`n\u0026#34; } \u0026#34;$v\u0026#34; } function Get-ParentDN([string]$dn) { $dn -replace \u0026#39;^CN=.+?(?\u0026lt;!\\\\),\u0026#39;, \u0026#39;\u0026#39; } $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 \u0026#34;`n\u0026#34;, \u0026#39; | \u0026#39; CurrentValue = (Get-Norm $curVal) -replace \u0026#34;`n\u0026#34;, \u0026#39; | \u0026#39; Action = $action Applied = $applied Error = $err } $report.Add($row) } foreach ($s in $snapshot) { try { $cur = Get-ADUser @common -Identity $s.ObjectGUID -Properties (@($toCheck) + \u0026#39;cn\u0026#39;) } catch { $action = if ($_.Exception.GetType().Name -eq \u0026#39;ADIdentityNotFoundException\u0026#39;) { \u0026#39;MISSING - check AD Recycle Bin\u0026#39; } else { \u0026#39;READ ERROR - user skipped\u0026#39; } Add-Row $s \u0026#39;(object)\u0026#39; $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 \u0026#39;manager\u0026#39;) { $curMgrGuid = $null if ($curVal) { try { $curMgrGuid = (Get-ADUser @common -Identity $curVal).ObjectGUID } catch {} } if (\u0026#34;$curMgrGuid\u0026#34; -eq \u0026#34;$($s.ManagerGuid)\u0026#34;) { continue } if (-not $s.ManagerGuid) { $clear.Add(\u0026#39;manager\u0026#39;); Add-Row $s $a $snapVal $curVal \u0026#39;Clear\u0026#39; $false $null; continue } try { $replace[\u0026#39;manager\u0026#39;] = (Get-ADUser @common -Identity $s.ManagerGuid).DistinguishedName Add-Row $s $a $snapVal $curVal \u0026#39;Replace\u0026#39; $false $null } catch { Add-Row $s $a $snapVal $curVal \u0026#39;Manager object not found\u0026#39; $false $_.Exception.Message } continue } if ((Get-Norm $snapVal) -ceq (Get-Norm $curVal)) { continue } if ($null -eq $snapVal -or (Get-Norm $snapVal) -eq \u0026#39;\u0026#39;) { $clear.Add($a); Add-Row $s $a $snapVal $curVal \u0026#39;Clear\u0026#39; $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 { \u0026#34;$_\u0026#34; }) } else { $replace[$a] = $snapVal } Add-Row $s $a $snapVal $curVal \u0026#39;Replace\u0026#39; $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 \u0026#39;cn\u0026#39; $s.CN $cur.cn $(if ($RestoreName) { \u0026#39;Rename\u0026#39; } else { \u0026#39;Rename (needs -RestoreName)\u0026#39; }) $false $null } if ($ouChanged) { Add-Row $s \u0026#39;OU\u0026#39; $s.ParentDN (Get-ParentDN $cur.DistinguishedName) $(if ($RestoreOU) { \u0026#39;Move\u0026#39; } else { \u0026#39;Move (needs -RestoreOU)\u0026#39; }) $false $null } if ($enChanged) { Add-Row $s \u0026#39;Enabled\u0026#39; $s.Enabled $cur.Enabled $(if ($RestoreEnabled) { \u0026#39;SetEnabled\u0026#39; } else { \u0026#39;SetEnabled (needs -RestoreEnabled)\u0026#39; }) $false $null } if (-not $Apply) { continue } $userRows = $report | Where-Object { $_.ObjectGUID -eq $s.ObjectGUID -and $_.Action -notmatch \u0026#39;needs|MISSING|not found\u0026#39; } 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 = \u0026#34;$($_.Error)$msg\u0026#34; } Write-Warning \u0026#34;$($s.SamAccountName): $msg\u0026#34; } } $report | Export-Csv $ReportPath -NoTypeInformation $mode = if ($Apply) { \u0026#39;APPLIED\u0026#39; } else { \u0026#39;REPORT ONLY (use -Apply to write)\u0026#39; } Write-Host \u0026#34;$mode - $(@($report).Count) differences across $(@($report | Select-Object -Unique ObjectGUID).Count) users -\u0026gt; $ReportPath\u0026#34; $report | Group-Object Attribute | Sort-Object Count -Descending | Format-Table Name, Count -AutoSize ","permalink":"https://stevelaporte.net/scripts/restore-ad-attributes/","summary":"\u003cp\u003e\u003cimg alt=\"PowerShell Script - Restore-ADUserAttributes.ps1\" loading=\"lazy\" src=\"/pwsh.png#center\" title=\"PowerShell Script - Restore-ADUserAttributes.ps1\"\u003e\u003c/p\u003e\n\u003ch2 id=\"what-it-is\"\u003eWhat it is\u003c/h2\u003e\n\u003cp\u003eA PowerShell script that compares current AD user objects against a snapshot taken with \u003ca href=\"/scripts/backup-ad-attributes\"\u003eBackup-ADUserAttributes.ps1\u003c/a\u003e\r\n and reports every difference. With \u003ccode\u003e-Apply\u003c/code\u003e, it writes the snapshot values back. Users are matched on \u003ccode\u003eobjectGUID\u003c/code\u003e, so renamed or moved accounts are still found.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eIt is report-only by default.\u003c/strong\u003e Nothing changes in AD unless you pass \u003ccode\u003e-Apply\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"what-it-does\"\u003eWhat it does\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eLoads a \u003ccode\u003e.clixml\u003c/code\u003e snapshot and looks up each user by GUID.\u003c/li\u003e\n\u003cli\u003eCompares each captured attribute (or the subset in \u003ccode\u003e-Attributes\u003c/code\u003e) and builds a per-attribute diff.\u003c/li\u003e\n\u003cli\u003eFor each difference, plans either \u003cstrong\u003eReplace\u003c/strong\u003e (set to the snapshot value) or \u003cstrong\u003eClear\u003c/strong\u003e (snapshot value was empty).\u003c/li\u003e\n\u003cli\u003eHandles \u003ccode\u003emanager\u003c/code\u003e by GUID, not DN, so a manager who was renamed or moved still resolves.\u003c/li\u003e\n\u003cli\u003eOptionally restores CN, OU and enabled state, each behind its own switch.\u003c/li\u003e\n\u003cli\u003eWrites a CSV report of every difference and what happened to it, and prints a count per attribute.\u003c/li\u003e\n\u003cli\u003eUsers that no longer exist are reported as \u003ccode\u003eMISSING - check AD Recycle Bin\u003c/code\u003e. Other read failures are reported per user and skipped.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"requirements\"\u003eRequirements\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003ePowerShell 5.1+\u003c/li\u003e\n\u003cli\u003eActiveDirectory module (RSAT, or run on a DC/management server)\u003c/li\u003e\n\u003cli\u003eA snapshot produced by \u003ccode\u003eBackup-ADUserAttributes.ps1\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eFor \u003ccode\u003e-Apply\u003c/code\u003e: write permission on the target user objects (and, for \u003ccode\u003e-RestoreOU\u003c/code\u003e, permission to move into the original OU, which must still exist)\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"before-you-run-it-with--apply\"\u003eBefore you run it with -Apply\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eStop whatever caused the change.\u003c/strong\u003e 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.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eRun the report-only pass first\u003c/strong\u003e and read the CSV.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eStart narrow.\u003c/strong\u003e Use \u003ccode\u003e-Identity\u003c/code\u003e and/or \u003ccode\u003e-Attributes\u003c/code\u003e on a test user before restoring everyone.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"parameters\"\u003eParameters\u003c/h2\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eParameter\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eRequired\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eDescription\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-SnapshotPath\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eYes\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003ePath to the \u003ccode\u003e.clixml\u003c/code\u003e from the backup script.\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-Attributes\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eOnly compare/restore these attributes. Default: everything captured.\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-Identity\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eLimit to these sAMAccountNames or objectGUIDs.\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-RestoreName\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eRename the CN back to the snapshot value (changes the DN).\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-RestoreOU\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eMove the object back to its original OU.\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-RestoreEnabled\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eEnable/disable the account to match the snapshot.\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-Apply\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eActually write. Without it, report only.\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-Server\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eDC to read from and write to.\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003e\u003ccode\u003e-ReportPath\u003c/code\u003e\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eNo\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eOutput CSV. Default: \u003ccode\u003e.\\ad-restore-report-\u0026lt;timestamp\u0026gt;.csv\u003c/code\u003e\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eCN, OU and enabled-state differences are always listed in the report. They show as \u003ccode\u003eRename (needs -RestoreName)\u003c/code\u003e, \u003ccode\u003eMove (needs -RestoreOU)\u003c/code\u003e and \u003ccode\u003eSetEnabled (needs -RestoreEnabled)\u003c/code\u003e until you opt in.\u003c/p\u003e","title":"Restore-ADUserAttributes.ps1: diff and restore AD user attributes from a snapshot"},{"content":"\nWhat it is A read-only PowerShell script that captures a configurable set of attributes, plus CN, OU and enabled state, for every user under one or more OUs. Output is keyed on objectGUID, so a snapshot stays usable after users are renamed or moved. It\u0026rsquo;s the capture half of a pair; the restore half is Restore-ADUserAttributes.ps1\r.\nTypical use: take a snapshot before anything that bulk-writes to AD (a sync tool, an HR-driven provisioning job, a mass script), so you have something to diff against or roll back to.\nWhat it does Validates every -SearchBase exists, then enumerates users (subtree, including disabled accounts) under each OU. Captures a default attribute set (below) plus anything in -ExtraAttributes. Checks each requested attribute against the forest schema and skips unknown ones with a warning, since Get-ADUser errors on attributes that don\u0026rsquo;t exist. Stores the manager as a GUID in addition to the DN, so a renamed or moved manager doesn\u0026rsquo;t break a later restore. Preserves multi-valued attributes (e.g. proxyAddresses, otherTelephone) as arrays. Runs with $ErrorActionPreference = 'Stop', so a bad OU or unreachable DC fails the run instead of writing a partial snapshot. Modifies nothing in AD. Default attributes: givenName, sn, initials, middleName, displayName, description, title, department, division, company, manager, employeeID, employeeNumber, employeeType, physicalDeliveryOfficeName, streetAddress, l, st, postalCode, c, co, countryCode, telephoneNumber, mobile, ipPhone, facsimileTelephoneNumber, homePhone, otherTelephone, info, wWWHomePage, mail, proxyAddresses, userPrincipalName, sAMAccountName, and extensionAttribute1 through extensionAttribute15.\nRequirements PowerShell 5.1+ ActiveDirectory module (RSAT, or run on a DC/management server) Read access to the target OUs No variables to set beforehand. Everything is a parameter. Parameters Parameter Required Description -SearchBase Yes One or more OU/container DNs to capture (subtree). -ExtraAttributes No Extra LDAP attribute names, e.g. custom schema attributes or anything your provisioning tooling writes that isn\u0026rsquo;t in the default list. -Server No DC to read from. Also accepts a mounted AD snapshot, e.g. localhost:51389 (dsamain). -OutDir No Output directory, created if missing. Default: current directory. Usage # One OU, default attributes .\\Backup-ADUserAttributes.ps1 -SearchBase \u0026#34;OU=Staff,DC=corp,DC=example,DC=com\u0026#34; -OutDir C:\\ADBackups # Multiple OUs, extra attribute, specific DC .\\Backup-ADUserAttributes.ps1 ` -SearchBase \u0026#34;OU=Staff,DC=corp,DC=example,DC=com\u0026#34;,\u0026#34;OU=Contractors,DC=corp,DC=example,DC=com\u0026#34; ` -ExtraAttributes msDS-cloudExtensionAttribute1 ` -Server dc01.corp.example.com Taking a snapshot after a bad change? Mount an AD snapshot from before the change with dsamain and point -Server at it (e.g. localhost:51389). The script reads the pre-change state from there.\nOutput Three files per run, named by timestamp:\nFile Purpose ad-snapshot-\u0026lt;timestamp\u0026gt;.clixml Restore source. Types preserved, multi-valued attributes kept as arrays. ad-snapshot-\u0026lt;timestamp\u0026gt;.csv Human-readable copy for diffing. Multi-valued attributes joined with a pipe character. ad-snapshot-\u0026lt;timestamp\u0026gt;.txt Metadata: DC, search bases, user count, attribute list. Each snapshot record holds ObjectGUID, SamAccountName, DistinguishedName, ParentDN, CN, Enabled, ManagerGuid, and an Attributes hashtable.\nNotes The output contains PII (names, phone numbers, email and employee IDs). Store and retain it accordingly. Only user objects are captured. Group membership, passwords and attributes outside the captured list are not. A manager that can\u0026rsquo;t be resolved produces a warning, and ManagerGuid is left empty for that user. License Copyright © 2026 Steve LaPorte. Released under the GNU General Public License v3.0 or later\r. Provided as-is, with no warranty. Test in a lab before running against production.\nScript View on GitHub\r. Source also provided below:\n\u0026lt;# .SYNOPSIS Point-in-time snapshot of AD user attributes, CN, OU and enabled state, keyed on objectGUID, for use with Restore-ADUserAttributes.ps1. Read-only. .DESCRIPTION Captures a configurable set of attributes for every user under the given OU(s) and writes: ad-snapshot-\u0026lt;timestamp\u0026gt;.clixml Restore source (types preserved, multi-valued kept as arrays) ad-snapshot-\u0026lt;timestamp\u0026gt;.csv Human-readable copy for diffing ad-snapshot-\u0026lt;timestamp\u0026gt;.txt Metadata (DC, OUs, attribute list, counts) Attributes that don\u0026#39;t exist in the forest\u0026#39;s schema are skipped with a warning. Manager is stored as a GUID as well as a DN, so a renamed or moved manager doesn\u0026#39;t break a restore. Nothing in AD is modified. The output files contain PII - store them accordingly. .PARAMETER SearchBase One or more OU/container distinguished names to capture (subtree search), e.g. \u0026#34;OU=Staff,DC=corp,DC=example,DC=com\u0026#34;. .PARAMETER ExtraAttributes Additional LDAP attribute names to capture, e.g. custom schema attributes or anything your provisioning/sync tooling writes that isn\u0026#39;t in the default list. .PARAMETER Server DC to read from. Also accepts a mounted AD snapshot, e.g. localhost:51389 (dsamain). .PARAMETER OutDir Output directory (created if missing). Default: current directory. .EXAMPLE .\\Backup-ADUserAttributes.ps1 -SearchBase \u0026#34;OU=Staff,DC=corp,DC=example,DC=com\u0026#34; -OutDir C:\\ADBackups .EXAMPLE .\\Backup-ADUserAttributes.ps1 -SearchBase \u0026#34;OU=Staff,DC=corp,DC=example,DC=com\u0026#34;,\u0026#34;OU=Contractors,DC=corp,DC=example,DC=com\u0026#34; -ExtraAttributes msDS-cloudExtensionAttribute1 -Server dc01.corp.example.com .NOTES Author: Steve LaPorte (stevelaporte.net | github.com/stevelaporte) Requires: PowerShell 5.1+, ActiveDirectory module (RSAT), read access to the target OUs 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 \u0026lt;https://www.gnu.org/licenses/\u0026gt;. #\u0026gt; [CmdletBinding()] param( [Parameter(Mandatory)][string[]]$SearchBase, [string[]]$ExtraAttributes = @(), [string]$Server, [string]$OutDir = \u0026#34;.\u0026#34; ) $ErrorActionPreference = \u0026#39;Stop\u0026#39; # a bad OU or DC fails the run instead of producing a partial snapshot Import-Module ActiveDirectory $common = @{} if ($Server) { $common.Server = $Server } foreach ($ou in $SearchBase) { try { Get-ADObject @common -Identity $ou | Out-Null } catch { throw \u0026#34;SearchBase not found: $ou\u0026#34; } } New-Item -ItemType Directory -Path $OutDir -Force | Out-Null $default = @( \u0026#39;givenName\u0026#39;,\u0026#39;sn\u0026#39;,\u0026#39;initials\u0026#39;,\u0026#39;middleName\u0026#39;,\u0026#39;displayName\u0026#39;,\u0026#39;description\u0026#39;, \u0026#39;title\u0026#39;,\u0026#39;department\u0026#39;,\u0026#39;division\u0026#39;,\u0026#39;company\u0026#39;,\u0026#39;manager\u0026#39;, \u0026#39;employeeID\u0026#39;,\u0026#39;employeeNumber\u0026#39;,\u0026#39;employeeType\u0026#39;, \u0026#39;physicalDeliveryOfficeName\u0026#39;,\u0026#39;streetAddress\u0026#39;,\u0026#39;l\u0026#39;,\u0026#39;st\u0026#39;,\u0026#39;postalCode\u0026#39;,\u0026#39;c\u0026#39;,\u0026#39;co\u0026#39;,\u0026#39;countryCode\u0026#39;, \u0026#39;telephoneNumber\u0026#39;,\u0026#39;mobile\u0026#39;,\u0026#39;ipPhone\u0026#39;,\u0026#39;facsimileTelephoneNumber\u0026#39;,\u0026#39;homePhone\u0026#39;,\u0026#39;otherTelephone\u0026#39;, \u0026#39;info\u0026#39;,\u0026#39;wWWHomePage\u0026#39;,\u0026#39;mail\u0026#39;,\u0026#39;proxyAddresses\u0026#39;,\u0026#39;userPrincipalName\u0026#39;,\u0026#39;sAMAccountName\u0026#39; ) + (1..15 | ForEach-Object { \u0026#34;extensionAttribute$_\u0026#34; }) # Drop anything not in this forest\u0026#39;s schema (Get-ADUser errors on unknown attributes) $schemaNC = (Get-ADRootDSE @common).schemaNamingContext $Attributes = foreach ($a in ($default + $ExtraAttributes | Select-Object -Unique)) { if (Get-ADObject @common -SearchBase $schemaNC -Filter \u0026#34;lDAPDisplayName -eq \u0026#39;$a\u0026#39;\u0026#34;) { $a } else { Write-Warning \u0026#34;Attribute \u0026#39;$a\u0026#39; not in schema - skipped.\u0026#34; } } $users = foreach ($ou in $SearchBase) { Get-ADUser @common -Filter * -SearchBase $ou -SearchScope Subtree -Properties ($Attributes + \u0026#39;cn\u0026#39;) } $users = $users | Sort-Object ObjectGUID -Unique # Manager is a DN; store the manager\u0026#39;s GUID too so a rename/move can\u0026#39;t break the restore $dnToGuid = @{} foreach ($u in $users) { $dnToGuid[$u.DistinguishedName] = $u.ObjectGUID } function Get-ParentDN([string]$dn) { $dn -replace \u0026#39;^CN=.+?(?\u0026lt;!\\\\),\u0026#39;, \u0026#39;\u0026#39; } $stamp = Get-Date -Format \u0026#39;yyyyMMdd-HHmmss\u0026#39; $snapshot = foreach ($u in $users) { $attrs = @{} foreach ($a in $Attributes) { $v = $u.$a if ($null -eq $v) { $attrs[$a] = $null } elseif ($v -is [System.Collections.IEnumerable] -and $v -isnot [string]) { $arr = [string[]]@($v | ForEach-Object { \u0026#34;$_\u0026#34; }) $attrs[$a] = if ($arr.Count) { ,$arr } else { $null } } else { $attrs[$a] = $v } } $mgrGuid = $null if ($u.manager) { $mgrGuid = $dnToGuid[$u.manager] if (-not $mgrGuid) { try { $mgrGuid = (Get-ADUser @common -Identity $u.manager).ObjectGUID } catch { Write-Warning \u0026#34;Can\u0026#39;t resolve manager for $($u.SamAccountName): $($u.manager)\u0026#34; } } } [pscustomobject]@{ ObjectGUID = $u.ObjectGUID SamAccountName = $u.SamAccountName DistinguishedName = $u.DistinguishedName ParentDN = Get-ParentDN $u.DistinguishedName CN = $u.cn Enabled = $u.Enabled ManagerGuid = $mgrGuid Attributes = $attrs SnapshotTime = $stamp } } $base = Join-Path $OutDir \u0026#34;ad-snapshot-$stamp\u0026#34; $snapshot | Export-Clixml \u0026#34;$base.clixml\u0026#34; -Depth 5 $snapshot | ForEach-Object { $row = [ordered]@{ ObjectGUID = $_.ObjectGUID; SamAccountName = $_.SamAccountName; CN = $_.CN ParentDN = $_.ParentDN; Enabled = $_.Enabled } foreach ($a in $Attributes) { $v = $_.Attributes[$a] $row[$a] = if ($v -is [array]) { $v -join \u0026#39; | \u0026#39; } else { $v } } [pscustomobject]$row } | Export-Csv \u0026#34;$base.csv\u0026#34; -NoTypeInformation @\u0026#34; Snapshot: $stamp Server: $(if ($Server) { $Server } else { \u0026#39;(default DC)\u0026#39; }) SearchBase: $($SearchBase -join \u0026#39;; \u0026#39;) Users: $(@($snapshot).Count) Attributes: $($Attributes -join \u0026#39;, \u0026#39;) \u0026#34;@ | Set-Content \u0026#34;$base.txt\u0026#34; Write-Host \u0026#34;Captured $(@($snapshot).Count) users -\u0026gt; $base.clixml\u0026#34; ","permalink":"https://stevelaporte.net/scripts/backup-ad-attributes/","summary":"\u003cp\u003e\u003cimg alt=\"PowerShell Script - Backup-ADUserAttributes.ps1\" loading=\"lazy\" src=\"/pwsh.png#center\" title=\"PowerShell Script - Backup-ADUserAttributes.ps1\"\u003e\u003c/p\u003e\n\u003ch2 id=\"what-it-is\"\u003eWhat it is\u003c/h2\u003e\n\u003cp\u003eA read-only PowerShell script that captures a configurable set of attributes, plus CN, OU and enabled state, for every user under one or more OUs. Output is keyed on \u003ccode\u003eobjectGUID\u003c/code\u003e, so a snapshot stays usable after users are renamed or moved. It\u0026rsquo;s the capture half of a pair; the restore half is \u003ca href=\"/scripts/restore-ad-attributes\"\u003eRestore-ADUserAttributes.ps1\u003c/a\u003e\r\n.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eTypical use:\u003c/strong\u003e take a snapshot before anything that bulk-writes to AD (a sync tool, an HR-driven provisioning job, a mass script), so you have something to diff against or roll back to.\u003c/p\u003e","title":"Backup-ADUserAttributes.ps1: point-in-time snapshot of AD user attributes"}]