diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b67dd3762..a381ea780 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -354,8 +354,6 @@ jobs:
$VSINSTALLDIR = $(vswhere.exe -latest -requires Microsoft.VisualStudio.Component.VC.Llvm.Clang -property installationPath)
Write-Output "LIBCLANG_PATH=$VSINSTALLDIR\VC\Tools\Llvm\x64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell
- Install-Module VsDevShell -Force
shell: pwsh
- name: Configure Windows (arm) runner
@@ -696,9 +694,6 @@ jobs:
# NASM is required by aws-lc-rs (used as rustls crypto backend)
choco install nasm
- # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell
- Install-Module VsDevShell -Force
-
# We need to add the NASM binary folder to the PATH manually.
Write-Output "$Env:ProgramFiles\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
shell: pwsh
@@ -707,9 +702,31 @@ jobs:
id: find_mc
if: ${{ matrix.os == 'windows' }}
run: |
- Enter-VsDevShell
- $path = (Get-Command -Type Application mc).Source | Split-Path -Parent
+ $sdkRoots = @(
+ $Env:WindowsSdkDir
+ (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots" -Name KitsRoot10 -ErrorAction SilentlyContinue)
+ "${Env:ProgramFiles(x86)}\Windows Kits\10"
+ ) | Where-Object { $_ } | Select-Object -Unique
+ $candidates = @()
+ if ($Env:WindowsSdkVerBinPath) {
+ $candidates += Join-Path $Env:WindowsSdkVerBinPath "mc.exe"
+ $candidates += Join-Path $Env:WindowsSdkVerBinPath "x64\mc.exe"
+ }
+ foreach ($root in $sdkRoots) {
+ $bin = Join-Path $root "bin"
+ $candidates += Join-Path $bin "x64\mc.exe"
+ $candidates += Get-ChildItem -LiteralPath $bin -Directory -ErrorAction SilentlyContinue |
+ Where-Object Name -Match '^\d+\.\d+\.\d+\.\d+$' |
+ Sort-Object { [version]$_.Name } -Descending |
+ ForEach-Object { Join-Path $_.FullName "x64\mc.exe" }
+ }
+ $mc = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
+ if (-Not $mc) {
+ throw "mc.exe was not found in the installed Windows SDK"
+ }
+ $path = Split-Path -Parent $mc
Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
+ Write-Output $path | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
shell: pwsh
- name: Build
@@ -870,6 +887,9 @@ jobs:
$DAgentSessionExecutable = Join-Path $TargetOutputPath "DevolutionsSession.exe"
echo "dagent-session-executable=$DAgentSessionExecutable" >> $Env:GITHUB_OUTPUT
+ $DAgentPolicyConsentHelper = Join-Path $TargetOutputPath "DevolutionsAgentPolicyConsent.exe"
+ echo "dagent-policy-consent-helper=$DAgentPolicyConsentHelper" >> $Env:GITHUB_OUTPUT
+
$DAgentUpdaterExecutable = Join-Path $TargetOutputPath "DevolutionsAgentUpdater.exe"
echo "dagent-updater-executable=$DAgentUpdaterExecutable" >> $Env:GITHUB_OUTPUT
}
@@ -975,6 +995,37 @@ jobs:
if: ${{ matrix.os == 'windows' }}
uses: microsoft/setup-msbuild@v3
+ - name: Find mc.exe
+ id: find_mc
+ if: ${{ matrix.os == 'windows' }}
+ run: |
+ $sdkRoots = @(
+ $Env:WindowsSdkDir
+ (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots" -Name KitsRoot10 -ErrorAction SilentlyContinue)
+ "${Env:ProgramFiles(x86)}\Windows Kits\10"
+ ) | Where-Object { $_ } | Select-Object -Unique
+ $candidates = @()
+ if ($Env:WindowsSdkVerBinPath) {
+ $candidates += Join-Path $Env:WindowsSdkVerBinPath "mc.exe"
+ $candidates += Join-Path $Env:WindowsSdkVerBinPath "x64\mc.exe"
+ }
+ foreach ($root in $sdkRoots) {
+ $bin = Join-Path $root "bin"
+ $candidates += Join-Path $bin "x64\mc.exe"
+ $candidates += Get-ChildItem -LiteralPath $bin -Directory -ErrorAction SilentlyContinue |
+ Where-Object Name -Match '^\d+\.\d+\.\d+\.\d+$' |
+ Sort-Object { [version]$_.Name } -Descending |
+ ForEach-Object { Join-Path $_.FullName "x64\mc.exe" }
+ }
+ $mc = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
+ if (-Not $mc) {
+ throw "mc.exe was not found in the installed Windows SDK"
+ }
+ $path = Split-Path -Parent $mc
+ Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
+ Write-Output $path | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
+ shell: pwsh
+
- name: Build
run: |
if ($Env:RUNNER_OS -eq "Windows") {
@@ -985,6 +1036,7 @@ jobs:
$Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}"
$Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}"
$Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}"
+ $Env:WindowsSdkVerBinPath = '${{ steps.find_mc.outputs.windows_sdk_ver_bin_path }}'
}
if ($Env:RUNNER_OS -eq "Linux") {
@@ -1003,6 +1055,28 @@ jobs:
DAGENT_EXECUTABLE: ${{ steps.load-variables.outputs.dagent-executable }}
TARGET_OUTPUT_PATH: ${{ steps.load-variables.outputs.target-output-path }}
+ - name: Build NativeAOT policy consent helper
+ if: ${{ matrix.os == 'windows' }}
+ run: |
+ $Rid = "win-${{ matrix.arch }}"
+ $Output = Split-Path -Parent '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}'
+ dotnet publish package/AgentPolicyConsent/DevolutionsAgentPolicyConsent.csproj `
+ --configuration Release `
+ --runtime $Rid `
+ --output $Output `
+ -p:Version=${{ needs.preflight.outputs.version }}
+ if ($LASTEXITCODE -ne 0) {
+ exit $LASTEXITCODE
+ }
+ $Helper = '${{ steps.load-variables.outputs.dagent-policy-consent-helper }}'
+ if (-Not (Test-Path -LiteralPath $Helper -PathType Leaf)) {
+ throw "NativeAOT policy consent helper was not produced"
+ }
+ if ((Get-Item -LiteralPath $Helper).Length -gt 8MB) {
+ throw "NativeAOT policy consent helper exceeds 8 MiB"
+ }
+ shell: pwsh
+
- name: Package
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
run: |
@@ -1016,6 +1090,7 @@ jobs:
$Env:DAGENT_PEDM_SHELL_EXT_DLL = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-dll }}"
$Env:DAGENT_PEDM_SHELL_EXT_MSIX = "${{ steps.load-variables.outputs.dagent-pedm-shell-ext-msix }}"
$Env:DAGENT_SESSION_EXECUTABLE = "${{ steps.load-variables.outputs.dagent-session-executable }}"
+ $Env:DAGENT_POLICY_CONSENT_HELPER = "${{ steps.load-variables.outputs.dagent-policy-consent-helper }}"
$Env:DAGENT_TUN2SOCKS_EXE = "${{ steps.tun2socks.outputs.tun2socks-executable-path }}"
$Env:DAGENT_WINTUN_DLL = "${{ steps.tun2socks.outputs.wintun-library-path }}"
$Env:DAGENT_MULTI_PWSH_EXECUTABLE = "${{ steps.multi-pwsh.outputs.executable-path }}"
@@ -1122,6 +1197,25 @@ jobs:
run: dotnet test utils/dotnet/GatewayUtils.sln
shell: pwsh
+ agent-installer-event-log-tests:
+ name: Agent installer Event Log lifecycle tests
+ runs-on: windows-2022
+ needs: [preflight]
+
+ steps:
+ - name: Checkout ${{ github.repository }}
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.preflight.outputs.ref }}
+
+ - name: Tests
+ run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj
+ shell: pwsh
+
+ - name: Policy consent helper tests
+ run: dotnet test package/AgentPolicyConsent.Tests/DevolutionsAgentPolicyConsent.Tests.csproj -c Release
+ shell: pwsh
+
winapi-sanitizer-tests:
name: Windows API sanitizer tests
@@ -1283,12 +1377,14 @@ jobs:
name: Agent policy end-to-end test
runs-on: windows-2022
needs: [preflight]
+ env:
+ AGENT_POLICY_TEST_SHA: ${{ inputs.ref || github.event.pull_request.head.sha || needs.preflight.outputs.ref }}
steps:
- name: Checkout ${{ github.repository }}
uses: actions/checkout@v6
with:
- ref: ${{ needs.preflight.outputs.ref }}
+ ref: ${{ env.AGENT_POLICY_TEST_SHA }}
- name: Setup Rust cache
uses: ./.github/actions/setup-rust-cache
@@ -1311,8 +1407,13 @@ jobs:
Add-Content -Path $env:GITHUB_PATH -Value $toolsDir
- name: Build Agent policy test executables
+ id: build-policy-executables
shell: pwsh
run: |
+ $actualCommit = git rev-parse HEAD
+ if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $env:AGENT_POLICY_TEST_SHA) {
+ throw "Agent policy tests must build the requested commit $env:AGENT_POLICY_TEST_SHA, got $actualCommit"
+ }
cargo build --locked -p devolutions-agent --features dev-skip-broker-signature
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
@@ -1322,7 +1423,18 @@ jobs:
exit $LASTEXITCODE
}
+ - name: Run Agent policy tester as standard user
+ shell: pwsh
+ run: |
+ ./crates/agent-policy-tester/run-unelevated.ps1
+ $exitCode = $LASTEXITCODE
+ Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester-unelevated.out
+ if ($exitCode -ne 0) {
+ exit $exitCode
+ }
+
- name: Run Agent policy tester as LocalSystem
+ if: ${{ !cancelled() && steps.build-policy-executables.outcome == 'success' }}
shell: pwsh
run: |
$scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-as-system.ps1"
@@ -1333,6 +1445,10 @@ jobs:
exit $exitCode
}
+ - name: Run policy route authorization tests
+ shell: pwsh
+ run: cargo test --locked -p now-package-broker --features dev-skip-broker-signature
+
- name: Show sccache stats
if: ${{ needs.preflight.outputs.sccache == 'true' && !cancelled() }}
shell: pwsh
@@ -1366,7 +1482,7 @@ jobs:
success:
name: Success
if: ${{ always() }}
- needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier]
+ needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, agent-installer-event-log-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier]
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index 27ee014f9..af56e0b9f 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -322,7 +322,7 @@ jobs:
run: |
$IncludePattern = @(switch ('${{ matrix.project }}') {
'devolutions-gateway' { @('DevolutionsGateway.exe') }
- 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') }
+ 'devolutions-agent' { @('DevolutionsAgent.exe', 'DevolutionsAgentUpdater.exe', 'DevolutionsAgentPolicyConsent.exe', 'DevolutionsPedmShellExt.dll', 'DevolutionsPedmShellExt.msix', 'DevolutionsDesktopAgent.exe') }
'jetsocat' { @('jetsocat.exe', 'jetsocat') }
})
$ExcludePattern = "*.pdb"
@@ -495,6 +495,7 @@ jobs:
$Env:DAGENT_PEDM_SHELL_EXT_DLL = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' -File | Select-Object -First 1
$Env:DAGENT_PEDM_SHELL_EXT_MSIX = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' -File | Select-Object -First 1
$Env:DAGENT_SESSION_EXECUTABLE = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' -File | Select-Object -First 1
+ $Env:DAGENT_POLICY_CONSENT_HELPER = Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' -File | Select-Object -First 1
$Env:DAGENT_TUN2SOCKS_EXE = Join-Path $ArchRoot 'tun2socks.exe'
$Env:DAGENT_WINTUN_DLL = Join-Path $ArchRoot 'wintun.dll'
$MultiPwshDirectory = Join-Path $Env:RUNNER_TEMP 'multi-pwsh' 'windows' $Arch
@@ -508,6 +509,7 @@ jobs:
Write-Host "DAGENT_PEDM_SHELL_EXT_DLL = ${Env:DAGENT_PEDM_SHELL_EXT_DLL}"
Write-Host "DAGENT_PEDM_SHELL_EXT_MSIX = ${Env:DAGENT_PEDM_SHELL_EXT_MSIX}"
Write-Host "DAGENT_SESSION_EXECUTABLE = ${Env:DAGENT_SESSION_EXECUTABLE}"
+ Write-Host "DAGENT_POLICY_CONSENT_HELPER = ${Env:DAGENT_POLICY_CONSENT_HELPER}"
Write-Host "DAGENT_TUN2SOCKS_EXE = ${Env:DAGENT_TUN2SOCKS_EXE}"
Write-Host "DAGENT_WINTUN_DLL = ${Env:DAGENT_WINTUN_DLL}"
Write-Host "DAGENT_MULTI_PWSH_EXECUTABLE = ${Env:DAGENT_MULTI_PWSH_EXECUTABLE}"
@@ -534,7 +536,8 @@ jobs:
@((Join-Path $ArchRoot DesktopAgent),
(Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.dll' | Select-Object -First 1),
(Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsPedmShellExt.msix' | Select-Object -First 1),
- (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1)) | ForEach-Object {
+ (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsSession.exe' | Select-Object -First 1),
+ (Get-ChildItem -Path $ArchRoot -Filter 'DevolutionsAgentPolicyConsent.exe' | Select-Object -First 1)) | ForEach-Object {
Remove-Item $_ -Recurse -ErrorAction SilentlyContinue | Out-Null
}
}
diff --git a/Cargo.lock b/Cargo.lock
index 8e668cbb0..74e389f1e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -94,6 +94,15 @@ dependencies = [
"serde_json",
"tempfile",
"tokio 1.52.3",
+ "win-api-wrappers",
+ "windows 0.61.3",
+]
+
+[[package]]
+name = "agent-sysevent-codes"
+version = "0.0.0"
+dependencies = [
+ "sysevent",
]
[[package]]
@@ -1782,6 +1791,7 @@ dependencies = [
"uuid",
"win-api-wrappers",
"windows 0.61.3",
+ "windows-registry 0.5.3",
"x509-parser",
]
@@ -2672,8 +2682,8 @@ dependencies = [
"libc",
"log",
"rustversion",
- "windows-link 0.2.1",
- "windows-result 0.4.1",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
]
[[package]]
@@ -3208,7 +3218,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.62.2",
+ "windows-core 0.61.2",
]
[[package]]
@@ -4804,6 +4814,7 @@ dependencies = [
name = "now-package-broker"
version = "0.0.0"
dependencies = [
+ "agent-sysevent-codes",
"anyhow",
"async-trait",
"axum 0.8.9",
@@ -4826,11 +4837,14 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
+ "sysevent",
+ "sysevent-winevent",
"tempfile",
"tokio 1.52.3",
"tokio-util",
"tower-service",
"tracing",
+ "unicode-normalization",
"uuid",
"widestring 1.2.1",
"win-api-wrappers",
@@ -4854,9 +4868,9 @@ dependencies = [
[[package]]
name = "now-policy-api"
-version = "0.6.0"
+version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b61d66fd334d2dac6150d1ab83f3831ec4b0ee20272fb3386fbe5b5e31c6663"
+checksum = "fcd733577077eb870204207836f596ec3fc8fe4876d3652be7f0dee4a52e0dc8"
dependencies = [
"chrono",
"derive_more",
@@ -4871,9 +4885,9 @@ dependencies = [
[[package]]
name = "now-policy-server-template"
-version = "0.6.0"
+version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fee165964d3b2dddfa2c6283b820d5cad337277d51365cf77e6b1376668f529d"
+checksum = "567491bfc7bf5615d1854cc951172987fe638084b86c6c153ad6d860f0096ae8"
dependencies = [
"aide 0.15.1",
"async-trait",
diff --git a/ci/README.md b/ci/README.md
index 264a9500d..bac41e802 100644
--- a/ci/README.md
+++ b/ci/README.md
@@ -14,14 +14,32 @@ This folder contains PowerShell scripts for CI, building, and packaging.
| Gateway | Windows (regular) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1` |
| Gateway | Windows (assembled) | `build.ps1 gateway`
`copy-ps-module.ps1`
`package-gateway-windows.ps1 -Generate`
`package-assembled.ps1 gateway` |
| Gateway | Linux | `build.ps1 gateway`
`package-gateway-linux.ps1` (not available yet) |
-| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1` |
-| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`package-agent-windows.ps1 -Generate`
`package-assembled.ps1 agent` |
+| Agent | Windows (regular) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1` with the arguments below |
+| Agent | Windows (assembled) | `build.ps1 agent`
`build.ps1 pedm`
`build.ps1 session`
`..\dotnet\DesktopAgent\build.ps1`
`dotnet publish ..\package\AgentPolicyConsent\DevolutionsAgentPolicyConsent.csproj -c Release -r win-x64 --self-contained`
`package-agent-windows.ps1 -Generate` with the arguments below
`package-assembled.ps1 agent` |
| Jetsocat | Windows/macOS/Linux | `build.ps1 jetsocat`
Jetsocat is not packaged. |
| Session | Windows/macOS/Linux | `build.ps1 session`
Session is not packaged. |
| PEDM module | Windows | `build.ps1 pedm` |
| PowerShell module | Windows | `copy-ps-module.ps1` |
| Desktop Agent | Windows | `..\dotnet\DesktopAgent\build.ps1` |
+## Agent Windows package arguments
+
+Pass every staged artifact to `package-agent-windows.ps1`.
+
+```powershell
+.\package-agent-windows.ps1 `
+ -Exe `
+ -UpdaterExe `
+ -PedmDll `
+ -PedmMsix `
+ -SessionExe `
+ -PolicyConsentHelper ..\package\AgentPolicyConsent\bin\Release\net10.0-windows\win-x64\publish\DevolutionsAgentPolicyConsent.exe `
+ -Architecture x64 `
+ -Outfile
+```
+
+For an assembled package, replace `-Outfile ` with `-Generate`.
+
## What is the difference between _Windows (regular)_ and _Windows (assembled)_?
_Windows (regular)_ is the "normal" build process where the MSI is built by WiX but not signed. This is used in _ci.yaml_. _Windows (assembled)_ is a two-step process where the `-Generate` flag is used to build supporting files for the MSI, including DLLs, language transforms, and _cmd_ scripts. The MSI is assembled in second step using _package-assembled.ps1_. The two-step approach is described [here](https://github.com/oleg-shilo/wixsharp/wiki/Developer's-Guide#compiling-wix-project).
diff --git a/ci/package-agent-windows.ps1 b/ci/package-agent-windows.ps1
index 4a37cc512..bb97553a5 100644
--- a/ci/package-agent-windows.ps1
+++ b/ci/package-agent-windows.ps1
@@ -11,6 +11,8 @@ param(
[parameter(Mandatory = $true)]
[string] $SessionExe,
[parameter(Mandatory = $true)]
+ [string] $PolicyConsentHelper,
+ [parameter(Mandatory = $true)]
[ValidateSet('x64', 'arm64')]
[string] $Architecture,
[string] $Outfile
@@ -43,8 +45,9 @@ function Set-FileNameAndCopy {
# If the name is already correct, return the original path without copying
if ($currName -ieq $NewName) {
- Write-Host "Using $Path without copying"
- return $Path
+ $resolvedPath = (Resolve-Path -LiteralPath $Path).Path
+ Write-Host "Using $resolvedPath without copying"
+ return $resolvedPath
}
# Copy to a temporary directory.
@@ -98,6 +101,9 @@ function New-AgentMsi() {
# The path to the devolutions-session.exe file.
[string] $SessionExe,
[parameter(Mandatory = $true)]
+ # The path to the signed DevolutionsAgentPolicyConsent.exe file.
+ [string] $PolicyConsentHelper,
+ [parameter(Mandatory = $true)]
[ValidateSet('x64', 'arm64')]
# Architecture: x64 or arm64
[string] $Architecture,
@@ -120,6 +126,7 @@ function New-AgentMsi() {
$PedmDll = Convert-Path -Path $PedmDll
$PedmMsix = Convert-Path -Path $PedmMsix
$SessionExe = Convert-Path -Path $SessionExe
+ $PolicyConsentHelper = Convert-Path -Path $PolicyConsentHelper
if ($Outfile) {
$Outfile = Convert-Path -Path $Outfile
}
@@ -137,6 +144,7 @@ function New-AgentMsi() {
$myUpdaterExe = Set-FileNameAndCopy -Path $UpdaterExe -NewName 'DevolutionsAgentUpdater.exe'
# The session is a service that gets launched on demand.
$mySessionExe = Set-FileNameAndCopy -Path $SessionExe -NewName 'DevolutionsSession.exe'
+ $myPolicyConsentHelper = Set-FileNameAndCopy -Path $PolicyConsentHelper -NewName 'DevolutionsAgentPolicyConsent.exe'
Write-Output "$repoDir\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe"
@@ -145,6 +153,7 @@ function New-AgentMsi() {
Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_DLL' $myPedmDll
Set-EnvVarPath 'DAGENT_PEDM_SHELL_EXT_MSIX' $myPedmMsix
Set-EnvVarPath 'DAGENT_SESSION_EXECUTABLE' $mySessionExe
+ Set-EnvVarPath 'DAGENT_POLICY_CONSENT_HELPER' $myPolicyConsentHelper
# The actual DevolutionsDesktopAgent.exe will be `\dotnet\DesktopAgent\bin\Release\net48\DevolutionsDesktopAgent.exe`.
# After install, the contents of `net48` will be copied to `C:\Program Files\Devolutions\Agent\desktop\`.
@@ -184,4 +193,4 @@ function New-AgentMsi() {
Pop-Location
}
-New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -Architecture $Architecture -Outfile $Outfile
+New-AgentMsi -Generate:($Generate.IsPresent) -Exe $Exe -UpdaterExe $UpdaterExe -PedmDll $PedmDll -PedmMsix $PedmMsix -SessionExe $SessionExe -PolicyConsentHelper $PolicyConsentHelper -Architecture $Architecture -Outfile $Outfile
diff --git a/crates/agent-policy-tester/Cargo.toml b/crates/agent-policy-tester/Cargo.toml
index ba2f20f77..69f0f8d67 100644
--- a/crates/agent-policy-tester/Cargo.toml
+++ b/crates/agent-policy-tester/Cargo.toml
@@ -12,6 +12,8 @@ fastrand = "2"
serde_json = "1"
tempfile = "3"
tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "time"] }
+win-api-wrappers = { path = "../win-api-wrappers" }
+windows = { version = "0.61", features = ["Win32_Security", "Win32_System_Threading"] }
[lints]
workspace = true
diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1
index 8520d10ec..f26337f00 100644
--- a/crates/agent-policy-tester/run-as-system.ps1
+++ b/crates/agent-policy-tester/run-as-system.ps1
@@ -4,11 +4,18 @@ $workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path
$testerPath = Join-Path $workspacePath "target/debug/agent-policy-tester.exe"
$agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe"
$outputPath = Join-Path $PSScriptRoot "agent-policy-tester.out"
-$stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))"
+$stagingPath = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))"
$stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe"
+$exitCode = 1
try {
Set-Content -LiteralPath $outputPath -Value ""
+ if (-not [System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) {
+ throw "This runner requires LocalSystem"
+ }
+ if ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($workspacePath)).DriveType -ne 'Fixed') {
+ throw "Use a local fixed-volume workspace path visible to LocalSystem, not a mapped drive"
+ }
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
@@ -61,7 +68,6 @@ public static class AgentPolicyTesterNativeDirectory
if (Get-ChildItem -LiteralPath $stagingPath -Force) {
throw "The atomically protected staged tester directory was not empty"
}
-
Copy-Item -LiteralPath $testerPath -Destination $stagedTesterPath
& icacls.exe $stagedTesterPath /setowner '*S-1-5-18' 2>&1 | Out-File $outputPath -Append
if ($LASTEXITCODE -ne 0) {
@@ -76,7 +82,7 @@ public static class AgentPolicyTesterNativeDirectory
"Staged policy tester at $stagedTesterPath" | Out-File $outputPath -Append
Get-Acl -LiteralPath $stagingPath | Format-List Owner, Sddl | Out-File $outputPath -Append
Get-Acl -LiteralPath $stagedTesterPath | Format-List Owner, Sddl | Out-File $outputPath -Append
- & $stagedTesterPath $agentPath 2>&1 | Out-File $outputPath -Append
+ & $stagedTesterPath $agentPath elevated 2>&1 | Out-File $outputPath -Append
$exitCode = $LASTEXITCODE
} catch {
$_ | Out-File $outputPath -Append
@@ -88,6 +94,7 @@ public static class AgentPolicyTesterNativeDirectory
} catch {
if ($attempt -eq 19) {
"Failed to remove $stagingPath after 20 attempts: $_" | Out-File $outputPath -Append
+ $exitCode = 1
} else {
Start-Sleep -Milliseconds 250
}
diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1
new file mode 100644
index 000000000..b6615393c
--- /dev/null
+++ b/crates/agent-policy-tester/run-unelevated.ps1
@@ -0,0 +1,664 @@
+param(
+ [ValidateSet("Orchestrate", "Stage", "Server", "Run", "Signal", "Cleanup", "SelfTest")]
+ [string] $Action = "Orchestrate",
+ [string] $TesterPath,
+ [string] $StagedTesterPath,
+ [string] $StagingPath,
+ [string] $AgentPath,
+ [string] $TempPath,
+ [string] $ReadyPath,
+ [string] $StopPath,
+ [string] $StatusPath,
+ [string] $ServerOutputPath,
+ [string] $Nonce,
+ [string] $ExpectedClientSid
+)
+
+$ErrorActionPreference = "Stop"
+
+function Test-ExplicitPsExecLaunchFailure {
+ param([string] $Diagnostics)
+
+ return $Diagnostics -match '(?im)^(Couldn''t install PSEXESVC service:|Error establishing communication with PsExec service|Access is denied\.)'
+}
+
+function Publish-ServerStatus {
+ param([string] $Path, [int] $ExitCode)
+
+ $temporaryPath = "$Path.$([guid]::NewGuid().ToString('N')).tmp"
+ try {
+ [System.IO.File]::WriteAllText($temporaryPath, $ExitCode.ToString([System.Globalization.CultureInfo]::InvariantCulture))
+ [System.IO.File]::Move($temporaryPath, $Path)
+ } finally {
+ if (Test-Path -LiteralPath $temporaryPath) {
+ Remove-Item -LiteralPath $temporaryPath -Force
+ }
+ }
+}
+
+function Read-ServerStatus {
+ param([string] $Path)
+
+ $text = [System.IO.File]::ReadAllText($Path)
+ $value = 0
+ if ($text -notmatch '^-?[0-9]+$' -or -not [int]::TryParse($text, [ref] $value)) {
+ throw "LocalSystem test server published an invalid completion status"
+ }
+ return $value
+}
+
+function Wait-ServerReadiness {
+ param(
+ [string] $Path,
+ [string] $ServerStatusPath,
+ [string] $ExpectedNonce,
+ [int] $TimeoutMilliseconds,
+ [int] $LaunchValue,
+ [string] $LaunchDiagnostics
+ )
+
+ if (Test-ExplicitPsExecLaunchFailure $LaunchDiagnostics) {
+ throw "LocalSystem test server launch failed (value $LaunchValue): $LaunchDiagnostics"
+ }
+
+ $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds)
+ while (-not (Test-Path -LiteralPath $Path)) {
+ if (Test-Path -LiteralPath $ServerStatusPath) {
+ $status = Read-ServerStatus -Path $ServerStatusPath
+ throw "LocalSystem test server exited with status $status before publishing readiness (launch value $LaunchValue): $LaunchDiagnostics"
+ }
+ if ([DateTime]::UtcNow -ge $deadline) {
+ throw "Timed out waiting for LocalSystem test server readiness (launch value $LaunchValue): $LaunchDiagnostics"
+ }
+ Start-Sleep -Milliseconds 100
+ }
+
+ $readiness = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
+ if ($readiness.Nonce -cne $ExpectedNonce) {
+ throw "LocalSystem test server readiness nonce mismatch"
+ }
+ if ([string]::IsNullOrWhiteSpace($readiness.PipeName)) {
+ throw "LocalSystem test server readiness has no pipe name"
+ }
+ if ($readiness.ServerPid -le 0 -or $readiness.AgentPid -le 0 -or $readiness.ServerPid -eq $readiness.AgentPid) {
+ throw "LocalSystem test server readiness has invalid process identities"
+ }
+ if ($readiness.ServerSid -cne 'S-1-5-18' -or $readiness.AgentSid -cne 'S-1-5-18') {
+ throw "LocalSystem test server readiness has non-SYSTEM identities"
+ }
+
+ return $readiness
+}
+
+function Remove-StagingPath {
+ param([string] $Path)
+
+ for ($attempt = 0; $attempt -lt 20 -and (Test-Path -LiteralPath $Path); $attempt++) {
+ try {
+ Remove-Item -LiteralPath $Path -Recurse -Force
+ } catch {
+ if ($attempt -eq 19) {
+ throw "Failed to remove $Path after 20 attempts: $_"
+ }
+ Start-Sleep -Milliseconds 250
+ }
+ }
+ if (Test-Path -LiteralPath $Path) {
+ throw "Failed to remove $Path"
+ }
+}
+
+function Complete-ServerShutdown {
+ param(
+ [bool] $ServerLaunchAttempted,
+ [bool] $ServerLaunchExplicitlyFailed,
+ [string] $StopPath,
+ [string] $StatusPath,
+ [string] $ServerOutputPath,
+ [string] $OutputPath,
+ [int] $ExitCode,
+ [scriptblock] $SignalServer
+ )
+
+ if (-not $ServerLaunchAttempted) {
+ return $ExitCode
+ }
+
+ $signal = & $SignalServer
+ $signal.Output | Out-File $OutputPath -Append
+ if ($signal.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $StopPath)) {
+ try {
+ New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null
+ "Created the stop marker directly after LocalSystem signaling did not confirm it" | Out-File $OutputPath -Append
+ } catch {
+ $_ | Out-File $OutputPath -Append
+ }
+ }
+ if (-not (Test-Path -LiteralPath $StopPath)) {
+ "Failed to create the LocalSystem test server stop marker" | Out-File $OutputPath -Append
+ $ExitCode = 1
+ }
+
+ if (-not $ServerLaunchExplicitlyFailed) {
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ while (-not (Test-Path -LiteralPath $StatusPath) -and [DateTime]::UtcNow -lt $deadline) {
+ Start-Sleep -Milliseconds 100
+ }
+ if (Test-Path -LiteralPath $ServerOutputPath) {
+ Get-Content -LiteralPath $ServerOutputPath | Out-File $OutputPath -Append
+ }
+ if (Test-Path -LiteralPath $StatusPath) {
+ try {
+ $serverExitCode = Read-ServerStatus -Path $StatusPath
+ if ($serverExitCode -ne 0 -and $ExitCode -eq 0) {
+ $ExitCode = $serverExitCode
+ }
+ } catch {
+ $_ | Out-File $OutputPath -Append
+ $ExitCode = 1
+ }
+ } else {
+ "Timed out waiting for LocalSystem test server shutdown" | Out-File $OutputPath -Append
+ $ExitCode = 1
+ }
+ }
+
+ return $ExitCode
+}
+
+function New-RandomSecurePassword {
+ $alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*"
+ $bytes = [byte[]]::new(32)
+ [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
+ $password = [System.Security.SecureString]::new()
+ foreach ($byte in $bytes) {
+ $password.AppendChar($alphabet[$byte % $alphabet.Length])
+ }
+ foreach ($required in "Aa1!".ToCharArray()) {
+ $password.AppendChar($required)
+ }
+ $password.MakeReadOnly()
+ return $password
+}
+
+function New-StandardUserAccount {
+ $name = "dgwpol$([guid]::NewGuid().ToString('N').Substring(0, 12))"
+ $password = New-RandomSecurePassword
+ try {
+ $user = New-LocalUser -Name $name -Password $password -AccountNeverExpires `
+ -PasswordNeverExpires -UserMayNotChangePassword `
+ -Description "Temporary Devolutions Agent policy E2E user"
+ $usersGroup = Get-LocalGroup -SID "S-1-5-32-545"
+ $isMember = Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop |
+ Where-Object { $_.SID.Value -eq $user.SID.Value }
+ if (-not $isMember) {
+ Add-LocalGroupMember -Group $usersGroup -Member $user -ErrorAction Stop
+ }
+ return [pscustomobject]@{
+ Name = $name
+ Sid = $user.SID.Value
+ Credential = [System.Management.Automation.PSCredential]::new(
+ $name,
+ $password
+ )
+ }
+ } catch {
+ Remove-LocalUser -Name $name -ErrorAction SilentlyContinue
+ $password.Dispose()
+ throw
+ }
+}
+
+function Set-StandardUserTempAcl {
+ param(
+ [string] $Path,
+ [string] $UserSid
+ )
+
+ New-Item -ItemType Directory -Path $Path -ErrorAction Stop | Out-Null
+ & icacls.exe $Path /inheritance:r /grant:r `
+ '*S-1-5-18:(OI)(CI)(F)' `
+ '*S-1-5-32-544:(OI)(CI)(F)' `
+ "*$($UserSid):(OI)(CI)(M)"
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to protect the standard-user temporary directory"
+ }
+}
+
+function Invoke-StandardUserClient {
+ param(
+ [System.Management.Automation.PSCredential] $Credential,
+ [string] $UserSid,
+ [string] $ClientTempPath,
+ [string] $ScriptPath,
+ [string] $TesterExecutablePath,
+ [string] $AgentExecutablePath,
+ [string] $ReadinessPath,
+ [string] $ExpectedNonce
+ )
+
+ $startInfo = [System.Diagnostics.ProcessStartInfo]::new()
+ $startInfo.FileName = (Get-Command pwsh.exe -CommandType Application).Source
+ $startInfo.UseShellExecute = $false
+ $startInfo.CreateNoWindow = $true
+ $startInfo.RedirectStandardOutput = $true
+ $startInfo.RedirectStandardError = $true
+ $startInfo.LoadUserProfile = $false
+ $startInfo.UserName = $Credential.UserName
+ $startInfo.Domain = "."
+ $startInfo.Password = $Credential.Password
+ $startInfo.WorkingDirectory = $ClientTempPath
+ $startInfo.Environment["TEMP"] = $ClientTempPath
+ $startInfo.Environment["TMP"] = $ClientTempPath
+ foreach ($argument in @(
+ "-NoProfile",
+ "-File",
+ $ScriptPath,
+ "-Action",
+ "Run",
+ "-StagedTesterPath",
+ $TesterExecutablePath,
+ "-AgentPath",
+ $AgentExecutablePath,
+ "-TempPath",
+ $ClientTempPath,
+ "-ReadyPath",
+ $ReadinessPath,
+ "-Nonce",
+ $ExpectedNonce,
+ "-ExpectedClientSid",
+ $UserSid
+ )) {
+ $startInfo.ArgumentList.Add($argument)
+ }
+
+ $process = [System.Diagnostics.Process]::new()
+ $process.StartInfo = $startInfo
+ try {
+ if (-not $process.Start()) {
+ throw "Failed to start the medium-integrity standard-user client"
+ }
+ $stdout = $process.StandardOutput.ReadToEndAsync()
+ $stderr = $process.StandardError.ReadToEndAsync()
+ if (-not $process.WaitForExit(60000)) {
+ $process.Kill($true)
+ $process.WaitForExit()
+ throw "Timed out waiting for the medium-integrity standard-user client"
+ }
+ return [pscustomobject]@{
+ ExitCode = $process.ExitCode
+ StdOut = $stdout.GetAwaiter().GetResult()
+ StdErr = $stderr.GetAwaiter().GetResult()
+ }
+ } finally {
+ $process.Dispose()
+ }
+}
+
+function Remove-StandardUserAccount {
+ param(
+ [string] $Name,
+ [string] $Sid
+ )
+
+ for ($attempt = 0; $attempt -lt 20; $attempt++) {
+ try {
+ $profile = Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$Sid'" -ErrorAction Stop
+ if ($profile) {
+ $profile | Remove-CimInstance -ErrorAction Stop
+ }
+ if (Get-LocalUser -Name $Name -ErrorAction SilentlyContinue) {
+ Remove-LocalUser -Name $Name -ErrorAction Stop
+ }
+ if (-not (Get-LocalUser -Name $Name -ErrorAction SilentlyContinue)) {
+ return
+ }
+ } catch {
+ if ($attempt -eq 19) {
+ throw
+ }
+ }
+ Start-Sleep -Milliseconds 250
+ }
+ throw "Temporary standard-user account still exists after 20 removal attempts"
+}
+
+function Invoke-RunnerSelfTests {
+ $root = Join-Path ([System.IO.Path]::GetTempPath()) "agent-policy-runner-$([guid]::NewGuid().ToString('N'))"
+ New-Item -ItemType Directory -Path $root | Out-Null
+ try {
+ $ready = Join-Path $root "ready.json"
+ $status = Join-Path $root "status"
+ $serverOutput = Join-Path $root "server.out"
+ foreach ($expected in @(0, 1, -1)) {
+ Publish-ServerStatus -Path $status -ExitCode $expected
+ if ((Read-ServerStatus -Path $status) -ne $expected) {
+ throw "Completion-status round trip failed"
+ }
+ Remove-Item -LiteralPath $status
+ }
+ foreach ($invalid in @("", " ", "failed", "2147483648", "0`n1")) {
+ [System.IO.File]::WriteAllText($status, $invalid)
+ try {
+ Read-ServerStatus -Path $status | Out-Null
+ throw "Invalid completion status unexpectedly succeeded"
+ } catch {
+ if ($_ -notmatch "published an invalid completion status") {
+ throw
+ }
+ }
+ Remove-Item -LiteralPath $status
+ }
+ Set-Content -LiteralPath $ready -Value (
+ @{
+ Nonce = "nonce"
+ PipeName = "\\.\pipe\test"
+ ServerPid = 100
+ ServerSid = "S-1-5-18"
+ AgentPid = 200
+ AgentSid = "S-1-5-18"
+ } | ConvertTo-Json -Compress
+ )
+ Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" `
+ -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null
+
+ Remove-Item -LiteralPath $ready
+ try {
+ Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" `
+ -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null
+ throw "Missing-readiness simulation unexpectedly succeeded"
+ } catch {
+ if (
+ $_ -notmatch "Timed out waiting for LocalSystem test server readiness" -or
+ $_ -notmatch "started detached process"
+ ) {
+ throw
+ }
+ }
+
+ try {
+ Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" `
+ -TimeoutMilliseconds 5000 -LaunchValue 6 `
+ -LaunchDiagnostics "Couldn't install PSEXESVC service: Access is denied." | Out-Null
+ throw "Explicit-launch-failure simulation unexpectedly succeeded"
+ } catch {
+ if (
+ $_ -notmatch "LocalSystem test server launch failed" -or
+ $_ -notmatch "Couldn't install PSEXESVC service"
+ ) {
+ throw
+ }
+ }
+
+ $launchAttempted = $true
+ Set-Content -LiteralPath $ready -Value (
+ @{
+ Nonce = "wrong-nonce"
+ PipeName = "\\.\pipe\test"
+ ServerPid = 100
+ ServerSid = "S-1-5-18"
+ AgentPid = 200
+ AgentSid = "S-1-5-18"
+ } | ConvertTo-Json -Compress
+ )
+ try {
+ Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" `
+ -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null
+ throw "Mismatched-readiness simulation unexpectedly succeeded"
+ } catch {
+ if ($_ -notmatch "readiness nonce mismatch" -or -not $launchAttempted) {
+ throw
+ }
+ }
+
+ Set-Content -LiteralPath $ready -Value (
+ @{
+ Nonce = "nonce"
+ PipeName = "\\.\pipe\test"
+ ServerPid = 100
+ ServerSid = "S-1-5-18"
+ AgentPid = 100
+ AgentSid = "S-1-5-18"
+ } | ConvertTo-Json -Compress
+ )
+ try {
+ Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" `
+ -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null
+ throw "Invalid-PID readiness simulation unexpectedly succeeded"
+ } catch {
+ if ($_ -notmatch "readiness has invalid process identities" -or -not $launchAttempted) {
+ throw
+ }
+ }
+
+ Remove-Item -LiteralPath $ready
+ try {
+ Wait-ServerReadiness -Path $ready -ServerStatusPath $status -ExpectedNonce "nonce" `
+ -TimeoutMilliseconds 50 -LaunchValue 6256 -LaunchDiagnostics "started detached process" | Out-Null
+ throw "Attempted-launch missing-readiness simulation unexpectedly succeeded"
+ } catch {
+ if ($_ -notmatch "Timed out waiting for LocalSystem test server readiness" -or -not $launchAttempted) {
+ throw
+ }
+ }
+
+ $stop = Join-Path $root "stop"
+ $shutdownOutput = Join-Path $root "shutdown.out"
+ $signalState = [pscustomobject]@{ Count = 0 }
+ [System.IO.File]::WriteAllText($status, "invalid")
+ $readinessFailureExitCode = Complete-ServerShutdown `
+ -ServerLaunchAttempted $true -ServerLaunchExplicitlyFailed $false `
+ -StopPath $stop -StatusPath $status -ServerOutputPath $serverOutput -OutputPath $shutdownOutput `
+ -ExitCode 1 -SignalServer {
+ $signalState.Count++
+ New-Item -ItemType File -Path $stop | Out-Null
+ [pscustomobject]@{ ExitCode = 0; Output = @("Simulated LocalSystem signal") }
+ }
+ if (
+ $readinessFailureExitCode -ne 1 -or
+ $signalState.Count -ne 1 -or
+ -not (Test-Path -LiteralPath $stop) -or
+ (Get-Content -LiteralPath $shutdownOutput -Raw) -notmatch "published an invalid completion status"
+ ) {
+ throw "Readiness failure did not signal and verify LocalSystem server shutdown"
+ }
+
+ Remove-StagingPath -Path (Join-Path $root "already-absent")
+ } finally {
+ Remove-StagingPath -Path $root
+ }
+}
+
+if ($Action -eq "SelfTest") {
+ Invoke-RunnerSelfTests
+ exit 0
+}
+
+if ($Action -eq "Run") {
+ $env:TEMP = $TempPath
+ $env:TMP = $TempPath
+ & $StagedTesterPath $AgentPath standard-client $ExpectedClientSid $ReadyPath $Nonce
+ exit $LASTEXITCODE
+}
+
+if ($Action -eq "Server") {
+ try {
+ & $StagedTesterPath $AgentPath standard-server $ReadyPath $StopPath $Nonce 2>&1 |
+ Out-File -LiteralPath $ServerOutputPath
+ $exitCode = $LASTEXITCODE
+ } catch {
+ $_ | Out-File -LiteralPath $ServerOutputPath -Append
+ $exitCode = 1
+ } finally {
+ Publish-ServerStatus -Path $StatusPath -ExitCode $exitCode
+ }
+ exit $exitCode
+}
+
+if ($Action -eq "Signal") {
+ if (-not (Test-Path -LiteralPath $StopPath)) {
+ New-Item -ItemType File -Path $StopPath -ErrorAction Stop | Out-Null
+ }
+ exit 0
+}
+
+if ($Action -eq "Cleanup") {
+ Remove-StagingPath -Path $StagingPath
+ exit 0
+}
+
+if ($Action -eq "Stage") {
+ Add-Type -TypeDefinition @'
+using System;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+
+public static class AgentPolicyStandardUserTesterDirectory
+{
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SecurityAttributes
+ {
+ internal int Length;
+ internal IntPtr SecurityDescriptor;
+ internal int InheritHandle;
+ }
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CreateDirectoryW(string path, ref SecurityAttributes securityAttributes);
+
+ public static void Create(string path, byte[] securityDescriptor)
+ {
+ GCHandle pinnedDescriptor = GCHandle.Alloc(securityDescriptor, GCHandleType.Pinned);
+ try
+ {
+ SecurityAttributes attributes = new SecurityAttributes
+ {
+ Length = Marshal.SizeOf(),
+ SecurityDescriptor = pinnedDescriptor.AddrOfPinnedObject(),
+ InheritHandle = 0,
+ };
+ if (!CreateDirectoryW(path, ref attributes))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error());
+ }
+ }
+ finally
+ {
+ pinnedDescriptor.Free();
+ }
+ }
+}
+'@
+ $directorySecurity = [System.Security.AccessControl.DirectorySecurity]::new()
+ $directorySecurity.SetSecurityDescriptorSddlForm(
+ 'O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)'
+ )
+ [AgentPolicyStandardUserTesterDirectory]::Create(
+ $StagingPath,
+ $directorySecurity.GetSecurityDescriptorBinaryForm()
+ )
+ if (Get-ChildItem -LiteralPath $StagingPath -Force) {
+ throw "The atomically protected staged tester directory was not empty"
+ }
+
+ Copy-Item -LiteralPath $TesterPath -Destination $StagedTesterPath
+ & icacls.exe $StagedTesterPath /setowner '*S-1-5-18'
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to set the staged tester owner"
+ }
+ & icacls.exe $StagedTesterPath /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' '*S-1-5-32-545:(RX)'
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to protect the staged tester executable"
+ }
+ Get-Acl -LiteralPath $StagingPath | Format-List Owner, Sddl
+ Get-Acl -LiteralPath $StagedTesterPath | Format-List Owner, Sddl
+ exit 0
+}
+
+$workspacePath = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path
+$testerPath = Join-Path $workspacePath "target/debug/agent-policy-tester.exe"
+$agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe"
+$outputPath = Join-Path $PSScriptRoot "agent-policy-tester-unelevated.out"
+$stagingPath = Join-Path $env:ProgramData "dgw-agent-policy-tester-$([guid]::NewGuid().ToString('N'))"
+$stagedTesterPath = Join-Path $stagingPath "agent-policy-tester.exe"
+$readyPath = Join-Path $stagingPath "standard-user-ready.json"
+$stopPath = Join-Path $stagingPath "standard-user-stop"
+$statusPath = Join-Path $stagingPath "standard-user-server.status"
+$serverOutputPath = Join-Path $stagingPath "standard-user-server.out"
+$nonce = [guid]::NewGuid().ToString("N")
+$exitCode = 1
+$serverLaunchAttempted = $false
+$serverLaunchExplicitlyFailed = $false
+$clientAccount = $null
+
+try {
+ Invoke-RunnerSelfTests
+ Set-Content -LiteralPath $outputPath -Value ""
+
+ $stageOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath `
+ -Action Stage -TesterPath $testerPath -StagedTesterPath $stagedTesterPath -StagingPath $stagingPath 2>&1
+ $stageExitCode = $LASTEXITCODE
+ $stageOutput | Out-File $outputPath -Append
+ if ($stageExitCode -ne 0) {
+ throw "LocalSystem tester staging failed with exit code $stageExitCode"
+ }
+
+ $clientAccount = New-StandardUserAccount
+ $clientTempPath = Join-Path $stagingPath "standard-user-temp"
+ Set-StandardUserTempAcl -Path $clientTempPath -UserSid $clientAccount.Sid
+
+ $serverLaunchAttempted = $true
+ $serverOutput = & psexec.exe -accepteula -s -d pwsh.exe -NoProfile -File $PSCommandPath `
+ -Action Server -StagedTesterPath $stagedTesterPath -AgentPath $agentPath -ReadyPath $readyPath `
+ -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -Nonce $nonce 2>&1
+ $serverStartExitCode = $LASTEXITCODE
+ $serverOutput | Out-File $outputPath -Append
+ "Detached server launch value: $serverStartExitCode" | Out-File $outputPath -Append
+ $serverLaunchDiagnostics = ($serverOutput | Out-String).Trim()
+ $serverLaunchExplicitlyFailed = Test-ExplicitPsExecLaunchFailure $serverLaunchDiagnostics
+ $readiness = Wait-ServerReadiness -Path $readyPath -ServerStatusPath $statusPath -ExpectedNonce $nonce `
+ -TimeoutMilliseconds 30000 -LaunchValue $serverStartExitCode -LaunchDiagnostics $serverLaunchDiagnostics
+ $readiness | ConvertTo-Json -Compress | Out-File $outputPath -Append
+
+ $client = Invoke-StandardUserClient -Credential $clientAccount.Credential -UserSid $clientAccount.Sid `
+ -ClientTempPath $clientTempPath -ScriptPath $PSCommandPath -TesterExecutablePath $stagedTesterPath `
+ -AgentExecutablePath $agentPath -ReadinessPath $readyPath -ExpectedNonce $nonce
+ $client.StdOut | Out-File $outputPath -Append
+ $client.StdErr | Out-File $outputPath -Append
+ $exitCode = $client.ExitCode
+} catch {
+ $_ | Out-File $outputPath -Append
+ $exitCode = 1
+} finally {
+ $exitCode = Complete-ServerShutdown `
+ -ServerLaunchAttempted $serverLaunchAttempted -ServerLaunchExplicitlyFailed $serverLaunchExplicitlyFailed `
+ -StopPath $stopPath -StatusPath $statusPath -ServerOutputPath $serverOutputPath -OutputPath $outputPath `
+ -ExitCode $exitCode -SignalServer {
+ $signalOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath `
+ -Action Signal -StopPath $stopPath 2>&1
+ [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $signalOutput }
+ }
+
+ if ($clientAccount) {
+ try {
+ Remove-StandardUserAccount -Name $clientAccount.Name -Sid $clientAccount.Sid
+ } catch {
+ $_ | Out-File $outputPath -Append
+ if ($exitCode -eq 0) {
+ $exitCode = 1
+ }
+ } finally {
+ $clientAccount.Credential.Password.Dispose()
+ }
+ }
+
+ $cleanupOutput = & psexec.exe -accepteula -s pwsh.exe -NoProfile -File $PSCommandPath `
+ -Action Cleanup -StagingPath $stagingPath 2>&1
+ $cleanupExitCode = $LASTEXITCODE
+ $cleanupOutput | Out-File $outputPath -Append
+ if ($cleanupExitCode -ne 0 -and $exitCode -eq 0) {
+ $exitCode = $cleanupExitCode
+ }
+}
+
+exit $exitCode
diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs
index 26ac51f30..c7511892d 100644
--- a/crates/agent-policy-tester/src/windows.rs
+++ b/crates/agent-policy-tester/src/windows.rs
@@ -1,3 +1,6 @@
+use std::fs::OpenOptions;
+use std::io::Write as _;
+use std::mem::size_of;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
@@ -6,14 +9,27 @@ use anyhow::{Context as _, bail, ensure};
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::windows::named_pipe::ClientOptions;
+use win_api_wrappers::identity::sid::Sid;
+use win_api_wrappers::process::Process;
+use windows::Win32::Foundation::{CloseHandle, HANDLE};
+use windows::Win32::Security::{
+ GetSidSubAuthority, GetSidSubAuthorityCount, GetTokenInformation, TOKEN_DUPLICATE, TOKEN_MANDATORY_LABEL,
+ TOKEN_QUERY, TokenIntegrityLevel, WinBuiltinAdministratorsSid, WinLocalSystemSid,
+};
+use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION};
const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json");
+const MANAGED_POLICY_RELATIVE_PATH: &str = r"Devolutions\PackageBroker\package-broker-policy.json";
+#[cfg(test)]
+const SECURITY_MANDATORY_LOW_RID: u32 = 0x1000;
+const SECURITY_MANDATORY_MEDIUM_RID: u32 = 0x2000;
struct AgentHarness {
child: tokio::process::Child,
- _data_dir: tempfile::TempDir,
+ data_dir: tempfile::TempDir,
pipe_name: String,
policy_path: PathBuf,
+ program_data: Option,
}
impl AgentHarness {
@@ -34,17 +50,43 @@ impl AgentHarness {
Self::start_with_path(agent_path, data_dir, pipe_name, policy_path).await
}
+ async fn start_unelevated(agent_path: &Path) -> anyhow::Result {
+ let data_dir = tempfile::tempdir().context("create unelevated Agent data directory")?;
+ let pipe_name = unique_pipe_name();
+ let policy_path = data_dir.path().join("policy.json");
+ Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, false).await
+ }
+
+ async fn start_managed_default(agent_path: &Path) -> anyhow::Result {
+ let data_dir = create_data_dir()?;
+ let pipe_name = unique_pipe_name();
+ let policy_path = data_dir.path().join(MANAGED_POLICY_RELATIVE_PATH);
+ Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, true).await
+ }
+
async fn start_with_path(
agent_path: &Path,
data_dir: tempfile::TempDir,
pipe_name: String,
policy_path: PathBuf,
) -> anyhow::Result {
+ Self::start_with_options(agent_path, data_dir, pipe_name, policy_path, false).await
+ }
+
+ async fn start_with_options(
+ agent_path: &Path,
+ data_dir: tempfile::TempDir,
+ pipe_name: String,
+ policy_path: PathBuf,
+ use_managed_default: bool,
+ ) -> anyhow::Result {
+ let policy_path_config = (!use_managed_default).then_some(&policy_path);
let config = json!({
+ "LogFile": data_dir.path().join("agent-e2e"),
"PackageBroker": {
"Enabled": true,
"PipeName": pipe_name,
- "PolicyPath": policy_path,
+ "PolicyPath": policy_path_config,
},
"__debug__": {
"skip_broker_signature_validation": true,
@@ -53,26 +95,35 @@ impl AgentHarness {
std::fs::write(data_dir.path().join("agent.json"), serde_json::to_vec_pretty(&config)?)
.context("write Agent configuration")?;
- let child = tokio::process::Command::new(agent_path)
- .env("DAGENT_CONFIG_PATH", data_dir.path())
- .arg("run")
- .kill_on_drop(true)
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .spawn()
- .context("start Devolutions Agent")?;
+ let program_data = use_managed_default.then(|| data_dir.path().to_owned());
+ let child = Self::spawn(agent_path, data_dir.path(), program_data.as_deref())?;
let mut harness = Self {
child,
- _data_dir: data_dir,
+ data_dir,
pipe_name,
policy_path,
+ program_data,
};
harness.wait_until_ready().await?;
Ok(harness)
}
+ fn spawn(agent_path: &Path, data_dir: &Path, program_data: Option<&Path>) -> anyhow::Result {
+ let mut command = tokio::process::Command::new(agent_path);
+ command
+ .env("DAGENT_CONFIG_PATH", data_dir)
+ .arg("run")
+ .kill_on_drop(true)
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+ if let Some(program_data) = program_data {
+ command.env("ProgramData", program_data);
+ }
+ command.spawn().context("start Devolutions Agent")
+ }
+
async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
let deadline = Instant::now() + Duration::from_secs(20);
@@ -89,6 +140,34 @@ impl AgentHarness {
}
}
}
+
+ async fn restart(&mut self, agent_path: &Path) -> anyhow::Result<()> {
+ self.stop().await?;
+ self.start_again(agent_path).await
+ }
+
+ async fn stop(&mut self) -> anyhow::Result<()> {
+ self.child.start_kill().context("stop Devolutions Agent")?;
+ self.child.wait().await.context("wait for Devolutions Agent to stop")?;
+ Ok(())
+ }
+
+ async fn start_again(&mut self, agent_path: &Path) -> anyhow::Result<()> {
+ self.child = Self::spawn(agent_path, self.data_dir.path(), self.program_data.as_deref())?;
+ self.wait_until_ready().await
+ }
+
+ fn logs(&self) -> anyhow::Result {
+ let mut logs = String::new();
+ for entry in std::fs::read_dir(self.data_dir.path()).context("read Agent log directory")? {
+ let entry = entry.context("read Agent log entry")?;
+ let name = entry.file_name();
+ if name.to_string_lossy().starts_with("agent-e2e") {
+ logs.push_str(&std::fs::read_to_string(entry.path()).context("read Agent log")?);
+ }
+ }
+ Ok(logs)
+ }
}
impl Drop for AgentHarness {
@@ -108,25 +187,222 @@ impl HttpResponse {
}
}
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Mode {
+ StandardServer,
+ StandardClient,
+ Elevated,
+}
+
+impl Mode {
+ fn parse(value: &str) -> anyhow::Result {
+ match value {
+ "standard-server" => Ok(Self::StandardServer),
+ "standard-client" => Ok(Self::StandardClient),
+ "elevated" => Ok(Self::Elevated),
+ _ => bail!("unknown mode '{value}'; expected 'standard-server', 'standard-client', or 'elevated'"),
+ }
+ }
+}
+
pub(crate) async fn run() -> anyhow::Result<()> {
- let agent_path = std::env::args_os()
- .nth(1)
+ let mut args = std::env::args_os().skip(1);
+ let agent_path = args
+ .next()
.map(PathBuf::from)
- .context("usage: agent-policy-tester ")?;
+ .context("usage: agent-policy-tester [mode arguments]")?;
+ let mode = args
+ .next()
+ .and_then(|value| value.into_string().ok())
+ .context("test mode is required")
+ .and_then(|value| Mode::parse(&value))?;
+ match mode {
+ Mode::StandardServer => {
+ verify_local_system()?;
+ ensure_agent_path(&agent_path)?;
+ let ready_path = next_path(&mut args, "ready path")?;
+ let stop_path = next_path(&mut args, "stop path")?;
+ let nonce = next_string(&mut args, "coordination nonce")?;
+ ensure!(args.next().is_none(), "unexpected standard-server arguments");
+ standard_user_server(&agent_path, &ready_path, &stop_path, &nonce).await?;
+ }
+ Mode::StandardClient => {
+ let expected_sid = next_string(&mut args, "expected client SID")?;
+ let client = verify_standard_user(&expected_sid)?;
+ let ready_path = next_path(&mut args, "ready path")?;
+ let nonce = next_string(&mut args, "coordination nonce")?;
+ ensure!(args.next().is_none(), "unexpected standard-client arguments");
+ standard_user_management(&ready_path, &nonce, &client).await?;
+ }
+ Mode::Elevated => {
+ verify_local_system()?;
+ ensure_agent_path(&agent_path)?;
+ ensure!(args.next().is_none(), "unexpected elevated arguments");
+ unavailable_policy_and_method_restrictions(&agent_path).await?;
+ complete_snapshots_across_reload(&agent_path).await?;
+ redirected_policy_paths_fail_closed(&agent_path).await?;
+ management_write_tokens_survive_watcher_reload(&agent_path).await?;
+ managed_policy_lifecycle(&agent_path).await?;
+ interrupted_malformed_repair(&agent_path).await?;
+ }
+ }
+
+ Ok(())
+}
+
+fn ensure_agent_path(agent_path: &Path) -> anyhow::Result<()> {
ensure!(
agent_path.is_file(),
"agent executable does not exist: {}",
agent_path.display()
);
+ Ok(())
+}
- unavailable_policy_and_method_restrictions(&agent_path).await?;
- complete_snapshots_across_reload(&agent_path).await?;
- redirected_policy_paths_fail_closed(&agent_path).await?;
- management_write_tokens_survive_watcher_reload(&agent_path).await?;
+struct ProcessIdentity {
+ pid: u32,
+ sid: Sid,
+}
+fn current_process_identity() -> anyhow::Result<(ProcessIdentity, bool, bool)> {
+ let token = Process::current_process()
+ .token(TOKEN_QUERY | TOKEN_DUPLICATE)
+ .context("open tester process token")?;
+ let administrators =
+ Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("construct Administrators SID")?;
+ let is_administrator = token
+ .is_member(&administrators)
+ .context("query tester Administrators membership")?;
+ let identity = ProcessIdentity {
+ pid: std::process::id(),
+ sid: token.sid_and_attributes().context("query tester user SID")?.sid,
+ };
+ Ok((
+ identity,
+ is_administrator,
+ token.is_elevated().context("query tester token elevation")?,
+ ))
+}
+
+fn verify_standard_user(expected_sid: &str) -> anyhow::Result {
+ let (identity, is_administrator, _) = current_process_identity()?;
+ validate_standard_user_token(
+ &identity.sid.to_string(),
+ expected_sid,
+ is_administrator,
+ current_integrity_level()?,
+ )?;
+ Ok(identity)
+}
+
+fn validate_standard_user_token(
+ actual_sid: &str,
+ expected_sid: &str,
+ is_administrator: bool,
+ integrity_level: u32,
+) -> anyhow::Result<()> {
+ ensure!(actual_sid == expected_sid, "standard-client account SID mismatch");
+ ensure!(
+ !is_administrator,
+ "standard-client mode requires disabled Administrators membership"
+ );
+ ensure!(
+ integrity_level == SECURITY_MANDATORY_MEDIUM_RID,
+ "standard-client mode requires Medium integrity, got RID {integrity_level:#x}"
+ );
Ok(())
}
+fn current_integrity_level() -> anyhow::Result {
+ let mut token = HANDLE::default();
+ // SAFETY: `GetCurrentProcess` has no preconditions and returns a process pseudo-handle.
+ let process = unsafe { GetCurrentProcess() };
+ // SAFETY: The process pseudo-handle is valid and `token` is a writable output parameter.
+ unsafe {
+ OpenProcessToken(process, TOKEN_QUERY, &mut token).context("open current process integrity token")?;
+ }
+ let result = integrity_level(token);
+ // SAFETY: `OpenProcessToken` returned this owned token handle.
+ unsafe {
+ CloseHandle(token).context("close current process integrity token")?;
+ }
+ result
+}
+
+fn integrity_level(token: HANDLE) -> anyhow::Result {
+ let mut length = 0;
+ // SAFETY: A null output buffer with length zero is the documented size query.
+ let _ = unsafe { GetTokenInformation(token, TokenIntegrityLevel, None, 0, &mut length) };
+ ensure!(
+ usize::try_from(length)? >= size_of::(),
+ "TokenIntegrityLevel returned an undersized buffer"
+ );
+
+ let word_count = usize::try_from(length)?.div_ceil(size_of::());
+ let mut buffer = vec![0usize; word_count];
+ // SAFETY: The aligned buffer is writable for `length` bytes and the token handle is valid.
+ unsafe {
+ GetTokenInformation(
+ token,
+ TokenIntegrityLevel,
+ Some(buffer.as_mut_ptr().cast()),
+ length,
+ &mut length,
+ )
+ .context("query token integrity level")?;
+ }
+ // SAFETY: A successful TokenIntegrityLevel query initialized a TOKEN_MANDATORY_LABEL.
+ let label = unsafe { &*buffer.as_ptr().cast::() };
+ // SAFETY: The returned token label contains a valid SID.
+ let sub_authority_count_ptr = unsafe { GetSidSubAuthorityCount(label.Label.Sid) };
+ // SAFETY: `GetSidSubAuthorityCount` returns a pointer into the valid label SID.
+ let sub_authority_count = unsafe { *sub_authority_count_ptr };
+ ensure!(sub_authority_count > 0, "integrity SID has no sub-authority");
+ // SAFETY: The index is within the validated SID sub-authority count.
+ let rid_ptr = unsafe { GetSidSubAuthority(label.Label.Sid, u32::from(sub_authority_count - 1)) };
+ // SAFETY: `GetSidSubAuthority` returns a pointer into the valid label SID.
+ let rid = unsafe { *rid_ptr };
+ Ok(rid)
+}
+
+fn verify_local_system() -> anyhow::Result {
+ let (identity, is_administrator, is_elevated) = current_process_identity()?;
+ let system = Sid::from_well_known(WinLocalSystemSid, None).context("construct LocalSystem SID")?;
+ ensure!(identity.sid == system, "this mode requires the LocalSystem account");
+ ensure!(is_administrator && is_elevated, "LocalSystem token is not elevated");
+ Ok(identity)
+}
+
+fn process_sid(pid: u32) -> anyhow::Result {
+ Process::get_by_pid(pid, PROCESS_QUERY_LIMITED_INFORMATION)
+ .with_context(|| format!("open process {pid}"))?
+ .token(TOKEN_QUERY)
+ .with_context(|| format!("open process {pid} token"))?
+ .sid_and_attributes()
+ .with_context(|| format!("query process {pid} SID"))
+ .map(|identity| identity.sid)
+}
+
+fn next_path(args: &mut impl Iterator- , name: &str) -> anyhow::Result {
+ args.next()
+ .map(PathBuf::from)
+ .with_context(|| format!("missing {name}"))
+}
+
+fn next_string(args: &mut impl Iterator
- , name: &str) -> anyhow::Result {
+ args.next()
+ .and_then(|value| value.into_string().ok())
+ .with_context(|| format!("missing or non-Unicode {name}"))
+}
+
+fn unique_pipe_name() -> String {
+ format!(
+ r"\\.\pipe\Devolutions.Now.PackageBroker.tests.{}.{}",
+ std::process::id(),
+ fastrand::u64(..)
+ )
+}
+
async fn request(pipe_name: &str, method: &str, path: &str) -> anyhow::Result {
request_with_body(pipe_name, method, path, None, &[]).await
}
@@ -205,7 +481,49 @@ fn policy_draft(id: &str, publisher: &str) -> Value {
"PolicyFormatVersion": "1.0.0",
"Metadata": { "Id": id, "Publisher": publisher },
"Enforcement": { "DefaultDecision": "Deny" },
- "Rules": []
+ "Rules": [
+ {
+ "Id": "allow.exact",
+ "Priority": 100,
+ "Decision": "Allow",
+ "Match": {
+ "Operations": ["Install"],
+ "Managers": ["Winget"],
+ "SourceNames": ["winget"],
+ "PackageIdentifiers": { "Exact": ["Microsoft.PowerToys"] },
+ "Version": { "Exact": ["0.86.0"] },
+ "ExecutionElevation": ["Elevated"],
+ "Interactive": true,
+ "SkipHashCheck": false
+ },
+ "Constraints": {
+ "AllowInteractive": true,
+ "AllowSkipHashCheck": false
+ }
+ },
+ {
+ "Id": "allow.pattern",
+ "Priority": 101,
+ "Decision": "Allow",
+ "Match": {
+ "Managers": ["Winget"],
+ "SourceNames": ["winget"],
+ "PackageIdentifiers": { "Patterns": ["Contoso.*"] },
+ "Version": {
+ "Range": {
+ "MinVersion": "1.0.0",
+ "MaxVersion": "2.0.0",
+ "IncludePrerelease": false
+ }
+ },
+ "ExecutionElevation": ["Standard"],
+ "HasCustomParameters": false
+ },
+ "Constraints": {
+ "AllowCustomParameters": false
+ }
+ }
+ ]
})
}
@@ -307,7 +625,6 @@ async fn assert_redirected_policy_rejected(
"ExpectedStoreToken": management["Management"]["StoreToken"],
"Operation": "Repair",
"ConflictHandling": "Reject",
- "WarningsAcknowledged": false,
"Draft": full_policy(),
"ValidationReceipt": "invalid"
});
@@ -352,7 +669,11 @@ async fn redirected_policy_paths_fail_closed(agent_path: &Path) -> anyhow::Resul
}
async fn policy_management(agent: &AgentHarness) -> anyhow::Result {
- let response = request(&agent.pipe_name, "GET", "/v1/policy/management").await?;
+ policy_management_by_pipe(&agent.pipe_name).await
+}
+
+async fn policy_management_by_pipe(pipe_name: &str) -> anyhow::Result {
+ let response = request(pipe_name, "GET", "/v1/policy/management").await?;
ensure!(
response.status == 200,
"GET /v1/policy/management returned HTTP {}",
@@ -361,19 +682,14 @@ async fn policy_management(agent: &AgentHarness) -> anyhow::Result {
Ok(response.json()?["Management"].clone())
}
-async fn replace_policy(
- agent: &AgentHarness,
- operation: &str,
- expected_store_token: Value,
- draft: Value,
-) -> anyhow::Result {
+async fn validate_policy_by_pipe(pipe_name: &str, draft: &Value) -> anyhow::Result {
let validation_request = json!({
"RequestKind": "PolicyValidationRequest",
"RequestVersion": "1.0",
"Draft": draft
});
let validation_response = request_with_body(
- &agent.pipe_name,
+ pipe_name,
"POST",
"/v1/policy/validate",
Some("application/json"),
@@ -387,24 +703,72 @@ async fn replace_policy(
);
let validation = validation_response.json()?["Validation"].clone();
ensure!(validation["IsValid"] == true, "policy validation failed");
+ ensure!(
+ validation["ValidatorVersion"] == "now-package-broker-policy-validator/10",
+ "unexpected validator contract"
+ );
+ ensure!(
+ validation["CanonicalDraft"]["PolicyFormatVersion"] == draft["PolicyFormatVersion"],
+ "canonical draft changed the format version"
+ );
+ Ok(validation)
+}
+
+async fn send_replacement(pipe_name: &str, replacement: &Value) -> anyhow::Result {
+ request_with_body(
+ pipe_name,
+ "PUT",
+ "/v1/policy",
+ Some("application/json"),
+ &serde_json::to_vec(replacement)?,
+ )
+ .await
+}
+
+async fn replace_policy_response(
+ agent: &AgentHarness,
+ operation: &str,
+ conflict_handling: &str,
+ expected_store_token: Value,
+ draft: Value,
+) -> anyhow::Result {
+ replace_policy_response_by_pipe(
+ &agent.pipe_name,
+ operation,
+ conflict_handling,
+ expected_store_token,
+ draft,
+ )
+ .await
+}
+
+async fn replace_policy_response_by_pipe(
+ pipe_name: &str,
+ operation: &str,
+ conflict_handling: &str,
+ expected_store_token: Value,
+ draft: Value,
+) -> anyhow::Result {
+ let validation = validate_policy_by_pipe(pipe_name, &draft).await?;
let replacement_request = json!({
"RequestKind": "PolicyReplacementRequest",
"RequestVersion": "1.0",
"ExpectedStoreToken": expected_store_token,
"Operation": operation,
- "ConflictHandling": "Reject",
- "WarningsAcknowledged": true,
+ "ConflictHandling": conflict_handling,
"Draft": validation["CanonicalDraft"],
"ValidationReceipt": validation["ValidationReceipt"]
});
- let response = request_with_body(
- &agent.pipe_name,
- "PUT",
- "/v1/policy",
- Some("application/json"),
- &serde_json::to_vec(&replacement_request)?,
- )
- .await?;
+ send_replacement(pipe_name, &replacement_request).await
+}
+
+async fn replace_policy(
+ agent: &AgentHarness,
+ operation: &str,
+ expected_store_token: Value,
+ draft: Value,
+) -> anyhow::Result {
+ let response = replace_policy_response(agent, operation, "Reject", expected_store_token, draft).await?;
ensure!(response.status == 200, "{operation} returned HTTP {}", response.status);
response.json()
}
@@ -461,6 +825,496 @@ async fn management_write_tokens_survive_watcher_reload(agent_path: &Path) -> an
Ok(())
}
+async fn standard_user_server(
+ agent_path: &Path,
+ ready_path: &Path,
+ stop_path: &Path,
+ nonce: &str,
+) -> anyhow::Result<()> {
+ ensure!(!ready_path.exists(), "standard-user readiness path already exists");
+ ensure!(!stop_path.exists(), "standard-user stop path already exists");
+
+ let mut agent = AgentHarness::start_unelevated(agent_path).await?;
+ let server = verify_local_system()?;
+ let agent_pid = agent.child.id().context("Agent process has no PID")?;
+ let child_sid = process_sid(agent_pid)?;
+ ensure!(
+ child_sid == server.sid,
+ "Agent and test server must both run as LocalSystem"
+ );
+
+ let readiness = serde_json::to_vec(&json!({
+ "Nonce": nonce,
+ "PipeName": agent.pipe_name,
+ "ServerPid": server.pid,
+ "ServerSid": server.sid.to_string(),
+ "AgentPid": agent_pid,
+ "AgentSid": child_sid.to_string(),
+ }))?;
+ let ready_temp_path = ready_path.with_extension("tmp");
+ let mut ready_file = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(&ready_temp_path)
+ .context("create standard-user readiness temporary file")?;
+ ready_file
+ .write_all(&readiness)
+ .context("write standard-user readiness temporary file")?;
+ ready_file
+ .sync_all()
+ .context("flush standard-user readiness temporary file")?;
+ drop(ready_file);
+ std::fs::rename(&ready_temp_path, ready_path).context("publish standard-user readiness file")?;
+
+ let deadline = Instant::now() + Duration::from_secs(90);
+ while !stop_path.exists() {
+ if let Some(status) = agent.child.try_wait().context("query Agent status")? {
+ bail!("Agent exited during standard-user test with {status}");
+ }
+ ensure!(
+ Instant::now() < deadline,
+ "timed out waiting for standard-user client completion"
+ );
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+
+ let result = wait_for_log(&agent, "Policy management write denied").await;
+ agent.stop().await?;
+ result
+}
+
+async fn standard_user_management(ready_path: &Path, nonce: &str, client: &ProcessIdentity) -> anyhow::Result<()> {
+ let readiness: Value =
+ serde_json::from_slice(&std::fs::read(ready_path).context("read standard-user readiness file")?)
+ .context("parse standard-user readiness file")?;
+ ensure!(readiness["Nonce"] == nonce, "standard-user readiness nonce mismatch");
+ let pipe_name = readiness["PipeName"]
+ .as_str()
+ .context("readiness file has no pipe name")?;
+ let server_pid = readiness["ServerPid"]
+ .as_u64()
+ .and_then(|pid| u32::try_from(pid).ok())
+ .context("readiness file has no valid server PID")?;
+ let agent_pid = readiness["AgentPid"]
+ .as_u64()
+ .and_then(|pid| u32::try_from(pid).ok())
+ .context("readiness file has no valid Agent PID")?;
+ let system = Sid::from_well_known(WinLocalSystemSid, None)
+ .context("construct LocalSystem SID")?
+ .to_string();
+ ensure!(
+ readiness["ServerSid"] == system && readiness["AgentSid"] == system,
+ "test server and Agent identities were not recorded as LocalSystem"
+ );
+ ensure!(
+ client.pid != server_pid && client.pid != agent_pid && server_pid != agent_pid,
+ "standard-user client, test server, and Agent must be distinct processes"
+ );
+ ensure!(
+ client.sid.to_string() != system,
+ "standard-user client unexpectedly uses the server identity"
+ );
+
+ let management = policy_management_by_pipe(pipe_name).await?;
+ ensure!(management["State"] == "Missing", "expected a missing policy");
+ let missing_policy = request(pipe_name, "GET", "/v1/policy").await?;
+ ensure!(
+ missing_policy.status == 404
+ && missing_policy.json()?["Code"] == "NotFound"
+ && missing_policy.json()?["Message"] == "active policy is unavailable",
+ "standard-user missing policy read did not return the canonical not-found response"
+ );
+
+ let valid_draft = policy_draft("tests.standard-user", "Test");
+ let validation = validate_policy_by_pipe(pipe_name, &valid_draft).await?;
+ ensure!(
+ validation["CanonicalDraft"].is_object() && validation["ValidationReceipt"].is_string(),
+ "valid draft did not produce a canonical draft and receipt"
+ );
+
+ strict_contract_validation(pipe_name).await?;
+
+ for operation in ["Create", "Update", "Repair", "ReplaceIdentity"] {
+ let denied = replace_policy_response_by_pipe(
+ pipe_name,
+ operation,
+ "Reject",
+ management["StoreToken"].clone(),
+ valid_draft.clone(),
+ )
+ .await?;
+ ensure!(
+ denied.status == 403 && denied.json()?["Code"] == "AdministratorRequired",
+ "standard-user {operation} did not require an administrator"
+ );
+ }
+ ensure!(
+ policy_management_by_pipe(pipe_name).await?["StoreToken"] == management["StoreToken"],
+ "denied writes changed the store token"
+ );
+ Ok(())
+}
+
+async fn strict_contract_validation(pipe_name: &str) -> anyhow::Result<()> {
+ for version in ["1.0.0", "1.7.3"] {
+ let mut draft = policy_draft("tests.contract", "Contract");
+ draft["PolicyFormatVersion"] = json!(version);
+ let validation = validate_policy_by_pipe(pipe_name, &draft).await?;
+ let canonical = &validation["CanonicalDraft"];
+ ensure!(
+ canonical["Rules"][0]["Match"]["Managers"] == json!(["Winget"])
+ && canonical["Rules"][0]["Match"]["SourceNames"] == json!(["winget"])
+ && canonical["Rules"][0]["Match"]["PackageIdentifiers"]["Exact"] == json!(["Microsoft.PowerToys"])
+ && canonical["Rules"][0]["Match"]["Version"]["Exact"] == json!(["0.86.0"])
+ && canonical["Rules"][0]["Match"]["ExecutionElevation"] == json!(["Elevated"])
+ && canonical["Rules"][1]["Match"]["PackageIdentifiers"]["Patterns"] == json!(["Contoso.*"])
+ && canonical["Rules"][1]["Match"]["Version"]["Range"]["MinVersion"] == "1.0.0"
+ && canonical["Rules"][1]["Match"]["ExecutionElevation"] == json!(["Standard"]),
+ "canonical draft did not preserve final rule shapes"
+ );
+ ensure!(
+ canonical["Rules"][0]["Match"].get("PreRelease").is_none()
+ && canonical["Rules"][1]["Match"].get("Interactive").is_none(),
+ "canonical draft did not omit absent optional characteristics"
+ );
+ }
+ for (value, expected_path) in [("2.0.0", "/PolicyFormatVersion"), ("broken", "/PolicyFormatVersion")] {
+ let mut draft = policy_draft("tests.contract", "Contract");
+ draft["PolicyFormatVersion"] = json!(value);
+ assert_invalid_draft(pipe_name, draft, expected_path).await?;
+ }
+ let mut invalid_interval = policy_draft("tests.contract", "Contract");
+ invalid_interval["Metadata"]["ValidFrom"] = json!("2026-01-01T00:00:00Z");
+ invalid_interval["Metadata"]["ValidUntil"] = json!("2026-01-01T00:00:00Z");
+ assert_invalid_draft(pipe_name, invalid_interval, "/Metadata/ValidUntil").await?;
+
+ let mut duplicate_rule = policy_draft("tests.contract", "Contract");
+ duplicate_rule["Rules"][1]["Id"] = duplicate_rule["Rules"][0]["Id"].clone();
+ assert_invalid_draft(pipe_name, duplicate_rule, "/Rules/1/Id").await?;
+ Ok(())
+}
+
+async fn assert_invalid_draft(pipe_name: &str, draft: Value, expected_path: &str) -> anyhow::Result<()> {
+ let invalid_request = json!({
+ "RequestKind": "PolicyValidationRequest",
+ "RequestVersion": "1.0",
+ "Draft": draft
+ });
+ let invalid_response = request_with_body(
+ pipe_name,
+ "POST",
+ "/v1/policy/validate",
+ Some("application/json"),
+ &serde_json::to_vec(&invalid_request)?,
+ )
+ .await?;
+ ensure!(
+ invalid_response.status == 200,
+ "invalid draft validation returned HTTP {}",
+ invalid_response.status
+ );
+ let invalid_validation = invalid_response.json()?["Validation"].clone();
+ ensure!(invalid_validation["IsValid"] == false, "invalid draft was accepted");
+ ensure!(
+ invalid_validation.get("CanonicalDraft").is_none()
+ && invalid_validation.get("ValidationReceipt").is_none()
+ && invalid_validation["ValidatorVersion"] == "now-package-broker-policy-validator/10",
+ "invalid draft returned a canonical draft, receipt, or wrong validator version"
+ );
+ ensure!(
+ invalid_validation["Findings"]
+ .as_array()
+ .is_some_and(|findings| findings
+ .iter()
+ .any(|finding| finding["Severity"] == "Error" && finding["Path"] == expected_path)),
+ "invalid draft did not report {expected_path}: {invalid_validation}"
+ );
+ Ok(())
+}
+
+async fn managed_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> {
+ let mut agent = AgentHarness::start_managed_default(agent_path).await?;
+ strict_contract_validation(&agent.pipe_name).await?;
+ let initial = policy_management(&agent).await?;
+ ensure!(
+ initial["State"] == "Missing",
+ "managed policy was not initially missing"
+ );
+ ensure!(
+ initial["Source"] == "DefaultPath" && initial["WriteCapability"] == "Writable",
+ "isolated managed default path was not writable"
+ );
+
+ let created = replace_policy(
+ &agent,
+ "Create",
+ initial["StoreToken"].clone(),
+ policy_draft("tests.managed-lifecycle", "Create"),
+ )
+ .await?;
+ ensure!(
+ created["Policy"]["Metadata"]["Revision"] == 1,
+ "Create did not assign revision 1"
+ );
+ wait_for_log(&agent, "Policy creation succeeded").await?;
+
+ let updated = replace_policy(
+ &agent,
+ "Update",
+ created["Management"]["StoreToken"].clone(),
+ policy_draft("tests.managed-lifecycle", "Update"),
+ )
+ .await?;
+ ensure!(
+ updated["Policy"]["Metadata"]["Revision"] == 2,
+ "Update did not increment the revision"
+ );
+
+ let secret = "malformed-policy-secret-marker";
+ std::fs::write(&agent.policy_path, format!(r#"{{"unterminated":"{secret}"#))
+ .context("write malformed external policy")?;
+ let invalid = wait_for_management(&agent, |management| management["State"] == "Invalid").await?;
+ let diagnostics = &invalid["InvalidDiagnostics"];
+ ensure!(
+ diagnostics["Findings"]
+ .as_array()
+ .is_some_and(|findings| !findings.is_empty()),
+ "invalid policy did not produce diagnostics"
+ );
+ ensure!(
+ !diagnostics.to_string().contains(secret),
+ "invalid policy diagnostics exposed file contents"
+ );
+ wait_for_log(&agent, "External policy change rejected").await?;
+
+ let repaired = replace_policy(
+ &agent,
+ "Repair",
+ invalid["StoreToken"].clone(),
+ policy_draft("tests.managed-repaired", "Repair"),
+ )
+ .await?;
+ ensure!(
+ repaired["Policy"]["Metadata"]["Revision"] == 1,
+ "Repair did not assign revision 1"
+ );
+
+ let stale_token = repaired["Management"]["StoreToken"].clone();
+ let mut external = empty_policy();
+ external["Metadata"]["Id"] = json!("tests.managed-external");
+ std::fs::write(&agent.policy_path, serde_json::to_vec_pretty(&external)?).context("write valid external policy")?;
+ wait_for_management(&agent, |management| {
+ management["Policy"]["Metadata"]["Id"] == "tests.managed-external"
+ })
+ .await?;
+ wait_for_log(&agent, "External policy change applied").await?;
+
+ let stale = replace_policy_response(
+ &agent,
+ "Update",
+ "Reject",
+ stale_token.clone(),
+ policy_draft("tests.managed-external", "Stale"),
+ )
+ .await?;
+ ensure!(stale.status == 409, "stale Update returned HTTP {}", stale.status);
+ let stale = stale.json()?;
+ ensure!(
+ stale["Code"] == "StalePolicyStoreToken"
+ && stale["Management"]["Policy"]["Metadata"]["Id"] == "tests.managed-external",
+ "stale Update did not return the current policy snapshot"
+ );
+ wait_for_log(&agent, "stale_conflict").await?;
+
+ let stale_confirm = replace_policy_response(
+ &agent,
+ "Update",
+ "ConfirmOverwrite",
+ stale_token,
+ policy_draft("tests.managed-external", "Stale confirmed overwrite"),
+ )
+ .await?;
+ ensure!(
+ stale_confirm.status == 409 && stale_confirm.json()?["Code"] == "StalePolicyStoreToken",
+ "stale ConfirmOverwrite did not return a store token conflict"
+ );
+
+ let current_token = stale["Management"]["StoreToken"].clone();
+ let confirmed = replace_policy_response(
+ &agent,
+ "Update",
+ "ConfirmOverwrite",
+ current_token.clone(),
+ policy_draft("tests.managed-external", "Confirmed overwrite"),
+ )
+ .await?;
+ ensure!(
+ confirmed.status == 200,
+ "exact ConfirmOverwrite returned HTTP {}",
+ confirmed.status
+ );
+ let confirmed = confirmed.json()?;
+ ensure!(
+ confirmed["Policy"]["Metadata"]["Revision"] == 2,
+ "confirmed Update did not increment the external policy revision"
+ );
+ wait_for_log(&agent, "confirmed_overwrite").await?;
+
+ let reused = replace_policy_response(
+ &agent,
+ "Update",
+ "ConfirmOverwrite",
+ current_token,
+ policy_draft("tests.managed-external", "Reused token"),
+ )
+ .await?;
+ ensure!(
+ reused.status == 409 && reused.json()?["Code"] == "StalePolicyStoreToken",
+ "reused ConfirmOverwrite token did not conflict"
+ );
+
+ let confirmed = warnings_identity_and_receipts(&mut agent, agent_path, &confirmed).await?;
+ agent.restart(agent_path).await?;
+ let restarted = request(&agent.pipe_name, "GET", "/v1/policy").await?;
+ ensure!(
+ restarted.status == 200,
+ "policy read after restart returned HTTP {}",
+ restarted.status
+ );
+ ensure!(
+ restarted.json()?["Policy"] == confirmed["Policy"],
+ "restart changed the active managed policy"
+ );
+ Ok(())
+}
+
+async fn warnings_identity_and_receipts(
+ agent: &mut AgentHarness,
+ agent_path: &Path,
+ current: &Value,
+) -> anyhow::Result {
+ let mut draft = policy_draft("tests.replaced-identity", "Canonical contract");
+ draft["PolicyFormatVersion"] = json!("1.7.3");
+ draft["Enforcement"]["AuditMode"] = json!(true);
+ let validation = validate_policy_by_pipe(&agent.pipe_name, &draft).await?;
+ ensure!(
+ validation["Findings"].as_array().is_some_and(|findings| findings
+ .iter()
+ .any(|finding| finding["Code"] == "AuditModeEnabled" && finding["Severity"] == "Warning")),
+ "audit-mode draft did not produce its warning"
+ );
+ let mut replacement = json!({
+ "RequestKind": "PolicyReplacementRequest",
+ "RequestVersion": "1.0",
+ "ExpectedStoreToken": current["Management"]["StoreToken"],
+ "Operation": "ReplaceIdentity",
+ "ConflictHandling": "Reject",
+ "Draft": validation["CanonicalDraft"],
+ "ValidationReceipt": validation["ValidationReceipt"]
+ });
+ replacement["Draft"]["Metadata"]["Publisher"] = json!("Tampered after validation");
+ let tampered = send_replacement(&agent.pipe_name, &replacement).await?;
+ ensure!(
+ tampered.status == 422 && tampered.json()?["Code"] == "ValidationFailed",
+ "receipt authorized a different draft"
+ );
+ replacement["Draft"] = validation["CanonicalDraft"].clone();
+ agent.restart(agent_path).await?;
+ let restarted = policy_management(agent).await?;
+ replacement["ExpectedStoreToken"] = restarted["StoreToken"].clone();
+ let expired = send_replacement(&agent.pipe_name, &replacement).await?;
+ ensure!(
+ expired.status == 422 && expired.json()?["Code"] == "ValidationFailed",
+ "pre-restart receipt remained valid in a new validator instance"
+ );
+ ensure!(
+ restarted["Policy"] == current["Policy"]
+ && policy_management(agent).await?["StoreToken"] == restarted["StoreToken"],
+ "rejected requests changed the policy or exact store token"
+ );
+ let replaced = replace_policy(agent, "ReplaceIdentity", restarted["StoreToken"].clone(), draft).await?;
+ ensure!(
+ replaced["Policy"]["Metadata"]["Id"] == "tests.replaced-identity"
+ && replaced["Policy"]["Metadata"]["Revision"] == 1
+ && replaced["Policy"]["PolicyFormatVersion"] == "1.7.3",
+ "advisory findings did not permit the canonical identity replacement"
+ );
+ wait_for_log(agent, "Policy change succeeded").await?;
+ wait_for_log(agent, "replace_identity").await?;
+ wait_for_log(agent, "invalid_receipt").await?;
+ Ok(replaced)
+}
+
+async fn interrupted_malformed_repair(agent_path: &Path) -> anyhow::Result<()> {
+ let original = b"malformed-policy-secret-marker";
+ for marker_staging in [false, true] {
+ let data_dir = create_data_dir()?;
+ let policy_path = data_dir.path().join("policy.json");
+ std::fs::write(&policy_path, original)?;
+ secure_policy_path(&policy_path, false)?;
+ let prefix = ".policy.json.txn-11111111-2222-4333-8444-555555555555";
+ let new_path = data_dir.path().join(format!("{prefix}.new"));
+ std::fs::write(&new_path, b"partial replacement")?;
+ secure_policy_path(&new_path, false)?;
+ let marker_path = data_dir.path().join(format!("{prefix}.marker.prepare"));
+ if marker_staging {
+ std::fs::write(&marker_path, br#"{"Version":"#)?;
+ secure_policy_path(&marker_path, false)?;
+ }
+ let mut agent = AgentHarness::start_with_path(agent_path, data_dir, unique_pipe_name(), policy_path).await?;
+ let invalid = policy_management(&agent).await?;
+ ensure!(
+ invalid["State"] == "Invalid" && invalid["WriteCapability"] == "Writable",
+ "interrupted Repair did not retain a repairable invalid original: {invalid}"
+ );
+ ensure!(
+ std::fs::read(&agent.policy_path)? == original && !new_path.exists() && !marker_path.exists(),
+ "recovery changed the original or retained prepublication remnants"
+ );
+ ensure!(
+ request(&agent.pipe_name, "GET", "/v1/policy").await?.status == 404,
+ "malformed policy became active"
+ );
+ let repaired = replace_policy(
+ &agent,
+ "Repair",
+ invalid["StoreToken"].clone(),
+ policy_draft("tests.interrupted-repair", "Recovered"),
+ )
+ .await?;
+ agent.restart(agent_path).await?;
+ ensure!(
+ policy_management(&agent).await?["Policy"] == repaired["Policy"],
+ "repaired policy did not survive restart"
+ );
+ agent.stop().await?;
+ }
+ Ok(())
+}
+
+async fn wait_for_management(agent: &AgentHarness, predicate: impl Fn(&Value) -> bool) -> anyhow::Result {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ loop {
+ let management = policy_management(agent).await?;
+ if predicate(&management) {
+ return Ok(management);
+ }
+ ensure!(Instant::now() < deadline, "timed out waiting for policy state");
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+}
+
+async fn wait_for_log(agent: &AgentHarness, expected: &str) -> anyhow::Result<()> {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ loop {
+ if agent.logs()?.contains(expected) {
+ return Ok(());
+ }
+ ensure!(Instant::now() < deadline, "Agent log did not contain '{expected}'");
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+}
+
async fn unavailable_policy_and_method_restrictions(agent_path: &Path) -> anyhow::Result<()> {
let agent = AgentHarness::start(agent_path, None).await?;
@@ -598,6 +1452,7 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<(
if policy == &full {
break;
}
+
ensure!(Instant::now() < deadline, "agent did not reload the policy");
tokio::task::yield_now().await;
}
@@ -613,3 +1468,46 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<(
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn policy_fixtures_use_the_current_contract() {
+ for policy in [full_policy(), empty_policy(), policy_draft("tests.contract", "Test")] {
+ assert_eq!(policy["PolicyFormatVersion"], "1.0.0");
+ }
+ }
+
+ #[test]
+ fn standard_user_token_rejects_wrong_identity_and_administrators() {
+ for (actual_sid, administrator, integrity) in [
+ ("S-1-5-21-1-2-3-1002", false, SECURITY_MANDATORY_MEDIUM_RID),
+ ("S-1-5-21-1-2-3-1001", true, SECURITY_MANDATORY_MEDIUM_RID),
+ ("S-1-5-21-1-2-3-1001", false, 0x3000),
+ ] {
+ assert!(validate_standard_user_token(actual_sid, "S-1-5-21-1-2-3-1001", administrator, integrity).is_err());
+ }
+ }
+
+ #[test]
+ fn standard_user_token_requires_medium_integrity() {
+ validate_standard_user_token(
+ "S-1-5-21-1-2-3-1001",
+ "S-1-5-21-1-2-3-1001",
+ false,
+ SECURITY_MANDATORY_MEDIUM_RID,
+ )
+ .expect("matching standard-user SID at Medium integrity is valid");
+
+ let error = validate_standard_user_token(
+ "S-1-5-21-1-2-3-1001",
+ "S-1-5-21-1-2-3-1001",
+ false,
+ SECURITY_MANDATORY_LOW_RID,
+ )
+ .expect_err("Low integrity must not satisfy the standard-user scenario");
+ assert!(error.to_string().contains("requires Medium integrity"));
+ }
+}
diff --git a/crates/agent-sysevent-codes/Cargo.toml b/crates/agent-sysevent-codes/Cargo.toml
new file mode 100644
index 000000000..beef0008e
--- /dev/null
+++ b/crates/agent-sysevent-codes/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "agent-sysevent-codes"
+version = "0.0.0"
+edition = "2024"
+authors = ["Devolutions Inc. "]
+license = "MIT OR Apache-2.0"
+publish = false
+
+[lints]
+workspace = true
+
+[dependencies]
+sysevent.path = "../sysevent"
diff --git a/crates/agent-sysevent-codes/src/lib.rs b/crates/agent-sysevent-codes/src/lib.rs
new file mode 100644
index 000000000..05620c3b0
--- /dev/null
+++ b/crates/agent-sysevent-codes/src/lib.rs
@@ -0,0 +1,123 @@
+//! Devolutions Agent-specific Windows Event Log event definitions.
+
+use std::path::Path;
+
+use sysevent::{Entry, Severity};
+
+pub const POLICY_WRITE_ATTEMPTED: u32 = 8000;
+pub const POLICY_WRITE_DENIED: u32 = 8001;
+pub const POLICY_CREATE_FAILED: u32 = 8002;
+pub const POLICY_CREATE_SUCCEEDED: u32 = 8003;
+pub const POLICY_CHANGE_FAILED: u32 = 8004;
+pub const POLICY_CHANGE_SUCCEEDED: u32 = 8005;
+pub const POLICY_EXTERNAL_CHANGE_APPLIED: u32 = 8010;
+pub const POLICY_EXTERNAL_CHANGE_REJECTED: u32 = 8011;
+
+pub fn policy_write_attempted(
+ actor_sid: impl ToString,
+ actor_exe: impl ToString,
+ intent: impl ToString,
+ path: &Path,
+) -> Entry {
+ Entry::new("Policy management write attempted")
+ .event_code(POLICY_WRITE_ATTEMPTED)
+ .severity(Severity::Info)
+ .field("actor_sid", actor_sid)
+ .field("actor_exe", actor_exe)
+ .field("intent", intent)
+ .field("path", path.display())
+}
+
+pub fn policy_write_denied(
+ actor_sid: impl ToString,
+ actor_exe: impl ToString,
+ intent: impl ToString,
+ path: &Path,
+ reason: impl ToString,
+) -> Entry {
+ Entry::new("Policy management write denied")
+ .event_code(POLICY_WRITE_DENIED)
+ .severity(Severity::Warning)
+ .field("actor_sid", actor_sid)
+ .field("actor_exe", actor_exe)
+ .field("intent", intent)
+ .field("path", path.display())
+ .field("reason", reason)
+}
+
+#[expect(
+ clippy::too_many_arguments,
+ reason = "the shared builder keeps the Create and change failure events field-compatible"
+)]
+pub fn policy_write_failed(
+ event_code: u32,
+ message: &'static str,
+ actor_sid: impl ToString,
+ actor_exe: impl ToString,
+ intent: impl ToString,
+ path: impl AsRef,
+ operation: impl ToString,
+ outcome: impl ToString,
+ reason: impl ToString,
+) -> Entry {
+ Entry::new(message)
+ .event_code(event_code)
+ .severity(Severity::Error)
+ .field("actor_sid", actor_sid)
+ .field("actor_exe", actor_exe)
+ .field("intent", intent)
+ .field("path", path.as_ref().display())
+ .field("operation", operation)
+ .field("outcome", outcome)
+ .field("reason", reason)
+}
+
+#[expect(
+ clippy::too_many_arguments,
+ reason = "the audit event records both policy identities and the operation outcome"
+)]
+pub fn policy_write_succeeded(
+ event_code: u32,
+ message: &'static str,
+ actor_sid: impl ToString,
+ actor_exe: impl ToString,
+ path: impl AsRef,
+ old_id: impl ToString,
+ old_revision: impl ToString,
+ new_id: impl ToString,
+ new_revision: u32,
+ intent: impl ToString,
+ operation: impl ToString,
+ outcome: impl ToString,
+) -> Entry {
+ Entry::new(message)
+ .event_code(event_code)
+ .severity(Severity::Info)
+ .field("actor_sid", actor_sid)
+ .field("actor_exe", actor_exe)
+ .field("path", path.as_ref().display())
+ .field("old_id", old_id)
+ .field("old_revision", old_revision)
+ .field("new_id", new_id)
+ .field("new_revision", new_revision)
+ .field("intent", intent)
+ .field("operation", operation)
+ .field("outcome", outcome)
+}
+
+pub fn policy_external_change_applied(path: impl AsRef, new_id: impl ToString, new_revision: u32) -> Entry {
+ Entry::new("External policy change applied")
+ .event_code(POLICY_EXTERNAL_CHANGE_APPLIED)
+ .severity(Severity::Notice)
+ .field("path", path.as_ref().display())
+ .field("new_id", new_id)
+ .field("new_revision", new_revision)
+}
+
+pub fn policy_external_change_rejected(path: impl AsRef, reason: impl ToString) -> Entry {
+ Entry::new("External policy change rejected")
+ .event_code(POLICY_EXTERNAL_CHANGE_REJECTED)
+ .severity(Severity::Warning)
+ .field("path", path.as_ref().display())
+ .field("reason", reason)
+}
diff --git a/crates/agent-sysevent-codes/tests/message_catalog_parity.rs b/crates/agent-sysevent-codes/tests/message_catalog_parity.rs
new file mode 100644
index 000000000..f838bfda0
--- /dev/null
+++ b/crates/agent-sysevent-codes/tests/message_catalog_parity.rs
@@ -0,0 +1,48 @@
+use std::path::Path;
+
+const EVENTS: &[(u32, usize)] = &[
+ (agent_sysevent_codes::POLICY_WRITE_ATTEMPTED, 5),
+ (agent_sysevent_codes::POLICY_WRITE_DENIED, 6),
+ (agent_sysevent_codes::POLICY_CREATE_FAILED, 8),
+ (agent_sysevent_codes::POLICY_CREATE_SUCCEEDED, 11),
+ (agent_sysevent_codes::POLICY_CHANGE_FAILED, 8),
+ (agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED, 11),
+ (agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, 4),
+ (agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, 3),
+];
+
+#[test]
+fn policy_events_match_the_agent_catalog() {
+ let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../devolutions-agent/devolutions-agent.mc");
+ let catalog = std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
+
+ for &(code, insertion_count) in EVENTS {
+ let marker = format!("MessageId={code}");
+ let start = catalog
+ .find(&marker)
+ .unwrap_or_else(|| panic!("Agent catalog omits {marker}"));
+ let block = &catalog[start
+ ..catalog[start..]
+ .find("\nMessageId=")
+ .map_or(catalog.len(), |end| start + end)];
+ let messages: Vec<_> = block
+ .lines()
+ .enumerate()
+ .filter(|(_, line)| line.starts_with("Language="))
+ .map(|(index, _)| block.lines().nth(index + 1).unwrap_or_default())
+ .collect();
+ assert_eq!(messages.len(), 3, "Agent catalog {marker}");
+ for message in messages {
+ for insertion in 1..=insertion_count {
+ assert!(
+ message.contains(&format!("%{insertion}")),
+ "Agent catalog {marker} omits %{insertion}"
+ );
+ }
+ assert!(
+ !message.contains(&format!("%{}", insertion_count + 1)),
+ "Agent catalog {marker} has an unexpected insertion"
+ );
+ }
+ }
+}
diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml
index 662cbc510..5b407647b 100644
--- a/crates/now-package-broker/Cargo.toml
+++ b/crates/now-package-broker/Cargo.toml
@@ -34,18 +34,22 @@ notify = { version = "7", default-features = false }
http-body-util = "0.1"
mime = "0.3"
now-policy = "=0.5.0"
-now-policy-api = "=0.6.0"
-now-policy-server-template = "=0.6.0"
+now-policy-api = "=0.7.0"
+now-policy-server-template = "=0.7.0"
parking_lot = "0.12"
regex = "1"
semver = "1"
serde = "1"
serde_json = "1"
sha2 = "0.10"
+sysevent = { path = "../sysevent" }
+agent-sysevent-codes = { path = "../agent-sysevent-codes" }
+sysevent-winevent = { path = "../sysevent-winevent" }
tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parking_lot", "fs", "sync", "time"] }
tokio-util = "0.7"
tower-service = "0.3"
tracing = "0.1"
+unicode-normalization = "0.1"
uuid = { version = "1.23", features = ["v4"] }
widestring = "1.2"
win-api-wrappers = { path = "../win-api-wrappers" }
diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs
new file mode 100644
index 000000000..598971c4e
--- /dev/null
+++ b/crates/now-package-broker/src/audit.rs
@@ -0,0 +1,597 @@
+//! Structured audit events for policy management writes and external policy changes.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+#[cfg(all(not(test), not(debug_assertions)))]
+use std::sync::atomic::AtomicU64;
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use agent_sysevent_codes as policy_events;
+use now_policy_api::{PolicyManagementState, PolicyReplacementOperation};
+use sysevent::Entry;
+#[cfg(not(test))]
+use sysevent::Severity;
+#[cfg(all(not(test), not(debug_assertions)))]
+use sysevent::SystemEventSink;
+use win_api_wrappers::identity::sid::Sid;
+
+const INTENT: &str = "PUT /v1/policy";
+const MAX_SID_BYTES: usize = 256;
+const MAX_PATH_BYTES: usize = 1024;
+const MAX_POLICY_ID_BYTES: usize = 256;
+#[cfg(all(not(test), not(debug_assertions)))]
+const EVENT_LOG_QUEUE_CAPACITY: usize = 256;
+
+static RECORDER: std::sync::LazyLock> = std::sync::LazyLock::new(default_recorder);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum DenialReason {
+ AuthenticationFailed,
+ AdministratorRequired,
+ RequestRejected,
+}
+
+impl DenialReason {
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::AuthenticationFailed => "authentication_failed",
+ Self::AdministratorRequired => "administrator_required",
+ Self::RequestRejected => "request_rejected",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum FailureReason {
+ MonitoringUnavailable,
+ StaleStoreToken,
+ PathNotWritable,
+ InvalidPolicy,
+ InvalidReceipt,
+ RevisionConflict,
+ DraftCommitFailed,
+ SerializationFailed,
+ PersistenceFailed,
+ ConditionalPublicationFailed,
+ ActivationFailed,
+}
+
+impl FailureReason {
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::MonitoringUnavailable => "monitoring_unavailable",
+ Self::StaleStoreToken => "stale_store_token",
+ Self::PathNotWritable => "path_not_writable",
+ Self::InvalidPolicy => "invalid_policy",
+ Self::InvalidReceipt => "invalid_receipt",
+ Self::RevisionConflict => "revision_conflict",
+ Self::DraftCommitFailed => "draft_commit_failed",
+ Self::SerializationFailed => "serialization_failed",
+ Self::PersistenceFailed => "persistence_failed",
+ Self::ConditionalPublicationFailed => "conditional_publication_failed",
+ Self::ActivationFailed => "activation_failed",
+ }
+ }
+}
+
+trait AuditRecorder: Send + Sync {
+ fn record(&self, entry: Entry);
+}
+
+fn default_recorder() -> Arc {
+ #[cfg(test)]
+ {
+ Arc::new(tests::TestRecorder)
+ }
+ #[cfg(all(not(test), debug_assertions))]
+ {
+ Arc::new(TracingRecorder)
+ }
+ #[cfg(all(not(test), not(debug_assertions)))]
+ {
+ match SystemRecorder::new() {
+ Ok(recorder) => Arc::new(recorder),
+ Err(error) => {
+ tracing::error!(%error, "Failed to start the Windows Event Log policy audit worker");
+ Arc::new(TracingRecorder)
+ }
+ }
+ }
+}
+
+#[cfg(not(test))]
+struct TracingRecorder;
+
+#[cfg(not(test))]
+impl AuditRecorder for TracingRecorder {
+ fn record(&self, entry: Entry) {
+ trace_entry(&entry);
+ }
+}
+
+#[cfg(all(not(test), not(debug_assertions)))]
+struct SystemRecorder {
+ sender: std::sync::mpsc::SyncSender,
+ dropped: AtomicU64,
+}
+
+#[cfg(all(not(test), not(debug_assertions)))]
+impl SystemRecorder {
+ fn new() -> std::io::Result {
+ let (sender, receiver) = std::sync::mpsc::sync_channel(EVENT_LOG_QUEUE_CAPACITY);
+ std::thread::Builder::new()
+ .name("policy-audit-event-log".to_owned())
+ .spawn(move || event_log_worker(&receiver))
+ .map(|_| Self {
+ sender,
+ dropped: AtomicU64::new(0),
+ })
+ }
+}
+
+#[cfg(all(not(test), not(debug_assertions)))]
+impl AuditRecorder for SystemRecorder {
+ fn record(&self, entry: sysevent::Entry) {
+ trace_entry(&entry);
+ if let Err(error) = self.sender.try_send(entry) {
+ let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
+ if dropped.is_power_of_two() {
+ tracing::warn!(
+ dropped,
+ error = %match error {
+ std::sync::mpsc::TrySendError::Full(_) => "queue_full",
+ std::sync::mpsc::TrySendError::Disconnected(_) => "worker_disconnected",
+ },
+ "Dropped policy audit Windows Event Log entries"
+ );
+ }
+ }
+ }
+}
+
+#[cfg(not(test))]
+fn trace_entry(entry: &Entry) {
+ let code = entry.event_code;
+ let message = &entry.message;
+ let fields = &entry.fields;
+ match entry.severity {
+ Severity::Critical | Severity::Error => tracing::error!(?code, %message, ?fields, "Policy audit event"),
+ Severity::Warning => tracing::warn!(?code, %message, ?fields, "Policy audit event"),
+ Severity::Notice | Severity::Info | Severity::Debug => {
+ tracing::info!(?code, %message, ?fields, "Policy audit event");
+ }
+ }
+}
+
+#[cfg(all(not(test), not(debug_assertions)))]
+fn event_log_worker(receiver: &std::sync::mpsc::Receiver) {
+ let sink: Arc = match sysevent_winevent::WinEvent::new("Devolutions Agent") {
+ Ok(event_log) => Arc::new(event_log),
+ Err(error) => {
+ tracing::error!(%error, "Failed to initialize the Windows Event Log policy audit sink");
+ Arc::new(sysevent::NoopSink)
+ }
+ };
+ for entry in receiver {
+ if let Err(error) = sink.emit(entry) {
+ tracing::warn!(%error, "Failed to emit policy audit event to the Windows Event Log");
+ }
+ }
+}
+
+struct WriteAuditState {
+ actor_sid: String,
+ actor_exe: String,
+ path: PathBuf,
+ terminal_recorded: AtomicBool,
+ recorder: Arc,
+}
+
+impl Drop for WriteAuditState {
+ fn drop(&mut self) {
+ if !self.terminal_recorded.swap(true, Ordering::AcqRel) {
+ self.record(policy_events::policy_write_denied(
+ &self.actor_sid,
+ &self.actor_exe,
+ INTENT,
+ &self.path,
+ DenialReason::RequestRejected.as_str(),
+ ));
+ }
+ }
+}
+
+#[derive(Clone)]
+pub(crate) struct WriteAudit(Arc);
+
+impl WriteAudit {
+ pub(crate) fn begin(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> Self {
+ Self::begin_with_recorder(actor_sid, actor_exe, path, Arc::clone(&RECORDER))
+ }
+
+ fn begin_with_recorder(actor_sid: &Sid, actor_exe: &Path, path: &Path, recorder: Arc) -> Self {
+ let state = Arc::new(WriteAuditState {
+ actor_sid: bounded(actor_sid.to_string(), MAX_SID_BYTES),
+ actor_exe: bounded(actor_exe.display().to_string(), MAX_PATH_BYTES),
+ path: bounded_path(path),
+ terminal_recorded: AtomicBool::new(false),
+ recorder,
+ });
+ state.record(policy_events::policy_write_attempted(
+ &state.actor_sid,
+ &state.actor_exe,
+ INTENT,
+ &state.path,
+ ));
+ Self(state)
+ }
+
+ pub(crate) fn denied(&self, reason: DenialReason) {
+ self.finish(|state| {
+ policy_events::policy_write_denied(&state.actor_sid, &state.actor_exe, INTENT, &state.path, reason.as_str())
+ });
+ }
+
+ pub(crate) fn failed(&self, operation: PolicyReplacementOperation, reason: FailureReason) {
+ self.failed_at(operation, &self.0.path, reason);
+ }
+
+ pub(crate) fn failed_at(&self, operation: PolicyReplacementOperation, path: &Path, reason: FailureReason) {
+ let path = bounded_path(path);
+ let operation_name = operation_name(operation);
+ let outcome = if reason == FailureReason::StaleStoreToken {
+ "stale_conflict"
+ } else {
+ "failed"
+ };
+ self.finish(|state| {
+ if operation == PolicyReplacementOperation::Create {
+ policy_events::policy_write_failed(
+ policy_events::POLICY_CREATE_FAILED,
+ "Policy creation failed",
+ &state.actor_sid,
+ &state.actor_exe,
+ INTENT,
+ path,
+ operation_name,
+ outcome,
+ reason.as_str(),
+ )
+ } else {
+ policy_events::policy_write_failed(
+ policy_events::POLICY_CHANGE_FAILED,
+ "Policy change failed",
+ &state.actor_sid,
+ &state.actor_exe,
+ INTENT,
+ path,
+ operation_name,
+ outcome,
+ reason.as_str(),
+ )
+ }
+ });
+ }
+
+ #[expect(
+ clippy::too_many_arguments,
+ reason = "the terminal event records operation and both policy identities"
+ )]
+ pub(crate) fn succeeded_at(
+ &self,
+ path: &Path,
+ old_id: Option<&str>,
+ old_revision: Option,
+ new_id: &str,
+ new_revision: u32,
+ operation: PolicyReplacementOperation,
+ confirmed_overwrite: bool,
+ ) {
+ let path = bounded_path(path);
+ let old_id = bounded(old_id.unwrap_or("").to_owned(), MAX_POLICY_ID_BYTES);
+ let old_revision = old_revision.map_or_else(|| "none".to_owned(), |revision| revision.to_string());
+ let new_id = bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES);
+ let operation_name = operation_name(operation);
+ let outcome = if confirmed_overwrite {
+ "confirmed_overwrite"
+ } else {
+ "applied"
+ };
+ self.finish(|state| {
+ if operation == PolicyReplacementOperation::Create {
+ policy_events::policy_write_succeeded(
+ policy_events::POLICY_CREATE_SUCCEEDED,
+ "Policy creation succeeded",
+ &state.actor_sid,
+ &state.actor_exe,
+ path,
+ old_id,
+ old_revision,
+ new_id,
+ new_revision,
+ INTENT,
+ operation_name,
+ outcome,
+ )
+ } else {
+ policy_events::policy_write_succeeded(
+ policy_events::POLICY_CHANGE_SUCCEEDED,
+ "Policy change succeeded",
+ &state.actor_sid,
+ &state.actor_exe,
+ path,
+ old_id,
+ old_revision,
+ new_id,
+ new_revision,
+ INTENT,
+ operation_name,
+ outcome,
+ )
+ }
+ });
+ }
+
+ fn finish(&self, entry: impl FnOnce(&WriteAuditState) -> Entry) {
+ if self
+ .0
+ .terminal_recorded
+ .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
+ .is_ok()
+ {
+ self.0.record(entry(&self.0));
+ }
+ }
+}
+
+impl WriteAuditState {
+ fn record(&self, entry: Entry) {
+ self.recorder.record(entry);
+ }
+}
+
+pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u32) {
+ RECORDER.record(policy_events::policy_external_change_applied(
+ bounded_path(path),
+ bounded(new_id.to_owned(), MAX_POLICY_ID_BYTES),
+ new_revision,
+ ));
+}
+
+pub(crate) fn external_change_rejected(path: &Path, state: PolicyManagementState) {
+ let reason = match state {
+ PolicyManagementState::Active => "active",
+ PolicyManagementState::Missing => "missing",
+ PolicyManagementState::Invalid => "invalid",
+ };
+ RECORDER.record(policy_events::policy_external_change_rejected(
+ bounded_path(path),
+ reason,
+ ));
+}
+
+fn bounded(mut value: String, max_bytes: usize) -> String {
+ value = value
+ .chars()
+ .map(|character| if is_audit_control(character) { ' ' } else { character })
+ .collect();
+ if value.len() <= max_bytes {
+ return value;
+ }
+ const SUFFIX: &str = "...";
+ let mut end = max_bytes - SUFFIX.len();
+ while !value.is_char_boundary(end) {
+ end -= 1;
+ }
+ value.truncate(end);
+ value.push_str(SUFFIX);
+ value
+}
+
+fn is_audit_control(character: char) -> bool {
+ character.is_control()
+ || matches!(
+ character,
+ '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}' | '\u{2029}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
+ )
+}
+
+fn bounded_path(path: &Path) -> PathBuf {
+ PathBuf::from(bounded(path.display().to_string(), MAX_PATH_BYTES))
+}
+
+const fn operation_name(operation: PolicyReplacementOperation) -> &'static str {
+ match operation {
+ PolicyReplacementOperation::Create => "create",
+ PolicyReplacementOperation::Update => "update",
+ PolicyReplacementOperation::Repair => "repair",
+ PolicyReplacementOperation::ReplaceIdentity => "replace_identity",
+ }
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+ use super::*;
+
+ std::thread_local! {
+ static EVENTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) };
+ }
+
+ pub(crate) struct TestRecorder;
+
+ impl AuditRecorder for TestRecorder {
+ fn record(&self, entry: Entry) {
+ EVENTS.with(|events| events.borrow_mut().push(entry));
+ }
+ }
+
+ pub(crate) fn take_events() -> Vec {
+ EVENTS.with(|events| std::mem::take(&mut *events.borrow_mut()))
+ }
+
+ #[derive(Default)]
+ pub(crate) struct Recorder(parking_lot::Mutex>);
+
+ impl Recorder {
+ pub(crate) fn events(&self) -> Vec {
+ self.0.lock().clone()
+ }
+ }
+
+ impl AuditRecorder for Recorder {
+ fn record(&self, entry: Entry) {
+ self.0.lock().push(entry);
+ }
+ }
+
+ pub(crate) fn begin(actor_sid: &Sid, actor_exe: &Path, path: &Path) -> (WriteAudit, Arc) {
+ let recorder = Arc::new(Recorder::default());
+ let audit = WriteAudit::begin_with_recorder(
+ actor_sid,
+ actor_exe,
+ path,
+ Arc::clone(&recorder) as Arc,
+ );
+ (audit, recorder)
+ }
+
+ pub(crate) fn noop() -> WriteAudit {
+ let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID");
+ WriteAudit::begin_with_recorder(
+ &sid,
+ Path::new(r"C:\test-client.exe"),
+ Path::new(r"C:\policy.json"),
+ Arc::new(NoopRecorder),
+ )
+ }
+
+ struct NoopRecorder;
+
+ impl AuditRecorder for NoopRecorder {
+ fn record(&self, _: Entry) {}
+ }
+
+ fn test_audit() -> (WriteAudit, Arc) {
+ let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID");
+ begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json"))
+ }
+
+ #[test]
+ fn attempt_precedes_denial_and_only_one_terminal_event_is_recorded() {
+ let (audit, recorder) = test_audit();
+ audit.denied(DenialReason::AuthenticationFailed);
+ audit.failed(PolicyReplacementOperation::Update, FailureReason::InvalidPolicy);
+ assert_eq!(
+ recorder
+ .events()
+ .iter()
+ .map(|entry| entry.event_code)
+ .collect::>(),
+ [
+ Some(policy_events::POLICY_WRITE_ATTEMPTED),
+ Some(policy_events::POLICY_WRITE_DENIED)
+ ]
+ );
+ }
+
+ #[test]
+ fn abandoned_clones_record_one_terminal_denial() {
+ let (audit, recorder) = test_audit();
+ let retained = audit.clone();
+ drop(audit);
+ assert_eq!(recorder.events().len(), 1);
+ drop(retained);
+ let events = recorder.events();
+ assert_eq!(events.len(), 2);
+ assert_eq!(events[1].event_code, Some(policy_events::POLICY_WRITE_DENIED));
+ assert!(
+ events[1]
+ .fields
+ .iter()
+ .any(|(name, value)| name == "reason" && value == "request_rejected")
+ );
+ }
+
+ #[test]
+ fn audit_text_removes_control_characters_before_truncation() {
+ let value = format!(
+ "injected\r\n\t\0\u{061c}\u{200e}\u{200f}\u{2028}\u{2029}\u{202a}\u{202b}\u{202c}\u{202d}\u{202e}\u{2066}\u{2067}\u{2068}\u{2069}{}",
+ "é".repeat(MAX_POLICY_ID_BYTES)
+ );
+ let bounded = bounded(value, MAX_POLICY_ID_BYTES);
+ assert!(bounded.len() <= MAX_POLICY_ID_BYTES);
+ assert!(bounded.ends_with("..."));
+ assert!(!bounded.chars().any(is_audit_control));
+ }
+
+ #[test]
+ fn audit_values_are_bounded_and_fields_are_allowlisted() {
+ let sid = Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).expect("SYSTEM SID");
+ let long = "é".repeat(MAX_PATH_BYTES);
+ let (audit, recorder) = begin(&sid, Path::new(&long), Path::new(&long));
+ audit.succeeded_at(
+ Path::new(&long),
+ Some(&long),
+ Some(1),
+ &long,
+ 2,
+ PolicyReplacementOperation::Update,
+ false,
+ );
+
+ let events = recorder.events();
+ let entry = &events[1];
+ assert!(entry.fields.iter().all(|(name, value)| {
+ matches!(
+ name.as_str(),
+ "actor_sid"
+ | "actor_exe"
+ | "intent"
+ | "path"
+ | "old_id"
+ | "old_revision"
+ | "new_id"
+ | "new_revision"
+ | "operation"
+ | "outcome"
+ ) && value.len() <= MAX_PATH_BYTES
+ }));
+ for forbidden in ["body", "draft", "policy", "receipt", "store_token"] {
+ assert!(!entry.fields.iter().any(|(name, _)| name == forbidden));
+ }
+ }
+
+ #[test]
+ fn terminal_event_codes_follow_the_replacement_operation() {
+ for (operation, failure_code, success_code) in [
+ (
+ PolicyReplacementOperation::Create,
+ policy_events::POLICY_CREATE_FAILED,
+ policy_events::POLICY_CREATE_SUCCEEDED,
+ ),
+ (
+ PolicyReplacementOperation::Update,
+ policy_events::POLICY_CHANGE_FAILED,
+ policy_events::POLICY_CHANGE_SUCCEEDED,
+ ),
+ (
+ PolicyReplacementOperation::Repair,
+ policy_events::POLICY_CHANGE_FAILED,
+ policy_events::POLICY_CHANGE_SUCCEEDED,
+ ),
+ (
+ PolicyReplacementOperation::ReplaceIdentity,
+ policy_events::POLICY_CHANGE_FAILED,
+ policy_events::POLICY_CHANGE_SUCCEEDED,
+ ),
+ ] {
+ let (failed, failed_recorder) = test_audit();
+ failed.failed(operation, FailureReason::StaleStoreToken);
+ assert_eq!(failed_recorder.events()[1].event_code, Some(failure_code));
+
+ let (succeeded, succeeded_recorder) = test_audit();
+ succeeded.succeeded_at(Path::new(r"C:\policy.json"), None, None, "new", 1, operation, true);
+ assert_eq!(succeeded_recorder.events()[1].event_code, Some(success_code));
+ }
+ }
+}
diff --git a/crates/now-package-broker/src/auth.rs b/crates/now-package-broker/src/auth.rs
index d891f6351..f293a0306 100644
--- a/crates/now-package-broker/src/auth.rs
+++ b/crates/now-package-broker/src/auth.rs
@@ -32,6 +32,7 @@ use windows::Win32::System::Threading::{
use crate::policy_security::RetainedExecutableSecurity;
const PROCESS_SYNCHRONIZE: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(0x0010_0000);
+const POLICY_CONSENT_HELPER_NAME: &str = "DevolutionsAgentPolicyConsent.exe";
const PROCESS_IDENTITY_ACCESS: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(
PROCESS_QUERY_INFORMATION.0 | PROCESS_QUERY_LIMITED_INFORMATION.0 | PROCESS_VM_READ.0 | PROCESS_SYNCHRONIZE.0,
);
@@ -222,6 +223,10 @@ impl PipeClient {
&self.user_sid
}
+ pub(crate) fn executable_path(&self) -> &Path {
+ &self.executable_path
+ }
+
pub(crate) fn is_elevated_administrator(&self) -> bool {
self.is_elevated && self.is_administrator
}
@@ -285,6 +290,20 @@ impl PipeClient {
Ok(())
}
+ pub(crate) fn validate_policy_write(&self, skip_signature_validation: bool) -> anyhow::Result<()> {
+ self.validate_connection(skip_signature_validation)?;
+ // Dev builds cannot enforce helper identity when signature validation is explicitly disabled.
+ if signature_validation_skipped(skip_signature_validation) {
+ return Ok(());
+ }
+ let agent = std::env::current_exe().context("failed to query Agent executable path")?;
+ let executable_file = self
+ .executable_file
+ .as_deref()
+ .context("policy consent helper executable handle is not retained")?;
+ Self::validate_policy_consent_helper_path(&self.executable_path, executable_file, &agent)
+ }
+
fn validate_process_instance(&self) -> anyhow::Result<()> {
let Some(process) = &self.process else {
return Ok(());
@@ -300,6 +319,29 @@ impl PipeClient {
)
}
+ fn validate_policy_consent_helper_path(client: &Path, client_file: &File, agent: &Path) -> anyhow::Result<()> {
+ if !client
+ .file_name()
+ .is_some_and(|name| name.eq_ignore_ascii_case(POLICY_CONSENT_HELPER_NAME))
+ {
+ bail!("policy replacement requires the Agent policy consent helper");
+ }
+ let expected = agent
+ .parent()
+ .context("Agent executable has no installation directory")?
+ .join(POLICY_CONSENT_HELPER_NAME);
+ if !crate::policy_security::windows_paths_equal(client, &expected) {
+ bail!("policy consent helper is not the installed Agent helper path");
+ }
+ let expected_id = file_id(&expected).context("failed to query installed policy consent helper identity")?;
+ let retained_id =
+ file_id_from_handle(client_file).context("failed to query retained policy consent helper identity")?;
+ if !same_file(&expected_id, &retained_id) {
+ bail!("policy consent helper does not match the installed helper");
+ }
+ Ok(())
+ }
+
/// Validate that the request's `effective_user` denotes the authenticated pipe client user.
///
/// The name is resolved to a SID and compared against the SID captured at connect,
@@ -535,6 +577,51 @@ mod tests {
.expect_err("a recycled PID with a different creation time must be rejected");
}
+ #[test]
+ fn policy_consent_helper_requires_exact_agent_sibling_path() {
+ let current_executable = std::env::current_exe().expect("current executable");
+ let current_file = open_executable_file(¤t_executable).expect("open current executable");
+ let agent = Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe");
+ assert!(
+ PipeClient::validate_policy_consent_helper_path(
+ Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgentPolicyConsent.exe"),
+ ¤t_file,
+ agent,
+ )
+ .is_err(),
+ "path text alone must not authorize a different retained image"
+ );
+ assert!(
+ PipeClient::validate_policy_consent_helper_path(
+ Path::new(r"C:\Users\Alice\DevolutionsAgentPolicyConsent.exe"),
+ ¤t_file,
+ agent,
+ )
+ .is_err()
+ );
+ assert!(
+ PipeClient::validate_policy_consent_helper_path(
+ Path::new(r"C:\Users\Alice\UniGetUI.exe"),
+ ¤t_file,
+ agent,
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn policy_consent_helper_accepts_exact_retained_sibling() {
+ let temp = tempfile::tempdir().expect("temp directory");
+ let agent = temp.path().join("DevolutionsAgent.exe");
+ let helper = temp.path().join(POLICY_CONSENT_HELPER_NAME);
+ std::fs::write(&agent, b"agent path anchor").expect("write Agent path anchor");
+ std::fs::copy(std::env::current_exe().expect("current executable"), &helper).expect("copy helper fixture");
+ let retained = open_executable_file(&helper).expect("retain helper fixture");
+
+ PipeClient::validate_policy_consent_helper_path(&helper, &retained, &agent)
+ .expect("exact retained sibling must be accepted");
+ }
+
#[test]
fn exited_process_cannot_supply_executable_identity() {
let mut child = std::process::Command::new("powershell.exe")
diff --git a/crates/now-package-broker/src/evaluator/matching.rs b/crates/now-package-broker/src/evaluator/matching.rs
index 4ee5301a9..3bd0dbf80 100644
--- a/crates/now-package-broker/src/evaluator/matching.rs
+++ b/crates/now-package-broker/src/evaluator/matching.rs
@@ -9,7 +9,7 @@ use now_policy_api::PackageRequest;
use super::RequestFlags;
use super::constraints::constraints_pass;
-use super::wildcard::wildcard_any;
+use super::wildcard::{literal_case_insensitive_match, wildcard_any};
pub(super) fn rule_matches(
rule: &PolicyRule,
@@ -21,7 +21,7 @@ pub(super) fn rule_matches(
operations_match(request.operation, &m.operations)
&& managers_match(request.manager, &m.managers)
- && source_names_match(&request.source.name, &m.source_names)
+ && source_names_match(request.manager, &request.source.name, &m.source_names)
&& package_identifiers_match(&request.package.id, &m.package_identifiers)
&& versions_match(effective_version, &m.version)
&& scopes_match(request.options.scope, &m.scopes)
@@ -129,8 +129,18 @@ fn elevation_match(elevation: now_policy_api::Elevation, allowed: &BTreeSet) -> bool {
- allowed.is_empty() || allowed.iter().any(|source| source.as_ref().eq_ignore_ascii_case(value))
+fn source_names_match(
+ manager: now_policy_api::ManagerName,
+ value: &str,
+ allowed: &BTreeSet,
+) -> bool {
+ allowed.is_empty()
+ || allowed.iter().any(|source| match manager {
+ now_policy_api::ManagerName::PowerShell | now_policy_api::ManagerName::PowerShell7 => {
+ literal_case_insensitive_match(value, source.as_ref())
+ }
+ _ => source.as_ref().eq_ignore_ascii_case(value),
+ })
}
fn package_identifiers_match(
@@ -262,6 +272,20 @@ mod tests {
}));
}
+ #[test]
+ fn non_powershell_source_names_remain_canonically_distinct() {
+ let mut request = request();
+ request.manager = api::ManagerName::Scoop;
+ request.source.name = "CO\u{0308}RP".to_owned();
+ let flags = RequestFlags::from_request(&request);
+ let rule = rule(PolicyMatch {
+ source_names: BTreeSet::from([now_policy::SourceName::parse("CÖRP").expect("valid source")]),
+ ..Default::default()
+ });
+
+ assert!(!rule_matches(&rule, &request, &flags, "1.2.3"));
+ }
+
#[test]
fn absent_scope_or_architecture_in_request_fails_when_rule_restricts_them() {
let mut request = request();
diff --git a/crates/now-package-broker/src/evaluator/mod.rs b/crates/now-package-broker/src/evaluator/mod.rs
index d0376caf8..729c8abc6 100644
--- a/crates/now-package-broker/src/evaluator/mod.rs
+++ b/crates/now-package-broker/src/evaluator/mod.rs
@@ -113,6 +113,12 @@ pub fn evaluate(policy: &PolicyDocument, request: &PackageRequest) -> PolicyDeci
}
}
+/// Whether a source spelling has a stable identity across package-manager lookup and
+/// policy evaluation.
+pub(crate) fn source_name_is_unambiguous(source_name: &str) -> bool {
+ source_name == source_name.trim() && !wildcard::has_default_ignorable_code_point(source_name)
+}
+
pub(crate) fn effective_execution_elevation(request: &PackageRequest) -> Elevation {
if request.options.scope == Some(Scope::Machine) || request.client.requested_elevation == Elevation::Elevated {
Elevation::Elevated
diff --git a/crates/now-package-broker/src/evaluator/tests.rs b/crates/now-package-broker/src/evaluator/tests.rs
index 8b6776a08..3e845a5e3 100644
--- a/crates/now-package-broker/src/evaluator/tests.rs
+++ b/crates/now-package-broker/src/evaluator/tests.rs
@@ -5,11 +5,11 @@ use std::collections::BTreeSet;
use chrono::Utc;
use now_policy::{
Decision, PackageIdentifier, PackageIdentifierCondition, PolicyDocument, PolicyEnforcement, PolicyFormatVersion,
- PolicyMatch, PolicyMetadata, PolicyRule, ResourceId,
+ PolicyMatch, PolicyMetadata, PolicyRule, ResourceId, SourceName,
};
use now_policy_api::{self as api, PackageRequest};
-use super::evaluate;
+use super::{evaluate, source_name_is_unambiguous};
fn make_policy(default_decision: Decision, rules: Vec) -> PolicyDocument {
PolicyDocument {
@@ -128,6 +128,52 @@ fn deny_unmatched_package() {
assert_eq!(result.rule_id, "");
}
+#[test]
+fn unicode_case_equivalent_source_deny_outranks_allow() {
+ let policy = make_policy(
+ Decision::Deny,
+ vec![
+ PolicyRule {
+ id: ResourceId::from("allow-any"),
+ enabled: true,
+ priority: 100,
+ decision: Decision::Allow,
+ reason: None,
+ match_criteria: PolicyMatch::default(),
+ constraints: None,
+ },
+ PolicyRule {
+ id: ResourceId::from("deny-corp"),
+ enabled: true,
+ priority: 100,
+ decision: Decision::Deny,
+ reason: None,
+ match_criteria: PolicyMatch {
+ source_names: BTreeSet::from([SourceName::parse("CÖRP").expect("valid source")]),
+ ..Default::default()
+ },
+ constraints: None,
+ },
+ ],
+ );
+ let mut request = make_request(api::Operation::Install, "Example.Package");
+ request.manager = api::ManagerName::PowerShell;
+ request.source.name = "cörp".to_owned();
+
+ let result = evaluate(&policy, &request);
+
+ assert_eq!(result.decision, Decision::Deny);
+ assert_eq!(result.rule_id, "deny-corp");
+}
+
+#[test]
+fn default_ignorable_source_spelling_is_rejected_before_evaluation() {
+ assert!(!source_name_is_unambiguous("PS\u{00AD}Gallery"));
+ assert!(!source_name_is_unambiguous("PSGallery "));
+ assert!(!source_name_is_unambiguous(" PSGallery"));
+ assert!(source_name_is_unambiguous("PSGallery"));
+}
+
#[test]
fn disabled_rules_are_ignored() {
let policy = make_policy(
diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs
index 68e99df85..fc586e9a8 100644
--- a/crates/now-package-broker/src/evaluator/wildcard.rs
+++ b/crates/now-package-broker/src/evaluator/wildcard.rs
@@ -1,6 +1,13 @@
//! Case-insensitive wildcard matching helpers.
use std::collections::BTreeSet;
+use std::sync::LazyLock;
+
+use unicode_normalization::UnicodeNormalization as _;
+use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal};
+
+static DEFAULT_IGNORABLE_CODE_POINT: LazyLock =
+ LazyLock::new(|| regex::Regex::new(r"\p{Default_Ignorable_Code_Point}").expect("valid Unicode property regex"));
pub(super) fn wildcard_any>(value: &str, patterns: &BTreeSet
) -> bool {
patterns.is_empty() || patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref()))
@@ -10,6 +17,27 @@ pub(super) fn wildcard_any_vec>(value: &str, patterns: &[S]) -> bo
patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref()))
}
+/// Match an exact source name using the PowerShell repository identity semantics.
+///
+/// PowerShell resolves repository names after canonical Unicode normalization with
+/// ordinal case-insensitive comparison.
+/// Source names are literals, so this deliberately does not apply wildcard semantics.
+pub(super) fn literal_case_insensitive_match(value: &str, expected: &str) -> bool {
+ let value: Vec = value.nfc().collect::().encode_utf16().collect();
+ let expected: Vec = expected.nfc().collect::().encode_utf16().collect();
+
+ // SAFETY: The binding marshals both valid UTF-8 strings as bounded UTF-16.
+ unsafe { CompareStringOrdinal(&value, &expected, true) == CSTR_EQUAL }
+}
+
+/// Default-ignorable characters are rejected before matching and command building.
+///
+/// PowerShell repository lookup ignores them, while an opaque package request would
+/// otherwise preserve them for `-Repository` and make policy identity ambiguous.
+pub(super) fn has_default_ignorable_code_point(value: &str) -> bool {
+ DEFAULT_IGNORABLE_CODE_POINT.is_match(value)
+}
+
fn wildcard_match(value: &str, pattern: &str) -> bool {
// Convert glob pattern to regex: escape everything except *, which becomes .*
let regex_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*"));
@@ -47,4 +75,17 @@ mod tests {
assert!(wildcard_any("Contoso.Tools+", &patterns));
assert!(!wildcard_any("Contoso.Toolss", &patterns));
}
+
+ #[test]
+ fn literal_match_uses_unicode_case_insensitive_semantics() {
+ assert!(literal_case_insensitive_match("CO\u{0308}RP", "CÖRP"));
+ assert!(!literal_case_insensitive_match("P\u{017F}Gallery", "PSGallery"));
+ assert!(!literal_case_insensitive_match("cörp", "CÖRP*"));
+ }
+
+ #[test]
+ fn default_ignorable_source_characters_are_detected() {
+ assert!(has_default_ignorable_code_point("PS\u{00AD}Gallery"));
+ assert!(!has_default_ignorable_code_point("CÖRP"));
+ }
}
diff --git a/crates/now-package-broker/src/lib.rs b/crates/now-package-broker/src/lib.rs
index e1542dda4..f8acf7e70 100644
--- a/crates/now-package-broker/src/lib.rs
+++ b/crates/now-package-broker/src/lib.rs
@@ -5,6 +5,8 @@
//!
//! The broker is only functional on Windows; on other platforms this crate is empty.
+#[cfg(windows)]
+mod audit;
#[cfg(windows)]
mod auth;
#[cfg(windows)]
diff --git a/crates/now-package-broker/src/pipe.rs b/crates/now-package-broker/src/pipe.rs
index 6a4c51b00..3ebe2c346 100644
--- a/crates/now-package-broker/src/pipe.rs
+++ b/crates/now-package-broker/src/pipe.rs
@@ -20,7 +20,7 @@ use windows::Win32::Security::Authorization::SET_ACCESS;
use windows::Win32::Storage::FileSystem::{FILE_GENERIC_READ, FILE_GENERIC_WRITE};
use crate::auth::PipeClient;
-use crate::server::{BrokerState, build_router_for_client, serve_connection};
+use crate::server::{BrokerState, build_router_for_client_with_policy_write_deadline, serve_connection};
/// Default pipe name for the package broker.
pub const DEFAULT_PIPE_NAME: &str = r"\\.\pipe\Devolutions.Now.PackageBroker.v1";
@@ -36,12 +36,16 @@ const MAX_CONCURRENT_CONNECTIONS: usize = 16;
/// Deadline for serving a single pipe connection, from accept to response completion.
///
/// Each connection serves exactly one HTTP request (`keep_alive` is disabled) and all
-/// endpoints respond without blocking on package operations (execution is asynchronous,
-/// tracked via the operation tracker), so a healthy exchange completes well within this
-/// deadline. Without it, idle clients holding their connection open without sending a
-/// request would each pin a connection slot indefinitely and could exhaust the pool.
+/// ordinary endpoints respond without blocking on package operations (execution is
+/// asynchronous and tracked via the operation tracker). Without this deadline, idle
+/// clients holding their connection open without sending a request would each pin a
+/// connection slot indefinitely and could exhaust the pool.
const CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
+/// The bounded policy-replacement exchange lifetime used only after the exact installed
+/// consent helper has passed write authorization.
+const POLICY_CONSENT_CONNECTION_DEADLINE: std::time::Duration = std::time::Duration::from_secs(120);
+
/// Start the named pipe server and accept connections until shutdown.
pub async fn run_pipe_server(state: Arc, shutdown: CancellationToken) -> anyhow::Result<()> {
let pipe_name = state.pipe_name.clone();
@@ -72,40 +76,65 @@ pub async fn run_pipe_server(state: Arc, shutdown: CancellationToke
match result {
Ok(()) => {
let state = Arc::clone(&state);
+ let connection_deadline = tokio::time::Instant::now() + CONNECTION_DEADLINE;
tokio::spawn(async move {
- let serve = async move {
- // Keep blocking unauthenticated capture off the accept loop and
- // retain the connection slot until the work actually completes.
- let capture = spawn_bounded_capture(permit, move || {
- let client = PipeClient::from_connected_pipe(&server);
- (server, client)
- });
- let (_permit, server, client) = match capture.await {
- Ok((permit, (server, Ok(client)))) => (permit, server, client),
- Ok((_permit, (_server, Err(error)))) => {
- warn!(error = format!("{error:#}"), "Rejected named pipe client");
- return;
- }
- Err(error) => {
- error!(
- error = format!("{error:#}"),
- "Named pipe client identity capture task failed"
- );
- return;
- }
- };
-
- info!("Client connected to named pipe");
- let router = build_router_for_client(state, client);
- serve_connection(server, router).await;
- info!("Client disconnected from named pipe");
+ // Keep blocking unauthenticated capture off the accept loop and
+ // retain the connection slot until the work actually completes.
+ let capture = spawn_bounded_capture(permit, move || {
+ let client = PipeClient::from_connected_pipe(&server);
+ (server, client)
+ });
+ let (_permit, server, client) =
+ match tokio::time::timeout_at(connection_deadline, capture).await {
+ Ok(Ok((permit, (server, Ok(client))))) => (permit, server, client),
+ Ok(Ok((_permit, (_server, Err(error))))) => {
+ warn!(error = format!("{error:#}"), "Rejected named pipe client");
+ return;
+ }
+ Ok(Err(error)) => {
+ error!(
+ error = format!("{error:#}"),
+ "Named pipe client identity capture task failed"
+ );
+ return;
+ }
+ Err(_) => {
+ warn!("Closed named pipe connection: client identity capture deadline exceeded");
+ return;
+ }
};
- // Enforce a deadline so idle or slow clients cannot pin
- // a connection slot indefinitely.
- if tokio::time::timeout(CONNECTION_DEADLINE, serve).await.is_err() {
- warn!("Closed named pipe connection: deadline exceeded");
+ info!("Client connected to named pipe");
+ let (policy_write_deadline, mut policy_write_authorized) = tokio::sync::watch::channel(false);
+ let router = build_router_for_client_with_policy_write_deadline(
+ state,
+ client,
+ policy_write_deadline,
+ );
+ let serve = serve_connection(server, router);
+ tokio::pin!(serve);
+ let mut policy_write_is_authorized = false;
+ let mut deadline = connection_deadline;
+ loop {
+ tokio::select! {
+ () = &mut serve => break,
+ () = tokio::time::sleep_until(deadline) => {
+ if policy_write_is_authorized {
+ warn!("Closed named pipe policy replacement: deadline exceeded");
+ } else {
+ warn!("Closed named pipe connection: deadline exceeded");
+ }
+ break;
+ }
+ result = policy_write_authorized.changed(), if !policy_write_is_authorized => {
+ if result.is_ok() && *policy_write_authorized.borrow_and_update() {
+ policy_write_is_authorized = true;
+ deadline = tokio::time::Instant::now() + POLICY_CONSENT_CONNECTION_DEADLINE;
+ }
+ }
+ }
}
+ info!("Client disconnected from named pipe");
});
}
Err(error) => {
@@ -200,6 +229,12 @@ mod tests {
use super::*;
+ #[test]
+ fn connection_deadlines_preserve_short_untrusted_and_long_authorized_bounds() {
+ assert_eq!(CONNECTION_DEADLINE, Duration::from_secs(30));
+ assert_eq!(POLICY_CONSENT_CONNECTION_DEADLINE, Duration::from_secs(120));
+ }
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn timed_out_capture_keeps_its_permit_until_blocking_work_finishes() {
let permits = Arc::new(Semaphore::new(1));
diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs
index 631025078..1785c9c67 100644
--- a/crates/now-package-broker/src/policy_store/mod.rs
+++ b/crates/now-package-broker/src/policy_store/mod.rs
@@ -9,9 +9,9 @@ use chrono::Utc;
use now_policy::PolicyDocument;
use now_policy_api::{
API_VERSION_STR, ErrorCode, ErrorResponse, ErrorResponseKind, InvalidPolicyDiagnostics, PolicyConfigurationSource,
- PolicyManagementSnapshot, PolicyManagementState, PolicyReadOnlyReason, PolicyReplacementOperation,
- PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, PolicyWriteCapability, ServerContext,
- Transport,
+ PolicyConflictHandling, PolicyManagementSnapshot, PolicyManagementState, PolicyReadOnlyReason,
+ PolicyReplacementOperation, PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult,
+ PolicyWriteCapability, ServerContext, Transport,
};
mod receipt;
@@ -255,7 +255,7 @@ impl PolicyStore {
return self.management_snapshot();
}
let (_, observation) = self.observe_storage(false);
- let management = self.publish_observation(observation);
+ let management = self.publish_external_observation(observation);
tracing::info!(?cause, state = ?management.state, "Reloaded package broker policy");
management
}
@@ -294,9 +294,15 @@ impl PolicyStore {
self.publish_observation(observation);
}
- pub async fn replace(&self, request: PolicyReplacementRequest) -> Result {
+ pub(crate) async fn replace(
+ &self,
+ request: PolicyReplacementRequest,
+ audit: crate::audit::WriteAudit,
+ ) -> Result {
+ let operation = request.operation;
let monitoring = self.writer.lock().await;
if *monitoring != Monitoring::Available {
+ audit.failed(operation, crate::audit::FailureReason::MonitoringUnavailable);
return Err(error_with_management(
ErrorCode::BrokerPaused,
"policy change monitoring is unavailable",
@@ -310,7 +316,9 @@ impl PolicyStore {
// Both conflict modes require this exact token.
// ConfirmOverwrite records retry intent without retaining token history.
if fresh_token != request.expected_store_token {
- let management = self.publish_observation(observation);
+ let audit_path = observation.canonical_path.clone();
+ let management = self.publish_external_observation(observation);
+ audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken);
return Err(error_with_management(
ErrorCode::StalePolicyStoreToken,
"the configured policy changed after the supplied store token was observed",
@@ -319,6 +327,11 @@ impl PolicyStore {
}
if observation.write_capability != PolicyWriteCapability::Writable {
+ audit.failed_at(
+ operation,
+ &observation.canonical_path,
+ crate::audit::FailureReason::PathNotWritable,
+ );
let code = match observation.read_only_reason {
Some(PolicyReadOnlyReason::UnsupportedFileSystem) => ErrorCode::UnsupportedPolicyFilesystem,
Some(PolicyReadOnlyReason::UnsupportedFormat) => ErrorCode::UnsupportedPolicyFormat,
@@ -329,6 +342,11 @@ impl PolicyStore {
let validation = self.validate_draft(&request.draft);
if !validation.is_valid {
+ audit.failed_at(
+ operation,
+ &observation.canonical_path,
+ crate::audit::FailureReason::InvalidPolicy,
+ );
return Err(error_with_validation(
ErrorCode::InvalidPolicy,
"the submitted draft failed authoritative validation",
@@ -345,35 +363,61 @@ impl PolicyStore {
&validation.findings,
&request.validation_receipt,
) {
+ audit.failed_at(
+ operation,
+ &observation.canonical_path,
+ crate::audit::FailureReason::InvalidReceipt,
+ );
return Err(error_with_validation(
ErrorCode::ValidationFailed,
"the validation receipt does not match this draft",
validation,
));
}
- if !validation.findings.is_empty() && !request.warnings_acknowledged {
- return Err(error_with_validation(
- ErrorCode::WarningConfirmationRequired,
- "validation warnings must be explicitly acknowledged",
- validation,
- ));
- }
-
- let revision = plan_revision(
+ let revision = match plan_revision(
request.operation,
observation.state,
observation.policy.as_ref(),
&draft.metadata.id.0,
- )
- .map_err(|message| error_response(ErrorCode::Conflict, message))?;
- let policy = draft.into_policy_document(revision, Utc::now()).map_err(|_| {
- error_response(
- ErrorCode::ValidationFailed,
- "failed to commit the validated policy draft",
- )
- })?;
- let bytes = serde_json::to_vec_pretty(&policy)
- .map_err(|_| error_response(ErrorCode::InternalError, "failed to serialize the committed policy"))?;
+ ) {
+ Ok(revision) => revision,
+ Err(message) => {
+ audit.failed_at(
+ operation,
+ &observation.canonical_path,
+ crate::audit::FailureReason::RevisionConflict,
+ );
+ return Err(error_response(ErrorCode::Conflict, message));
+ }
+ };
+ let policy = match draft.into_policy_document(revision, Utc::now()) {
+ Ok(policy) => policy,
+ Err(_) => {
+ audit.failed_at(
+ operation,
+ &observation.canonical_path,
+ crate::audit::FailureReason::DraftCommitFailed,
+ );
+ return Err(error_response(
+ ErrorCode::ValidationFailed,
+ "failed to commit the validated policy draft",
+ ));
+ }
+ };
+ let bytes = match serde_json::to_vec_pretty(&policy) {
+ Ok(bytes) => bytes,
+ Err(_) => {
+ audit.failed_at(
+ operation,
+ &observation.canonical_path,
+ crate::audit::FailureReason::SerializationFailed,
+ );
+ return Err(error_response(
+ ErrorCode::InternalError,
+ "failed to serialize the committed policy",
+ ));
+ }
+ };
let persisted = if request.operation == PolicyReplacementOperation::Create {
self.storage
@@ -388,13 +432,20 @@ impl PolicyStore {
tracing::warn!(error = format!("{error:#}"), "Policy persistence failed");
let (_, current) = self.observe_storage(false);
if current.fingerprint != observation.fingerprint {
- let management = self.publish_observation(current);
+ let audit_path = current.canonical_path.clone();
+ let management = self.publish_external_observation(current);
+ audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken);
return Err(error_with_management(
ErrorCode::StalePolicyStoreToken,
"the policy storage changed before publication; retry with the current store token",
management,
));
}
+ audit.failed_at(
+ operation,
+ &observation.canonical_path,
+ crate::audit::FailureReason::PersistenceFailed,
+ );
return Err(error_response(
ErrorCode::PolicyPersistenceFailed,
"failed to persist the policy",
@@ -407,12 +458,19 @@ impl PolicyStore {
);
let (_, current) = self.observe_storage(false);
if current.fingerprint == observation.fingerprint {
+ audit.failed_at(
+ operation,
+ &observation.canonical_path,
+ crate::audit::FailureReason::ConditionalPublicationFailed,
+ );
return Err(error_response(
ErrorCode::PolicyPersistenceFailed,
"failed to conditionally persist the policy",
));
}
- let management = self.publish_observation(current);
+ let audit_path = current.canonical_path.clone();
+ let management = self.publish_external_observation(current);
+ audit.failed_at(operation, &audit_path, crate::audit::FailureReason::StaleStoreToken);
return Err(error_with_management(
ErrorCode::StalePolicyStoreToken,
"the policy storage changed during publication; retry with the current store token",
@@ -425,7 +483,9 @@ impl PolicyStore {
"Published policy failed authoritative reload"
);
let (_, current) = self.observe_storage(false);
- let management = self.publish_observation(current);
+ let audit_path = current.canonical_path.clone();
+ let management = self.publish_external_observation(current);
+ audit.failed_at(operation, &audit_path, crate::audit::FailureReason::ActivationFailed);
return Err(error_with_management(
ErrorCode::PolicyActivationFailed,
"the policy was published but failed authoritative reload",
@@ -434,6 +494,9 @@ impl PolicyStore {
}
};
+ let old_id = observation.policy.as_ref().map(|policy| policy.metadata.id.0.clone());
+ let old_revision = observation.policy.as_ref().map(|policy| policy.metadata.revision);
+ let canonical_path = observation.canonical_path.clone();
let token = token_for(&previous, &persisted.fingerprint);
let snapshot = Arc::new(Snapshot {
state: PolicyManagementState::Active,
@@ -447,6 +510,16 @@ impl PolicyStore {
});
*self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot;
+ audit.succeeded_at(
+ &canonical_path,
+ old_id.as_deref(),
+ old_revision,
+ &persisted.policy.metadata.id.0,
+ persisted.policy.metadata.revision,
+ operation,
+ request.conflict_handling == PolicyConflictHandling::ConfirmOverwrite,
+ );
+
Ok(ReplaceSuccess {
policy: persisted.policy,
validation,
@@ -469,6 +542,34 @@ impl PolicyStore {
management
}
+ fn publish_external_observation(&self, observation: Observation) -> PolicyManagementSnapshot {
+ let policy_changed = self.snapshot().fingerprint != observation.fingerprint;
+ let management = self.publish_observation(observation);
+ if policy_changed {
+ let path = Path::new(&management.configured_path);
+ match (management.state, management.policy.as_ref()) {
+ (PolicyManagementState::Active, Some(policy)) => {
+ crate::audit::external_change_applied(path, &policy.metadata.id.0, policy.metadata.revision);
+ }
+ (PolicyManagementState::Missing | PolicyManagementState::Invalid, _) => {
+ crate::audit::external_change_rejected(path, management.state);
+ }
+ (PolicyManagementState::Active, None) => {
+ crate::audit::external_change_rejected(path, PolicyManagementState::Invalid);
+ }
+ }
+ }
+ management
+ }
+
+ #[cfg(test)]
+ pub(crate) async fn replace_for_tests(
+ &self,
+ request: PolicyReplacementRequest,
+ ) -> Result {
+ self.replace(request, crate::audit::tests::noop()).await
+ }
+
#[cfg(test)]
pub(crate) fn for_tests(policy: Option) -> Arc {
let storage = Arc::new(TestStorage::new(policy));
@@ -809,6 +910,7 @@ fn clone_observation(observation: &Observation) -> Observation {
mod storage_tests {
use now_policy::PolicyDraftDocument;
use now_policy_api::{PolicyConflictHandling, PolicyReplacementRequestKind};
+ use win_api_wrappers::identity::sid::Sid;
use super::*;
@@ -847,12 +949,126 @@ mod storage_tests {
expected_store_token: store.management_snapshot().store_token,
operation: PolicyReplacementOperation::Update,
conflict_handling: PolicyConflictHandling::Reject,
- warnings_acknowledged: false,
draft: raw,
validation_receipt: validation.validation_receipt.expect("valid receipt"),
}
}
+ fn recording_audit() -> (crate::audit::WriteAudit, Arc) {
+ let sid =
+ Sid::from_well_known(::windows::Win32::Security::WinLocalSystemSid, None).expect("resolve SYSTEM SID");
+ crate::audit::tests::begin(&sid, Path::new(r"C:\client.exe"), Path::new(r"C:\policy.json"))
+ }
+
+ #[tokio::test]
+ async fn audited_old_validator_receipt_fails_once_without_publication() {
+ let store = PolicyStore::load_with_storage(
+ Some(PathBuf::from(r"C:\policy.json")),
+ Arc::new(TestStorage::new(Some(policy("current", 1)))),
+ Monitoring::Available,
+ );
+ let mut request = update_request(&store);
+ let validation = store.validate_draft(&request.draft);
+ let canonical = validation.canonical_draft.as_ref().expect("canonical draft");
+ request.validation_receipt =
+ store
+ .receipt_key
+ .issue("now-package-broker-policy-validator/8", canonical, &validation.findings);
+ let (audit, recorder) = recording_audit();
+
+ let error = store
+ .replace(request, audit)
+ .await
+ .expect_err("old validator receipt is rejected");
+
+ assert_eq!(error.code, ErrorCode::ValidationFailed);
+ assert_eq!(store.active_policy().expect("unchanged policy").metadata.revision, 1);
+ assert_eq!(
+ recorder
+ .events()
+ .iter()
+ .map(|entry| entry.event_code)
+ .collect::>(),
+ [
+ Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED),
+ Some(agent_sysevent_codes::POLICY_CHANGE_FAILED)
+ ]
+ );
+ assert!(
+ recorder.events()[1]
+ .fields
+ .iter()
+ .any(|(name, value)| name == "reason" && value == "invalid_receipt")
+ );
+ }
+
+ #[tokio::test(flavor = "current_thread")]
+ async fn canonical_external_observations_are_audited_once_per_change() {
+ let storage = Arc::new(TestStorage::new(Some(policy("current", 1))));
+ let store = PolicyStore::load_with_storage(
+ Some(PathBuf::from(r"C:\policy.json")),
+ Arc::clone(&storage) as Arc,
+ Monitoring::Available,
+ );
+ crate::audit::tests::take_events();
+
+ store.reload_from_disk(ReloadCause::ExternalChange).await;
+ assert!(
+ crate::audit::tests::take_events().is_empty(),
+ "unchanged policy is not an event"
+ );
+
+ storage.set_disk_state(None, true, 2);
+ let rejected = store.reload_from_disk(ReloadCause::ExternalChange).await;
+ assert_eq!(rejected.state, PolicyManagementState::Invalid);
+ assert!(
+ store.active_policy().is_none(),
+ "invalid external policy is not published"
+ );
+ let events = crate::audit::tests::take_events();
+ assert_eq!(events.len(), 1);
+ assert_eq!(
+ events[0].event_code,
+ Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED)
+ );
+ assert!(
+ events[0]
+ .fields
+ .iter()
+ .any(|(name, value)| name == "reason" && value == "invalid")
+ );
+
+ store.reload_from_disk(ReloadCause::ExternalChange).await;
+ assert!(
+ crate::audit::tests::take_events().is_empty(),
+ "unchanged invalid policy is not an event"
+ );
+
+ storage.set_disk_state(Some(policy("external", 7)), false, 3);
+ let applied = store.reload_from_disk(ReloadCause::ExternalChange).await;
+ assert_eq!(applied.state, PolicyManagementState::Active);
+ assert_eq!(
+ store
+ .active_policy()
+ .expect("external policy is active")
+ .metadata
+ .revision,
+ 7
+ );
+ let events = crate::audit::tests::take_events();
+ assert_eq!(events.len(), 1);
+ assert_eq!(
+ events[0].event_code,
+ Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)
+ );
+
+ store.reload_from_disk(ReloadCause::ExternalChange).await;
+ assert!(
+ crate::audit::tests::take_events().is_empty(),
+ "unchanged external policy is not an event"
+ );
+ }
+
#[tokio::test]
async fn compatible_format_version_is_bound_to_receipts_and_persisted_tokens() {
let storage = Arc::new(TestStorage::new(Some(policy("current", 1))));
@@ -864,7 +1080,7 @@ mod storage_tests {
let mut request = update_request(&store);
request.draft["PolicyFormatVersion"] = serde_json::json!("1.7.3");
let error = store
- .replace(request.clone())
+ .replace_for_tests(request.clone())
.await
.expect_err("format version is receipt-bound");
assert_eq!(error.code, ErrorCode::ValidationFailed);
@@ -878,14 +1094,17 @@ mod storage_tests {
.issue("now-package-broker-policy-validator/8", canonical, &validation.findings);
request.validation_receipt = old_receipt;
let error = store
- .replace(request.clone())
+ .replace_for_tests(request.clone())
.await
.expect_err("old validator receipt is rejected");
assert_eq!(error.code, ErrorCode::ValidationFailed);
request.validation_receipt = validation.validation_receipt.expect("current receipt");
let before = store.management_snapshot().store_token;
- let result = store.replace(request).await.expect("compatible format is writable");
+ let result = store
+ .replace_for_tests(request)
+ .await
+ .expect("compatible format is writable");
assert_eq!(
serde_json::to_value(&result.policy).expect("serialize committed policy")["PolicyFormatVersion"],
"1.7.3"
@@ -900,8 +1119,9 @@ mod storage_tests {
);
}
- #[tokio::test]
+ #[tokio::test(flavor = "current_thread")]
async fn concurrent_external_replacement_is_preserved_and_published() {
+ crate::audit::tests::take_events();
let storage = Arc::new(TestStorage::new(Some(policy("current", 1))));
let store = PolicyStore::load_with_storage(
Some(PathBuf::from(r"C:\policy.json")),
@@ -909,9 +1129,13 @@ mod storage_tests {
Monitoring::Available,
);
let request = update_request(&store);
+ let (audit, recorder) = recording_audit();
storage.race_before_next_persist(policy("external", 7));
- let error = store.replace(request).await.expect_err("external replacement wins");
+ let error = store
+ .replace(request, audit)
+ .await
+ .expect_err("external replacement wins");
assert_eq!(error.code, ErrorCode::StalePolicyStoreToken);
assert_eq!(
@@ -926,6 +1150,58 @@ mod storage_tests {
.revision,
7
);
+ assert_eq!(
+ crate::audit::tests::take_events()
+ .iter()
+ .map(|entry| entry.event_code)
+ .collect::>(),
+ [Some(agent_sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED)]
+ );
+ assert_eq!(
+ recorder
+ .events()
+ .iter()
+ .map(|entry| entry.event_code)
+ .collect::>(),
+ [
+ Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED),
+ Some(agent_sysevent_codes::POLICY_CHANGE_FAILED)
+ ]
+ );
+ assert!(
+ recorder.events()[1]
+ .fields
+ .iter()
+ .any(|(name, value)| name == "outcome" && value == "stale_conflict")
+ );
+ }
+
+ #[tokio::test]
+ async fn audited_replacement_records_one_success_after_activation() {
+ let store = PolicyStore::load_with_storage(
+ Some(PathBuf::from(r"C:\policy.json")),
+ Arc::new(TestStorage::new(Some(policy("current", 1)))),
+ Monitoring::Available,
+ );
+ let (audit, recorder) = recording_audit();
+
+ let success = store
+ .replace(update_request(&store), audit)
+ .await
+ .expect("replacement succeeds");
+
+ assert_eq!(success.policy.metadata.revision, 2);
+ assert_eq!(
+ recorder
+ .events()
+ .iter()
+ .map(|entry| entry.event_code)
+ .collect::>(),
+ [
+ Some(agent_sysevent_codes::POLICY_WRITE_ATTEMPTED),
+ Some(agent_sysevent_codes::POLICY_CHANGE_SUCCEEDED)
+ ]
+ );
}
#[tokio::test]
@@ -942,7 +1218,10 @@ mod storage_tests {
.fail_concurrent_check
.store(true, std::sync::atomic::Ordering::SeqCst);
- let error = store.replace(request).await.expect_err("identity check fails");
+ let error = store
+ .replace_for_tests(request)
+ .await
+ .expect_err("identity check fails");
assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed);
assert_eq!(store.management_snapshot().store_token, previous_token);
@@ -970,7 +1249,10 @@ mod storage_tests {
.fail_target_retention
.store(true, std::sync::atomic::Ordering::SeqCst);
- let error = store.replace(request).await.expect_err("target retention fails");
+ let error = store
+ .replace_for_tests(request)
+ .await
+ .expect_err("target retention fails");
assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed);
assert_eq!(store.management_snapshot().store_token, previous_token);
@@ -996,7 +1278,10 @@ mod storage_tests {
*storage.post_persist_capability.lock() =
Some((PolicyWriteCapability::ReadOnly, Some(PolicyReadOnlyReason::UnsafePath)));
- let success = store.replace(request).await.expect("policy replacement succeeds");
+ let success = store
+ .replace_for_tests(request)
+ .await
+ .expect("policy replacement succeeds");
assert_eq!(success.management.write_capability, PolicyWriteCapability::ReadOnly);
assert_eq!(
@@ -1022,7 +1307,10 @@ mod storage_tests {
);
assert_eq!(store.watched_path(), canonical);
- let success = store.replace(update_request(&store)).await.expect("replace policy");
+ let success = store
+ .replace_for_tests(update_request(&store))
+ .await
+ .expect("replace policy");
assert_eq!(&*storage.persisted_configured_paths.lock(), &[configured]);
assert_eq!(store.watched_path(), canonical);
diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs
index 4aec0b184..300c416fb 100644
--- a/crates/now-package-broker/src/policy_store/receipt.rs
+++ b/crates/now-package-broker/src/policy_store/receipt.rs
@@ -115,7 +115,6 @@ mod tests {
expected_store_token: store.management_snapshot().store_token,
operation,
conflict_handling: PolicyConflictHandling::Reject,
- warnings_acknowledged: false,
draft: raw,
validation_receipt: validation.validation_receipt.expect("valid receipt"),
}
@@ -199,7 +198,10 @@ mod tests {
let mut stale_request = request(&store, PolicyReplacementOperation::Update, raw.clone());
storage.set_disk_state(Some(policy("retargeted", 9)), false, 9);
stale_request.conflict_handling = PolicyConflictHandling::ConfirmOverwrite;
- let stale_error = store.replace(stale_request).await.expect_err("stale token rejected");
+ let stale_error = store
+ .replace_for_tests(stale_request)
+ .await
+ .expect_err("stale token rejected");
assert_eq!(stale_error.code, ErrorCode::StalePolicyStoreToken);
assert!(stale_error.management.is_some());
assert_eq!(
@@ -208,11 +210,14 @@ mod tests {
);
let mut tampered = request(&store, PolicyReplacementOperation::Update, raw);
tampered.draft["Metadata"]["Publisher"] = "Tampered".into();
- let receipt_error = store.replace(tampered).await.expect_err("tampered draft rejected");
+ let receipt_error = store
+ .replace_for_tests(tampered)
+ .await
+ .expect_err("tampered draft rejected");
assert_eq!(receipt_error.code, ErrorCode::ValidationFailed);
}
#[tokio::test]
- async fn store_requires_warning_acknowledgement() {
+ async fn store_saves_valid_drafts_with_advisory_findings() {
let store = PolicyStore::for_tests(None);
let mut risky = serde_json::to_value(draft("risky")).expect("serialize draft");
risky["Rules"] = serde_json::Value::Array(
@@ -251,14 +256,11 @@ mod tests {
serde_json::to_value(round_trip).expect("serialize round-tripped validation result"),
serialized
);
- let mut replacement = request(&store, PolicyReplacementOperation::Create, risky);
- let error = store
- .replace(replacement.clone())
+ let replacement = request(&store, PolicyReplacementOperation::Create, risky);
+ store
+ .replace_for_tests(replacement)
.await
- .expect_err("warning must be acknowledged");
- assert_eq!(error.code, ErrorCode::WarningConfirmationRequired);
- replacement.warnings_acknowledged = true;
- store.replace(replacement).await.expect("acknowledged warning succeeds");
+ .expect("advisory findings do not block a valid draft");
}
#[tokio::test]
async fn canonical_sensitive_warnings_accept_the_original_receipt() {
@@ -309,7 +311,6 @@ mod tests {
expected_store_token: store.management_snapshot().store_token,
operation: PolicyReplacementOperation::Create,
conflict_handling: PolicyConflictHandling::Reject,
- warnings_acknowledged: true,
draft: canonical.clone(),
validation_receipt: receipt.clone(),
};
@@ -317,13 +318,13 @@ mod tests {
let mut changed = replacement.clone();
changed.draft["Rules"][0]["Constraints"]["AllowSkipHashCheck"] = serde_json::json!(false);
let error = store
- .replace(changed)
+ .replace_for_tests(changed)
.await
.expect_err("meaningful option change invalidates receipt");
assert_eq!(error.code, ErrorCode::ValidationFailed);
}
store
- .replace(replacement)
+ .replace_for_tests(replacement)
.await
.unwrap_or_else(|error| panic!("{option} via {explicit} failed: {error:?}"));
}
@@ -334,21 +335,21 @@ mod tests {
let create = PolicyStore::for_tests(None);
let raw = serde_json::to_value(draft("created")).expect("serialize draft");
let created = create
- .replace(request(&create, PolicyReplacementOperation::Create, raw))
+ .replace_for_tests(request(&create, PolicyReplacementOperation::Create, raw))
.await
.expect("create succeeds");
assert_eq!(created.policy.metadata.revision, 1);
let update = PolicyStore::for_tests(Some(policy("current", 7)));
let raw = serde_json::to_value(draft("current")).expect("serialize draft");
let updated = update
- .replace(request(&update, PolicyReplacementOperation::Update, raw))
+ .replace_for_tests(request(&update, PolicyReplacementOperation::Update, raw))
.await
.expect("update succeeds");
assert_eq!(updated.policy.metadata.revision, 8);
let replace = PolicyStore::for_tests(Some(policy("current", 7)));
let raw = serde_json::to_value(draft("replacement")).expect("serialize draft");
let replaced = replace
- .replace(request(&replace, PolicyReplacementOperation::ReplaceIdentity, raw))
+ .replace_for_tests(request(&replace, PolicyReplacementOperation::ReplaceIdentity, raw))
.await
.expect("identity replacement succeeds");
assert_eq!(replaced.policy.metadata.revision, 1);
@@ -360,14 +361,14 @@ mod tests {
);
let raw = serde_json::to_value(draft("repaired")).expect("serialize draft");
let repaired = repair
- .replace(request(&repair, PolicyReplacementOperation::Repair, raw))
+ .replace_for_tests(request(&repair, PolicyReplacementOperation::Repair, raw))
.await
.expect("repair succeeds");
assert_eq!(repaired.policy.metadata.revision, 1);
let wrong_identity = PolicyStore::for_tests(Some(policy("current", 1)));
let raw = serde_json::to_value(draft("different")).expect("serialize draft");
let error = wrong_identity
- .replace(request(&wrong_identity, PolicyReplacementOperation::Update, raw))
+ .replace_for_tests(request(&wrong_identity, PolicyReplacementOperation::Update, raw))
.await
.expect_err("update must preserve identity");
assert_eq!(error.code, ErrorCode::Conflict);
@@ -377,7 +378,7 @@ mod tests {
let store = PolicyStore::for_tests(Some(policy("current", 1)));
let raw = serde_json::to_value(draft("current")).expect("serialize draft");
let first = request(&store, PolicyReplacementOperation::Update, raw);
- let (first, second) = tokio::join!(store.replace(first.clone()), store.replace(first));
+ let (first, second) = tokio::join!(store.replace_for_tests(first.clone()), store.replace_for_tests(first));
let outcomes = [first, second];
assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(
@@ -399,7 +400,7 @@ mod tests {
storage.fail_persist.store(true, std::sync::atomic::Ordering::SeqCst);
let raw = serde_json::to_value(draft("current")).expect("serialize draft");
let error = store
- .replace(request(&store, PolicyReplacementOperation::Update, raw))
+ .replace_for_tests(request(&store, PolicyReplacementOperation::Update, raw))
.await
.expect_err("persistence failure");
assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed);
@@ -470,7 +471,7 @@ mod tests {
);
replacement.expected_store_token = token;
let error = store
- .replace(replacement)
+ .replace_for_tests(replacement)
.await
.expect_err("monitoring failure blocks PUT");
assert_eq!(error.code, ErrorCode::BrokerPaused);
diff --git a/crates/now-package-broker/src/policy_store/validation.rs b/crates/now-package-broker/src/policy_store/validation.rs
index 2f0771679..c24cf8da7 100644
--- a/crates/now-package-broker/src/policy_store/validation.rs
+++ b/crates/now-package-broker/src/policy_store/validation.rs
@@ -10,6 +10,8 @@ use now_policy_api::{
API_VERSION_STR, PolicyFinding, PolicyFindingCode, PolicyFindingSeverity, PolicyValidationResult,
};
+use crate::evaluator;
+
pub(super) const VALIDATOR_VERSION: &str = "now-package-broker-policy-validator/10";
const MAX_RULES: usize = 1024;
const MAX_RULE_PRIORITY: u32 = i32::MAX as u32;
@@ -419,6 +421,17 @@ fn check_rule(index: usize, rule: &PolicyRule, findings: &mut Findings) {
if let Some(reason) = &rule.reason {
check_string_len(reason, 0, 512, &format!("{base}/Reason"), findings);
}
+ for (source_index, source_name) in rule.match_criteria.source_names.iter().enumerate() {
+ if !evaluator::source_name_is_unambiguous(source_name.as_ref()) {
+ findings.push(rule_finding(
+ rule,
+ PolicyFindingSeverity::Error,
+ PolicyFindingCode::InvalidFieldValue,
+ format!("{base}/Match/SourceNames/{source_index}"),
+ "SourceNames must not contain leading, trailing, or default-ignorable characters",
+ ));
+ }
+ }
if let Some(PackageIdentifierCondition::Patterns(patterns)) = &rule.match_criteria.package_identifiers {
check_patterns(
index,
@@ -709,6 +722,34 @@ mod tests {
assert_eq!(canonical.pointer("/Rules/0/Match/Interactive"), Some(&json!(false)));
}
+ #[test]
+ fn source_names_reject_ambiguous_spellings() {
+ for source_name in ["PSGallery ", " PSGallery", "PS\u{00AD}Gallery"] {
+ let mut raw = draft();
+ raw["Rules"] = json!([rule(
+ "deny",
+ json!({ "Managers": ["PowerShell"], "SourceNames": [source_name] })
+ )]);
+
+ let result = validate_draft(&raw);
+
+ assert!(!result.is_valid, "{source_name:?} must be rejected");
+ assert!(
+ result
+ .findings
+ .iter()
+ .any(|finding| finding.path == "/Rules/0/Match/SourceNames/0")
+ );
+ }
+
+ let mut valid = draft();
+ valid["Rules"] = json!([rule(
+ "deny",
+ json!({ "Managers": ["PowerShell"], "SourceNames": ["PSGallery"] })
+ )]);
+ assert!(validate_draft(&valid).is_valid);
+ }
+
#[test]
fn shared_contract_rejects_invalid_rule_shapes() {
let cases = [
diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs
index 972029cf2..52b802742 100644
--- a/crates/now-package-broker/src/policy_store/windows.rs
+++ b/crates/now-package-broker/src/policy_store/windows.rs
@@ -34,10 +34,11 @@ use windows::Win32::Storage::FileSystem::{
CREATE_NEW, CreateFileW, DELETE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT,
FILE_DISPOSITION_FLAG_DELETE, FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE,
FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, FILE_DISPOSITION_INFO_EX, FILE_DISPOSITION_INFO_EX_FLAGS,
- FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, FILE_GENERIC_READ,
- FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, FILE_SHARE_DELETE,
- FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx, FileRenameInfo,
- FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL, SetFileInformationByHandle,
+ FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_DELETE_ON_CLOSE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH,
+ FILE_GENERIC_READ, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0,
+ FILE_SHARE_DELETE, FILE_SHARE_NONE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileDispositionInfoEx,
+ FileRenameInfo, FileRenameInfoEx, GetVolumeInformationW, GetVolumePathNameW, READ_CONTROL,
+ SetFileInformationByHandle,
};
#[cfg(test)]
use windows::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW};
@@ -544,6 +545,11 @@ impl std::error::Error for UnsupportedAtomicSemantics {}
/// Filesystem names known to support atomic same-directory handle renames.
/// Conservative by design: an unrecognized filesystem is treated as unsupported.
const ATOMIC_REPLACE_CAPABLE_FILESYSTEMS: &[&str] = &["NTFS", "ReFS"];
+const PROBE_SOURCE_NAME: &str = ".package-broker-write-probe-a.tmp";
+const PROBE_TARGET_NAME: &str = ".package-broker-write-probe-b.tmp";
+const PROBE_TOMBSTONE_NAME: &str = ".package-broker-write-probe-old.tmp";
+const PROBE_SOURCE_CONTENT: &[u8] = b"probe-source";
+const PROBE_TARGET_CONTENT: &[u8] = b"probe-target";
/// Verifies that `dir` supports the handle-based tombstone and create-new publication semantics required by [`atomic_replace`].
///
@@ -559,11 +565,12 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> {
}
let dir_handle = open_directory_no_reparse(dir)?;
- let source_path = dir.join(".package-broker-write-probe-a.tmp");
- let target_path = dir.join(".package-broker-write-probe-b.tmp");
- let tombstone_path = dir.join(".package-broker-write-probe-old.tmp");
- let source = create_probe_file(&source_path, b"probe-source", false)?;
- let target = match create_probe_file(&target_path, b"probe-target", true) {
+ recover_interrupted_write_capability_probe(&dir_handle, dir)?;
+ let source_path = dir.join(PROBE_SOURCE_NAME);
+ let target_path = dir.join(PROBE_TARGET_NAME);
+ let tombstone_path = dir.join(PROBE_TOMBSTONE_NAME);
+ let source = create_probe_file(&source_path, PROBE_SOURCE_CONTENT, false)?;
+ let target = match create_probe_file(&target_path, PROBE_TARGET_CONTENT, true) {
Ok(target) => target,
Err(error) => {
return match cleanup_probe_file(source, &source_path, "write-capability probe source") {
@@ -595,7 +602,7 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> {
source_published = true;
let replaced = std::fs::read(&target_path).context("read write-capability probe result")?;
ensure!(
- replaced == b"probe-source",
+ replaced == PROBE_SOURCE_CONTENT,
"atomic replacement did not take effect on this filesystem"
);
delete_file_handle(&target).context("probe POSIX tombstone unlink")?;
@@ -620,6 +627,59 @@ fn probe_write_capability(dir: &Path) -> anyhow::Result<()> {
probe_result.and(source_cleanup).and(target_cleanup)
}
+/// Retire a trusted remnant from a capability probe interrupted before its
+/// delete-on-close handles were released.
+///
+/// The fixed names are only reclaimable when the entry is a non-reparse,
+/// single-link, managed-policy file containing one of the probe's exact payloads.
+/// This preserves fail-closed collision handling for untrusted lookalikes.
+fn recover_interrupted_write_capability_probe(dir: &File, dir_path: &Path) -> anyhow::Result<()> {
+ verify_directory_handle_type(dir, "write-capability probe directory")?;
+
+ for (name, expected_contents) in [
+ (PROBE_SOURCE_NAME, &[PROBE_SOURCE_CONTENT][..]),
+ (PROBE_TARGET_NAME, &[PROBE_TARGET_CONTENT, PROBE_SOURCE_CONTENT][..]),
+ (PROBE_TOMBSTONE_NAME, &[PROBE_TARGET_CONTENT][..]),
+ ] {
+ let path = dir_path.join(name);
+ let file = match OpenOptions::new()
+ .access_mode(FILE_GENERIC_READ.0 | DELETE.0 | READ_CONTROL.0)
+ .share_mode(FILE_SHARE_READ.0)
+ .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0)
+ .open(&path)
+ {
+ Ok(file) => file,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(error) => {
+ return Err(error)
+ .with_context(|| format!("failed to open interrupted write probe {}", path.display()));
+ }
+ };
+
+ policy_security::verify_policy_file_path(&file, &path)
+ .with_context(|| format!("interrupted write probe {} is unsafe", path.display()))?;
+ policy_security::verify_managed_policy_file_security(&file)
+ .with_context(|| format!("interrupted write probe {} has unsafe security", path.display()))?;
+ ensure!(
+ policy_security::file_link_count(&file)? == 1,
+ "interrupted write probe {} has multiple hard links",
+ path.display()
+ );
+ let content = read_file_from_start(&file)
+ .with_context(|| format!("failed to read interrupted write probe {}", path.display()))?;
+ ensure!(
+ expected_contents.contains(&content.as_slice()),
+ "interrupted write probe {} has unexpected content",
+ path.display()
+ );
+ delete_file_handle(&file)
+ .with_context(|| format!("failed to retire interrupted write probe {}", path.display()))?;
+ drop(file);
+ ensure_path_absent(&path, "interrupted write probe")?;
+ }
+ Ok(())
+}
+
fn verify_no_replace_collision(
source: &File,
target: &File,
@@ -775,7 +835,7 @@ fn create_probe_file(path: &Path, bytes: &[u8], allow_delete_share: bool) -> any
}
.0,
)
- .custom_flags((FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0)
+ .custom_flags((FILE_FLAG_DELETE_ON_CLOSE | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH).0)
.open(path)
.with_context(|| format!("failed to create write-capability probe {}", path.display()))?;
if let Err(error) = file
@@ -4148,7 +4208,7 @@ mod tests {
// ─── probe_write_capability / volume_filesystem_name ──────────────────────
//
- // No elevation required: these never touch `admin_only_security_attributes`.
+ // No elevation required: ordinary probes use inherited directory security.
#[test]
fn volume_filesystem_name_reports_a_known_filesystem_for_a_temp_directory() {
@@ -4171,6 +4231,54 @@ mod tests {
assert!(leftover.is_empty(), "probe left files behind: {leftover:?}");
}
+ #[test]
+ fn probe_file_is_removed_when_its_handle_closes() {
+ let dir = temp_dir();
+ let path = dir.path().join(PROBE_SOURCE_NAME);
+ let file = create_probe_file(&path, PROBE_SOURCE_CONTENT, false).unwrap();
+
+ drop(file);
+
+ assert!(!path.exists(), "delete-on-close probe file survived its handle");
+ }
+
+ #[test]
+ fn interrupted_trusted_probe_remnant_is_recovered() {
+ use std::io::Write as _;
+
+ let dir = temp_dir();
+ let dir_file = open_directory_no_reparse(dir.path()).unwrap();
+ let path = dir.path().join(PROBE_SOURCE_NAME);
+ let mut file = match create_secure_transaction_file(&path) {
+ Ok(file) => file,
+ Err(error) => {
+ tracing::warn!(
+ error = %format!("{error:#}"),
+ "Skipping administrator-owned probe recovery fixture"
+ );
+ return;
+ }
+ };
+ file.write_all(PROBE_SOURCE_CONTENT).unwrap();
+ file.sync_all().unwrap();
+ drop(file);
+
+ recover_interrupted_write_capability_probe(&dir_file, dir.path()).unwrap();
+
+ assert!(!path.exists(), "trusted interrupted probe remnant was not retired");
+ }
+
+ #[test]
+ fn interrupted_untrusted_probe_remnant_is_not_removed() {
+ let dir = temp_dir();
+ let dir_file = open_directory_no_reparse(dir.path()).unwrap();
+ let path = dir.path().join(PROBE_SOURCE_NAME);
+ std::fs::write(&path, PROBE_SOURCE_CONTENT).unwrap();
+
+ assert!(recover_interrupted_write_capability_probe(&dir_file, dir.path()).is_err());
+ assert!(path.exists(), "untrusted probe collision must remain fail-closed");
+ }
+
#[test]
fn occupied_no_replace_probe_preserves_both_retained_files() {
let dir = temp_dir();
diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs
index 61664d16c..9d5fcef8c 100644
--- a/crates/now-package-broker/src/server/mod.rs
+++ b/crates/now-package-broker/src/server/mod.rs
@@ -2,6 +2,7 @@
use std::collections::HashMap;
use std::fmt;
+use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -25,6 +26,7 @@ use now_policy_api::{
use now_policy_server_template::{
MAX_POLICY_MANAGEMENT_BODY_BYTES, MAX_REQUEST_BODY_BYTES, PackageBrokerServer, SharedPackageBrokerServer,
};
+use tokio::sync::watch;
use tracing::{info, trace, warn};
use win_api_wrappers::identity::sid::Sid;
@@ -48,6 +50,7 @@ use responses::{
// The unit value marks the scope in which an authenticated policy management request is dispatched.
tokio::task_local! {
static POLICY_MANAGEMENT_AUTHENTICATED: ();
+ static POLICY_WRITE_AUDIT: crate::audit::WriteAudit;
}
/// How long a per-user manager availability probe stays fresh before it is re-run.
@@ -110,7 +113,25 @@ struct EvaluatedRequest {
}
/// Build the axum router for a single authenticated pipe client.
+#[cfg(test)]
pub(crate) fn build_router_for_client(state: Arc, client: PipeClient) -> axum::Router {
+ build_router_for_client_with_optional_policy_write_deadline(state, client, None)
+}
+
+/// Build the router and signal when the exact helper policy-write authorization completes.
+pub(crate) fn build_router_for_client_with_policy_write_deadline(
+ state: Arc,
+ client: PipeClient,
+ policy_write_deadline: watch::Sender,
+) -> axum::Router {
+ build_router_for_client_with_optional_policy_write_deadline(state, client, Some(policy_write_deadline))
+}
+
+fn build_router_for_client_with_optional_policy_write_deadline(
+ state: Arc,
+ client: PipeClient,
+ policy_write_deadline: Option>,
+) -> axum::Router {
let server: SharedPackageBrokerServer = Arc::new(BrokerConnection {
state: Arc::clone(&state),
client: client.clone(),
@@ -118,6 +139,7 @@ pub(crate) fn build_router_for_client(state: Arc, client: PipeClien
axum::Router::from(now_policy_server_template::api_router_from_shared(server))
.layer(middleware::from_fn(reject_duplicate_policy_json_members))
.layer(middleware::from_fn_with_state(state, authenticate_policy_management))
+ .layer(Extension(policy_write_deadline))
.layer(Extension(client))
}
@@ -290,9 +312,14 @@ fn reject_duplicate_json_members(bytes: &[u8]) -> Result<(), serde_json::Error>
async fn authenticate_policy_management(
State(state): State>,
Extension(client): Extension,
+ Extension(policy_write_deadline): Extension