This guide continues the Active Directory at Scale series. It assumes you have the
ActiveDirectoryPowerShell module and delegated write access to the OU tree you're reconciling. If your state file is YAML you also need thepowershell-yamlmodule (Install-Module powershell-yaml -Scope CurrentUser); JSON needs nothing extra (ConvertFrom-Jsonis built in).
The promise of "infrastructure as code" is that a file in git is the truth, and the running system is reconciled towards it. AD is one of the last tier-zero systems that usually isn't managed this way. Everyone else gets Terraform; AD gets a screenshot of an OU tree in a SharePoint page updated in 2019.
This post closes that gap. We'll define the OU / group / ACL topology in YAML (or JSON both are accepted, see the primer's loader), apply it idempotently with the Get-Test-Set pattern, and handle the two operations that usually break naive reconcilers: renames and moves.
The Shape of the State File
One file ad-state.yaml per domain. Everything under it is declarative.
domain: corp.example.com
searchBase: DC=corp,DC=example,DC=com
organizationalUnits:
- id: ou.staff # stable identity (see below)
name: Staff
path: DC=corp,DC=example,DC=com
description: Employee accounts
protectFromAccidentalDeletion: true
blockInheritance: false
- id: ou.staff.engineering
name: Engineering
path: OU=Staff,DC=corp,DC=example,DC=com
description: Engineering org
protectFromAccidentalDeletion: true
- id: ou.groups
name: Groups
path: DC=corp,DC=example,DC=com
protectFromAccidentalDeletion: true
groups:
- id: grp.engineering.all
name: Eng-All
path: OU=Groups,DC=corp,DC=example,DC=com
scope: DomainLocal
category: Security
description: All engineering staff
members:
- ref: user:alice
- ref: user:bob
- id: grp.engineering.admins
name: Eng-Admins
path: OU=Groups,DC=corp,DC=example,DC=com
scope: DomainLocal
category: Security
description: Admin rights on engineering servers
members:
- ref: group:grp.engineering.all
acls:
- target: ou.staff.engineering
identity: grp.engineering.admins
rights: [ReadProperty, WriteProperty, GenericExecute]
applies: Descendants # Self | Children | Descendants
inheritance: All
Three classes of object (OUs, groups, ACLs), each with stable IDs decoupled from names. The IDs are what the reconciler tracks. That's the key insight more on it in a moment.
Why IDs, Not Names
AD's default "primary key" is the Distinguished Name. The DN changes when you rename or move an object. A naive reconciler that keys off DN treats a rename as delete + create losing group memberships, GPO links, ACLs, and history.
We solve this by storing a stable identity in a well-known attribute the reconciler controls. The obvious candidate is extensionAttribute15 (rarely used for anything in the wild) or a custom attribute if you can extend the schema. For most environments, extensionAttribute15 is plenty.
The reconciler's rule:
- Look up by
extensionAttribute15 == "<id>"first. - If not found, fall back to looking up by DN.
- If still not found, create and stamp the ID.
This lets you rename OU=Staff to OU=Employees in the YAML the reconciler finds the old OU by ID, renames it, and keeps all downstream references intact.
function Find-AdObjectById
{
<#
.SYNOPSIS
Locates an AD object by its stable id (extensionAttribute15).
.DESCRIPTION
Used by every reconciler in this series to decouple "identity" from
Distinguished Name. A rename or move changes the DN; the stable id
stays put, so the reconciler finds the same object and updates it
in place instead of creating a duplicate.
.PARAMETER Id
The stable id previously stamped onto extensionAttribute15.
.PARAMETER SearchBase
Distinguished name of the subtree to scope the search to.
.OUTPUTS
Microsoft.ActiveDirectory.Management.ADObject
#>
[CmdletBinding()]
[OutputType([object])]
param
(
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[string]
$Id,
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[string]
$SearchBase
)
begin
{
Write-Verbose -Message ('[Find-AdObjectById] Begin')
}
process
{
$findParams = @{
LDAPFilter = "(extensionAttribute15=$Id)"
SearchBase = $SearchBase
Properties = 'extensionAttribute15', 'name', 'distinguishedName', 'objectClass'
ErrorAction = 'SilentlyContinue'
}
Get-ADObject @findParams | Select-Object -First 1
}
end
{
Write-Verbose -Message ('[Find-AdObjectById] End')
}
}
OU Reconciler Create, Rename, Move
The Get-Test-Set triple for an OU, following the pattern from the primer:
function Get-DesiredOuState
{
<#
.SYNOPSIS
Returns the current state of an Organizational Unit by stable id.
.DESCRIPTION
Looks the OU up by its extensionAttribute15 id. Missing is not an
error - an object with Exists = $false is returned instead, so
downstream Test-* / Set-* functions can rely on uniform shape.
.PARAMETER Id
Stable id of the OU.
.PARAMETER SearchBase
Distinguished name of the subtree to scope the search to.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param
(
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[string]
$Id,
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[string]
$SearchBase
)
begin
{
Write-Verbose -Message ('[Get-DesiredOuState] Begin')
}
process
{
$ou = Find-AdObjectById -Id $Id -SearchBase $SearchBase
if (-not $ou)
{
return [pscustomobject] @{
Exists = $false
Id = $Id
DistinguishedName = $null
}
}
$full = Get-ADOrganizationalUnit -Identity $ou.DistinguishedName -Properties 'Description', 'ProtectedFromAccidentalDeletion', 'extensionAttribute15'
[pscustomobject] @{
Exists = $true
Id = $Id
DistinguishedName = $full.DistinguishedName
Name = $full.Name
ParentDn = ($full.DistinguishedName -replace '^OU=[^,]+,', '')
Description = $full.Description
ProtectedFromAccidentalDeletion = [bool]$full.ProtectedFromAccidentalDeletion
}
}
end
{
Write-Verbose -Message ('[Get-DesiredOuState] End')
}
}
The Test-* function follows the chain laid out in the primer it takes Desired, calls Get-* internally, and returns { Compliant, Drifts, Current } so Set-* never has to read state again:
function Test-OuCompliance
{
<#
.SYNOPSIS
Reads an OU's current state and classifies drift against Desired.
.DESCRIPTION
Calls Get-DesiredOuState internally so callers pass only Desired +
SearchBase. Returns the current state on the result object so
Set-OuCompliance does not have to re-read.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param
(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[pscustomobject]
$Desired,
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[string]
$SearchBase
)
begin
{
Write-Verbose -Message '[Test-OuCompliance] Begin'
}
process
{
$current = Get-DesiredOuState -Id $Desired.Id -SearchBase $SearchBase
$drifts = New-Object -TypeName 'System.Collections.Generic.List[string]'
if (-not $current.Exists)
{
$drifts.Add('OU does not exist')
}
else
{
if ($current.Name -ne $Desired.Name)
{
$drifts.Add(('Name: {0} != {1}' -f $current.Name, $Desired.Name))
}
if ($current.ParentDn -ne $Desired.Path)
{
$drifts.Add(('Parent: {0} != {1}' -f $current.ParentDn, $Desired.Path))
}
if (($current.Description ?? '') -ne ($Desired.Description ?? ''))
{
$drifts.Add('Description drift')
}
if ($current.ProtectedFromAccidentalDeletion -ne $Desired.ProtectFromAccidentalDeletion)
{
$drifts.Add('Deletion-protection drift')
}
}
[pscustomobject] @{
Compliant = $drifts.Count -eq 0
Drifts = $drifts.ToArray()
Current = $current
}
}
end
{
Write-Verbose -Message '[Test-OuCompliance] End'
}
}
The interesting function is Set-* it drives the create/rename/move/attribute flow, but calls Test-* to get its view of the world (no direct Get-* call):
function Set-OuCompliance
{
<#
.SYNOPSIS
Converges an OU to match the desired-state object.
.DESCRIPTION
Handles create, rename, move, and attribute drift in that order so
later steps always operate on a well-placed object. Every mutation
is guarded by ShouldProcess so -WhatIf produces a full dry-run.
.PARAMETER Desired
Desired-state hashtable with Id, Name, Path, Description, and
ProtectFromAccidentalDeletion.
.PARAMETER SearchBase
Root of the subtree the reconciler operates within.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
[OutputType([pscustomobject])]
param
(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[pscustomobject]
$Desired,
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[string]
$SearchBase
)
begin
{
Write-Verbose -Message ('[Set-OuCompliance] Begin')
}
process
{
$ErrorActionPreference = 'Stop'
$test = Test-OuCompliance -Desired $Desired -SearchBase $SearchBase
$current = $test.Current
$actions = New-Object -TypeName 'System.Collections.Generic.List[string]'
$errors = New-Object -TypeName 'System.Collections.Generic.List[string]'
if ($test.Compliant)
{
return [pscustomobject] @{
Id = $Desired.Id
Changed = $false
Actions = @()
Errors = @()
}
}
# 1. Create if missing
if (-not $current.Exists)
{
if ($PSCmdlet.ShouldProcess($Desired.Name, "Create OU in $($Desired.Path)"))
{
$newParams = @{
Name = $Desired.Name
Path = $Desired.Path
Description = $Desired.Description
ProtectedFromAccidentalDeletion = $Desired.ProtectFromAccidentalDeletion
OtherAttributes = @{ extensionAttribute15 = $Desired.Id }
ErrorAction = 'Stop'
}
try
{
$null = New-ADOrganizationalUnit @newParams
$actions.Add('Created OU')
}
catch [Microsoft.ActiveDirectory.Management.ADException]
{
$errors.Add(('New-ADOrganizationalUnit failed for {0}: {1}' -f $Desired.Name, $_.Exception.Message))
throw
}
catch
{
$errors.Add(('Unexpected error creating {0}: {1}' -f $Desired.Name, $_.Exception.Message))
throw
}
}
return [pscustomobject] @{
Id = $Desired.Id
Changed = $actions.Count -gt 0
Actions = $actions.ToArray()
Errors = $errors.ToArray()
}
}
# 2. Rename if the name changed (same parent)
if ($current.Name -ne $Desired.Name)
{
if ($PSCmdlet.ShouldProcess($current.DistinguishedName, "Rename to $($Desired.Name)"))
{
try
{
# Protected-from-deletion blocks rename; lift then reapply
Set-ADOrganizationalUnit -Identity $current.DistinguishedName -ProtectedFromAccidentalDeletion $false -ErrorAction Stop
Rename-ADObject -Identity $current.DistinguishedName -NewName $Desired.Name -ErrorAction Stop
$actions.Add("Renamed to $($Desired.Name)")
$current = Get-DesiredOuState -Id $Desired.Id -SearchBase $SearchBase # refresh
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]
{
$errors.Add(('Rename target {0} disappeared mid-run; replication lag or concurrent delete' -f $current.DistinguishedName))
throw
}
catch [Microsoft.ActiveDirectory.Management.ADException]
{
$errors.Add(('Rename failed for {0}: {1}' -f $current.DistinguishedName, $_.Exception.Message))
throw
}
catch
{
$errors.Add(('Unexpected error renaming {0}: {1}' -f $current.DistinguishedName, $_.Exception.Message))
throw
}
}
}
# 3. Move if parent changed
if ($current.ParentDn -ne $Desired.Path)
{
if ($PSCmdlet.ShouldProcess($current.DistinguishedName, "Move to $($Desired.Path)"))
{
try
{
Set-ADOrganizationalUnit -Identity $current.DistinguishedName -ProtectedFromAccidentalDeletion $false -ErrorAction Stop
Move-ADObject -Identity $current.DistinguishedName -TargetPath $Desired.Path -ErrorAction Stop
$actions.Add("Moved under $($Desired.Path)")
$current = Get-DesiredOuState -Id $Desired.Id -SearchBase $SearchBase
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]
{
# Source or target not visible on this DC - usually replication lag from a pass-1 create.
$errors.Add(('Move failed; source or target not visible on this DC: {0} -> {1}' -f $current.DistinguishedName, $Desired.Path))
throw
}
catch [Microsoft.ActiveDirectory.Management.ADException]
{
$errors.Add(('Move failed for {0}: {1}' -f $current.DistinguishedName, $_.Exception.Message))
throw
}
catch
{
$errors.Add(('Unexpected error moving {0}: {1}' -f $current.DistinguishedName, $_.Exception.Message))
throw
}
}
}
# 4. Attribute drift
$setParams = @{ Identity = $current.DistinguishedName }
if (($current.Description ?? '') -ne ($Desired.Description ?? ''))
{
$setParams.Description = $Desired.Description
$actions.Add('Description updated')
}
if ($current.ProtectedFromAccidentalDeletion -ne $Desired.ProtectFromAccidentalDeletion)
{
$setParams.ProtectedFromAccidentalDeletion = $Desired.ProtectFromAccidentalDeletion
$actions.Add('Deletion protection toggled')
}
if ($setParams.Count -gt 1 -and
$PSCmdlet.ShouldProcess($current.DistinguishedName, 'Update OU attributes'))
{
try
{
Set-ADOrganizationalUnit @setParams -ErrorAction Stop
}
catch [Microsoft.ActiveDirectory.Management.ADException]
{
$errors.Add(('Set-ADOrganizationalUnit failed for {0}: {1}' -f $current.DistinguishedName, $_.Exception.Message))
throw
}
catch
{
$errors.Add(('Unexpected error updating {0}: {1}' -f $current.DistinguishedName, $_.Exception.Message))
throw
}
}
[pscustomobject] @{
Id = $Desired.Id
Changed = $actions.Count -gt 0
Actions = $actions.ToArray()
Errors = $errors.ToArray()
}
}
end
{
Write-Verbose -Message ('[Set-OuCompliance] End')
}
}
The order of operations matters:
- Create first so IDs exist for cross-references before anything references them.
- Rename before move
Move-ADObjectneeds the current DN to match; renaming first avoids a double-look-up bug. - Attribute drift last the cheapest, always safe, can run on every pass.
Protected-from-deletion is a gotcha: AD blocks rename / move / delete when the flag is set. The reconciler lifts it, does the change, then reapplies it in step 4.
Groups With Reference Resolution
The YAML uses ref: user:alice or ref: group:grp.engineering.all rather than hard DNs. That keeps the file portable across domains and avoids the "rename group, break ten references" cliff.
Reference resolution is a small function:
function Resolve-Ref
{
<#
.SYNOPSIS
Resolves a "kind:identity" reference to a Distinguished Name.
.DESCRIPTION
Accepts references like 'user:alice' or 'group:grp.engineering.all'
and returns the corresponding DN. Users are resolved by
sAMAccountName; groups by stable id via Find-AdObjectById.
Throws if the reference cannot be resolved.
.PARAMETER Ref
The reference string to resolve (e.g. 'user:alice').
.PARAMETER SearchBase
Distinguished name of the subtree to resolve within.
#>
[CmdletBinding()]
[OutputType([string])]
param
(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[string]
$Ref,
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[string]
$SearchBase
)
begin
{
Write-Verbose -Message ('[Resolve-Ref] Begin')
}
process
{
$parts = $Ref -split ':', 2
$kind = $parts[0]
$id = $parts[1]
switch ($kind)
{
'user'
{
$u = Get-ADUser -Filter "SamAccountName -eq '$id'" -SearchBase $SearchBase -ErrorAction SilentlyContinue
if (-not $u)
{
throw ('User not found: {0}' -f $id)
}
return $u.DistinguishedName
}
'group'
{
$g = Find-AdObjectById -Id $id -SearchBase $SearchBase
if (-not $g)
{
throw ('Group not found by id: {0}' -f $id)
}
return $g.DistinguishedName
}
default
{
throw ('Unknown ref kind: {0}' -f $kind)
}
}
}
end
{
Write-Verbose -Message ('[Resolve-Ref] End')
}
}
The group reconciler then resolves members[].ref to DNs before comparing to the current membership same compare/remediate logic as the primer.
ACLs The Hard Part
Object ACLs on OUs are where most "AD as code" projects fall over. Get-Acl in PowerShell returns an ActiveDirectorySecurity object; applying changes requires constructing ActiveDirectoryAccessRule objects with the right ObjectType GUIDs.
The trick is defining a small vocabulary of rights in the YAML and mapping to the full set of flags in code:
$adRightsMap = @{
'Read' = [System.DirectoryServices.ActiveDirectoryRights]::GenericRead
'Write' = [System.DirectoryServices.ActiveDirectoryRights]::GenericWrite
'FullControl' = [System.DirectoryServices.ActiveDirectoryRights]::GenericAll
'CreateChild' = [System.DirectoryServices.ActiveDirectoryRights]::CreateChild
'DeleteChild' = [System.DirectoryServices.ActiveDirectoryRights]::DeleteChild
'ReadProperty' = [System.DirectoryServices.ActiveDirectoryRights]::ReadProperty
'WriteProperty' = [System.DirectoryServices.ActiveDirectoryRights]::WriteProperty
'GenericExecute' = [System.DirectoryServices.ActiveDirectoryRights]::GenericExecute
}
$inheritanceMap = @{
'Self' = [System.DirectoryServices.ActiveDirectorySecurityInheritance]::None
'Children' = [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Children
'Descendants' = [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents
'All' = [System.DirectoryServices.ActiveDirectorySecurityInheritance]::All
}
The Test-* function for an ACL needs to compare the desired rule set against the actual DACL and classify each entry as missing, extra, or correct:
function Test-AclCompliance
{
<#
.SYNOPSIS
Tests whether a target DN has an ACE matching the desired rule.
.DESCRIPTION
Returns an object carrying both the compliance boolean and the
full ACL, so callers can mutate and Set-Acl without re-reading.
.PARAMETER TargetDn
Distinguished name of the object to inspect.
.PARAMETER IdentitySid
Security principal whose ACE we expect.
.PARAMETER Rights
Required AD rights (flags enum - a subset match is sufficient).
.PARAMETER Inheritance
Required inheritance scope.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param
(
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[string]
$TargetDn,
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[System.Security.Principal.SecurityIdentifier]
$IdentitySid,
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[System.DirectoryServices.ActiveDirectoryRights]
$Rights,
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[System.DirectoryServices.ActiveDirectorySecurityInheritance]
$Inheritance
)
begin
{
Write-Verbose -Message ('[Test-AclCompliance] Begin')
}
process
{
$acl = Get-Acl -Path ('AD:\{0}' -f $TargetDn)
$match = $acl.Access | Where-Object -FilterScript {
$_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) -eq $IdentitySid -and
$_.ActiveDirectoryRights -band $Rights -eq $Rights -and
$_.InheritanceType -eq $Inheritance -and
$_.AccessControlType -eq 'Allow'
}
[pscustomobject] @{
Compliant = [bool]$match
Acl = $acl
}
}
end
{
Write-Verbose -Message ('[Test-AclCompliance] End')
}
}
Notice -band $Rights -eq $Rights ACL rights are a flags enum. The desired set being a subset of the existing rights counts as compliant; we don't want to remove an inheritable right that was granted by a parent.
The Set-* side constructs and adds the missing rule:
function Set-AclCompliance
{
<#
.SYNOPSIS
Converges an ACE onto a target OU when drift is detected.
.DESCRIPTION
Looks up the target and principal by stable id, composes the
desired rule, and adds it to the DACL if not already present.
Deliberately additive - does not remove ACEs not described by
Desired; see the governance note in the post body.
.PARAMETER Desired
Hashtable with Target, Identity, Rights, Applies properties.
.PARAMETER SearchBase
Root of the subtree the reconciler operates within.
.OUTPUTS
System.Management.Automation.PSCustomObject
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
[OutputType([pscustomobject])]
param
(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[pscustomobject]
$Desired,
[Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
[string]
$SearchBase
)
begin
{
Write-Verbose -Message ('[Set-AclCompliance] Begin')
}
process
{
$ErrorActionPreference = 'Stop'
$errors = New-Object -TypeName 'System.Collections.Generic.List[string]'
$target = Find-AdObjectById -Id $Desired.Target -SearchBase $SearchBase
if (-not $target)
{
throw ('ACL target not found: {0}' -f $Desired.Target)
}
$principal = Find-AdObjectById -Id $Desired.Identity -SearchBase $SearchBase
if (-not $principal)
{
throw ('ACL principal not found: {0}' -f $Desired.Identity)
}
try
{
$sid = (Get-ADObject -Identity $principal.DistinguishedName -Properties objectSid -ErrorAction Stop).objectSid
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]
{
$errors.Add(('Principal {0} not visible on this DC (replication lag)' -f $principal.DistinguishedName))
throw
}
$rights = $Desired.Rights | ForEach-Object { $adRightsMap[$_] } |
Where-Object { $_ } | Measure-Object -Sum | Select-Object -ExpandProperty Sum
$inh = $inheritanceMap[$Desired.Applies]
$testParams = @{
TargetDn = $target.DistinguishedName
IdentitySid = $sid
Rights = $rights
Inheritance = $inh
}
$compliant = Test-AclCompliance @testParams
if ($compliant.Compliant)
{
return [pscustomobject] @{
Target = $Desired.Target
Changed = $false
Errors = @()
}
}
if ($PSCmdlet.ShouldProcess(('{0} / {1}' -f $Desired.Target, $Desired.Identity), 'Add ACE'))
{
try
{
$rule = [System.DirectoryServices.ActiveDirectoryAccessRule]::new(
$sid, $rights, [System.Security.AccessControl.AccessControlType]::Allow, $inh)
$compliant.Acl.AddAccessRule($rule)
Set-Acl -Path ('AD:\{0}' -f $target.DistinguishedName) -AclObject $compliant.Acl -ErrorAction Stop
}
catch [System.UnauthorizedAccessException]
{
$errors.Add(('Insufficient rights to update DACL on {0}: {1}' -f $target.DistinguishedName, $_.Exception.Message))
throw
}
catch [Microsoft.ActiveDirectory.Management.ADException]
{
$errors.Add(('Set-Acl failed for {0}: {1}' -f $target.DistinguishedName, $_.Exception.Message))
throw
}
catch
{
$errors.Add(('Unexpected error applying ACE to {0}: {1}' -f $target.DistinguishedName, $_.Exception.Message))
throw
}
}
[pscustomobject] @{
Target = $Desired.Target
Changed = $true
Errors = $errors.ToArray()
}
}
end
{
Write-Verbose -Message ('[Set-AclCompliance] End')
}
}
One crucial decision: do we also remove ACEs not present in the desired state? This depends on your governance model.
- If your YAML is the exclusive source of truth, yes the reconciler should remove anything else. Expect surprises the first time a manually granted right disappears.
- If the YAML is additive (delegations you know about), no only add missing ACEs, never remove.
Start additive. Move to exclusive only after a steady period of zero drift in your drift-detection reports.
The Runner
With the three reconcilers written, the top-level runner is trivial. It loads the state file via Import-DesiredState from the primer, so the same ad-state.yaml can also be delivered as ad-state.json useful when the file is generated from another tool (Terraform, a CMDB exporter, etc.) rather than hand-edited:
[CmdletBinding(SupportsShouldProcess)]
param
(
[Parameter(Mandatory = $true)]
[ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
[string]
$StateFile
)
$ErrorActionPreference = 'Stop'
Import-Module ./src/ADOps/ADOps.psd1 -Force
# Accepts .yaml, .yml, or .json - dispatcher normalizes to PSCustomObject.
$state = Import-DesiredState -Path $StateFile
# Two passes: first creates, second resolves cross-references
1..2 | ForEach-Object {
Write-Verbose -Message ('[pass {0}]' -f $_)
foreach ($ou in $state.organizationalUnits) { Set-OuCompliance -Desired $ou -SearchBase $state.searchBase }
foreach ($g in $state.groups) { Set-GroupCompliance -Desired $g -SearchBase $state.searchBase }
foreach ($a in $state.acls) { Set-AclCompliance -Desired $a -SearchBase $state.searchBase }
}
Two passes is cheap and robust. If pass 2 is a no-op, the state is fully converged. If it produces changes, something in pass 1 created an object that a later object needed which the reconciler then picked up.
Drift Detection as a Separate Job
Apply with -WhatIf on a schedule and fail the job if anything would change. That's your drift detector:
$output = ./apply.ps1 -StateFile ./state/prod.yaml -WhatIf -Verbose 4>&1
$drift = $output | Where-Object { $_.Message -match 'What if' }
if ($drift)
{
$drift | Set-Content ./drift-$(Get-Date -Format 'yyyy-MM-dd').log
exit 1
}
Run that on a cron. Alert on non-zero exit. You now know within the hour if someone clicked in ADUC.
CI Workflow
A minimal Gitea / GitHub Actions workflow:
name: ad-reconcile
on:
push:
branches: [ main ]
paths: [ 'state/**', 'src/ADOps/**' ]
pull_request:
paths: [ 'state/**' ]
schedule:
- cron: '0 * * * *' # hourly drift detection
jobs:
plan:
if: github.event_name == 'pull_request' || github.event_name == 'schedule'
runs-on: [ self-hosted, windows ]
steps:
- uses: actions/checkout@v4
- shell: pwsh
run: ./apply.ps1 -StateFile ./state/prod.yaml -WhatIf -Verbose
apply:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: [ self-hosted, windows ]
environment: production
steps:
- uses: actions/checkout@v4
- shell: pwsh
run: ./apply.ps1 -StateFile ./state/prod.yaml -Verbose
Pull requests get a plan (dry-run) posted as a check. Merges to main apply. The hourly schedule runs the plan against production and fails if drift exists. Same pipeline pattern as Terraform, just with AD on the other end.
Gotchas I Keep Hitting
extensionAttribute15appears in the GAL by default. If you have Exchange / Outlook users rendering the attribute in address lists, usemsDS-cloudExtensionAttribute1..20instead those are invisible in Outlook but still Unicode strings.- ACL inheritance flags are underspecified in the docs.
Descendents(yes, that spelling) andAlllook similar but behave differently on the root OU. Test in a lab, always. Move-ADObjectrequires the target path to exist. The two-pass runner usually handles this, but if pass 1 fails for a missing parent and pass 2 tries to move, you get a cryptic "target path not found". Process containers first in the YAML.- Replication lag breaks the second pass if your domain has multiple DCs. Target the same DC in both passes (
-Server dc01.corp.example.com) or add retry to the criticalGet-ADObjectcalls. - DNs with commas in names (yes, it happens:
OU=Sales\, Western) need escaping. Let the AD cmdlets handle that don't build DNs by string concatenation.
Final Notes
That's the "AD as code" foundation. A single YAML file in git describes the topology; a small PowerShell module reconciles reality towards it; CI runs the reconciler on push and on a schedule. What used to be a pile of one-off scripts and a screenshot in SharePoint becomes a versioned, diffable, code-reviewable artifact.
The next post applies the same pattern to Group Policy which has its own special ways of making reconciliation hard then WMI filters, the silent half of Group Policy with its own LDAP container and hand-rolled encoding.


