Browse Source

test: add live tests in a lab

Add Tests\Lab, which runs the module against a Windows file server with
domain accounts in an AutomatedLab lab: #34 over SMB, the audit cmdlets
over SMB, Get-NTFSEffectiveAccess with domain and file server groups,
Get-NTFSOrphanedAccess with a deleted domain account, long paths on a
share, and #108. Invoke-NTFSSecurityLabTest.ps1 prepares the lab and
runs the tests per module version and PowerShell edition; without a
lab, every live test skips. CI excludes the folder.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: AI Assistant <ai@example.com>
pull/114/head
Raimund Andree 4 days ago
parent
commit
5b71ab5e83
  1. 7
      .github/scripts/Invoke-Tests.ps1
  2. 4
      Docs/Contributing/02-Writing.md
  3. 1010
      Tests/Lab/Invoke-NTFSSecurityLabTest.ps1
  4. 238
      Tests/Lab/NTFSSecurity.LabHelpers.ps1
  5. 468
      Tests/Lab/NTFSSecurity.Live.Tests.ps1
  6. 124
      Tests/Lab/README.md
  7. 85
      Tests/Lab/Start-NTFSSecurityLiveTest.ps1

7
.github/scripts/Invoke-Tests.ps1

@ -5,7 +5,7 @@
.DESCRIPTION .DESCRIPTION
Imports Pester 5.7.1, runs the tests against the module build in NTFSSecurity\bin\Release, writes the result Imports Pester 5.7.1, runs the tests against the module build in NTFSSecurity\bin\Release, writes the result
file in the NUnit format, and adds the counts and the failed tests to the job summary of GitHub Actions. Fails if file in the NUnit format, and adds the counts and the failed tests to the job summary of GitHub Actions. Fails if
a test or a test file fails. a test or a test file fails. The live tests in Tests\Lab need a lab and don't run here.
.PARAMETER ResultPath .PARAMETER ResultPath
Specifies the path of the result file. Specifies the path of the result file.
@ -39,8 +39,11 @@ if ($resultFolder -and -not (Test-Path -LiteralPath $resultFolder)) {
New-Item -ItemType Directory -Path $resultFolder | Out-Null New-Item -ItemType Directory -Path $resultFolder | Out-Null
} }
$testsPath = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..\Tests')).ProviderPath
$configuration = New-PesterConfiguration $configuration = New-PesterConfiguration
$configuration.Run.Path = Join-Path -Path $PSScriptRoot -ChildPath '..\..\Tests' $configuration.Run.Path = $testsPath
# The live tests in Tests\Lab need a lab (Tests\Lab\README.md). Pester matches the full path of each test file.
$configuration.Run.ExcludePath = '{0}\Lab\*' -f [WildcardPattern]::Escape($testsPath)
$configuration.Run.PassThru = $true $configuration.Run.PassThru = $true
$configuration.Output.Verbosity = 'Detailed' $configuration.Output.Verbosity = 'Detailed'
$configuration.TestResult.Enabled = $true $configuration.TestResult.Enabled = $true

4
Docs/Contributing/02-Writing.md

@ -127,6 +127,10 @@ Before you open a pull request, check the following:
Invoke-Pester -Path .\Tests -Output Detailed Invoke-Pester -Path .\Tests -Output Detailed
``` ```
The [live tests](../../Tests/Lab/README.md) in `Tests\Lab` need a lab with
a file server and domain accounts. Without one, they skip all their tests,
and the CI workflow doesn't run them.
- All links work. The CI workflow checks them with `Get-MarkdownLink` from - All links work. The CI workflow checks them with `Get-MarkdownLink` from
the MarkdownLinkCheck module: the MarkdownLinkCheck module:

1010
Tests/Lab/Invoke-NTFSSecurityLabTest.ps1

File diff suppressed because it is too large

238
Tests/Lab/NTFSSecurity.LabHelpers.ps1

@ -0,0 +1,238 @@
<#
Helpers of the live tests in a lab (README.md). NTFSSecurity.Live.Tests.ps1 dot-sources this file on the client
and on the file server, and Invoke-NTFSSecurityLabTest.ps1 runs it on both to prepare the fixtures. Dot-sourcing
it only defines the helpers.
#>
if (-not ('NTFSSecurityLab.NativeMethods' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace NTFSSecurityLab
{
// GetFileSecurity and SetFileSecurity read and write a security descriptor as Windows stores it.
// GetNamedSecurityInfo and SetNamedSecurityInfo, which .NET and the module use, convert a DACL without the
// auto-inherit flag when they read it, and add the flag when they write a DACL.
public static class NativeMethods
{
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool GetFileSecurityW(string fileName, int requestedInformation, byte[] securityDescriptor, int length, out int lengthNeeded);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool SetFileSecurityW(string fileName, int securityInformation, byte[] securityDescriptor);
public static byte[] GetFileSecurity(string path, int information)
{
int needed;
GetFileSecurityW(path, information, null, 0, out needed);
if (needed == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
var buffer = new byte[needed];
if (!GetFileSecurityW(path, information, buffer, buffer.Length, out needed))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return buffer;
}
public static void SetFileSecurity(string path, int information, byte[] securityDescriptor)
{
if (!SetFileSecurityW(path, information, securityDescriptor))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
}
}
'@
}
function Get-LabSecurityDescriptor {
<#
.SYNOPSIS
Returns the owner, the group, and the DACL of a file or folder as Windows stores them.
.DESCRIPTION
GetNamedSecurityInfo returns the owner with a DACL without the auto-inherit flag even when only the DACL is
read, and converts such a DACL. This function reads with GetFileSecurity, which does neither.
.PARAMETER Path
A local path or a UNC path. The account needs the right to read the permissions.
#>
[CmdletBinding()]
[OutputType([System.Security.AccessControl.RawSecurityDescriptor])]
param (
[Parameter(Mandatory)]
[string]
$Path
)
# OWNER_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, and DACL_SECURITY_INFORMATION
$bytes = [NTFSSecurityLab.NativeMethods]::GetFileSecurity($Path, 7)
New-Object -TypeName 'System.Security.AccessControl.RawSecurityDescriptor' -ArgumentList $bytes, 0
}
function Test-LabDaclAutoInherited {
<#
.SYNOPSIS
Returns whether the stored DACL of a file or folder has the auto-inherit flag.
.PARAMETER Path
A local path or a UNC path.
#>
[CmdletBinding()]
[OutputType([bool])]
param (
[Parameter(Mandatory)]
[string]
$Path
)
$autoInherited = [System.Security.AccessControl.ControlFlags]::DiscretionaryAclAutoInherited
((Get-LabSecurityDescriptor -Path $Path).ControlFlags -band $autoInherited) -ne 0
}
function Set-LabLegacyDacl {
<#
.SYNOPSIS
Stores the DACL of a file or folder again, without the auto-inherit flag.
.DESCRIPTION
Windows adds the flag whenever SetNamedSecurityInfo writes a DACL. SetFileSecurity stores the DACL as given,
like tools that predate Windows 2000. For such a DACL, GetNamedSecurityInfo returns the owner also when only
the DACL is read, and NTFSSecurity before 5.0.0-rc3 wrote that owner back (#34).
.PARAMETER Path
A local path. The account needs the right to change the permissions.
#>
[CmdletBinding(SupportsShouldProcess)]
param (
[Parameter(Mandatory)]
[string]
$Path
)
$descriptor = Get-LabSecurityDescriptor -Path $Path
$autoInherited = [System.Security.AccessControl.ControlFlags]::DiscretionaryAclAutoInherited
$descriptor.SetFlags([System.Security.AccessControl.ControlFlags]($descriptor.ControlFlags -band -bnot $autoInherited))
$bytes = New-Object -TypeName 'byte[]' -ArgumentList $descriptor.BinaryLength
$descriptor.GetBinaryForm($bytes, 0)
if ($PSCmdlet.ShouldProcess($Path, 'Store the DACL without the auto-inherit flag')) {
# DACL_SECURITY_INFORMATION
[NTFSSecurityLab.NativeMethods]::SetFileSecurity($Path, 4, $bytes)
}
}
function Get-LabTokenSid {
<#
.SYNOPSIS
Returns the SIDs of the token that this computer creates for a domain account.
.DESCRIPTION
Logs the account on with Kerberos S4U, without its password, like the Effective Access tab of the advanced
security settings does. The token holds the account and all its groups that this computer knows: nested
domain groups and the local groups of this computer.
.PARAMETER UserPrincipalName
The user principal name of the account, such as user@contoso.com.
#>
[CmdletBinding()]
[OutputType([string])]
param (
[Parameter(Mandatory)]
[string]
$UserPrincipalName
)
$identity = New-Object -TypeName 'System.Security.Principal.WindowsIdentity' -ArgumentList $UserPrincipalName
try {
$identity.User.Value
foreach ($group in $identity.Groups) {
$group.Value
}
}
finally {
$identity.Dispose()
}
}
function Get-LabGrantedRight {
<#
.SYNOPSIS
Returns the access mask that the allow entries of a DACL grant to a set of SIDs.
.DESCRIPTION
Combines the entries that apply to the item itself and name one of the SIDs. A deny entry for one of the SIDs
makes the function throw, because the calculation doesn't cover it.
.PARAMETER Descriptor
The security descriptor of the item.
.PARAMETER Sid
The SIDs of a token, such as the output of Get-LabTokenSid.
#>
[CmdletBinding()]
[OutputType([long])]
param (
[Parameter(Mandatory)]
[System.Security.AccessControl.RawSecurityDescriptor]
$Descriptor,
[Parameter(Mandatory)]
[string[]]
$Sid
)
$granted = 0L
foreach ($ace in $Descriptor.DiscretionaryAcl) {
if ($ace -isnot [System.Security.AccessControl.CommonAce] -or $ace.SecurityIdentifier.Value -notin $Sid) {
continue
}
# HasFlag, because Windows PowerShell can't apply -band to an enum of the type byte.
if ($ace.AceFlags.HasFlag([System.Security.AccessControl.AceFlags]::InheritOnly)) {
continue
}
if ($ace.AceQualifier -ne [System.Security.AccessControl.AceQualifier]::AccessAllowed) {
throw "The DACL denies $($ace.SecurityIdentifier) access, which Get-LabGrantedRight doesn't calculate."
}
$granted = $granted -bor ([long]$ace.AccessMask -band 0xFFFFFFFFL)
}
$granted
}
function Assert-LabTestTarget {
<#
.SYNOPSIS
Throws unless this process runs on the client or the file server that a configuration of the lab names, and
the folder of the run lies in the share of the lab.
.DESCRIPTION
The live tests change security descriptors and must never run on another computer, such as the host of the
lab or a workstation.
.PARAMETER Configuration
The configuration of the run that Invoke-NTFSSecurityLabTest.ps1 wrote.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[object]
$Configuration
)
# Without CIM, which an account that isn't an administrator can't use in a remote session
$domainName = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().DomainName
if ($domainName -ne $Configuration.DomainName) {
throw "The live tests run only on a computer of the lab domain '$($Configuration.DomainName)'."
}
if ($env:COMPUTERNAME -notin $Configuration.Client, $Configuration.FileServer) {
throw "The live tests run only on '$($Configuration.Client)' and '$($Configuration.FileServer)', not on '$env:COMPUTERNAME'."
}
$shareRoot = '\\{0}\{1}\' -f $Configuration.FileServerFqdn, $Configuration.ShareName
$comparison = [System.StringComparison]::OrdinalIgnoreCase
if (-not $Configuration.SharePath.StartsWith($shareRoot, $comparison) -or
-not $Configuration.ServerPath.StartsWith($Configuration.ShareLocalPath + '\', $comparison) -or
$Configuration.SharePath.Contains('..') -or $Configuration.ServerPath.Contains('..')) {
throw "The folder of the run must lie in the share '$shareRoot' of the lab."
}
}

468
Tests/Lab/NTFSSecurity.Live.Tests.ps1

@ -0,0 +1,468 @@
<#
Live tests of the module against a Windows file server in a lab, for the cases that depend on the file server or
on domain accounts and that the tests in the Tests folder can't cover. Invoke-NTFSSecurityLabTest.ps1 prepares the
lab and runs this file on the client in the roles Delegate, ServerAdmin, and Admin, as the accounts of these roles,
and then on the file server in the role Server, which checks the security descriptors that the runs on the client
left, without the module. README.md describes the cases and the lab. Without a configuration, all tests are
skipped.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
)]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSReviewUnusedParameter', '', Justification = 'Pester passes the data of the container to the blocks.'
)]
param (
[string]
$ModulePath,
[string]
$ConfigurationPath,
[ValidateSet('', 'Delegate', 'ServerAdmin', 'Admin', 'Server')]
[string]
$Role
)
BeforeDiscovery {
$configured = -not [string]::IsNullOrEmpty($ConfigurationPath)
$variants = @(
@{ Variant = 'LegacyDacl'; Description = 'a DACL without the auto-inherit flag'; AutoInherited = $false }
@{ Variant = 'AutoInheritedDacl'; Description = 'an auto-inherited DACL'; AutoInherited = $true }
)
$operations = 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance', 'EnableInheritance',
'SetInheritance', 'SetSecurityDescriptor'
$auditSuccessCases = @(
@{ AuditRole = 'Admin'; Description = 'an administrator of the file server and the client' }
@{ AuditRole = 'ServerAdmin'; Description = 'an administrator of the file server only' }
)
$ownedFolders = @(
foreach ($variant in $variants) {
foreach ($operation in $operations) {
@{ Folder = 'Case1\{0}\{1}' -f $variant.Variant, $operation }
}
}
foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') {
foreach ($operation in 'GetAudit', 'AddAudit', 'RemoveAudit') {
@{ Folder = 'Case2\{0}\{1}' -f $auditRole, $operation }
}
}
)
# The administrators of the file server add and remove the audit entries; the delegated account changes nothing.
$auditExpectations = @(
foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') {
$mayWrite = $auditRole -ne 'Delegate'
@{ Folder = "Case2\$auditRole\GetAudit"; Count = 1 }
@{ Folder = "Case2\$auditRole\AddAudit"; Count = [int]$mayWrite }
@{ Folder = "Case2\$auditRole\RemoveAudit"; Count = [int](-not $mayWrite) }
}
)
}
BeforeAll {
if ($ConfigurationPath) {
. (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.LabHelpers.ps1')
$configuration = Get-Content -LiteralPath $ConfigurationPath -Raw | ConvertFrom-Json
Assert-LabTestTarget -Configuration $configuration
# On the client, the tests use the share; on the file server, the folder of the share.
$runRoot = if ($Role -eq 'Server') { $configuration.ServerPath } else { $configuration.SharePath }
if ($ModulePath) {
Import-Module -Name (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.psd1') -Force -ErrorAction Stop
}
$administrators = 'S-1-5-32-544'
$everyone = 'S-1-1-0'
$sidType = [System.Security.Principal.SecurityIdentifier]
$synchronize = 0x100000L
}
function Get-LabPath {
param ([string] $RelativePath)
# Normalized, so that neither '..' nor '/' leads out of the folder of the run.
$path = [System.IO.Path]::GetFullPath((Join-Path -Path $runRoot -ChildPath $RelativePath))
if ([System.IO.Path]::IsPathRooted($RelativePath) -or
-not $path.StartsWith($runRoot.TrimEnd('\') + '\', [System.StringComparison]::OrdinalIgnoreCase)) {
throw "'$RelativePath' must be a path in the folder of the run."
}
$path
}
function Get-LabOwner {
param ([string] $Path)
(Get-LabSecurityDescriptor -Path $Path).Owner.Value
}
function Get-LabExplicitAccessRule {
param ([string] $Path, [string] $Sid)
(Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, $sidType) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq $Sid }
}
function Format-LabError {
param ([object[]] $ErrorRecord)
foreach ($record in $ErrorRecord) {
if ($null -ne $record) {
'{0}: {1}' -f $record.FullyQualifiedErrorId, $record.Exception.Message
}
}
}
function Format-LabRight {
param ([object] $Right)
# .NET adds Synchronize to every allow entry that it creates.
'0x{0:X}' -f (([long]$Right) -bor $synchronize)
}
}
AfterAll {
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue
}
Describe 'Account of the run' -Tag 'Delegate', 'ServerAdmin', 'Admin', 'Server' -Skip:(-not $configured) {
It 'Should run as the account of the role' {
[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value | Should -Be $configuration.Accounts.$Role.Sid
}
It 'Should be an administrator of this computer only in the roles that are' {
$principal = New-Object -TypeName 'System.Security.Principal.WindowsPrincipal' -ArgumentList (
[System.Security.Principal.WindowsIdentity]::GetCurrent()
)
$expected = if ($env:COMPUTERNAME -eq $configuration.FileServer) {
$configuration.Accounts.$Role.FileServerAdministrator
}
else {
$configuration.Accounts.$Role.ClientAdministrator
}
$principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) | Should -Be $expected
}
It 'Should test the module version of the run' -Skip:($Role -eq 'Server') {
$module = Get-Module -Name NTFSSecurity
$version = [string]$module.Version
if ($module.PrivateData.PSData.Prerelease) {
$version = '{0}-{1}' -f $version, $module.PrivateData.PSData.Prerelease
}
$version | Should -Be $configuration.ModuleVersion
}
}
Describe 'Access and inheritance cmdlets on a share folder whose owner the account may not assign (#34)' -Tag 'Delegate' -Skip:(-not $configured) {
# The delegated account has Full Control on the folders through a domain group, but isn't an administrator of the
# file server, so the file server refuses Administrators as the owner that the account writes: (1307) This security
# ID may not be assigned as the owner of this object. Before 5.0.0-rc3, the cmdlets wrote the unchanged owner back
# whenever they had read it: Windows returns the owner with a DACL that is read alone when the DACL has no
# auto-inherit flag, and Get-NTFSSecurityDescriptor always reads it.
Context 'With <Description>' -ForEach $variants {
BeforeAll {
$folder = Get-LabPath -RelativePath "Case1\$Variant"
}
It 'Should start with folders that Administrators own' {
foreach ($operation in 'AddAccess', 'RemoveAccess', 'ClearAccess', 'DisableInheritance',
'EnableInheritance', 'SetInheritance', 'SetSecurityDescriptor') {
$path = Join-Path -Path $folder -ChildPath $operation
Get-LabOwner -Path $path | Should -Be $administrators -Because $operation
Test-LabDaclAutoInherited -Path $path | Should -Be $AutoInherited -Because $operation
}
}
It 'Add-NTFSAccess should add the entry and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'AddAccess'
Add-NTFSAccess -Path $path -Account $everyone -AccessRights ReadData -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
@(Get-LabExplicitAccessRule -Path $path -Sid $everyone) | Should -HaveCount 1
}
It 'Remove-NTFSAccess should remove the entry and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'RemoveAccess'
@(Get-LabExplicitAccessRule -Path $path -Sid $everyone) | Should -HaveCount 1
Remove-NTFSAccess -Path $path -Account $everyone -AccessRights ReadAndExecute -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
Get-LabExplicitAccessRule -Path $path -Sid $everyone | Should -BeNullOrEmpty
}
It 'Clear-NTFSAccess should remove the explicit entries and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'ClearAccess'
Clear-NTFSAccess -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
(Get-Acl -LiteralPath $path).GetAccessRules($true, $false, $sidType) | Should -BeNullOrEmpty
}
It 'Disable-NTFSAccessInheritance should disable the inheritance and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'DisableInheritance'
Disable-NTFSAccessInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
(Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue
}
It 'Enable-NTFSAccessInheritance should enable the inheritance and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'EnableInheritance'
(Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue
Enable-NTFSAccessInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
(Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeFalse
}
It 'Set-NTFSInheritance should disable the inheritance and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'SetInheritance'
Set-NTFSInheritance -Path $path -AccessInheritanceEnabled $false -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
(Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue
}
It 'Set-NTFSSecurityDescriptor should write the added entry and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'SetSecurityDescriptor'
$descriptor = Get-NTFSSecurityDescriptor -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Add-NTFSAccess -SecurityDescriptor $descriptor -Account $everyone -AccessRights ReadData -ErrorVariable +operationErrors -ErrorAction SilentlyContinue
Set-NTFSSecurityDescriptor -SecurityDescriptor $descriptor -ErrorVariable +operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
@(Get-LabExplicitAccessRule -Path $path -Sid $everyone) | Should -HaveCount 1
}
}
}
Describe 'Audit cmdlets on a share folder' -Skip:(-not $configured) {
# Over SMB, the file server checks whether the account holds the Security privilege there; the role Server checks
# the audit entries that the runs left on the file server.
foreach ($auditCase in $auditSuccessCases) {
Context 'As <Description>' -Tag $auditCase.AuditRole -ForEach @($auditCase) {
BeforeAll {
$folder = Get-LabPath -RelativePath "Case2\$AuditRole"
}
It 'Get-NTFSAudit should return the audit entry of the folder' {
$entries = @(Get-NTFSAudit -Path (Join-Path -Path $folder -ChildPath 'GetAudit') -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$entries | Should -HaveCount 1
$entries[0].Account.Sid | Should -Be $everyone
$entries[0].AuditFlags | Should -Be 'Success'
[long]$entries[0].AccessRights | Should -Be 0x10000
}
It 'Add-NTFSAudit should add the audit entry and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'AddAudit'
Add-NTFSAudit -Path $path -Account $everyone -AccessRights ReadData -AuditFlags Failure -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
}
It 'Remove-NTFSAudit should remove the audit entry and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'RemoveAudit'
Remove-NTFSAudit -Path $path -Account $everyone -AccessRights Delete -AuditFlags Success -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
}
}
}
Context 'As the delegated account, an administrator of the client only' -Tag 'Delegate' {
# The account has Full Control on the folders, so taking ownership succeeds, but it doesn't hold the Security
# privilege on the file server, and it may not assign Administrators as the owner again.
BeforeAll {
$folder = Get-LabPath -RelativePath 'Case2\Delegate'
}
It 'Get-NTFSAudit should write a ReadSecurityError that names the missing privilege' {
$entries = @(Get-NTFSAudit -Path (Join-Path -Path $folder -ChildPath 'GetAudit') -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
$entries | Should -BeNullOrEmpty
@(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1
$operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*'
$operationErrors[0].Exception.Message | Should -Match 'privilege'
}
It 'Add-NTFSAudit should write an AddAceError that names the missing privilege and leave the folder unchanged' {
$path = Join-Path -Path $folder -ChildPath 'AddAudit'
$before = (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All')
Add-NTFSAudit -Path $path -Account $everyone -AccessRights ReadData -AuditFlags Failure -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue
$written = (Format-LabError -ErrorRecord $operationErrors) -join ' | '
(Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') | Should -Be $before -Because "the cmdlet wrote: $written"
@(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written"
$operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*'
$operationErrors[0].Exception.Message | Should -Match 'privilege'
}
It 'Remove-NTFSAudit should write a RemoveAceError that names the missing privilege and leave the folder unchanged' {
$path = Join-Path -Path $folder -ChildPath 'RemoveAudit'
$before = (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All')
Remove-NTFSAudit -Path $path -Account $everyone -AccessRights Delete -AuditFlags Success -InheritanceFlags None -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue
$written = (Format-LabError -ErrorRecord $operationErrors) -join ' | '
(Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') | Should -Be $before -Because "the cmdlet wrote: $written"
@(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written"
$operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'RemoveAceError,*'
$operationErrors[0].Exception.Message | Should -Match 'privilege'
}
}
}
Describe 'Get-NTFSEffectiveAccess for a domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) {
# The account gets ReadAndExecute through two nested domain groups and Write through a local group of the file
# server. Only the file server knows its local groups. The expected rights come from the S4U tokens that the file
# server and the client create for the account, which hold the same groups as the Effective Access tab there.
BeforeAll {
$path = Get-LabPath -RelativePath 'Case3\EffectiveAccess'
$subject = $configuration.Accounts.Subject.Name
}
It 'Should return the rights through the domain groups and the local group of the file server with -ServerName, without a warning' {
$result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -ServerName $configuration.FileServerFqdn -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$operationWarnings | Should -BeNullOrEmpty
$result | Should -HaveCount 1
Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.FileServerRights)
}
It 'Should return only the rights through the domain groups without -ServerName' {
$result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$result | Should -HaveCount 1
Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.ClientRights)
}
# The cmdlet page: when the remote authorization manager can't be reached, the cmdlet falls back to the local one
# and warns that the result may be inaccurate.
It 'Should fall back to the authorization manager of the client and warn when -ServerName can''t be reached' {
$result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -ServerName $configuration.UnreachableServerName -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$operationWarnings.Message | Should -Contain ('The effective rights can only be computed based on group membership on this computer. ' +
'For more accurate results, calculate effective access rights on the target computer')
$result | Should -HaveCount 1
Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.ClientRights)
}
}
Describe 'Get-NTFSOrphanedAccess with the entry of a deleted domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) {
BeforeAll {
$folder = Get-LabPath -RelativePath 'Case4\OrphanedAccess'
$file = Join-Path -Path $folder -ChildPath 'File.txt'
$orphan = $configuration.Accounts.Orphan.Sid
}
It 'Should return the entry of the deleted account with its SID' {
$entries = @(Get-NTFSOrphanedAccess -Path $folder -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$operationWarnings | Should -BeNullOrEmpty
$entries | Should -HaveCount 1
$entries[0].Account.Sid | Should -Be $orphan
$entries[0].Account.AccountName | Should -BeNullOrEmpty
$entries[0].IsInherited | Should -BeFalse
}
It 'Should return the inherited entry for a file in the folder, and nothing with -ExcludeInherited' {
$entries = @(Get-NTFSOrphanedAccess -Path $file -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
$explicitEntries = @(Get-NTFSOrphanedAccess -Path $file -ExcludeInherited -ErrorVariable +operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$entries | Should -HaveCount 1
$entries[0].Account.Sid | Should -Be $orphan
$entries[0].IsInherited | Should -BeTrue
$explicitEntries | Should -BeNullOrEmpty
}
}
Describe 'Paths longer than 260 characters on a share' -Tag 'Admin' -Skip:(-not $configured) {
BeforeAll {
$folder = Get-LabPath -RelativePath 'LongPath'
$file = Join-Path -Path $folder -ChildPath $configuration.LongPath
}
It 'Get-ChildItem2 should return the file at the end of the long path' {
$files = @(Get-ChildItem2 -Path $folder -Recurse -File -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$files | Should -HaveCount 1
$files[0].FullName.Length | Should -BeGreaterThan 260
}
It 'Get-NTFSAccess should return the entries of that file' {
$file.Length | Should -BeGreaterThan 260
$entries = @(Get-NTFSAccess -Path $file -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$entries | Should -Not -BeNullOrEmpty
}
}
Describe 'Copy-Item2 and Move-Item2 with -WhatIf onto an existing file on a share (#108)' -Tag 'Admin' -Skip:(-not $configured) {
# Before 5.0.0-rc4, the cmdlets wrote an error with -WhatIf when the destination file existed.
BeforeAll {
$folder = Get-LabPath -RelativePath 'WhatIf'
$source = Join-Path -Path $folder -ChildPath 'Source.txt'
$destination = Join-Path -Path $folder -ChildPath 'Destination.txt'
}
It 'Copy-Item2 should write no error and leave the destination unchanged' {
Copy-Item2 -Path $source -Destination $destination -WhatIf -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-Content -LiteralPath $destination -Raw | Should -Be 'Destination'
}
It 'Move-Item2 should write no error and leave both files unchanged' {
Move-Item2 -Path $source -Destination $destination -WhatIf -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-Content -LiteralPath $source -Raw | Should -Be 'Source'
Get-Content -LiteralPath $destination -Raw | Should -Be 'Destination'
}
}
Describe 'Security descriptors on the file server after the runs on the client' -Tag 'Server' -Skip:(-not $configured) {
It 'Should keep Administrators as the owner of <Folder>' -ForEach $ownedFolders {
Get-LabOwner -Path (Get-LabPath -RelativePath $Folder) | Should -Be $administrators
}
It 'Should have <Count> explicit audit entries on <Folder>' -ForEach $auditExpectations {
$acl = Get-Acl -LiteralPath (Get-LabPath -RelativePath $Folder) -Audit
@($acl.GetAuditRules($true, $false, $sidType)).Count | Should -Be $Count
}
}

124
Tests/Lab/README.md

@ -0,0 +1,124 @@
# Live tests in a lab
The tests in this folder run the module against a Windows file server with
domain accounts, in an [AutomatedLab](https://automatedlab.org) lab. They cover
the cases that depend on the file server or on the accounts, which the tests in
`Tests` can't cover: those run on one computer, against local folders, with
local and well-known accounts. CI doesn't run the tests in this folder, and
without a lab they skip every test.
## Cases
| Case | Role | What the tests check |
| --- | --- | --- |
| 1, [#34][issue-34] | Delegate | `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, `Disable-NTFSAccessInheritance`, `Enable-NTFSAccessInheritance`, `Set-NTFSInheritance`, and `Set-NTFSSecurityDescriptor` on share folders that Administrators own and on which a domain group has Full Control, run by a member of that group who isn't an administrator of the file server. They succeed and keep the owner. |
| 2 | Admin, ServerAdmin, Delegate | `Get-NTFSAudit`, `Add-NTFSAudit`, and `Remove-NTFSAudit` on share folders. Over SMB, the file server checks the Security privilege of the account. The administrators of the file server read and change the audit entries; the delegated account gets the errors that the cmdlet pages describe, and the folders stay unchanged. |
| 3 | Admin | `Get-NTFSEffectiveAccess` for a domain account with rights through two nested domain groups and through a local group of the file server. With `-ServerName`, the result includes the local group, without a warning; without it, the client doesn't know that group. With an unreachable server, the cmdlet falls back to the client and warns. |
| 4 | Admin | `Get-NTFSOrphanedAccess` returns the entry of a deleted domain account with its SID, on the folder and as inherited entry on a file in it. |
| Long paths | Admin | `Get-ChildItem2` and `Get-NTFSAccess` with a share path longer than 260 characters. |
| [#108][issue-108] | Admin | `Copy-Item2` and `Move-Item2` with `-WhatIf` onto an existing file on the share write no error. |
| State | Server | After the runs on the client, the file server checks the owners and the audit entries of the folders itself, without the module. |
Case 1 uses two kinds of folders. Before 5.0.0-rc3, the cmdlets wrote back the
owner that Windows returns with a DACL without the auto-inherit flag, and the
file server refused it with error 1307, "This security ID may not be assigned
as the owner of this object". Windows sets that flag whenever it writes a DACL
with `SetNamedSecurityInfo`, so the fixture stores the DACL of one kind of
folders again with `SetFileSecurity`, without the flag, like tools that predate
Windows 2000. `Set-NTFSSecurityDescriptor` wrote the owner on both kinds.
The expected rights of case 3 come from the tokens that the file server and the
client create for the account with a Kerberos S4U logon, the way the Effective
Access tab of the advanced security settings does.
## Roles
| Role | Account | Administrator of the client | Administrator of the file server |
| --- | --- | --- | --- |
| Delegate | `NtfsLiveDelegate`, member of `NtfsLiveDelegates` | Yes | No |
| ServerAdmin | `NtfsLiveServerAdmin`, member of Remote Management Users on the client | No | Yes |
| Admin | `NtfsLiveAdmin` | Yes | Yes |
| Server | The installation account of the lab, on the file server | Yes | Yes |
The script also creates `NtfsLiveSubject`, the account of case 3, which is a
member of `NtfsLiveInner`, a member of `NtfsLiveOuter`, and of the local group
`NtfsLiveLocal` of the file server, and `NtfsLiveOrphan`, which it deletes in
every run.
## Lab
The lab needs a domain controller, a file server, and a client of one domain,
PowerShell 7 and Pester 5.7.1 on the client and the file server, and
remoting with CredSSP from the host, which AutomatedLab sets up. The defaults
use the lab of
[WindowsAccessControl](https://github.com/raandree/WindowsAccessControl), which
`tests/Lab/Deploy-WindowsAccessControlLab.ps1` in that repository deploys:
`F1ADC1` as domain controller, `F1AFile2` as file server, and `F1AFile1` as
client, all in `a.forest1.net`. `-DomainController`, `-FileServer`, and
`-Client` select other machines.
The script adds to the lab:
- the organizational unit `NTFSSecurityLive` with the accounts and groups
- the local group `NtfsLiveLocal` and members of Administrators on the file
server, and members of Administrators and Remote Management Users on the
client
- the share `NTFSSecurityLive` on `C:\NTFSSecurityLive` of the file server,
with a folder for each run
- the folder `C:\NTFSSecurityLab` with the tests on the file server, and with
the modules and the tests on the client
## Run the tests
In an elevated Windows PowerShell 5.1 session on the Hyper-V host of the lab:
```powershell
.\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc2, 5.0.0-rc4 -Confirm:$false
```
The script downloads each version from the PowerShell Gallery, checks the hash
that the gallery publishes, and runs the tests for each version in Windows
PowerShell 5.1 and PowerShell 7. Each version runs in its own process, because
all versions of `NTFSSecurity.dll` have the same assembly version. To test a
build, add `-ModulePath .\NTFSSecurity\bin\Release`; it runs as the version
`local`.
The script sets new random passwords for the accounts in every call and keeps
them in memory only. The tests refuse to run on a computer other than the
client and the file server of the configuration, and on a folder outside the
share.
Remove everything the script added to the lab:
```powershell
.\Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture
```
## Results
Each call writes to a new folder in `$env:TEMP\NTFSSecurityLab\Results`:
- `Summary.md` and `Summary.json`: the counts per version, edition, and role,
and the failed tests with their messages
- `<run>-<role>.result.json` and `<run>-<role>.log`: the result and the
error message of each test that ran, and the output of Pester
- `<run>.json`: the configuration of the run
- `<run>-State.json`: the stored owner, group, and DACL, and the SACL of each
folder after the run
A version before 5.0.0-rc3 fails case 1 with error 1307, a version before
5.0.0-rc4 fails the tests of #108, and a version before 5.0.0-rc5 fails the
test of case 3 with a computer that can't be reached: it returned no access
instead of the result of the client.
## Files
| File | Purpose |
| --- | --- |
| `Invoke-NTFSSecurityLabTest.ps1` | Prepares the lab, runs the tests, and writes the results; runs on the host. |
| `NTFSSecurity.Live.Tests.ps1` | The tests; run on the client and the file server. |
| `Start-NTFSSecurityLiveTest.ps1` | Runs the tests of one role in a new process. |
| `NTFSSecurity.LabHelpers.ps1` | Reads and writes security descriptors as Windows stores them, and calculates the expected rights. |
[issue-34]: https://github.com/raandree/NTFSSecurity/issues/34
[issue-108]: https://github.com/raandree/NTFSSecurity/issues/108

85
Tests/Lab/Start-NTFSSecurityLiveTest.ps1

@ -0,0 +1,85 @@
<#
.SYNOPSIS
Runs NTFSSecurity.Live.Tests.ps1 for one role and writes the result file.
.DESCRIPTION
Invoke-NTFSSecurityLabTest.ps1 starts this script in a new Windows PowerShell 5.1 or PowerShell 7 process on the
client or the file server of the lab, as the account of the role. Each module version runs in its own process,
because all versions of NTFSSecurity.dll have the same assembly version, and a second import in a process would
use the first DLL. Exits with 0 when all tests passed and with 1 otherwise.
.PARAMETER ModulePath
The folder that contains NTFSSecurity.psd1 of the version to test. The role Server needs no module.
.PARAMETER ConfigurationPath
The configuration of the run that Invoke-NTFSSecurityLabTest.ps1 wrote.
.PARAMETER Role
The role whose tests run: Delegate, ServerAdmin, Admin, or Server.
.PARAMETER ResultPath
The path of the result file: a JSON array with the name, the result, and the error message of each test that
ran, and of each test file that failed. Pester's result formats read the operating system through CIM, which an
account that isn't an administrator can't use in a remote session.
.EXAMPLE
.\Start-NTFSSecurityLiveTest.ps1 -ModulePath C:\NTFSSecurityLab\Modules\5.0.0-rc4\NTFSSecurity -ConfigurationPath C:\NTFSSecurityLab\Configuration\Run.json -Role Delegate -ResultPath $env:TEMP\Delegate.json
Runs the tests of the delegated account against 5.0.0-rc4.
#>
[CmdletBinding()]
param (
[string]
$ModulePath,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]
$ConfigurationPath,
[Parameter(Mandatory)]
[ValidateSet('Delegate', 'ServerAdmin', 'Admin', 'Server')]
[string]
$Role,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]
$ResultPath
)
$ErrorActionPreference = 'Stop'
Import-Module -Name Pester -RequiredVersion 5.7.1
$data = @{
ModulePath = $ModulePath
ConfigurationPath = $ConfigurationPath
Role = $Role
}
$configuration = New-PesterConfiguration
$configuration.Run.Container = New-PesterContainer -Path (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.Live.Tests.ps1') -Data $data
$configuration.Run.PassThru = $true
$configuration.Filter.Tag = $Role
$configuration.Output.Verbosity = 'Detailed'
$configuration.Output.RenderMode = 'Plaintext'
$result = Invoke-Pester -Configuration $configuration
$tests = foreach ($test in $result.Tests | Where-Object -FilterScript { $_.Result -ne 'NotRun' }) {
[pscustomobject]@{
Name = $test.ExpandedPath
Result = [string]$test.Result
Message = (@($test.ErrorRecord) | Where-Object -FilterScript { $_ } | ForEach-Object -Process { $_.ToString() }) -join [Environment]::NewLine
}
}
# A test file fails also when only its tests fail; it is listed only when it failed with an error of its own.
$failedFiles = foreach ($container in $result.Containers | Where-Object -FilterScript { $_.Result -eq 'Failed' -and @($_.ErrorRecord).Count -gt 0 }) {
[pscustomobject]@{
Name = 'Test file {0}' -f $container.Item
Result = 'Failed'
Message = (@($container.ErrorRecord) | Where-Object -FilterScript { $_ } | ForEach-Object -Process { $_.ToString() }) -join [Environment]::NewLine
}
}
ConvertTo-Json -InputObject @(@($tests) + @($failedFiles)) -Depth 3 | Set-Content -LiteralPath $ResultPath -Encoding UTF8
exit [int]($result.Result -ne 'Passed')
Loading…
Cancel
Save