functions/AccessRules/Test-DCAccessRule.ps1

function Test-DCAccessRule
{
<#
    .SYNOPSIS
        Tests all DCs, whether their NTFS filesystem Access Rules are configured as designed.
     
    .DESCRIPTION
        Tests all DCs, whether their NTFS filesystem Access Rules are configured as designed.
        This test ONLY considers paths, that are configured.
        In opposite to the DomainManagement AccessRule Component there is no system that considers part of the DCs filesystem as "under management".
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Test-DCAccessRule -Server corp.contoso.com
     
        Tests, whether the filesystem Access Rules on all DCs of the corp.contoso.com domain are configured as designed.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-PSFCallback -Data $parameters -EnableException $true -PSCmdlet $PSCmdlet
        Assert-Configuration -Type fileSystemAccessRules -Cmdlet $PSCmdlet
        Set-DCDomainContext @parameters
        
        $domainControllers = Get-DomainController @parameters
        $psCred = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
        
        #region Utility Functions
        function ConvertFrom-AccessRuleDefinition
        {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                $InputObject,
                
                [hashtable]
                $Parameters
            )
            
            process
            {
                $resolvedPath = Resolve-String -Text $InputObject.Path -ArgumentList $Parameters
                
                if ($InputObject.Empty)
                {
                    [PSCustomObject]@{
                        Path          = $resolvedPath
                        Identity      = $null
                        Principal      = $null
                        AccessRule    = $null
                        Configuration = $InputObject
                        IdentityError = $false
                        ServerRole    = $InputObject.ServerRole
                        AccessMode    = 'Constrained'
                        Empty          = $true
                    }
                    return
                }
                
                $resolvedIdentity = Resolve-String -Text $InputObject.Identity -ArgumentList $Parameters
                $identityError = $false
                try { $resolvedPrincipal = Resolve-Principal @Parameters -Name $resolvedIdentity -OutputType SID -ErrorAction Stop }
                catch
                {
                    $identityError = $true
                    $resolvedPrincipal = [System.Security.Principal.NTAccount]$resolvedIdentity
                }
                
                $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($resolvedPrincipal, $InputObject.Rights, $InputObject.Inheritance, $InputObject.Propagation, $InputObject.Type)
                Add-Member -InputObject $rule -MemberType NoteProperty -Name DisplayName -Value $resolvedIdentity
                
                [PSCustomObject]@{
                    Path          = $resolvedPath
                    Identity      = $resolvedIdentity
                    Principal      = $resolvedPrincipal
                    AccessRule    = $rule
                    Configuration = $InputObject
                    IdentityError = $identityError
                    ServerRole    = $InputObject.ServerRole
                    AccessMode    = $InputObject.AccessMode
                    Empty          = $false
                }
            }
        }
        
        function Get-RemoteAccessRule
        {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
            [CmdletBinding()]
            param (
                $Session,
                
                [string]
                $Path
            )
            
            $rules = Invoke-Command -Session $Session -ScriptBlock {
                $acl = Get-Acl -Path $using:path
                foreach ($rule in $acl.Access)
                {
                    if ($rule.IsInherited) { continue }
                    [PSCustomObject]@{
                        PSTypeName = 'Remote.FileSystemAccessRule'
                        DisplayName = $rule.IdentityReference.ToString()
                        FileSystemRights = $rule.FileSystemRights
                        FileSystemRightsNumeric = [int]$rule.FileSystemRights
                        AccessControlType = $rule.AccessControlType
                        IdentityReference = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier])
                        InheritanceFlags = $rule.InheritanceFlags
                        PropagationFlags = $rule.PropagationFlags
                        OriginalRights = $rule.FileSystemRights
                    }
                }
            }
            # The default object had display issues when displayed in the "Change" property
            foreach ($rule in $rules)
            {
                $rule.FileSystemRights = Convert-AccessRight -Right $rule.FileSystemRightsNumeric
                $rule
            }
        }
        
        function New-Change
        {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                $RuleObject
            )
            
            Add-Member -InputObject $RuleObject -MemberType ScriptMethod -Name ToString -Force -Value {
                if ($this.DisplayName) { return $this.DisplayName }
                
                return $this.IdentityReference
            } -PassThru
        }
        
        function Test-AccessRule
        {
            [CmdletBinding()]
            param (
                $RuleObject,
                
                $Reference
            )
            
            foreach ($entry in $Reference)
            {
                if ($entry.FileSystemRights -ne $RuleObject.FileSystemRights) { continue }
                if ($entry.AccessControlType -ne $RuleObject.AccessControlType) { continue }
                if ($entry.IdentityReference.ToString() -ne $RuleObject.IdentityReference.ToString()) { continue }
                if ($entry.InheritanceFlags -ne $RuleObject.InheritanceFlags) { continue }
                if ($entry.PropagationFlags -ne $RuleObject.PropagationFlags) { continue }
                
                return $true
            }
            return $false
        }
        
        function Convert-AccessRight
        {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
            [CmdletBinding()]
            param (
                [int]
                $Right
            )
            $bytes = [System.BitConverter]::GetBytes($Right)
            $uint = [System.BitConverter]::ToUInt32($bytes, 0)
            
            $definitiveRight = [DCManagement.FileSystemPermission]$uint
            
            # https://docs.microsoft.com/en-us/windows/win32/fileio/file-security-and-access-rights
            # https://docs.microsoft.com/en-us/windows/win32/secauthz/standard-access-rights
            $genericRightsMap = @{
                All = [DCManagement.FileSystemPermission]::FullControl
                Execute = ([DCManagement.FileSystemPermission]'ExecuteFile, ReadAttributes, ReadPermissions, Synchronize')
                Read = ([DCManagement.FileSystemPermission]'ReadAttributes, ReadData, ReadExtendedAttributes, ReadPermissions, Synchronize')
                Write = ([DCManagement.FileSystemPermission]'AppendData, WriteAttributes, WriteData, WriteExtendedAttributes, ReadPermissions, Synchronize')
            }
            
            if ($definitiveRight -band [DCManagement.FileSystemPermission]::GenericAll) { return [System.Security.AccessControl.FileSystemRights]::FullControl }
            if ($definitiveRight -band [DCManagement.FileSystemPermission]::GenericExecute)
            {
                $definitiveRight = $definitiveRight -bxor [DCManagement.FileSystemPermission]::GenericExecute -bor $genericRightsMap.Execute
            }
            if ($definitiveRight -band [DCManagement.FileSystemPermission]::GenericRead)
            {
                $definitiveRight = $definitiveRight -bxor [DCManagement.FileSystemPermission]::GenericRead -bor $genericRightsMap.Read
            }
            if ($definitiveRight -band [DCManagement.FileSystemPermission]::GenericWrite)
            {
                $definitiveRight = $definitiveRight -bxor [DCManagement.FileSystemPermission]::GenericWrite -bor $genericRightsMap.Write
            }
            [System.Security.AccessControl.FileSystemRights]$definitiveRight.Value__
        }
        #endregion Utility Functions
    }
    process
    {
        foreach ($domainController in $domainControllers)
        {
            $results = @{
                ObjectType = 'FSAccessRule'
                Server       = $domainController.Name
            }
            
            Write-PSFMessage -String 'Test-DCAccessRule.Processing' -StringValues $domainController.Name -Target $domainController.Name
            try { $psSession = New-PSSession -ComputerName $domainController.Name @psCred -ErrorAction Stop }
            catch { Stop-PSFFunction -String 'Test-DCAccessRule.PSSession.Failed' -StringValues $domainController.Name -EnableException $EnableException -Cmdlet $PSCmdlet -Continue -Target $domainController.Name -ErrorRecord $_ }
            $accessConfigurations = Get-DCAccessRule | Where-Object {
                $_.ServerRole -eq 'ALL' -or
                ($_.ServerRole -eq 'FSMO' -and $domainController.IsFSMO) -or
                ($_.ServerRole -eq 'PDC' -and $domainController.IsPDCEmulator)
            } | ConvertFrom-AccessRuleDefinition -Parameters $parameters
            
            $groupedByPath = $accessConfigurations | Group-Object -Property Path
            foreach ($path in $groupedByPath)
            {
                Write-PSFMessage -String 'Test-DCAccessRule.Processing.Path' -StringValues $domainController.Name, $path.Name -Target $domainController.Name
                
                $pathExists = Invoke-Command -Session $psSession -ScriptBlock { Test-Path -Path $using:path.Name }
                if (-not $pathExists)
                {
                    foreach ($entry in $path.Group)
                    {
                        New-TestResult @results -Type NoPath -Configuration $entry -Identity $path.Name -Changed (New-Change -RuleObject $desiredRule.AccessRule)
                    }
                    Stop-PSFFunction -String 'Test-DCAccessRule.Path.ExistsNot' -StringValues $domainController.Name, $path.Name -EnableException $EnableException -Cmdlet $PSCmdlet -Continue -Target $domainController.Name
                }
                
                $existingRules = Get-RemoteAccessRule -Session $psSession -Path $path.Name
                
                #region Empty Mode: No explicit ACE should exist
                if ($path.Group | Where-Object Empty)
                {
                    
                    foreach ($rule in $existingRules)
                    {
                        New-TestResult @results -Type Remove -Configuration $path.Group -ADObject $existingRules -Identity $path.Name -Changed (New-Change -RuleObject $rule)
                    }
                    continue
                }
                #endregion Empty Mode: No explicit ACE should exist
                
                $effectiveMode = 'Additive'
                if ($path.Group | Where-Object AccessMode -EQ 'Defined') { $effectiveMode = 'Defined' }
                if ($path.Group | Where-Object AccessMode -EQ 'Constrained') { $effectiveMode = 'Constrained' }
                
                if ($path.Group | Where-Object IdentityError)
                {
                    # Interrupt if Constrained and resolution error
                    if ($effectiveMode -eq 'Constrained')
                    {
                        $errorCfg = $path.Group | Where-Object IdentityError
                        Stop-PSFFunction -String 'Test-DCAccessRule.Identity.Error' -StringValues $domainController.Name, $path.Name, ($errorCfg.Identity -join ",") -EnableException $EnableException -Cmdlet $PSCmdlet -Continue -Target $domainController.Name
                    }
                    else
                    {
                        Write-PSFMessage -Level Warning -String 'Test-DCAccessRule.Identity.Error' -StringValues $domainController.Name, $path.Name, ($errorCfg.Identity -join ",")
                    }
                }
                
                $effectiveDesiredState = $path.Group | Where-Object IdentityError -NE $true
                
                #region Compare desired state with existing state
                foreach ($desiredRule in $effectiveDesiredState)
                {
                    if (Test-AccessRule -RuleObject $desiredRule.AccessRule -Reference $existingRules) { continue }
                    New-TestResult @results -Type Add -Configuration $desiredRule -ADObject $existingRules -Identity $path.Name -Changed (New-Change -RuleObject $desiredRule.AccessRule)
                }
                
                if ($effectiveMode -eq 'Additive') { continue }
                
                foreach ($existingRule in $existingRules)
                {
                    if ($effectiveMode -eq 'Defined' -and "$($existingRule.IdentityReference.ToString())" -notin ($effectiveDesiredState.AccessRule.IdentityReference | ForEach-Object ToString)) { continue }
                    if (Test-AccessRule -RuleObject $existingRule -Reference $effectiveDesiredState.AccessRule) { continue }
                    New-TestResult @results -Type Remove -Configuration $effectiveDesiredState -ADObject $existingRule -Identity $path.Name -Changed (New-Change -RuleObject $existingRule)
                }
                #endregion Compare desired state with existing state
            }
            
            Remove-PSSession -Session $psSession -ErrorAction Ignore -Confirm:$false
        }
    }
}