Invoke-FvAdOuAceRemediation.psm1

#Region './Public/Backup-FvAdOuAcl.ps1' -1

function Backup-FvAdOuAcl {
    <#
    .SYNOPSIS
    Serialises an OU's current DACL to a manifest file, sufficient to restore it exactly.

    .DESCRIPTION
    Reads the target OU's nTSecurityDescriptor, extracts the DACL-only portion in SDDL form
    (owner, primary group and the SACL are deliberately excluded - out of scope for this
    suite), and writes it to a JSON manifest alongside a SHA-256 integrity hash of that SDDL
    string, the OU's distinguished name, and a UTC timestamp. Restore-FvAdOuAcl is the only
    function that consumes this manifest format.

    .PARAMETER OrganizationalUnit
    One or more OU distinguished names to back up.

    .PARAMETER BackupPath
    Directory to write the manifest into. Created if it does not already exist. Defaults to
    a 'FvAdOuAclBackups' folder under the current working directory.

    .PARAMETER Server
    The domain controller to query. Defaults to runtime discovery.

    .PARAMETER Credential
    Alternate credential to use. Defaults to the caller's current security context.

    .EXAMPLE
    Backup-FvAdOuAcl -OrganizationalUnit 'OU=Example,DC=contoso,DC=local' -BackupPath 'C:\Backups'

    Writes a timestamped manifest for the named OU into C:\Backups.

    .EXAMPLE
    'OU=Example,DC=contoso,DC=local' | Backup-FvAdOuAcl -BackupPath 'C:\Backups' -WhatIf

    Previews the manifest that would be written without creating it.

    .OUTPUTS
    PSCustomObject (PSTypeName 'Fv.AdOuAclBackup') describing the manifest written: path,
    OU distinguished name, UTC timestamp, and the SHA-256 integrity hash.

    .NOTES
    Required permissions: read access to the target OU's nTSecurityDescriptor attribute, and
    write access to -BackupPath. No AD write permission is required or used.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')]
        [Alias('DistinguishedName')]
        [string[]]
        $OrganizationalUnit,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $BackupPath = (Join-Path -Path $PWD -ChildPath 'FvAdOuAclBackups'),

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Server,

        [Parameter()]
        [PSCredential]
        [System.Management.Automation.Credential()]
        $Credential = [PSCredential]::Empty
    )

    begin {
        $ErrorActionPreference = 'Stop'

        $adParams = @{}
        if ($PSBoundParameters.ContainsKey('Server')) {
            $adParams['Server'] = $Server
        }
        if ($PSBoundParameters.ContainsKey('Credential')) {
            $adParams['Credential'] = $Credential
        }

        try {
            if (-not (Test-Path -Path $BackupPath)) {
                New-Item -ItemType Directory -Path $BackupPath -Force -ErrorAction Stop | Out-Null
            }
        } catch {
            throw "Backup-FvAdOuAcl: unable to create backup directory '$BackupPath': $($_.Exception.Message)"
        }

        $accessOnly = [System.Security.AccessControl.AccessControlSections]::Access
    }

    process {
        foreach ($dn in $OrganizationalUnit) {
            try {
                $adObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop
                $sddl = $adObject.nTSecurityDescriptor.GetSecurityDescriptorSddlForm($accessOnly)

                $sha256 = [System.Security.Cryptography.SHA256]::Create()
                try {
                    $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($sddl))
                } finally {
                    $sha256.Dispose()
                }
                $hashHex = -join ($hashBytes | ForEach-Object { $_.ToString('x2') })

                $timestampUtc = [datetime]::UtcNow
                $sanitizedDn = $dn -replace '[\\/:*?"<>|,=]', '_'
                $fileName = "FvAdOuAcl_${sanitizedDn}_$($timestampUtc.ToString('yyyyMMddTHHmmssZ')).json"
                $manifestPath = Join-Path -Path $BackupPath -ChildPath $fileName

                $manifest = [PSCustomObject]@{
                    OrganizationalUnit = $dn
                    TimestampUtc       = $timestampUtc.ToString('o')
                    Sddl               = $sddl
                    Sha256             = $hashHex
                    CapturedBy         = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
                }

                if ($PSCmdlet.ShouldProcess($dn, "Write DACL backup manifest to '$manifestPath'")) {
                    $manifest | ConvertTo-Json -Depth 4 | Out-File -FilePath $manifestPath -Encoding UTF8 -ErrorAction Stop

                    [PSCustomObject]@{
                        PSTypeName         = 'Fv.AdOuAclBackup'
                        OrganizationalUnit = $dn
                        ManifestPath       = $manifestPath
                        TimestampUtc       = $manifest.TimestampUtc
                        Sha256             = $hashHex
                        Success            = $true
                        ErrorReason        = $null
                    }
                }
            } catch {
                Write-Error -Message "Backup-FvAdOuAcl: backup failed for '$dn': $($_.Exception.Message)" -ErrorAction Continue
                [PSCustomObject]@{
                    PSTypeName         = 'Fv.AdOuAclBackup'
                    OrganizationalUnit = $dn
                    ManifestPath       = $null
                    TimestampUtc       = [datetime]::UtcNow.ToString('o')
                    Sha256             = $null
                    Success            = $false
                    ErrorReason        = $_.Exception.Message
                }
            }
        }
    }

    end {
        Write-Verbose 'Backup-FvAdOuAcl: complete.'
    }
}
#EndRegion './Public/Backup-FvAdOuAcl.ps1' 151
#Region './Public/Get-FvAdOuAce.ps1' -1

function Get-FvAdOuAce {
    <#
    .SYNOPSIS
    Reads the ACEs on one or more Active Directory Organizational Units and flags any that
    match the identity and rights watchlists.

    .DESCRIPTION
    Strictly read-only. For each supplied OU distinguished name, reads the nTSecurityDescriptor
    attribute via Get-ADObject and emits one object per ACE found, annotated with whether it
    matches the identity/rights watchlists, whether it is inherited, and (where the identity
    reference cannot be resolved to a SID) an Unresolved status. This function accepts no
    parameter, and performs no call, capable of writing to the directory.

    .PARAMETER OrganizationalUnit
    One or more OU distinguished names to assess, e.g. 'OU=Finance,DC=contoso,DC=local'.

    .PARAMETER Server
    The domain controller to query. Defaults to runtime discovery (Get-ADObject's own default).

    .PARAMETER Credential
    Alternate credential to use. Defaults to the caller's current security context.

    .PARAMETER IdentityWatchlist
    Security principals considered over-permissive, as well-known/domain-relative SID strings.
    When omitted, defaults to S-1-1-0 (Everyone), S-1-5-11 (Authenticated Users), S-1-5-7
    (Anonymous Logon), S-1-5-32-545 (BUILTIN\Users), and the domain-relative Domain Users
    (<domain SID>-513) and Pre-Windows 2000 Compatible Access (<domain SID>-554) principals,
    with the domain SID discovered at runtime.

    .PARAMETER RightsWatchlist
    The ActiveDirectoryRights values considered over-permissive. Defaults to GenericAll,
    GenericWrite, WriteDacl, WriteOwner, WriteProperty, Delete, DeleteTree, ExtendedRight.

    .EXAMPLE
    Get-FvAdOuAce -OrganizationalUnit 'OU=Example,DC=contoso,DC=local'

    Lists every ACE on the named OU, flagging any that match the default watchlists.

    .EXAMPLE
    'OU=Example,DC=contoso,DC=local' | Get-FvAdOuAce | Where-Object IsOverPermissive

    Pipes a single OU DN in and returns only the ACEs flagged as over-permissive.

    .OUTPUTS
    PSCustomObject (PSTypeName 'Fv.AdOuAce') - one per ACE, or one Status='Error' row per OU
    that could not be read.

    .NOTES
    Required permissions: read access to the target OU's nTSecurityDescriptor attribute
    (generally granted by default AD read access). No write permission is required or used.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')]
        [Alias('DistinguishedName')]
        [string[]]
        $OrganizationalUnit,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Server,

        [Parameter()]
        [PSCredential]
        [System.Management.Automation.Credential()]
        $Credential = [PSCredential]::Empty,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]
        $IdentityWatchlist,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.DirectoryServices.ActiveDirectoryRights[]]
        $RightsWatchlist = @(
            'GenericAll', 'GenericWrite', 'WriteDacl', 'WriteOwner',
            'WriteProperty', 'Delete', 'DeleteTree', 'ExtendedRight'
        )
    )

    begin {
        $ErrorActionPreference = 'Stop'
        Write-Verbose 'Get-FvAdOuAce: resolving connection parameters and watchlists.'

        $adParams = @{}
        if ($PSBoundParameters.ContainsKey('Server')) {
            $adParams['Server'] = $Server
        }
        if ($PSBoundParameters.ContainsKey('Credential')) {
            $adParams['Credential'] = $Credential
        }

        if (-not $PSBoundParameters.ContainsKey('IdentityWatchlist')) {
            try {
                $domainSid = (Get-ADDomain @adParams -ErrorAction Stop).DomainSID.Value
            } catch {
                throw "Get-FvAdOuAce: unable to discover the domain SID to build the default identity watchlist: $($_.Exception.Message)"
            }
            $IdentityWatchlist = @(
                'S-1-1-0', 'S-1-5-11', 'S-1-5-7', 'S-1-5-32-545',
                "$domainSid-513", "$domainSid-554"
            )
        }

        $watchlistSidValues = [System.Collections.Generic.List[string]]::new()
        foreach ($entry in $IdentityWatchlist) {
            try {
                $watchlistSidValues.Add(([System.Security.Principal.SecurityIdentifier]::new($entry)).Value)
            } catch {
                Write-Warning "Get-FvAdOuAce: '$entry' in the identity watchlist is not a valid SID and will be ignored."
            }
        }

        # A right "matches the watchlist" when the ACE's rights fully contain at least one
        # watchlisted right's bit pattern - not merely when any bit overlaps. GenericAll's
        # numeric value (983551 / 0xF01FF) is a combination of many lower-privilege bits
        # (including ReadProperty), so a naive combined-mask "-band -ne 0" test would flag an
        # ordinary ReadProperty-only ACE as a GenericAll match purely by bit coincidence -
        # confirmed during this build. Testing full containment per watchlisted right avoids that.
        $rightsMatchTest = {
            param($CandidateRights)
            foreach ($watchedRight in $RightsWatchlist) {
                if (($CandidateRights -band $watchedRight) -eq $watchedRight) {
                    return $true
                }
            }
            return $false
        }

        $ouCount = 0
        $aceCount = 0
    }

    process {
        foreach ($dn in $OrganizationalUnit) {
            $ouCount++
            try {
                $adObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop
            } catch {
                Write-Error -Message "Get-FvAdOuAce: could not read '$dn': $($_.Exception.Message)" -ErrorAction Continue
                [PSCustomObject]@{
                    PSTypeName            = 'Fv.AdOuAce'
                    OrganizationalUnit    = $dn
                    IdentityReference     = $null
                    Sid                   = $null
                    ActiveDirectoryRights = $null
                    AccessControlType     = $null
                    IsInherited           = $null
                    InheritanceType       = $null
                    ObjectType            = $null
                    InheritedObjectType   = $null
                    IsOverPermissive      = $false
                    MatchedRule           = $null
                    Status                = 'Error'
                    ErrorReason           = $_.Exception.Message
                }
                continue
            }

            $securityDescriptor = $adObject.nTSecurityDescriptor
            if (-not $securityDescriptor) {
                Write-Warning "Get-FvAdOuAce: '$dn' returned no nTSecurityDescriptor."
                continue
            }

            # ActiveDirectorySecurity.Access returns only explicit rules by default; GetAccessRules
            # with includeInherited:$true is required to see inherited ACEs too (confirmed by direct
            # comparison against a live OU's descriptor during this build: .Access returned 1 entry
            # where GetAccessRules($true,$true,...) returned 33 for the same object).
            $allAces = $securityDescriptor.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
            foreach ($ace in $allAces) {
                $aceCount++
                $status = 'Ok'
                $sidValue = $null
                $errorReason = $null

                try {
                    $sidValue = $ace.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
                } catch {
                    $status = 'Unresolved'
                    $errorReason = "Identity reference '$($ace.IdentityReference)' could not be translated to a SID."
                }

                $isOverPermissive = $false
                $matchedRule = $null
                if ($status -eq 'Ok') {
                    $identityMatch = $sidValue -in $watchlistSidValues
                    $rightsMatch = & $rightsMatchTest $ace.ActiveDirectoryRights
                    if ($identityMatch -and $rightsMatch -and
                        $ace.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow) {
                        $isOverPermissive = $true
                        $matchedRule = "Identity:$sidValue;Rights:$($ace.ActiveDirectoryRights)"
                        $status = 'OverPermissive'
                    }
                }

                [PSCustomObject]@{
                    PSTypeName            = 'Fv.AdOuAce'
                    OrganizationalUnit    = $dn
                    IdentityReference     = $ace.IdentityReference.Value
                    Sid                   = $sidValue
                    ActiveDirectoryRights = $ace.ActiveDirectoryRights
                    AccessControlType     = $ace.AccessControlType
                    IsInherited           = $ace.IsInherited
                    InheritanceType       = $ace.InheritanceType
                    ObjectType            = $ace.ObjectType
                    InheritedObjectType   = $ace.InheritedObjectType
                    IsOverPermissive      = $isOverPermissive
                    MatchedRule           = $matchedRule
                    Status                = $status
                    ErrorReason           = $errorReason
                }
            }
        }
    }

    end {
        Write-Verbose "Get-FvAdOuAce: processed $ouCount OU(s), $aceCount ACE(s)."
    }
}
#EndRegion './Public/Get-FvAdOuAce.ps1' 226
#Region './Public/Get-FvAdOuAceReport.ps1' -1

function Get-FvAdOuAceReport {
    <#
    .SYNOPSIS
    Domain-wide (or search-base-scoped) sweep for over-permissive ACEs. Must be invoked
    deliberately - nothing else in this suite calls it implicitly.

    .DESCRIPTION
    Enumerates Organizational Units under the supplied search base(s), evaluates each one's
    ACEs against the identity and rights watchlists (by composing Get-FvAdOuAce), and returns
    the combined object set. When -OutputPath is supplied, also exports the report in the
    requested format(s). Read-only against the directory; writing the export file is the only
    state change this function makes, and is itself guarded by ShouldProcess.

    .PARAMETER SearchBase
    One or more distinguished names to sweep. Defaults to the current domain's distinguished
    name, discovered at runtime via Get-ADDomain.

    .PARAMETER SearchScope
    The search scope to use under each search base: Base, OneLevel or Subtree. Default: Subtree.

    .PARAMETER Server
    The domain controller to query. Defaults to runtime discovery.

    .PARAMETER Credential
    Alternate credential to use. Defaults to the caller's current security context.

    .PARAMETER IdentityWatchlist
    See Get-FvAdOuAce. Forwarded unchanged; same default-resolution behaviour applies.

    .PARAMETER RightsWatchlist
    See Get-FvAdOuAce. Forwarded unchanged.

    .PARAMETER OutputPath
    Directory to write the exported report into. Created if it does not already exist. When
    omitted, no file is written - only pipeline objects are returned.

    .PARAMETER OutputFormat
    One or more of 'Csv', 'Html', 'Json'. Only meaningful when -OutputPath is supplied.
    Default: Csv.

    .EXAMPLE
    Get-FvAdOuAceReport -SearchBase 'DC=contoso,DC=local'

    Sweeps every OU in the domain and returns the flagged/unflagged ACE objects to the pipeline.

    .EXAMPLE
    Get-FvAdOuAceReport -SearchBase 'DC=contoso,DC=local' -OutputPath 'C:\Reports' -OutputFormat Csv, Html

    Sweeps the domain and additionally writes a timestamped CSV and HTML report to C:\Reports.

    .OUTPUTS
    PSCustomObject (PSTypeName 'Fv.AdOuAce') - the same shape as Get-FvAdOuAce.

    .NOTES
    Required permissions: read access to enumerate OUs under the search base and to each
    target OU's nTSecurityDescriptor attribute. Write access to -OutputPath's filesystem
    location when an export is requested. No AD write permission is required or used.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string[]]
        $SearchBase,

        [Parameter()]
        [ValidateSet('Base', 'OneLevel', 'Subtree')]
        [string]
        $SearchScope = 'Subtree',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Server,

        [Parameter()]
        [PSCredential]
        [System.Management.Automation.Credential()]
        $Credential = [PSCredential]::Empty,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]
        $IdentityWatchlist,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.DirectoryServices.ActiveDirectoryRights[]]
        $RightsWatchlist = @(
            'GenericAll', 'GenericWrite', 'WriteDacl', 'WriteOwner',
            'WriteProperty', 'Delete', 'DeleteTree', 'ExtendedRight'
        ),

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $OutputPath,

        [Parameter()]
        [ValidateSet('Csv', 'Html', 'Json')]
        [string[]]
        $OutputFormat = @('Csv')
    )

    begin {
        $ErrorActionPreference = 'Stop'
        Write-Verbose 'Get-FvAdOuAceReport: resolving connection parameters.'

        $adParams = @{}
        if ($PSBoundParameters.ContainsKey('Server')) {
            $adParams['Server'] = $Server
        }
        if ($PSBoundParameters.ContainsKey('Credential')) {
            $adParams['Credential'] = $Credential
        }

        $assessParams = @{} + $adParams
        if ($PSBoundParameters.ContainsKey('IdentityWatchlist')) {
            $assessParams['IdentityWatchlist'] = $IdentityWatchlist
        }
        $assessParams['RightsWatchlist'] = $RightsWatchlist

        $searchBaseResolved = $SearchBase
        if (-not $PSBoundParameters.ContainsKey('SearchBase') -or -not $searchBaseResolved) {
            try {
                $searchBaseResolved = @((Get-ADDomain @adParams -ErrorAction Stop).DistinguishedName)
            } catch {
                throw "Get-FvAdOuAceReport: unable to discover the default search base: $($_.Exception.Message)"
            }
        }

        if ($OutputPath -and -not (Test-Path -Path $OutputPath)) {
            try {
                New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction Stop | Out-Null
            } catch {
                throw "Get-FvAdOuAceReport: unable to create output directory '$OutputPath': $($_.Exception.Message)"
            }
        }

        $allResults = [System.Collections.Generic.List[PSCustomObject]]::new()
        $searchBaseCount = 0
    }

    process {
        foreach ($base in $searchBaseResolved) {
            $searchBaseCount++
            try {
                $ous = Get-ADOrganizationalUnit -SearchBase $base -Filter '*' -SearchScope $SearchScope @adParams -ErrorAction Stop
            } catch {
                Write-Error -Message "Get-FvAdOuAceReport: could not enumerate OUs under '$base': $($_.Exception.Message)" -ErrorAction Continue
                continue
            }

            foreach ($ou in $ous) {
                foreach ($result in (Get-FvAdOuAce -OrganizationalUnit $ou.DistinguishedName @assessParams)) {
                    $allResults.Add($result)
                }
            }
        }
    }

    end {
        Write-Verbose "Get-FvAdOuAceReport: swept $searchBaseCount search base(s), returning $($allResults.Count) ACE record(s)."

        if ($OutputPath) {
            $generatedUtc = [datetime]::UtcNow
            $generatedDisplay = $generatedUtc.ToString('dd/MM/yyyy HH:mm', [System.Globalization.CultureInfo]::InvariantCulture) + ' UTC'
            $stamp = $generatedUtc.ToString('yyyyMMddTHHmmssZ')

            if ($PSCmdlet.ShouldProcess($OutputPath, "Write Fv AD OU ACE report ($($OutputFormat -join ', '))")) {
                foreach ($format in $OutputFormat) {
                    $reportFile = Join-Path -Path $OutputPath -ChildPath "FvAdOuAceReport_$stamp.$($format.ToLowerInvariant())"
                    switch ($format) {
                        'Csv' {
                            $allResults | Export-Csv -Path $reportFile -NoTypeInformation -Encoding UTF8
                        }
                        'Html' {
                            $allResults |
                                ConvertTo-Html -Title 'Active Directory OU ACE Report' `
                                    -PreContent "<h1>Active Directory OU ACE Report</h1><p>Generated: $generatedDisplay</p>" |
                                    Out-File -FilePath $reportFile -Encoding UTF8
                        }
                        'Json' {
                            [PSCustomObject]@{
                                GeneratedUtc = $generatedUtc.ToString('o')
                                Findings     = $allResults
                            } | ConvertTo-Json -Depth 6 | Out-File -FilePath $reportFile -Encoding UTF8
                        }
                    }
                    Write-Verbose "Get-FvAdOuAceReport: wrote '$reportFile'."
                }
            }
        }

        $allResults.ToArray()
    }
}
#EndRegion './Public/Get-FvAdOuAceReport.ps1' 199
#Region './Public/Invoke-FvAdOuAceRemediation.ps1' -1

function Invoke-FvAdOuAceRemediation {
    <#
    .SYNOPSIS
    Single entry point for assessment, reporting, remediation and rollback of over-permissive
    OU ACEs, selected by mutually exclusive switch parameter.

    .DESCRIPTION
    Dispatches to Get-FvAdOuAce (-Assess, the default when no switch is given),
    Get-FvAdOuAceReport (-Report), Reset-FvAdOuAce (-Remediate) or Restore-FvAdOuAcl
    (-Rollback). The four modes are implemented as distinct PowerShell parameter sets, so the
    shell itself rejects specifying more than one switch, and each set surfaces only the
    parameters relevant to that mode. -Remediate and -Rollback carry SupportsShouldProcess;
    this function makes one batch-level ShouldProcess call covering the whole invocation, then
    forwards -WhatIf and calls the worker function with -Confirm:$false so the operator is
    prompted once, not twice, while the worker's own per-OU idempotence and verification logic
    still runs unchanged.

    .PARAMETER Assess
    Read-only assessment (Get-FvAdOuAce). This is the default parameter set - if no switch is
    given at all, this is what runs.

    .PARAMETER Report
    Domain-wide reporting sweep (Get-FvAdOuAceReport). Must be selected explicitly.

    .PARAMETER Remediate
    Remediation (Reset-FvAdOuAce). State-changing; SupportsShouldProcess.

    .PARAMETER Rollback
    Rollback (Restore-FvAdOuAcl). State-changing; SupportsShouldProcess.

    .PARAMETER OrganizationalUnit
    OU distinguished name(s). Used by -Assess and -Remediate; optional cross-check for
    -Rollback.

    .PARAMETER SearchBase
    Search base(s) for -Report. See Get-FvAdOuAceReport.

    .PARAMETER SearchScope
    Search scope for -Report. See Get-FvAdOuAceReport.

    .PARAMETER OutputPath
    Export directory for -Report. See Get-FvAdOuAceReport.

    .PARAMETER OutputFormat
    Export format(s) for -Report. See Get-FvAdOuAceReport.

    .PARAMETER RestoreMode
    Restore mode for -Remediate. See Reset-FvAdOuAce. Default: RemoveAce.

    .PARAMETER BackupPath
    Backup manifest directory for -Remediate. See Reset-FvAdOuAce / Backup-FvAdOuAcl.

    .PARAMETER ManifestPath
    Manifest path(s) for -Rollback. See Restore-FvAdOuAcl.

    .PARAMETER IdentityWatchlist
    Identity watchlist for -Assess, -Report and -Remediate. See Get-FvAdOuAce.

    .PARAMETER RightsWatchlist
    Rights watchlist for -Assess, -Report and -Remediate. See Get-FvAdOuAce.

    .PARAMETER Server
    The domain controller to query. Defaults to runtime discovery. Available in every mode.

    .PARAMETER Credential
    Alternate credential. Defaults to the caller's current security context. Available in
    every mode.

    .EXAMPLE
    Invoke-FvAdOuAceRemediation -OrganizationalUnit 'OU=Example,DC=contoso,DC=local'

    Read-only assessment of the named OU (the default mode - equivalent to -Assess).

    .EXAMPLE
    Invoke-FvAdOuAceRemediation -Remediate -OrganizationalUnit 'OU=Example,DC=contoso,DC=local' -BackupPath 'C:\Backups' -WhatIf

    Previews remediation of the named OU with no changes made.

    .OUTPUTS
    PSCustomObject - the shape returned by whichever worker function the selected mode
    dispatches to (Fv.AdOuAce for -Assess/-Report, Fv.AdOuAceResult for -Remediate/-Rollback).

    .NOTES
    Required permissions: as the dispatched worker function - read-only for -Assess/-Report,
    WriteDacl for -Remediate/-Rollback (see their own .NOTES). The default parameter set is
    -Assess (read-only); a write path is only ever reached when the caller explicitly selects
    -Remediate or -Rollback.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Assess', SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(ParameterSetName = 'Assess')]
        [switch]
        $Assess,

        [Parameter(ParameterSetName = 'Report')]
        [switch]
        $Report,

        [Parameter(ParameterSetName = 'Remediate')]
        [switch]
        $Remediate,

        [Parameter(ParameterSetName = 'Rollback')]
        [switch]
        $Rollback,

        [Parameter(ParameterSetName = 'Assess', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Parameter(ParameterSetName = 'Remediate', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Parameter(ParameterSetName = 'Rollback', ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')]
        [string[]]
        $OrganizationalUnit,

        [Parameter(ParameterSetName = 'Report', ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string[]]
        $SearchBase,

        [Parameter(ParameterSetName = 'Report')]
        [ValidateSet('Base', 'OneLevel', 'Subtree')]
        [string]
        $SearchScope = 'Subtree',

        [Parameter(ParameterSetName = 'Report')]
        [ValidateNotNullOrEmpty()]
        [string]
        $OutputPath,

        [Parameter(ParameterSetName = 'Report')]
        [ValidateSet('Csv', 'Html', 'Json')]
        [string[]]
        $OutputFormat = @('Csv'),

        [Parameter(ParameterSetName = 'Remediate')]
        [ValidateSet('RemoveAce', 'SchemaDefaultDacl')]
        [string]
        $RestoreMode = 'RemoveAce',

        [Parameter(ParameterSetName = 'Remediate')]
        [ValidateNotNullOrEmpty()]
        [string]
        $BackupPath = (Join-Path -Path $PWD -ChildPath 'FvAdOuAclBackups'),

        [Parameter(ParameterSetName = 'Rollback', Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
        [string[]]
        $ManifestPath,

        [Parameter(ParameterSetName = 'Assess')]
        [Parameter(ParameterSetName = 'Report')]
        [Parameter(ParameterSetName = 'Remediate')]
        [ValidateNotNullOrEmpty()]
        [string[]]
        $IdentityWatchlist,

        [Parameter(ParameterSetName = 'Assess')]
        [Parameter(ParameterSetName = 'Report')]
        [Parameter(ParameterSetName = 'Remediate')]
        [ValidateNotNullOrEmpty()]
        [System.DirectoryServices.ActiveDirectoryRights[]]
        $RightsWatchlist = @(
            'GenericAll', 'GenericWrite', 'WriteDacl', 'WriteOwner',
            'WriteProperty', 'Delete', 'DeleteTree', 'ExtendedRight'
        ),

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Server,

        [Parameter()]
        [PSCredential]
        [System.Management.Automation.Credential()]
        $Credential = [PSCredential]::Empty
    )

    begin {
        $forwardKeys = 'Server', 'Credential', 'IdentityWatchlist', 'RightsWatchlist'
        $forward = @{}
        foreach ($key in $forwardKeys) {
            if ($PSBoundParameters.ContainsKey($key)) {
                $forward[$key] = $PSBoundParameters[$key]
            }
        }

        $ouBuffer = [System.Collections.Generic.List[string]]::new()
        $manifestBuffer = [System.Collections.Generic.List[string]]::new()
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Assess' {
                foreach ($dn in $OrganizationalUnit) { $ouBuffer.Add($dn) }
            }
            'Rollback' {
                foreach ($m in $ManifestPath) { $manifestBuffer.Add($m) }
            }
            default {
                if ($OrganizationalUnit) {
                    foreach ($dn in $OrganizationalUnit) { $ouBuffer.Add($dn) }
                }
            }
        }
    }

    end {
        switch ($PSCmdlet.ParameterSetName) {
            'Assess' {
                Get-FvAdOuAce -OrganizationalUnit $ouBuffer.ToArray() @forward
            }
            'Report' {
                $reportForward = $forward.Clone()
                if ($PSBoundParameters.ContainsKey('SearchBase')) { $reportForward['SearchBase'] = $SearchBase }
                if ($PSBoundParameters.ContainsKey('OutputPath')) { $reportForward['OutputPath'] = $OutputPath }
                $reportForward['SearchScope'] = $SearchScope
                $reportForward['OutputFormat'] = $OutputFormat
                Get-FvAdOuAceReport @reportForward
            }
            'Remediate' {
                if ($PSCmdlet.ShouldProcess("$($ouBuffer.Count) organizational unit(s)", "Remediate (RestoreMode=$RestoreMode)")) {
                    $remediateForward = $forward.Clone()
                    $remediateForward['RestoreMode'] = $RestoreMode
                    $remediateForward['BackupPath'] = $BackupPath
                    Reset-FvAdOuAce -OrganizationalUnit $ouBuffer.ToArray() @remediateForward -Confirm:$false
                }
            }
            'Rollback' {
                if ($PSCmdlet.ShouldProcess("$($manifestBuffer.Count) manifest(s)", 'Roll back DACL')) {
                    $rollbackForward = @{}
                    if ($PSBoundParameters.ContainsKey('Server')) { $rollbackForward['Server'] = $Server }
                    if ($PSBoundParameters.ContainsKey('Credential')) { $rollbackForward['Credential'] = $Credential }
                    if ($OrganizationalUnit) { $rollbackForward['OrganizationalUnit'] = $OrganizationalUnit }
                    Restore-FvAdOuAcl -ManifestPath $manifestBuffer.ToArray() @rollbackForward -Confirm:$false
                }
            }
        }
    }
}
#EndRegion './Public/Invoke-FvAdOuAceRemediation.ps1' 242
#Region './Public/Reset-FvAdOuAce.ps1' -1

function Reset-FvAdOuAce {
    <#
    .SYNOPSIS
    Removes over-permissive explicit ACEs from an OU's DACL (remediation).

    .DESCRIPTION
    For each target OU: takes a fresh Backup-FvAdOuAcl snapshot first and aborts that OU if
    the backup fails; determines which explicit, Allow ACEs match the identity and rights
    watchlists; if none match, reports NoChangeRequired and performs no write (this idempotence
    check runs before ShouldProcess, so -WhatIf output is honest); otherwise applies the
    selected -RestoreMode, guarded by $PSCmdlet.ShouldProcess(); then re-reads the OU and
    verifies the targeted ACE(s) are gone.

    Two restore modes are implemented:
      RemoveAce (default) - removes only the explicit Allow ACEs matching the identity and
        rights watchlists. Every other ACE, including all inherited ACEs and all legitimate
        delegations, is left untouched. This is the minimal, reversible change.
      SchemaDefaultDacl - rebuilds the OU's entire DACL from the live defaultSecurityDescriptor
        of the organizationalUnit classSchema object, read from the schema naming context at
        runtime (never a literal SDDL string). This discards ALL custom delegations on the OU,
        not just the offending ACE, and inherited ACEs will re-merge per the standard
        DACL-inheritance rules for directory objects - it is not equivalent to a pristine OU.
        A Write-Warning is emitted every time this mode runs. It is never the default.

    Only explicit (IsInherited -eq $false) Allow ACEs are ever removed. Inherited ACEs matching
    the watchlists are left in place - remediating them requires a change at their parent.

    .PARAMETER OrganizationalUnit
    One or more OU distinguished names to remediate.

    .PARAMETER RestoreMode
    'RemoveAce' (default) or 'SchemaDefaultDacl'. See DESCRIPTION.

    .PARAMETER BackupPath
    Directory for the pre-change backup manifest. Forwarded to Backup-FvAdOuAcl.

    .PARAMETER IdentityWatchlist
    See Get-FvAdOuAce. Used only by RemoveAce mode.

    .PARAMETER RightsWatchlist
    See Get-FvAdOuAce. Used only by RemoveAce mode.

    .PARAMETER Server
    The domain controller to query. Defaults to runtime discovery.

    .PARAMETER Credential
    Alternate credential to use. Defaults to the caller's current security context.

    .EXAMPLE
    Reset-FvAdOuAce -OrganizationalUnit 'OU=Example,DC=contoso,DC=local' -WhatIf

    Previews the explicit over-permissive ACE(s) that would be removed, with no changes made.

    .EXAMPLE
    'OU=Example,DC=contoso,DC=local' | Reset-FvAdOuAce -BackupPath 'C:\Backups' -Confirm:$false

    Backs up then removes the offending ACE(s) from the named OU without an interactive prompt.

    .OUTPUTS
    PSCustomObject (PSTypeName 'Fv.AdOuAceResult'): OrganizationalUnit, Action (Removed,
    Skipped, NoChangeRequired or Failed), AcesAffected, BackupManifestPath, VerificationResult,
    TimestampUtc, RanAs.

    .NOTES
    Required permissions: WriteDacl on each target OU; read access to the schema naming
    context when -RestoreMode SchemaDefaultDacl is used. This function changes directory
    state - SupportsShouldProcess/-WhatIf/-Confirm are fully honoured, and ConfirmImpact is
    'High'.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')]
        [Alias('DistinguishedName')]
        [string[]]
        $OrganizationalUnit,

        [Parameter()]
        [ValidateSet('RemoveAce', 'SchemaDefaultDacl')]
        [string]
        $RestoreMode = 'RemoveAce',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $BackupPath = (Join-Path -Path $PWD -ChildPath 'FvAdOuAclBackups'),

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]
        $IdentityWatchlist,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.DirectoryServices.ActiveDirectoryRights[]]
        $RightsWatchlist = @(
            'GenericAll', 'GenericWrite', 'WriteDacl', 'WriteOwner',
            'WriteProperty', 'Delete', 'DeleteTree', 'ExtendedRight'
        ),

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Server,

        [Parameter()]
        [PSCredential]
        [System.Management.Automation.Credential()]
        $Credential = [PSCredential]::Empty
    )

    begin {
        $ErrorActionPreference = 'Stop'

        $adParams = @{}
        if ($PSBoundParameters.ContainsKey('Server')) {
            $adParams['Server'] = $Server
        }
        if ($PSBoundParameters.ContainsKey('Credential')) {
            $adParams['Credential'] = $Credential
        }

        $backupParams = @{} + $adParams
        $backupParams['BackupPath'] = $BackupPath

        if (-not $PSBoundParameters.ContainsKey('IdentityWatchlist')) {
            try {
                $domainSid = (Get-ADDomain @adParams -ErrorAction Stop).DomainSID.Value
            } catch {
                throw "Reset-FvAdOuAce: unable to discover the domain SID to build the default identity watchlist: $($_.Exception.Message)"
            }
            $IdentityWatchlist = @(
                'S-1-1-0', 'S-1-5-11', 'S-1-5-7', 'S-1-5-32-545',
                "$domainSid-513", "$domainSid-554"
            )
        }

        $watchlistSidValues = [System.Collections.Generic.List[string]]::new()
        foreach ($entry in $IdentityWatchlist) {
            try {
                $watchlistSidValues.Add(([System.Security.Principal.SecurityIdentifier]::new($entry)).Value)
            } catch {
                Write-Warning "Reset-FvAdOuAce: '$entry' in the identity watchlist is not a valid SID and will be ignored."
            }
        }

        # See the matching identical comment in Get-FvAdOuAce: full containment per watchlisted
        # right, not a combined-mask "any bit overlaps" test.
        $rightsMatchTest = {
            param($CandidateRights)
            foreach ($watchedRight in $RightsWatchlist) {
                if (($CandidateRights -band $watchedRight) -eq $watchedRight) {
                    return $true
                }
            }
            return $false
        }

        $accessOnly = [System.Security.AccessControl.AccessControlSections]::Access
        $runAsIdentity = if ($PSBoundParameters.ContainsKey('Credential')) {
            $Credential.UserName
        } else {
            [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
        }

        $schemaDefaultDaclSddl = $null
        if ($RestoreMode -eq 'SchemaDefaultDacl') {
            Write-Warning 'Reset-FvAdOuAce: -RestoreMode SchemaDefaultDacl discards ALL custom delegations on each target OU, not only the offending ACE, and inherited ACEs will re-merge per standard DACL-inheritance rules. This is not equivalent to a pristine OU.'
            try {
                $schemaNc = (Get-ADRootDSE @adParams -ErrorAction Stop).schemaNamingContext
                $classSchema = Get-ADObject -SearchBase $schemaNc -Filter "lDAPDisplayName -eq 'organizationalUnit' -and objectClass -eq 'classSchema'" -Properties defaultSecurityDescriptor @adParams -ErrorAction Stop
                if (-not $classSchema -or -not $classSchema.defaultSecurityDescriptor) {
                    throw 'the organizationalUnit classSchema object returned no defaultSecurityDescriptor value.'
                }
                $schemaSd = [System.DirectoryServices.ActiveDirectorySecurity]::new()
                $schemaSd.SetSecurityDescriptorSddlForm($classSchema.defaultSecurityDescriptor)
                $schemaDefaultDaclSddl = $schemaSd.GetSecurityDescriptorSddlForm($accessOnly)
            } catch {
                throw "Reset-FvAdOuAce: unable to read the live schema default security descriptor: $($_.Exception.Message)"
            }
        }
    }

    process {
        foreach ($dn in $OrganizationalUnit) {
            $result = [ordered]@{
                PSTypeName         = 'Fv.AdOuAceResult'
                OrganizationalUnit = $dn
                Action             = 'Failed'
                AcesAffected       = @()
                BackupManifestPath = $null
                VerificationResult = $false
                VerificationDetail = $null
                TimestampUtc       = [datetime]::UtcNow.ToString('o')
                RanAs              = $runAsIdentity
                ErrorReason        = $null
            }

            try {
                $adObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop
                $securityDescriptor = $adObject.nTSecurityDescriptor

                $matchingAces = [System.Collections.Generic.List[System.DirectoryServices.ActiveDirectoryAccessRule]]::new()
                # See the note in Get-FvAdOuAce: use GetAccessRules(includeInherited:$true), not .Access.
                foreach ($ace in @($securityDescriptor.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))) {
                    if ($ace.IsInherited) { continue }
                    if ($ace.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue }
                    $aceSid = $null
                    try {
                        $aceSid = $ace.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
                    } catch {
                        continue
                    }
                    if ($aceSid -notin $watchlistSidValues) { continue }
                    if (-not (& $rightsMatchTest $ace.ActiveDirectoryRights)) { continue }
                    $matchingAces.Add($ace)
                }

                if ($RestoreMode -eq 'RemoveAce' -and $matchingAces.Count -eq 0) {
                    $result.Action = 'NoChangeRequired'
                    $result.ErrorReason = $null
                    [PSCustomObject]$result
                    continue
                }

                $target = if ($RestoreMode -eq 'RemoveAce') {
                    "remove $($matchingAces.Count) over-permissive explicit ACE(s)"
                } else {
                    'rebuild the entire DACL from the live schema default'
                }

                if (-not $PSCmdlet.ShouldProcess($dn, $target)) {
                    $result.Action = 'Skipped'
                    $result.ErrorReason = 'Declined by operator or -WhatIf specified.'
                    [PSCustomObject]$result
                    continue
                }

                $backupResult = Backup-FvAdOuAcl -OrganizationalUnit $dn @backupParams -Confirm:$false -ErrorAction Stop
                if (-not $backupResult -or -not $backupResult.Success) {
                    $result.Action = 'Failed'
                    $result.ErrorReason = 'Pre-change backup failed; no write was attempted.'
                    [PSCustomObject]$result
                    continue
                }
                $result.BackupManifestPath = $backupResult.ManifestPath

                $affected = [System.Collections.Generic.List[string]]::new()
                if ($RestoreMode -eq 'RemoveAce') {
                    foreach ($ace in $matchingAces) {
                        $null = $securityDescriptor.RemoveAccessRuleSpecific($ace)
                        $affected.Add("$($ace.IdentityReference.Value): $($ace.ActiveDirectoryRights) (Allow)")
                    }
                    Set-ADObject -Identity $dn -Replace @{ ntSecurityDescriptor = $securityDescriptor } @adParams -Confirm:$false -ErrorAction Stop
                } else {
                    $newSecurityDescriptor = [System.DirectoryServices.ActiveDirectorySecurity]::new()
                    $newSecurityDescriptor.SetSecurityDescriptorSddlForm($schemaDefaultDaclSddl, $accessOnly)
                    $affected.Add("DACL rebuilt from schema default; $($matchingAces.Count) previously-matching explicit ACE(s) superseded.")
                    Set-ADObject -Identity $dn -Replace @{ ntSecurityDescriptor = $newSecurityDescriptor } @adParams -Confirm:$false -ErrorAction Stop
                }
                $result.AcesAffected = $affected.ToArray()
                $result.Action = 'Removed'

                $postAdObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop
                $postAllAces = $postAdObject.nTSecurityDescriptor.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
                $remaining = @($postAllAces | Where-Object {
                        $candidateAce = $_
                        if ($candidateAce.IsInherited) { return $false }
                        if ($candidateAce.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { return $false }
                        if (-not (& $rightsMatchTest $candidateAce.ActiveDirectoryRights)) { return $false }
                        try {
                            $candidateAce.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -in $watchlistSidValues
                        } catch {
                            $false
                        }
                    })

                if ($remaining.Count -eq 0) {
                    $result.VerificationResult = $true
                    $result.VerificationDetail = 'Re-read after write: no watchlisted explicit Allow ACE remains.'
                } else {
                    $result.VerificationResult = $false
                    $result.VerificationDetail = "Re-read after write: $($remaining.Count) watchlisted explicit Allow ACE(s) still present."
                }
            } catch {
                $result.Action = 'Failed'
                $result.ErrorReason = $_.Exception.Message
                Write-Error -Message "Reset-FvAdOuAce: remediation failed for '$dn': $($_.Exception.Message)" -ErrorAction Continue
            }

            [PSCustomObject]$result
        }
    }

    end {
        Write-Verbose 'Reset-FvAdOuAce: complete.'
    }
}
#EndRegion './Public/Reset-FvAdOuAce.ps1' 301
#Region './Public/Restore-FvAdOuAcl.ps1' -1

function Restore-FvAdOuAcl {
    <#
    .SYNOPSIS
    Restores an OU's DACL from a manifest produced by Backup-FvAdOuAcl (rollback).

    .DESCRIPTION
    Reads a Backup-FvAdOuAcl manifest, recomputes the SHA-256 hash of its stored SDDL and
    refuses to proceed if it does not match the manifest's recorded hash (integrity check).
    When -OrganizationalUnit is supplied, the manifest's own OrganizationalUnit value must
    match it exactly, or the manifest is refused for that target. Applies the manifest's
    DACL-only SDDL to the target OU's nTSecurityDescriptor, guarded by
    $PSCmdlet.ShouldProcess(), then re-reads the OU and verifies the restored DACL matches
    the manifest.

    .PARAMETER ManifestPath
    One or more paths to manifest files written by Backup-FvAdOuAcl.

    .PARAMETER OrganizationalUnit
    Optional cross-check: the OU distinguished name(s) expected to be restored. When supplied,
    every manifest processed in this invocation must have this DN embedded, or it is refused.
    When omitted, the manifest's own embedded DN is trusted as the target (with a warning).

    .PARAMETER Server
    The domain controller to query. Defaults to runtime discovery.

    .PARAMETER Credential
    Alternate credential to use. Defaults to the caller's current security context.

    .EXAMPLE
    Restore-FvAdOuAcl -ManifestPath 'C:\Backups\FvAdOuAcl_OU_Example_20260101T000000Z.json' -WhatIf

    Previews restoring the named OU's DACL from the manifest, with no changes made.

    .EXAMPLE
    Get-ChildItem 'C:\Backups\*.json' | Restore-FvAdOuAcl -Confirm:$false

    Restores every manifest in the backup folder without an interactive prompt.

    .OUTPUTS
    PSCustomObject (PSTypeName 'Fv.AdOuAceResult'): OrganizationalUnit, Action (Restored,
    Skipped or Failed), AcesAffected, BackupManifestPath, VerificationResult, TimestampUtc,
    RanAs.

    .NOTES
    Required permissions: WriteDacl on each target OU. This function changes directory
    state - SupportsShouldProcess/-WhatIf/-Confirm are fully honoured, and ConfirmImpact is
    'High'.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
        [Alias('FullName', 'Path')]
        [string[]]
        $ManifestPath,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^OU=[^,]+(,(OU|DC)=[^,]+)+$')]
        [string[]]
        $OrganizationalUnit,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Server,

        [Parameter()]
        [PSCredential]
        [System.Management.Automation.Credential()]
        $Credential = [PSCredential]::Empty
    )

    begin {
        $ErrorActionPreference = 'Stop'

        $adParams = @{}
        if ($PSBoundParameters.ContainsKey('Server')) {
            $adParams['Server'] = $Server
        }
        if ($PSBoundParameters.ContainsKey('Credential')) {
            $adParams['Credential'] = $Credential
        }

        $accessOnly = [System.Security.AccessControl.AccessControlSections]::Access
        $runAsIdentity = if ($PSBoundParameters.ContainsKey('Credential')) {
            $Credential.UserName
        } else {
            [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
        }

        $expectedOuList = $OrganizationalUnit
    }

    process {
        foreach ($path in $ManifestPath) {
            $result = [ordered]@{
                PSTypeName         = 'Fv.AdOuAceResult'
                OrganizationalUnit = $null
                Action             = 'Failed'
                AcesAffected       = @()
                BackupManifestPath = $path
                VerificationResult = $false
                VerificationDetail = $null
                TimestampUtc       = [datetime]::UtcNow.ToString('o')
                RanAs              = $runAsIdentity
                ErrorReason        = $null
            }

            try {
                $manifest = Get-Content -Path $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
                $result.OrganizationalUnit = $manifest.OrganizationalUnit

                $sha256 = [System.Security.Cryptography.SHA256]::Create()
                try {
                    $recomputedBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($manifest.Sddl))
                } finally {
                    $sha256.Dispose()
                }
                $recomputedHex = -join ($recomputedBytes | ForEach-Object { $_.ToString('x2') })

                if ($recomputedHex -ne $manifest.Sha256) {
                    throw "manifest integrity check failed for '$path' (SHA-256 mismatch)."
                }

                if ($expectedOuList -and $manifest.OrganizationalUnit -notin $expectedOuList) {
                    throw "manifest OU '$($manifest.OrganizationalUnit)' does not match the requested target(s) ($($expectedOuList -join ', '))."
                }
                if (-not $expectedOuList) {
                    Write-Warning "Restore-FvAdOuAcl: no -OrganizationalUnit cross-check was supplied for '$path'; trusting the manifest's own embedded DN '$($manifest.OrganizationalUnit)'."
                }

                $dn = $manifest.OrganizationalUnit

                if (-not $PSCmdlet.ShouldProcess($dn, "Restore DACL from manifest '$path'")) {
                    $result.Action = 'Skipped'
                    $result.ErrorReason = 'Declined by operator or -WhatIf specified.'
                    [PSCustomObject]$result
                    continue
                }

                $restoredSecurityDescriptor = [System.DirectoryServices.ActiveDirectorySecurity]::new()
                $restoredSecurityDescriptor.SetSecurityDescriptorSddlForm($manifest.Sddl, $accessOnly)

                Set-ADObject -Identity $dn -Replace @{ ntSecurityDescriptor = $restoredSecurityDescriptor } @adParams -Confirm:$false -ErrorAction Stop

                $result.Action = 'Restored'
                $result.AcesAffected = @("DACL replaced from manifest '$path' (captured $($manifest.TimestampUtc)).")

                $postAdObject = Get-ADObject -Identity $dn -Properties nTSecurityDescriptor @adParams -ErrorAction Stop
                $postSddl = $postAdObject.nTSecurityDescriptor.GetSecurityDescriptorSddlForm($accessOnly)

                if ($postSddl -eq $manifest.Sddl) {
                    $result.VerificationResult = $true
                    $result.VerificationDetail = 'Re-read after write: DACL matches the manifest exactly.'
                } else {
                    $result.VerificationResult = $false
                    $result.VerificationDetail = 'Re-read after write: DACL does not match the manifest (this can legitimately occur if inherited ACEs re-merged differently).'
                }
            } catch {
                $result.Action = 'Failed'
                $result.ErrorReason = $_.Exception.Message
                Write-Error -Message "Restore-FvAdOuAcl: rollback failed for manifest '$path': $($_.Exception.Message)" -ErrorAction Continue
            }

            [PSCustomObject]$result
        }
    }

    end {
        Write-Verbose 'Restore-FvAdOuAcl: complete.'
    }
}
#EndRegion './Public/Restore-FvAdOuAcl.ps1' 176