From e7ee203ea651a4c49e473bc9aaea1213b7354193 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 07:50:18 +0000 Subject: [PATCH] test: cover permission scopes and folder inheritance transitions Exercise all 13 scopes using named scopes and explicit Windows flags, through disk paths and in-memory descriptors, for access and audit entries. Verify removal keeps another account, and check actual inheritance through children and grandchildren, including OneLevel. Cover keep/remove switches and successful PassThru for both files and folders, including Set-NTFSInheritance enabling protection states. Controlled mutations lose OneLevel and keep folders protected: 12 tests fail as expected. Restored implementations pass the 170-test focused suite in all four configurations where privileges permit. No cmdlet contract changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Inheritance.Tests.ps1 | 126 +++++++++++++++++++++++ Tests/PermissionScopes.Tests.ps1 | 165 +++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 Tests/PermissionScopes.Tests.ps1 diff --git a/Tests/Inheritance.Tests.ps1 b/Tests/Inheritance.Tests.ps1 index 50d3fce..a4e41fe 100644 --- a/Tests/Inheritance.Tests.ps1 +++ b/Tests/Inheritance.Tests.ps1 @@ -12,6 +12,13 @@ BeforeDiscovery { $canChangeAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' # Assigning an owner other than the user or one of its groups needs the Restore privilege. $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' + $inheritanceCases = @(foreach ($type in 'file', 'folder') { + foreach ($enable in $false, $true) { + foreach ($remove in $false, $true) { + @{ Type = $type; Enable = $enable; Remove = $remove } + } + } + }) } BeforeAll { @@ -276,6 +283,125 @@ Describe 'Audit inheritance switches' { } } +Describe 'Access inheritance transitions on files and folders' { + It 'Should set enabled= on a , remove requested entries=, and report the written state' -ForEach $inheritanceCases { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'AccessParent' -Directory + $path = Join-Path -Path $parent -ChildPath 'Child' + $parentAccount = 'S-1-5-21-1-2-3-4901' + $childAccount = 'S-1-5-21-1-2-3-4902' + Add-NTFSAccess -Path $parent -Account $parentAccount -AccessRights ReadData -ErrorAction Stop + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + if ($Type -eq 'folder') { New-Item -ItemType Directory -Path $path | Out-Null } else { Set-Content -LiteralPath $path -Value 'Child' } + Add-NTFSAccess -Path $path -Account $childAccount -AccessRights Delete -AppliesTo ThisFolderOnly -ErrorAction Stop + @(Get-NTFSAccess -Path $path -Account $parentAccount -ExcludeExplicit -ErrorAction Stop) | Should -HaveCount 1 + $owner = (Get-NTFSOwner -Path $path -ErrorAction Stop).Owner.Sid + if ($Enable) { + Disable-NTFSAccessInheritance -Path $path -RemoveInheritedAccessRules -ErrorAction Stop + $parameters = @{ RemoveExplicitAccessRules = $Remove } + $command = 'Enable-NTFSAccessInheritance' + } + else { + $parameters = @{ RemoveInheritedAccessRules = $Remove } + $command = 'Disable-NTFSAccessInheritance' + } + + $result = @(& $command -Path $path @parameters -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Security2.FileSystemInheritanceInfo] + $result[0].FullName | Should -Be $path + $result[0].AccessInheritanceEnabled | Should -Be $Enable + (Get-NTFSInheritance -Path $path).AccessInheritanceEnabled | Should -Be $Enable + (Get-NTFSOwner -Path $path).Owner.Sid | Should -Be $owner + $parentRules = @(Get-NTFSAccess -Path $path -Account $parentAccount) + if ($Enable) { + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -BeTrue + } + elseif ($Remove) { + $parentRules | Should -BeNullOrEmpty + } + else { + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -BeFalse + } + $childRules = @(Get-NTFSAccess -Path $path -Account $childAccount) + if ($Enable -and $Remove) { $childRules | Should -BeNullOrEmpty } else { $childRules | Should -HaveCount 1 } + } + + It 'Set-NTFSInheritance should re-enable access inheritance on a <_> and keep its explicit entry' -ForEach @('file', 'folder') { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AccessEnable' -Directory:($_ -eq 'folder') + Add-NTFSAccess -Path $path -Account 'S-1-5-21-1-2-3-4902' -AccessRights Delete -AppliesTo ThisFolderOnly -ErrorAction Stop + Disable-NTFSAccessInheritance -Path $path -RemoveInheritedAccessRules -ErrorAction Stop + + $result = @(Set-NTFSInheritance -Path $path -AccessInheritanceEnabled $true -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AccessInheritanceEnabled | Should -BeTrue + @(Get-NTFSAccess -Path $path -ExcludeExplicit) | Should -Not -BeNullOrEmpty + @(Get-NTFSAccess -Path $path -Account 'S-1-5-21-1-2-3-4902' -ExcludeInherited) | Should -HaveCount 1 + } +} + +Describe 'Audit inheritance transitions on files and folders' -Skip:(-not $canChangeAudit) { + It 'Should set enabled= on a , remove requested audit entries=, and leave the DACL unchanged' -ForEach $inheritanceCases { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditParent' -Directory + $path = Join-Path -Path $parent -ChildPath 'Child' + $parentAccount = 'S-1-5-21-1-2-3-4911' + $childAccount = 'S-1-5-21-1-2-3-4912' + Add-NTFSAudit -Path $parent -Account $parentAccount -AccessRights ReadData -AuditFlags Success -ErrorAction Stop + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + if ($Type -eq 'folder') { New-Item -ItemType Directory -Path $path | Out-Null } else { Set-Content -LiteralPath $path -Value 'Child' } + Add-NTFSAudit -Path $path -Account $childAccount -AccessRights Delete -AuditFlags Failure -AppliesTo ThisFolderOnly -ErrorAction Stop + @(Get-NTFSAudit -Path $path -Account $parentAccount -ExcludeExplicit -ErrorAction Stop) | Should -HaveCount 1 + $before = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') + if ($Enable) { + Disable-NTFSAuditInheritance -Path $path -RemoveInheritedAuditRules -ErrorAction Stop + $parameters = @{ RemoveExplicitAuditRules = $Remove } + $command = 'Enable-NTFSAuditInheritance' + } + else { + $parameters = @{ RemoveInheritedAuditRules = $Remove } + $command = 'Disable-NTFSAuditInheritance' + } + + $result = @(& $command -Path $path @parameters -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $path + $result[0].AuditInheritanceEnabled | Should -Be $Enable + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -Be $Enable + (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before + $parentRules = @(Get-NTFSAudit -Path $path -Account $parentAccount) + if ($Enable) { + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -BeTrue + } + elseif ($Remove) { + $parentRules | Should -BeNullOrEmpty + } + else { + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -BeFalse + } + $childRules = @(Get-NTFSAudit -Path $path -Account $childAccount) + if ($Enable -and $Remove) { $childRules | Should -BeNullOrEmpty } else { $childRules | Should -HaveCount 1 } + } + + It 'Set-NTFSInheritance should re-enable audit inheritance on a <_> and keep its explicit audit entry' -ForEach @('file', 'folder') { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditEnable' -Directory:($_ -eq 'folder') + Add-NTFSAudit -Path $path -Account 'S-1-5-21-1-2-3-4912' -AccessRights Delete -AuditFlags Failure -AppliesTo ThisFolderOnly -ErrorAction Stop + Disable-NTFSAuditInheritance -Path $path -RemoveInheritedAuditRules -ErrorAction Stop + $before = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') + + $result = @(Set-NTFSInheritance -Path $path -AuditInheritanceEnabled $true -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -BeTrue + @(Get-NTFSAudit -Path $path -Account 'S-1-5-21-1-2-3-4912' -ExcludeInherited) | Should -HaveCount 1 + (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before + } +} Describe 'Access inheritance cmdlets' { Context 'When the item has an owner that the user cannot assign' { BeforeAll { diff --git a/Tests/PermissionScopes.Tests.ps1 b/Tests/PermissionScopes.Tests.ps1 new file mode 100644 index 0000000..16ff8d1 --- /dev/null +++ b/Tests/PermissionScopes.Tests.ps1 @@ -0,0 +1,165 @@ +<# + Tests all permission scopes through the access and audit cmdlets, both parameter forms and both storage modes. + Expected flags are the Windows ACE flags, independent of the module's scope converter. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $canReadAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + $scopes = @( + @{ Name = 'ThisFolderOnly'; Inheritance = 'None'; Propagation = 'None' } + @{ Name = 'ThisFolderSubfoldersAndFiles'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'None' } + @{ Name = 'ThisFolderAndSubfolders'; Inheritance = 'ContainerInherit'; Propagation = 'None' } + @{ Name = 'ThisFolderAndFiles'; Inheritance = 'ObjectInherit'; Propagation = 'None' } + @{ Name = 'SubfoldersAndFilesOnly'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'InheritOnly' } + @{ Name = 'SubfoldersOnly'; Inheritance = 'ContainerInherit'; Propagation = 'InheritOnly' } + @{ Name = 'FilesOnly'; Inheritance = 'ObjectInherit'; Propagation = 'InheritOnly' } + @{ Name = 'ThisFolderSubfoldersAndFilesOneLevel'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'NoPropagateInherit' } + @{ Name = 'ThisFolderAndSubfoldersOneLevel'; Inheritance = 'ContainerInherit'; Propagation = 'NoPropagateInherit' } + @{ Name = 'ThisFolderAndFilesOneLevel'; Inheritance = 'ObjectInherit'; Propagation = 'NoPropagateInherit' } + @{ Name = 'SubfoldersAndFilesOnlyOneLevel'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'InheritOnly, NoPropagateInherit' } + @{ Name = 'SubfoldersOnlyOneLevel'; Inheritance = 'ContainerInherit'; Propagation = 'InheritOnly, NoPropagateInherit' } + @{ Name = 'FilesOnlyOneLevel'; Inheritance = 'ObjectInherit'; Propagation = 'InheritOnly, NoPropagateInherit' } + ) + $activeTargets = @{ + ThisFolderOnly = @('Root') + ThisFolderSubfoldersAndFiles = @('Root', 'File', 'Child', 'ChildFile', 'Grandchild', 'GrandchildFile') + ThisFolderAndSubfolders = @('Root', 'Child', 'Grandchild') + ThisFolderAndFiles = @('Root', 'File', 'ChildFile', 'GrandchildFile') + SubfoldersAndFilesOnly = @('File', 'Child', 'ChildFile', 'Grandchild', 'GrandchildFile') + SubfoldersOnly = @('Child', 'Grandchild') + FilesOnly = @('File', 'ChildFile', 'GrandchildFile') + ThisFolderSubfoldersAndFilesOneLevel = @('Root', 'File', 'Child') + ThisFolderAndSubfoldersOneLevel = @('Root', 'Child') + ThisFolderAndFilesOneLevel = @('Root', 'File') + SubfoldersAndFilesOnlyOneLevel = @('File', 'Child') + SubfoldersOnlyOneLevel = @('Child') + FilesOnlyOneLevel = @('File') + } + $propagationCases = @($scopes | ForEach-Object { @{ Name = $_.Name; ActiveTargets = $activeTargets[$_.Name] } }) + $scopeNames = @($scopes.Name) + $scopeCases = @(foreach ($scope in $scopes) { + foreach ($source in 'Path', 'SecurityDescriptor') { + foreach ($form in 'AppliesTo', 'Flags') { + @{ Name = $scope.Name; Inheritance = $scope.Inheritance; Propagation = $scope.Propagation; Source = $source; Form = $form } + } + } + }) +} + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1') -Force -ErrorAction Stop + $sandbox = New-TestSandbox -Name 'PermissionScopes' + Push-Location -LiteralPath $sandbox + $account = 'S-1-5-21-1-2-3-4801' + $keeper = 'S-1-5-21-1-2-3-4802' +} + +AfterAll { + Pop-Location + Remove-TestSandbox -Sandbox $sandbox + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'Permission scope inventory' { + It 'Should cover every named -AppliesTo value' -ForEach @(@{ ScopeNames = $scopeNames }) { + (@([Enum]::GetNames([Security2.ApplyTo])) | Sort-Object) -join ',' | + Should -Be (($scopeNames | Sort-Object) -join ',') + } +} + +Describe 'Access rule scopes' { + It 'Should add and remove using
on , preserving the other account' -ForEach $scopeCases { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AccessScope' -Directory + $before = (Get-Acl -LiteralPath $folder).GetSecurityDescriptorSddlForm('Access') + $location = if ($Source -eq 'Path') { @{ Path = $folder } } else { @{ SecurityDescriptor = Get-NTFSSecurityDescriptor -Path $folder -ErrorAction Stop } } + $flags = @{ InheritanceFlags = $Inheritance; PropagationFlags = $Propagation } + $addScope = if ($Form -eq 'AppliesTo') { @{ AppliesTo = $Name } } else { $flags } + $removeScope = if ($Form -eq 'AppliesTo') { $flags } else { @{ AppliesTo = $Name } } + Add-NTFSAccess @location -Account $keeper -AccessRights Delete -AppliesTo ThisFolderOnly -ErrorAction Stop + + $added = @(Add-NTFSAccess @location @addScope -Account $account -AccessRights ReadData -PassThru -ErrorAction Stop | + Where-Object -FilterScript { $_.Account.Sid -eq $account }) + + $added | Should -HaveCount 1 + $added[0] | Should -BeOfType [Security2.FileSystemAccessRule2] + $added[0].InheritanceFlags | Should -Be ([Security.AccessControl.InheritanceFlags] $Inheritance) + $added[0].PropagationFlags | Should -Be ([Security.AccessControl.PropagationFlags] $Propagation) + $added[0].AccessRights.HasFlag([Security2.FileSystemRights2]::ReadData) | Should -BeTrue + [Security2.FileSystemSecurity2]::ConvertToApplyTo($added[0].InheritanceFlags, $added[0].PropagationFlags).ToString() | Should -Be $Name + if ($Source -eq 'SecurityDescriptor') { + (Get-Acl -LiteralPath $folder).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before + } + + $remaining = @(Remove-NTFSAccess @location @removeScope -Account $account -AccessRights ReadData -RemoveSpecific -PassThru -ErrorAction Stop) + + @($remaining | Where-Object -FilterScript { $_.Account.Sid -eq $account }) | Should -BeNullOrEmpty + @($remaining | Where-Object -FilterScript { $_.Account.Sid -eq $keeper }) | Should -HaveCount 1 + @(Get-NTFSAccess @location -Account $account -ErrorAction Stop) | Should -BeNullOrEmpty + } +} + +Describe 'Access scopes on descendants' { + It 'Should apply only to its intended descendants, including the OneLevel boundary' -ForEach $propagationCases { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Propagation' -Directory + Add-NTFSAccess -Path $folder -Account $account -AccessRights ReadData -AppliesTo $Name -ErrorAction Stop + $paths = [ordered]@{ + Root = $folder + File = Join-Path -Path $folder -ChildPath 'File.txt' + Child = Join-Path -Path $folder -ChildPath 'Child' + ChildFile = Join-Path -Path $folder -ChildPath 'Child\File.txt' + Grandchild = Join-Path -Path $folder -ChildPath 'Child\Grandchild' + GrandchildFile = Join-Path -Path $folder -ChildPath 'Child\Grandchild\File.txt' + } + Assert-TestSandboxPath -Sandbox $sandbox -Path @($paths.Values) + New-Item -ItemType Directory -Path $paths.Grandchild -Force | Out-Null + foreach ($key in 'File', 'ChildFile', 'GrandchildFile') { + Set-Content -LiteralPath $paths[$key] -Value $key + } + + $actual = @(foreach ($key in $paths.Keys) { + $active = @(Get-NTFSAccess -Path $paths[$key] -Account $account -ErrorAction Stop | Where-Object -FilterScript { + -not $_.PropagationFlags.HasFlag([Security.AccessControl.PropagationFlags]::InheritOnly) -and + $_.AccessRights.HasFlag([Security2.FileSystemRights2]::ReadData) + }) + if ($active.Count -gt 0) { $key } + }) + + ($actual | Sort-Object) -join ',' | Should -Be (($ActiveTargets | Sort-Object) -join ',') + } +} +Describe 'Audit rule scopes' -Skip:(-not $canReadAudit) { + It 'Should add and remove using on , preserving the other account' -ForEach $scopeCases { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditScope' -Directory + $before = (Get-Acl -LiteralPath $folder -Audit).GetSecurityDescriptorSddlForm('Audit') + $location = if ($Source -eq 'Path') { @{ Path = $folder } } else { @{ SecurityDescriptor = Get-NTFSSecurityDescriptor -Path $folder -ErrorAction Stop } } + $flags = @{ InheritanceFlags = $Inheritance; PropagationFlags = $Propagation } + $addScope = if ($Form -eq 'AppliesTo') { @{ AppliesTo = $Name } } else { $flags } + $removeScope = if ($Form -eq 'AppliesTo') { $flags } else { @{ AppliesTo = $Name } } + Add-NTFSAudit @location -Account $keeper -AccessRights Delete -AuditFlags Failure -AppliesTo ThisFolderOnly -ErrorAction Stop + + $added = @(Add-NTFSAudit @location @addScope -Account $account -AccessRights ReadData -AuditFlags 'Success, Failure' -PassThru -ErrorAction Stop | + Where-Object -FilterScript { $_.Account.Sid -eq $account }) + + $added | Should -HaveCount 1 + $added[0] | Should -BeOfType [Security2.FileSystemAuditRule2] + $added[0].InheritanceFlags | Should -Be ([Security.AccessControl.InheritanceFlags] $Inheritance) + $added[0].PropagationFlags | Should -Be ([Security.AccessControl.PropagationFlags] $Propagation) + $added[0].AuditFlags | Should -Be ([Security.AccessControl.AuditFlags] 'Success, Failure') + [Security2.FileSystemSecurity2]::ConvertToApplyTo($added[0].InheritanceFlags, $added[0].PropagationFlags).ToString() | Should -Be $Name + if ($Source -eq 'SecurityDescriptor') { + (Get-Acl -LiteralPath $folder -Audit).GetSecurityDescriptorSddlForm('Audit') | Should -BeExactly $before + } + + $remaining = @(Remove-NTFSAudit @location @removeScope -Account $account -AccessRights ReadData -AuditFlags 'Success, Failure' -RemoveSpecific -PassThru -ErrorAction Stop) + + @($remaining | Where-Object -FilterScript { $_.Account.Sid -eq $account }) | Should -BeNullOrEmpty + @($remaining | Where-Object -FilterScript { $_.Account.Sid -eq $keeper }) | Should -HaveCount 1 + @(Get-NTFSAudit @location -Account $account -ErrorAction Stop) | Should -BeNullOrEmpty + } +}