# ADDelegation.ps1 # OU delegation (ACL) management. # Uses AD schema GUIDs to construct ActiveDirectoryAccessRule objects. # No dependencies on other AD modules. # AD Schema GUIDs -- well-known, universal across all AD forests. # Verified against Microsoft AD Schema documentation. # https://learn.microsoft.com/en-us/windows/win32/adschema/ $Script:ADClassGuid = @{ user = [Guid]'bf967aba-0de6-11d0-a285-00aa003049e2' } $Script:ADAttributeGuid = @{ userAccountControl = [Guid]'bf967a68-0de6-11d0-a285-00aa003049e2' lockoutTime = [Guid]'28630ebf-41d5-11d1-a9c1-0000f80367c1' displayName = [Guid]'bf967953-0de6-11d0-a285-00aa003049e2' givenName = [Guid]'f0f8ff8e-1191-11d0-a060-00aa006c33ed' sn = [Guid]'bf967a41-0de6-11d0-a285-00aa003049e2' mail = [Guid]'bf967961-0de6-11d0-a285-00aa003049e2' telephoneNumber = [Guid]'bf967a49-0de6-11d0-a285-00aa003049e2' description = [Guid]'bf967950-0de6-11d0-a285-00aa003049e2' } $Script:ADExtendedRight = @{ ResetPassword = [Guid]'00299570-246d-11d0-a768-00aa006e0529' } function ConvertTo-DelegationACEs { <# .SYNOPSIS Converts a rights specification into ActiveDirectoryAccessRule objects. #> param( [Parameter(Mandatory)] [System.Security.Principal.SecurityIdentifier]$SID, [Parameter(Mandatory)] $Rights ) $aces = @() $rightsArray = @($Rights) foreach ($right in $rightsArray) { if ($right -eq 'FullControl') { $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( $SID, [System.DirectoryServices.ActiveDirectoryRights]::GenericAll, [System.Security.AccessControl.AccessControlType]::Allow, [System.DirectoryServices.ActiveDirectorySecurityInheritance]::All ) } elseif ($right -eq 'ListContents') { $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( $SID, [System.DirectoryServices.ActiveDirectoryRights]::ListChildren, [System.Security.AccessControl.AccessControlType]::Allow, [System.DirectoryServices.ActiveDirectorySecurityInheritance]::None ) } elseif ($right -eq 'ReadAllProperties') { $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( $SID, [System.DirectoryServices.ActiveDirectoryRights]::ReadProperty, [System.Security.AccessControl.AccessControlType]::Allow, [System.DirectoryServices.ActiveDirectorySecurityInheritance]::None ) } elseif ($right -eq 'ResetPassword') { $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( $SID, [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight, [System.Security.AccessControl.AccessControlType]::Allow, $Script:ADExtendedRight['ResetPassword'], [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents, $Script:ADClassGuid['user'] ) } elseif ($right -match '^ReadWriteProperty:(.+)$') { $attrName = $Matches[1] if (-not $Script:ADAttributeGuid.ContainsKey($attrName)) { throw "Unknown AD attribute '$attrName' in delegation rule. Add it to `$Script:ADAttributeGuid in ADDelegation.ps1." } $aces += New-Object System.DirectoryServices.ActiveDirectoryAccessRule( $SID, ([System.DirectoryServices.ActiveDirectoryRights]::ReadProperty -bor [System.DirectoryServices.ActiveDirectoryRights]::WriteProperty), [System.Security.AccessControl.AccessControlType]::Allow, $Script:ADAttributeGuid[$attrName], [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents, $Script:ADClassGuid['user'] ) } else { throw "Unknown delegation right: '$right'. Valid: FullControl, ListContents, ReadAllProperties, ResetPassword, ReadWriteProperty:" } } return $aces } function Test-ACEExists { <# .SYNOPSIS Returns $true if an equivalent ACE already exists in the given ACL. #> param( [System.DirectoryServices.ActiveDirectorySecurity]$ACL, [System.DirectoryServices.ActiveDirectoryAccessRule]$DesiredACE ) $desiredSID = $DesiredACE.IdentityReference.Value foreach ($existing in $ACL.Access) { $existingSID = $null try { $existingSID = $existing.IdentityReference.Translate( [System.Security.Principal.SecurityIdentifier]).Value } catch { continue } if ($existingSID -ne $desiredSID) { continue } # Bitwise subset check: AD merges compatible ACEs, so the stored # rights may be a superset of what we asked for (e.g. ListChildren # + ReadProperty merged into one ACE). if (($existing.ActiveDirectoryRights -band $DesiredACE.ActiveDirectoryRights) -ne $DesiredACE.ActiveDirectoryRights) { continue } if ($existing.AccessControlType -ne $DesiredACE.AccessControlType) { continue } if ($existing.ObjectType -ne $DesiredACE.ObjectType) { continue } if ($existing.InheritanceType -ne $DesiredACE.InheritanceType) { continue } if ($existing.InheritedObjectType -ne $DesiredACE.InheritedObjectType) { continue } return $true } return $false } function Ensure-OUDelegation { <# .SYNOPSIS Idempotently applies delegation ACEs to an OU for a given security group. Returns the number of ACEs added (0 if already in desired state). #> param( [Parameter(Mandatory)] [string]$GroupName, [Parameter(Mandatory)] [string]$TargetOU, [Parameter(Mandatory)] $Rights ) $group = Get-ADGroup -Identity $GroupName -ErrorAction Stop $sid = New-Object System.Security.Principal.SecurityIdentifier($group.SID) $desiredACEs = ConvertTo-DelegationACEs -SID $sid -Rights $Rights $adPath = "AD:$TargetOU" $acl = Get-Acl $adPath $added = 0 foreach ($ace in $desiredACEs) { if (-not (Test-ACEExists -ACL $acl -DesiredACE $ace)) { $acl.AddAccessRule($ace) $added++ } } if ($added -gt 0) { Set-Acl $adPath $acl Write-Host " [DELEGATED] $GroupName on $TargetOU ($added ACE(s) added)" -ForegroundColor Yellow } else { Write-Host " [OK] $GroupName delegation on $TargetOU" -ForegroundColor Green } return $added } function Compare-OUDelegation { <# .SYNOPSIS Checks if delegation ACEs exist for a group on an OU. Returns diff objects for any missing ACEs. #> param( [Parameter(Mandatory)] [string]$GroupName, [Parameter(Mandatory)] [string]$TargetOU, [Parameter(Mandatory)] $Rights ) $diffs = @() $group = Get-ADGroup -Identity $GroupName -ErrorAction Stop $sid = New-Object System.Security.Principal.SecurityIdentifier($group.SID) $desiredACEs = ConvertTo-DelegationACEs -SID $sid -Rights $Rights $adPath = "AD:$TargetOU" $acl = Get-Acl $adPath $missing = 0 foreach ($ace in $desiredACEs) { if (-not (Test-ACEExists -ACL $acl -DesiredACE $ace)) { $missing++ } } if ($missing -eq 0) { Write-Host " [OK] $GroupName delegation on $TargetOU" -ForegroundColor Green } else { Write-Host " [DRIFT] $GroupName missing $missing ACE(s) on $TargetOU" -ForegroundColor Red $diffs += [PSCustomObject]@{ Type = 'Delegation' Group = $GroupName TargetOU = $TargetOU Status = "Missing $missing ACE(s)" } } return $diffs }