Production-Ready Active Directory Automation with PowerShell
Last reviewed: June 2026
Scope
This article is about the difference between a PowerShell snippet and a PowerShell tool. Most AD automation you find online is a one-liner that works on the author's machine and quietly assumes a clean domain, an interactive admin, and a forgiving change window. None of those hold in production. What follows is a complete, advanced example — a stale-account discovery and remediation tool built as proper advanced functions, with pipeline composition, structured logging, fail-closed error handling, rollback, and Pester tests — and the reasoning behind each engineering decision. It is not a copy-paste solution for every domain; it is a pattern you can adapt.
What "production-grade" means for an advanced function
A production AD function is more than a working command. The properties I insist on, and that the example below demonstrates:
SupportsShouldProcesswith a meaningfulConfirmImpactso-WhatIfand-Confirmwork, and high-impact actions prompt by default.- Typed, validated parameters (
[ValidateRange],[ValidateScript],[ValidatePattern]) so bad input fails before any directory call, not halfway through. - Pipeline composition via
ValueFromPipelineByPropertyName, so discovery and remediation are separate, testable functions you can chain —Get-StaleAdAccount | Disable-StaleAdAccount. begin/process/endblocks so the function behaves correctly when it receives many objects from the pipeline, and so logs and rollback data are assembled once.- Fail-closed error handling: every directory call uses
-ErrorAction Stopinsidetry/catch, and an error on one object is recorded and skipped rather than aborting the batch or, worse, continuing blindly. - Structured output: the function emits objects (
[pscustomobject]), not console text, so results can be filtered, exported, or asserted against in tests. - An explicit rollback artifact captured before the change.
- Comment-based help and
[OutputType]so the tool is discoverable and self-documenting.
If a function does not have these, it is a demo. The rest of this article builds one that does.
A complete tool: stale-account discovery and remediation
Package this as a small module (AdHygiene.psm1 exporting the two public functions, plus a private logging helper). Splitting discovery from remediation is the most important design choice here: the read-only function is safe to run anywhere and produces the evidence; the state-changing function consumes that evidence and is the only place that touches the directory.
Private helper: structured operation logging
function Write-OperationLog {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Action,
[Parameter(Mandatory)][string]$Target,
[Parameter(Mandatory)][ValidateSet('Success','Failure','WhatIf','Skipped')][string]$Result,
[string]$Detail,
[Parameter(Mandatory)][string]$LogPath
)
$entry = [pscustomobject]@{
Timestamp = (Get-Date).ToString('o')
Action = $Action
Target = $Target
Result = $Result
Detail = $Detail
RunBy = "$env:USERDOMAIN\$env:USERNAME"
}
$entry | Export-Csv -Path $LogPath -NoTypeInformation -Append -Encoding UTF8
Write-Output $entry
}
The log entry is an object first and a CSV row second. That means the same record drives both the on-disk audit trail and the function's return value, and tests can assert against it without parsing text.
Discovery: Get-StaleAdAccount (read-only)
function Get-StaleAdAccount {
<#
.SYNOPSIS
Finds enabled, inactive AD user accounts, excluding service and break-glass accounts.
.EXAMPLE
Get-StaleAdAccount -SearchBase 'OU=Staff,DC=contoso,DC=com' -InactiveDays 120
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[string]$SearchBase,
[ValidateRange(1, 3650)]
[int]$InactiveDays = 90,
[ValidatePattern('^\S+$')]
[string]$ServiceAccountPattern = '^(svc|sa|gmsa)[-_]',
[string[]]$BreakGlassSamAccountName = @('breakglass', 'emergency-admin'),
[string]$Server
)
$cutoff = (Get-Date).AddDays(-$InactiveDays)
$query = @{
SearchBase = $SearchBase
Filter = "Enabled -eq 'True'"
Properties = 'LastLogonDate', 'whenCreated', 'Description', 'MemberOf'
}
if ($PSBoundParameters.ContainsKey('Server')) { $query.Server = $Server }
Get-ADUser @query | ForEach-Object {
$effectiveLast = if ($_.LastLogonDate) { $_.LastLogonDate } else { $_.whenCreated }
$reason =
if ($_.SamAccountName -in $BreakGlassSamAccountName) { 'Excluded: break-glass' }
elseif ($_.SamAccountName -match $ServiceAccountPattern) { 'Excluded: service account' }
elseif ($effectiveLast -ge $cutoff) { 'Active' }
else { 'Stale' }
if ($reason -eq 'Stale') {
[pscustomobject]@{
SamAccountName = $_.SamAccountName
DistinguishedName = $_.DistinguishedName
LastActivity = $effectiveLast
DaysInactive = [int]((Get-Date) - $effectiveLast).TotalDays
Privileged = [bool]($_.MemberOf -match 'Domain Admins|Enterprise Admins|Schema Admins')
}
}
}
}
Two correctness points worth calling out, because they are the bugs I see most often in "stale account" scripts. First, LastLogonDate is a calculated property the AD module builds from lastLogonTimestamp — you cannot use it in the server-side -Filter, so Enabled is filtered on the server and inactivity is evaluated client-side. Second, lastLogonTimestamp is deliberately imprecise: it only replicates roughly every 9–14 days, so treat it as fine for a "clearly stale for months" threshold, not for precise last-activity reporting. An account that has never logged on has a null LastLogonDate, so the example falls back to whenCreated rather than silently treating null as "infinitely stale."
Remediation: Disable-StaleAdAccount (state-changing, gated)
function Disable-StaleAdAccount {
<#
.SYNOPSIS
Disables and quarantines stale accounts from the pipeline, with rollback capture.
.EXAMPLE
Get-StaleAdAccount -SearchBase $ou | Disable-StaleAdAccount -QuarantineOu $q -RollbackPath .\rb.csv -LogPath .\log.csv
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[string]$SamAccountName,
[Parameter(ValueFromPipelineByPropertyName)]
[bool]$Privileged,
[Parameter(Mandatory)]
[string]$QuarantineOu,
[Parameter(Mandatory)]
[string]$RollbackPath,
[Parameter(Mandatory)]
[string]$LogPath,
[string]$Server
)
begin {
$common = @{}
if ($PSBoundParameters.ContainsKey('Server')) { $common.Server = $Server }
$rollback = [System.Collections.Generic.List[object]]::new()
}
process {
try {
$user = Get-ADUser -Identity $SamAccountName -Properties DistinguishedName, Enabled @common -ErrorAction Stop
if ($Privileged -and -not $PSCmdlet.ShouldContinue(
"$SamAccountName is in a privileged group. Disable anyway?", 'Privileged account')) {
Write-OperationLog -Action 'Disable' -Target $SamAccountName -Result 'Skipped' -Detail 'Privileged, operator declined' -LogPath $LogPath
return
}
if ($PSCmdlet.ShouldProcess($SamAccountName, 'Disable and quarantine stale account')) {
$rollback.Add([pscustomobject]@{
SamAccountName = $user.SamAccountName
DistinguishedName = $user.DistinguishedName
WasEnabled = $user.Enabled
CapturedAt = (Get-Date).ToString('o')
})
Disable-ADAccount -Identity $user.DistinguishedName @common -ErrorAction Stop
Move-ADObject -Identity $user.DistinguishedName -TargetPath $QuarantineOu @common -ErrorAction Stop
Write-OperationLog -Action 'Disable' -Target $SamAccountName -Result 'Success' -LogPath $LogPath
}
else {
Write-OperationLog -Action 'Disable' -Target $SamAccountName -Result 'WhatIf' -LogPath $LogPath
}
}
catch {
Write-OperationLog -Action 'Disable' -Target $SamAccountName -Result 'Failure' -Detail $_.Exception.Message -LogPath $LogPath
Write-Error "Failed to disable $SamAccountName : $($_.Exception.Message)"
}
}
end {
if ($rollback.Count) {
$rollback | Export-Csv -Path $RollbackPath -NoTypeInformation -Encoding UTF8
}
}
}
This is where the advanced patterns earn their place. The function takes its input from the pipeline by property name, so the discovery function's output flows straight in. ShouldProcess gives you -WhatIf and a confirmation prompt for free; ShouldContinue adds a second, independent gate specifically for privileged accounts, which is the population you least want to disable by accident. Every directory call is -ErrorAction Stop inside try/catch, so a single bad object is logged as a Failure and skipped — the batch fails closed on that object rather than aborting everything or, worse, continuing as if nothing happened. Disabling rather than deleting, and moving to a quarantine OU, means the change is reversible from the rollback file. And because the rollback record is captured inside the ShouldProcess block, a -WhatIf run writes no rollback file and makes no change — exactly what you want from a preview.
Composing the tool
$params = @{
QuarantineOu = 'OU=Quarantine,DC=contoso,DC=com'
RollbackPath = ".\rollback-$(Get-Date -f yyyyMMdd-HHmmss).csv"
LogPath = ".\stale-$(Get-Date -f yyyyMMdd).csv"
}
# Preview only — no changes, no rollback file:
Get-StaleAdAccount -SearchBase 'OU=Staff,DC=contoso,DC=com' -InactiveDays 120 |
Disable-StaleAdAccount @params -WhatIf
# Execute, with a confirmation prompt on every privileged account:
Get-StaleAdAccount -SearchBase 'OU=Staff,DC=contoso,DC=com' -InactiveDays 120 |
Disable-StaleAdAccount @params
Discovery and remediation are separate commands, which is what makes the whole thing reviewable: a change reviewer can run the first command, inspect the objects, and only then pipe them into the second.
Run it as a service identity, with least privilege
An advanced tool is only as safe as the account it runs under. In production I run scheduled AD automation under a group managed service account (gMSA), not a human admin and not a stored credential. The gMSA's password is managed by AD and never handled by a person, and it is granted only the specific delegated rights the task needs — for this tool, the right to disable accounts and move objects within the target and quarantine OUs, and nothing else. Delegate at the OU level, never by adding the account to Account Operators or Domain Admins. The principle is simple: the blast radius of a compromised or buggy automation account should be the OU it operates on, not the forest.
Prove it with Pester
A tool that touches identity should have tests, and the read/write split makes them straightforward because you can mock the AD cmdlets. These are Pester v5 tests; Get-ADUser and the change cmdlets are mocked so the tests never touch a real directory.
BeforeAll {
. $PSScriptRoot\AdHygiene.ps1 # dot-source the functions under test
}
Describe 'Get-StaleAdAccount' {
BeforeAll {
Mock Get-ADUser {
@(
[pscustomobject]@{ SamAccountName = 'j.smith'; Enabled = $true; LastLogonDate = (Get-Date).AddDays(-200); whenCreated = (Get-Date).AddYears(-3); DistinguishedName = 'CN=j.smith,OU=Staff,DC=contoso,DC=com'; MemberOf = @() }
[pscustomobject]@{ SamAccountName = 'svc-sql'; Enabled = $true; LastLogonDate = (Get-Date).AddDays(-400); whenCreated = (Get-Date).AddYears(-4); DistinguishedName = 'CN=svc-sql,OU=Staff,DC=contoso,DC=com'; MemberOf = @() }
[pscustomobject]@{ SamAccountName = 'breakglass'; Enabled = $true; LastLogonDate = (Get-Date).AddDays(-500); whenCreated = (Get-Date).AddYears(-5); DistinguishedName = 'CN=breakglass,OU=Admins,DC=contoso,DC=com'; MemberOf = @('CN=Domain Admins,...') }
[pscustomobject]@{ SamAccountName = 'a.active'; Enabled = $true; LastLogonDate = (Get-Date).AddDays(-3); whenCreated = (Get-Date).AddYears(-1); DistinguishedName = 'CN=a.active,OU=Staff,DC=contoso,DC=com'; MemberOf = @() }
)
}
}
It 'flags an inactive enabled user as stale' {
(Get-StaleAdAccount -SearchBase 'OU=Staff,DC=contoso,DC=com').SamAccountName | Should -Contain 'j.smith'
}
It 'excludes service accounts by naming pattern' {
(Get-StaleAdAccount -SearchBase 'OU=Staff,DC=contoso,DC=com').SamAccountName | Should -Not -Contain 'svc-sql'
}
It 'never returns a break-glass account, even when long inactive' {
(Get-StaleAdAccount -SearchBase 'OU=Staff,DC=contoso,DC=com').SamAccountName | Should -Not -Contain 'breakglass'
}
It 'does not flag a recently active user' {
(Get-StaleAdAccount -SearchBase 'OU=Staff,DC=contoso,DC=com').SamAccountName | Should -Not -Contain 'a.active'
}
}
Describe 'Disable-StaleAdAccount' {
BeforeAll {
Mock Get-ADUser { [pscustomobject]@{ SamAccountName = 'j.smith'; Enabled = $true; DistinguishedName = 'CN=j.smith,OU=Staff,DC=contoso,DC=com' } }
Mock Disable-ADAccount {}
Mock Move-ADObject {}
Mock Export-Csv {}
}
It 'makes no changes under -WhatIf' {
'j.smith' | Disable-StaleAdAccount -QuarantineOu 'OU=Q,DC=contoso,DC=com' -RollbackPath 'TestDrive:\rb.csv' -LogPath 'TestDrive:\log.csv' -WhatIf
Should -Invoke Disable-ADAccount -Times 0
}
It 'disables and quarantines when confirmed' {
'j.smith' | Disable-StaleAdAccount -QuarantineOu 'OU=Q,DC=contoso,DC=com' -RollbackPath 'TestDrive:\rb.csv' -LogPath 'TestDrive:\log.csv' -Confirm:$false
Should -Invoke Disable-ADAccount -Times 1
Should -Invoke Move-ADObject -Times 1
}
It 'fails closed: a directory error is caught, not fatal' {
Mock Get-ADUser { throw 'server unavailable' }
{ 'j.smith' | Disable-StaleAdAccount -QuarantineOu 'OU=Q,DC=contoso,DC=com' -RollbackPath 'TestDrive:\rb.csv' -LogPath 'TestDrive:\log.csv' -Confirm:$false -ErrorAction SilentlyContinue } | Should -Not -Throw
Should -Invoke Disable-ADAccount -Times 0
}
}
Run them with Invoke-Pester, and run them in CI on every change to the module. The break-glass and fail-closed tests are the two I care about most: they encode operational knowledge — "never disable the emergency account," "a flaky DC must not produce a misleading result" — that no amount of reading the code will guarantee on its own. The -WhatIf test proves the preview is genuinely side-effect-free, which is the property the whole safety model rests on.
Minimum production standards
Use this as the checklist the tool above is built to satisfy:
SupportsShouldProcessand a sensibleConfirmImpacton every change-making function.- Typed parameter validation that rejects bad input before the first directory call.
- Read-only discovery separated from state-changing remediation.
-ErrorAction Stopinsidetry/catchon every AD call, failing closed per object.- Structured object output and an append-only, structured log.
- A rollback artifact captured before the change, written only on real execution.
- Execution under a least-privilege gMSA with OU-scoped delegation.
- Pester tests covering exclusions,
-WhatIf, the happy path, and failure paths, run in CI. - A test OU and a real change-approval step before production.
Final recommendation
The gap between a snippet and a tool is the gap between "it ran on my machine" and "operations can run it on a Tuesday and explain exactly what it did." Advanced PowerShell — advanced functions, pipeline composition, ShouldProcess/ShouldContinue, fail-closed error handling, structured logging, rollback, and tests — is how you close that gap. If a script that changes Active Directory cannot preview itself, log every action as data, produce rollback evidence, run under a least-privilege identity, and pass a test that proves it won't touch the break-glass account, it is not ready for production, no matter how clever the one-liner at its core.