Browse Source

Merge pull request #114 from raandree/ai/release-5.0.0-rc5

Release 5.0.0-rc5: live tests in a lab and the Get-NTFSEffectiveAccess -ServerName fix
pull/121/head 5.0.0-rc5
Raimund Andrée 4 days ago
committed by GitHub
parent
commit
fcb370efef
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      .github/scripts/Invoke-Tests.ps1
  2. 81
      .memory-bank/activeContext.md
  3. 27
      .memory-bank/decisions/0020-live-tests-in-tests-lab.md
  4. 32
      .memory-bank/progress.md
  5. 10
      .memory-bank/systemPatterns.md
  6. 27
      .memory-bank/techContext.md
  7. 3
      CHANGELOG.md
  8. 2
      Docs/Cmdlets/Get-NTFSEffectiveAccess.md
  9. 4
      Docs/Contributing/02-Writing.md
  10. 2
      NTFSSecurity/NTFSSecurity.psd1
  11. 2
      NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml
  12. 4
      Security2/Win32/Lib.cs
  13. 18
      Tests/Access.Tests.ps1
  14. 1010
      Tests/Lab/Invoke-NTFSSecurityLabTest.ps1
  15. 238
      Tests/Lab/NTFSSecurity.LabHelpers.ps1
  16. 468
      Tests/Lab/NTFSSecurity.Live.Tests.ps1
  17. 124
      Tests/Lab/README.md
  18. 85
      Tests/Lab/Start-NTFSSecurityLiveTest.ps1
  19. 2
      Tests/Repository.Tests.ps1

7
.github/scripts/Invoke-Tests.ps1

@ -5,7 +5,7 @@
.DESCRIPTION
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
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
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
}
$testsPath = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..\Tests')).ProviderPath
$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.Output.Verbosity = 'Detailed'
$configuration.TestResult.Enabled = $true

81
.memory-bank/activeContext.md

@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-10-06
last-verified: 2026-10-07
owner: active-agent
source: current task evidence
---
@ -9,47 +9,50 @@ source: current task evidence
## Current focus
Live tests of 5.0.0-rc4 in a lab, which the maintainer decided on
2026-10-06 to run before 5.0.0. He continues on another workstation that
has his lab script. 5.0.0 waits for these results and for the tester
feedback in #34; then the repository is archived in favor of
WindowsAccessControl (Decision 18).
5.0.0-rc5, prepared on the branch `ai/release-5.0.0-rc5`: the live tests
in `Tests\Lab` (Decision 20) and the fix of
`Get-NTFSEffectiveAccess -ServerName` that they found. The maintainer
pushes the branch, opens and merges the pull request, and tags
`5.0.0-rc5`; CI publishes it (Decision 12). Then, with the tester feedback
in #34, he decides on 5.0.0. After 5.0.0 the repository is archived in
favor of WindowsAccessControl (Decision 18).
## Evidence
- 2026-10-06: 5.0.0-rc4 is published from the tag `5.0.0-rc4` on
`01d9264`, the merge commit of #113, and verified from the Gallery
package in both editions; its issues are closed (progress).
- The Pester tests run only on standalone machines against local NTFS
folders, with local and well-known accounts. No test uses a UNC path, a
share, a domain account, or `Get-NTFSEffectiveAccess -ServerName`, which
calls `AuthzInitializeRemoteResourceManager` over RPC. 5.0.0 changed no
code that is specific to domains or SMB.
- In #34, the tester wrote on 2026-10-06 that the owner problem also exists
on their IBM ESS system, and that he reports rc3 results against both
their file servers on 2026-10-07. No lab reproduces IBM ESS.
- The second workstation has Hyper-V and AutomatedLab 5.61, but no
LabSources folder, ISO, or lab.
- Hand commands to the maintainer as fenced code blocks at the end of the
reply, and end the turn there; never put them in the question dialog,
which also covers a reply before it. A pull request names an issue
without a closing keyword unless the merge should close it (techContext).
- 2026-10-07: the live tests ran in the lab of WindowsAccessControl
(`F1ADC1`, `F1AFile2`, `F1AFile1` in `a.forest1.net`) against the Gallery
packages of 5.0.0-rc2 and 5.0.0-rc4 and against the branch build, in
Windows PowerShell 5.1 and PowerShell 7, with the same results in both:
- Case 1 (#34 over SMB): rc2 fails `Add-NTFSAccess`, `Clear-NTFSAccess`,
and `Set-NTFSSecurityDescriptor` with error 1307, on folders with and
without the auto-inherit flag; the other four cmdlets succeed in rc2
too. rc4 passes all seven and keeps Administrators as the owner.
- Case 2: the administrators of the file server read, add, and remove
audit entries over SMB; the delegated account gets the errors that the
cmdlet pages describe, and the folders stay unchanged. Same in rc2.
- Case 3: with `-ServerName` of the file server, the result includes its
local group, without a warning; without it, only the domain groups
count. With a computer that can't be reached, rc2 and rc4 returned no
access, because error 1722 was swallowed; fixed on the branch.
- Case 4, long paths on the share, and #108 pass; #108 fails in rc2.
- The branch build passes every live test, and the 499 tests of `Tests`
in both editions.
- One `security-reviewer` pass approved the branch with minor findings;
the assertion of the fallback warning and the path guard of the live
tests were hardened. Deferred: the bare `catch` in
`Win32.GetEffectiveAccess`, which still swallows any other error of the
remote initialization (not reproducible: for a user who isn't an
administrator of the file server, the cmdlet writes "Access is denied");
the unchecked `AUTHZ_ACCESS_REPLY.Error`; a fallback warning that names
the server and the error, which would change behavior (Decision 16).
- #34: no report from the tester by 16:00 UTC on 2026-10-07; he announced
results against two file servers, one of them IBM ESS, for that day.
- The lab keeps the accounts, the share, and the folders of the last run;
`Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture` removes them.
## Next step
Build the lab with the maintainer's script: a domain controller, a file
server with a share, and a client. From the client, run live tests against
5.0.0-rc2 as the baseline and 5.0.0-rc4, in Windows PowerShell 5.1 and
PowerShell 7:
1. #34 over SMB: a domain user who isn't an admin on the file server, with
Full Control on a share folder owned by Administrators, runs the access
and inheritance cmdlets on its UNC path. rc2 should fail with (1307);
rc4 should succeed and keep the owner.
2. The audit cmdlets over SMB, where the file server, not the client,
checks the Security privilege: as a user with and without it there.
3. `Get-NTFSEffectiveAccess` for domain accounts with nested groups, and
with `-ServerName`.
4. `Get-NTFSOrphanedAccess` with the entry of a deleted domain account.
Then, with the #34 feedback, release 5.0.0 as `progress.md` describes.
The maintainer runs the push and pull request commands of the session of
2026-10-07, merges, and tags `5.0.0-rc5`. Then check the published package
with `Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc5`, wait for the #34
feedback, and release 5.0.0 as `progress.md` describes.

27
.memory-bank/decisions/0020-live-tests-in-tests-lab.md

@ -0,0 +1,27 @@
---
status: accepted
date: 2026-10-07
last-verified: 2026-10-07
owner: shared
source: maintainer decision of 2026-10-07
---
# Decision 20: Live tests in a lab live in Tests\Lab
- Choice: The live tests that need a file server and domain accounts live
in `Tests\Lab`: the Pester file `NTFSSecurity.Live.Tests.ps1`, the
controller `Invoke-NTFSSecurityLabTest.ps1`, which prepares an
AutomatedLab lab and runs the tests per module version and PowerShell
edition, and a README with the cases. CI excludes the folder through
`Run.ExcludePath` in `.github/scripts/Invoke-Tests.ps1`, and without a
configuration every live test skips. The default lab is the one of
WindowsAccessControl (`F1ADC1`, `F1AFile2`, `F1AFile1` in
`a.forest1.net`), which the maintainer allowed on 2026-10-07.
- Rationale: The tests in `Tests` run on one computer with local accounts.
#34 over SMB, the Security privilege that the file server checks,
`Get-NTFSEffectiveAccess -ServerName`, and orphaned domain accounts need a
file server and a domain. In the repository, a later release can repeat
the tests and review them in a pull request.
- Rejected: a handoff folder outside the repository, which keeps the tests
on one machine.
- Applied: 5.0.0-rc5.

32
.memory-bank/progress.md

@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-10-06
last-verified: 2026-10-07
owner: active-agent
source: repository evidence
---
@ -13,8 +13,11 @@ source: repository evidence
published by CI from the tag `5.0.0-rc4` on `master` (`01d9264`, the merge
of #113) on 2026-10-06 (Decision 12). It adds to 5.0.0-rc3 the fixes of the
issues #41, #108, #109, and #111 and of the leftovers of the rc3 review.
The stable Gallery version is still 4.2.6. NTFSSecurity will be archived
soon; its users move to WindowsAccessControl (Decision 18).
5.0.0-rc5 is prepared on the branch `ai/release-5.0.0-rc5`: the live tests
in a lab (Decision 20) and the fix of `Get-NTFSEffectiveAccess -ServerName`
that they found. The stable Gallery version is still 4.2.6. NTFSSecurity
will be archived soon; its users move to WindowsAccessControl
(Decision 18).
## Recent milestones
@ -56,6 +59,15 @@ soon; its users move to WindowsAccessControl (Decision 18).
small items (#111). One `security-reviewer` pass approved it; its Minor
findings R1, R2, R6, and R8 were fixed before the merge. #41, #108,
#109, and #111 closed as completed, #90 and #107 as not planned.
- 2026-10-07: live tests in the lab of WindowsAccessControl (Decision 20)
against 5.0.0-rc2 and 5.0.0-rc4 in both editions: rc2 fails #34 over SMB
with error 1307 for `Add-NTFSAccess`, `Clear-NTFSAccess`, and
`Set-NTFSSecurityDescriptor`; rc4 passes the four cases, except
`Get-NTFSEffectiveAccess -ServerName` with a computer that can't be
reached, which returned no access since before rc1. The maintainer chose
to fix it test-first in 5.0.0-rc5; the branch build passes all live tests
and the suite. One `security-reviewer` pass approved it with minor
findings (`activeContext.md`).
## Stable capabilities
@ -70,9 +82,10 @@ soon; its users move to WindowsAccessControl (Decision 18).
## Open work
1. Release 5.0.0 through CI (Decision 12) after the live tests in a lab
(`activeContext.md`) and the tester feedback in #34: remove the label,
date `[Unreleased]` as `[5.0.0]`, add `5.0.0-rc4` to
1. Publish 5.0.0-rc5 (merge, then tag), check its package in the lab with
`Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc5`, and then
release 5.0.0 through CI (Decision 12) with the tester feedback in #34:
remove the label, date `[Unreleased]` as `[5.0.0]`, add `5.0.0-rc5` to
`$publishedVersions`, and tag `5.0.0` (steps in
`Docs/Contributing/05-Releasing.md`). #34 stays open with Bug and Help
Wanted until a tester with a file server that refuses the owner
@ -86,7 +99,12 @@ soon; its users move to WindowsAccessControl (Decision 18).
snapshots on read paths, and a duplicate SACL check; of rc4, R3 to R5,
R7, and five older defects, listed in the description of #113, among
them the accounts filter that `RemoveFileSystemAccessRuleAll` and
`RemoveFileSystemAuditRuleAll` ignore, which no cmdlet passes.
`RemoveFileSystemAuditRuleAll` ignore, which no cmdlet passes; of rc5,
the bare `catch` in `Win32.GetEffectiveAccess`, the unchecked
`AUTHZ_ACCESS_REPLY.Error`, a fallback warning without the server name,
and hardening of the lab controller (guards in the setup blocks,
interpolated `-EncodedCommand` paths, CredSSP by IP address, the
password string in memory, disabling the role accounts after a run).
4. `pwsh` 7.6.1 crashed three times during test runs on the ARM64
workstation (x64 emulation), without module frames; none of the CI runs
on native x64 on 2026-10-05 crashed.

10
.memory-bank/systemPatterns.md

@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-10-06
last-verified: 2026-10-07
owner: active-agent
source: repository evidence
---
@ -66,6 +66,7 @@ Each Decision record is a file in `decisions/`; read only the relevant ones.
| 17 | [Issue labels](decisions/0017-issue-labels.md) |
| 18 | [NTFSSecurity will be archived](decisions/0018-archive-for-windowsaccesscontrol.md) |
| 19 | [Cmdlets write only the sections that they change](decisions/0019-write-only-changed-sections.md) |
| 20 | [Live tests in a lab live in Tests\Lab](decisions/0020-live-tests-in-tests-lab.md) |
## Patterns
@ -96,3 +97,10 @@ Each Decision record is a file in `decisions/`; read only the relevant ones.
hook `BypassOnlineHelpRetrieval`. `Manifest.Tests.ps1` and
`Release.Tests.ps1` check the manifest, the version (Decision 10), the
release notes, and the packages.
- The live tests in `Tests\Lab` (Decision 20) run as domain accounts in a
lab: on the client over SMB, then on the file server, which checks what
the client runs left. They read and write descriptors as Windows stores
them with `GetFileSecurity` and `SetFileSecurity`, because
`GetNamedSecurityInfo` converts a DACL without the auto-inherit flag and
returns its owner. The expected effective rights come from the S4U tokens
of the file server and the client, like the Effective Access tab.

27
.memory-bank/techContext.md

@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-10-04
last-verified: 2026-10-07
owner: active-agent
source: repository evidence
---
@ -69,10 +69,23 @@ source: repository evidence
same way, its folder first on `$env:PSModulePath` of the test process.
The GitHub CLI is in `C:\Program Files\GitHub CLI`, outside the PATH of
sessions started before its installation.
- The third workstation (`ExHost`, a Windows Server 2025 VM, x64, used since
2026-10-07) runs the agent session elevated and hosts the AutomatedLab lab
`WindowsAccessControlLab` (Decision 20) with Hyper-V and AutomatedLab
5.61.704. It has no NuGet cache, platyPS, or GitHub CLI: check each
nuget.org package against the SHA-512 `packageHash` of its catalog entry
(`https://api.nuget.org/v3/registration5-semver1/<id>/<version>.json`,
then `catalogEntry`), and each Gallery package against `PackageHash` of
`api/v2/Packages(Id='<id>',Version='<version>')`. Pester 5.7.1 is in
`V:\Git\WindowsAccessControl\output\RequiredModules`; read issues and pull
requests through the GitHub REST API. The lab domains `a.forest1.net` and
`b.forest1.net` had a maximum password age of 42 days, so the password of
`install` expired on 2026-09-15 and AutomatedLab got access denied; it
never expires since 2026-10-07, as in `forest1.net`.
## Constraints
- `ModuleVersion` on `master` is `5.0.0` with the prerelease label `rc4`.
- `ModuleVersion` is `5.0.0` with the prerelease label `rc5`.
The latest stable tag and Gallery release is `4.2.6`. The manifest
requires PowerShell 5.1 and .NET Framework 4.5.2, uses `RootModule`, and
lists exactly 36 cmdlets; `Test-ModuleManifest` passes in Windows
@ -108,7 +121,9 @@ source: repository evidence
at the end of the reply, which the chat shows with a copy button, and end
the turn there; the maintainer reports back in the chat. The question
dialog joins the lines of its text, has no copy button, and covers the
reply before it (maintainer, 2026-10-06). A pull request description
reply before it (maintainer, 2026-10-06). A long question also hides its
choices, so that it can't be answered: keep it to a few short sentences
(2026-10-07). A pull request description
names an issue without a closing keyword (fixes, closes, resolves) unless
the merge should close it: "fixes #34" in #112 closed #34. Simulated `gh`
commands in offline tests must print what the real ones print, such as
@ -160,6 +175,12 @@ source: repository evidence
session. Run them as a basic user with `runas /trustlevel:0x20000`, and
give Windows PowerShell its own `PSModulePath`; that token holds one
privilege, so the `Enable-Privileges -PassThru` count test fails there.
- Live tests (Decision 20): in an elevated Windows PowerShell 5.1 session
on the lab host, `Tests\Lab\Invoke-NTFSSecurityLabTest.ps1` with
`-Version` for Gallery packages or `-ModulePath` for a build; it writes
the results to `$env:TEMP\NTFSSecurityLab\Results`. A run of two versions
in both editions takes about 30 minutes; `-RemoveFixture` removes its
accounts, share, and folders from the lab.
- Markdown lint: `npx markdownlint-cli2` with `MD013` limited to prose
(tables, code, and headings excluded) on the conceptual pages; for
`CHANGELOG.md` also `MD024` with `siblings_only: true`, because every

3
CHANGELOG.md

@ -266,5 +266,8 @@ The format is based on
of `Get-FileHash2`, which named the AlphaFS `FileInfo` instead of the type
name of its objects
([#111](https://github.com/raandree/NTFSSecurity/issues/111))
- Fix `Get-NTFSEffectiveAccess`, which returned no access when the computer
of `-ServerName` couldn't be reached, although it warned that it had
calculated the result on this computer; it now returns that result
[Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD

2
Docs/Cmdlets/Get-NTFSEffectiveAccess.md

@ -182,7 +182,7 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `Priva
Reading effective access needs the Security privilege. In a session that does not hold it, the cmdlet warns before it starts and the calculation may fail with an error. Use `Enable-Privileges` in an elevated session to enable the privilege, and `Get-Privileges` to see which privileges the session holds. When the calculation fails, the error names the cause that Windows reported, such as a security descriptor without an owner; before 5.0.0, it blamed a missing Security privilege whenever the privilege wasn't enabled.
Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`.
Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`. When the computer of `-ServerName` couldn't be reached, the cmdlet warned that it had calculated the result on this computer, but returned no access instead of that result.
## RELATED LINKS

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
```
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
the MarkdownLinkCheck module:

2
NTFSSecurity/NTFSSecurity.psd1

@ -102,7 +102,7 @@
ProjectUri = 'https://github.com/raandree/NTFSSecurity'
ReleaseNotes = 'https://github.com/raandree/NTFSSecurity/blob/master/CHANGELOG.md'
# Remove the prerelease label for the final release, see Docs/Contributing/05-Releasing.md
Prerelease = 'rc4'
Prerelease = 'rc5'
}
}
}

2
NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml

@ -5152,7 +5152,7 @@ PS C:\&gt; Get-NTFSAudit -SecurityDescriptor $sd</dev:code>
<maml:alert>
<maml:para>When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message.</maml:para>
<maml:para>Reading effective access needs the Security privilege. In a session that does not hold it, the cmdlet warns before it starts and the calculation may fail with an error. Use `Enable-Privileges` in an elevated session to enable the privilege, and `Get-Privileges` to see which privileges the session holds. When the calculation fails, the error names the cause that Windows reported, such as a security descriptor without an owner; before 5.0.0, it blamed a missing Security privilege whenever the privilege wasn't enabled.</maml:para>
<maml:para>Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`.</maml:para>
<maml:para>Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`. When the computer of `-ServerName` couldn't be reached, the cmdlet warned that it had calculated the result on this computer, but returned no access instead of that result.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>

4
Security2/Win32/Lib.cs

@ -173,7 +173,9 @@ namespace Security2
{
int error = Marshal.GetLastWin32Error();
if (error != Win32Error.EPT_S_NOT_REGISTERED) //if not RPC server unavailable
// The computer can't be resolved or reached (RPC server unavailable), or it doesn't offer the remote
// interface (endpoint not registered); the local authorization manager calculates the result instead.
if (error != Win32Error.EPT_S_NOT_REGISTERED && error != Win32Error.RPC_S_SERVER_UNAVAILABLE)
{
throw new Win32Exception(error);
}

18
Tests/Access.Tests.ps1

@ -131,6 +131,24 @@ Describe 'Get-NTFSEffectiveAccess' {
$accessErrors[0].Exception.Message | Should -Not -BeLike '*Enable-Privileges*'
}
}
Context 'When the computer of -ServerName cannot be reached' {
# Before 5.0.0-rc5, the cmdlet returned no access when the computer couldn't be reached, although it warned that
# it had calculated the result on this computer. Windows reports a computer that it can't resolve or reach with
# the error RPC server unavailable; the name ends in .invalid, which no DNS server resolves (RFC 2606).
It 'Should return the result of this computer and warn' {
$expected = Get-NTFSEffectiveAccess -Path $effectiveFile -WarningAction SilentlyContinue -ErrorAction Stop
[long] $expected.AccessRights | Should -BeGreaterThan ([long] [Security2.FileSystemRights2]::Synchronize)
$result = @(Get-NTFSEffectiveAccess -Path $effectiveFile -ServerName 'ntfssecurity-test.invalid' -WarningVariable accessWarnings -WarningAction SilentlyContinue -ErrorVariable accessErrors -ErrorAction SilentlyContinue)
$accessErrors | Should -BeNullOrEmpty
$result | Should -HaveCount 1
$result[0].AccessRights | Should -Be $expected.AccessRights
$accessWarnings.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')
}
}
}
Describe 'Get-NTFSOrphanedAccess' {

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')

2
Tests/Repository.Tests.ps1

@ -97,7 +97,7 @@ Describe 'Release metadata' {
# The PowerShell Gallery doesn't accept a version twice. Add every published version to this list
# (Docs/Contributing/05-Releasing.md).
It 'Should not reuse a version that the PowerShell Gallery already has' {
$publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1', '5.0.0-rc2', '5.0.0-rc3'
$publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1', '5.0.0-rc2', '5.0.0-rc3', '5.0.0-rc4'
$publishedVersions | Should -Not -Contain $version
}

Loading…
Cancel
Save