This guide continues the Active Directory at Scale series. It assumes you have the ActiveDirectory PowerShell module and delegated write access to the OU tree you're reconciling. If your state file is YAML you also need the powershell-yaml module (Install-Module powershell-yaml -Scope CurrentUser); JSON needs nothing extra (ConvertFrom-Json is 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:

  1. Create first so IDs exist for cross-references before anything references them.
  2. Rename before move Move-ADObject needs the current DN to match; renaming first avoids a double-look-up bug.
  3. 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

  • extensionAttribute15 appears in the GAL by default. If you have Exchange / Outlook users rendering the attribute in address lists, use msDS-cloudExtensionAttribute1..20 instead those are invisible in Outlook but still Unicode strings.
  • ACL inheritance flags are underspecified in the docs. Descendents (yes, that spelling) and All look similar but behave differently on the root OU. Test in a lab, always.
  • Move-ADObject requires 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 critical Get-ADObject calls.
  • 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.