Files
WELA/scripts/AdObjectSacl.ps1
T

447 lines
32 KiB
PowerShell

# Explicit, additive AD DS audit ACEs. No AD: drive, server discovery, or DACL writes.
function Search-WelaAdDirectory {
param($Session, [string]$Dn, [string]$Filter = '(objectClass=*)', [string]$Scope = 'Base',
[string[]]$Attributes, [switch]$SecurityDescriptor)
$request = [System.DirectoryServices.Protocols.SearchRequest]::new($Dn, $Filter,
[System.DirectoryServices.Protocols.SearchScope]::$Scope, $Attributes)
if ($SecurityDescriptor) {
$control = [System.DirectoryServices.Protocols.SecurityDescriptorFlagControl]::new(
[System.DirectoryServices.Protocols.SecurityMasks]15)
$control.IsCritical = $true
$null = $request.Controls.Add($control)
}
$response = $Session.Connection.SendRequest($request)
foreach ($entry in $response.Entries) {
$values = @{}
foreach ($name in $entry.Attributes.AttributeNames) {
$type = if ($name -in @('nTSecurityDescriptor', 'objectGUID', 'schemaIDGUID')) { [byte[]] } else { [string] }
$values[$name] = $entry.Attributes[$name].GetValues($type)
}
[pscustomobject]@{ Dn = $entry.DistinguishedName; Values = $values }
}
}
function Get-WelaAdSingleValue {
param($Entry, [string]$Name)
if (-not $Entry.Values.ContainsKey($Name) -or @($Entry.Values[$Name]).Count -ne 1) {
throw "Required AD attribute is missing or ambiguous: $Name ($($Entry.Dn))."
}
return ,$Entry.Values[$Name][0]
}
function New-WelaAdConnection {
param([string]$Server)
Add-Type -AssemblyName System.DirectoryServices.Protocols -ErrorAction Stop
$identifier = [System.DirectoryServices.Protocols.LdapDirectoryIdentifier]::new($Server, 389, $true, $false)
$connection = [System.DirectoryServices.Protocols.LdapConnection]::new($identifier)
try {
$connection.AuthType = [System.DirectoryServices.Protocols.AuthType]::Negotiate
$connection.Timeout = [TimeSpan]::FromSeconds(30)
$connection.SessionOptions.ProtocolVersion = 3
$connection.SessionOptions.Signing = $true
$connection.SessionOptions.Sealing = $true
$connection.SessionOptions.ReferralChasing = [System.DirectoryServices.Protocols.ReferralChasingOptions]::None
return $connection
} catch { $connection.Dispose(); throw }
}
function Open-WelaAdSession {
param([string]$Server)
if ($env:OS -ne 'Windows_NT') { throw 'AD object SACL operations require Windows PowerShell 5.1 or PowerShell 7 on Windows.' }
if ($Server -notmatch '^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$') {
throw 'AdServer must be the exact DNS host name of one DC (FQDN), without a port, path, or LDAP URL.'
}
$connection = New-WelaAdConnection $Server
try {
$connection.Bind()
$session = [pscustomobject]@{ Server = $Server; Connection = $connection; DomainDn = ''; ConfigurationDn = ''; SchemaDn = ''; DsaDn = ''; Writable = $false }
$root = @(Search-WelaAdDirectory -Session $session -Dn '' -Attributes @('dnsHostName', 'defaultNamingContext', 'configurationNamingContext', 'schemaNamingContext', 'dsServiceName', 'supportedCapabilities', 'supportedControl'))
if ($root.Count -ne 1) { throw 'RootDSE was not returned uniquely.' }
if ((Get-WelaAdSingleValue $root[0] 'dnsHostName') -ine $Server) { throw 'RootDSE dnsHostName differs from AdServer; aliases and domain-wide targets are refused.' }
if ($root[0].Values['supportedCapabilities'] -notcontains '1.2.840.113556.1.4.800' -or
$root[0].Values['supportedControl'] -notcontains '1.2.840.113556.1.4.801') { throw 'AD DS and the security-descriptor flags control must be supported.' }
$session.DomainDn = Get-WelaAdSingleValue $root[0] 'defaultNamingContext'
$session.ConfigurationDn = Get-WelaAdSingleValue $root[0] 'configurationNamingContext'
$session.SchemaDn = Get-WelaAdSingleValue $root[0] 'schemaNamingContext'
$session.DsaDn = Get-WelaAdSingleValue $root[0] 'dsServiceName'
$dsa = @(Search-WelaAdDirectory -Session $session -Dn $session.DsaDn -Attributes @('msDS-isRODC'))
if ($dsa.Count -ne 1 -or (Get-WelaAdSingleValue $dsa[0] 'msDS-isRODC') -notin @('TRUE', 'FALSE')) { throw 'DC write capability is unknown.' }
$session.Writable = (Get-WelaAdSingleValue $dsa[0] 'msDS-isRODC') -eq 'FALSE'
return $session
} catch { $connection.Dispose(); throw }
}
function Get-WelaAdAuditDefinitions {
# Exact masks in Microsoft's Test-MdiReadiness at 730dad6870154279b6c41009c9ebab84ffa24689.
$classes = @(
@('user', 'bf967aba-0de6-11d0-a285-00aa003049e2', 852331),
@('group', 'bf967a9c-0de6-11d0-a285-00aa003049e2', 852331),
@('computer', 'bf967a86-0de6-11d0-a285-00aa003049e2', 852331),
@('msDS-ManagedServiceAccount', 'ce206244-5827-4a86-ba1c-1c0c386c1b64', 852331),
@('msDS-GroupManagedServiceAccount', '7b8b558a-93a5-4af7-adca-c017e67f1057', 852075),
@('msDS-DelegatedManagedServiceAccount', '0feb936f-47b3-49f2-9386-1dedc2c23765', 852075)
)
foreach ($item in $classes) {
[pscustomobject]@{ Class = $item[0]; Sid = 'S-1-1-0'; AccessMask = $item[2]; AuditFlags = 'Success'; AceFlags = 74;
ObjectType = [guid]::Empty.ToString(); InheritedObjectType = $item[1]; Inheritance = 'DescendantsOnly';
Rights = 'CreateChild, DeleteChild, Self, WriteProperty, DeleteTree, Delete, WriteDacl, WriteOwner' + $(if ($item[2] -eq 852331) { ', ExtendedRight' } else { '' }) }
}
}
function Test-WelaAdSchemaClass {
param($Session, [string]$Class, [string]$Guid)
# Class is from our fixed allowlist, never user-provided LDAP filter text.
$rows = @(Search-WelaAdDirectory -Session $Session -Dn $Session.SchemaDn -Scope OneLevel -Filter "(&(objectClass=classSchema)(lDAPDisplayName=$Class))" -Attributes @('schemaIDGUID'))
if ($rows.Count -eq 0) { return $false }
if ($rows.Count -ne 1 -or ([guid]::new([byte[]](Get-WelaAdSingleValue $rows[0] 'schemaIDGUID'))).ToString() -ne $Guid) {
throw "Schema GUID mismatch or ambiguous class: $Class."
}
return $true
}
function Test-WelaAdDmsaDomain {
param($Session)
$rows = @(Search-WelaAdDirectory -Session $Session -Dn $Session.DomainDn -Scope Subtree -Filter '(&(objectClass=computer)(primaryGroupID=516))' -Attributes @('operatingSystemVersion'))
if (-not $rows.Count) { throw 'No domain controller computer versions were readable for the dMSA applicability check.' }
$unknown = $false
foreach ($row in $rows) {
if (-not $row.Values.ContainsKey('operatingSystemVersion')) { $unknown = $true; continue }
$version = [string](Get-WelaAdSingleValue $row 'operatingSystemVersion')
if ($version -match '^10\.0\s*\((\d+)\)$') { if ([int]$Matches[1] -ge 26100) { return $true } }
else { $unknown = $true }
}
if ($unknown) { throw 'DC versions could not all be classified; dMSA applicability is unknown.' }
return $false
}
function ConvertTo-WelaAdBinaryString {
param($Object)
if ($null -eq $Object) { return $null }
$bytes = New-Object byte[] $Object.BinaryLength
$Object.GetBinaryForm($bytes, 0)
return [Convert]::ToBase64String($bytes)
}
function New-WelaAdAuditAce {
param($Definition)
$sid = [Security.Principal.SecurityIdentifier]::new($Definition.Sid)
if ($Definition.InheritedObjectType -ne [guid]::Empty.ToString()) {
return [Security.AccessControl.ObjectAce]::new([Security.AccessControl.AceFlags]$Definition.AceFlags,
[Security.AccessControl.AceQualifier]::SystemAudit, [int]$Definition.AccessMask, $sid,
[Security.AccessControl.ObjectAceFlags]::InheritedObjectAceTypePresent, [guid]::Empty,
[guid]$Definition.InheritedObjectType, $false, $null)
}
return [Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]$Definition.AceFlags,
[Security.AccessControl.AceQualifier]::SystemAudit, [int]$Definition.AccessMask, $sid, $false, $null)
}
function Get-WelaAdDescriptorInfo {
param([string]$Binary)
$sd = [Security.AccessControl.RawSecurityDescriptor]::new([Convert]::FromBase64String($Binary), 0)
$aces = @(); if ($sd.SystemAcl) { foreach ($ace in $sd.SystemAcl) { $aces += ConvertTo-WelaAdBinaryString $ace } }
[pscustomobject]@{ Binary = $Binary; Sddl = $sd.GetSddlForm([Security.AccessControl.AccessControlSections]::All);
Owner = [string]$sd.Owner; Group = [string]$sd.Group; Dacl = ConvertTo-WelaAdBinaryString $sd.DiscretionaryAcl;
ControlFlags = [int]$sd.ControlFlags; Sacl = $aces }
}
function Get-WelaAdObjectState {
param($Session, [string]$Dn)
$rows = @(Search-WelaAdDirectory -Session $Session -Dn $Dn -Attributes @('nTSecurityDescriptor', 'objectGUID', 'uSNChanged', 'objectClass') -SecurityDescriptor)
if ($rows.Count -ne 1) { throw "AD object missing or ambiguous: $Dn." }
$binary = [Convert]::ToBase64String([byte[]](Get-WelaAdSingleValue $rows[0] 'nTSecurityDescriptor'))
$info = Get-WelaAdDescriptorInfo $binary
[pscustomobject]@{ Server = $Session.Server; Dn = $rows[0].Dn;
ObjectGuid = ([guid]::new([byte[]](Get-WelaAdSingleValue $rows[0] 'objectGUID'))).ToString();
UsnChanged = [string](Get-WelaAdSingleValue $rows[0] 'uSNChanged'); Classes = @($rows[0].Values['objectClass']); Descriptor = $info }
}
function Test-WelaAdAcePresent {
param($Descriptor, $Definition)
$wanted = New-WelaAdAuditAce $Definition
foreach ($encoded in $Descriptor.Sacl) {
$ace = [Security.AccessControl.GenericAce]::CreateFromBinaryForm([Convert]::FromBase64String($encoded), 0)
# Accept an existing audit ACE granting a superset of the required audited
# rights/outcomes, but only with the exact inheritance and object scope.
if ($ace -isnot [Security.AccessControl.QualifiedAce] -or $ace.IsCallback -or $ace.AceQualifier -ne $wanted.AceQualifier -or
$ace.SecurityIdentifier -ne $wanted.SecurityIdentifier -or ($ace.AccessMask -band $wanted.AccessMask) -ne $wanted.AccessMask) { continue }
if (([int]$ace.AceFlags -band 63) -ne ([int]$wanted.AceFlags -band 63) -or
([int]$ace.AceFlags -band [int]$wanted.AceFlags) -ne [int]$wanted.AceFlags) { continue }
if ($wanted -is [Security.AccessControl.ObjectAce]) {
if ($ace -isnot [Security.AccessControl.ObjectAce] -or $ace.ObjectAceFlags -ne $wanted.ObjectAceFlags -or
$ace.ObjectAceType -ne $wanted.ObjectAceType -or $ace.InheritedObjectAceType -ne $wanted.InheritedObjectAceType) { continue }
} elseif ($ace -is [Security.AccessControl.ObjectAce] -and [int]$ace.ObjectAceFlags -ne 0) { continue }
return $true
}
return $false
}
function New-WelaAdSaclAddition {
param($Before, [array]$Definitions)
$sd = [Security.AccessControl.RawSecurityDescriptor]::new([Convert]::FromBase64String($Before.Descriptor.Binary), 0)
$oldCount = if ($sd.SystemAcl) { $sd.SystemAcl.Count } else { 0 }
$acl = [Security.AccessControl.RawAcl]::new([byte]4, $oldCount + $Definitions.Count)
if ($sd.SystemAcl) { foreach ($ace in $sd.SystemAcl) { $acl.InsertAce($acl.Count, $ace) } }
$added = @()
foreach ($definition in $Definitions) {
if (Test-WelaAdAcePresent $Before.Descriptor $definition) { continue }
$ace = New-WelaAdAuditAce $definition
# Insert explicit ACEs before inherited ACEs; preserve every existing ACE.
$position = 0
while ($position -lt $acl.Count -and -not $acl[$position].IsInherited) { $position++ }
$acl.InsertAce($position, $ace)
$added += ConvertTo-WelaAdBinaryString $ace
}
$sd.SystemAcl = $acl
$sd.SetFlags($sd.ControlFlags -bor [Security.AccessControl.ControlFlags]::SystemAclPresent)
[pscustomobject]@{ Binary = ConvertTo-WelaAdBinaryString $sd; AddedAces = $added }
}
function Test-WelaAdPreserved {
param($Before, $After)
if ($Before.ObjectGuid -ne $After.ObjectGuid -or $Before.Server -ine $After.Server -or $Before.Dn -ine $After.Dn -or
$Before.Descriptor.Owner -ne $After.Descriptor.Owner -or $Before.Descriptor.Group -ne $After.Descriptor.Group -or
$Before.Descriptor.Dacl -ne $After.Descriptor.Dacl -or
($Before.Descriptor.ControlFlags -band 65519) -ne ($After.Descriptor.ControlFlags -band 65519)) { return $false }
$remaining = New-Object 'System.Collections.Generic.List[string]'
foreach ($ace in $After.Descriptor.Sacl) { $remaining.Add($ace) }
foreach ($ace in $Before.Descriptor.Sacl) { if (-not $remaining.Remove($ace)) { return $false } }
return $true
}
function Write-WelaAdSacl {
param($Session, [string]$Dn, [string]$Binary)
if (-not $Session.Writable) { throw 'The explicitly selected DC is read-only; no write was sent.' }
$modification = [System.DirectoryServices.Protocols.DirectoryAttributeModification]::new()
$modification.Name = 'nTSecurityDescriptor'
$modification.Operation = [System.DirectoryServices.Protocols.DirectoryAttributeOperation]::Replace
$null = $modification.Add([Convert]::FromBase64String($Binary))
$request = [System.DirectoryServices.Protocols.ModifyRequest]::new($Dn, $modification)
# The DC modifies SACL only. Owner/group/DACL are never sent as a requested change.
$control = [System.DirectoryServices.Protocols.SecurityDescriptorFlagControl]::new([System.DirectoryServices.Protocols.SecurityMasks]::Sacl)
$control.IsCritical = $true
$null = $request.Controls.Add($control)
$null = $Session.Connection.SendRequest($request)
}
function Test-WelaAdPkiContainer {
param($Session, $Snapshot, [string]$ContainerDn)
# A one-level GUID lookup proves parent membership without interpreting DN
# text (escaped commas can otherwise impersonate an approved suffix).
$escapedGuid = (([guid]$Snapshot.ObjectGuid).ToByteArray() | ForEach-Object { '\{0:X2}' -f $_ }) -join ''
$rows = @(Search-WelaAdDirectory -Session $Session -Dn $ContainerDn -Scope OneLevel `
-Filter "(objectGUID=$escapedGuid)" -Attributes @('objectGUID'))
if ($rows.Count -eq 0) { return $false }
if ($rows.Count -ne 1 -or $rows[0].Dn -ine $Snapshot.Dn -or
([guid]::new([byte[]](Get-WelaAdSingleValue $rows[0] 'objectGUID'))).ToString() -ne $Snapshot.ObjectGuid) {
throw 'PKI container membership lookup returned an unexpected object identity.'
}
return $true
}
function Get-WelaAdSaclPlan {
param($Session, [string[]]$Profiles, [string[]]$ObjectDn)
$requests = @()
if ($Profiles -contains 'MdiDomain') { $requests += [pscustomobject]@{ Profile = 'MdiDomain'; Dn = $Session.DomainDn } }
if ($Profiles -contains 'MdiConfiguration') { $requests += [pscustomobject]@{ Profile = 'MdiConfiguration'; Dn = $Session.ConfigurationDn } }
if ($Profiles -contains 'PkiObjects') {
if (-not $ObjectDn.Count) { throw 'PkiObjects requires explicit -AdObjectDn certificate template or enrollment service object DNs.' }
foreach ($dn in @($ObjectDn | Select-Object -Unique)) { $requests += [pscustomobject]@{ Profile = 'PkiObjects'; Dn = $dn } }
} elseif ($ObjectDn.Count) { throw '-AdObjectDn is valid only with the PkiObjects profile.' }
foreach ($request in $requests) {
$definitions = @(); $skippedDefinitions = @(); $before = $null; $notes = @(); $status = 'Unknown'
try {
if ($request.Profile -eq 'MdiDomain') {
foreach ($definition in Get-WelaAdAuditDefinitions) {
if ($definition.Class -eq 'msDS-DelegatedManagedServiceAccount') {
# dMSA is conditional. Its unknown schema/DC prerequisites
# must not prevent the five independent class ACEs.
$prerequisiteStatus = 'Applicable'; $diagnostic = ''
try {
$exists = Test-WelaAdSchemaClass $Session $definition.Class $definition.InheritedObjectType
if (-not $exists -or -not (Test-WelaAdDmsaDomain $Session)) {
$prerequisiteStatus = 'NotApplicable'
$diagnostic = 'dMSA skipped: schema class and a domain DC version >= 10.0 (26100) are required.'
}
} catch {
$prerequisiteStatus = 'Unknown'
$diagnostic = "dMSA skipped: prerequisite Unknown ($($_.Exception.Message)). dMSA auditing is not established; the other five class ACEs remain independent."
}
if ($prerequisiteStatus -ne 'Applicable') {
$notes += $diagnostic
$skippedDefinitions += [pscustomobject]@{ Definition = $definition; Status = 'Skipped'; PrerequisiteStatus = $prerequisiteStatus; Diagnostic = $diagnostic }
continue
}
} elseif (-not (Test-WelaAdSchemaClass $Session $definition.Class $definition.InheritedObjectType)) {
throw "Required MDI schema class is absent: $($definition.Class)."
}
$definitions += $definition
}
} elseif ($request.Profile -eq 'MdiConfiguration') {
$exchange = @(Search-WelaAdDirectory -Session $Session -Dn $Session.ConfigurationDn -Scope Subtree -Filter '(objectClass=msExchOrganizationContainer)' -Attributes @('objectClass'))
if (-not $exchange.Count) { $status = 'NotApplicable'; throw 'No Exchange organization container observed. MDI Configuration auditing is intended for current or former Exchange deployments; review removed-history cases manually.' }
$definitions = @([pscustomobject]@{ Class = ''; Sid = 'S-1-1-0'; AccessMask = 32; AuditFlags = 'Success, Failure'; AceFlags = 194; ObjectType = [guid]::Empty.ToString(); InheritedObjectType = [guid]::Empty.ToString(); Inheritance = 'ThisObjectAndAllDescendants'; Rights = 'WriteProperty' })
} else {
$before = Get-WelaAdObjectState $Session $request.Dn
$class = $null; $guid = $null
if ($before.Classes -contains 'pKICertificateTemplate' -and
(Test-WelaAdPkiContainer $Session $before "CN=Certificate Templates,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)")) {
$class = 'pKICertificateTemplate'; $guid = 'e5209ca2-3bba-11d2-90cc-00c04fd91ab1'
} elseif ($before.Classes -contains 'pKIEnrollmentService' -and
(Test-WelaAdPkiContainer $Session $before "CN=Enrollment Services,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)")) {
$class = 'pKIEnrollmentService'; $guid = 'ee4aa692-3bba-11d2-90cc-00c04fd91ab1'
} else { throw 'PkiObjects accepts only existing certificate template/enrollment service objects under the selected forest PKI containers.' }
if (-not (Test-WelaAdSchemaClass $Session $class $guid)) { throw "PKI schema class missing: $class." }
$definitions = @([pscustomobject]@{ Class = $class; Sid = 'S-1-1-0'; AccessMask = 852000; AuditFlags = 'Success'; AceFlags = 64; ObjectType = [guid]::Empty.ToString(); InheritedObjectType = [guid]::Empty.ToString(); Inheritance = 'ThisObjectOnly'; Rights = 'WriteProperty, Delete, WriteDacl, WriteOwner' })
$notes += 'WELA targeted PKI profile, not an MDI-prescribed PKI baseline; no child objects, enrollment rights or CA AuditFilter changes.'
}
if (-not $before) { $before = Get-WelaAdObjectState $Session $request.Dn }
$missing = @($definitions | Where-Object { -not (Test-WelaAdAcePresent $before.Descriptor $_) })
$status = if ($missing.Count) { 'ChangeRequired' } else { 'SaclConfigured' }
if (-not $Session.Writable -and $missing.Count) { $status = 'Blocked'; $notes += 'Selected DC is read-only.' }
} catch { $notes += $_.Exception.Message }
[pscustomobject]@{ Profile = $request.Profile; Server = $Session.Server; Dn = $request.Dn; Status = $status;
Definitions = $definitions; SkippedDefinitions = $skippedDefinitions; Before = $before; Diagnostic = $notes -join ' ' }
}
}
function Set-WelaAdSaclControls {
param($Session, $Context, [array]$Plan)
foreach ($entry in $Plan) {
$id = "AdSacl/$($entry.Profile)/$($entry.Dn)"
foreach ($skipped in $entry.SkippedDefinitions) {
$Context.Results.Add([pscustomobject]@{ Id = "$id/$($skipped.Definition.Class)/Prerequisite"; Kind = 'AdObjectSaclPrerequisite';
Target = @{ Server = $Session.Server; Dn = $entry.Dn; Class = $skipped.Definition.Class }; Desired = $skipped.Definition;
Before = $null; After = $null; Status = 'Skipped'; PrerequisiteStatus = $skipped.PrerequisiteStatus; Diagnostic = $skipped.Diagnostic })
Write-Host "[Skipped] $id/$($skipped.Definition.Class) $($skipped.Diagnostic)" -ForegroundColor Yellow
}
if ($entry.Status -notin @('SaclConfigured', 'ChangeRequired')) {
$Context.Results.Add([pscustomobject]@{ Id = $id; Kind = 'AdObjectSacl'; Target = @{ Server = $Session.Server; Dn = $entry.Dn }; Desired = $entry.Definitions;
Before = $entry.Before; After = $null; Status = $(if ($entry.Status -eq 'NotApplicable') { 'Skipped' } else { 'Failed' }); Diagnostic = $entry.Diagnostic })
continue
}
$state = @{ Session = $Session; Entry = $entry; Observed = $null; Baseline = $null; Context = $Context }
$read = {
param($state)
$snapshot = Get-WelaAdObjectState $state.Session $state.Entry.Dn
if ($snapshot.ObjectGuid -ne $state.Entry.Before.ObjectGuid) { throw 'Target object identity changed after planning.' }
if ($state.Baseline -and -not (Test-WelaAdPreserved $state.Baseline $snapshot)) { throw 'Existing owner, group, DACL or SACL ACE changed; inspect the recovery journal. No automatic restore is attempted.' }
$state.Observed = $snapshot
return $snapshot
}
$test = { param($snapshot, $state)
foreach ($definition in $state.Entry.Definitions) { if (-not (Test-WelaAdAcePresent $snapshot.Descriptor $definition)) { return $false } }
return $true
}
$apply = {
param($state)
$before = $state.Observed
$addition = New-WelaAdSaclAddition $before $state.Entry.Definitions
$receipt = [ordered]@{ Version = 1; Kind = 'WelaAdSaclAddition'; ReceiptStatus = 'Pending'; Server = $state.Session.Server; Dn = $before.Dn;
ObjectGuid = $before.ObjectGuid; Before = $before; AddedAces = $addition.AddedAces; ExpectedBinary = $addition.Binary;
ConfirmedUtc = $null; ConfirmedAfter = $null }
# Durable intent precedes mutation, but cannot authorize rollback.
# A failed/stale request may never have written its intended ACEs.
$receiptPath = Join-Path $state.Context.BackupPath ('ad-sacl-' + [guid]::NewGuid().ToString('N') + '.json')
$receipt | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $receiptPath -Encoding UTF8 -ErrorAction Stop
$fresh = Get-WelaAdObjectState $state.Session $state.Entry.Dn
if ($fresh.ObjectGuid -ne $before.ObjectGuid -or $fresh.UsnChanged -ne $before.UsnChanged -or $fresh.Descriptor.Binary -ne $before.Descriptor.Binary) { throw 'AD object changed after journaling; no write was sent. Re-audit and retry.' }
$state.Baseline = $before
Write-WelaAdSacl $state.Session $before.Dn $addition.Binary
$verified = Get-WelaAdObjectState $state.Session $before.Dn
if (-not (Test-WelaAdPreserved $before $verified)) { throw 'Existing owner, group, DACL or SACL ACE changed after writing; receipt remains Pending and requires manual recovery review.' }
foreach ($definition in $state.Entry.Definitions) {
if (-not (Test-WelaAdAcePresent $verified.Descriptor $definition)) { throw 'Requested SACL did not verify after writing; receipt remains Pending and requires manual recovery review.' }
}
$receipt.ReceiptStatus = 'Confirmed'
$receipt.ConfirmedUtc = [DateTime]::UtcNow.ToString('o')
$receipt.ConfirmedAfter = $verified
$confirmedPath = $receiptPath + '.tmp'
$receipt | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $confirmedPath -Encoding UTF8 -ErrorAction Stop
# Preserve the complete Pending receipt if confirmation cannot persist.
[IO.File]::Replace($confirmedPath, $receiptPath, ($receiptPath + '.pending'))
"SACL-only write and read-back verified; confirmed recovery receipt: $receiptPath. Event generation and inheritance propagation remain unverified."
}
Invoke-WelaConfigurationControl -Context $Context -Id $id -Kind AdObjectSacl -Target @{ Server = $Session.Server; Dn = $entry.Dn } `
-Desired $entry.Definitions -Read $read -Compliant $test -Apply $apply -CallbackState $state `
-Description 'Add only missing audit ACEs on this exact DC/object. Directory auditing may increase event volume.'
}
}
function Invoke-WelaAdSaclRollback {
param($Session, $Context, [string]$ReceiptPath)
$receipt = Get-Content -LiteralPath $ReceiptPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($receipt.Version -ne 1 -or $receipt.Kind -ne 'WelaAdSaclAddition' -or $receipt.Server -ine $Session.Server -or
-not $receipt.AddedAces.Count -or $receipt.ObjectGuid -ne $receipt.Before.ObjectGuid -or $receipt.Dn -ine $receipt.Before.Dn) { throw 'Invalid receipt, empty additions, or a different DC target.' }
if ($receipt.ReceiptStatus -ne 'Confirmed' -or -not $receipt.ConfirmedUtc -or
$receipt.ConfirmedAfter.ObjectGuid -ne $receipt.ObjectGuid -or $receipt.ConfirmedAfter.Server -ine $Session.Server -or
$receipt.ConfirmedAfter.Dn -ine $receipt.Dn) { throw 'Unconfirmed receipt: automatic rollback cannot establish ACE ownership. Review Pending/failed/interrupted changes manually.' }
$expected = Get-WelaAdDescriptorInfo $receipt.ExpectedBinary
# Receipts are recovery evidence, not a general descriptor-restore mechanism.
$remaining = New-Object 'System.Collections.Generic.List[string]'
foreach ($ace in $expected.Sacl) { $remaining.Add($ace) }
foreach ($ace in $receipt.AddedAces) { if (-not $remaining.Remove([string]$ace)) { throw 'Receipt additions do not match its expected SACL.' } }
if (($remaining.ToArray() -join '|') -ne (@($receipt.Before.Descriptor.Sacl) -join '|')) { throw 'Receipt would remove or replace pre-existing audit ACEs.' }
$state = @{ Session = $Session; Receipt = $receipt; Observed = $null; Expected = $expected; Baseline = $null }
$read = { param($state)
$snapshot = Get-WelaAdObjectState $state.Session $state.Receipt.Dn
if ($snapshot.ObjectGuid -ne $state.Receipt.ObjectGuid) { throw 'Rollback object identity mismatch.' }
if ($state.Baseline -and ($snapshot.Descriptor.Owner -ne $state.Baseline.Descriptor.Owner -or
$snapshot.Descriptor.Group -ne $state.Baseline.Descriptor.Group -or $snapshot.Descriptor.Dacl -ne $state.Baseline.Descriptor.Dacl -or
$snapshot.Descriptor.ControlFlags -ne $state.Baseline.Descriptor.ControlFlags)) { throw 'Owner/group/DACL/descriptor flags changed during rollback; inspect the journal.' }
$state.Observed = $snapshot
return $snapshot
}
$test = { param($snapshot, $state)
return (@($snapshot.Descriptor.Sacl) -join '|') -eq (@($state.Receipt.Before.Descriptor.Sacl) -join '|')
}
$apply = { param($state)
$before = $state.Observed
if ((@($before.Descriptor.Sacl) -join '|') -ne (@($state.Expected.Sacl) -join '|')) { throw 'SACL drift or ACE merging makes ownership ambiguous; automated rollback refused.' }
$fresh = Get-WelaAdObjectState $state.Session $state.Receipt.Dn
if ($fresh.UsnChanged -ne $before.UsnChanged -or $fresh.ObjectGuid -ne $before.ObjectGuid -or $fresh.Descriptor.Binary -ne $before.Descriptor.Binary) { throw 'Object changed before rollback; no write sent.' }
$sd = [Security.AccessControl.RawSecurityDescriptor]::new([Convert]::FromBase64String($fresh.Descriptor.Binary), 0)
for ($i = $sd.SystemAcl.Count - 1; $i -ge 0; $i--) {
$encoded = ConvertTo-WelaAdBinaryString $sd.SystemAcl[$i]
if ($state.Receipt.AddedAces -contains $encoded) { $sd.SystemAcl.RemoveAce($i) }
}
$state.Baseline = $fresh
Write-WelaAdSacl $state.Session $state.Receipt.Dn (ConvertTo-WelaAdBinaryString $sd)
'Removed only exact receipt-owned audit ACEs; no owner/group/DACL restoration performed.'
}
Invoke-WelaConfigurationControl -Context $Context -Id "AdSaclRollback/$($receipt.Dn)" -Kind AdObjectSaclRollback `
-Target @{ Server = $Session.Server; Dn = $receipt.Dn } -Desired @{ RemoveExactAces = $receipt.AddedAces } `
-Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Remove only the exact audit ACE additions from this trusted receipt.'
}
function Invoke-WelaAdSaclCommand {
param([ValidateSet('Audit', 'Plan', 'Configure', 'Rollback')][string]$Action = 'Audit', [string]$Server,
[ValidateSet('MdiDomain', 'MdiConfiguration', 'PkiObjects')][string[]]$Profiles, [string[]]$ObjectDn,
[string]$ReceiptPath, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
if ($DryRun -and $Action -notin @('Configure', 'Rollback')) { throw 'DryRun applies only to Configure or Rollback.' }
if ($Action -eq 'Rollback') {
if (-not $ReceiptPath -or $Profiles.Count -or $ObjectDn.Count) { throw 'Rollback requires AdReceiptPath and no profile/object selection.' }
} elseif (-not $Profiles.Count -or $ReceiptPath) { throw 'Select at least one explicit AdSaclProfile; AdReceiptPath is for Rollback only.' }
$session = Open-WelaAdSession $Server
try {
$plan = @()
if ($Action -ne 'Rollback') { $plan = @(Get-WelaAdSaclPlan $session $Profiles $ObjectDn) }
if ($Action -in @('Configure', 'Rollback')) {
$context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
if ($Action -eq 'Rollback') { Invoke-WelaAdSaclRollback $session $context $ReceiptPath }
else { Set-WelaAdSaclControls $session $context $plan }
$report = Complete-WelaConfiguration -Context $context -Scope 'ad-object-sacl-only' `
-SuccessMessage 'Requested SACL state verified on the selected DC; audit policy, inherited propagation and event generation remain separate checks.'
} else {
$report = [pscustomobject]@{ ExitCode = $(if (@($plan | Where-Object Status -in @('Unknown', 'Blocked')).Count) { 1 } else { 0 }); Scope = 'ad-object-sacl-only'; Results = $plan }
}
$report | Add-Member NoteProperty Server $session.Server
$report | Add-Member NoteProperty Action $Action
$report | Add-Member NoteProperty AuditPolicyPrerequisites @(
[pscustomobject]@{ Name = 'Directory Service Access'; Guid = '0cce923b-69ae-11d9-bed3-505054503030'; Required = 'Success (Failure also required for Configuration failure auditing)'; Status = 'Unknown'; Diagnostic = 'Remote DC audit policy is not read or changed by this LDAP command.' },
[pscustomobject]@{ Name = 'Directory Service Changes'; Guid = '0cce923c-69ae-11d9-bed3-505054503030'; Required = 'Success'; Status = 'Unknown'; Diagnostic = 'Verify effective policy and 5136 on the DC handling the object change.' })
$report | Add-Member NoteProperty VerificationScope 'Selected-DC object SACL state only. Skipped or Unknown class prerequisites do not establish auditing for those classes. Inherited child SACLs, protected objects, policy, 4662/5136 generation, replication and collection are unverified. No Sigma uplift is claimed.'
if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
return $report
} finally { $session.Connection.Dispose() }
}