From 472003e49e95bea96778eb39cd3df3eab98e588e Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Tue, 20 Oct 2026 13:26:04 +0000 Subject: [PATCH] test(access): close budget parameter-set gaps for the access cmdlets Add 20 new It blocks (32 executed cases once the 13-scope -ForEach of Add-NTFSAccess/SDSimple expands) to ParameterSets.Access.Tests.ps1, closing every REMAINING item that worklist-access.md marks budget for rows 1-13: - Add-NTFSAccess: several accounts and positional binding (PathComplex); a Deny entry's exact rights with no auto-added Synchronize (PathComplex and PathSimple); a denied write and a Deny entry without rights through PathSimple; all 13 -AppliesTo scopes of a security descriptor read back with .NET, piped and several (SDSimple); piped and several descriptors asserted for Add itself, not only as a fixture (SDComplex). - Remove-NTFSAccess: the -PassThru output's count against the complete survivor list for several piped paths; several accounts and positional binding (PathComplex); a Deny entry's rights-reduction without -RemoveSpecific; a missing and a denied path, piped, several (PathSimple); rights-reduction without -RemoveSpecific and disk independence until Set-NTFSSecurityDescriptor, piped and several descriptors (SDSimple); the on-disk write-through only after Set-NTFSSecurityDescriptor, piped and several descriptors (SDComplex). - Get-NTFSAccess: what the cmdlet returns without -Path, asserted on an actual emitted object's type (Path). - Get-NTFSOrphanedAccess: the category of ReadSecurityError after a failed ownership retry (Path); -ExcludeInherited and an entry added to a descriptor only in memory (SD). - Get-NTFSSimpleAccess: the category and target of a missing path's ReadError (Path). - Get-NTFSEffectiveAccess: one call combining a failing and a succeeding path (Path). Items the REASON marks MaintainerDecision, Limit, or would-pin are left untouched, per the group notes of the brief. No product code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ParameterSets.Access.Tests.ps1 | 452 +++++++++++++++++++++++++++ 1 file changed, 452 insertions(+) diff --git a/Tests/ParameterSets.Access.Tests.ps1 b/Tests/ParameterSets.Access.Tests.ps1 index 23f2e17..551cc3b 100644 --- a/Tests/ParameterSets.Access.Tests.ps1 +++ b/Tests/ParameterSets.Access.Tests.ps1 @@ -25,6 +25,24 @@ BeforeDiscovery { @{ Name = 'Get-NTFSEffectiveAccess'; Types = @('Security2.FileSystemAccessRule2') } @{ Name = 'Get-NTFSSimpleAccess'; Types = @('Security2.SimpleFileSystemAccessRule') } ) + + # The 13 named -AppliesTo scopes, each as the ACE flags that PermissionScopes.Tests.ps1 also uses, so that an + # entry a security descriptor set added can be read back with .NET independently of the cmdlet. + $sdScopeCases = @( + @{ 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' } + ) } BeforeAll { @@ -265,6 +283,135 @@ Describe 'Add-NTFSAccess' { $guestsRule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeTrue } } + + Context 'PathComplex parameter set, additional state assertions' { + It 'Should add an explicit allow entry for each of several accounts bound positionally, with the default scope' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AddPositionalSeveral' -Directory + + Add-NTFSAccess $folder @('S-1-1-0', 'S-1-5-32-546') 'ReadData' -ErrorAction Stop + + foreach ($account in 'S-1-1-0', 'S-1-5-32-546') { + $rule = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq $account }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Allow) + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeTrue + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags] 'ContainerInherit, ObjectInherit') + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None) + } + } + + # Only an Allow entry gets Synchronize added (the page); a Deny entry written through -Path must carry + # exactly the requested rights. + It 'Should write a Deny entry through -Path with exactly the requested rights and no Synchronize' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddDenySynchronize' + + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -AccessType Deny -ErrorAction Stop + + $rule = @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Deny) + $rule[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) + } + } + + Context 'PathSimple parameter set, Deny entries and a denied write' { + BeforeAll { + # So that an elevated session meets the same denial as a basic user (see PathErrors.Tests.ps1). + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + It 'Should report AddAceError by category and target for a denied write scoped by -AppliesTo, and leave the item unchanged' { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSimpleBlocked' + Block-TestWritePermission -Sandbox $sandbox -Path $blocked + $before = (Get-Acl -LiteralPath $blocked).Sddl + + Add-NTFSAccess -Path $blocked -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly -ErrorVariable addErrors -ErrorAction SilentlyContinue + + $addErrors | Should -HaveCount 1 + $addErrors[0].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*' + $addErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + $addErrors[0].TargetObject | Should -Be $blocked + (Get-Acl -LiteralPath $blocked).Sddl | Should -BeExactly $before + } + + # .NET refuses to build a deny entry without rights for this parameter set too; the write-denial code path + # above is shared with PathComplex (Access.Tests.ps1:740), so only the categorization needs its own case here. + It 'Should write an AddAceError for a Deny entry without rights through the PathSimple set, and change nothing' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSimpleDenyNone' + $before = (Get-Acl -LiteralPath $file).Sddl + + $result = @(Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights None -AccessType Deny -AppliesTo ThisFolderOnly -PassThru -ErrorVariable addErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $addErrors | Should -HaveCount 1 + $addErrors[0].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*' + $addErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + (Get-Acl -LiteralPath $file).Sddl | Should -BeExactly $before + } + + It 'Should write a Deny entry scoped by -AppliesTo with exactly the requested rights and no Synchronize' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSimpleDenyScoped' -Directory + + Add-NTFSAccess -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -AccessType Deny -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + + $rule = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Deny) + $rule[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit) + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None) + } + } + + Context 'SDSimple parameter set' { + It 'Should add an entry with the scope to each of several descriptors bound through the pipeline, read back with .NET, and leave the items unchanged on disk' -ForEach $sdScopeCases { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSdScopeFirst' -Directory + $second = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSdScopeSecond' -Directory + $before = foreach ($folder in $first, $second) { (Get-Acl -LiteralPath $folder).GetSecurityDescriptorSddlForm('Access') } + $descriptors = @(Get-NTFSSecurityDescriptor -Path $first, $second -ErrorAction Stop) + + $descriptors | Add-NTFSAccess -Account 'S-1-5-32-546' -AccessRights ReadData -AppliesTo $Name -ErrorAction Stop + + for ($index = 0; $index -lt 2; $index++) { + $rule = @($descriptors[$index].SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-32-546' }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Allow) + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags] $Inheritance) + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags] $Propagation) + } + (Get-Acl -LiteralPath $first).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before[0] + (Get-Acl -LiteralPath $second).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before[1] + } + } + + Context 'SDComplex parameter set with several descriptors' { + It 'Should add the entry to each of several descriptors bound through the pipeline, read back with .NET, and leave the items unchanged on disk' { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSdComplexPipeFirst' + $second = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSdComplexPipeSecond' + $before = foreach ($file in $first, $second) { (Get-Acl -LiteralPath $file).Sddl } + $descriptors = @(Get-NTFSSecurityDescriptor -Path $first, $second -ErrorAction Stop) + + $descriptors | Add-NTFSAccess -Account 'S-1-5-21-1-2-3-9501' -AccessRights ReadData, WriteData -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + + for ($index = 0; $index -lt 2; $index++) { + $rule = @($descriptors[$index].SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9501' }) + $rule | Should -HaveCount 1 + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeTrue + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::WriteData) | Should -BeTrue + } + (Get-Acl -LiteralPath $first).Sddl | Should -BeExactly $before[0] + (Get-Acl -LiteralPath $second).Sddl | Should -BeExactly $before[1] + } + } } Describe 'Remove-NTFSAccess' { @@ -387,6 +534,202 @@ Describe 'Remove-NTFSAccess' { $guestsRule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::Delete) | Should -BeTrue } } + + Context 'PathComplex parameter set, additional state assertions' { + It 'Should return exactly the surviving entries with -PassThru for each of several paths bound through the pipeline in one call' { + $keeper = 'S-1-1-0' + $target = 'S-1-5-32-546' + $folders = @( + New-TestSandboxItem -Sandbox $sandbox -Name 'RemovePassThruCountFirst' -Directory + New-TestSandboxItem -Sandbox $sandbox -Name 'RemovePassThruCountSecond' -Directory + ) + foreach ($folder in $folders) { + Add-NTFSAccess -Path $folder -Account $keeper -AccessRights ReadData -ErrorAction Stop + Add-NTFSAccess -Path $folder -Account $target -AccessRights ReadData -ErrorAction Stop + } + # The independent oracle: every rule of the item that isn't the one being removed. + $expected = foreach ($folder in $folders) { + @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $true, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -ne $target }).Count + } + + $result = @(Get-Item2 -Path $folders | Remove-NTFSAccess -Account $target -AccessRights ReadData -PassThru -ErrorAction Stop) + + for ($index = 0; $index -lt $folders.Count; $index++) { + $folderResult = @($result | Where-Object -FilterScript { $_.FullName -eq $folders[$index] }) + $folderResult | Should -HaveCount $expected[$index] + $folderResult | ForEach-Object -Process { $_ | Should -BeOfType ([Security2.FileSystemAccessRule2]) } + @($folderResult | Where-Object -FilterScript { $_.Account.Sid -eq $target }) | Should -BeNullOrEmpty + @($folderResult | Where-Object -FilterScript { $_.Account.Sid -eq $keeper }) | Should -HaveCount 1 + } + } + + It 'Should remove the entries of several accounts bound positionally, leaving another account untouched' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemovePositionalSeveral' -Directory + Add-NTFSAccess -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + Add-NTFSAccess -Path $folder -Account 'S-1-5-32-546' -AccessRights ReadData -ErrorAction Stop + Add-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9701' -AccessRights ReadData -ErrorAction Stop + + Remove-NTFSAccess $folder @('S-1-1-0', 'S-1-5-32-546') 'ReadData' -ErrorAction Stop + + $rules = (Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) + @($rules | Where-Object -FilterScript { $_.IdentityReference.Value -in 'S-1-1-0', 'S-1-5-32-546' }) | Should -BeNullOrEmpty + @($rules | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9701' }) | Should -HaveCount 1 + } + } + + Context 'PathSimple parameter set, a Deny entry and pipeline errors' { + BeforeAll { + # So that an elevated session meets the same denial as a basic user (see PathErrors.Tests.ps1). Harmless + # for the Deny-reduction test below, which never hits a permission denial. + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + It 'Should take only the requested rights from a Deny entry scoped by -AppliesTo without -RemoveSpecific, and keep its flags' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSimpleDenyReduce' -Directory + Add-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9702' -AccessRights Modify -AccessType Deny -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + + Remove-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9702' -AccessRights ReadData -AccessType Deny -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + + $rule = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9702' }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Deny) + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::WriteData) | Should -BeTrue + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit) + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None) + } + + It 'Should report a missing path''s and a denied path''s errors by category and target, and still scope the surviving paths'' entries, all bound through the pipeline' { + $missing = Join-Path -Path $sandbox -ChildPath ('RemoveSimpleMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSimpleBlocked' -Directory + $survivor1 = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSimpleSurvivor1' -Directory + $survivor2 = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSimpleSurvivor2' -Directory + foreach ($folder in $blocked, $survivor1, $survivor2) { + Add-NTFSAccess -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly -ErrorAction Stop + } + Block-TestWritePermission -Sandbox $sandbox -Path $blocked + $before = (Get-Acl -LiteralPath $blocked).Sddl + # Property-name binding of -Path, like the piped objects of the Add-NTFSAccess pipeline tests; -Account, + # -AccessRights and -AppliesTo come from the call itself and apply to every piped object alike. + $paths = foreach ($value in $missing, $blocked, $survivor1, $survivor2) { [pscustomobject]@{ Path = $value } } + + $result = @($paths | Remove-NTFSAccess -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue) + + $removeErrors | Should -HaveCount 2 + $missingError = @($removeErrors | Where-Object -FilterScript { $_.TargetObject -eq $missing }) + $missingError | Should -HaveCount 1 + $missingError[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $missingError[0].CategoryInfo.Category | Should -Be 'OpenError' + $blockedError = @($removeErrors | Where-Object -FilterScript { $_.TargetObject -eq $blocked }) + $blockedError | Should -HaveCount 1 + $blockedError[0].FullyQualifiedErrorId | Should -BeLike 'RemoveAceError,*' + $blockedError[0].CategoryInfo.Category | Should -Be 'WriteError' + (Get-Acl -LiteralPath $blocked).Sddl | Should -BeExactly $before + foreach ($survivor in $survivor1, $survivor2) { + @((Get-Acl -LiteralPath $survivor).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -BeNullOrEmpty + @($result | Where-Object -FilterScript { $_.FullName -eq $survivor }) | Should -Not -BeNullOrEmpty + } + } + } + + Context 'SDSimple parameter set' { + It 'Should take only the requested rights from an entry scoped by -AppliesTo on a descriptor without -RemoveSpecific, keep its flags, and leave the item on disk unchanged until Set-NTFSSecurityDescriptor' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdSimpleReduce' -Directory + Add-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9602' -AccessRights Modify -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + $before = (Get-Acl -LiteralPath $folder).Sddl + $sd = Get-NTFSSecurityDescriptor -Path $folder -ErrorAction Stop + + Remove-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-9602' -AccessRights ReadData -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + + $rule = @($sd.SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9602' }) + $rule | Should -HaveCount 1 + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::WriteData) | Should -BeTrue + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit) + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None) + (Get-Acl -LiteralPath $folder).Sddl | Should -BeExactly $before + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + $onDisk = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9602' }) + $onDisk | Should -HaveCount 1 + $onDisk[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse + } + + It 'Should remove the matching entry from each of several descriptors bound through the pipeline for the Simple parameter set, leaving the items unchanged on disk' { + $folders = @( + New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdSimplePipeFirst' -Directory + New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdSimplePipeSecond' -Directory + ) + foreach ($folder in $folders) { + Add-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9603' -AccessRights ReadData -AppliesTo ThisFolderOnly -ErrorAction Stop + } + $descriptors = @(Get-NTFSSecurityDescriptor -Path $folders -ErrorAction Stop) + + $descriptors | Remove-NTFSAccess -Account 'S-1-5-21-1-2-3-9603' -AccessRights ReadData -AppliesTo ThisFolderOnly -RemoveSpecific -ErrorAction Stop + + foreach ($sd in $descriptors) { + @($sd.SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9603' }) | Should -BeNullOrEmpty + } + foreach ($folder in $folders) { + @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9603' }) | Should -HaveCount 1 + } + } + } + + Context 'SDComplex parameter set' { + It 'Should remove an on-disk entry from a descriptor in memory only, and write the change through only when Set-NTFSSecurityDescriptor runs' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdWriteThrough' + Add-NTFSAccess -Path $file -Account 'S-1-5-21-1-2-3-9601' -AccessRights ReadData -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + + Remove-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-9601' -AccessRights ReadData -ErrorAction Stop + + @($sd.SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9601' }) | Should -BeNullOrEmpty + @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9601' }) | Should -HaveCount 1 + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9601' }) | Should -BeNullOrEmpty + } + + It 'Should remove the matching entry from each of several descriptors bound through the pipeline for the Complex parameter set, leaving the items unchanged on disk' { + $files = @( + New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdComplexPipeFirst' + New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdComplexPipeSecond' + ) + foreach ($file in $files) { + Add-NTFSAccess -Path $file -Account 'S-1-5-21-1-2-3-9604' -AccessRights ReadData -ErrorAction Stop + } + $descriptors = @(Get-NTFSSecurityDescriptor -Path $files -ErrorAction Stop) + + $descriptors | Remove-NTFSAccess -Account 'S-1-5-21-1-2-3-9604' -AccessRights ReadData -ErrorAction Stop + + foreach ($sd in $descriptors) { + @($sd.SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9604' }) | Should -BeNullOrEmpty + } + foreach ($file in $files) { + @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9604' }) | Should -HaveCount 1 + } + } + } } Describe 'Clear-NTFSAccess' { @@ -518,6 +861,29 @@ Describe 'Get-NTFSAccess' { } } + Context 'Path parameter set without -Path' { + It 'Should return the entries of the current location as actual Security2.FileSystemAccessRule2 objects when -Path is omitted' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAccessNoPath' -Directory + Add-NTFSAccess -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + $expected = @(Get-NTFSAccess -Path $folder -ErrorAction Stop) + + Push-Location -LiteralPath $folder + try { + $result = @(Get-NTFSAccess -ErrorAction Stop) + } + finally { + Pop-Location + } + + $result | Should -HaveCount $expected.Count + $result | ForEach-Object -Process { + $_ | Should -BeOfType ([Security2.FileSystemAccessRule2]) + $_.FullName | Should -Be $folder + } + @($result | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' -and -not $_.IsInherited }) | Should -HaveCount 1 + } + } + Context 'SD parameter set' { It 'Should filter an entry added to a descriptor in memory by -Account, partition it with -ExcludeInherited and -ExcludeExplicit, and read two descriptors bound through the pipeline' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAccessSdFilter' @@ -595,6 +961,36 @@ Describe 'Get-NTFSOrphanedAccess' { } } + Context 'Path parameter set, unreadable folder' { + BeforeAll { + # Forcing EnablePrivileges off reproduces the same denial as a basic user for an elevated session, as + # PathErrors.Tests.ps1 does for the same scenario. + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Unlike Get-NTFSAccess, ReadSecurityError of this cmdlet has one consistent category after a failed + # ownership retry: ReadError (GetOrphanedAccess.cs), so it is safe to pin. + It 'Should write a ReadSecurityError categorized as ReadError after a failed ownership retry, and still return the next path''s orphaned entry' { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedUnreadable' + Block-TestReadPermission -Sandbox $sandbox -Path $blocked + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedUnreadableNext' + Add-NTFSAccess -Path $next -Account 'S-1-5-21-1-2-3-9301' -AccessRights ReadData -ErrorAction Stop + + $result = @(Get-NTFSOrphanedAccess -Path $blocked, $next -ErrorVariable orphanErrors -ErrorAction SilentlyContinue) + + $orphanErrors | Should -HaveCount 1 + $orphanErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $orphanErrors[0].CategoryInfo.Category | Should -Be 'ReadError' + $orphanErrors[0].TargetObject | Should -Be $blocked + $result | Should -HaveCount 1 + "$($result[0].Account.Sid)" | Should -Be 'S-1-5-21-1-2-3-9301' + } + } + Context 'SD parameter set' { It 'Should filter a descriptor''s orphaned entries by -Account, report both orphaned Sids by default, exclude them all with -ExcludeExplicit, and read two descriptors bound through the pipeline' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedSdFile' @@ -620,6 +1016,28 @@ Describe 'Get-NTFSOrphanedAccess' { @($piped | Where-Object -FilterScript { $_.FullName -eq $file2 }) | Should -HaveCount 1 } } + + Context 'SD parameter set, -ExcludeInherited and an in-memory change' { + It 'Should return the explicit entry with -ExcludeInherited and still report an entry added to the descriptor only in memory' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedSdMemory' + Add-NTFSAccess -Path $file -Account 'S-1-5-21-1-2-3-9401' -AccessRights ReadData -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + + $explicitOnly = @(Get-NTFSOrphanedAccess -SecurityDescriptor $sd -ExcludeInherited -ErrorAction Stop) + + $explicitOnly | Should -HaveCount 1 + "$($explicitOnly[0].Account.Sid)" | Should -Be 'S-1-5-21-1-2-3-9401' + + Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-9402' -AccessRights ReadData -ErrorAction Stop + + $afterMemoryChange = @(Get-NTFSOrphanedAccess -SecurityDescriptor $sd -ErrorAction Stop) + + ($afterMemoryChange | ForEach-Object -Process { "$($_.Account.Sid)" } | Sort-Object) -join ',' | + Should -Be (('S-1-5-21-1-2-3-9401', 'S-1-5-21-1-2-3-9402' | Sort-Object) -join ',') + # The disk copy stays as it was: only the descriptor in memory saw the second account. + @(Get-NTFSOrphanedAccess -Path $file -ErrorAction Stop) | Should -HaveCount 1 + } + } } Describe 'Get-NTFSSimpleAccess' { @@ -653,6 +1071,25 @@ Describe 'Get-NTFSSimpleAccess' { } } + Context 'Path parameter set, missing path' { + # Unlike the unreadable-folder case above, a missing path needs no retry and no privilege change: this cmdlet + # does not retry as the owner, so the ReadError of a missing path was pinned by ErrorId only until now. + It 'Should report a missing path''s error by category and target and still report the next folder''s entries' { + $missing = Join-Path -Path $sandbox -ChildPath ('SimpleMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleMissingNext' -Directory + Add-NTFSAccess -Path $next -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + + $result = @(Get-NTFSSimpleAccess -Path $missing, $next -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue) + + $simpleErrors | Should -HaveCount 1 + $simpleErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadError,*' + $simpleErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $simpleErrors[0].TargetObject | Should -Be $missing + @($result | Where-Object -FilterScript { $_.FullName -eq $next }) | Should -Not -BeNullOrEmpty + } + } + Context 'SD parameter set' { It 'Should filter and partition a descriptor''s entries like the equivalent -Path call, and read two descriptors bound through the pipeline' { $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleSdParent' -Directory @@ -708,6 +1145,21 @@ Describe 'Get-NTFSEffectiveAccess' { $result[0].FullName | Should -Be $first $result[1].FullName | Should -Be $second } + + It 'Should write nothing for a missing path and only the succeeding path''s result in the same call' { + $missing = Join-Path -Path $sandbox -ChildPath ('EffectiveMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'EffectiveMissingNext' + + $result = @(Get-NTFSEffectiveAccess -Path $missing, $next -Account 'S-1-1-0' -WarningAction SilentlyContinue -ErrorVariable accessErrors -ErrorAction SilentlyContinue) + + $accessErrors | Should -HaveCount 1 + $accessErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $accessErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $accessErrors[0].TargetObject | Should -Be $missing + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $next + } } Context 'SecurityDescriptor parameter set' {