Cloning Active Directory OU Permissions with PowerShell: Safe Delegation Pattern
Last reviewed: June 2026
Scope
Cloning OU delegation is one of those tasks where the naive one-liner is actively dangerous: copy an OU's ACL to ten other OUs and you have potentially propagated inherited rights, orphaned SIDs, and over-permissioning ten times over, with no record of what changed and no way back. This article builds the safe version as a proper tool — read-only extraction of just the explicit ACEs for one group, an ACL backup before any change, an idempotent gated apply, verification, a one-line restore, and Pester tests. It uses the same engineering patterns as Production-Ready AD Automation: advanced functions, ShouldProcess, fail-closed error handling, structured logging.
Why ACL cloning needs care
Delegation models drift. Rights get added for a project and never removed, so the "source" OU is rarely as clean as anyone assumes. Two specific traps turn a quick clone into a security incident. First, Get-Acl returns inherited ACEs alongside explicit ones; if you copy everything that matches your group, you re-apply rights the source OU merely inherited from its parent — as explicit entries on the target, which is both wrong and sticky. Second, matching delegation by display name is fragile: the same account can appear as DOMAIN\group, a raw SID, or an unresolved SID. The tool below avoids both by extracting only explicit ACEs and matching identity by SID, not by string.
So the rule is the same as everywhere else in this series: extract and preview with a read-only function, back up before you touch anything, apply behind a ShouldProcess gate, and verify after.
Read-only: extract the delegation
function Get-OuDelegation {
<#
.SYNOPSIS
Returns the access-control entries a specific group holds on an OU. Explicit only by default.
.EXAMPLE
Get-OuDelegation -OuDistinguishedName 'OU=Source,DC=contoso,DC=com' -GroupName 'GG-Delegated-Admins'
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[string]$OuDistinguishedName,
[Parameter(Mandatory)]
[string]$GroupName,
[switch]$IncludeInherited
)
$group = Get-ADGroup -Identity $GroupName -ErrorAction Stop
$sidValue = $group.SID.Value
$acl = Get-Acl -Path "AD:\$OuDistinguishedName" -ErrorAction Stop
$acl.Access | Where-Object {
$idRef = $_.IdentityReference
$idSid =
if ($idRef -is [System.Security.Principal.SecurityIdentifier]) { $idRef.Value }
else { try { $idRef.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $null } }
($idSid -eq $sidValue) -and ($IncludeInherited -or -not $_.IsInherited)
} | ForEach-Object {
[pscustomobject]@{
Group = $group.Name
ActiveDirectoryRights = $_.ActiveDirectoryRights
AccessControlType = $_.AccessControlType
ObjectType = $_.ObjectType
InheritanceType = $_.InheritanceType
IsInherited = $_.IsInherited
Rule = $_ # the ACE itself, for re-application on the target
}
}
}
This is the function that makes the whole operation reviewable: run it against the source OU and you see exactly which rights the group holds, with IsInherited visible, and inherited entries excluded unless you explicitly ask for them. The identity match is by SID, so a renamed group or an NTAccount-vs-SID representation difference doesn't cause a silent miss. The raw ACE is carried on the Rule property so the apply step can re-add it verbatim.
State-changing: back up, apply, gate
function Test-AceExists {
param($Acl, $Rule)
[bool]($Acl.Access | Where-Object {
$_.IdentityReference -eq $Rule.IdentityReference -and
$_.ActiveDirectoryRights -eq $Rule.ActiveDirectoryRights -and
$_.AccessControlType -eq $Rule.AccessControlType -and
$_.ObjectType -eq $Rule.ObjectType -and
$_.InheritanceType -eq $Rule.InheritanceType
})
}
function Copy-OuDelegation {
<#
.SYNOPSIS
Clones a group's explicit delegation from a source OU onto one or more target OUs,
backing up each target ACL first. Idempotent and gated.
.EXAMPLE
'OU=Branch1,DC=contoso,DC=com','OU=Branch2,DC=contoso,DC=com' |
Copy-OuDelegation -SourceOu 'OU=Source,DC=contoso,DC=com' -GroupName 'GG-Delegated-Admins' `
-BackupPath .\acl-backups -LogPath .\delegation.csv -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[string]$SourceOu,
[Parameter(Mandatory, ValueFromPipeline)]
[string]$TargetOu,
[Parameter(Mandatory)]
[string]$GroupName,
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Container })]
[string]$BackupPath,
[Parameter(Mandatory)]
[string]$LogPath
)
begin {
$sourceRules = Get-OuDelegation -OuDistinguishedName $SourceOu -GroupName $GroupName
if (-not $sourceRules) {
throw "No explicit delegation found for '$GroupName' on source OU '$SourceOu'. Nothing to clone."
}
}
process {
try {
$path = "AD:\$TargetOu"
$targetAcl = Get-Acl -Path $path -ErrorAction Stop
$backupFile = Join-Path $BackupPath ("{0}.acl.clixml" -f ($TargetOu -replace '[^A-Za-z0-9-]', '_'))
$targetAcl | Export-Clixml -Path $backupFile
$toAdd = $sourceRules | Where-Object { -not (Test-AceExists -Acl $targetAcl -Rule $_.Rule) }
if (-not $toAdd) {
Write-OperationLog -Action 'CloneDelegation' -Target $TargetOu -Result 'Skipped' -Detail 'All ACEs already present' -LogPath $LogPath
return [pscustomobject]@{ TargetOu = $TargetOu; Added = 0; Backup = $backupFile; Result = 'Skipped' }
}
if ($PSCmdlet.ShouldProcess($TargetOu, "Add $($toAdd.Count) delegation ACE(s) for $GroupName")) {
foreach ($r in $toAdd) { $targetAcl.AddAccessRule($r.Rule) }
Set-Acl -Path $path -AclObject $targetAcl -ErrorAction Stop
Write-OperationLog -Action 'CloneDelegation' -Target $TargetOu -Result 'Success' -Detail "$($toAdd.Count) ACE(s)" -LogPath $LogPath
[pscustomobject]@{ TargetOu = $TargetOu; Added = $toAdd.Count; Backup = $backupFile; Result = 'Applied' }
}
else {
Write-OperationLog -Action 'CloneDelegation' -Target $TargetOu -Result 'WhatIf' -Detail "$($toAdd.Count) ACE(s)" -LogPath $LogPath
[pscustomobject]@{ TargetOu = $TargetOu; Added = $toAdd.Count; Backup = $backupFile; Result = 'WhatIf' }
}
}
catch {
Write-OperationLog -Action 'CloneDelegation' -Target $TargetOu -Result 'Failure' -Detail $_.Exception.Message -LogPath $LogPath
Write-Error "Failed to clone delegation to $TargetOu : $($_.Exception.Message)"
}
}
}
Write-OperationLog is the shared structured-logging helper from the AD automation article. The safety properties stack up: the target ACL is exported with Export-Clixml before any change, so every target has a precise restore point; Test-AceExists makes the operation idempotent, so re-running adds nothing already present; the AddAccessRule/Set-Acl pair is inside the ShouldProcess gate, so -WhatIf reports the count and changes nothing; and because the source rules come from Get-OuDelegation with inherited ACEs excluded, you clone the delegation you reviewed and nothing the source OU merely inherited.
Verify, and restore if needed
After applying, verification is the same read-only function pointed at the target:
# Confirm the target now holds the expected delegation:
Get-OuDelegation -OuDistinguishedName 'OU=Branch1,DC=contoso,DC=com' -GroupName 'GG-Delegated-Admins'
# Roll a target back to its pre-change ACL from the backup:
$backup = Import-Clixml .\acl-backups\OU_Branch1_DC_contoso_DC_com.acl.clixml
Set-Acl -Path 'AD:\OU=Branch1,DC=contoso,DC=com' -AclObject $backup
Because the backup is the full ACL object captured before the change, the restore is exact — it puts the OU back precisely as it was, not "approximately."
Prove it with Pester
The read/write split and SID-based matching are testable by mocking the AD provider. These v5 tests never touch a real directory.
BeforeAll {
. $PSScriptRoot\AdDelegation.ps1
}
Describe 'Copy-OuDelegation' {
BeforeAll {
Mock Set-Acl {}
Mock Export-Clixml {}
Mock Get-Acl {
$acl = [pscustomobject]@{ Access = @() }
$acl | Add-Member -MemberType ScriptMethod -Name AddAccessRule -Value { param($r) } -PassThru
}
}
It 'throws when the source OU has no explicit delegation' {
Mock Get-OuDelegation { @() }
{ Copy-OuDelegation -SourceOu 'OU=S,DC=c,DC=com' -TargetOu 'OU=T,DC=c,DC=com' -GroupName 'G' -BackupPath $TestDrive -LogPath 'TestDrive:\l.csv' } |
Should -Throw
}
It 'makes no change under -WhatIf' {
Mock Get-OuDelegation { [pscustomobject]@{ Rule = 'ace' } }
Mock Test-AceExists { $false }
'OU=T,DC=c,DC=com' | Copy-OuDelegation -SourceOu 'OU=S,DC=c,DC=com' -GroupName 'G' -BackupPath $TestDrive -LogPath 'TestDrive:\l.csv' -WhatIf
Should -Invoke Set-Acl -Times 0
Should -Invoke Export-Clixml -Times 1 # backup is still taken
}
It 'applies missing ACEs when confirmed' {
Mock Get-OuDelegation { [pscustomobject]@{ Rule = 'ace' } }
Mock Test-AceExists { $false }
'OU=T,DC=c,DC=com' | Copy-OuDelegation -SourceOu 'OU=S,DC=c,DC=com' -GroupName 'G' -BackupPath $TestDrive -LogPath 'TestDrive:\l.csv' -Confirm:$false
Should -Invoke Set-Acl -Times 1
}
It 'is idempotent: skips when the ACE already exists' {
Mock Get-OuDelegation { [pscustomobject]@{ Rule = 'ace' } }
Mock Test-AceExists { $true }
$r = 'OU=T,DC=c,DC=com' | Copy-OuDelegation -SourceOu 'OU=S,DC=c,DC=com' -GroupName 'G' -BackupPath $TestDrive -LogPath 'TestDrive:\l.csv' -Confirm:$false
Should -Invoke Set-Acl -Times 0
$r.Result | Should -Be 'Skipped'
}
}
The -WhatIf test asserts two things that matter together: no Set-Acl call, but the backup is taken — previewing should be free of changes yet still leave you a restore point. The idempotency test proves a re-run won't double-apply, which is what lets you safely run the same clone across a growing list of branch OUs over time.
When I use this, and when I do not
I use this pattern when standing up consistent delegation across many similar OUs — branches, business units, sites — and the source OU has been reviewed and signed off as the known-good baseline, the security owner has approved the group and its rights, and a test OU is available first. I do not use it when the source delegation is unknown or legacy, when inheritance behaviour hasn't been checked, when the change is privileged delegation without security sign-off, or when there's no rollback plan and no test OU. The tool makes the mechanics safe; it does not make an unreviewed source OU safe to copy.
Production checklist
- Review and sign off the source OU's delegation before cloning anything.
- Extract explicit ACEs only (the default) unless you have a specific reason to include inherited ones.
- Match identity by SID, not by display name.
- Back up each target ACL with
Export-Clixmlbefore applying. - Apply behind
ShouldProcess; preview with-WhatIffirst. - Keep the operation idempotent so re-runs are safe.
- Verify with
Get-OuDelegationafter applying. - Keep the structured log and backups as change evidence.
- Run the Pester tests in CI; apply under a least-privilege account.
Final recommendation
Clone OU permissions only when the source delegation is known-good and approved, and only with a tool that extracts explicit ACEs, matches by SID, backs up before it writes, gates the change behind ShouldProcess, and can prove with a test that -WhatIf changes nothing while still capturing a restore point. The fast one-liner that copies a whole ACL is exactly how over-permissioning spreads silently across an estate — the extra structure here is what keeps a convenience from becoming a security finding.