Files
WELA/tests/AdObjectSacl.Tests.ps1

227 lines
18 KiB
PowerShell

$ErrorActionPreference = 'Stop'
$repo = Split-Path $PSScriptRoot -Parent
$script:ScriptRoot = $repo
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/AdObjectSacl.ps1')
$script:checks = 0
function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:checks++ }
function Assert-Throws([scriptblock]$Action, [string]$Message) { $thrown = $false; try { & $Action } catch { $thrown = $true }; Assert $thrown $Message }
$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-ad-sacl-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $root
$session = [pscustomobject]@{ Server = 'dc1.example.test'; DomainDn = 'DC=example,DC=test'; ConfigurationDn = 'CN=Configuration,DC=example,DC=test'; SchemaDn = 'CN=Schema,CN=Configuration,DC=example,DC=test'; Writable = $true }
$script:originalDmsa = ${function:Test-WelaAdDmsaDomain}
$script:originalSchema = ${function:Test-WelaAdSchemaClass}
$script:originalRead = ${function:Get-WelaAdObjectState}
$script:originalSearch = ${function:Search-WelaAdDirectory}
$script:originalPresent = ${function:Test-WelaAdAcePresent}
$script:originalAddition = ${function:New-WelaAdSaclAddition}
$script:originalWrite = ${function:Write-WelaAdSacl}
$script:originalInfo = ${function:Get-WelaAdDescriptorInfo}
function Reset-Mocks {
$script:writes = 0; $script:reads = 0; $script:readError = $false; $script:writeError = $false
$script:race = $false; $script:finalDrift = $false; $script:badReadback = $false; $script:removeExisting = $false
$script:absentClass = ''; $script:dmsa = $true; $script:exchange = $true; $script:onPrompt = $null; $script:pkiMember = $true; $script:pkiRequest = $null
$script:dmsaError = ''; $script:schemaErrorClass = ''; $script:plannedAdditions = @()
$script:state = [pscustomobject]@{ Server = $session.Server; Dn = $session.DomainDn; ObjectGuid = '01234567-89ab-cdef-0123-456789abcdef'; UsnChanged = '17'; Classes = @('top', 'domainDNS');
Descriptor = [pscustomobject]@{ Binary = 'before'; Sddl = 'O:SYG:SYD:(A;;GA;;;SY)S:(AU;SA;WP;;;BA)'; Owner = 'S-1-5-18'; Group = 'S-1-5-18'; Dacl = 'unchanged-dacl'; ControlFlags = 32788; Sacl = @('unrelated') } }
$session.Writable = $true
}
function Test-WelaAdSchemaClass { param($Session, $Class, $Guid)
if ($Class -eq $script:schemaErrorClass) { throw 'Schema lookup access denied' }
return $Class -ne $script:absentClass
}
function Test-WelaAdDmsaDomain { param($Session)
if ($script:dmsaError) { throw $script:dmsaError }
return $script:dmsa
}
function Search-WelaAdDirectory {
param($Session, $Dn, $Filter, $Scope, $Attributes, [switch]$SecurityDescriptor)
if ($Filter -eq '(objectClass=msExchOrganizationContainer)' -and $script:exchange) { [pscustomobject]@{ Dn = 'CN=Exchange'; Values = @{} } }
if ($Filter -like '(objectGUID=*') {
$script:pkiRequest = @{ Dn = $Dn; Scope = $Scope; Filter = $Filter }
if ($script:pkiMember) { [pscustomobject]@{ Dn = $script:state.Dn; Values = @{ objectGUID = @(,([guid]$script:state.ObjectGuid).ToByteArray()) } } }
}
}
function Get-WelaAdObjectState {
param($Session, $Dn)
$script:reads++
if ($script:readError) { throw 'LDAP access denied' }
$snapshot = $script:state | ConvertTo-Json -Depth 10 | ConvertFrom-Json
if ($script:race -and $script:reads -ge 3) { $snapshot.UsnChanged = '99' }
if ($script:finalDrift -and $script:reads -ge 6) { $snapshot.Descriptor.Sacl = @('unrelated'); $snapshot.Descriptor.Binary = 'drift' }
return $snapshot
}
function Test-WelaAdAcePresent { param($Descriptor, $Definition) return $Descriptor.Sacl -contains ('added-' + $Definition.Class) }
function New-WelaAdSaclAddition {
param($Before, $Definitions)
$script:plannedAdditions = @($Definitions | Where-Object { -not (Test-WelaAdAcePresent $Before.Descriptor $_) } | ForEach-Object { 'added-' + $_.Class })
[pscustomobject]@{ Binary = 'after'; AddedAces = $script:plannedAdditions }
}
function Write-WelaAdSacl {
param($Session, $Dn, $Binary)
if ($script:writeError) { throw 'LDAP insufficientAccessRights' }
$script:writes++
if (-not $script:badReadback) {
$script:state.Descriptor.Sacl = @($script:state.Descriptor.Sacl) + $script:plannedAdditions
$script:state.Descriptor.Binary = $Binary
}
if ($script:removeExisting) { $script:state.Descriptor.Sacl = @($script:state.Descriptor.Sacl | Where-Object { $_ -ne 'unrelated' }) }
$script:state.UsnChanged = '18'
}
function Read-Host { param($Prompt) if ($script:onPrompt) { & $script:onPrompt }; return 'y' }
function Get-Context([bool]$Dry = $false, [bool]$Automatic = $true) {
New-WelaConfigurationContext -Auto:$Automatic -DryRun:$Dry -BackupPath (Join-Path $root ([guid]::NewGuid().ToString('N')))
}
function Invoke-TestConfigure($Context) {
$plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @())
Set-WelaAdSaclControls $session $Context $plan
Complete-WelaConfiguration -Context $Context -Scope ad-object-sacl-only
}
try {
$defs = @(Get-WelaAdAuditDefinitions)
Assert ($defs.Count -eq 6) 'all six MDI descendant classes are defined'
Assert ((@($defs.AccessMask) -join ',') -eq '852331,852331,852331,852331,852075,852075') 'exact Microsoft readiness masks, including gMSA/dMSA distinction'
Assert (@($defs | Where-Object { $_.Sid -ne 'S-1-1-0' -or $_.AceFlags -ne 74 -or $_.Inheritance -ne 'DescendantsOnly' -or $_.ObjectType -ne [guid]::Empty.ToString() }).Count -eq 0) 'success, descendant-only, unrestricted property scope is explicit'
Assert (($defs.InheritedObjectType | Select-Object -Unique).Count -eq 6) 'all inherited class GUIDs differ'
Reset-Mocks
$plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @())
Assert ($plan.Count -eq 1 -and $plan[0].Definitions.Count -eq 6 -and $plan[0].Status -eq 'ChangeRequired') 'domain plan covers exact root'
Assert ($plan[0].Server -eq $session.Server -and $plan[0].Dn -eq $session.DomainDn) 'plan binds exact server and DN'
$script:dmsa = $false
$plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @())
Assert ($plan[0].Definitions.Count -eq 5 -and $plan[0].Diagnostic -like '*dMSA skipped*') 'dMSA omitted without a 2025 domain DC'
Assert ($plan[0].SkippedDefinitions[0].PrerequisiteStatus -eq 'NotApplicable') 'known dMSA omission is distinct from unknown prerequisites'
$script:absentClass = 'user'
Assert ((@(Get-WelaAdSaclPlan $session @('MdiDomain') @()))[0].Status -eq 'Unknown') 'missing required class blocks writes'
foreach ($errorMessage in @('DC versions could not all be classified; dMSA applicability is unknown.', 'LDAP DC version query access denied')) {
Reset-Mocks; $script:dmsaError = $errorMessage
$plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @())
Assert ($plan[0].Status -eq 'ChangeRequired' -and $plan[0].Definitions.Count -eq 5) 'unknown optional dMSA check preserves five independent planned ACEs'
Assert ($plan[0].SkippedDefinitions.Count -eq 1 -and $plan[0].SkippedDefinitions[0].PrerequisiteStatus -eq 'Unknown' -and
$plan[0].SkippedDefinitions[0].Diagnostic.Contains($errorMessage)) 'plan records the skipped dMSA prerequisite and exact unknown diagnostic'
$ctx = Get-Context; $report = Invoke-TestConfigure $ctx
$applied = @($report.Results | Where-Object Kind -eq 'AdObjectSacl')
$gap = @($report.Results | Where-Object Kind -eq 'AdObjectSaclPrerequisite')
Assert ($report.ExitCode -eq 0 -and $report.Skipped -eq 1 -and $applied[0].Status -eq 'Applied' -and $applied[0].Desired.Count -eq 5) 'five ACEs configure with a visible skipped-control result'
Assert ($gap.Count -eq 1 -and $gap[0].Status -eq 'Skipped' -and $gap[0].PrerequisiteStatus -eq 'Unknown' -and $gap[0].Target.Class -eq 'msDS-DelegatedManagedServiceAccount') 'configuration retains structured dMSA Unknown gap'
Assert ($script:plannedAdditions.Count -eq 5 -and $script:state.Descriptor.Sacl -notcontains 'added-msDS-DelegatedManagedServiceAccount') 'unknown dMSA never produces a sixth ACE write'
}
Reset-Mocks; $script:schemaErrorClass = 'msDS-DelegatedManagedServiceAccount'
$plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @())
Assert ($plan[0].Status -eq 'ChangeRequired' -and $plan[0].Definitions.Count -eq 5 -and $plan[0].SkippedDefinitions[0].PrerequisiteStatus -eq 'Unknown') 'optional dMSA schema read error also remains isolated'
Reset-Mocks; $script:schemaErrorClass = 'user'
$ctx = Get-Context; $report = Invoke-TestConfigure $ctx
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0 -and $report.Results[0].Status -eq 'Failed') 'mandatory schema lookup failure still fails closed'
Reset-Mocks; $script:exchange = $false
Assert ((@(Get-WelaAdSaclPlan $session @('MdiConfiguration') @()))[0].Status -eq 'NotApplicable') 'configuration gated on Exchange history evidence'
$script:exchange = $true
$plan = @(Get-WelaAdSaclPlan $session @('MdiConfiguration') @())
Assert ($plan[0].Definitions[0].AccessMask -eq 32 -and $plan[0].Definitions[0].AceFlags -eq 194) 'Configuration WriteProperty success/failure inheritance is exact'
Assert-Throws { Get-WelaAdSaclPlan $session @('PkiObjects') @() } 'PKI requires explicit target DNs'
$script:state.Dn = "CN=Test,CN=Certificate Templates,CN=Public Key Services,CN=Services,$($session.ConfigurationDn)"
$script:state.Classes = @('top', 'pKICertificateTemplate')
$plan = @(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn))
Assert ($plan[0].Status -eq 'ChangeRequired' -and $plan[0].Definitions[0].AccessMask -eq 852000 -and $plan[0].Definitions[0].AceFlags -eq 64) 'PKI direct-object write/delete/ACL audit only'
Assert ($script:pkiRequest.Scope -eq 'OneLevel' -and $script:pkiRequest.Dn -eq "CN=Certificate Templates,CN=Public Key Services,CN=Services,$($session.ConfigurationDn)" -and
$script:pkiRequest.Filter -eq '(objectGUID=\67\45\23\01\AB\89\EF\CD\01\23\45\67\89\AB\CD\EF)') 'PKI membership uses exact parent and byte-escaped object GUID'
$script:pkiMember = $false
$script:state.Dn = "CN=Test,$($session.DomainDn)"
Assert ((@(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn)))[0].Status -eq 'Unknown') 'PKI class outside trusted forest containers refused'
$script:state.Dn = "CN=Foo\,CN=Certificate Templates,CN=Public Key Services,CN=Services,$($session.ConfigurationDn)"
Assert ((@(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn)))[0].Status -eq 'Unknown') 'escaped RDN suffix cannot impersonate approved PKI parent'
Reset-Mocks; $session.Writable = $false
Assert ((@(Get-WelaAdSaclPlan $session @('MdiDomain') @()))[0].Status -eq 'Blocked') 'RODC plan explicitly blocked'
Reset-Mocks; $script:readError = $true
$ctx = Get-Context; $report = Invoke-TestConfigure $ctx
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0) 'access denied is failed/unknown, never an empty descriptor'
Reset-Mocks; $ctx = Get-Context $true
$report = Invoke-TestConfigure $ctx
Assert ($report.DryRun -and $script:writes -eq 0 -and -not (Test-Path $ctx.BackupPath)) 'dry run does not write AD or journal'
Reset-Mocks; $ctx = Get-Context
$report = Invoke-TestConfigure $ctx
Assert ($report.ExitCode -eq 0 -and $report.Results[0].Status -eq 'Applied' -and $script:writes -eq 1) 'additive apply and final verification succeed'
$journal = Get-Content (Join-Path $ctx.BackupPath 'before.jsonl') | ConvertFrom-Json
Assert ($journal.Before.Descriptor.Sddl -like 'O:SY*' -and $journal.Before.ObjectGuid -eq $script:state.ObjectGuid) 'journal contains original SDDL and identity'
$receipts = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')
$receipt = Get-Content $receipts[0].FullName -Raw | ConvertFrom-Json
Assert ($receipt.AddedAces.Count -eq 6 -and $receipt.Before.Descriptor.Binary -eq 'before') 'receipt stores only intended additions and before binary'
Assert ($receipt.ReceiptStatus -eq 'Confirmed' -and $receipt.ConfirmedAfter.Descriptor.Binary -eq 'after' -and $receipt.ConfirmedUtc) 'successful write and readback confirm rollback ownership'
$ctx2 = Get-Context; $report2 = Invoke-TestConfigure $ctx2
Assert ($script:writes -eq 1 -and $report2.Results[0].Status -eq 'AlreadyCompliant') 'repeat run is idempotent'
Reset-Mocks; $script:race = $true; $ctx = Get-Context
$report = Invoke-TestConfigure $ctx
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0 -and $report.Results[0].Diagnostic -like '*changed after journaling*') 'USN race fails closed before write'
$pendingPath = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')[0].FullName
Assert ((Get-Content $pendingPath -Raw | ConvertFrom-Json).ReceiptStatus -eq 'Pending') 'stale pre-write receipt remains pending'
$script:state.Descriptor.Sacl = @('unrelated') + @(Get-WelaAdAuditDefinitions | ForEach-Object { 'added-' + $_.Class })
$script:state.Descriptor.Binary = 'after'
Assert-Throws { Invoke-WelaAdSaclRollback $session (Get-Context $true) $pendingPath } 'pending intent cannot authorize rollback of another writers matching ACEs'
Reset-Mocks; $script:writeError = $true; $ctx = Get-Context
$report = Invoke-TestConfigure $ctx
$pendingPath = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')[0].FullName
Assert ($report.ExitCode -eq 1 -and (Get-Content $pendingPath -Raw | ConvertFrom-Json).ReceiptStatus -eq 'Pending') 'failed LDAP write cannot confirm receipt'
Assert-Throws { Invoke-WelaAdSaclRollback $session (Get-Context $true) $pendingPath } 'failed-write receipt cannot authorize automatic rollback'
Reset-Mocks; $script:badReadback = $true; $ctx = Get-Context
Assert ((Invoke-TestConfigure $ctx).ExitCode -eq 1) 'missing audit ACE readback fails'
$pendingPath = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')[0].FullName
Assert ((Get-Content $pendingPath -Raw | ConvertFrom-Json).ReceiptStatus -eq 'Pending') 'failed readback leaves receipt pending'
Reset-Mocks; $script:removeExisting = $true; $ctx = Get-Context
$report = Invoke-TestConfigure $ctx
Assert ($report.ExitCode -eq 1 -and $report.Results[0].Diagnostic -like '*Existing owner*') 'loss of pre-existing audit ACE fails verification'
Reset-Mocks; $script:finalDrift = $true; $ctx = Get-Context
$report = Invoke-TestConfigure $ctx
Assert ($report.ExitCode -eq 1 -and $report.Results[0].Status -eq 'Overridden') 'later missing WELA ACEs are overridden'
Reset-Mocks; $ctx = Get-Context $false $false
$script:onPrompt = { Remove-Item -LiteralPath $ctx.BackupPath -Recurse -Force }
$report = Invoke-TestConfigure $ctx
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0) 'journal failure prevents mutation'
# Test schema and DC eligibility adapters with controlled directory responses.
Set-Item function:Test-WelaAdDmsaDomain $script:originalDmsa
function Search-WelaAdDirectory { param($Session, $Dn, $Filter, $Scope, $Attributes)
[pscustomobject]@{ Dn = 'CN=DC1'; Values = @{ operatingSystemVersion = @($script:version) } }
}
$script:version = '10.0 (20348)'; Assert (-not (Test-WelaAdDmsaDomain $session)) 'Server 2022 is not dMSA eligible'
$script:version = '10.0 (26100)'; Assert (Test-WelaAdDmsaDomain $session) 'Server 2025 version proves dMSA applicability'
$script:version = 'unreadable'; Assert-Throws { Test-WelaAdDmsaDomain $session } 'unknown DC versions remain unknown'
Set-Item function:Test-WelaAdSchemaClass $script:originalSchema
function Search-WelaAdDirectory { param($Session, $Dn, $Filter, $Scope, $Attributes)
[pscustomobject]@{ Dn = 'CN=User'; Values = @{ schemaIDGUID = @(,([guid]$script:schemaGuid).ToByteArray()) } }
}
$script:schemaGuid = $defs[0].InheritedObjectType
Assert (Test-WelaAdSchemaClass $session user $script:schemaGuid) 'schema binary GUID checked'
Assert-Throws { Test-WelaAdSchemaClass $session user $defs[1].InheritedObjectType } 'unexpected schema GUID rejected'
# RootDSE binding validation with a fully fake connection: no platform/native calls.
$savedOs = $env:OS
try {
$env:OS = 'Windows_NT'; $script:rootHost = 'dc1.example.test'; $script:rodc = 'FALSE'; $script:disposed = 0
function New-WelaAdConnection { param($Server)
$fake = [pscustomobject]@{}
$fake | Add-Member ScriptMethod Bind { }
$fake | Add-Member ScriptMethod Dispose { $script:disposed++ }
return $fake
}
function Search-WelaAdDirectory { param($Session, $Dn, $Filter, $Scope, $Attributes)
if ($Dn -eq '') {
[pscustomobject]@{ Dn = ''; Values = @{ dnsHostName = @($script:rootHost); defaultNamingContext = @('DC=example,DC=test');
configurationNamingContext = @('CN=Configuration,DC=example,DC=test'); schemaNamingContext = @('CN=Schema,CN=Configuration,DC=example,DC=test');
dsServiceName = @('CN=NTDS Settings,CN=DC1'); supportedCapabilities = @('1.2.840.113556.1.4.800'); supportedControl = @('1.2.840.113556.1.4.801') } }
} else { [pscustomobject]@{ Dn = $Dn; Values = @{ 'msDS-isRODC' = @($script:rodc) } } }
}
$bound = Open-WelaAdSession 'dc1.example.test'
Assert ($bound.Writable -and $bound.Server -eq 'dc1.example.test') 'RootDSE confirms exact selected writable DC'
$script:rodc = 'TRUE'; Assert (-not (Open-WelaAdSession 'dc1.example.test').Writable) 'RootDSE RODC capability remains read-only'
$script:rootHost = 'dc2.example.test'
Assert-Throws { Open-WelaAdSession 'dc1.example.test' } 'wrong RootDSE host fails closed'
Assert ($script:disposed -eq 1) 'failed binding validation disposes connection'
Assert-Throws { Open-WelaAdSession 'LDAP://dc1.example.test' } 'LDAP URLs are not accepted as exact DC names'
} finally { $env:OS = $savedOs }
# No network is ever used. Native SID/ACL APIs are exercised in the Windows suite.
$tokens = $null; $errors = $null
[void][Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors)
Assert ($errors.Count -eq 0) 'WELA entry point parses'
Write-Host "Passed $script:checks AD object SACL mocked assertions. No live AD connection or mutation occurred."
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }