From ac66cc73e6b60c86ace6906f4400c1a1a4436647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 29 Aug 2026 20:01:17 +0900 Subject: [PATCH 1/5] feat(agent,agent-installer): add transactional policy store Add authenticated policy management and validation for the package broker. Persist JSON policies atomically with secure path, ACL, concurrency, receipt, watcher, and audit checks while preserving stable runtime snapshots. Stacked on Devolutions/devolutions-gateway#1937. Uses the contract from Devolutions/now-libraries#99. UniGetUI policy management depends on this API. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 34 +- Cargo.lock | 140 +- Cargo.toml | 6 + crates/agent-policy-tester/Cargo.toml | 1 + crates/agent-policy-tester/run-as-system.ps1 | 2 +- crates/agent-policy-tester/src/windows.rs | 1081 ++++++- crates/now-package-broker/Cargo.toml | 7 +- crates/now-package-broker/src/audit.rs | 203 ++ crates/now-package-broker/src/auth.rs | 85 +- .../now-package-broker/src/evaluator/mod.rs | 2 +- .../src/evaluator/wildcard.rs | 40 +- crates/now-package-broker/src/lib.rs | 8 +- .../now-package-broker/src/policy_loader.rs | 106 - .../now-package-broker/src/policy_security.rs | 436 ++- .../src/policy_store/mod.rs | 2543 +++++++++++++++++ .../src/policy_store/receipt.rs | 178 ++ .../src/policy_store/validation.rs | 1822 ++++++++++++ .../src/policy_store/windows.rs | 1846 ++++++++++++ .../now-package-broker/src/policy_watcher.rs | 143 - .../now-package-broker/src/scenario_tests.rs | 8 +- crates/now-package-broker/src/server/mod.rs | 460 ++- .../src/server/responses.rs | 181 +- crates/now-package-broker/src/task.rs | 93 +- crates/now-package-broker/src/test_support.rs | 11 + crates/sysevent-codes/src/lib.rs | 161 ++ .../tests/message_catalog_parity.rs | 133 + crates/sysevent-winevent/src/lib.rs | 46 +- crates/win-api-wrappers/src/token.rs | 30 + devolutions-agent/build.rs | 115 + devolutions-agent/devolutions-agent.mc | 498 ++++ devolutions-gateway/devolutions-gateway.mc | 89 + .../Actions/AgentActions.cs | 38 + .../Actions/CustomActions.cs | 53 + package/AgentWindowsManaged/Program.cs | 10 + .../AgentWindowsManaged/Resources/Includes.cs | 22 + 35 files changed, 10129 insertions(+), 502 deletions(-) create mode 100644 crates/now-package-broker/src/audit.rs delete mode 100644 crates/now-package-broker/src/policy_loader.rs create mode 100644 crates/now-package-broker/src/policy_store/mod.rs create mode 100644 crates/now-package-broker/src/policy_store/receipt.rs create mode 100644 crates/now-package-broker/src/policy_store/validation.rs create mode 100644 crates/now-package-broker/src/policy_store/windows.rs delete mode 100644 crates/now-package-broker/src/policy_watcher.rs create mode 100644 crates/now-package-broker/src/test_support.rs create mode 100644 crates/sysevent-codes/tests/message_catalog_parity.rs create mode 100644 devolutions-agent/devolutions-agent.mc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6d8729c9..25cb0af7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -953,6 +953,9 @@ 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 @@ -966,6 +969,15 @@ jobs: if: ${{ matrix.os == 'windows' }} uses: microsoft/setup-msbuild@v3 + - name: Find mc.exe + id: find_mc + if: ${{ matrix.os == 'windows' }} + run: | + Enter-VsDevShell + $path = (Get-Command -Type Application mc).Source | Split-Path -Parent + Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + shell: pwsh + - name: Build run: | if ($Env:RUNNER_OS -eq "Windows") { @@ -976,6 +988,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") { @@ -1313,7 +1326,17 @@ jobs: exit $LASTEXITCODE } - - name: Run Agent policy tester as LocalSystem + # Runs as the ordinary, unelevated CI runner account (never SYSTEM/elevated): the + # complementary half of the split test suite (item 23). Running the whole tester + # only under `psexec -s` (as the step below still does, for the privileged half) + # made the unelevated `PUT` denial assertion contradictory, since that process + # actually *is* elevated/SYSTEM. + - name: Run Agent policy tester (unelevated) + shell: pwsh + run: | + cargo run --locked -p agent-policy-tester -- (Resolve-Path "./target/debug/devolutions-agent.exe") unelevated + + - name: Run Agent policy tester as LocalSystem (elevated) shell: pwsh run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-as-system.ps1" @@ -1324,6 +1347,15 @@ jobs: exit $exitCode } + # The dev-signature-bypass-only unit tests (elevation/Administrators gating at the + # HTTP route layer; see `now-package-broker::server::tests::elevation_gating`) only + # compile and run under this feature, so they are exercised here alongside the + # other `dev-skip-broker-signature`-dependent steps in this job rather than in the + # default `cargo test --workspace` run. + - name: Run now-package-broker dev-skip-broker-signature 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 diff --git a/Cargo.lock b/Cargo.lock index 0aedd5f92..1c9e38cf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,7 @@ version = "0.0.0" dependencies = [ "anyhow", "fastrand", + "now-policy-server-template", "serde_json", "tempfile", "tokio 1.52.3", @@ -170,7 +171,28 @@ dependencies = [ "cfg-if", "http 1.4.2", "indexmap 2.14.0", - "schemars", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_qs", + "thiserror 2.0.20", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "aide" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6966317188cdfe54c58c0900a195d021294afb3ece9b7073d09e4018dbb1e3a2" +dependencies = [ + "axum 0.8.9", + "bytes 1.12.1", + "cfg-if", + "http 1.4.2", + "indexmap 2.14.0", + "schemars 0.9.0", "serde", "serde_json", "serde_qs", @@ -1551,7 +1573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -1920,7 +1942,7 @@ dependencies = [ name = "devolutions-pedm" version = "2026.2.4" dependencies = [ - "aide", + "aide 0.14.2", "anyhow", "async-trait", "axum 0.8.9", @@ -1941,7 +1963,7 @@ dependencies = [ "hyper-util", "libsql", "parking_lot", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "sha1 0.11.0", @@ -1994,7 +2016,7 @@ dependencies = [ "hyper 0.14.32", "pin-project 1.1.13", "regex", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "tokio 1.52.3", @@ -2321,7 +2343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2637,8 +2659,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]] @@ -3144,12 +3166,12 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite 0.2.17", - "socket2 0.6.5", + "socket2 0.5.10", "system-configuration", "tokio 1.52.3", "tower-service", "tracing", - "windows-registry 0.6.1", + "windows-registry 0.5.3", ] [[package]] @@ -3164,7 +3186,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3683,7 +3705,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4767,6 +4789,7 @@ dependencies = [ "devolutions-agent-shared", "devolutions-gateway-task", "hex", + "hmac", "hyper 1.10.1", "hyper-util", "notify 7.0.0", @@ -4778,8 +4801,10 @@ dependencies = [ "semver", "serde", "serde_json", - "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", + "sysevent", + "sysevent-codes", + "sysevent-winevent", "tempfile", "tokio 1.52.3", "tokio-util", @@ -4794,15 +4819,13 @@ dependencies = [ [[package]] name = "now-policy" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc04b547209a37ecd3993eb47773ec6fa38f8cac178479079f650555bbedbbb" +source = "git+https://github.com/Devolutions/now-libraries.git?rev=ec592a91f95a66c8a79b7d612ed73d931ef74aeb#ec592a91f95a66c8a79b7d612ed73d931ef74aeb" dependencies = [ "chrono", - "schemars", + "schemars 0.9.0", "semver", "serde", "serde_json", - "serde_yaml", "thiserror 2.0.20", "url", ] @@ -4810,12 +4833,12 @@ dependencies = [ [[package]] name = "now-policy-api" version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa0817fd85c0a6b0173e2b837fa2b369be684c93c11fed1b5182284021411839" +source = "git+https://github.com/Devolutions/now-libraries.git?rev=ec592a91f95a66c8a79b7d612ed73d931ef74aeb#ec592a91f95a66c8a79b7d612ed73d931ef74aeb" dependencies = [ "chrono", "derive_more", - "schemars", + "now-policy", + "schemars 0.9.0", "semver", "serde", "serde_json", @@ -4826,14 +4849,14 @@ dependencies = [ [[package]] name = "now-policy-server-template" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4480ae83e4c1302f47f3b033804b9b2f5054b5fb8c0079a8bf7c63c8a813d7a" +source = "git+https://github.com/Devolutions/now-libraries.git?rev=ec592a91f95a66c8a79b7d612ed73d931ef74aeb#ec592a91f95a66c8a79b7d612ed73d931ef74aeb" dependencies = [ - "aide", + "aide 0.15.1", "async-trait", "axum 0.8.9", + "now-policy", "now-policy-api", - "schemars", + "schemars 0.9.0", "serde", "serde_json", "serde_yaml", @@ -4866,7 +4889,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6013,7 +6036,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls 0.23.43", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.20", "tokio 1.52.3", "tracing", @@ -6053,9 +6076,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6251,6 +6274,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.46", + "syn 3.0.3", +] + [[package]] name = "regex" version = "1.13.1" @@ -6552,7 +6595,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6652,7 +6695,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6730,12 +6773,27 @@ dependencies = [ "chrono", "dyn-clone", "indexmap 2.14.0", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "uuid", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "chrono", + "dyn-clone", + "indexmap 2.14.0", + "ref-cast", + "schemars_derive 0.9.0", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -6748,6 +6806,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "schemars_derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5016d94c77c6d32f0b8e08b781f7dc8a90c2007d4e77472cc2807bc10a8438fe" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.46", + "serde_derive_internals", + "syn 2.0.118", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -7180,7 +7250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7484,7 +7554,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8944,7 +9014,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e5eb35315..b82d0cb6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,12 @@ lto = true [patch.crates-io] tracing-appender = { git = "https://github.com/CBenoit/tracing.git", rev = "42097daf92e683cf18da7639ddccb056721a796c" } +# Freeze on the exact commit implementing the Phase 2 package-policy management contract +# (Devolutions/now-libraries#99) ahead of publication. Pin with `rev` (not `branch`) so the +# dependency never silently moves as the upstream branch is amended. +now-policy = { git = "https://github.com/Devolutions/now-libraries.git", rev = "ec592a91f95a66c8a79b7d612ed73d931ef74aeb" } +now-policy-api = { git = "https://github.com/Devolutions/now-libraries.git", rev = "ec592a91f95a66c8a79b7d612ed73d931ef74aeb" } +now-policy-server-template = { git = "https://github.com/Devolutions/now-libraries.git", rev = "ec592a91f95a66c8a79b7d612ed73d931ef74aeb" } [workspace.lints.rust] # Declare the custom cfgs. diff --git a/crates/agent-policy-tester/Cargo.toml b/crates/agent-policy-tester/Cargo.toml index ba2f20f77..071eb26df 100644 --- a/crates/agent-policy-tester/Cargo.toml +++ b/crates/agent-policy-tester/Cargo.toml @@ -9,6 +9,7 @@ anyhow = "1" [target.'cfg(windows)'.dependencies] fastrand = "2" +now-policy-server-template = "0.3" serde_json = "1" tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "time"] } diff --git a/crates/agent-policy-tester/run-as-system.ps1 b/crates/agent-policy-tester/run-as-system.ps1 index 10b988fb4..94db383ad 100644 --- a/crates/agent-policy-tester/run-as-system.ps1 +++ b/crates/agent-policy-tester/run-as-system.ps1 @@ -6,7 +6,7 @@ $agentPath = Join-Path $workspacePath "target/debug/devolutions-agent.exe" $outputPath = Join-Path $PSScriptRoot "agent-policy-tester.out" try { - & $testerPath $agentPath 2>&1 | Out-File $outputPath + & $testerPath $agentPath elevated 2>&1 | Out-File $outputPath $exitCode = $LASTEXITCODE } catch { $_ | Out-File $outputPath -Append diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 3ff4250a5..885c4cc51 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -3,28 +3,160 @@ use std::process::Stdio; use std::time::{Duration, Instant}; use anyhow::{Context as _, bail, ensure}; +use now_policy_server_template::{MAX_POLICY_MANAGEMENT_BODY_BYTES, MAX_REQUEST_BODY_BYTES}; use serde_json::{Value, json}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe::ClientOptions; const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); +/// The Agent's test data/config directory (also hosting `PolicyPath`), materialized +/// differently depending on [`Mode`] (item 23). +/// +/// `Mode::Unelevated` uses an ordinary, non-privileged temporary directory: never +/// touching anything privilege-sensitive, and (see +/// [`unelevated_management_and_validation_succeed_put_requires_administrator`]) +/// deliberately owned by the current, non-admin test user, so it correctly fails the +/// store's own custom-directory security check. +/// +/// `Mode::Elevated` instead creates a real, uniquely-named directory secured +/// SYSTEM/Administrators-only (see [`SecureTestDir`]), matching the strict bar the real +/// policy store enforces (`verify_policy_directory_security`): without this, every +/// elevated-mode test that expects a real `Active`/`Writable` observation would instead +/// see the store correctly (but unhelpfully, for testing) refuse an ordinary, +/// non-admin-owned temp directory. +enum TestHostDir { + Unelevated(tempfile::TempDir), + Elevated(SecureTestDir), +} + +impl TestHostDir { + fn create(mode: Mode) -> anyhow::Result { + match mode { + Mode::Unelevated => Ok(Self::Unelevated( + tempfile::tempdir().context("create Agent data directory")?, + )), + Mode::Elevated => Ok(Self::Elevated(SecureTestDir::create()?)), + } + } + + fn path(&self) -> &Path { + match self { + Self::Unelevated(dir) => dir.path(), + Self::Elevated(dir) => &dir.path, + } + } +} + +/// RAII guard for a uniquely-named directory under +/// `%ProgramData%\Devolutions\PackageBroker\tests`, secured SYSTEM/Administrators-only +/// (owner and DACL) before any policy file is ever created inside it, so the real policy +/// store's own directory-security check (`verify_policy_directory_security`) is +/// genuinely satisfied rather than run against an ordinary user-owned temp directory +/// that could never pass it under `LocalSystem`. +/// +/// Only used in `Mode::Elevated` (see [`TestHostDir`]): the tester process itself runs +/// as `LocalSystem` there (see `run-as-system.ps1`), which is exactly the identity that +/// needs `admin_only_security_attributes`-equivalent access to both secure and later +/// clean up this directory. +struct SecureTestDir { + path: PathBuf, +} + +impl SecureTestDir { + fn create() -> anyhow::Result { + let program_data = std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + let root = program_data.join("Devolutions").join("PackageBroker").join("tests"); + std::fs::create_dir_all(&root).context("create the test-host root directory")?; + + let path = root.join(format!("{}-{}", std::process::id(), fastrand::u64(..))); + std::fs::create_dir(&path).context("create the unique per-run test-host directory")?; + + // Owner must be a trusted principal (`verify_policy_directory_security`): + // SYSTEM, the same identity the real Agent service runs as in production. + let owner_status = std::process::Command::new("icacls.exe") + .arg(&path) + .args(["/setowner", "*S-1-5-18"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("set the test-host directory owner")?; + ensure!( + owner_status.success(), + "setting the test-host directory owner to LocalSystem failed; run the tester as LocalSystem" + ); + + // Break inheritance and grant SYSTEM/Administrators-only full control (the same + // admin-only bar `verify_policy_directory_security` enforces on the real policy + // directory): no other principal may create, rename, or delete entries, or + // rewrite the directory's own security descriptor. `(OI)(CI)` so the policy file + // subsequently created inside inherits the same admin-only grant. + let dacl_status = std::process::Command::new("icacls.exe") + .arg(&path) + .args([ + "/inheritance:r", + "/grant:r", + "*S-1-5-18:(OI)(CI)F", + "*S-1-5-32-544:(OI)(CI)F", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("set the test-host directory DACL")?; + ensure!( + dacl_status.success(), + "failed to set a system-and-administrators-only test-host directory DACL" + ); + + Ok(Self { path }) + } +} + +impl Drop for SecureTestDir { + fn drop(&mut self) { + // Best-effort cleanup. The directory (and the policy file created inside it, + // separately owned by SYSTEM via `secure_policy_file`) grants SYSTEM full + // control, and this process itself runs as SYSTEM in `Mode::Elevated` (see + // `run-as-system.ps1`), so removal is expected to succeed regardless of which of + // the two admin-only owners a given entry happens to carry. A leftover + // directory here would not corrupt any later run, since each run gets its own + // uniquely-named directory, but is still cleaned up so repeated runs do not + // accumulate stale directories under ProgramData. + let _ = std::fs::remove_dir_all(&self.path); + } +} + struct AgentHarness { child: tokio::process::Child, - _data_dir: tempfile::TempDir, + data_dir: TestHostDir, pipe_name: String, policy_path: PathBuf, } impl AgentHarness { - async fn start(agent_path: &Path, policy: Option<&Value>) -> anyhow::Result { - let data_dir = tempfile::tempdir().context("create Agent data directory")?; + async fn start(agent_path: &Path, mode: Mode, policy: Option<&Value>) -> anyhow::Result { + Self::start_with_file_name(agent_path, mode, "policy.json", policy).await + } + + /// Same as [`Self::start`], but configures `PolicyPath` with the given file name + /// instead of the fixed `policy.json` used everywhere else: used to exercise the + /// store's extension-based format rejection/acceptance (item 18/31), which the fixed + /// name can never itself trigger either way. + async fn start_with_file_name( + agent_path: &Path, + mode: Mode, + file_name: &str, + policy: Option<&Value>, + ) -> anyhow::Result { + let data_dir = TestHostDir::create(mode)?; let pipe_name = format!( r"\\.\pipe\Devolutions.Now.PackageBroker.tests.{}.{}", std::process::id(), fastrand::u64(..) ); - let policy_path = data_dir.path().join("policy.json"); + let policy_path = data_dir.path().join(file_name); if let Some(policy) = policy { std::fs::write(&policy_path, serde_json::to_vec_pretty(policy)?).context("write policy")?; @@ -44,18 +176,11 @@ 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 child = Self::spawn(agent_path, data_dir.path())?; let mut harness = Self { child, - _data_dir: data_dir, + data_dir, pipe_name, policy_path, }; @@ -64,6 +189,17 @@ impl AgentHarness { Ok(harness) } + fn spawn(agent_path: &Path, data_dir: &Path) -> anyhow::Result { + tokio::process::Command::new(agent_path) + .env("DAGENT_CONFIG_PATH", data_dir) + .arg("run") + .kill_on_drop(true) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("start Devolutions Agent") + } + async fn wait_until_ready(&mut self) -> anyhow::Result<()> { let deadline = Instant::now() + Duration::from_secs(20); @@ -80,6 +216,16 @@ impl AgentHarness { } } } + + /// Stop the Agent process and start a fresh one against the exact same data + /// directory (configuration and policy file untouched), reusing the same pipe name. + /// Used to prove a policy survives an Agent restart (item 23). + async fn restart(&mut self, agent_path: &Path) -> anyhow::Result<()> { + let _ = self.child.start_kill(); + let _ = self.child.wait().await; + self.child = Self::spawn(agent_path, self.data_dir.path())?; + self.wait_until_ready().await + } } impl Drop for AgentHarness { @@ -99,19 +245,75 @@ impl HttpResponse { } } +/// Test mode, matching whether the *tester process itself* is running elevated/as +/// SYSTEM (item 23): the two modes exercise disjoint, non-contradictory assertions, so +/// unlike a single suite that assumed a specific privilege level, either mode is correct +/// for the process it actually runs as. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + /// The tester process itself is an ordinary, unelevated, non-SYSTEM token: policy + /// writes (`PUT /v1/policy`) must be denied with `AdministratorRequired`, but + /// inspection/validation must still fully succeed. + Unelevated, + /// The tester process itself is elevated/SYSTEM (see `run-as-system.ps1`): the full + /// write lifecycle can be exercised end-to-end. + Elevated, +} + +impl Mode { + fn parse(raw: Option<&str>) -> anyhow::Result { + match raw { + None | Some("unelevated") => Ok(Self::Unelevated), + Some("elevated") => Ok(Self::Elevated), + Some(other) => bail!("unknown mode '{other}'; expected 'unelevated' 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 [unelevated|elevated]")?; + let mode_arg = args.next(); + let mode = Mode::parse(mode_arg.as_deref().and_then(|arg| arg.to_str()))?; + ensure!( agent_path.is_file(), "agent executable does not exist: {}", agent_path.display() ); - unavailable_policy_and_method_restrictions(&agent_path).await?; - complete_snapshots_across_reload(&agent_path).await?; + match mode { + Mode::Unelevated => { + // Neither of these touches anything privilege-sensitive: no policy file is + // ever pre-seeded with an admin-only ACL, and the unelevated PUT assertion + // specifically requires this process to *not* be elevated/SYSTEM, unlike the + // contradictory assertion that resulted from running everything as SYSTEM + // (item 23). + unavailable_policy_and_method_restrictions(&agent_path).await?; + unelevated_management_and_validation_succeed_put_requires_administrator(&agent_path).await?; + // `POST /v1/policy/validate` requires no special privilege either way, so + // the policy-management body-size limit is exercised unelevated. + policy_management_body_size_limits(&agent_path).await?; + } + Mode::Elevated => { + // Both of these seed/replace the policy file's own owner/ACL (via + // `secure_policy_file`, which sets the owner to LocalSystem), which requires + // an elevated/SYSTEM token; see `run-as-system.ps1`. + complete_snapshots_across_reload(&agent_path).await?; + elevated_policy_lifecycle(&agent_path).await?; + // Both of these issue a `PUT /v1/policy` and so require an elevated, + // Administrators-member token to ever reach the store's write-capability + // check at all (see `unelevated_management_and_validation_succeed_put_requires_administrator`, + // which proves the unelevated half of that gate): an unelevated caller would + // be rejected with `AdministratorRequired` before the configured path's + // format is ever considered, masking exactly what these prove (item 18/31). + unsupported_configured_path_format_is_rejected(&agent_path).await?; + uppercase_json_extension_is_active_and_writable(&agent_path).await?; + } + } Ok(()) } @@ -141,6 +343,41 @@ async fn request(pipe_name: &str, method: &str, path: &str) -> anyhow::Result anyhow::Result { + let payload = serde_json::to_vec(body).context("serialize request body")?; + + let deadline = Instant::now() + Duration::from_secs(10); + let mut pipe = loop { + match ClientOptions::new().open(pipe_name) { + Ok(pipe) => break pipe, + Err(_) if Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err(error) => return Err(error).with_context(|| format!("open named pipe {pipe_name}")), + } + }; + + let header = format!( + "{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + pipe.write_all(header.as_bytes()) + .await + .context("write HTTP request header")?; + pipe.write_all(&payload).await.context("write HTTP request body")?; + pipe.flush().await.context("flush HTTP request")?; + + let mut raw_response = Vec::new(); + tokio::time::timeout(Duration::from_secs(10), pipe.read_to_end(&mut raw_response)) + .await + .context("timed out reading HTTP response")? + .context("read HTTP response")?; + + parse_response(raw_response) +} + fn parse_response(raw_response: Vec) -> anyhow::Result { let header_end = raw_response .windows(4) @@ -173,6 +410,17 @@ fn empty_policy() -> Value { policy } +/// Convert a committed policy document's JSON into an equivalent editable draft: a draft +/// omits the server-assigned `Revision` and `PublishedAt` metadata fields. +fn draft_from(policy: &Value) -> Value { + let mut draft = policy.clone(); + if let Some(metadata) = draft.get_mut("Metadata").and_then(Value::as_object_mut) { + metadata.remove("Revision"); + metadata.remove("PublishedAt"); + } + draft +} + fn secure_policy_file(path: &Path) -> anyhow::Result<()> { let owner_status = std::process::Command::new("icacls.exe") .arg(path) @@ -202,7 +450,7 @@ fn secure_policy_file(path: &Path) -> anyhow::Result<()> { } async fn unavailable_policy_and_method_restrictions(agent_path: &Path) -> anyhow::Result<()> { - let agent = AgentHarness::start(agent_path, None).await?; + let agent = AgentHarness::start(agent_path, Mode::Unelevated, None).await?; for path in ["/v1/health", "/v1/capabilities"] { let response = request(&agent.pipe_name, "GET", path).await?; @@ -233,7 +481,10 @@ async fn unavailable_policy_and_method_restrictions(agent_path: &Path) -> anyhow "unavailable-policy response exposed a policy" ); - for method in ["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"] { + // PUT is deliberately excluded: the Phase 2 management contract routes it to + // policy replacement (see `policy_management_endpoints`). It is no longer rejected + // outright. + for method in ["POST", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"] { let response = request(&agent.pipe_name, method, "/v1/policy").await?; ensure!( response.status == 405, @@ -252,9 +503,14 @@ async fn unavailable_policy_and_method_restrictions(agent_path: &Path) -> anyhow Ok(()) } +/// Must run with `Mode::Elevated` (see `run`): the seeded policy file requires an +/// admin-only ACL (`secure_policy_file`), and reaching a real `Active` observation at all +/// requires the hosting directory to itself pass the store's admin-only directory +/// security check, which only the secured test-host directory (see [`SecureTestDir`]) +/// can satisfy. async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<()> { let empty = empty_policy(); - let agent = AgentHarness::start(agent_path, Some(&empty)).await?; + let agent = AgentHarness::start(agent_path, Mode::Elevated, Some(&empty)).await?; let initial = request(&agent.pipe_name, "GET", "/v1/policy").await?; ensure!(initial.status == 200, "active policy returned HTTP {}", initial.status); @@ -316,3 +572,786 @@ async fn complete_snapshots_across_reload(agent_path: &Path) -> anyhow::Result<( Ok(()) } + +/// Exercises the Phase 2 management endpoints: `GET /v1/policy/management`, +/// `POST /v1/policy/validate`, and `PUT /v1/policy`. +/// +/// Inspection and validation are authenticated but unelevated; only the replacement +/// endpoint requires an elevated Administrator token. This must run with `Mode::Unelevated` +/// (see `run`): the `PUT` denial assertion below is contradictory if the tester process +/// itself happens to be elevated/SYSTEM (item 23), which is exactly why this suite is +/// split by mode instead of assuming one privilege level for the whole binary. A full +/// write commit additionally requires SYSTEM or an elevated Administrators-member token; +/// see [`elevated_policy_lifecycle`] and `run-as-system.ps1`. +async fn unelevated_management_and_validation_succeed_put_requires_administrator( + agent_path: &Path, +) -> anyhow::Result<()> { + let agent = AgentHarness::start(agent_path, Mode::Unelevated, None).await?; + + // `GET /v1/policy/management` reflects the Missing state atomically, with no policy + // or diagnostics attached, and always advertises that writes require elevation. + let management = request(&agent.pipe_name, "GET", "/v1/policy/management").await?; + ensure!( + management.status == 200, + "policy management returned HTTP {}", + management.status + ); + let management = management.json()?; + ensure!( + management["ResponseKind"] == "PolicyManagementResponse", + "unexpected policy management response kind" + ); + ensure!( + management["Management"]["State"] == "Missing", + "expected Missing state with no policy configured" + ); + // The test harness always configures a custom `PolicyPath` inside an isolated temp + // directory (never the real default ProgramData location), so this is always + // ConfiguredPath. That directory is owned by the current (non-admin) test user, not + // SYSTEM/Administrators, so it correctly fails the custom-path security check: the + // store never rewrites a custom directory's ACL, it only ever verifies it. + ensure!( + management["Management"]["Source"] == "ConfiguredPath", + "expected the harness-configured custom policy path: {management:?}" + ); + ensure!( + management["Management"]["WriteCapability"] == "ReadOnly" + && management["Management"]["ReadOnlyReason"] == "UnsafePath", + "expected a non-admin-owned custom directory to be read-only/unsafe: {management:?}" + ); + ensure!( + management["Management"]["ElevationRequired"] == true, + "writes must always require elevation" + ); + ensure!( + management["Management"].get("Policy").is_none(), + "Missing state must not expose a policy" + ); + ensure!( + management["Management"].get("InvalidDiagnostics").is_none(), + "Missing state must not expose diagnostics" + ); + let store_token = management["Management"]["StoreToken"] + .as_str() + .context("management response missing StoreToken")? + .to_owned(); + + // A well-formed, schema-compliant draft validates successfully with a canonical + // draft and receipt, and no findings (no audit mode, no default-allow, no rules). + let valid_draft = draft_from(&empty_policy()); + let validate = request_with_body( + &agent.pipe_name, + "POST", + "/v1/policy/validate", + &json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": valid_draft, + }), + ) + .await?; + ensure!( + validate.status == 200, + "policy validate returned HTTP {}", + validate.status + ); + let validate_body = validate.json()?; + ensure!( + validate_body["ResponseKind"] == "PolicyValidationResponse", + "unexpected policy validation response kind" + ); + ensure!( + validate_body["Validation"]["IsValid"] == true, + "expected a well-formed empty policy draft to validate" + ); + ensure!( + validate_body["Validation"]["CanonicalDraft"].is_object(), + "a valid result must carry a canonical draft" + ); + ensure!( + validate_body["Validation"]["Findings"] == json!([]), + "expected no findings for an empty, non-audit, default-deny draft" + ); + let receipt = validate_body["Validation"]["ValidationReceipt"] + .as_str() + .context("valid result missing ValidationReceipt")? + .to_owned(); + + // An unsupported schema constant is rejected with a precise finding code rather than + // a generic schema violation. + let mut unsupported_schema_draft = valid_draft.clone(); + unsupported_schema_draft["$schema"] = json!("https://example.com/wrong-schema.json"); + let invalid_validate = request_with_body( + &agent.pipe_name, + "POST", + "/v1/policy/validate", + &json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": unsupported_schema_draft, + }), + ) + .await?; + ensure!( + invalid_validate.status == 200, + "invalid draft validate returned HTTP {}", + invalid_validate.status + ); + let invalid_body = invalid_validate.json()?; + ensure!( + invalid_body["Validation"]["IsValid"] == false, + "expected an unsupported-schema draft to be invalid" + ); + ensure!( + invalid_body["Validation"].get("CanonicalDraft").is_none(), + "an invalid result must not carry a canonical draft" + ); + ensure!( + invalid_body["Validation"]["Findings"] + .as_array() + .is_some_and(|findings| findings.iter().any(|finding| finding["Code"] == "UnsupportedSchema")), + "expected an UnsupportedSchema finding: {invalid_body:?}" + ); + + // Audit mode is accepted but flagged as a warning, not an error. + let mut audit_mode_draft = valid_draft.clone(); + audit_mode_draft["Enforcement"]["AuditMode"] = json!(true); + let audit_validate = request_with_body( + &agent.pipe_name, + "POST", + "/v1/policy/validate", + &json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": audit_mode_draft, + }), + ) + .await?; + ensure!( + audit_validate.status == 200, + "audit-mode draft validate returned HTTP {}", + audit_validate.status + ); + let audit_body = audit_validate.json()?; + ensure!( + audit_body["Validation"]["IsValid"] == true, + "warnings must not invalidate an otherwise-valid draft" + ); + ensure!( + audit_body["Validation"]["Findings"] + .as_array() + .is_some_and(|findings| findings + .iter() + .any(|finding| finding["Code"] == "AuditModeEnabled" && finding["Severity"] == "Warning")), + "expected an AuditModeEnabled warning: {audit_body:?}" + ); + + // Writes require an elevated, Administrators-member token; this test process + // presents neither, so even a well-formed Create request is denied before it ever + // touches the store. + let replace = request_with_body( + &agent.pipe_name, + "PUT", + "/v1/policy", + &json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": store_token, + "Operation": "Create", + "ConflictHandling": "Reject", + "WarningsAcknowledged": true, + "Draft": valid_draft, + "ValidationReceipt": receipt, + }), + ) + .await?; + ensure!( + replace.status == 403, + "unelevated policy replacement returned HTTP {}", + replace.status + ); + let replace_error = replace.json()?; + ensure!( + replace_error["Code"] == "AdministratorRequired", + "expected AdministratorRequired for an unelevated write: {replace_error:?}" + ); + + Ok(()) +} + +/// Exercises the policy-management body-size limit exported by the shared contract +/// (`now_policy_server_template::MAX_POLICY_MANAGEMENT_BODY_BYTES`, 16 MiB): far larger +/// than the general per-operation limit (`MAX_REQUEST_BODY_BYTES`, 256 KiB) that every +/// `POST /v1/package-operations/*` route keeps instead. Both limits are applied +/// entirely inside the shared router, before the request ever reaches this broker's own +/// handlers, so a request need not carry a well-formed policy draft to prove either +/// bound: only that the HTTP layer accepts or rejects it by size alone. `POST +/// /v1/policy/validate` requires no special privilege either way, so this runs +/// unelevated. +async fn policy_management_body_size_limits(agent_path: &Path) -> anyhow::Result<()> { + let agent = AgentHarness::start(agent_path, Mode::Unelevated, None).await?; + + // Comfortably above the 256 KiB operation-endpoint limit but still well inside the + // dedicated 16 MiB policy-management limit: proves `/v1/policy/validate` does not + // share the smaller operation-endpoint limit. + let accepted_len = MAX_REQUEST_BODY_BYTES * 2; + let accepted = request_with_body( + &agent.pipe_name, + "POST", + "/v1/policy/validate", + &padded_validate_request(accepted_len), + ) + .await?; + ensure!( + accepted.status == 200, + "a {accepted_len}-byte request (over the 256 KiB operation limit, under the 16 MiB \ + policy-management limit) returned HTTP {}", + accepted.status + ); + + // Comfortably over the 16 MiB policy-management limit. + let rejected_len = MAX_POLICY_MANAGEMENT_BODY_BYTES + MAX_REQUEST_BODY_BYTES; + let rejected = request_with_body( + &agent.pipe_name, + "POST", + "/v1/policy/validate", + &padded_validate_request(rejected_len), + ) + .await?; + ensure!( + rejected.status == 413, + "a {rejected_len}-byte request (over the 16 MiB policy-management limit) returned HTTP {}", + rejected.status + ); + ensure!( + rejected.json()?["Code"] == "PayloadTooLarge", + "expected PayloadTooLarge for an oversized policy-management request" + ); + + Ok(()) +} + +/// Build a syntactically valid `PolicyValidationRequest` envelope whose serialized body +/// is at least `target_len` bytes, via a single large filler string in `Draft` (not a +/// well-formed policy draft): `Draft` is a raw JSON value in the shared contract, so any +/// valid JSON deserializes, and the body-size limit is enforced before the draft's +/// content is ever inspected. One contiguous allocation for the filler, reused by +/// `serde_json`/`request_with_body` without further copies, instead of building a large +/// tree of many small values. +fn padded_validate_request(target_len: usize) -> Value { + json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": "a".repeat(target_len), + }) +} + +/// Authoritatively (re)validate `draft` and return its canonical validation receipt, +/// failing the test outright if the draft (expected to be well-formed) does not validate. +async fn validate_draft_or_fail(agent: &AgentHarness, draft: &Value) -> anyhow::Result { + let response = request_with_body( + &agent.pipe_name, + "POST", + "/v1/policy/validate", + &json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": draft, + }), + ) + .await?; + ensure!(response.status == 200, "validate returned HTTP {}", response.status); + let body = response.json()?; + ensure!(body["Validation"]["IsValid"] == true, "draft must validate: {body:?}"); + body["Validation"]["ValidationReceipt"] + .as_str() + .context("valid result missing ValidationReceipt") + .map(str::to_owned) +} + +/// Issue `PUT /v1/policy` with the given operation/conflict-handling/draft/receipt. +async fn replace_policy( + agent: &AgentHarness, + store_token: &str, + operation: &str, + conflict_handling: &str, + draft: &Value, + receipt: &str, + warnings_acknowledged: bool, +) -> anyhow::Result { + request_with_body( + &agent.pipe_name, + "PUT", + "/v1/policy", + &json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": store_token, + "Operation": operation, + "ConflictHandling": conflict_handling, + "WarningsAcknowledged": warnings_acknowledged, + "Draft": draft, + "ValidationReceipt": receipt, + }), + ) + .await +} + +/// Build a well-formed, empty-rules policy draft/document with the given id (and, +/// for `..._document` variants, revision), for use as a distinct identity at each stage +/// of [`elevated_policy_lifecycle`]. +fn policy_with_id(id: &str) -> Value { + let mut policy = empty_policy(); + policy["Metadata"]["Id"] = json!(id); + policy +} + +/// Exercises the full privileged policy-management write lifecycle end to end (item 23): +/// Missing -> Create, Update (revision increment/new `PublishedAt`), ReplaceIdentity +/// (revision resets to 1), Invalid -> Repair (with redacted diagnostics), a warning that +/// must be explicitly acknowledged, a stale-token conflict that carries the current +/// published snapshot, `ConfirmOverwrite` against the exact current token followed by a +/// second conflict, an out-of-band external edit picked up by the watcher, and the same +/// policy remaining active across an Agent restart. +/// +/// Must run with `Mode::Elevated` (see `run`): every write here requires an elevated, +/// Administrators-member (or SYSTEM) token, and the external-edit steps additionally +/// require the ability to set the policy file's owner to LocalSystem (`secure_policy_file`). +async fn elevated_policy_lifecycle(agent_path: &Path) -> anyhow::Result<()> { + let mut agent = AgentHarness::start(agent_path, Mode::Elevated, None).await?; + + // ── Missing -> Create: exact persisted active ────────────────────────── + let initial_management = request(&agent.pipe_name, "GET", "/v1/policy/management") + .await? + .json()?; + ensure!( + initial_management["Management"]["State"] == "Missing", + "expected Missing before Create: {initial_management:?}" + ); + let mut store_token = initial_management["Management"]["StoreToken"] + .as_str() + .context("management response missing StoreToken")? + .to_owned(); + + let draft_a = draft_from(&policy_with_id("tests.lifecycle-a")); + let receipt_a = validate_draft_or_fail(&agent, &draft_a).await?; + let created = replace_policy(&agent, &store_token, "Create", "Reject", &draft_a, &receipt_a, true).await?; + ensure!( + created.status == 200, + "Create returned HTTP {}: {:?}", + created.status, + created.json() + ); + let created_body = created.json()?; + ensure!(created_body["Policy"]["Metadata"]["Id"] == "tests.lifecycle-a"); + ensure!( + created_body["Policy"]["Metadata"]["Revision"] == 1, + "Create must assign revision 1" + ); + + let get_after_create = request(&agent.pipe_name, "GET", "/v1/policy").await?.json()?; + ensure!( + get_after_create["Policy"] == created_body["Policy"], + "GET after Create does not match the exact created policy: {get_after_create:?} vs {:?}", + created_body["Policy"] + ); + store_token = created_body["Management"]["StoreToken"] + .as_str() + .context("Create response missing StoreToken")? + .to_owned(); + + // ── Update: revision increments, PublishedAt changes, identity retained ─ + let previous_published_at = created_body["Policy"]["Metadata"]["PublishedAt"].clone(); + let receipt_a_again = validate_draft_or_fail(&agent, &draft_a).await?; + let updated = replace_policy( + &agent, + &store_token, + "Update", + "Reject", + &draft_a, + &receipt_a_again, + true, + ) + .await?; + ensure!( + updated.status == 200, + "Update returned HTTP {}: {:?}", + updated.status, + updated.json() + ); + let updated_body = updated.json()?; + ensure!( + updated_body["Policy"]["Metadata"]["Id"] == "tests.lifecycle-a", + "Update must retain identity" + ); + ensure!( + updated_body["Policy"]["Metadata"]["Revision"] == 2, + "Update must increment the revision" + ); + ensure!( + updated_body["Policy"]["Metadata"]["PublishedAt"] != previous_published_at, + "Update must assign a fresh PublishedAt" + ); + store_token = updated_body["Management"]["StoreToken"] + .as_str() + .context("Update response missing StoreToken")? + .to_owned(); + + // ── ReplaceIdentity: different identity, revision resets to 1 ─────────── + let draft_b = draft_from(&policy_with_id("tests.lifecycle-b")); + let receipt_b = validate_draft_or_fail(&agent, &draft_b).await?; + let replaced_identity = replace_policy( + &agent, + &store_token, + "ReplaceIdentity", + "Reject", + &draft_b, + &receipt_b, + true, + ) + .await?; + ensure!( + replaced_identity.status == 200, + "ReplaceIdentity returned HTTP {}: {:?}", + replaced_identity.status, + replaced_identity.json() + ); + let replaced_identity_body = replaced_identity.json()?; + ensure!(replaced_identity_body["Policy"]["Metadata"]["Id"] == "tests.lifecycle-b"); + ensure!( + replaced_identity_body["Policy"]["Metadata"]["Revision"] == 1, + "ReplaceIdentity must assign revision 1" + ); + // Its resulting token is deliberately never used: the next stage (Invalid -> Repair) + // bypasses the API and edits disk directly, so the fresh token it needs afterward + // comes from re-observing that external edit, not from carrying this one forward. + + // ── Invalid -> Repair: diagnostics redacted, revision resets to 1 ─────── + let secret_marker = "sso1kkD0-attacker-controlled-marker"; + std::fs::write(&agent.policy_path, format!(r#"{{"unterminated": "{secret_marker}"#)) + .context("write malformed policy")?; + secure_policy_file(&agent.policy_path)?; + + let invalid_management = poll_until(Duration::from_secs(10), || async { + let management = request(&agent.pipe_name, "GET", "/v1/policy/management") + .await? + .json()?; + Ok((management["Management"]["State"] == "Invalid").then_some(management)) + }) + .await + .context("store never observed the malformed external edit")?; + + let diagnostics = &invalid_management["Management"]["InvalidDiagnostics"]; + ensure!( + diagnostics["Findings"].as_array().is_some_and(|f| !f.is_empty()), + "Invalid state must carry at least one finding: {invalid_management:?}" + ); + ensure!( + !diagnostics.to_string().contains(secret_marker), + "diagnostics leaked the malformed on-disk content: {diagnostics:?}" + ); + let invalid_token = invalid_management["Management"]["StoreToken"] + .as_str() + .context("Invalid management response missing StoreToken")? + .to_owned(); + + let draft_c = draft_from(&policy_with_id("tests.lifecycle-c")); + let receipt_c = validate_draft_or_fail(&agent, &draft_c).await?; + let repaired = replace_policy(&agent, &invalid_token, "Repair", "Reject", &draft_c, &receipt_c, true).await?; + ensure!( + repaired.status == 200, + "Repair returned HTTP {}: {:?}", + repaired.status, + repaired.json() + ); + let repaired_body = repaired.json()?; + ensure!( + repaired_body["Policy"]["Metadata"]["Revision"] == 1, + "Repair must assign revision 1" + ); + store_token = repaired_body["Management"]["StoreToken"] + .as_str() + .context("Repair response missing StoreToken")? + .to_owned(); + + // ── Warning must be explicitly acknowledged before it is allowed through ─ + let mut audit_draft = policy_with_id("tests.lifecycle-c"); + audit_draft["Enforcement"]["AuditMode"] = json!(true); + let audit_draft = draft_from(&audit_draft); + let receipt_audit = validate_draft_or_fail(&agent, &audit_draft).await?; + + let unacknowledged = replace_policy( + &agent, + &store_token, + "Update", + "Reject", + &audit_draft, + &receipt_audit, + false, + ) + .await?; + ensure!( + unacknowledged.status == 409, + "an unacknowledged warning must conflict: HTTP {}", + unacknowledged.status + ); + ensure!(unacknowledged.json()?["Code"] == "WarningConfirmationRequired"); + + let acknowledged = replace_policy( + &agent, + &store_token, + "Update", + "Reject", + &audit_draft, + &receipt_audit, + true, + ) + .await?; + ensure!( + acknowledged.status == 200, + "an acknowledged warning must succeed: HTTP {}: {:?}", + acknowledged.status, + acknowledged.json() + ); + store_token = acknowledged.json()?["Management"]["StoreToken"] + .as_str() + .context("acknowledged Update response missing StoreToken")? + .to_owned(); + + // ── Stale conflict carries the current published snapshot ────────────── + let stale_token = store_token.clone(); + let external = policy_with_id("tests.lifecycle-external"); // a full document, not a draft: written directly to disk. + std::fs::write(&agent.policy_path, serde_json::to_vec_pretty(&external)?).context("write external edit")?; + secure_policy_file(&agent.policy_path)?; + poll_until(Duration::from_secs(10), || async { + let management = request(&agent.pipe_name, "GET", "/v1/policy/management") + .await? + .json()?; + Ok((management["Management"]["Policy"]["Metadata"]["Id"] == "tests.lifecycle-external").then_some(())) + }) + .await + .context("store never observed the external edit before the stale-conflict check")?; + + let draft_stale = draft_from(&policy_with_id("tests.lifecycle-c")); + let receipt_stale = validate_draft_or_fail(&agent, &draft_stale).await?; + let stale = replace_policy( + &agent, + &stale_token, + "Update", + "Reject", + &draft_stale, + &receipt_stale, + true, + ) + .await?; + ensure!( + stale.status == 409, + "a stale token must conflict: HTTP {}", + stale.status + ); + let stale_body = stale.json()?; + ensure!(stale_body["Code"] == "StalePolicyStoreToken"); + ensure!( + stale_body["Management"]["Policy"]["Metadata"]["Id"] == "tests.lifecycle-external", + "the stale-conflict error must carry the current published snapshot: {stale_body:?}" + ); + let current_token = stale_body["Management"]["StoreToken"] + .as_str() + .context("stale-conflict error missing StoreToken")? + .to_owned(); + + // ── ConfirmOverwrite: exact-token success, then a second conflict ─────── + let draft_confirm = draft_from(&policy_with_id("tests.lifecycle-external")); + let receipt_confirm = validate_draft_or_fail(&agent, &draft_confirm).await?; + let confirmed = replace_policy( + &agent, + ¤t_token, + "Update", + "ConfirmOverwrite", + &draft_confirm, + &receipt_confirm, + true, + ) + .await?; + ensure!( + confirmed.status == 200, + "ConfirmOverwrite against the exact current token must succeed: HTTP {}: {:?}", + confirmed.status, + confirmed.json() + ); + + let re_conflict = replace_policy( + &agent, + ¤t_token, + "Update", + "ConfirmOverwrite", + &draft_confirm, + &receipt_confirm, + true, + ) + .await?; + ensure!( + re_conflict.status == 409, + "reusing an already-consumed token must conflict again, even under ConfirmOverwrite: HTTP {}", + re_conflict.status + ); + ensure!(re_conflict.json()?["Code"] == "StalePolicyStoreToken"); + + // ── External edit watcher: picked up with no further API call ─────────── + let watched = policy_with_id("tests.lifecycle-watched"); + std::fs::write(&agent.policy_path, serde_json::to_vec_pretty(&watched)?).context("write watched external edit")?; + secure_policy_file(&agent.policy_path)?; + poll_until(Duration::from_secs(10), || async { + let response = request(&agent.pipe_name, "GET", "/v1/policy").await?; + if response.status != 200 { + return Ok(None); + } + let body = response.json()?; + Ok((body["Policy"]["Metadata"]["Id"] == "tests.lifecycle-watched").then_some(())) + }) + .await + .context("the watcher never picked up the external edit")?; + + // ── Restart: the same policy remains active afterward ────────────────── + agent.restart(agent_path).await?; + let after_restart = request(&agent.pipe_name, "GET", "/v1/policy").await?; + ensure!( + after_restart.status == 200, + "the policy must still be active after a restart: HTTP {}", + after_restart.status + ); + let after_restart_body = after_restart.json()?; + ensure!( + after_restart_body["Policy"]["Metadata"]["Id"] == "tests.lifecycle-watched", + "policy identity changed across a restart: {after_restart_body:?}" + ); + + Ok(()) +} + +/// Configured-path *format* rejection, end to end (item 18/31): a policy path whose +/// extension the store does not support (anything other than case-insensitive `.json`) +/// is reported as `Invalid`/`ReadOnly`/`UnsupportedFormat` through `GET +/// /v1/policy/management`, whatever (if anything) actually exists at that path, and `PUT +/// /v1/policy` against it is rejected with the shared contract's dedicated +/// `UnsupportedPolicyFormat` (HTTP 422) -- distinct from every other read-only reason, +/// which maps to `UnsafePolicyPath`/`UnsupportedPolicyFilesystem` instead. +/// +/// Must run with `Mode::Elevated` (see `run`): reaching `PolicyStore::replace`'s +/// write-capability check at all requires first passing the handler's own elevated- +/// Administrator gate (see `unelevated_management_and_validation_succeed_put_requires_administrator`, +/// which proves the unelevated half of that gate) -- an unelevated PUT would be denied +/// with `AdministratorRequired` before the configured path's format is ever considered, +/// masking exactly what this proves. +async fn unsupported_configured_path_format_is_rejected(agent_path: &Path) -> anyhow::Result<()> { + for file_name in ["policy.yaml", "policy.yml", "policy", "policy.txt"] { + let agent = AgentHarness::start_with_file_name(agent_path, Mode::Elevated, file_name, None).await?; + + let management = request(&agent.pipe_name, "GET", "/v1/policy/management").await?; + ensure!( + management.status == 200, + "{file_name}: policy management returned HTTP {}", + management.status + ); + let management = management.json()?; + ensure!( + management["Management"]["State"] == "Invalid", + "{file_name}: expected Invalid state for an unsupported extension: {management:?}" + ); + ensure!( + management["Management"]["WriteCapability"] == "ReadOnly" + && management["Management"]["ReadOnlyReason"] == "UnsupportedFormat", + "{file_name}: expected ReadOnly/UnsupportedFormat: {management:?}" + ); + let store_token = management["Management"]["StoreToken"] + .as_str() + .context("management response missing StoreToken")? + .to_owned(); + + let draft = draft_from(&empty_policy()); + let receipt = validate_draft_or_fail(&agent, &draft).await?; + let replace = replace_policy(&agent, &store_token, "Create", "Reject", &draft, &receipt, true).await?; + ensure!( + replace.status == 422, + "{file_name}: PUT against an unsupported-format path returned HTTP {}", + replace.status + ); + ensure!( + replace.json()?["Code"] == "UnsupportedPolicyFormat", + "{file_name}: expected UnsupportedPolicyFormat: {:?}", + replace.json() + ); + } + + Ok(()) +} + +/// Companion to the rejection test above (item 18/31): an uppercase `.JSON` extension is +/// accepted end to end -- not just by shape validation in isolation, but through the +/// real disk-loading and write pipeline -- proving the case-insensitive match documented +/// on `validate_configured_path_shape` holds all the way through. +/// +/// Must run with `Mode::Elevated`: seeds the policy file with an admin-only ACL (via +/// `secure_policy_file`) and issues a `PUT /v1/policy`, both of which require an +/// elevated/SYSTEM token. +async fn uppercase_json_extension_is_active_and_writable(agent_path: &Path) -> anyhow::Result<()> { + let policy = empty_policy(); + let agent = AgentHarness::start_with_file_name(agent_path, Mode::Elevated, "policy.JSON", Some(&policy)).await?; + + let management = request(&agent.pipe_name, "GET", "/v1/policy/management").await?; + ensure!( + management.status == 200, + "policy management returned HTTP {}", + management.status + ); + let management = management.json()?; + ensure!( + management["Management"]["State"] == "Active", + "expected an uppercase .JSON policy to load as Active: {management:?}" + ); + ensure!( + management["Management"]["WriteCapability"] == "Writable", + "expected an uppercase .JSON policy directory to be writable: {management:?}" + ); + + let store_token = management["Management"]["StoreToken"] + .as_str() + .context("management response missing StoreToken")? + .to_owned(); + // Same id as the seeded policy above: `Update` requires the active policy's own + // identity to be preserved (see `policy_store::plan_revision`), so this proves the + // write path (not just the read path) works end to end for an uppercase `.JSON` + // configured path. + let draft = draft_from(&policy); + let receipt = validate_draft_or_fail(&agent, &draft).await?; + let replace = replace_policy(&agent, &store_token, "Update", "Reject", &draft, &receipt, true).await?; + ensure!( + replace.status == 200, + "PUT against an uppercase .JSON path returned HTTP {}: {:?}", + replace.status, + replace.json() + ); + + Ok(()) +} + +/// Poll `probe` until it returns `Some(_)` or `deadline` elapses, sleeping briefly +/// between attempts. Used throughout [`elevated_policy_lifecycle`] to await an +/// asynchronous, watcher-driven state transition rather than assuming a fixed delay. +async fn poll_until(timeout: Duration, mut probe: F) -> anyhow::Result +where + F: FnMut() -> Fut, + Fut: Future>>, +{ + let deadline = Instant::now() + timeout; + loop { + if let Some(value) = probe().await? { + return Ok(value); + } + ensure!( + Instant::now() < deadline, + "timed out waiting for the expected condition" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} diff --git a/crates/now-package-broker/Cargo.toml b/crates/now-package-broker/Cargo.toml index 273b11d97..6e25ca4f5 100644 --- a/crates/now-package-broker/Cargo.toml +++ b/crates/now-package-broker/Cargo.toml @@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] } devolutions-agent-shared = { path = "../devolutions-agent-shared" } devolutions-gateway-task = { path = "../devolutions-gateway-task" } hex = "0.4" +hmac = "0.13" hyper = { version = "1", features = ["http1", "server"] } hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto", "service"] } notify = { version = "7", default-features = false } @@ -37,7 +38,10 @@ parking_lot = "0.12" regex = "1" semver = "1" serde_json = "1" -sha2 = "0.10" +sha2 = "0.11" +sysevent = { path = "../sysevent" } +sysevent-codes = { path = "../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" @@ -66,6 +70,5 @@ features = [ [target.'cfg(windows)'.dev-dependencies] serde = { version = "1", features = ["derive"] } -serde_yaml = "0.9" tempfile = "3" tokio = { version = "1.52", features = ["rt-multi-thread"] } diff --git a/crates/now-package-broker/src/audit.rs b/crates/now-package-broker/src/audit.rs new file mode 100644 index 000000000..f41d819de --- /dev/null +++ b/crates/now-package-broker/src/audit.rs @@ -0,0 +1,203 @@ +//! Structured audit logging for policy management writes. +//! +//! Every attempt, denial, conflict, confirmed overwrite, failure, and success is recorded +//! twice: once as a structured `tracing` event (for local diagnostics and log +//! aggregation) and once to the platform system event log via [`sysevent`] (a +//! tamper-evident, centrally collectible security audit trail). Entries carry actor +//! SID/executable, intent, path, and old/new policy id/revision, but never full policy +//! content. + +use std::path::Path; +use std::sync::Arc; + +use sysevent::SystemEventSink; +use win_api_wrappers::identity::sid::Sid; + +/// Lazily-initialized Windows Event Log sink for policy management audit events. +/// +/// Mirrors `devolutions_gateway::SYSTEM_LOGGER`, which the Agent does not otherwise have +/// an equivalent of; the package broker owns this one since it is the only Agent +/// subsystem that currently needs security-audit event log entries. +static SYSTEM_LOGGER: std::sync::LazyLock> = std::sync::LazyLock::new(init_system_logger); + +fn init_system_logger() -> Arc { + match sysevent_winevent::WinEvent::new("Devolutions Agent") { + Ok(winevent) => Arc::new(winevent), + Err(error) => { + // Explicitly traced before falling back: an operator relying on the Windows + // Event Log audit trail needs to know it is not being written to. The Noop + // fallback exists precisely so this initialization failure never blocks + // policy writes themselves. + tracing::error!( + %error, + "Failed to initialize the Windows Event Log sink for policy management audit events; \ + falling back to a no-op sink (policy writes are not blocked)" + ); + Arc::new(sysevent::NoopSink) + } + } +} + +fn emit(entry: sysevent::Entry) { + if let Err(error) = SYSTEM_LOGGER.emit(entry) { + tracing::warn!(%error, "Failed to emit policy management audit event to the system event log"); + } +} + +pub(crate) fn write_attempted(actor_sid: &Sid, actor_exe: &Path, intent: &str, path: &Path) { + tracing::info!( + actor_sid = %actor_sid, + actor_exe = %actor_exe.display(), + intent, + path = %path.display(), + "Policy management write attempted" + ); + emit(sysevent_codes::policy_write_attempted( + actor_sid.to_string(), + actor_exe.display().to_string(), + intent, + path, + )); +} + +pub(crate) fn write_denied(actor_sid: &Sid, actor_exe: &Path, intent: &str, path: &Path, reason: &str) { + tracing::warn!( + actor_sid = %actor_sid, + actor_exe = %actor_exe.display(), + intent, + path = %path.display(), + reason, + "Policy management write denied" + ); + emit(sysevent_codes::policy_write_denied( + actor_sid.to_string(), + actor_exe.display().to_string(), + intent, + path, + reason, + )); +} + +pub(crate) fn write_conflict(actor_sid: &Sid, actor_exe: &Path, intent: &str, path: &Path) { + tracing::info!( + actor_sid = %actor_sid, + actor_exe = %actor_exe.display(), + intent, + path = %path.display(), + "Policy management write conflict: expected store token no longer matches" + ); + emit(sysevent_codes::policy_write_conflict( + actor_sid.to_string(), + actor_exe.display().to_string(), + intent, + path, + )); +} + +pub(crate) fn write_failed(actor_sid: &Sid, actor_exe: &Path, intent: &str, path: &Path, reason: &str) { + tracing::error!( + actor_sid = %actor_sid, + actor_exe = %actor_exe.display(), + intent, + path = %path.display(), + reason, + "Policy management write failed" + ); + emit(sysevent_codes::policy_write_failed( + actor_sid.to_string(), + actor_exe.display().to_string(), + intent, + path, + reason, + )); +} + +#[expect( + clippy::too_many_arguments, + reason = "audit event needs the full old/new identity for a security trail" +)] +pub(crate) fn write_succeeded( + actor_sid: &Sid, + actor_exe: &Path, + intent: &str, + path: &Path, + old_id: &str, + old_revision: Option, + new_id: &str, + new_revision: u32, +) { + let old_revision_display = old_revision.map_or_else(|| "none".to_owned(), |revision| revision.to_string()); + tracing::info!( + actor_sid = %actor_sid, + actor_exe = %actor_exe.display(), + intent, + path = %path.display(), + old_id, + old_revision = old_revision_display, + new_id, + new_revision, + "Policy management write succeeded" + ); + emit(sysevent_codes::policy_write_succeeded( + actor_sid.to_string(), + actor_exe.display().to_string(), + path, + old_id, + old_revision_display, + new_id, + new_revision, + intent, + )); +} + +#[expect( + clippy::too_many_arguments, + reason = "audit event needs the full old/new identity for a security trail" +)] +pub(crate) fn write_confirmed_overwrite( + actor_sid: &Sid, + actor_exe: &Path, + intent: &str, + path: &Path, + old_id: &str, + old_revision: Option, + new_id: &str, + new_revision: u32, +) { + let old_revision_display = old_revision.map_or_else(|| "none".to_owned(), |revision| revision.to_string()); + tracing::warn!( + actor_sid = %actor_sid, + actor_exe = %actor_exe.display(), + intent, + path = %path.display(), + old_id, + old_revision = old_revision_display, + new_id, + new_revision, + "Policy management confirmed overwrite" + ); + emit(sysevent_codes::policy_write_confirmed_overwrite( + actor_sid.to_string(), + actor_exe.display().to_string(), + path, + old_id, + old_revision_display, + new_id, + new_revision, + intent, + )); +} + +pub(crate) fn external_change_applied(path: &Path, new_id: &str, new_revision: u32) { + tracing::info!(path = %path.display(), new_id, new_revision, "External policy change applied"); + emit(sysevent_codes::policy_external_change_applied( + path, + new_id, + new_revision, + )); +} + +pub(crate) fn external_change_rejected(path: &Path, reason: &str) { + tracing::warn!(path = %path.display(), reason, "External policy change rejected"); + emit(sysevent_codes::policy_external_change_rejected(path, reason)); +} diff --git a/crates/now-package-broker/src/auth.rs b/crates/now-package-broker/src/auth.rs index 65b5515d4..307ea717f 100644 --- a/crates/now-package-broker/src/auth.rs +++ b/crates/now-package-broker/src/auth.rs @@ -11,7 +11,7 @@ use widestring::U16CString; use win_api_wrappers::identity::account::lookup_account_by_name; use win_api_wrappers::identity::sid::Sid; use win_api_wrappers::process::Process; -use windows::Win32::Security::TOKEN_QUERY; +use windows::Win32::Security::{TOKEN_DUPLICATE, TOKEN_QUERY, WinBuiltinAdministratorsSid}; use windows::Win32::Storage::FileSystem::FILE_ID_INFO; use windows::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; @@ -21,6 +21,15 @@ pub(crate) struct PipeClient { executable_path: PathBuf, /// Security identifier of the pipe client process token user, captured at connect. user_sid: Sid, + /// Whether the pipe client process token is elevated, captured at connect. + /// + /// Request fields are never trusted for this: policy management writes require the + /// actual token state observed on the named-pipe process, not a claim in the request + /// body. + is_elevated: bool, + /// Whether the pipe client process token has the built-in Administrators group + /// enabled, captured at connect (see [`win_api_wrappers::token::Token::is_member`]). + is_administrator: bool, } impl PipeClient { @@ -40,17 +49,30 @@ impl PipeClient { let executable_path = process .exe_path() .with_context(|| format!("failed to query pipe client process {process_id} executable path"))?; - let user_sid = process - .token(TOKEN_QUERY) - .with_context(|| format!("failed to open pipe client process {process_id} token"))? + // TOKEN_DUPLICATE is required so `Token::is_member` can duplicate this handle to + // an impersonation-level token for `CheckTokenMembership`. + let token = process + .token(TOKEN_QUERY | TOKEN_DUPLICATE) + .with_context(|| format!("failed to open pipe client process {process_id} token"))?; + let user_sid = token .sid_and_attributes() .with_context(|| format!("failed to query pipe client process {process_id} token user"))? .sid; + let is_elevated = token + .is_elevated() + .with_context(|| format!("failed to query pipe client process {process_id} token elevation"))?; + let administrators_sid = + Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("resolve Administrators SID")?; + let is_administrator = token + .is_member(&administrators_sid) + .with_context(|| format!("failed to query pipe client process {process_id} Administrators membership"))?; Ok(Self { process_id, executable_path, user_sid, + is_elevated, + is_administrator, }) } @@ -59,11 +81,55 @@ impl PipeClient { Self::from_process_id(std::process::id()) } + /// Build a synthetic pipe client claiming an elevated, Administrators-member token + /// for `user_sid`/`executable_path`, regardless of the real privilege of the process + /// actually running the test. Used only by tests elsewhere in the crate (e.g. + /// `server::mod::tests`) that need to exercise post-elevation-gate logic + /// deterministically -- independent of whether the host actually running the test + /// suite happens to be elevated (item 23's core concern, applied to unit tests too). + /// Gated the same way as its only callers: only meaningful with the signature bypass + /// active (see `server::tests::elevation_gating`'s own module doc comment). + #[cfg(all(test, feature = "dev-skip-broker-signature"))] + pub(crate) fn test_elevated_administrator(user_sid: Sid, executable_path: PathBuf) -> Self { + Self { + process_id: 0, + executable_path, + user_sid, + is_elevated: true, + is_administrator: true, + } + } + + /// Same as [`PipeClient::test_elevated_administrator`], but for a token that is + /// authenticated yet neither elevated nor an Administrators member: the ordinary, + /// unprivileged case `AdministratorRequired` must still reject. + #[cfg(all(test, feature = "dev-skip-broker-signature"))] + pub(crate) fn test_unelevated(user_sid: Sid, executable_path: PathBuf) -> Self { + Self { + process_id: 0, + executable_path, + user_sid, + is_elevated: false, + is_administrator: false, + } + } + /// Security identifier of the authenticated pipe client user, captured at connect. pub(crate) fn user_sid(&self) -> &Sid { &self.user_sid } + /// File path of the authenticated pipe client executable, captured at connect. + pub(crate) fn executable_path(&self) -> &Path { + &self.executable_path + } + + /// Whether the pipe client presented an elevated, Administrators-member token at + /// connect. Policy management writes require both; inspection/validation does not. + pub(crate) fn is_elevated_administrator(&self) -> bool { + self.is_elevated && self.is_administrator + } + pub(crate) fn validate_request( &self, request: &PackageRequest, @@ -251,13 +317,10 @@ fn same_file(left: &FILE_ID_INFO, right: &FILE_ID_INFO) -> bool { #[cfg(test)] mod tests { - use windows::Win32::Security::{WinLocalSystemSid, WinWorldSid}; + use windows::Win32::Security::WinWorldSid; use super::*; - - fn system_sid() -> Sid { - Sid::from_well_known(WinLocalSystemSid, None).expect("well-known SYSTEM SID") - } + use crate::test_support::system_sid; /// Host-localized (domain, name) for the LocalSystem account. fn system_account_names() -> (String, String) { @@ -280,6 +343,8 @@ mod tests { process_id: 0, executable_path: PathBuf::new(), user_sid: system_sid(), + is_elevated: true, + is_administrator: true, } } @@ -371,6 +436,8 @@ mod tests { process_id: std::process::id(), executable_path: std::env::current_exe().expect("current test executable path"), user_sid: client_user_sid(), + is_elevated: true, + is_administrator: true, }; assert!(client.validate_connection(true).is_err()); diff --git a/crates/now-package-broker/src/evaluator/mod.rs b/crates/now-package-broker/src/evaluator/mod.rs index 127cf8ffe..8974b757f 100644 --- a/crates/now-package-broker/src/evaluator/mod.rs +++ b/crates/now-package-broker/src/evaluator/mod.rs @@ -11,7 +11,7 @@ use now_policy_api::PackageRequest; mod constraints; mod matching; mod version; -mod wildcard; +pub(crate) mod wildcard; #[cfg(test)] mod tests; diff --git a/crates/now-package-broker/src/evaluator/wildcard.rs b/crates/now-package-broker/src/evaluator/wildcard.rs index 68e99df85..7aeaf7d08 100644 --- a/crates/now-package-broker/src/evaluator/wildcard.rs +++ b/crates/now-package-broker/src/evaluator/wildcard.rs @@ -2,6 +2,8 @@ use std::collections::BTreeSet; +use regex::{Regex, RegexBuilder}; + pub(super) fn wildcard_any>(value: &str, patterns: &BTreeSet) -> bool { patterns.is_empty() || patterns.iter().any(|pattern| wildcard_match(value, pattern.as_ref())) } @@ -11,12 +13,25 @@ pub(super) fn wildcard_any_vec>(value: &str, patterns: &[S]) -> bo } fn wildcard_match(value: &str, pattern: &str) -> bool { - // Convert glob pattern to regex: escape everything except *, which becomes .* + compile_pattern(pattern).is_some_and(|re| re.is_match(value)) +} + +/// Whether `pattern` compiles into the same evaluator-side matcher used at request-evaluation +/// time. +/// +/// Every character is escaped except `*` (converted to `.*`), so a pattern can only fail to +/// compile once it grows large/complex enough to exceed the regex engine's default program +/// size limit; this is the same condition under which [`wildcard_match`] silently treats the +/// pattern as never matching, surfaced here as a validation finding instead of a silent no-op. +pub(crate) fn pattern_compiles(pattern: &str) -> bool { + compile_pattern(pattern).is_some() +} + +/// Convert a glob pattern (only `*` is special, converted to `.*`) into a compiled, +/// case-insensitive regex. +fn compile_pattern(pattern: &str) -> Option { let regex_pattern = format!("^{}$", regex::escape(pattern).replace(r"\*", ".*")); - regex::RegexBuilder::new(®ex_pattern) - .case_insensitive(true) - .build() - .is_ok_and(|re| re.is_match(value)) + RegexBuilder::new(®ex_pattern).case_insensitive(true).build().ok() } #[cfg(test)] @@ -47,4 +62,19 @@ mod tests { assert!(wildcard_any("Contoso.Tools+", &patterns)); assert!(!wildcard_any("Contoso.Toolss", &patterns)); } + + #[test] + fn ordinary_patterns_compile() { + assert!(pattern_compiles("Microsoft.*")); + assert!(pattern_compiles("Contoso.Tools+")); + assert!(pattern_compiles("*")); + } + + #[test] + fn pathologically_large_pattern_fails_to_compile() { + // Comfortably past regex's default 10MiB compiled-program size limit once escaped + // and repeated; each `*` widens the resulting alternation-free `.*` chain. + let huge = "a*".repeat(2_000_000); + assert!(!pattern_compiles(&huge)); + } } diff --git a/crates/now-package-broker/src/lib.rs b/crates/now-package-broker/src/lib.rs index b55c994db..beb66d643 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)] @@ -20,11 +22,9 @@ pub mod operation_tracker; #[cfg(windows)] pub mod pipe; #[cfg(windows)] -pub mod policy_loader; -#[cfg(windows)] mod policy_security; #[cfg(windows)] -pub mod policy_watcher; +pub mod policy_store; #[cfg(windows)] pub mod server; #[cfg(windows)] @@ -32,3 +32,5 @@ pub mod task; #[cfg(all(test, windows))] mod scenario_tests; +#[cfg(all(test, windows))] +mod test_support; diff --git a/crates/now-package-broker/src/policy_loader.rs b/crates/now-package-broker/src/policy_loader.rs deleted file mode 100644 index 3d44cb9dd..000000000 --- a/crates/now-package-broker/src/policy_loader.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Policy file loader. -//! -//! Loads policy documents from the configured directory. -//! Supports both JSON (`.json`) and YAML (`.yaml`, `.yml`) formats. -//! Default location: `%PROGRAMDATA%/Devolutions/Agent/` - -use std::io::Read as _; -use std::path::{Path, PathBuf}; - -use now_policy::PolicyDocument; -use now_policy::schema::{parse_policy_json, parse_policy_yaml}; -use tracing::info; - -use crate::policy_security; - -/// Default policy directory. -pub fn default_policy_dir() -> PathBuf { - if cfg!(windows) { - let program_data = std::env::var("PROGRAMDATA").unwrap_or_else(|_| r"C:\ProgramData".to_owned()); - PathBuf::from(program_data).join("Devolutions").join("Agent") - } else { - PathBuf::from("/etc/devolutions-agent") - } -} - -/// Base name for the policy file (without extension). -const POLICY_FILE_BASE: &str = "package-broker-policy"; - -/// Supported policy file extensions in priority order. -const POLICY_EXTENSIONS: &[&str] = &["json", "yaml", "yml"]; - -/// Load a policy document from a file path. -/// -/// The file format is detected from the extension: -/// - `.json` — parsed as JSON -/// - `.yaml` or `.yml` — parsed as YAML -/// -/// Deserialization performs all validation (structure, types, length constraints, patterns). -/// -/// Before trusting the policy, the file's owner and DACL are verified to restrict write -/// access to SYSTEM/Administrators. -/// This function fails when the check does not pass, so the broker pauses (fail-closed). -pub fn load_policy(path: &Path) -> anyhow::Result { - let mut file = std::fs::File::open(path) - .map_err(|e| anyhow::anyhow!("failed to open policy file at {}: {e}", path.display()))?; - - // Verify security on the open handle (not the path), and read from the same handle, - // so the verified security descriptor belongs to the very same file being parsed. - policy_security::verify_policy_file_security(&file) - .map_err(|e| anyhow::anyhow!("policy file at {} failed security validation: {e}", path.display()))?; - - let mut content = String::new(); - file.read_to_string(&mut content) - .map_err(|e| anyhow::anyhow!("failed to read policy file at {}: {e}", path.display()))?; - - let policy = deserialize_policy(&content, path)?; - - info!( - policy_id = %policy.metadata.id, - revision = policy.metadata.revision, - rules_count = policy.rules.len(), - "Loaded policy" - ); - - Ok(policy) -} - -/// Deserialize policy content, detecting format from file extension. -fn deserialize_policy(content: &str, path: &Path) -> anyhow::Result { - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - - match ext.as_str() { - "yaml" | "yml" => { - parse_policy_yaml(content).map_err(|e| anyhow::anyhow!("invalid YAML policy at {}: {e}", path.display())) - } - _ => parse_policy_json(content).map_err(|e| anyhow::anyhow!("invalid JSON policy at {}: {e}", path.display())), - } -} - -/// Find the policy file in the default location. -/// -/// Searches for `package-broker-policy.{json,yaml,yml}` in priority order. -pub fn find_default_policy() -> anyhow::Result { - let dir = default_policy_dir(); - - for ext in POLICY_EXTENSIONS { - let path = dir.join(format!("{POLICY_FILE_BASE}.{ext}")); - if path.exists() { - return Ok(path); - } - } - - anyhow::bail!( - "policy file not found in {}; create package-broker-policy.{{json,yaml,yml}} to enable the broker", - dir.display() - ) -} - -/// Candidate default policy path used when no default policy file exists yet. -pub fn default_policy_candidate() -> PathBuf { - default_policy_dir().join(format!("{POLICY_FILE_BASE}.json")) -} diff --git a/crates/now-package-broker/src/policy_security.rs b/crates/now-package-broker/src/policy_security.rs index 4ebb290d6..f806569ee 100644 --- a/crates/now-package-broker/src/policy_security.rs +++ b/crates/now-package-broker/src/policy_security.rs @@ -1,7 +1,9 @@ //! Admin-only-writable file security validation. //! -//! Shared by two trust boundaries in the package broker: +//! Shared by three trust boundaries in the package broker: //! - The policy file, which is the entire authorization control for the broker. +//! - The dedicated directory hosting the default policy file, which the broker itself +//! creates and secures. //! - Package-manager executables resolved for elevated/machine-scope execution //! (e.g. `winget.exe`, `choco.exe`). //! @@ -15,8 +17,11 @@ //! a trusted principal and that its DACL does not grant write access to any other //! principal. Callers fail closed when this check fails. //! -//! For the policy file, the trusted principals are SYSTEM, `LOCAL SERVICE`, and the -//! built-in Administrators group. For executables, `LOCAL SERVICE` is not trusted, but +//! For the policy file and its hosting directory, the trusted principals are SYSTEM and +//! the built-in Administrators group only. `LOCAL SERVICE` is deliberately *not* trusted: +//! it is a low-privilege shared service identity, and the managed policy store must not +//! accept it as a legitimate writer even though other, unrelated Agent subtrees grant it +//! write access. For executables, `LOCAL SERVICE` is likewise not trusted, but //! `NT SERVICE\TrustedInstaller` is, since Windows-protected binaries (`System32`, //! `Program Files`, `WindowsApps`) are owned by and writable by that service. //! @@ -26,28 +31,36 @@ //! object cannot be written, deleted, or renamed until it has been executed. Execution is //! bound to the final path resolved from the verified handle (defeating reparse-point //! retargeting of the originally supplied name), and every ancestor directory of that -//! path is checked so untrusted principals cannot swap path components either. +//! path is checked so untrusted principals cannot swap path components either. The policy +//! directory's own ancestor chain is checked more strictly still (see +//! [`verify_policy_ancestor_chain`]): every level must also reject reparse points outright +//! and resolve to the exact expected location, tolerating create rights at every level +//! since a sibling entry cannot redirect or replace the already-identity-checked directory +//! itself. use std::ffi::OsString; use std::fs::{File, OpenOptions}; use std::os::windows::ffi::OsStringExt as _; -use std::os::windows::fs::OpenOptionsExt as _; +use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; use std::os::windows::io::AsRawHandle as _; use std::path::{Path, PathBuf}; use anyhow::{Context as _, bail}; +use sha2::{Digest as _, Sha256}; +use win_api_wrappers::identity::sid::Sid; +use win_api_wrappers::security::acl::{Acl, InheritableAcl, InheritableAclKind}; +use win_api_wrappers::security::attributes::{SecurityAttributes, SecurityAttributesInit}; use windows::Win32::Foundation::{ERROR_SUCCESS, GENERIC_ALL, GENERIC_WRITE, HANDLE, HLOCAL, LocalFree}; use windows::Win32::Security::Authorization::{ConvertSidToStringSidW, GetSecurityInfo, SE_FILE_OBJECT}; use windows::Win32::Security::{ ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, DACL_SECURITY_INFORMATION, GetAce, INHERIT_ONLY_ACE, IsWellKnownSid, - OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, WinBuiltinAdministratorsSid, WinLocalServiceSid, - WinLocalSystemSid, + OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, WinBuiltinAdministratorsSid, WinLocalSystemSid, }; use windows::Win32::Storage::FileSystem::{ - DELETE, FILE_APPEND_DATA, FILE_DELETE_CHILD, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_NAME_NORMALIZED, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, - FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, GetFinalPathNameByHandleW, READ_CONTROL, WRITE_DAC, - WRITE_OWNER, + DELETE, FILE_APPEND_DATA, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_DELETE_CHILD, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_NAME_NORMALIZED, FILE_READ_ATTRIBUTES, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, + GetFinalPathNameByHandleW, READ_CONTROL, WRITE_DAC, WRITE_OWNER, }; use windows::core::PWSTR; @@ -95,7 +108,10 @@ const TRUSTED_INSTALLER_SID: &str = "S-1-5-80-956008885-3418522649-1831038044-18 /// Principals trusted to hold write access over a verified file. #[derive(Clone, Copy, PartialEq, Eq)] enum TrustedWriters { - /// SYSTEM, `LOCAL SERVICE`, and the built-in Administrators group (policy file). + /// SYSTEM and the built-in Administrators group only (policy file and its hosting + /// directory). `LOCAL SERVICE` is deliberately not trusted: it is a low-privilege + /// shared service identity, and the managed policy store must not treat it as a + /// legitimate writer even though other, unrelated Agent subtrees grant it write access. AdminOnly, /// SYSTEM, the built-in Administrators group, and `NT SERVICE\TrustedInstaller` /// (Windows-protected executables). `LOCAL SERVICE` is deliberately not trusted here: @@ -147,8 +163,7 @@ impl Drop for OwnedSecurityDescriptor { } } -/// Verify that the policy file may only be written by SYSTEM, `LOCAL SERVICE`, or -/// built-in Administrators. +/// Verify that the policy file may only be written by SYSTEM or built-in Administrators. /// /// The check is performed on the already-opened file handle so the verified security /// descriptor belongs to the very same file that is subsequently read (no TOCTOU window @@ -166,6 +181,201 @@ pub(crate) fn verify_policy_file_security(file: &File) -> anyhow::Result<()> { verify_handle_security(file, "policy file", TrustedWriters::AdminOnly, WRITE_ACCESS_MASK) } +/// Verify that a directory intended to exclusively host the managed policy file denies +/// untrusted principals every right that would let them interfere with it: creating, +/// renaming, or deleting entries (including the atomic-replace temporary file), or +/// rewriting the directory's own security descriptor. +/// +/// Unlike [`verify_ancestor_directories`], which only cares about redirecting an +/// *existing* path component and therefore tolerates create rights on higher ancestors, +/// this directory is exclusively owned by the broker: create rights would let an +/// untrusted principal plant or race the atomic-replace temporary file, so they are +/// rejected here too. The same [`TrustedWriters::AdminOnly`] principals as the policy +/// file itself apply (`LOCAL SERVICE` is not trusted). +pub(crate) fn verify_policy_directory_security(dir: &File) -> anyhow::Result<()> { + verify_handle_security( + dir, + "policy directory", + TrustedWriters::AdminOnly, + PARENT_DIRECTORY_TAMPER_MASK, + ) +} + +/// Compute a digest that changes whenever `file`'s owner or DACL changes, even between +/// two configurations that would each independently pass [`verify_policy_file_security`] +/// (e.g. a rewritten-but-still-admin-only ACL, or SYSTEM vs. Administrators ownership). +/// Used only to fold "security-relevant state" into the policy store's opaque token so an +/// ACL change rotates it; never to authorize anything by itself. +/// +/// Callers must only invoke this after [`verify_policy_file_security`] has already +/// succeeded on the same handle: every ACE this reads is then guaranteed to be one of the +/// simple, fixed-layout allow/deny/audit/alarm types (object ACE types would already have +/// been rejected), so no further type dispatch is needed here. +pub(crate) fn security_state_digest(file: &File) -> anyhow::Result<[u8; 32]> { + let handle = HANDLE(file.as_raw_handle()); + + let mut owner = PSID::default(); + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut descriptor = OwnedSecurityDescriptor(PSECURITY_DESCRIPTOR::default()); + + // SAFETY: `handle` is a valid open file handle, all out pointers point to live stack + // variables, and the requested security information matches the provided out parameters. + let ret = unsafe { + GetSecurityInfo( + handle, + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + Some(&mut owner), + None, + Some(&mut dacl), + None, + Some(&mut descriptor.0), + ) + }; + if ret != ERROR_SUCCESS { + bail!("failed to read security information for digest: error {}", ret.0); + } + + let mut hasher = Sha256::new(); + + let owner_string = if owner.0.is_null() { + "".to_owned() + } else { + // SAFETY: `owner` points into `descriptor`, which outlives this call. + unsafe { sid_to_string(owner) } + }; + hasher.update(owner_string.as_bytes()); + hasher.update(b"\0"); + + if dacl.is_null() { + hasher.update(b"NULL_DACL"); + } else { + // SAFETY: On success, `dacl` points into `descriptor`, which outlives this call. + let ace_count = u32::from(unsafe { (*dacl).AceCount }); + hasher.update(ace_count.to_le_bytes()); + + for idx in 0..ace_count { + let mut ace_ptr: *mut core::ffi::c_void = std::ptr::null_mut(); + // SAFETY: `dacl` is a valid ACL pointer and `idx` is within `AceCount`. + unsafe { GetAce(dacl, idx, &mut ace_ptr) }.context("failed to read DACL entry for security digest")?; + + // SAFETY: GetAce succeeded, so `ace_ptr` points to an ACE starting with an + // ACE_HEADER, and (per this function's precondition) a simple, fixed-layout + // (header, mask, inline SID) ACE type. + let header = unsafe { &*ace_ptr.cast::() }; + // SAFETY: Same as above: `ace_ptr` points to a simple, fixed-layout ACE. + let ace = unsafe { &*ace_ptr.cast::() }; + hasher.update([header.AceType, header.AceFlags]); + hasher.update(ace.Mask.to_le_bytes()); + + let trustee = PSID(std::ptr::from_ref(&ace.SidStart).cast_mut().cast()); + // SAFETY: `SidStart` is the first DWORD of the trustee SID stored inline in the ACE. + let trustee_string = unsafe { sid_to_string(trustee) }; + hasher.update(trustee_string.as_bytes()); + hasher.update(b"\0"); + } + } + + Ok(hasher.finalize().into()) +} + +/// Build the admin-only ACL (SYSTEM and built-in Administrators only, full control) +/// shared by every place the broker establishes the policy store's on-disk security. +/// +/// `inheritance` controls whether the resulting ACEs propagate to children (appropriate +/// for a directory) or apply only to the object itself (appropriate for a leaf file). +fn admin_only_acl(inheritance: windows::Win32::Security::ACE_FLAGS) -> anyhow::Result { + use win_api_wrappers::security::acl::{ExplicitAccess, Trustee}; + use windows::Win32::Security::Authorization::GRANT_ACCESS; + + let system = Sid::from_well_known(WinLocalSystemSid, None).context("resolve SYSTEM SID")?; + let admins = Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("resolve Administrators SID")?; + + Acl::new() + .context("initialize ACL")? + .set_entries(&[ + ExplicitAccess { + access_permissions: GENERIC_ALL.0, + access_mode: GRANT_ACCESS, + inheritance, + trustee: Trustee::Sid(system), + }, + ExplicitAccess { + access_permissions: GENERIC_ALL.0, + access_mode: GRANT_ACCESS, + inheritance, + trustee: Trustee::Sid(admins), + }, + ]) + .context("build admin-only ACL") +} + +/// Build `SECURITY_ATTRIBUTES` granting only SYSTEM and built-in Administrators access +/// (owner: SYSTEM; `Protected` DACL, so inheritance changes to ancestors cannot loosen it +/// later), for use with `CreateDirectoryW`/`CreateFileW` so the object's security is +/// correct from the instant it becomes visible on disk -- no create-then-ACL window an +/// untrusted principal could win. +/// +/// Set `inherit_to_children` for a directory whose files/subdirectories should default to +/// the same restrictive ACL; leave it unset for a leaf file, which has no children. +pub(crate) fn admin_only_security_attributes(inherit_to_children: bool) -> anyhow::Result { + use windows::Win32::Security::{CONTAINER_INHERIT_ACE, NO_INHERITANCE, OBJECT_INHERIT_ACE}; + + let inheritance = if inherit_to_children { + CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE + } else { + NO_INHERITANCE + }; + + let owner = Sid::from_well_known(WinLocalSystemSid, None).context("resolve SYSTEM SID")?; + let acl = admin_only_acl(inheritance)?; + + Ok(SecurityAttributesInit { + owner: Some(owner), + dacl: Some(InheritableAcl { + kind: InheritableAclKind::Protected, + acl, + }), + ..Default::default() + } + .init()) +} + +/// Volume serial number and 128-bit file id uniquely identifying an open filesystem +/// object: stable across renames of the same object, but distinct across a delete and +/// recreate at the same path (even with byte-identical content), which is exactly the +/// distinction the policy store's opaque tokens depend on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct FileIdentity { + pub(crate) volume_serial: u64, + pub(crate) file_id: [u8; 16], +} + +/// Query the [`FileIdentity`] of the open object behind `file`. +pub(crate) fn file_identity(file: &File) -> anyhow::Result { + use windows::Win32::Storage::FileSystem::{FILE_ID_INFO, FileIdInfo, GetFileInformationByHandleEx}; + + let mut info = FILE_ID_INFO::default(); + let info_size = u32::try_from(size_of::()).expect("FILE_ID_INFO size fits in u32"); + + // SAFETY: `file` is an open file handle, and the output pointer points to a properly + // sized FILE_ID_INFO valid for the duration of the call. + unsafe { + GetFileInformationByHandleEx( + HANDLE(file.as_raw_handle()), + FileIdInfo, + (&raw mut info).cast(), + info_size, + ) + } + .context("GetFileInformationByHandleEx(FileIdInfo) failed")?; + + Ok(FileIdentity { + volume_serial: info.VolumeSerialNumber, + file_id: info.FileId.Identifier, + }) +} + /// A package-manager executable that was verified for elevated execution. /// /// The held file handle was opened without write or delete sharing, so the verified file @@ -254,7 +464,7 @@ pub(crate) fn verify_elevated_executable_security( WRITE_ACCESS_MASK, )?; - verify_ancestor_directories(&final_path, &subject)?; + verify_elevated_executable_ancestor_directories(&final_path, &subject)?; Ok(Some(VerifiedExecutable { _file: file, @@ -439,9 +649,113 @@ fn parse_app_exec_alias(buffer: &[u8]) -> Option { /// file when the image is finally loaded. Create rights higher up are harmless (and are /// granted to unprivileged users on stock drive roots), since they cannot redirect an /// existing path component. -fn verify_ancestor_directories(path: &Path, subject: &str) -> anyhow::Result<()> { +pub(crate) fn verify_elevated_executable_ancestor_directories(path: &Path, subject: &str) -> anyhow::Result<()> { + verify_ancestor_directories(path, subject, PARENT_DIRECTORY_TAMPER_MASK) +} + +/// Verify that every ancestor of `dir` (starting at its parent; `dir` itself must already +/// be separately verified by the caller, e.g. with [`verify_policy_directory_security`]) +/// is a genuine directory resolving to the expected location and denies untrusted +/// principals the rights needed to delete, rename, or replace `dir` out from under an +/// already-verified identity check -- a "path-swap" further up the tree. +/// +/// Unlike [`verify_elevated_executable_ancestor_directories`] (and the shared +/// [`verify_ancestor_directories`] helper it relies on, which this deliberately never +/// calls or alters), every level here is opened with `FILE_FLAG_OPEN_REPARSE_POINT` and +/// rejected outright if it turns out to be a reparse point (junction/symlink) rather than +/// transparently traversed, and its handle-resolved final path is compared against the +/// exact literal component being verified: a directory silently retargeted partway up the +/// policy directory's own ancestor chain must never be trusted just because reparse +/// traversal would have "worked". Create rights are still tolerated at *every* level, +/// including the immediate parent: other, unrelated features may legitimately create +/// sibling entries in a shared ancestor (the installer grants `LOCAL SERVICE` write +/// access to the shared `%ProgramData%\Devolutions\Agent` directory for unrelated Agent +/// subtrees), and that alone cannot redirect or replace `dir`, which is what this check +/// actually defends against. Only delete/rename/take-ownership rights +/// ([`DIRECTORY_TAMPER_MASK`]) are rejected. +/// +/// Returns a digest summarizing every verified level's resolved path and security state +/// (owner/DACL), so a caller folding this into a fingerprint (see +/// `policy_store::windows::DiskFingerprint`) can detect a change anywhere in the ancestor +/// chain -- not just the immediate parent -- without re-deriving the individual checks. +pub(crate) fn verify_policy_ancestor_chain(dir: &Path, subject: &str) -> anyhow::Result<[u8; 32]> { + let mut hasher = Sha256::new(); + let mut current = dir.parent(); + + while let Some(ancestor) = current { + let dir_subject = format!("{subject} ancestor directory '{}'", ancestor.display()); + + let handle = OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0 | READ_CONTROL.0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(ancestor) + .with_context(|| format!("failed to open {dir_subject}"))?; + + let attributes = handle + .metadata() + .with_context(|| format!("failed to query metadata for {dir_subject}"))? + .file_attributes(); + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + bail!("{dir_subject} is a reparse point (junction/symlink); ancestor directories must be real directories"); + } + if attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + bail!("{dir_subject} is not a directory"); + } + + let resolved = final_path_from_handle(&handle).with_context(|| format!("failed to resolve {dir_subject}"))?; + if !paths_match_case_insensitive(&resolved, ancestor) { + bail!( + "{dir_subject} resolved to an unexpected location '{}'; refusing to trust a retargeted ancestor", + resolved.display() + ); + } + + verify_handle_security( + &handle, + &dir_subject, + TrustedWriters::AdminOrTrustedInstaller, + DIRECTORY_TAMPER_MASK, + )?; + + let level_security_digest = + security_state_digest(&handle).with_context(|| format!("failed to digest {dir_subject} security"))?; + hasher.update(resolved.as_os_str().to_string_lossy().to_lowercase().as_bytes()); + hasher.update(b"\0"); + hasher.update(level_security_digest); + + current = ancestor.parent(); + } + + Ok(hasher.finalize().into()) +} + +/// Case-insensitive path comparison for verifying a handle's resolved final path against +/// an expected literal component. Falls back to an exact `OsStr` comparison on the rare +/// input that is not valid Unicode, which only ever makes the comparison *stricter* +/// (fail-closed), never more permissive. +pub(crate) fn paths_match_case_insensitive(a: &Path, b: &Path) -> bool { + match (a.to_str(), b.to_str()) { + (Some(a), Some(b)) => a.eq_ignore_ascii_case(b), + _ => a.as_os_str() == b.as_os_str(), + } +} + +/// Verify that every ancestor of `dir` (starting at its parent; `dir` itself must already +/// be separately verified by the caller, e.g. with [`verify_policy_directory_security`]) +/// denies untrusted principals the rights needed to delete, rename, or replace `dir` out +/// from under an already-verified identity check -- a "path-swap" further up the tree. +/// +/// Unlike [`verify_elevated_executable_ancestor_directories`], create rights are tolerated +/// at *every* level, including the immediate parent: other, unrelated features may +/// legitimately create sibling entries in a shared ancestor (the installer grants +/// `LOCAL SERVICE` write access to the shared `%ProgramData%\Devolutions\Agent` directory +/// for unrelated Agent subtrees), and that alone cannot redirect or replace `dir`, which is +/// what this check actually defends against. Only delete/rename/take-ownership rights +/// ([`DIRECTORY_TAMPER_MASK`]) are rejected. +fn verify_ancestor_directories(path: &Path, subject: &str, first_level_mask: u32) -> anyhow::Result<()> { let mut current = path.parent(); - let mut tamper_mask = PARENT_DIRECTORY_TAMPER_MASK; + let mut tamper_mask = first_level_mask; while let Some(dir) = current { let dir_subject = format!("{subject} ancestor directory '{}'", dir.display()); @@ -468,7 +782,7 @@ fn verify_ancestor_directories(path: &Path, subject: &str) -> anyhow::Result<()> } /// Resolve the normalized final path of an open file from its handle. -fn final_path_from_handle(file: &File) -> anyhow::Result { +pub(crate) fn final_path_from_handle(file: &File) -> anyhow::Result { let handle = HANDLE(file.as_raw_handle()); let mut buffer = vec![0u16; 512]; @@ -628,7 +942,7 @@ unsafe fn verify_owner_and_dacl( // SAFETY: `trustee` points to a valid SID inside the ACE. let trustee_string = unsafe { sid_to_string(trustee) }; bail!( - "{subject} DACL grants write access to {trustee_string}; only trusted principals may be able to write it" + "{subject} DACL grants write access to {trustee_string}; only trusted principals may write it" ); } } @@ -651,15 +965,6 @@ unsafe fn is_trusted_sid(sid: PSID, trusted_writers: TrustedWriters) -> bool { return true; } - // The Devolutions Agent installer creates `C:\ProgramData\Devolutions\Agent` with - // write access for `LOCAL SERVICE`, so it must be trusted for the policy file. - // It is a low-privilege shared service identity, however, so it is not trusted for - // elevated executables, where accepting it would open a privilege-escalation path. - // SAFETY: Per function contract, `sid` points to a valid SID. - if trusted_writers == TrustedWriters::AdminOnly && unsafe { IsWellKnownSid(sid, WinLocalServiceSid) }.as_bool() { - return true; - } - // SAFETY: Per function contract, `sid` points to a valid SID. if unsafe { IsWellKnownSid(sid, WinBuiltinAdministratorsSid) }.as_bool() { return true; @@ -717,6 +1022,40 @@ mod tests { use super::*; + /// Proves the property [`admin_only_security_attributes`] is relied on for (item 8): + /// the DACL it builds is `Protected` (`SE_DACL_PROTECTED`), so `CreateFileW`/ + /// `CreateDirectoryW` never merges it with whatever the hosting directory would + /// otherwise have inherited. An insecure inherited/default DACL on the parent + /// therefore cannot expose the object even if inheritance were somehow + /// misconfigured: the new object's DACL is authoritative from the instant of + /// creation, not a merge. This only inspects the in-memory descriptor this process + /// just built, so it needs no elevation and touches no real file. + #[test] + fn admin_only_security_attributes_use_a_protected_non_inheriting_dacl() { + let attributes = admin_only_security_attributes(false).expect("build admin-only security attributes"); + + // SAFETY: `attributes.as_ptr()` is a valid, live `SECURITY_ATTRIBUTES` this + // process just constructed. + let raw = unsafe { &*attributes.as_ptr() }; + // SAFETY: `lpSecurityDescriptor` points to a live `SECURITY_DESCRIPTOR` built the + // same way, valid for the duration of this read. + let descriptor = unsafe { + &*raw + .lpSecurityDescriptor + .cast::() + }; + let control = descriptor.Control; + + assert!( + control.contains(windows::Win32::Security::SE_DACL_PROTECTED), + "expected SE_DACL_PROTECTED to be set so the DACL never merges with inherited ACEs" + ); + assert!( + control.contains(windows::Win32::Security::SE_DACL_PRESENT), + "expected a DACL to actually be present (a NULL DACL would grant everyone full control)" + ); + } + /// SDDL-backed security descriptor together with its extracted owner and DACL pointers. struct SddlDescriptor { _descriptor: OwnedSecurityDescriptor, @@ -813,11 +1152,16 @@ mod tests { } #[test] - fn local_service_write_ace_is_accepted_for_policy_file() { - // The installer creates the Agent ProgramData directory with write access for - // LOCAL SERVICE, so the policy-file check must accept it. + fn local_service_write_ace_is_rejected_for_policy_file() { + // The managed policy store lives in its own dedicated, broker-secured directory, + // and LOCAL SERVICE is a low-privilege shared service identity that must not be + // trusted to write the policy that authorizes elevated installs. let sd = SddlDescriptor::parse("O:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;LS)"); - sd.verify().expect("LOCAL SERVICE write access must be accepted"); + let error = sd.verify().unwrap_err(); + assert!( + error.to_string().contains("grants write access"), + "unexpected error: {error}" + ); } #[test] @@ -842,6 +1186,34 @@ mod tests { ); } + #[test] + fn shared_ancestor_create_only_grant_passes_the_relaxed_ancestor_mask() { + // Mirrors the installer-configured shared `%ProgramData%\Devolutions\Agent` + // parent: SYSTEM/Administrators full control, plus LOCAL SERVICE granted only + // add-file/add-subdirectory rights (0x6: FILE_WRITE_DATA | FILE_APPEND_DATA) for + // unrelated Agent features. No delete-child, delete, write-DAC, or take-ownership + // rights are granted, so this must pass the ancestor chain's relaxed + // `DIRECTORY_TAMPER_MASK` (see `verify_policy_ancestor_chain`): a sibling + // create right cannot redirect or replace the dedicated policy directory. + let sd = SddlDescriptor::parse("O:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x6;;;LS)"); + sd.verify_with_mask(DIRECTORY_TAMPER_MASK) + .expect("create-only rights on a shared ancestor must not fail the relaxed ancestor check"); + } + + #[test] + fn shared_ancestor_delete_child_grant_fails_the_relaxed_ancestor_mask() { + // If the shared parent ever granted delete-child (path-swap) rights to a + // non-trusted principal, the dedicated policy directory could be renamed or + // replaced out from under an already-passed identity check. This must never be + // silently accepted, even by the deliberately relaxed ancestor mask. + let sd = SddlDescriptor::parse("O:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x40;;;LS)"); + let error = sd.verify_with_mask(DIRECTORY_TAMPER_MASK).unwrap_err(); + assert!( + error.to_string().contains("grants write access"), + "unexpected error: {error}" + ); + } + #[test] fn app_exec_alias_reparse_buffer_is_parsed() { // Synthetic AppExecLink buffer: version 3, then package family, entry point, diff --git a/crates/now-package-broker/src/policy_store/mod.rs b/crates/now-package-broker/src/policy_store/mod.rs new file mode 100644 index 000000000..390680d6b --- /dev/null +++ b/crates/now-package-broker/src/policy_store/mod.rs @@ -0,0 +1,2543 @@ +//! Agent-owned, serialized policy store. +//! +//! Owns the configured/resolved policy path, the observed Active/Missing/Invalid state, +//! sanitized diagnostics for an Invalid configuration, the immutable active policy +//! snapshot, opaque store tokens bound to the exact observed disk state (see +//! [`windows::DiskFingerprint`] and [`PolicyStore::token_for`]), keyed validation receipts +//! (see [`receipt::ReceiptKey`]), atomic persistence, and coordinated reload from both the +//! management API and external (out-of-band) edits. +//! +//! Concurrency model: hot reads ([`PolicyStore::snapshot`] and friends) take a brief +//! read-lock only to clone one `Arc` and never touch disk; every disk-touching operation +//! (an API-driven [`PolicyStore::replace`] or a watcher-driven +//! [`PolicyStore::reload_from_disk`]) is serialized through a single `tokio::sync::Mutex`, +//! so an API write and an external-edit reload can never interleave. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use notify::{RecommendedWatcher, RecursiveMode, Watcher as _}; +use now_policy::PolicyDocument; +use now_policy_api::{ + ErrorCode, ErrorResponse, InvalidPolicyDiagnostics, PolicyConfigurationSource, PolicyConflictHandling, + PolicyManagementSnapshot, PolicyManagementState, PolicyReadOnlyReason, PolicyReplacementOperation, + PolicyReplacementRequest, PolicyStoreToken, PolicyValidationResult, PolicyWriteCapability, +}; +use tokio_util::sync::CancellationToken; +use win_api_wrappers::identity::sid::Sid; + +use crate::audit; +use crate::server::responses::{ + error_response, error_response_with_management, policy_read_only_error_code, stale_token_response, + validation_error_response, +}; + +mod receipt; +pub mod validation; +mod windows; + +/// Storage backend used by [`PolicyStore`] for every disk-touching operation. +/// +/// Abstracted so unit tests can inject an in-memory fake and exercise the store's +/// transactional logic (token comparison, revision planning, validation-receipt/warning +/// gating, snapshot swaps, audit calls) deterministically and without requiring the real +/// SYSTEM/Administrators-only ACL enforcement that production storage depends on. +trait PolicyStorage: Send + Sync { + /// Observe the exact current disk state of the configured policy path, including + /// the write capability resolved as part of that same observation (see + /// `windows::DiskObservation`, item 20/26): capability is never derived from a + /// separately cached snapshot, so it can never silently drift from the state it + /// describes. + fn observe(&self, source: PolicyConfigurationSource, path: &Path) -> windows::DiskObservation; + fn atomic_replace( + &self, + dir: &Path, + final_path: &Path, + bytes: &[u8], + ) -> Result; + /// Same as [`PolicyStorage::atomic_replace`], but must never overwrite an existing + /// destination (used for `Create`; see `windows::atomic_create`). + fn atomic_create( + &self, + dir: &Path, + final_path: &Path, + bytes: &[u8], + ) -> Result; +} + +/// Production storage backend: the real Windows filesystem/ACL implementation. +struct WindowsPolicyStorage { + /// Caches the one-time, side-effecting filesystem atomic-replace capability probe + /// (see `windows::AtomicityProbeCache`); shared across every observation this store + /// makes for the lifetime of the process. + probe_cache: windows::AtomicityProbeCache, +} + +impl WindowsPolicyStorage { + fn new() -> Self { + Self { + probe_cache: windows::AtomicityProbeCache::new(), + } + } +} + +impl PolicyStorage for WindowsPolicyStorage { + fn observe(&self, source: PolicyConfigurationSource, path: &Path) -> windows::DiskObservation { + windows::observe(source, path, &self.probe_cache) + } + + fn atomic_replace( + &self, + dir: &Path, + final_path: &Path, + bytes: &[u8], + ) -> Result { + windows::atomic_replace(dir, final_path, bytes) + } + + fn atomic_create( + &self, + dir: &Path, + final_path: &Path, + bytes: &[u8], + ) -> Result { + windows::atomic_create(dir, final_path, bytes) + } +} + +/// Identity of the pipe client attempting a policy write, threaded through purely for +/// audit logging; authorization itself (signature, elevation, Administrators membership) +/// is already decided by the caller before reaching [`PolicyStore::replace`]. +pub struct PolicyWriteActor<'a> { + pub sid: &'a Sid, + pub executable: &'a Path, +} + +/// Successful outcome of [`PolicyStore::replace`]. +#[derive(Debug)] +pub struct ReplaceSuccess { + pub policy: PolicyDocument, + pub validation: PolicyValidationResult, + pub management: PolicyManagementSnapshot, +} + +/// Immutable internal snapshot backing the store; swapped atomically as a whole so +/// readers never observe a partially updated state. +struct Snapshot { + state: PolicyManagementState, + write_capability: PolicyWriteCapability, + read_only_reason: Option, + policy: Option>, + invalid_diagnostics: Option, + store_token: PolicyStoreToken, + /// Internal identity the published `store_token` is bound to; never itself exposed. + /// See [`PolicyStore::token_for`]. + fingerprint: windows::DiskFingerprint, + /// Canonical path resolved by the observation this snapshot was published from (see + /// item 22): the *only* path value used for display (`configured_path` in + /// [`PolicyManagementSnapshot`]), audit, and writes from that point on. Never + /// re-derived from the original configuration string once an observation has run. + canonical_path: PathBuf, +} + +/// Serialized, transactional store for the configured package-broker policy. +pub struct PolicyStore { + /// The literal configured (or default) path, exactly as configured: the fixed input + /// fed to every [`PolicyStorage::observe`] call. Never itself displayed, audited, or + /// used for a write; see [`Snapshot::canonical_path`] for the value that is. + configured_path: PathBuf, + source: PolicyConfigurationSource, + snapshot: std::sync::RwLock>, + /// Serializes every disk-touching operation: API-driven replacement and + /// watcher-driven reload from an external edit. + write_lock: tokio::sync::Mutex<()>, + storage: Arc, + /// Process-random key binding every validation receipt this store issues; see + /// [`PolicyStore::validate_draft`]. + receipt_key: receipt::ReceiptKey, +} + +impl PolicyStore { + /// Resolve the configured path, create/secure the default directory (or verify a + /// custom one without rewriting it), and observe the current disk state. + /// + /// Never fails: any resolution or observation problem is reflected in the returned + /// store's state/capability instead (fail-closed, matching the broker's existing + /// pause-on-problem philosophy). + pub fn load(configured_path: Option) -> Arc { + Self::load_with_storage(configured_path, Arc::new(WindowsPolicyStorage::new())) + } + + fn load_with_storage(configured_path: Option, storage: Arc) -> Arc { + let (configured_path, source) = match configured_path { + Some(path) => (path, PolicyConfigurationSource::ConfiguredPath), + None => (windows::default_policy_path(), PolicyConfigurationSource::DefaultPath), + }; + + let observation = storage.observe(source, &configured_path); + if observation.write_capability != PolicyWriteCapability::Writable { + tracing::warn!( + path = %observation.canonical_path.display(), + write_capability = ?observation.write_capability, + read_only_reason = ?observation.read_only_reason, + "Policy directory is not writable through the management API" + ); + } + match &observation.state { + PolicyManagementState::Active => { + let policy = observation + .policy + .as_ref() + .expect("Active observation always carries a policy"); + tracing::info!( + policy_id = %policy.metadata.id, + revision = policy.metadata.revision, + path = %observation.canonical_path.display(), + "Loaded package broker policy" + ); + } + PolicyManagementState::Missing => { + tracing::warn!( + path = %observation.canonical_path.display(), + "No configured policy found; broker will pause until one is created through the management API" + ); + } + PolicyManagementState::Invalid => { + tracing::warn!( + path = %observation.canonical_path.display(), + "Configured policy is invalid; broker will pause until it is repaired through the management API" + ); + } + } + + // First observation ever made by this store: there is no previous fingerprint to + // compare against, so a fresh token is always minted (see `token_for`). + let store_token = windows::random_store_token(); + + let snapshot = Arc::new(Snapshot { + state: observation.state, + write_capability: observation.write_capability, + read_only_reason: observation.read_only_reason, + policy: observation.policy.map(Arc::new), + invalid_diagnostics: observation.invalid_diagnostics, + store_token, + fingerprint: observation.fingerprint, + canonical_path: observation.canonical_path, + }); + + Arc::new(Self { + configured_path, + source, + snapshot: std::sync::RwLock::new(snapshot), + write_lock: tokio::sync::Mutex::new(()), + storage, + receipt_key: receipt::ReceiptKey::generate(), + }) + } + + /// Cheap hot-path read: clones one `Arc` under a brief read-lock, never touches disk. + fn snapshot(&self) -> Arc { + Arc::clone(&self.snapshot.read().expect("policy store snapshot lock poisoned")) + } + + /// Resolve the opaque token for a freshly observed `fingerprint`, given the + /// previously published snapshot to compare it against. + /// + /// This is the *only* place a [`PolicyStoreToken`] is ever produced: reusing + /// `previous`'s token when the fingerprint did not change, minting and remembering a + /// fresh process-random one ([`windows::random_store_token`]) otherwise. Tokens never + /// encode or derive from the fingerprint's content, so they cannot be correlated with + /// file content/identity by an outside observer, and are stable only for as long as + /// the exact observed disk state (content, identity, security) does not change. + fn token_for(previous: &Snapshot, fingerprint: &windows::DiskFingerprint) -> PolicyStoreToken { + if previous.fingerprint == *fingerprint { + previous.store_token.clone() + } else { + windows::random_store_token() + } + } + + /// Authoritatively (re)validate raw draft JSON and, if valid, bind a keyed receipt + /// under this store's own process-random key. + /// + /// This is the *only* place a validation receipt is ever issued or accepted: both + /// `POST /v1/policy/validate` and the `PUT /v1/policy` replacement transaction call + /// this same method (see [`PolicyStore::replace`]), so they always bind against the + /// exact same key. + pub fn validate_draft(&self, raw: &serde_json::Value) -> PolicyValidationResult { + let mut result = validation::validate_draft(raw); + if let Some(canonical_draft) = &result.canonical_draft { + result.validation_receipt = Some(self.receipt_key.issue( + &result.validator_version, + canonical_draft, + &result.findings, + )); + } + result + } + + /// The currently active policy, or `None` when the broker is paused + /// (Missing/Invalid configured policy). + pub fn active_policy(&self) -> Option> { + self.snapshot().policy.clone() + } + + /// Build a store with no disk backing, for unit tests exercising `BrokerState` + /// request handling (evaluate/execute/status/cancel) without touching the + /// filesystem. Never used outside `#[cfg(test)]`. + #[cfg(test)] + pub(crate) fn for_tests(policy: Option) -> Arc { + let (state, fingerprint) = match &policy { + Some(policy) => ( + PolicyManagementState::Active, + windows::DiskFingerprint::test_active( + &serde_json::to_vec(policy).expect("test policy serializes"), + 0, + 0, + 0, + ), + ), + None => ( + PolicyManagementState::Missing, + windows::DiskFingerprint::test_missing(0), + ), + }; + let snapshot = Arc::new(Snapshot { + state, + write_capability: PolicyWriteCapability::Writable, + read_only_reason: None, + policy: policy.map(Arc::new), + invalid_diagnostics: None, + store_token: windows::random_store_token(), + fingerprint, + canonical_path: PathBuf::from("test-policy.json"), + }); + + Arc::new(Self { + configured_path: PathBuf::from("test-policy.json"), + source: PolicyConfigurationSource::DefaultPath, + snapshot: std::sync::RwLock::new(snapshot), + write_lock: tokio::sync::Mutex::new(()), + storage: Arc::new(tests::FakePolicyStorage::writable()), + receipt_key: receipt::ReceiptKey::generate(), + }) + } + + /// Build a store backed entirely by an injected [`PolicyStorage`], for unit tests + /// exercising the full `replace`/`reload_from_disk` transactional logic (token + /// comparison, revision planning, validation-receipt/warning gating, persistence + /// failures) deterministically and without the real SYSTEM/Administrators-only ACL + /// enforcement that production storage depends on. + #[cfg(test)] + pub(crate) fn for_tests_with_storage(storage: Arc) -> Arc { + Self::load_with_storage(Some(PathBuf::from(r"C:\fake\package-broker-policy.json")), storage) + } + + /// Directly (synchronously) swap the active policy, bypassing the write lock and + /// disk entirely. Only used to exercise the hot-read Arc-swap concurrency guarantee + /// in unit tests; production code always goes through [`PolicyStore::replace`]. + #[cfg(test)] + pub(crate) fn test_set_active(&self, policy: Arc) { + let fingerprint = windows::DiskFingerprint::test_active( + &serde_json::to_vec(&*policy).expect("test policy serializes"), + 0, + 0, + 0, + ); + let canonical_path = self.snapshot().canonical_path.clone(); + let snapshot = Arc::new(Snapshot { + state: PolicyManagementState::Active, + write_capability: PolicyWriteCapability::Writable, + read_only_reason: None, + policy: Some(policy), + invalid_diagnostics: None, + store_token: windows::random_store_token(), + fingerprint, + canonical_path, + }); + *self.snapshot.write().expect("policy store snapshot lock poisoned") = snapshot; + } + + /// Atomic view of configured policy state and management guidance, suitable for + /// `GET /v1/policy/management` and for `ErrorResponse::management`. + pub fn management_snapshot(&self) -> PolicyManagementSnapshot { + let snapshot = self.snapshot(); + PolicyManagementSnapshot { + state: snapshot.state, + configured_path: snapshot.canonical_path.display().to_string(), + store_token: snapshot.store_token.clone(), + source: self.source, + write_capability: snapshot.write_capability, + read_only_reason: snapshot.read_only_reason, + // Writes always require an elevated, Administrators-member token regardless of + // write capability; see `crate::auth`. + elevation_required: true, + policy: snapshot.policy.as_deref().cloned(), + invalid_diagnostics: snapshot.invalid_diagnostics.clone(), + } + } + + /// Re-observe the configured policy file after an external (out-of-band) change and + /// adopt it if it differs from the current snapshot. + /// + /// Serialized with [`PolicyStore::replace`] through the same write lock. A bad + /// external edit can legitimately transition the store to Invalid/paused (unlike a + /// self-replacement through the management API, which never pauses the broker: it + /// only ever commits an already-validated document). + pub async fn reload_from_disk(&self, cause: &str) { + let _guard = self.write_lock.lock().await; + let observation = self.storage.observe(self.source, &self.configured_path); + self.publish_if_changed(observation, cause); + } + + /// Reconcile the store's published snapshot with a freshly observed disk state, + /// swapping it in (and logging/auditing the change) only if the observation's + /// fingerprint differs from what is currently published. Returns the resulting + /// authoritative management snapshot -- guaranteed to reflect `observation` when it + /// differed, so a caller that just detected a stale token can publish the current + /// reality *before* reporting `StalePolicyStoreToken`, and `ErrorResponse::management` + /// is always exactly that published snapshot. + /// + /// Write capability is always taken from this fresh `observation`, never carried + /// forward from the previous snapshot (item 20): a directory that became writable or + /// unwritable since the last observation must be reflected immediately, not only the + /// next time something else about the disk state happens to change too. + /// + /// Must be called while holding `write_lock`. + fn publish_if_changed(&self, observation: windows::DiskObservation, cause: &str) -> PolicyManagementSnapshot { + let previous = self.snapshot(); + + if observation.fingerprint == previous.fingerprint + && observation.write_capability == previous.write_capability + && observation.read_only_reason == previous.read_only_reason + { + // No real change: either a spurious filesystem event, this reload was + // triggered by our own just-applied write (which already swapped the + // snapshot before releasing the lock), or (from `replace`'s stale-token + // check) the caller's own idea of the token was simply wrong, not the + // store's. + return self.management_snapshot(); + } + + let store_token = Self::token_for(&previous, &observation.fingerprint); + let new_snapshot = Arc::new(Snapshot { + state: observation.state, + write_capability: observation.write_capability, + read_only_reason: observation.read_only_reason, + policy: observation.policy.map(Arc::new), + invalid_diagnostics: observation.invalid_diagnostics, + store_token, + fingerprint: observation.fingerprint, + canonical_path: observation.canonical_path, + }); + + match (&new_snapshot.state, &new_snapshot.policy) { + (PolicyManagementState::Active, Some(policy)) => { + tracing::info!( + policy_id = %policy.metadata.id, + revision = policy.metadata.revision, + %cause, + "External policy change applied; broker resumed/updated" + ); + audit::external_change_applied( + &new_snapshot.canonical_path, + &policy.metadata.id, + policy.metadata.revision, + ); + } + (state, _) => { + tracing::warn!(?state, %cause, "External policy change left the configured policy unavailable"); + audit::external_change_rejected(&new_snapshot.canonical_path, &format!("{state:?}")); + } + } + + *self.snapshot.write().expect("policy store snapshot lock poisoned") = new_snapshot; + + self.management_snapshot() + } + + /// Default interval for the periodic disk re-observation fallback (item 19/29): + /// runs unconditionally alongside the event-driven filesystem watcher below, so an + /// external change is eventually detected even if OS-level watch setup/registration + /// fails outright, the watcher terminates unexpectedly at runtime, or an individual + /// notification is lost (e.g. an OS-level notification buffer overflow, reported by + /// `notify` as a callback `Err`). Short enough that an operator waiting for a + /// external repair to take effect notices quickly; negligible overhead otherwise + /// (a single cheap re-observation, most of which is already fast/side-effect-free). + const FALLBACK_POLL_INTERVAL: Duration = Duration::from_secs(30); + + /// Watch the configured policy file's parent directory and reload on external + /// changes. Runs until `shutdown` is triggered. + pub async fn watch(self: Arc, shutdown: CancellationToken) { + self.watch_with_poll_interval(shutdown, Self::FALLBACK_POLL_INTERVAL) + .await; + } + + /// Same as [`PolicyStore::watch`], but with an injectable poll interval: the seam a + /// unit test uses to prove the periodic fallback alone -- independent of whether OS + /// filesystem notification delivery works at all in the test environment -- + /// eventually reflects an external change (item 19). + async fn watch_with_poll_interval(self: Arc, shutdown: CancellationToken, poll_interval: Duration) { + let dir = self + .snapshot() + .canonical_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_owned(); + + // Bounded, but the sending side below always uses `blocking_send` (which blocks + // for capacity) rather than `try_send`, so a burst of events can never be + // silently discarded by this channel filling up (item 29): the debounced + // consumer below just coalesces a backlog into a single re-observation once it + // catches up, exactly as it already does for a single event. + let (fs_tx, mut fs_rx) = tokio::sync::mpsc::channel::<()>(16); + let (watcher_stop_tx, watcher_stop_rx) = std::sync::mpsc::channel::<()>(); + + let watch_path = dir.clone(); + let _watcher_handle = tokio::task::spawn_blocking(move || { + let rt_tx = fs_tx; + let mut watcher: RecommendedWatcher = + match notify::recommended_watcher(move |res: notify::Result| match res { + Ok(event) => { + use notify::EventKind; + if matches!( + event.kind, + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) + ) { + let _ = rt_tx.blocking_send(()); + } + } + Err(error) => { + // A watcher callback error can mean lost or overflowed events + // (e.g. the OS-level notification buffer overflowed): silently + // ignoring it (item 29) could leave the store serving a stale + // snapshot indefinitely once event delivery quietly resumes. + // Force an immediate re-observation, the same as an observed + // change, rather than only relying on the next real event or the + // periodic fallback poll to eventually notice. Never logs the + // notify-internal error's own content as anything but an opaque + // diagnostic string; there is no policy content involved here. + tracing::warn!(%error, "Policy directory watcher reported an error; forcing re-observation"); + let _ = rt_tx.blocking_send(()); + } + }) { + Ok(watcher) => watcher, + Err(error) => { + tracing::error!( + %error, + "Failed to create policy file watcher; \ + relying solely on the periodic fallback poll to detect external changes" + ); + return; + } + }; + + if let Err(error) = watcher.watch(&watch_path, RecursiveMode::NonRecursive) { + tracing::error!( + %error, path = %watch_path.display(), + "Failed to watch policy directory; \ + relying solely on the periodic fallback poll to detect external changes" + ); + return; + } + + let _ = watcher_stop_rx.recv(); + }); + + let debounce = Duration::from_millis(500); + let mut poll_timer = tokio::time::interval(poll_interval); + poll_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // The first tick fires immediately; the store was already observed once at + // construction, so skip it to avoid a redundant reobservation on startup. + poll_timer.tick().await; + + loop { + tokio::select! { + _ = shutdown.cancelled() => { + tracing::info!("Policy store watcher shutting down"); + let _ = watcher_stop_tx.send(()); + break; + } + Some(()) = fs_rx.recv() => { + tokio::time::sleep(debounce).await; + while fs_rx.try_recv().is_ok() {} + self.reload_from_disk("external file system event").await; + } + _ = poll_timer.tick() => { + self.reload_from_disk("periodic fallback poll").await; + } + } + } + } + + /// Authoritatively replace the configured policy in a single serialized transaction. + /// + /// Reparses/revalidates the raw draft from scratch (never trusting the caller's own + /// validation), reobserves disk state under the write lock, compares the expected + /// store token, applies the operation's identity/revision rule, and only then + /// persists atomically. A failure at any step leaves the previously active policy + /// (if any) untouched and serving. + pub async fn replace( + &self, + request: PolicyReplacementRequest, + actor: PolicyWriteActor<'_>, + ) -> Result { + let intent = format!("{:?}", request.operation); + + let _guard = self.write_lock.lock().await; + + let previous = self.snapshot(); + let observation = self.storage.observe(self.source, &self.configured_path); + let current_token = Self::token_for(&previous, &observation.fingerprint); + // The canonical path resolved by *this* observation (item 22): every audit/write + // call below uses only this value, never `self.configured_path` (the literal, + // possibly non-canonical configuration string) or a value cached from a previous + // transaction. + let canonical_path = observation.canonical_path.clone(); + + if current_token != request.expected_store_token { + audit::write_conflict(actor.sid, actor.executable, &intent, &canonical_path); + let management = self.publish_if_changed(observation, "replacement observed a stale store token"); + return Err(stale_token_response( + "the configured policy has changed since the expected store token was observed; \ + retry with the current management snapshot's store token" + .to_owned(), + management, + )); + } + + // The directory's write capability was resolved as part of this exact same + // observation (item 20), so it is already as fresh as any capability check right + // before writing could be -- re-observing a second time here would only + // reintroduce the two-separate-observations inconsistency item 20 removes. + if observation.write_capability != PolicyWriteCapability::Writable { + let message = match &observation.read_only_reason { + Some(reason) => format!("the configured policy path is not currently writable ({reason:?})"), + None => "the configured policy path is not currently writable".to_owned(), + }; + audit::write_failed(actor.sid, actor.executable, &intent, &canonical_path, &message); + // Preserve storage error semantics rather than collapsing every reason into + // one code (item 31): see `responses::policy_read_only_error_code`. + return Err(error_response( + policy_read_only_error_code(observation.read_only_reason), + message, + )); + } + + let validation = self.validate_draft(&request.draft); + + if !validation.is_valid { + audit::write_failed( + actor.sid, + actor.executable, + &intent, + &canonical_path, + "authoritative revalidation of the submitted draft failed", + ); + return Err(validation_error_response( + ErrorCode::InvalidPolicy, + "the submitted draft failed authoritative revalidation", + validation, + )); + } + + let canonical_draft = validation + .canonical_draft + .clone() + .expect("a valid PolicyValidationResult always carries a canonical draft"); + + // Constant-time: a receipt is a security credential (proof of authoritative + // revalidation), and comparing it with `==` would leak timing information about + // how many leading bytes of a forged candidate happened to match. + let receipt_valid = self.receipt_key.verify( + &validation.validator_version, + &canonical_draft, + &validation.findings, + &request.validation_receipt, + ); + if !receipt_valid { + audit::write_failed( + actor.sid, + actor.executable, + &intent, + &canonical_path, + "validation receipt does not match the draft's current authoritative validation", + ); + return Err(validation_error_response( + ErrorCode::ValidationFailed, + "validation receipt does not match the current authoritative validation of this draft; \ + re-validate and retry", + validation, + )); + } + + if !validation.findings.is_empty() && !request.warnings_acknowledged { + audit::write_failed( + actor.sid, + actor.executable, + &intent, + &canonical_path, + "validation warnings were not acknowledged", + ); + return Err(validation_error_response( + ErrorCode::WarningConfirmationRequired, + "the draft produced validation warnings that must be explicitly acknowledged", + validation, + )); + } + + let new_id: &str = &canonical_draft.metadata.id; + + let new_revision = match plan_revision( + request.operation, + observation.state, + observation.policy.as_ref(), + new_id, + ) { + Ok(revision) => revision, + Err(message) => { + audit::write_failed(actor.sid, actor.executable, &intent, &canonical_path, &message); + return Err(error_response(ErrorCode::Conflict, message)); + } + }; + + let published_at = Utc::now(); + let final_policy = match canonical_draft.into_policy_document(new_revision, published_at) { + Ok(policy) => policy, + Err(model_error) => { + let message = model_error.to_string(); + audit::write_failed(actor.sid, actor.executable, &intent, &canonical_path, &message); + return Err(error_response(ErrorCode::ValidationFailed, message)); + } + }; + + let bytes = serde_json::to_vec_pretty(&final_policy).expect("BUG: PolicyDocument always serializes"); + let canonical_dir = canonical_path.parent().unwrap_or_else(|| Path::new(".")).to_owned(); + + // `Create` must never replace an unexpectedly-reappeared destination (see + // `windows::atomic_create`); every other operation already observed an + // Active/Invalid document above and intentionally replaces it. + let write_result = if request.operation == PolicyReplacementOperation::Create { + self.storage.atomic_create(&canonical_dir, &canonical_path, &bytes) + } else { + self.storage.atomic_replace(&canonical_dir, &canonical_path, &bytes) + }; + + let persisted = match write_result { + Ok(persisted) => persisted, + Err(windows::WriteFailure::PrePublication(io_error)) => { + // Disk is provably unchanged: the rename that would have published the + // new content never happened (or, for `Create`, failed because it must + // never overwrite an existing destination). The previously active policy + // (if any) is still exactly what it was. + let message = format!("{io_error:#}"); + audit::write_failed(actor.sid, actor.executable, &intent, &canonical_path, &message); + + if request.operation == PolicyReplacementOperation::Create { + let reobservation = self.storage.observe(self.source, &self.configured_path); + if reobservation.state != PolicyManagementState::Missing { + // A leaf appeared between the Missing observation above and this + // call: a real race, not a generic persistence failure. Publish + // the freshly observed reality and report it, rather than ever + // silently overwriting a file this transaction never actually + // observed as absent. + let management = self.publish_if_changed(reobservation, "Create raced with an unexpected leaf"); + return Err(stale_token_response( + "the configured policy path unexpectedly changed while attempting to create a policy; \ + retry with the current management snapshot's store token" + .to_owned(), + management, + )); + } + } + + return Err(error_response( + ErrorCode::PolicyPersistenceFailed, + format!("failed to persist the policy: {message}"), + )); + } + Err(windows::WriteFailure::PostPublication(io_error)) => { + // The atomic rename already made the new content live: whatever the + // in-memory `previous` snapshot claimed, disk has already changed. Never + // report `PolicyPersistenceFailed` here (it would falsely imply nothing + // happened): synchronously reobserve and publish the actual current disk + // state under this same lock (item 27) before returning, so a subsequent + // `GET` is never left showing a stale "previous policy still active" + // snapshot until the watcher or fallback poll happens to catch up. + let message = format!("{io_error:#}"); + audit::write_failed(actor.sid, actor.executable, &intent, &canonical_path, &message); + let reobservation = self.storage.observe(self.source, &self.configured_path); + let management = self.publish_if_changed(reobservation, "post-write verification failed"); + // Item 27: the shared `ErrorResponse.management` field is generic, so the + // snapshot this transaction just republished is attached directly rather + // than making the caller issue an immediate follow-up `GET` to learn what + // this request already observed. + return Err(error_response_with_management( + ErrorCode::PolicyActivationFailed, + format!("the policy was written but could not be activated: {message}"), + management, + )); + } + }; + + let old_id = observation.policy.as_ref().map(|policy| policy.metadata.id.to_string()); + let old_revision = observation.policy.as_ref().map(|policy| policy.metadata.revision); + + let new_snapshot = Arc::new(Snapshot { + state: PolicyManagementState::Active, + write_capability: observation.write_capability, + read_only_reason: observation.read_only_reason, + policy: Some(Arc::new(persisted.policy.clone())), + invalid_diagnostics: None, + store_token: Self::token_for(&previous, &persisted.fingerprint), + fingerprint: persisted.fingerprint, + canonical_path: canonical_path.clone(), + }); + *self.snapshot.write().expect("policy store snapshot lock poisoned") = new_snapshot; + + let old_id_display = old_id.as_deref().unwrap_or(""); + if request.conflict_handling == PolicyConflictHandling::ConfirmOverwrite { + audit::write_confirmed_overwrite( + actor.sid, + actor.executable, + &intent, + &canonical_path, + old_id_display, + old_revision, + &persisted.policy.metadata.id, + new_revision, + ); + } else { + audit::write_succeeded( + actor.sid, + actor.executable, + &intent, + &canonical_path, + old_id_display, + old_revision, + &persisted.policy.metadata.id, + new_revision, + ); + } + + Ok(ReplaceSuccess { + policy: persisted.policy, + validation, + management: self.management_snapshot(), + }) + } +} + +/// Determine the target revision for a replacement operation, validating the +/// operation's identity/state precondition against the current disk observation. +fn plan_revision( + operation: PolicyReplacementOperation, + current_state: PolicyManagementState, + current_policy: Option<&PolicyDocument>, + new_id: &str, +) -> Result { + match operation { + PolicyReplacementOperation::Update => { + let policy = current_policy.ok_or_else(|| "Update requires an Active configured policy".to_owned())?; + let current_id: &str = &policy.metadata.id; + if current_id != new_id { + return Err(format!( + "Update requires the same policy id ('{current_id}'); the draft specifies '{new_id}'" + )); + } + policy + .metadata + .revision + .checked_add(1) + .ok_or_else(|| "policy revision would overflow".to_owned()) + } + PolicyReplacementOperation::ReplaceIdentity => { + let policy = + current_policy.ok_or_else(|| "ReplaceIdentity requires an Active configured policy".to_owned())?; + let current_id: &str = &policy.metadata.id; + if current_id == new_id { + return Err(format!( + "ReplaceIdentity requires a different policy id than the active '{current_id}'" + )); + } + Ok(1) + } + PolicyReplacementOperation::Create => { + if current_state != PolicyManagementState::Missing { + return Err("Create requires no existing configured policy".to_owned()); + } + Ok(1) + } + PolicyReplacementOperation::Repair => { + if current_state != PolicyManagementState::Invalid { + return Err("Repair requires an Invalid configured policy".to_owned()); + } + Ok(1) + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + // Disambiguated from the local `windows` submodule (`policy_store::windows`), which + // `use super::*` also brings into scope. + use now_policy_api::{ + API_VERSION_STR, PolicyConflictHandling, PolicyReplacementOperation, PolicyReplacementRequestKind, + PolicyStoreToken, PolicyWriteCapability, + }; + + use super::*; + use crate::test_support::system_sid; + + /// In-memory [`PolicyStorage`] fake letting tests drive [`PolicyStore::replace`] and + /// [`PolicyStore::reload_from_disk`] deterministically, without requiring the real + /// SYSTEM/Administrators-only ACL enforcement that `WindowsPolicyStorage` depends on. + /// + /// Tracks three independent "generation" counters standing in for the real + /// [`windows::DiskFingerprint`]'s identity components: `target_generation` (the policy + /// file object itself), `parent_generation` (its hosting directory), and + /// `acl_generation` (its security state). Each is bumped only by an operation that + /// should plausibly rotate the opaque store token, letting tests exercise every + /// `DiskFingerprint` rotation/stability rule without a real filesystem. + pub(crate) struct FakePolicyStorage { + write_capability: std::sync::Mutex, + read_only_reason: std::sync::Mutex>, + disk: std::sync::Mutex>>, + /// When set, the *next* (and every subsequent, until a successful write clears + /// it) observation of an existing target reports it as insecure (item 26): + /// forces `ReadOnly`/`UnsafePath` regardless of the directory's own + /// `write_capability`, simulating a file whose own ACL is untrustworthy even + /// though its hosting directory is fine (so Repair must still be blocked). + target_insecure: std::sync::Mutex, + fail_next_write: std::sync::Mutex>, + /// Same shape as `fail_next_write`, but simulates a failure discovered only + /// *after* the atomic rename already made the new content live (item 27): the + /// fake's `write` still applies the write to `disk` before returning this error, + /// so tests can observe that the store re-observes and publishes that already-changed + /// reality rather than assuming the previous snapshot is still current. + fail_next_write_post_publication: std::sync::Mutex>, + race_next_write: std::sync::Mutex>>, + target_generation: std::sync::Mutex, + parent_generation: std::sync::Mutex, + acl_generation: std::sync::Mutex, + } + + impl FakePolicyStorage { + pub(crate) fn writable() -> Self { + Self::with_capability(PolicyWriteCapability::Writable, None) + } + + fn read_only(reason: PolicyReadOnlyReason) -> Self { + Self::with_capability(PolicyWriteCapability::ReadOnly, Some(reason)) + } + + fn with_capability( + write_capability: PolicyWriteCapability, + read_only_reason: Option, + ) -> Self { + Self { + write_capability: std::sync::Mutex::new(write_capability), + read_only_reason: std::sync::Mutex::new(read_only_reason), + disk: std::sync::Mutex::new(None), + target_insecure: std::sync::Mutex::new(false), + fail_next_write: std::sync::Mutex::new(None), + fail_next_write_post_publication: std::sync::Mutex::new(None), + race_next_write: std::sync::Mutex::new(None), + target_generation: std::sync::Mutex::new(0), + parent_generation: std::sync::Mutex::new(0), + acl_generation: std::sync::Mutex::new(0), + } + } + + /// Set the on-disk content, bumping `target_generation`: every write (through the + /// store or, as here, simulating an out-of-band external edit) is a new file + /// object, even when it happens to write byte-identical content. + fn set_disk(&self, content: Option>) { + *self.disk.lock().expect("disk lock poisoned") = content; + *self.target_generation.lock().expect("target generation lock poisoned") += 1; + } + + fn seed(&self, policy: &PolicyDocument) { + let bytes = serde_json::to_vec(policy).expect("test policy serializes"); + self.set_disk(Some(bytes)); + } + + fn seed_invalid(&self, bytes: impl Into>) { + self.set_disk(Some(bytes.into())); + } + + fn fail_next_write(&self, message: &str) { + *self.fail_next_write.lock().expect("fail lock poisoned") = Some(message.to_owned()); + } + + /// Simulate a write failure discovered only after the atomic rename already + /// published the new content (item 27): `PolicyStore::replace` must classify + /// this as `PolicyActivationFailed`, not `PolicyPersistenceFailed`, and + /// synchronously publish the now-actually-active content rather than leaving the + /// previous snapshot published. + fn fail_next_write_after_publish(&self, message: &str) { + *self + .fail_next_write_post_publication + .lock() + .expect("post-publication fail lock poisoned") = Some(message.to_owned()); + } + + /// Simulate the parent directory itself being deleted and recreated (even with + /// byte-identical file content underneath), which must still rotate the token. + fn replace_parent(&self) { + *self.parent_generation.lock().expect("parent generation lock poisoned") += 1; + } + + /// Simulate the policy file's owner/DACL changing with no content change, which + /// must still rotate the token. + fn change_acl(&self) { + *self.acl_generation.lock().expect("acl generation lock poisoned") += 1; + } + + /// Simulate a directory-level capability change discovered on the *next* + /// observation (item 20): e.g. an operator loosens or tightens a custom + /// directory's ACL, or the filesystem capability changes, after the store + /// started. + fn set_capability( + &self, + write_capability: PolicyWriteCapability, + read_only_reason: Option, + ) { + *self.write_capability.lock().expect("capability lock poisoned") = write_capability; + *self.read_only_reason.lock().expect("read-only reason lock poisoned") = read_only_reason; + } + + /// Simulate the existing target file itself becoming untrustworthy (its own ACL + /// failing storage security validation) even though the hosting directory's own + /// capability is unaffected (item 26): distinguishes an insecure/unreadable + /// target (Repair blocked) from a merely malformed-but-securely-stored one + /// (Repair still allowed). + fn mark_target_insecure(&self) { + *self.target_insecure.lock().expect("insecure flag lock poisoned") = true; + *self.acl_generation.lock().expect("acl generation lock poisoned") += 1; + } + + /// Simulate an external actor writing directly to disk in the narrow window + /// between this store's own re-observation (already completed) and its next + /// `atomic_create`/`atomic_replace` call: the *next* write on this storage first + /// "discovers" `policy` already present, before applying its own must-not-replace + /// or replace semantics. + fn race_in_content_before_next_write(&self, policy: &PolicyDocument) { + let bytes = serde_json::to_vec(policy).expect("test policy serializes"); + *self.race_next_write.lock().expect("race lock poisoned") = Some(bytes); + } + + fn write( + &self, + bytes: &[u8], + must_not_replace_existing: bool, + ) -> Result { + if let Some(message) = self.fail_next_write.lock().expect("fail lock poisoned").take() { + return Err(windows::WriteFailure::PrePublication(anyhow::anyhow!("{message}"))); + } + + if let Some(raced_content) = self.race_next_write.lock().expect("race lock poisoned").take() { + self.set_disk(Some(raced_content)); + } + + { + let mut disk = self.disk.lock().expect("disk lock poisoned"); + if must_not_replace_existing && disk.is_some() { + return Err(windows::WriteFailure::PrePublication(anyhow::anyhow!( + "simulated ERROR_ALREADY_EXISTS: destination already exists" + ))); + } + *disk = Some(bytes.to_vec()); + } + // A (simulated) successful rename always publishes a fresh, trusted target: + // clear any previously simulated insecurity. + *self.target_insecure.lock().expect("insecure flag lock poisoned") = false; + + *self.target_generation.lock().expect("target generation lock poisoned") += 1; + let target_generation = *self.target_generation.lock().expect("target generation lock poisoned"); + let parent_generation = *self.parent_generation.lock().expect("parent generation lock poisoned"); + let acl_generation = *self.acl_generation.lock().expect("acl generation lock poisoned"); + + // The rename above is the publication boundary (item 27): any failure from + // here on is post-publication, even though this fake has no real separate + // "reopen" step to fail independently of the rename itself. + if let Some(message) = self + .fail_next_write_post_publication + .lock() + .expect("post-publication fail lock poisoned") + .take() + { + return Err(windows::WriteFailure::PostPublication(anyhow::anyhow!("{message}"))); + } + + let policy = serde_json::from_slice::(bytes) + .expect("fake atomic write always receives a canonical, parseable policy"); + + Ok(windows::PersistedPolicy { + policy, + fingerprint: windows::DiskFingerprint::test_active( + bytes, + target_generation, + parent_generation, + acl_generation, + ), + }) + } + } + + impl PolicyStorage for FakePolicyStorage { + fn observe(&self, _source: PolicyConfigurationSource, path: &Path) -> windows::DiskObservation { + let parent_generation = *self.parent_generation.lock().expect("parent generation lock poisoned"); + let write_capability = *self.write_capability.lock().expect("capability lock poisoned"); + let read_only_reason = *self.read_only_reason.lock().expect("read-only reason lock poisoned"); + let target_insecure = *self.target_insecure.lock().expect("insecure flag lock poisoned"); + + match &*self.disk.lock().expect("disk lock poisoned") { + None => windows::DiskObservation { + state: PolicyManagementState::Missing, + policy: None, + invalid_diagnostics: None, + fingerprint: windows::DiskFingerprint::test_missing(parent_generation), + write_capability, + read_only_reason, + canonical_path: path.to_owned(), + }, + Some(bytes) => { + let target_generation = *self.target_generation.lock().expect("target generation lock poisoned"); + let acl_generation = *self.acl_generation.lock().expect("acl generation lock poisoned"); + + // Item 26: an insecure target always forces ReadOnly/UnsafePath, + // regardless of the directory's own (otherwise possibly Writable) + // capability -- Repair must never be attempted against it. + let (effective_write_capability, effective_read_only_reason) = if target_insecure { + (PolicyWriteCapability::ReadOnly, Some(PolicyReadOnlyReason::UnsafePath)) + } else { + (write_capability, read_only_reason) + }; + + if target_insecure { + return windows::DiskObservation { + state: PolicyManagementState::Invalid, + policy: None, + invalid_diagnostics: Some(InvalidPolicyDiagnostics { + diagnostics_version: API_VERSION_STR.into(), + findings: vec![validation::disk_failure_finding( + validation::DiskFailureReason::InsecureStorage, + )], + }), + fingerprint: windows::DiskFingerprint::test_invalid(bytes, target_generation), + write_capability: effective_write_capability, + read_only_reason: effective_read_only_reason, + canonical_path: path.to_owned(), + }; + } + + match serde_json::from_slice::(bytes) { + Ok(policy) => { + // Item 30: the fake also runs committed documents through the + // same authoritative semantic validator a submitted draft + // would go through, not just structural parseability. + let committed_validation = validation::validate_committed_policy(&policy); + if !committed_validation.is_valid { + return windows::DiskObservation { + state: PolicyManagementState::Invalid, + policy: None, + invalid_diagnostics: Some(InvalidPolicyDiagnostics { + diagnostics_version: API_VERSION_STR.into(), + findings: vec![validation::disk_failure_finding( + validation::DiskFailureReason::FailedSemanticValidation, + )], + }), + fingerprint: windows::DiskFingerprint::test_invalid(bytes, target_generation), + write_capability: effective_write_capability, + read_only_reason: effective_read_only_reason, + canonical_path: path.to_owned(), + }; + } + + windows::DiskObservation { + state: PolicyManagementState::Active, + policy: Some(policy), + invalid_diagnostics: None, + fingerprint: windows::DiskFingerprint::test_active( + bytes, + target_generation, + parent_generation, + acl_generation, + ), + write_capability: effective_write_capability, + read_only_reason: effective_read_only_reason, + canonical_path: path.to_owned(), + } + } + Err(_) => windows::DiskObservation { + state: PolicyManagementState::Invalid, + policy: None, + invalid_diagnostics: Some(InvalidPolicyDiagnostics { + diagnostics_version: API_VERSION_STR.into(), + findings: vec![validation::disk_failure_finding( + validation::DiskFailureReason::MalformedContent, + )], + }), + fingerprint: windows::DiskFingerprint::test_invalid(bytes, target_generation), + write_capability: effective_write_capability, + read_only_reason: effective_read_only_reason, + canonical_path: path.to_owned(), + }, + } + } + } + } + + fn atomic_replace( + &self, + _dir: &Path, + _final_path: &Path, + bytes: &[u8], + ) -> Result { + self.write(bytes, false) + } + + fn atomic_create( + &self, + _dir: &Path, + _final_path: &Path, + bytes: &[u8], + ) -> Result { + self.write(bytes, true) + } + } + + fn actor(sid: &Sid) -> PolicyWriteActor<'_> { + PolicyWriteActor { + sid, + executable: Path::new(r"C:\Program Files\Devolutions\Agent\DevolutionsAgent.exe"), + } + } + + fn draft_json(id: &str) -> serde_json::Value { + serde_json::json!({ + "$schema": now_policy::POLICY_SCHEMA_URI, + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": id, "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [], + }) + } + + /// Build a well-formed [`PolicyReplacementRequest`], computing its validation receipt + /// the same way a real client would: by first calling `store.validate_draft` (so the + /// receipt is bound to that exact store's own process-random key). + fn replacement_request( + store: &PolicyStore, + expected_store_token: &PolicyStoreToken, + operation: PolicyReplacementOperation, + conflict_handling: PolicyConflictHandling, + draft: serde_json::Value, + ) -> PolicyReplacementRequest { + let result = store.validate_draft(&draft); + assert!(result.is_valid, "test draft must validate: {:?}", result.findings); + PolicyReplacementRequest { + request_kind: PolicyReplacementRequestKind, + request_version: API_VERSION_STR.into(), + expected_store_token: expected_store_token.clone(), + operation, + conflict_handling, + warnings_acknowledged: true, + draft, + validation_receipt: result + .validation_receipt + .expect("a valid draft always carries a receipt"), + } + } + + fn current_token(store: &PolicyStore) -> PolicyStoreToken { + store.management_snapshot().store_token + } + + // ─── plan_revision ─────────────────────────────────────────────────────── + + fn policy(id: &str, revision: u32) -> PolicyDocument { + serde_json::from_value(serde_json::json!({ + "$schema": now_policy::POLICY_SCHEMA_URI, + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": id, "Publisher": "Test", "Revision": revision, "PublishedAt": Utc::now() }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [], + })) + .expect("test policy is well-formed") + } + + #[test] + fn update_requires_same_id_and_increments_revision() { + let active = policy("policy-a", 5); + let revision = plan_revision( + PolicyReplacementOperation::Update, + PolicyManagementState::Active, + Some(&active), + "policy-a", + ) + .expect("same-id update is allowed"); + assert_eq!(revision, 6); + } + + #[test] + fn update_rejects_different_id() { + let active = policy("policy-a", 5); + assert!( + plan_revision( + PolicyReplacementOperation::Update, + PolicyManagementState::Active, + Some(&active), + "policy-b", + ) + .is_err() + ); + } + + #[test] + fn update_rejects_missing_state() { + assert!( + plan_revision( + PolicyReplacementOperation::Update, + PolicyManagementState::Missing, + None, + "policy-a" + ) + .is_err() + ); + } + + #[test] + fn replace_identity_requires_different_id_at_revision_one() { + let active = policy("policy-a", 5); + let revision = plan_revision( + PolicyReplacementOperation::ReplaceIdentity, + PolicyManagementState::Active, + Some(&active), + "policy-b", + ) + .expect("different-id replace is allowed"); + assert_eq!(revision, 1); + } + + #[test] + fn replace_identity_rejects_same_id() { + let active = policy("policy-a", 5); + assert!( + plan_revision( + PolicyReplacementOperation::ReplaceIdentity, + PolicyManagementState::Active, + Some(&active), + "policy-a", + ) + .is_err() + ); + } + + #[test] + fn create_requires_missing_state_at_revision_one() { + let revision = plan_revision( + PolicyReplacementOperation::Create, + PolicyManagementState::Missing, + None, + "policy-a", + ) + .expect("create while missing is allowed"); + assert_eq!(revision, 1); + } + + #[test] + fn create_rejects_active_state() { + let active = policy("policy-a", 5); + assert!( + plan_revision( + PolicyReplacementOperation::Create, + PolicyManagementState::Active, + Some(&active), + "policy-a", + ) + .is_err() + ); + } + + #[test] + fn repair_requires_invalid_state_at_revision_one() { + let revision = plan_revision( + PolicyReplacementOperation::Repair, + PolicyManagementState::Invalid, + None, + "policy-a", + ) + .expect("repair while invalid is allowed"); + assert_eq!(revision, 1); + } + + #[test] + fn repair_rejects_active_state() { + let active = policy("policy-a", 5); + assert!( + plan_revision( + PolicyReplacementOperation::Repair, + PolicyManagementState::Active, + Some(&active), + "policy-a", + ) + .is_err() + ); + } + + // ─── PolicyStore::replace ──────────────────────────────────────────────── + + #[tokio::test] + async fn create_on_missing_activates_the_policy() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::writable())); + assert!(store.active_policy().is_none()); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let success = store.replace(request, actor(&sid)).await.expect("create succeeds"); + + assert_eq!(success.policy.metadata.id.to_string(), "policy-a"); + assert_eq!(success.policy.metadata.revision, 1); + assert_eq!(success.management.state, PolicyManagementState::Active); + let active = store.active_policy().expect("policy now active"); + assert_eq!(active.metadata.id.to_string(), "policy-a"); + } + + #[tokio::test] + async fn create_on_active_is_rejected() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(storage); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-b"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("create must fail when active"); + assert_eq!(error.code, ErrorCode::Conflict); + } + + #[tokio::test] + async fn update_increments_revision_and_preserves_id() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 3)); + let store = PolicyStore::for_tests_with_storage(storage); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let success = store.replace(request, actor(&sid)).await.expect("update succeeds"); + assert_eq!(success.policy.metadata.revision, 4); + } + + #[tokio::test] + async fn stale_token_conflict_carries_fresh_management_snapshot() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(storage); + let sid = system_sid(); + + let stale_token = PolicyStoreToken::from("sha256:not-the-real-token"); + let request = replacement_request( + &store, + &stale_token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("stale token must be rejected"); + assert_eq!(error.code, ErrorCode::StalePolicyStoreToken); + let management = error + .management + .expect("stale-token error carries the current management snapshot"); + assert_eq!(management.store_token, current_token(&store)); + // The store's own active policy is untouched by a rejected conflicting write. + assert_eq!(store.active_policy().expect("still active").metadata.revision, 1); + } + + #[tokio::test] + async fn confirm_overwrite_succeeds_against_the_exact_current_token() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(storage); + let sid = system_sid(); + let current = current_token(&store); + + let request = replacement_request( + &store, + ¤t, + PolicyReplacementOperation::Update, + PolicyConflictHandling::ConfirmOverwrite, + draft_json("policy-a"), + ); + let success = store + .replace(request, actor(&sid)) + .await + .expect("confirmed overwrite succeeds"); + assert_eq!(success.policy.metadata.revision, 2); + } + + #[tokio::test] + async fn confirm_overwrite_still_conflicts_after_another_change() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(storage); + let sid = system_sid(); + let stale = current_token(&store); + + // Someone else updates the policy first. + let first = replacement_request( + &store, + &stale, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + store.replace(first, actor(&sid)).await.expect("first update succeeds"); + + // A ConfirmOverwrite bound to the now-stale token must still conflict: it is not + // an unconditional force mode. + let second = replacement_request( + &store, + &stale, + PolicyReplacementOperation::Update, + PolicyConflictHandling::ConfirmOverwrite, + draft_json("policy-a"), + ); + let error = store + .replace(second, actor(&sid)) + .await + .expect_err("confirm overwrite bound to a stale token must still conflict"); + assert_eq!(error.code, ErrorCode::StalePolicyStoreToken); + } + + #[tokio::test] + async fn warnings_require_explicit_acknowledgement() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::writable())); + let token = current_token(&store); + let sid = system_sid(); + + let mut draft = draft_json("policy-a"); + draft["Enforcement"]["AuditMode"] = serde_json::json!(true); + let mut request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft, + ); + request.warnings_acknowledged = false; + + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("unacknowledged warnings must be rejected"); + assert_eq!(error.code, ErrorCode::WarningConfirmationRequired); + let validation = error.validation.expect("carries the validation result"); + assert!(validation.is_valid); + assert!(!validation.findings.is_empty()); + } + + #[tokio::test] + async fn tampered_validation_receipt_is_rejected() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::writable())); + let token = current_token(&store); + let sid = system_sid(); + + let mut request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + request.validation_receipt = PolicyStoreToken::from("sha256:forged").to_string().into(); + + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a receipt for a different draft must be rejected"); + assert_eq!(error.code, ErrorCode::ValidationFailed); + } + + #[tokio::test] + async fn invalid_draft_is_rejected_with_findings() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::writable())); + let token = current_token(&store); + let sid = system_sid(); + + // Bypass `replacement_request`'s validity assertion: build the invalid request by hand. + let mut draft = draft_json("policy-a"); + draft["PolicyType"] = serde_json::json!("NotAPackageBrokerPolicy"); + let request = PolicyReplacementRequest { + request_kind: PolicyReplacementRequestKind, + request_version: API_VERSION_STR.into(), + expected_store_token: token, + operation: PolicyReplacementOperation::Create, + conflict_handling: PolicyConflictHandling::Reject, + warnings_acknowledged: true, + draft, + validation_receipt: PolicyStoreToken::from("sha256:irrelevant").to_string().into(), + }; + + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("an invalid draft must be rejected"); + assert_eq!(error.code, ErrorCode::InvalidPolicy); + assert!( + error + .validation + .expect("carries findings") + .findings + .iter() + .any(|finding| finding.code == now_policy_api::PolicyFindingCode::UnsupportedPolicyType) + ); + } + + #[tokio::test] + async fn unwritable_directory_is_reported_as_unsafe_path() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::read_only( + PolicyReadOnlyReason::UnsafePath, + ))); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a read-only directory must reject writes"); + assert_eq!(error.code, ErrorCode::UnsafePolicyPath); + } + + // ─── Storage error semantics preserved end-to-end (item 31) ──────────────── + + #[tokio::test] + async fn unsupported_filesystem_maps_to_unsupported_policy_filesystem() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::read_only( + PolicyReadOnlyReason::UnsupportedFileSystem, + ))); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("an unsupported filesystem must reject writes"); + assert_eq!(error.code, ErrorCode::UnsupportedPolicyFilesystem); + } + + #[tokio::test] + async fn unsupported_format_maps_to_unsupported_policy_format() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::read_only( + PolicyReadOnlyReason::UnsupportedFormat, + ))); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a configured path with an unsupported format must reject writes"); + assert_eq!(error.code, ErrorCode::UnsupportedPolicyFormat); + } + + #[tokio::test] + async fn insufficient_permissions_maps_to_unsafe_policy_path_never_an_auth_code() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::read_only( + PolicyReadOnlyReason::InsufficientPermissions, + ))); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("insufficient (server-side) permissions must reject writes"); + // Never an authentication/authorization code: this describes a server-side + // environment condition, not the caller's own identity or permissions. + assert_eq!(error.code, ErrorCode::UnsafePolicyPath); + assert_ne!(error.code, ErrorCode::Forbidden); + assert_ne!(error.code, ErrorCode::Unauthorized); + assert_ne!(error.code, ErrorCode::AdministratorRequired); + } + + #[tokio::test] + async fn management_disabled_maps_to_unsafe_policy_path_never_an_auth_code() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::read_only( + PolicyReadOnlyReason::ManagementDisabled, + ))); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a disabled management path must reject writes"); + assert_eq!(error.code, ErrorCode::UnsafePolicyPath); + assert_ne!(error.code, ErrorCode::Forbidden); + assert_ne!(error.code, ErrorCode::Unauthorized); + assert_ne!(error.code, ErrorCode::AdministratorRequired); + } + + #[tokio::test] + async fn path_not_configured_maps_to_unsafe_policy_path_never_an_auth_code() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::read_only( + PolicyReadOnlyReason::PathNotConfigured, + ))); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("an unconfigured path must reject writes"); + assert_eq!(error.code, ErrorCode::UnsafePolicyPath); + assert_ne!(error.code, ErrorCode::Forbidden); + assert_ne!(error.code, ErrorCode::Unauthorized); + assert_ne!(error.code, ErrorCode::AdministratorRequired); + } + + #[tokio::test] + async fn persistence_failure_leaves_the_previous_policy_active() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + storage.fail_next_write("simulated disk failure"); + let store = PolicyStore::for_tests_with_storage(storage); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a persistence failure must be reported"); + assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); + + // The broker never pauses during a failed self-replacement: the old policy stays active. + let active = store.active_policy().expect("previous policy remains active"); + assert_eq!(active.metadata.revision, 1); + } + + #[tokio::test] + async fn concurrent_replace_calls_are_serialized_and_only_one_wins() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = Arc::new(PolicyStore::for_tests_with_storage(storage)); + let token = current_token(&store); + let sid = system_sid(); + + let store_a = Arc::clone(&store); + let sid_a = sid.clone(); + let token_a = token.clone(); + let task_a = tokio::spawn(async move { + let request = replacement_request( + &store_a, + &token_a, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + store_a.replace(request, actor(&sid_a)).await + }); + + let store_b = Arc::clone(&store); + let sid_b = sid.clone(); + let task_b = tokio::spawn(async move { + let request = replacement_request( + &store_b, + &token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + store_b.replace(request, actor(&sid_b)).await + }); + + let (result_a, result_b) = tokio::join!(task_a, task_b); + let outcomes = [result_a.unwrap(), result_b.unwrap()]; + let successes = outcomes.iter().filter(|outcome| outcome.is_ok()).count(); + let conflicts = outcomes + .iter() + .filter(|outcome| { + outcome + .as_ref() + .is_err_and(|error| error.code == ErrorCode::StalePolicyStoreToken) + }) + .count(); + + // Both requests read the same pre-write token; the write lock serializes them, so + // exactly one observes it as still current and the other is a stale-token conflict. + assert_eq!(successes, 1, "exactly one concurrent replace should succeed"); + assert_eq!(conflicts, 1, "the other concurrent replace should observe a conflict"); + assert_eq!(store.active_policy().expect("active").metadata.revision, 2); + } + + // ─── PolicyStore::reload_from_disk ─────────────────────────────────────── + + #[tokio::test] + async fn external_change_is_applied() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + assert_eq!(store.active_policy().unwrap().metadata.revision, 1); + + // Simulate an administrator directly editing the file outside the API. + storage.seed(&policy("policy-a", 2)); + + store.reload_from_disk("test").await; + assert_eq!(store.active_policy().unwrap().metadata.revision, 2); + } + + #[tokio::test] + async fn bad_external_change_transitions_to_invalid() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + + storage.seed_invalid(b"{ not json".to_vec()); + + store.reload_from_disk("test").await; + assert!( + store.active_policy().is_none(), + "a broken external edit must pause the broker" + ); + assert_eq!(store.management_snapshot().state, PolicyManagementState::Invalid); + } + + #[tokio::test] + async fn reload_is_a_noop_when_disk_is_unchanged() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(storage); + let token_before = current_token(&store); + + store.reload_from_disk("test").await; + + assert_eq!(current_token(&store), token_before); + assert_eq!(store.active_policy().unwrap().metadata.revision, 1); + } + + // ─── Opaque store token: fingerprint-driven rotation/stability (item 1) ─── + + #[tokio::test] + async fn token_rotates_on_same_byte_external_replacement() { + // An external actor rewrites the exact same bytes: the underlying file object + // still changed (a new `target_generation`), so the token must still rotate. + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let before = current_token(&store); + + storage.seed(&policy("policy-a", 1)); + store.reload_from_disk("test").await; + + assert_ne!(current_token(&store), before); + } + + #[tokio::test] + async fn token_rotates_on_acl_change_alone() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let before = current_token(&store); + + storage.change_acl(); + store.reload_from_disk("test").await; + + assert_ne!(current_token(&store), before); + } + + #[tokio::test] + async fn token_rotates_on_parent_directory_replacement() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let before = current_token(&store); + + storage.replace_parent(); + store.reload_from_disk("test").await; + + assert_ne!(current_token(&store), before); + } + + #[tokio::test] + async fn token_is_stable_when_truly_unchanged() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(storage); + let before = current_token(&store); + + // Repeated observation with no intervening change whatsoever. + store.reload_from_disk("test").await; + store.reload_from_disk("test").await; + + assert_eq!(current_token(&store), before); + } + + #[tokio::test] + async fn different_missing_custom_paths_yield_different_tokens() { + // Two independently configured (here: independently faked) custom paths that are + // both currently Missing must not be mistaken for each other: their tokens must + // differ, since nothing about "Missing" alone should let a client's stale idea of + // one store's token be accidentally accepted as current for a different one. + let store_a = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::writable())); + + let storage_b = Arc::new(FakePolicyStorage::writable()); + storage_b.replace_parent(); // Give the second store a distinct parent identity. + let store_b = PolicyStore::for_tests_with_storage(storage_b); + + assert_eq!(store_a.management_snapshot().state, PolicyManagementState::Missing); + assert_eq!(store_b.management_snapshot().state, PolicyManagementState::Missing); + assert_ne!(current_token(&store_a), current_token(&store_b)); + } + + // ─── Stale publication: replace reobserves and publishes before erroring (item 5) ─ + + #[tokio::test] + async fn stale_replace_publishes_the_external_change_before_returning() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let stale_token = current_token(&store); + let sid = system_sid(); + + // An external edit lands after the client observed `stale_token`, but before its + // replacement request reaches the store. + storage.seed(&policy("policy-a", 2)); + + let request = replacement_request( + &store, + &stale_token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a stale token must be rejected"); + assert_eq!(error.code, ErrorCode::StalePolicyStoreToken); + + // The store's own state was published to the new reality *before* returning, not + // left to be picked up later by the file watcher. + assert_eq!(store.active_policy().expect("still active").metadata.revision, 2); + let management = error + .management + .expect("stale-token error carries the current management snapshot"); + assert_eq!(management.state, PolicyManagementState::Active); + assert_eq!(management.store_token, current_token(&store)); + assert_eq!( + management + .policy + .expect("published snapshot carries the policy") + .metadata + .revision, + 2 + ); + } + + #[tokio::test] + async fn stale_replace_publishes_missing_when_external_edit_removed_the_policy() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let stale_token = current_token(&store); + let sid = system_sid(); + + storage.set_disk(None); + + let request = replacement_request( + &store, + &stale_token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a stale token must be rejected"); + assert_eq!(error.code, ErrorCode::StalePolicyStoreToken); + + assert!(store.active_policy().is_none(), "broker paused: policy is now Missing"); + let management = error.management.expect("carries the current management snapshot"); + assert_eq!(management.state, PolicyManagementState::Missing); + assert_eq!(management.store_token, current_token(&store)); + } + + #[tokio::test] + async fn repeated_conflicting_replace_attempts_observe_the_same_published_snapshot() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let stale_token = current_token(&store); + let sid = system_sid(); + + storage.seed(&policy("policy-a", 2)); + + let first = replacement_request( + &store, + &stale_token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let first_error = store.replace(first, actor(&sid)).await.expect_err("stale"); + let first_management = first_error.management.expect("carries a snapshot"); + + // A second attempt bound to the very same now-stale token must observe the exact + // same already-published reality, not detect yet another "change". + let second = replacement_request( + &store, + &stale_token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let second_error = store.replace(second, actor(&sid)).await.expect_err("still stale"); + let second_management = second_error.management.expect("carries a snapshot"); + + assert_eq!(first_management.store_token, second_management.store_token); + assert_eq!( + first_management.policy.map(|p| p.metadata.revision), + second_management.policy.map(|p| p.metadata.revision) + ); + } + + // ─── Missing `Create` race: never overwrite, publish fresh snapshot (item 12) ──── + + #[tokio::test] + async fn create_race_never_overwrites_and_reports_a_fresh_snapshot() { + let storage = Arc::new(FakePolicyStorage::writable()); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let token = current_token(&store); + let sid = system_sid(); + + // Simulate a different actor creating the policy in the exact window between this + // transaction's own re-observation (Missing, above) and its `atomic_create` call. + storage.race_in_content_before_next_write(&policy("policy-raced-in", 1)); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a raced-in leaf must never be silently overwritten"); + assert_eq!(error.code, ErrorCode::StalePolicyStoreToken); + + // The raced-in policy -- never ours -- is the one now active. + let active = store.active_policy().expect("raced-in policy is now active"); + assert_eq!(active.metadata.id.to_string(), "policy-raced-in"); + + let management = error.management.expect("carries the freshly published snapshot"); + assert_eq!(management.store_token, current_token(&store)); + assert_eq!( + management.policy.expect("policy").metadata.id.to_string(), + "policy-raced-in" + ); + } + + // ─── Invalid disk diagnostics redaction (item 6) ────────────────────────── + + #[tokio::test] + async fn malformed_external_content_never_leaks_into_diagnostics() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + + let secret_marker = "sso1kkD0-attacker-controlled-marker"; + storage.seed_invalid(format!(r#"{{"unterminated": "{secret_marker}"#).into_bytes()); + + store.reload_from_disk("test").await; + + let management = store.management_snapshot(); + assert_eq!(management.state, PolicyManagementState::Invalid); + let diagnostics = management + .invalid_diagnostics + .expect("Invalid state carries diagnostics"); + for finding in &diagnostics.findings { + assert!( + !finding.message.contains(secret_marker), + "diagnostics leaked malformed on-disk content: {}", + finding.message + ); + } + // The message is one of the fixed, generic strings `disk_failure_finding` returns + // for every draft with this shape of failure, never anything computed from this + // specific draft's bytes. + assert!(diagnostics.findings.iter().all(|finding| finding.message + == "the configured policy file does not contain valid JSON matching the expected policy schema")); + } + + // ─── Capability refreshed on every re-observation/publication (item 20) ── + + #[tokio::test] + async fn writable_directory_becoming_unwritable_is_reflected_on_next_observation() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + assert_eq!( + store.management_snapshot().write_capability, + PolicyWriteCapability::Writable + ); + + storage.set_capability(PolicyWriteCapability::ReadOnly, Some(PolicyReadOnlyReason::UnsafePath)); + store.reload_from_disk("directory ACL tightened externally").await; + + let management = store.management_snapshot(); + assert_eq!(management.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(management.read_only_reason, Some(PolicyReadOnlyReason::UnsafePath)); + } + + #[tokio::test] + async fn unwritable_directory_becoming_writable_is_reflected_on_next_observation() { + let storage = Arc::new(FakePolicyStorage::read_only( + PolicyReadOnlyReason::InsufficientPermissions, + )); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + assert_eq!( + store.management_snapshot().write_capability, + PolicyWriteCapability::ReadOnly + ); + + storage.set_capability(PolicyWriteCapability::Writable, None); + store.reload_from_disk("directory ACL loosened externally").await; + + let management = store.management_snapshot(); + assert_eq!(management.write_capability, PolicyWriteCapability::Writable); + assert_eq!(management.read_only_reason, None); + } + + #[tokio::test] + async fn acl_only_capability_change_is_reflected_with_no_content_change() { + let storage = Arc::new(FakePolicyStorage::read_only(PolicyReadOnlyReason::UnsafePath)); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + + // Only the *reason* changes (e.g. the volume itself was remounted on a + // filesystem that no longer proves atomic-replace capability), not the file + // content at all. + storage.set_capability( + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsupportedFileSystem), + ); + store.reload_from_disk("filesystem capability changed").await; + + let management = store.management_snapshot(); + assert_eq!(management.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!( + management.read_only_reason, + Some(PolicyReadOnlyReason::UnsupportedFileSystem) + ); + } + + #[tokio::test] + async fn replace_authorizes_against_freshly_resolved_capability_not_a_cached_one() { + // The store starts out writable (Missing state), but the directory's capability + // changes *before* the write attempt is ever made -- proving `replace` never + // trusts a capability resolved at construction time or from a previous + // transaction (item 20). + let storage = Arc::new(FakePolicyStorage::writable()); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let token = current_token(&store); + let sid = system_sid(); + + storage.set_capability(PolicyWriteCapability::ReadOnly, Some(PolicyReadOnlyReason::UnsafePath)); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a directory that became unwritable just before the write must reject it"); + assert_eq!(error.code, ErrorCode::UnsafePolicyPath); + } + + // ─── ConfirmOverwrite cannot bypass the operation invariant (item 25) ───── + + #[tokio::test] + async fn confirm_overwrite_does_not_bypass_update_identity_invariant() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(storage); + let token = current_token(&store); + let sid = system_sid(); + + // `Update` with a different id must fail (it requires `ReplaceIdentity` + // instead) even under `ConfirmOverwrite` and even with the exact current token. + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::ConfirmOverwrite, + draft_json("policy-b"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("Update with a changed identity must still require ReplaceIdentity"); + assert_eq!(error.code, ErrorCode::Conflict); + + // The current snapshot is exactly what it was: unaffected by the rejected request. + let management = store.management_snapshot(); + assert_eq!(management.state, PolicyManagementState::Active); + assert_eq!( + management.policy.expect("still active").metadata.id.to_string(), + "policy-a" + ); + } + + #[tokio::test] + async fn confirm_overwrite_does_not_bypass_replace_identity_same_id_invariant() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(storage); + let token = current_token(&store); + let sid = system_sid(); + + // `ReplaceIdentity` with the *same* id must fail (it requires `Update` instead). + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::ReplaceIdentity, + PolicyConflictHandling::ConfirmOverwrite, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("ReplaceIdentity with an unchanged identity must still require Update"); + assert_eq!(error.code, ErrorCode::Conflict); + } + + #[tokio::test] + async fn confirm_overwrite_on_missing_state_still_requires_create() { + let store = PolicyStore::for_tests_with_storage(Arc::new(FakePolicyStorage::writable())); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::ConfirmOverwrite, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("Missing state must still require Create, not Update, even under ConfirmOverwrite"); + assert_eq!(error.code, ErrorCode::Conflict); + assert_eq!(store.management_snapshot().state, PolicyManagementState::Missing); + } + + #[tokio::test] + async fn confirm_overwrite_on_invalid_state_still_requires_repair() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed_invalid(b"{not valid json".to_vec()); + let store = PolicyStore::for_tests_with_storage(storage); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::ConfirmOverwrite, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("Invalid state must still require Repair, not Create, even under ConfirmOverwrite"); + assert_eq!(error.code, ErrorCode::Conflict); + assert_eq!(store.management_snapshot().state, PolicyManagementState::Invalid); + } + + // ─── Malformed-but-secure vs insecure/unreadable target (item 26) ───────── + + #[tokio::test] + async fn malformed_but_secure_target_stays_writable_and_repair_succeeds() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed_invalid(b"{not valid json".to_vec()); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + + let management = store.management_snapshot(); + assert_eq!(management.state, PolicyManagementState::Invalid); + assert_eq!(management.write_capability, PolicyWriteCapability::Writable); + + let token = management.store_token; + let sid = system_sid(); + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Repair, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + store + .replace(request, actor(&sid)) + .await + .expect("repairing a malformed-but-securely-stored file must succeed"); + } + + #[tokio::test] + async fn insecure_target_forces_read_only_even_with_valid_content() { + // The content is perfectly well-formed, but the target's own security is + // untrustworthy: this must still be Invalid + ReadOnly, never Active, and + // Repair must be blocked. Security failure is checked -- and fails closed -- + // before content is ever trusted, matching the real Windows backend. + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + storage.mark_target_insecure(); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + + let management = store.management_snapshot(); + assert_eq!(management.state, PolicyManagementState::Invalid); + assert_eq!(management.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(management.read_only_reason, Some(PolicyReadOnlyReason::UnsafePath)); + assert!( + management.policy.is_none(), + "an insecure target must never expose its content" + ); + + let sid = system_sid(); + let request = replacement_request( + &store, + &management.store_token, + PolicyReplacementOperation::Repair, + PolicyConflictHandling::Reject, + draft_json("policy-b"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("repair of an insecure target must be blocked"); + assert_eq!(error.code, ErrorCode::UnsafePolicyPath); + } + + #[tokio::test] + async fn insecure_malformed_target_is_reported_read_only_not_writable() { + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed_invalid(b"{not valid json".to_vec()); + storage.mark_target_insecure(); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + + let management = store.management_snapshot(); + assert_eq!(management.state, PolicyManagementState::Invalid); + assert_eq!(management.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!(management.read_only_reason, Some(PolicyReadOnlyReason::UnsafePath)); + } + + // ─── Typed pre/post-publication storage errors (item 27) ────────────────── + + #[tokio::test] + async fn post_publication_failure_returns_activation_failed_and_publishes_actual_state() { + let storage = Arc::new(FakePolicyStorage::writable()); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let token = current_token(&store); + let sid = system_sid(); + + storage.fail_next_write_after_publish("simulated post-write verification failure"); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Create, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a post-publication verification failure must be reported"); + assert_eq!(error.code, ErrorCode::PolicyActivationFailed); + + // The rename already happened (the fake's `disk` was updated before the + // simulated failure): the store must publish that actual reality immediately, + // synchronously, under the same lock -- not leave the previous (Missing) + // snapshot published until the watcher or fallback poll happens to catch up. + let management = store.management_snapshot(); + assert_eq!(management.state, PolicyManagementState::Active); + assert_eq!( + management.policy.expect("now active").metadata.id.to_string(), + "policy-a" + ); + + // The shared `ErrorResponse.management` field is generic (item 27): the error + // itself must already carry the same freshly republished snapshot, so the caller + // never has to issue a follow-up `GET` to learn what this request already knows. + let error_management = error + .management + .expect("PolicyActivationFailed must carry the freshly republished management snapshot"); + assert_eq!(error_management.state, PolicyManagementState::Active); + assert_eq!( + error_management.policy.expect("now active").metadata.id.to_string(), + "policy-a" + ); + } + + #[tokio::test] + async fn pre_publication_failure_is_distinct_from_post_publication_failure() { + // Companion to `persistence_failure_leaves_the_previous_policy_active`: a + // pre-publication failure must map to a different error code than a + // post-publication one, and disk must remain provably unchanged. + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + storage.fail_next_write("simulated pre-write failure"); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + let token = current_token(&store); + let sid = system_sid(); + + let request = replacement_request( + &store, + &token, + PolicyReplacementOperation::Update, + PolicyConflictHandling::Reject, + draft_json("policy-a"), + ); + let error = store + .replace(request, actor(&sid)) + .await + .expect_err("a pre-publication failure must be reported"); + assert_eq!(error.code, ErrorCode::PolicyPersistenceFailed); + assert_ne!(error.code, ErrorCode::PolicyActivationFailed); + + let management = store.management_snapshot(); + assert_eq!(management.policy.expect("unchanged").metadata.revision, 1); + } + + // ─── Watcher periodic fallback poll (item 19/29) ────────────────────────── + + #[tokio::test] + async fn periodic_fallback_poll_eventually_reflects_an_external_change_alone() { + // Exercises the seam independent of real OS filesystem notification delivery + // (item 19): even if the event-driven watcher never fires at all, the periodic + // fallback poll alone must eventually pick up an external change. + let storage = Arc::new(FakePolicyStorage::writable()); + storage.seed(&policy("policy-a", 1)); + let store = PolicyStore::for_tests_with_storage(Arc::clone(&storage)); + + let shutdown = CancellationToken::new(); + let poll_interval = Duration::from_millis(20); + let watch_handle = tokio::spawn({ + let store = Arc::clone(&store); + let shutdown = shutdown.clone(); + async move { store.watch_with_poll_interval(shutdown, poll_interval).await } + }); + + // External edit, never signaled through the (nonexistent, in this test) OS + // filesystem watcher: only the periodic poll can ever notice it. + storage.seed(&policy("policy-b", 1)); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if store + .active_policy() + .is_some_and(|policy| policy.metadata.id.to_string() == "policy-b") + { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "periodic fallback poll never picked up the external change" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + shutdown.cancel(); + watch_handle.await.expect("watch task must not panic"); + } +} diff --git a/crates/now-package-broker/src/policy_store/receipt.rs b/crates/now-package-broker/src/policy_store/receipt.rs new file mode 100644 index 000000000..3c73b78e3 --- /dev/null +++ b/crates/now-package-broker/src/policy_store/receipt.rs @@ -0,0 +1,178 @@ +//! Keyed validation receipts, issued and verified only by [`PolicyStore`](super::PolicyStore). +//! +//! A receipt is not just a content hash of the canonical draft: without a key, any +//! process could compute a matching value for any draft it likes, which would defeat its +//! whole purpose (proving that *this exact* draft/validator-version/findings triple was +//! authoritatively (re)validated by *this* store immediately before a replacement commits +//! it). HMAC-SHA256 under a process-random key closes that gap -- forging a receipt +//! requires the key, not just the draft -- and verification compares the tag in constant +//! time, so a forgery attempt cannot learn anything from how long the check took. +//! +//! The key lives only in process memory (never logged, never persisted): a receipt issued +//! by one broker instance can never be replayed against a different instance, or the same +//! instance after a restart. + +use hmac::{Hmac, KeyInit as _, Mac}; +use now_policy::PolicyDraftDocument; +use now_policy_api::{PolicyFinding, PolicyValidationReceipt}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +/// Prefix identifying the receipt encoding, so [`ReceiptKey::verify`] can reject a +/// malformed or foreign-format candidate before attempting a MAC comparison. +const RECEIPT_PREFIX: &str = "hmac-sha256:"; + +/// Process-random key binding every validation receipt issued by one [`PolicyStore`] +/// instance. Generated once at store construction and held only in memory. +pub(super) struct ReceiptKey([u8; 32]); + +impl ReceiptKey { + /// Generate a fresh 256-bit process-random key. + pub(super) fn generate() -> Self { + let mut key = [0u8; 32]; + key[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); + key[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); + Self(key) + } + + fn mac( + &self, + validator_version: &str, + canonical_draft: &PolicyDraftDocument, + findings: &[PolicyFinding], + ) -> HmacSha256 { + let canonical_json = serde_json::to_vec(canonical_draft).expect("BUG: canonical draft always serializes"); + let findings_json = serde_json::to_vec(findings).expect("BUG: findings always serialize"); + + // A fresh instance per call: `Mac::finalize`/`verify_slice` both consume `self`. + let mut mac = HmacSha256::new_from_slice(&self.0).expect("HMAC-SHA256 accepts any key length"); + mac.update(validator_version.as_bytes()); + mac.update(b"\0"); + mac.update(&canonical_json); + mac.update(b"\0"); + mac.update(&findings_json); + mac + } + + /// Issue a receipt binding `canonical_draft`, `validator_version`, and the exact + /// `findings` set observed for it. + pub(super) fn issue( + &self, + validator_version: &str, + canonical_draft: &PolicyDraftDocument, + findings: &[PolicyFinding], + ) -> PolicyValidationReceipt { + let tag = self + .mac(validator_version, canonical_draft, findings) + .finalize() + .into_bytes(); + format!("{RECEIPT_PREFIX}{}", hex::encode(tag)).into() + } + + /// Verify, in constant time, that `candidate` is exactly the receipt this key would + /// issue for `canonical_draft`/`validator_version`/`findings`. Any mismatch -- a + /// tampered draft, a different validator version, a different warning set, or a + /// receipt from a different store instance/process -- is rejected identically. + pub(super) fn verify( + &self, + validator_version: &str, + canonical_draft: &PolicyDraftDocument, + findings: &[PolicyFinding], + candidate: &PolicyValidationReceipt, + ) -> bool { + let Some(hex_tag) = candidate.strip_prefix(RECEIPT_PREFIX) else { + return false; + }; + let Ok(tag_bytes) = hex::decode(hex_tag) else { + return false; + }; + + self.mac(validator_version, canonical_draft, findings) + .verify_slice(&tag_bytes) + .is_ok() + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use now_policy_api::PolicyFindingSeverity; + + use super::*; + + fn draft(id: &str) -> PolicyDraftDocument { + serde_json::from_value(serde_json::json!({ + "$schema": now_policy::POLICY_SCHEMA_URI, + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": id, "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [], + })) + .unwrap() + } + + fn finding() -> PolicyFinding { + PolicyFinding { + finding_version: "1.0".into(), + severity: PolicyFindingSeverity::Warning, + code: now_policy_api::PolicyFindingCode::DefaultAllow, + path: "/Enforcement/DefaultDecision".to_owned(), + rule_id: None, + arguments: Default::default(), + message: "test finding".to_owned(), + } + } + + #[test] + fn same_store_key_is_stable() { + let key = ReceiptKey::generate(); + let draft = draft("policy-a"); + let receipt_a = key.issue("v1", &draft, &[]); + let receipt_b = key.issue("v1", &draft, &[]); + assert_eq!(receipt_a, receipt_b); + assert!(key.verify("v1", &draft, &[], &receipt_a)); + } + + #[test] + fn different_store_key_differs() { + let draft = draft("policy-a"); + let receipt = ReceiptKey::generate().issue("v1", &draft, &[]); + let other = ReceiptKey::generate(); + assert_ne!(receipt.to_string(), other.issue("v1", &draft, &[]).to_string()); + assert!(!other.verify("v1", &draft, &[], &receipt)); + } + + #[test] + fn tampered_draft_is_rejected() { + let key = ReceiptKey::generate(); + let receipt = key.issue("v1", &draft("policy-a"), &[]); + assert!(!key.verify("v1", &draft("policy-b"), &[], &receipt)); + } + + #[test] + fn different_validator_version_is_rejected() { + let key = ReceiptKey::generate(); + let draft = draft("policy-a"); + let receipt = key.issue("v1", &draft, &[]); + assert!(!key.verify("v2", &draft, &[], &receipt)); + } + + #[test] + fn different_warning_set_is_rejected() { + let key = ReceiptKey::generate(); + let draft = draft("policy-a"); + let receipt = key.issue("v1", &draft, &[]); + assert!(!key.verify("v1", &draft, std::slice::from_ref(&finding()), &receipt)); + } + + #[test] + fn malformed_candidate_is_rejected() { + let key = ReceiptKey::generate(); + let draft = draft("policy-a"); + assert!(!key.verify("v1", &draft, &[], &PolicyValidationReceipt::from("not-a-real-receipt"))); + assert!(!key.verify("v1", &draft, &[], &PolicyValidationReceipt::from("hmac-sha256:not-hex"))); + } +} diff --git a/crates/now-package-broker/src/policy_store/validation.rs b/crates/now-package-broker/src/policy_store/validation.rs new file mode 100644 index 000000000..a1dab7550 --- /dev/null +++ b/crates/now-package-broker/src/policy_store/validation.rs @@ -0,0 +1,1822 @@ +//! Authoritative, deterministic validation of raw policy draft JSON. +//! +//! Validation is strict: it never silently ignores unknown members, ineffective match +//! values, or unsupported constants. It is also authoritative: it reparses the raw JSON +//! from scratch every time rather than trusting any previously computed result, so it can +//! run identically from `POST /v1/policy/validate` and again inside the `PUT /v1/policy` +//! replacement transaction. +//! +//! Two disjoint kinds of findings are produced: +//! - Errors: the draft is rejected outright (schema/strict failures, duplicate rule ids, +//! version-range/wildcard/validity problems, contradictory constraints, unsupported +//! schema/type/version constants). +//! - Warnings: the draft is accepted, but flags choices worth a human's attention (audit +//! mode, a default-allow posture, and specific sensitive capabilities enabled by an +//! effective `Allow` rule). Warnings are computed per-rule from that rule's own match +//! and constraints only; this deliberately does not attempt any cross-rule shadowing or +//! "which rule wins" analysis. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use now_policy::{ + Decision, PolicyConstraints, PolicyDraftDocument, PolicyDraftMetadata, PolicyEnforcement, PolicyMatch, PolicyRule, +}; +use now_policy_api::{ + API_VERSION_STR, PolicyFinding, PolicyFindingCode, PolicyFindingSeverity, PolicyValidationResult, +}; + +use crate::evaluator::wildcard::pattern_compiles; + +/// Identifies the logic that produced a [`PolicyValidationResult`], bound into every +/// validation receipt. Bump this whenever validation semantics change, so a receipt +/// computed by an older/newer broker can never be mistaken for a match against the +/// current logic. +pub const VALIDATOR_VERSION: &str = "now-package-broker-policy-validator/1"; + +/// Maximum number of rules accepted in a single policy, mirroring the shared contract's +/// documented schema bound (`schemars(length(max = 1024))` on `PolicyDraftDocument::rules`), +/// which is not itself enforced at deserialization time. +const MAX_RULES: usize = 1024; + +/// Authoritatively validate raw draft JSON. +/// +/// Never panics on attacker/administrator-controlled input; parsing and structural +/// failures are reported as findings rather than propagated as Rust errors. +/// +/// This is the keyless, deterministic half of validation: `validation_receipt` is always +/// `None`, even for a valid result. Binding a receipt requires a process-random key that +/// only `PolicyStore` holds; API callers reach this function through +/// `PolicyStore::validate_draft`, never directly, so `POST /v1/policy/validate` and the +/// `PUT /v1/policy` replacement transaction always bind against the exact same key. +pub fn validate_draft(raw: &serde_json::Value) -> PolicyValidationResult { + let mut findings = Vec::new(); + + if !raw.is_object() { + findings.push(error( + PolicyFindingCode::SchemaViolation, + "", + "the policy draft must be a JSON object", + )); + return invalid_result(findings); + } + + // Pin the fixed-constant fields precisely before attempting the full structural + // parse, so a mismatch is reported with its specific code (an "unsupported constant") + // instead of a generic schema-violation message from the marker types' own strict + // deserialization. + check_constant_field( + raw, + "$schema", + "/$schema", + now_policy::POLICY_SCHEMA_URI, + PolicyFindingCode::UnsupportedSchema, + &mut findings, + ); + check_constant_field( + raw, + "PolicyType", + "/PolicyType", + "PackageBrokerPolicy", + PolicyFindingCode::UnsupportedPolicyType, + &mut findings, + ); + check_policy_version(raw, &mut findings); + + if has_error(&findings) { + return invalid_result(findings); + } + + match serde_json::from_value::(raw.clone()) { + Ok(draft) => { + semantic_checks(&draft, &mut findings); + if has_error(&findings) { + invalid_result(findings) + } else { + valid_result(draft, findings) + } + } + Err(parse_error) => { + findings.push(classify_parse_error(&parse_error)); + invalid_result(findings) + } + } +} + +/// Authoritatively (re)validate an on-disk committed [`now_policy::PolicyDocument`] the +/// same deterministic way a submitted draft is validated (item 30): duplicate rule ids, +/// out-of-bounds lengths, invalid wildcard/version/constraint/validity values, and so on +/// are never silently accepted just because the bytes happened to structurally parse +/// into the typed model. Called for every observation of the configured file (including +/// immediately after this store's own write, so a freshly persisted policy is verified +/// through the exact same path it would be re-observed through later) and, at the +/// authoritative validator-version level, is the *same* logic a submitted draft goes +/// through: a committed document can never be less scrutinized than a draft that +/// produced it. +/// +/// Warnings (audit mode, default-allow, sensitive options enabled) do not affect the +/// result: [`PolicyValidationResult::is_valid`] already only reflects Error-severity +/// findings (see [`validate_draft`]), so a committed document with only warnings still +/// activates normally (Active), matching how a replacement with only warnings commits +/// once acknowledged. +/// +/// Additionally requires a nonzero revision: [`PolicyDraftDocument::into_policy_document`] +/// itself rejects revision 0 when this store commits a document, so a *committed* file +/// claiming revision 0 could only be tampering or corruption, never this store's own output. +/// +/// Returns the full [`PolicyValidationResult`] (including specific findings) purely for +/// the caller to trace for operator diagnosis: like [`disk_failure_finding`], the specific +/// findings for a *committed* file must never be exposed through the management API, +/// which only ever sees the generic, sanitized [`DiskFailureReason::FailedSemanticValidation`]. +pub(crate) fn validate_committed_policy(policy: &now_policy::PolicyDocument) -> PolicyValidationResult { + if policy.metadata.revision == 0 { + return invalid_result(vec![error( + PolicyFindingCode::SchemaViolation, + "/Metadata/Revision", + "committed policy revision must be at least 1", + )]); + } + + let draft = PolicyDraftDocument::from(policy); + let raw = serde_json::to_value(&draft).expect("BUG: a committed PolicyDocument's derived draft always serializes"); + validate_draft(&raw) +} + +fn has_error(findings: &[PolicyFinding]) -> bool { + findings + .iter() + .any(|finding| finding.severity == PolicyFindingSeverity::Error) +} + +fn invalid_result(findings: Vec) -> PolicyValidationResult { + PolicyValidationResult { + result_version: API_VERSION_STR.into(), + validator_version: VALIDATOR_VERSION.to_owned(), + is_valid: false, + canonical_draft: None, + validation_receipt: None, + findings, + } +} + +fn valid_result(draft: PolicyDraftDocument, findings: Vec) -> PolicyValidationResult { + PolicyValidationResult { + result_version: API_VERSION_STR.into(), + validator_version: VALIDATOR_VERSION.to_owned(), + is_valid: true, + canonical_draft: Some(draft), + validation_receipt: None, + findings, + } +} + +fn finding( + severity: PolicyFindingSeverity, + code: PolicyFindingCode, + path: impl Into, + message: impl Into, +) -> PolicyFinding { + PolicyFinding { + finding_version: API_VERSION_STR.into(), + severity, + code, + path: path.into(), + rule_id: None, + arguments: BTreeMap::new(), + message: message.into(), + } +} + +fn error(code: PolicyFindingCode, path: impl Into, message: impl Into) -> PolicyFinding { + finding(PolicyFindingSeverity::Error, code, path, message) +} + +fn warning(code: PolicyFindingCode, path: impl Into, message: impl Into) -> PolicyFinding { + finding(PolicyFindingSeverity::Warning, code, path, message) +} + +fn push_rule_finding( + findings: &mut Vec, + rule: &PolicyRule, + severity: PolicyFindingSeverity, + code: PolicyFindingCode, + path: impl Into, + message: impl Into, +) { + let mut f = finding(severity, code, path, message); + f.rule_id = Some(api_resource_id(&rule.id)); + findings.push(f); +} + +/// Convert a `now_policy::ResourceId` (the policy document/rule identifier type) into the +/// API crate's own distinct `ResourceId` type used by [`PolicyFinding::rule_id`]. +fn api_resource_id(id: &now_policy::ResourceId) -> now_policy_api::ResourceId { + now_policy_api::ResourceId::from(id.0.as_str()) +} + +// ─── Pre-parse constant checks ────────────────────────────────────────────── + +fn check_constant_field( + raw: &serde_json::Value, + key: &str, + path: &str, + expected: &str, + mismatch_code: PolicyFindingCode, + findings: &mut Vec, +) { + match raw.get(key) { + None => findings.push(error( + PolicyFindingCode::MissingRequiredField, + path, + format!("missing required field '{key}'"), + )), + Some(serde_json::Value::String(value)) if value == expected => {} + Some(serde_json::Value::String(value)) => findings.push(error( + mismatch_code, + path, + format!("unsupported value '{value}' for '{key}'; expected '{expected}'"), + )), + Some(_) => findings.push(error( + PolicyFindingCode::InvalidFieldType, + path, + format!("'{key}' must be a string"), + )), + } +} + +fn check_policy_version(raw: &serde_json::Value, findings: &mut Vec) { + const PATH: &str = "/PolicyVersion"; + + match raw.get("PolicyVersion") { + None => findings.push(error( + PolicyFindingCode::MissingRequiredField, + PATH, + "missing required field 'PolicyVersion'", + )), + Some(serde_json::Value::String(value)) => match semver::Version::parse(value) { + Ok(version) if version.major == 1 => {} + Ok(version) => findings.push(error( + PolicyFindingCode::UnsupportedPolicyVersion, + PATH, + format!( + "unsupported PolicyVersion major '{}'; this broker implements schema version 1.x", + version.major + ), + )), + Err(parse_error) => findings.push(error( + PolicyFindingCode::InvalidFieldValue, + PATH, + format!("PolicyVersion '{value}' is not a valid semantic version: {parse_error}"), + )), + }, + Some(_) => findings.push(error( + PolicyFindingCode::InvalidFieldType, + PATH, + "'PolicyVersion' must be a string", + )), + } +} + +/// Heuristically classify a strict-deserialization failure of the overall draft shape. +/// +/// `serde_json::Error` from a `Value`-based deserialization carries no JSON-pointer path, +/// only a human-readable message; this maps that message to the closest +/// [`PolicyFindingCode`]. Semantic checks below run on the successfully typed draft and +/// therefore always produce precise, path-qualified findings — this heuristic path is +/// only reached for structural/strict-schema failures of the raw JSON itself. +/// +/// Only used for a client-submitted draft (`POST /v1/policy/validate` and the `PUT +/// /v1/policy` replacement transaction): the detailed message helps the submitter fix +/// their own input, and cannot leak anything they do not already know, since it is their +/// own content. A parse failure of the *committed on-disk* document is a different trust +/// boundary and must never repeat raw parser text back through the management API to a +/// caller who did not necessarily write that file; see [`disk_failure_finding`]. +pub(crate) fn classify_parse_error(parse_error: &serde_json::Error) -> PolicyFinding { + let message = parse_error.to_string(); + + let code = if message.contains("missing field") { + PolicyFindingCode::MissingRequiredField + } else if message.contains("unknown field") { + PolicyFindingCode::UnknownField + } else if message.contains("invalid type") { + PolicyFindingCode::InvalidFieldType + } else { + PolicyFindingCode::InvalidFieldValue + }; + + error( + code, + "", + format!("policy draft does not match the expected schema: {message}"), + ) +} + +/// Category of a disk-level failure that prevented the configured policy file from being +/// trusted, parsed, or activated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DiskFailureReason { + /// The file could not be opened, its identity/security state could not be queried, or + /// its content could not be read. + Unreadable, + /// The file's owner/DACL failed storage security validation. + InsecureStorage, + /// The file was read successfully but is not valid JSON matching the expected schema. + MalformedContent, + /// The configured path itself does not have a supported shape (relative, empty + /// leaf, trailing separator, `.`/`..` component, ...) or extension (anything other + /// than case-insensitive `.json`); see `windows::validate_configured_path_shape`. + /// The file, if any exists at that path, is never touched. + UnsupportedFormat, + /// The file parsed as structurally valid JSON matching [`now_policy::PolicyDocument`], + /// but failed the same deterministic semantic validation a submitted draft would + /// (duplicate rule ids, out-of-bounds lengths, invalid wildcard/version/constraint/ + /// validity values, ...), or has a zero committed revision. See item 30: a committed + /// document is never activated on structural parseability alone. + FailedSemanticValidation, +} + +/// Build a generic, sanitized diagnostic finding for a storage-level failure (I/O, +/// security, parse, or semantic-validation) that prevented the on-disk policy file from +/// being trusted or activated. +/// +/// Deliberately carries none of the underlying OS/serde error text, specific validation +/// findings, or any bytes from the file itself: unlike [`classify_parse_error`], this +/// describes the *committed on-disk* document, which `GET /v1/policy/management` exposes +/// to any authenticated (but not necessarily elevated/Administrator) caller. Repeating +/// raw parser/OS error text or the specific semantic-validation findings here could leak +/// fragments of attacker- or corruption-controlled file content, or implementation/ +/// filesystem detail, to a caller who may not even be the one who wrote that file. The +/// detailed error/findings are only ever traced (`tracing::warn!`) at the call site in +/// `policy_store::windows`, for operator diagnosis. +pub(crate) fn disk_failure_finding(reason: DiskFailureReason) -> PolicyFinding { + let message = match reason { + DiskFailureReason::Unreadable => "the configured policy file could not be opened or read", + DiskFailureReason::InsecureStorage => { + "the configured policy file failed storage security validation (unexpected owner or write permissions)" + } + DiskFailureReason::MalformedContent => { + "the configured policy file does not contain valid JSON matching the expected policy schema" + } + DiskFailureReason::UnsupportedFormat => { + "the configured policy path does not have a supported file extension; only '.json' is supported" + } + DiskFailureReason::FailedSemanticValidation => { + "the configured policy file contains a policy that fails validation (see server logs for detail)" + } + }; + error(PolicyFindingCode::SchemaViolation, "", message) +} + +// ─── Semantic checks on the successfully parsed draft ─────────────────────── + +fn semantic_checks(draft: &PolicyDraftDocument, findings: &mut Vec) { + check_metadata_bounds(&draft.metadata, findings); + check_validity_interval(&draft.metadata, findings); + check_duplicate_rule_ids(&draft.rules, findings); + + if draft.rules.len() > MAX_RULES { + findings.push(error( + PolicyFindingCode::SchemaViolation, + "/Rules", + format!( + "policy defines {} rules, exceeding the maximum of {MAX_RULES}", + draft.rules.len() + ), + )); + } + + for (idx, rule) in draft.rules.iter().enumerate() { + check_version_range(idx, rule, findings); + check_wildcard_patterns(idx, rule, findings); + check_match_collection_bounds(idx, rule, findings); + check_rule_bounds(idx, rule, findings); + check_contradictory_constraints(idx, rule, findings); + } + + // Warnings are computed unconditionally alongside the hard-error checks above; an + // invalid draft's finding set may legitimately mix errors and warnings. + check_audit_mode(&draft.enforcement, findings); + check_default_allow(&draft.enforcement, findings); + for (idx, rule) in draft.rules.iter().enumerate() { + check_sensitive_options(idx, rule, findings); + } +} + +/// Enforce a plain (non-newtype-validated) human-text string field's declared bounds: +/// the shared contract documents these via `#[schemars(length(...))]`, which only +/// affects generated JSON Schema, not `serde` deserialization -- unlike e.g. +/// `ResourceId`, this field has no custom `Deserialize` impl enforcing it, so nothing +/// rejects an out-of-bounds value before it reaches here. +/// +/// Counts Unicode scalar values (`chars().count()`), not UTF-8 bytes: JSON Schema's +/// `minLength`/`maxLength` (what `schemars(length(...))` generates) are defined in terms +/// of Unicode code points, and these fields (publisher, description, reason, ...) are +/// arbitrary human text with no ASCII restriction. A 128-character CJK publisher name is +/// three times that many UTF-8 bytes, so counting bytes here would reject well-formed +/// values far below their documented limit. This is deliberately distinct from the +/// shared contract's own newtypes (`ResourceId`, version/pattern strings, ...), which +/// are ASCII-constrained by their own regex and correctly count bytes for that reason; +/// see [`check_version_bound`] for one of those. +fn check_string_bounds(value: &str, min: usize, max: usize, path: &str, findings: &mut Vec) { + let length = value.chars().count(); + if length < min { + findings.push(error( + PolicyFindingCode::SchemaViolation, + path, + format!("{path} has length {length}, below the documented minimum of {min}"), + )); + } else if length > max { + findings.push(error( + PolicyFindingCode::SchemaViolation, + path, + format!("{path} has length {length}, exceeding the documented maximum of {max}"), + )); + } +} + +/// Enforce a collection's declared maximum length: see [`check_string_bounds`] for why +/// this is not already covered by `serde`/`schemars`. +fn check_max_len(len: usize, max: usize, path: &str, findings: &mut Vec) { + if len > max { + findings.push(error( + PolicyFindingCode::SchemaViolation, + path, + format!("{path} has {len} entries, exceeding the documented maximum of {max}"), + )); + } +} + +fn check_metadata_bounds(metadata: &PolicyDraftMetadata, findings: &mut Vec) { + check_string_bounds(&metadata.publisher, 1, 128, "/Metadata/Publisher", findings); + if let Some(description) = &metadata.description { + check_string_bounds(description, 0, 512, "/Metadata/Description", findings); + } +} + +fn check_validity_interval(metadata: &PolicyDraftMetadata, findings: &mut Vec) { + if let (Some(valid_from), Some(valid_until)) = (metadata.valid_from, metadata.valid_until) + && valid_from > valid_until + { + findings.push(error( + PolicyFindingCode::InvalidValidityInterval, + "/Metadata/ValidUntil", + format!("ValidUntil ({valid_until}) is before ValidFrom ({valid_from})"), + )); + } +} + +fn check_duplicate_rule_ids(rules: &[PolicyRule], findings: &mut Vec) { + let mut seen: HashMap<&str, usize> = HashMap::new(); + + for (idx, rule) in rules.iter().enumerate() { + let id: &str = &rule.id; + + if let Some(&first_idx) = seen.get(id) { + push_rule_finding( + findings, + rule, + PolicyFindingSeverity::Error, + PolicyFindingCode::DuplicateRuleId, + format!("/Rules/{idx}/Id"), + format!("rule id '{id}' is already used by the rule at index {first_idx}"), + ); + } else { + seen.insert(id, idx); + } + } +} + +/// Match-criteria collections whose declared maximum length is *not* structurally +/// guaranteed by their element type: `sources`/`package_identifiers`/`package_names` are +/// sets of an unbounded [`now_policy::StringPattern`], and `versions` a set of an +/// unbounded [`now_policy::VersionString`], so any number of distinct values can be +/// submitted regardless of the documented bound. +/// +/// `operations` (max 3), `scopes` (max 2), and `elevation` (max 2) are deliberately not +/// checked here: each is a `BTreeSet` of a fully enumerated, closed enum whose own variant +/// count matches its declared schema maximum exactly, so a set of that type can +/// structurally never violate the bound. `architectures` (max 5, 4 variants) is the same, +/// with headroom to spare. `managers`, by contrast, *is* checked: it declares a maximum of +/// 16, but `now_policy::ManagerName` has 17 variants, so a set naming every manager would +/// silently exceed the documented bound without this check. +fn check_match_collection_bounds(idx: usize, rule: &PolicyRule, findings: &mut Vec) { + let m: &PolicyMatch = &rule.match_criteria; + let base = format!("/Rules/{idx}/Match"); + check_max_len(m.managers.len(), 16, &format!("{base}/Managers"), findings); + check_max_len(m.sources.len(), 128, &format!("{base}/Sources"), findings); + check_max_len( + m.package_identifiers.len(), + 1024, + &format!("{base}/PackageIdentifiers"), + findings, + ); + check_max_len(m.package_names.len(), 1024, &format!("{base}/PackageNames"), findings); + check_max_len(m.versions.len(), 256, &format!("{base}/Versions"), findings); +} + +/// `Reason` and the `Constraints` allow/deny collections, none of which are covered by +/// `serde`/`schemars` enforcement (plain `Option`/`Vec` +/// fields with no bound-checking `Deserialize` impl of their own). +fn check_rule_bounds(idx: usize, rule: &PolicyRule, findings: &mut Vec) { + if let Some(reason) = &rule.reason { + check_string_bounds(reason, 0, 512, &format!("/Rules/{idx}/Reason"), findings); + } + + let Some(constraints) = &rule.constraints else { + return; + }; + let base = format!("/Rules/{idx}/Constraints"); + check_max_len( + constraints.allowed_install_location_patterns.len(), + 64, + &format!("{base}/AllowedInstallLocationPatterns"), + findings, + ); + check_max_len( + constraints.allowed_custom_parameters.len(), + 128, + &format!("{base}/AllowedCustomParameters"), + findings, + ); + check_max_len( + constraints.allowed_custom_parameter_patterns.len(), + 128, + &format!("{base}/AllowedCustomParameterPatterns"), + findings, + ); + check_max_len( + constraints.denied_custom_parameters.len(), + 128, + &format!("{base}/DeniedCustomParameters"), + findings, + ); +} + +fn check_version_range(idx: usize, rule: &PolicyRule, findings: &mut Vec) { + let Some(range) = &rule.match_criteria.version_range else { + return; + }; + let base = format!("/Rules/{idx}/Match/VersionRange"); + + let min_ok = check_version_bound( + range.min_version.as_deref(), + &format!("{base}/MinVersion"), + rule, + findings, + ); + let max_ok = check_version_bound( + range.max_version.as_deref(), + &format!("{base}/MaxVersion"), + rule, + findings, + ); + + if let (Some(min_version), Some(max_version)) = (&min_ok, &max_ok) + && min_version > max_version + { + push_rule_finding( + findings, + rule, + PolicyFindingSeverity::Error, + PolicyFindingCode::EmptyVersionRange, + base, + format!("MinVersion {min_version} is greater than MaxVersion {max_version}; this range can never match"), + ); + } +} + +/// Validate one `VersionRange` bound (`MinVersion`/`MaxVersion`). Enforces the shared +/// contract's documented (but, being a plain `Option`, not `serde`/`schemars`- +/// enforced) nonempty/max-length bound *before* attempting a semantic-version parse, so a +/// present-but-empty or oversized value is reported precisely rather than silently +/// treated as absent (this crate's validation is strict: see the module docs). Returns +/// the parsed version on success, for the caller's min/max ordering check. +/// +/// Deliberately counts UTF-8 bytes (`value.len()`), unlike [`check_string_bounds`]'s +/// Unicode scalar count: a semantic version is required to parse with [`semver::Version`], +/// whose grammar is ASCII-only, so byte and scalar counts always coincide here and this +/// mirrors the shared contract's own ASCII-constrained newtypes (e.g. `ResourceId`). +fn check_version_bound( + value: Option<&str>, + path: &str, + rule: &PolicyRule, + findings: &mut Vec, +) -> Option { + let value = value?; + + if value.is_empty() || value.len() > 128 { + push_rule_finding( + findings, + rule, + PolicyFindingSeverity::Error, + PolicyFindingCode::InvalidVersionRange, + path, + format!( + "must be non-empty and at most 128 characters when present, got length {}", + value.len() + ), + ); + return None; + } + + match semver::Version::parse(value) { + Ok(version) => Some(version), + Err(parse_error) => { + push_rule_finding( + findings, + rule, + PolicyFindingSeverity::Error, + PolicyFindingCode::InvalidVersionRange, + path, + format!("'{value}' is not a valid semantic version: {parse_error}"), + ); + None + } + } +} + +fn check_wildcard_patterns(idx: usize, rule: &PolicyRule, findings: &mut Vec) { + let m = &rule.match_criteria; + check_patterns(idx, rule, "Match/Sources", &m.sources, findings); + check_patterns(idx, rule, "Match/PackageIdentifiers", &m.package_identifiers, findings); + check_patterns(idx, rule, "Match/PackageNames", &m.package_names, findings); + + if let Some(constraints) = &rule.constraints { + check_patterns( + idx, + rule, + "Constraints/AllowedInstallLocationPatterns", + &constraints.allowed_install_location_patterns, + findings, + ); + check_patterns( + idx, + rule, + "Constraints/AllowedCustomParameterPatterns", + &constraints.allowed_custom_parameter_patterns, + findings, + ); + } +} + +/// Check every pattern in any wildcard-pattern collection (`BTreeSet` match criteria or +/// `Vec` constraint allow-lists alike) for the same compile-ability the evaluator itself +/// requires at request-evaluation time. +fn check_patterns<'a, S: AsRef + 'a>( + idx: usize, + rule: &PolicyRule, + field_name: &str, + patterns: impl IntoIterator, + findings: &mut Vec, +) { + for pattern in patterns { + if !pattern_compiles(pattern.as_ref()) { + push_rule_finding( + findings, + rule, + PolicyFindingSeverity::Error, + PolicyFindingCode::InvalidWildcardPattern, + format!("/Rules/{idx}/{field_name}"), + format!("pattern '{}' is too large or complex to evaluate", pattern.as_ref()), + ); + } + } +} + +/// Flag a rule whose own match criteria and own constraints can never be simultaneously +/// satisfied, making the rule permanently unreachable. This only inspects a rule against +/// itself (its match vs. its own constraints), never other rules, so it is not a +/// cross-rule shadowing analysis. +fn check_contradictory_constraints(idx: usize, rule: &PolicyRule, findings: &mut Vec) { + if !rule.enabled { + return; + } + let Some(constraints) = &rule.constraints else { + return; + }; + let m = &rule.match_criteria; + + let mut check = |bool_match: &BTreeSet, allow_flag: bool, option_name: &str| { + if !allow_flag && bool_match.contains(&true) { + push_rule_finding( + findings, + rule, + PolicyFindingSeverity::Error, + PolicyFindingCode::ContradictoryConstraints, + format!("/Rules/{idx}/Constraints"), + format!( + "rule matches only requests where {option_name}=true, but Constraints denies {option_name}; \ + this rule can never match" + ), + ); + } + }; + + check(&m.interactive, constraints.allow_interactive, "Interactive"); + check(&m.skip_hash_check, constraints.allow_skip_hash_check, "SkipHashCheck"); + check(&m.pre_release, constraints.allow_pre_release, "PreRelease"); + check( + &m.has_custom_install_location, + constraints.allow_custom_install_location, + "HasCustomInstallLocation", + ); + check( + &m.has_custom_parameters, + constraints.allow_custom_parameters, + "HasCustomParameters", + ); + check( + &m.has_pre_post_commands, + constraints.allow_pre_post_commands, + "HasPrePostCommands", + ); + check( + &m.has_kill_before_operation, + constraints.allow_kill_before_operation, + "HasKillBeforeOperation", + ); + check( + &m.has_uninstall_previous, + constraints.allow_uninstall_previous, + "HasUninstallPrevious", + ); +} + +// ─── Warnings ──────────────────────────────────────────────────────────────── + +fn check_audit_mode(enforcement: &PolicyEnforcement, findings: &mut Vec) { + if enforcement.audit_mode == Some(true) { + findings.push(warning( + PolicyFindingCode::AuditModeEnabled, + "/Enforcement/AuditMode", + "audit mode is enabled; decisions are logged but not enforced", + )); + } +} + +fn check_default_allow(enforcement: &PolicyEnforcement, findings: &mut Vec) { + if enforcement.default_decision == Decision::Allow { + findings.push(warning( + PolicyFindingCode::DefaultAllow, + "/Enforcement/DefaultDecision", + "the default decision is Allow; requests matching no rule are permitted", + )); + } +} + +/// Emit a deterministic, per-rule warning for specifically named sensitive capabilities +/// that an enabled `Allow` rule leaves open: `SkipHashCheck`, `PreRelease`, custom install +/// location, custom parameters, pre/post operation commands, killing processes before the +/// operation, and uninstalling a previous version. `Interactive` and `AllowUpgrade` are +/// deliberately excluded: an interactive install is the ordinary case for a +/// non-elevated/user-scope request, and skipping an upgrade when one is already installed +/// is not a privilege-relevant capability. +/// +/// A warning fires only when the rule's own match criteria could actually be reached by a +/// request that has the sensitive option set (the match is absent for that flag, meaning +/// it matches both `true` and `false`, or explicitly includes `true`) *and* the rule's own +/// constraints (defaulting to the fully permissive [`PolicyConstraints::default`] when +/// absent, matching evaluator semantics in `constraints_pass`) permit it. This only +/// inspects the rule against itself, never other rules or overall reachability. +/// +/// For custom install location and custom parameters, any configured restriction (an +/// allow-pattern list, an exact allow-list, or a deny-list) is reported in the finding's +/// structured `arguments` rather than used to silently suppress the warning: a partial +/// restriction is still worth a human's attention, and hiding it behind silence would be +/// misleading. The one exception is a *provably* catch-all deny -- a +/// `denied_custom_parameters` entry of exactly `"*"`, which unconditionally rejects every +/// possible value regardless of any allow-list -- which suppresses the warning outright, +/// since no request carrying a custom parameter can ever be let through. +fn check_sensitive_options(idx: usize, rule: &PolicyRule, findings: &mut Vec) { + if !rule.enabled || rule.decision != Decision::Allow { + return; + } + + let default_constraints = PolicyConstraints::default(); + let constraints = rule.constraints.as_ref().unwrap_or(&default_constraints); + let m: &PolicyMatch = &rule.match_criteria; + + let mut warn = |option_name: &str, detail: &str, arguments: Vec<(&str, serde_json::Value)>| { + let path = match &rule.constraints { + Some(_) => format!("/Rules/{idx}/Constraints/{option_name}"), + None => format!("/Rules/{idx}"), + }; + let mut f = warning( + PolicyFindingCode::SensitiveOptionAllowed, + path, + format!("rule '{}' allows {option_name}: {detail}", rule.id), + ); + f.rule_id = Some(api_resource_id(&rule.id)); + f.arguments + .insert("Option".to_owned(), serde_json::Value::from(option_name)); + for (key, value) in arguments { + f.arguments.insert(key.to_owned(), value); + } + findings.push(f); + }; + + // A rule can only ever be reached by a request whose sensitive-option value is `true` + // if its own match either does not restrict that flag at all (matches both `true` and + // `false`) or explicitly matches `true`. + let reachable_when_true = |flag_match: &BTreeSet| flag_match.is_empty() || flag_match.contains(&true); + + if constraints.allow_skip_hash_check && reachable_when_true(&m.skip_hash_check) { + warn("SkipHashCheck", "requests may skip package hash verification", vec![]); + } + if constraints.allow_pre_release && reachable_when_true(&m.pre_release) { + warn( + "PreRelease", + "requests may install pre-release package versions", + vec![], + ); + } + if constraints.allow_custom_install_location && reachable_when_true(&m.has_custom_install_location) { + let patterns = &constraints.allowed_install_location_patterns; + let arguments = if patterns.is_empty() { + Vec::new() + } else { + vec![( + "AllowedInstallLocationPatterns", + serde_json::Value::from(patterns.iter().map(|p| p.as_ref().to_owned()).collect::>()), + )] + }; + warn( + "AllowCustomInstallLocation", + "requests may install to a custom location", + arguments, + ); + } + if constraints.allow_pre_post_commands && reachable_when_true(&m.has_pre_post_commands) { + warn( + "AllowPrePostCommands", + "requests may run arbitrary pre/post operation commands", + vec![], + ); + } + if constraints.allow_kill_before_operation && reachable_when_true(&m.has_kill_before_operation) { + warn( + "AllowKillBeforeOperation", + "requests may terminate arbitrary processes before the operation runs", + vec![], + ); + } + if constraints.allow_uninstall_previous && reachable_when_true(&m.has_uninstall_previous) { + warn( + "AllowUninstallPrevious", + "requests may uninstall a previously installed version before installing an update", + vec![], + ); + } + if constraints.allow_custom_parameters + && reachable_when_true(&m.has_custom_parameters) + && !denies_every_custom_parameter(constraints) + { + let mut arguments = Vec::new(); + if !constraints.allowed_custom_parameters.is_empty() { + arguments.push(( + "AllowedCustomParameters", + serde_json::Value::from( + constraints + .allowed_custom_parameters + .iter() + .map(|p| p.as_ref().to_owned()) + .collect::>(), + ), + )); + } + if !constraints.allowed_custom_parameter_patterns.is_empty() { + arguments.push(( + "AllowedCustomParameterPatterns", + serde_json::Value::from( + constraints + .allowed_custom_parameter_patterns + .iter() + .map(|p| p.as_ref().to_owned()) + .collect::>(), + ), + )); + } + if !constraints.denied_custom_parameters.is_empty() { + arguments.push(( + "DeniedCustomParameters", + serde_json::Value::from( + constraints + .denied_custom_parameters + .iter() + .map(|p| p.as_ref().to_owned()) + .collect::>(), + ), + )); + } + warn( + "AllowCustomParameters", + "requests may pass custom parameters", + arguments, + ); + } +} + +/// Whether `constraints.denied_custom_parameters` provably rejects every possible custom +/// parameter value, regardless of any allow-list: an exact `"*"` entry. This is +/// deliberately narrow (only the single literal universal pattern, not a general +/// subsumption analysis of arbitrary glob patterns), so it only ever suppresses a warning +/// when doing so is certain to be safe. +fn denies_every_custom_parameter(constraints: &PolicyConstraints) -> bool { + constraints + .denied_custom_parameters + .iter() + .any(|pattern| pattern.as_ref() == "*") +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use serde_json::json; + + use super::*; + + fn minimal_draft() -> serde_json::Value { + json!({ + "$schema": now_policy::POLICY_SCHEMA_URI, + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": "test-policy", "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [], + }) + } + + #[test] + fn minimal_draft_is_valid_with_no_findings() { + let result = validate_draft(&minimal_draft()); + assert!(result.is_valid); + assert!(result.canonical_draft.is_some()); + // `validate_draft` is the keyless half of validation; binding a receipt requires + // `PolicyStore`'s process-random key (see `PolicyStore::validate_draft`). + assert!(result.validation_receipt.is_none()); + assert!(result.findings.is_empty()); + } + + #[test] + fn non_object_draft_is_a_schema_violation() { + let result = validate_draft(&json!("not an object")); + assert!(!result.is_valid); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].code, PolicyFindingCode::SchemaViolation); + } + + #[test] + fn wrong_schema_constant_is_reported_precisely() { + let mut draft = minimal_draft(); + draft["$schema"] = json!("https://example.com/wrong.json"); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::UnsupportedSchema && f.path == "/$schema") + ); + } + + #[test] + fn wrong_policy_type_constant_is_reported_precisely() { + let mut draft = minimal_draft(); + draft["PolicyType"] = json!("SomethingElse"); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::UnsupportedPolicyType) + ); + } + + #[test] + fn unsupported_policy_version_major_is_reported() { + let mut draft = minimal_draft(); + draft["PolicyVersion"] = json!("2.0.0"); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::UnsupportedPolicyVersion) + ); + } + + #[test] + fn missing_required_field_is_reported() { + let mut draft = minimal_draft(); + draft.as_object_mut().unwrap().remove("Metadata"); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::MissingRequiredField) + ); + } + + #[test] + fn unknown_field_is_reported() { + let mut draft = minimal_draft(); + draft["UnexpectedField"] = json!(true); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::UnknownField) + ); + } + + fn rule(id: &str, extra: serde_json::Value) -> serde_json::Value { + let mut base = json!({ + "Id": id, + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Match": { "Managers": ["Winget"] }, + }); + for (key, value) in extra.as_object().into_iter().flatten() { + base[key] = value.clone(); + } + base + } + + #[test] + fn duplicate_rule_ids_are_rejected() { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule("dup", json!({})), rule("dup", json!({}))]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::DuplicateRuleId && f.path == "/Rules/1/Id") + ); + } + + #[test] + fn empty_version_range_is_rejected() { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Match": { "Managers": ["Winget"], "VersionRange": { "MinVersion": "2.0.0", "MaxVersion": "1.0.0" } } }) + )]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::EmptyVersionRange) + ); + } + + #[test] + fn invalid_version_range_bound_is_rejected() { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Match": { "Managers": ["Winget"], "VersionRange": { "MinVersion": "not-a-version" } } }) + )]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::InvalidVersionRange) + ); + } + + #[test] + fn empty_version_bound_is_rejected_rather_than_silently_ignored() { + // `VersionRange` fields are documented as non-empty-when-present + // (`schemars(length(min = 1))`), but being plain `Option` this is not + // itself enforced by serde/schemars; validation must not silently treat an empty + // string the same as an absent field (see the module docs: validation is strict). + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Match": { "Managers": ["Winget"], "VersionRange": { "MinVersion": "" } } }) + )]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::InvalidVersionRange) + ); + } + + #[test] + fn oversized_version_bound_is_rejected() { + let mut draft = minimal_draft(); + let mut oversized = "1.".to_owned(); + oversized.push_str(&"0".repeat(200)); + draft["Rules"] = json!([rule( + "r1", + json!({ "Match": { "Managers": ["Winget"], "VersionRange": { "MinVersion": oversized } } }) + )]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::InvalidVersionRange) + ); + } + + #[test] + fn publisher_below_minimum_length_is_rejected() { + let mut draft = minimal_draft(); + draft["Metadata"]["Publisher"] = json!(""); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation && f.path == "/Metadata/Publisher") + ); + } + + #[test] + fn publisher_over_maximum_length_is_rejected() { + let mut draft = minimal_draft(); + draft["Metadata"]["Publisher"] = json!("x".repeat(129)); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation && f.path == "/Metadata/Publisher") + ); + } + + #[test] + fn publisher_at_maximum_length_is_accepted() { + let mut draft = minimal_draft(); + draft["Metadata"]["Publisher"] = json!("x".repeat(128)); + let result = validate_draft(&draft); + assert!(result.is_valid, "{:?}", result.findings); + } + + /// Regression test for length being counted in Unicode scalar values (code points), + /// not UTF-8 bytes: 128 CJK characters is exactly at the documented maximum, but each + /// character is 3 bytes in UTF-8 (384 bytes total), so a byte-counting implementation + /// would incorrectly reject this well-formed value. + #[test] + fn publisher_at_maximum_length_with_cjk_characters_is_accepted() { + let mut draft = minimal_draft(); + let publisher: String = "世".repeat(128); + assert_eq!(publisher.chars().count(), 128); + assert_eq!( + publisher.len(), + 384, + "sanity check: each CJK character is 3 UTF-8 bytes" + ); + draft["Metadata"]["Publisher"] = json!(publisher); + let result = validate_draft(&draft); + assert!(result.is_valid, "{:?}", result.findings); + } + + /// Companion to the acceptance test above: one character past the documented maximum + /// (129 Unicode scalar values) must still be rejected, proving the check is not + /// simply disabled for multi-byte content. + #[test] + fn publisher_over_maximum_length_with_cjk_characters_is_rejected() { + let mut draft = minimal_draft(); + let publisher: String = "世".repeat(129); + draft["Metadata"]["Publisher"] = json!(publisher); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation && f.path == "/Metadata/Publisher") + ); + } + + #[test] + fn description_over_maximum_length_is_rejected() { + let mut draft = minimal_draft(); + draft["Metadata"]["Description"] = json!("x".repeat(513)); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation && f.path == "/Metadata/Description") + ); + } + + /// Same Unicode-scalar-counting regression as the publisher tests above, for the + /// other plain human-text field sharing `check_string_bounds`. + #[test] + fn description_at_maximum_length_with_cjk_characters_is_accepted() { + let mut draft = minimal_draft(); + let description: String = "説".repeat(512); + assert_eq!(description.chars().count(), 512); + draft["Metadata"]["Description"] = json!(description); + let result = validate_draft(&draft); + assert!(result.is_valid, "{:?}", result.findings); + } + + /// Companion to the acceptance test above: one character past the documented maximum + /// (513 Unicode scalar values) must still be rejected, proving the check is not + /// simply disabled for multi-byte content. + #[test] + fn description_over_maximum_length_with_cjk_characters_is_rejected() { + let mut draft = minimal_draft(); + let description: String = "説".repeat(513); + draft["Metadata"]["Description"] = json!(description); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation && f.path == "/Metadata/Description") + ); + } + + #[test] + fn rule_reason_over_maximum_length_is_rejected() { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule("r1", json!({ "Reason": "x".repeat(513) }))]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation && f.path == "/Rules/0/Reason") + ); + } + + /// Same Unicode-scalar-counting regression as the publisher/description tests above, + /// for the third and last plain human-text field sharing `check_string_bounds`. + #[test] + fn rule_reason_at_maximum_length_with_cjk_characters_is_accepted() { + let mut draft = minimal_draft(); + let reason: String = "理".repeat(512); + assert_eq!(reason.chars().count(), 512); + draft["Rules"] = json!([rule("r1", json!({ "Reason": reason }))]); + let result = validate_draft(&draft); + assert!(result.is_valid, "{:?}", result.findings); + } + + #[test] + fn rule_reason_over_maximum_length_with_cjk_characters_is_rejected() { + let mut draft = minimal_draft(); + let reason: String = "理".repeat(513); + draft["Rules"] = json!([rule("r1", json!({ "Reason": reason }))]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation && f.path == "/Rules/0/Reason") + ); + } + + #[test] + fn managers_over_maximum_count_is_rejected() { + // `ManagerName` has 17 variants, one more than the documented `Managers` bound of + // 16: naming every manager is a real, reachable violation, not just a + // structurally-impossible edge case. + let all_managers = [ + "Winget", + "PowerShell", + "PowerShell7", + "Apt", + "Bun", + "Cargo", + "Chocolatey", + "Dnf", + "Dotnet", + "Flatpak", + "Homebrew", + "Npm", + "Pacman", + "Pip", + "Scoop", + "Snap", + "Vcpkg", + ]; + assert_eq!( + all_managers.len(), + 17, + "test fixture must list every ManagerName variant" + ); + + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule("r1", json!({ "Match": { "Managers": all_managers } }))]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation && f.path == "/Rules/0/Match/Managers") + ); + } + + #[test] + fn sources_over_maximum_count_is_rejected() { + let patterns: Vec = (0..129).map(|i| format!("source-{i}")).collect(); + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule("r1", json!({ "Match": { "Sources": patterns } }))]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation && f.path == "/Rules/0/Match/Sources") + ); + } + + #[test] + fn allowed_install_location_patterns_over_maximum_count_is_rejected() { + let patterns: Vec = (0..65).map(|i| format!("C:\\Tools{i}\\*")).collect(); + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Constraints": { "AllowedInstallLocationPatterns": patterns } }) + )]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::SchemaViolation + && f.path == "/Rules/0/Constraints/AllowedInstallLocationPatterns") + ); + } + + #[test] + fn managers_at_maximum_count_is_accepted() { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Match": { "Managers": [ + "Winget", "PowerShell", "PowerShell7", "Apt", "Bun", "Cargo", "Chocolatey", "Dnf", + "Dotnet", "Flatpak", "Homebrew", "Npm", "Pacman", "Pip", "Scoop", "Snap", + ] } }) + )]); + let result = validate_draft(&draft); + assert!(result.is_valid, "{:?}", result.findings); + } + + #[test] + fn disk_failure_finding_never_interpolates_content() { + // Never takes any variable/attacker-controlled input at all: its whole point is + // that no code path can accidentally make it echo raw OS/serde error text or file + // content. Assert every category's message is a fixed, non-empty string. + for reason in [ + DiskFailureReason::Unreadable, + DiskFailureReason::InsecureStorage, + DiskFailureReason::MalformedContent, + ] { + let finding = disk_failure_finding(reason); + assert_eq!(finding.code, PolicyFindingCode::SchemaViolation); + assert_eq!(finding.path, ""); + assert!(!finding.message.is_empty()); + } + } + + #[test] + fn contradictory_constraints_are_rejected() { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ + "Match": { "Interactive": [true] }, + "Constraints": { "AllowInteractive": false } + }) + )]); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::ContradictoryConstraints) + ); + } + + #[test] + fn audit_mode_and_default_allow_are_warnings_only() { + let mut draft = minimal_draft(); + draft["Enforcement"] = + json!({ "DefaultDecision": "Allow", "RulePrecedence": "PriorityThenDeny", "AuditMode": true }); + let result = validate_draft(&draft); + assert!(result.is_valid, "warnings must not invalidate an otherwise-valid draft"); + let codes: Vec<_> = result.findings.iter().map(|f| f.code).collect(); + assert!(codes.contains(&PolicyFindingCode::AuditModeEnabled)); + assert!(codes.contains(&PolicyFindingCode::DefaultAllow)); + assert!( + result + .findings + .iter() + .all(|f| f.severity == PolicyFindingSeverity::Warning) + ); + } + + /// (constraint field name in the draft JSON, corresponding `Match` field name, + /// `Option` argument value reported on the finding) for every sensitive capability + /// this validator flags. `Interactive` and `AllowUpgrade` are deliberately absent: + /// see `check_sensitive_options`'s doc comment for why. + const SENSITIVE_OPTIONS: &[(&str, &str, &str)] = &[ + ("AllowSkipHashCheck", "SkipHashCheck", "SkipHashCheck"), + ("AllowPreRelease", "PreRelease", "PreRelease"), + ( + "AllowCustomInstallLocation", + "HasCustomInstallLocation", + "AllowCustomInstallLocation", + ), + ("AllowCustomParameters", "HasCustomParameters", "AllowCustomParameters"), + ("AllowPrePostCommands", "HasPrePostCommands", "AllowPrePostCommands"), + ( + "AllowKillBeforeOperation", + "HasKillBeforeOperation", + "AllowKillBeforeOperation", + ), + ( + "AllowUninstallPrevious", + "HasUninstallPrevious", + "AllowUninstallPrevious", + ), + ]; + + fn obj_with(pairs: &[(&str, serde_json::Value)]) -> serde_json::Value { + let mut map = serde_json::Map::new(); + for (key, value) in pairs { + map.insert((*key).to_owned(), value.clone()); + } + serde_json::Value::Object(map) + } + + fn is_warned_for(result: &PolicyValidationResult, option: &str) -> bool { + result.findings.iter().any(|f| { + f.code == PolicyFindingCode::SensitiveOptionAllowed + && f.arguments.get("Option") == Some(&serde_json::Value::from(option)) + }) + } + + #[test] + fn sensitive_options_warn_when_match_is_absent_and_constraint_allows() { + for (constraint_field, _match_field, option) in SENSITIVE_OPTIONS { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Constraints": obj_with(&[(constraint_field, json!(true))]) }) + )]); + let result = validate_draft(&draft); + assert!(result.is_valid, "{constraint_field}: {:?}", result.findings); + assert!( + is_warned_for(&result, option), + "{constraint_field} should warn when Match does not restrict it" + ); + } + } + + #[test] + fn sensitive_options_warn_when_match_is_true() { + for (constraint_field, match_field, option) in SENSITIVE_OPTIONS { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ + "Match": obj_with(&[("Managers", json!(["Winget"])), (match_field, json!([true]))]), + "Constraints": obj_with(&[(constraint_field, json!(true))]), + }) + )]); + let result = validate_draft(&draft); + assert!(result.is_valid, "{constraint_field}: {:?}", result.findings); + assert!( + is_warned_for(&result, option), + "{constraint_field} should warn when Match=[true]" + ); + } + } + + #[test] + fn sensitive_options_do_not_warn_when_match_is_false_only() { + for (constraint_field, match_field, option) in SENSITIVE_OPTIONS { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ + "Match": obj_with(&[("Managers", json!(["Winget"])), (match_field, json!([false]))]), + "Constraints": obj_with(&[(constraint_field, json!(true))]), + }) + )]); + let result = validate_draft(&draft); + assert!(result.is_valid, "{constraint_field}: {:?}", result.findings); + assert!( + !is_warned_for(&result, option), + "{constraint_field} must not warn when Match=[false] can never see it true" + ); + } + } + + #[test] + fn sensitive_options_do_not_warn_when_constraint_denies() { + for (constraint_field, _match_field, option) in SENSITIVE_OPTIONS { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Constraints": obj_with(&[(constraint_field, json!(false))]) }) + )]); + let result = validate_draft(&draft); + assert!( + !is_warned_for(&result, option), + "{constraint_field}=false must not warn" + ); + } + } + + #[test] + fn sensitive_options_do_not_warn_when_rule_disabled_or_deny() { + for (constraint_field, _match_field, option) in SENSITIVE_OPTIONS { + let mut draft = minimal_draft(); + draft["Rules"] = json!([ + rule( + "r1", + json!({ "Enabled": false, "Constraints": obj_with(&[(constraint_field, json!(true))]) }) + ), + rule( + "r2", + json!({ "Decision": "Deny", "Constraints": obj_with(&[(constraint_field, json!(true))]) }) + ), + ]); + let result = validate_draft(&draft); + assert!(result.is_valid); + assert!( + !is_warned_for(&result, option), + "{constraint_field} on a disabled or Deny rule must not warn" + ); + } + } + + #[test] + fn unrestricted_custom_parameters_are_warned() { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule("r1", json!({ "Constraints": { "AllowCustomParameters": true } }))]); + let result = validate_draft(&draft); + assert!(result.is_valid); + let finding = result + .findings + .iter() + .find(|f| { + f.code == PolicyFindingCode::SensitiveOptionAllowed + && f.arguments.get("Option") == Some(&serde_json::Value::from("AllowCustomParameters")) + }) + .expect("unrestricted custom parameters must be warned"); + assert!( + !finding.arguments.contains_key("AllowedCustomParameters"), + "an unrestricted rule must not report an allow-list that does not exist" + ); + } + + #[test] + fn restricted_custom_parameters_are_warned_with_restriction_details() { + // A partial allow-list is still worth a human's attention: it must not silently + // suppress the warning, only enrich it with the actual restriction in effect. + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Constraints": { "AllowCustomParameters": true, "AllowedCustomParameters": ["--silent"] } }) + )]); + let result = validate_draft(&draft); + assert!(result.is_valid); + let finding = result + .findings + .iter() + .find(|f| { + f.code == PolicyFindingCode::SensitiveOptionAllowed + && f.arguments.get("Option") == Some(&serde_json::Value::from("AllowCustomParameters")) + }) + .expect("a restricted-but-not-catch-all-denied rule must still be warned"); + assert_eq!( + finding.arguments.get("AllowedCustomParameters"), + Some(&json!(["--silent"])) + ); + } + + #[test] + fn provable_catch_all_deny_suppresses_custom_parameters_warning() { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Constraints": { "AllowCustomParameters": true, "DeniedCustomParameters": ["*"] } }) + )]); + let result = validate_draft(&draft); + assert!(result.is_valid); + assert!( + !is_warned_for(&result, "AllowCustomParameters"), + "a denied_custom_parameters entry of exactly '*' rejects every value, so nothing can get through" + ); + } + + #[test] + fn partial_deny_list_does_not_suppress_custom_parameters_warning() { + // Only an exact `"*"` is a *provable* catch-all; a merely broad-looking pattern + // must not be trusted to suppress the warning. + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ "Constraints": { "AllowCustomParameters": true, "DeniedCustomParameters": ["--force"] } }) + )]); + let result = validate_draft(&draft); + assert!(result.is_valid); + assert!(is_warned_for(&result, "AllowCustomParameters")); + } + + #[test] + fn custom_install_location_restriction_is_included_in_structured_args() { + let mut draft = minimal_draft(); + draft["Rules"] = json!([rule( + "r1", + json!({ + "Constraints": { + "AllowCustomInstallLocation": true, + "AllowedInstallLocationPatterns": ["C:\\Tools\\*"], + } + }) + )]); + let result = validate_draft(&draft); + assert!(result.is_valid); + let finding = result + .findings + .iter() + .find(|f| { + f.code == PolicyFindingCode::SensitiveOptionAllowed + && f.arguments.get("Option") == Some(&serde_json::Value::from("AllowCustomInstallLocation")) + }) + .expect("a restricted custom install location must still be warned"); + assert_eq!( + finding.arguments.get("AllowedInstallLocationPatterns"), + Some(&json!(["C:\\Tools\\*"])) + ); + } + + #[test] + fn invalid_wildcard_pattern_is_rejected() { + // `StringPattern` enforces a 256-character cap at deserialization time, so a + // pattern large enough to blow past the regex engine's compiled-program size + // limit can only be constructed directly (bypassing JSON parsing), exercising + // `check_wildcard_patterns` at the Rust level instead of through `validate_draft`. + let huge_pattern = now_policy::StringPattern("a*".repeat(2_000_000)); + let rule = PolicyRule { + id: now_policy::ResourceId::from("r1"), + enabled: true, + priority: 100, + decision: Decision::Allow, + reason: None, + match_criteria: PolicyMatch { + sources: BTreeSet::from([huge_pattern]), + ..Default::default() + }, + constraints: None, + }; + + let mut findings = Vec::new(); + check_wildcard_patterns(0, &rule, &mut findings); + + assert!( + findings + .iter() + .any(|f| f.code == PolicyFindingCode::InvalidWildcardPattern) + ); + } + + #[test] + fn validity_interval_order_is_enforced() { + let mut draft = minimal_draft(); + draft["Metadata"]["ValidFrom"] = json!("2030-01-01T00:00:00Z"); + draft["Metadata"]["ValidUntil"] = json!("2020-01-01T00:00:00Z"); + let result = validate_draft(&draft); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::InvalidValidityInterval) + ); + } + + #[test] + fn canonical_draft_is_deterministic_for_the_same_input() { + let draft = minimal_draft(); + let first = validate_draft(&draft); + let second = validate_draft(&draft); + assert_eq!( + serde_json::to_value(first.canonical_draft).unwrap(), + serde_json::to_value(second.canonical_draft).unwrap() + ); + } + + #[test] + fn canonical_draft_reflects_input_changes() { + let first = validate_draft(&minimal_draft()); + let mut other = minimal_draft(); + other["Metadata"]["Publisher"] = json!("Someone Else"); + let second = validate_draft(&other); + assert_ne!( + serde_json::to_value(first.canonical_draft).unwrap(), + serde_json::to_value(second.canonical_draft).unwrap() + ); + } + + // ─── validate_committed_policy (item 30) ─────────────────────────────────── + + fn minimal_committed_policy(id: &str, revision: u32) -> now_policy::PolicyDocument { + serde_json::from_value(json!({ + "$schema": now_policy::POLICY_SCHEMA_URI, + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": id, "Publisher": "Test", "Revision": revision, "PublishedAt": chrono::Utc::now() }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [], + })) + .expect("test committed policy is well-formed") + } + + #[test] + fn well_formed_committed_policy_is_valid() { + let policy = minimal_committed_policy("test-policy", 1); + let result = validate_committed_policy(&policy); + assert!(result.is_valid, "{:?}", result.findings); + } + + #[test] + fn committed_policy_with_zero_revision_is_invalid() { + // `PolicyDraftDocument::into_policy_document` itself rejects revision 0 when this + // store commits a document, so an on-disk file claiming revision 0 could only be + // tampering or corruption -- it must never be reactivated as-is. + let policy = minimal_committed_policy("test-policy", 0); + let result = validate_committed_policy(&policy); + assert!(!result.is_valid); + } + + #[test] + fn committed_policy_with_duplicate_rule_ids_is_invalid() { + let mut policy = minimal_committed_policy("test-policy", 1); + let one_rule: PolicyRule = serde_json::from_value(rule("r1", json!({}))).unwrap(); + policy.rules = vec![one_rule.clone(), one_rule]; + let result = validate_committed_policy(&policy); + assert!(!result.is_valid); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::DuplicateRuleId) + ); + } + + #[test] + fn committed_policy_with_only_warnings_is_still_valid() { + // Warnings (audit mode, default-allow, sensitive options) never block + // activation, whether for a submitted draft or an already-committed document: + // `is_valid` reflects only Error-severity findings. + let mut policy = minimal_committed_policy("test-policy", 1); + policy.enforcement.audit_mode = Some(true); + let result = validate_committed_policy(&policy); + assert!(result.is_valid, "{:?}", result.findings); + assert!( + result + .findings + .iter() + .any(|f| f.code == PolicyFindingCode::AuditModeEnabled) + ); + } + + #[test] + fn committed_policy_with_oversized_publisher_is_invalid() { + // Proves the committed-document path shares the exact same bound checks a + // submitted draft is held to (item 30), not just structural JSON parseability. + let mut policy = minimal_committed_policy("test-policy", 1); + policy.metadata.publisher = "x".repeat(129); + let result = validate_committed_policy(&policy); + assert!(!result.is_valid); + } +} diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs new file mode 100644 index 000000000..3aecf6efc --- /dev/null +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -0,0 +1,1846 @@ +//! Windows filesystem primitives backing the policy store. +//! +//! Owns: resolving the default policy directory/path, creating that dedicated directory +//! securely (SYSTEM/Administrators only, established atomically at creation), verifying +//! (never rewriting) a custom-configured directory's existing security -- including both +//! directories' ancestor chains -- observing the exact on-disk state of the policy file +//! as an internal [`DiskFingerprint`] (never itself exposed; see `PolicyStore::token_for` +//! for how it becomes the opaque store token), and the atomic same-directory +//! temp-file-then-rename write with post-write verification. +//! +//! Windows provides no target-identity compare-and-swap primitive for file replacement +//! (`ReplaceFileW`'s write-through mode is not universally supported, and there is no +//! `MoveFileEx`-family option conditioned on the destination's current file id). What is +//! implemented here is the strongest supported approximation: the hosting directory is +//! opened without delete sharing and held for the duration of each observation/write (so +//! it cannot be deleted or replaced mid-operation), restricted to trusted principals so +//! untrusted processes cannot race the temporary file, written through a same-volume +//! atomic rename ([`atomic_replace`], or [`atomic_create`] when the destination must not +//! be overwritten), and verified (security, exact bytes, parse) after the fact. This +//! narrows -- it does not eliminate -- the residual race with a *different*, already +//! SYSTEM/Administrators-trusted writer (including an external editor) acting on the same +//! file at the same time; Windows offers no primitive that closes that specific gap. + +use std::fs::{File, OpenOptions}; +use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, bail, ensure}; +use now_policy::PolicyDocument; +use now_policy_api::{ + API_VERSION_STR, InvalidPolicyDiagnostics, PolicyConfigurationSource, PolicyManagementState, PolicyReadOnlyReason, + PolicyStoreToken, PolicyWriteCapability, +}; +use sha2::{Digest as _, Sha256}; +use win_api_wrappers::str::{U16CStrExt as _, U16CString}; +use windows::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, GetVolumeInformationW, + GetVolumePathNameW, MOVE_FILE_FLAGS, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, READ_CONTROL, +}; + +use crate::policy_security::{self, FileIdentity}; +use crate::policy_store::validation; + +/// Base file name for the policy file (a fixed name inside its dedicated directory). +pub(super) const POLICY_FILE_NAME: &str = "package-broker-policy.json"; + +/// Default dedicated directory hosting the policy file: `%PROGRAMDATA%\Devolutions\PackageBroker`. +/// +/// Deliberately a top-level sibling of `%PROGRAMDATA%\Devolutions\Agent`, not a +/// subdirectory of it: `Agent` is shared with unrelated Agent features and its own +/// ancestor-security check must tolerate whatever grants those features require there, +/// which can never be proven as strict as the dedicated policy directory itself needs +/// its *own* ancestor chain to be (see [`policy_security::verify_directory_ancestor_chain`]). +/// A directory nested under `Agent` would inherit `Agent` as an ancestor and could never +/// honestly advertise [`PolicyWriteCapability::Writable`]. This dedicated root is created +/// and secured by this crate alone (both by the Agent installer at install time and, as +/// a fallback/self-heal, by this function's own caller at runtime), so it never has to +/// depend on -- or touch -- the shared `Agent` directory's ACL at all. +pub(super) fn default_policy_dir() -> PathBuf { + let program_data = std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + program_data.join("Devolutions").join("PackageBroker") +} + +/// Default policy file path inside [`default_policy_dir`]. +pub(super) fn default_policy_path() -> PathBuf { + default_policy_dir().join(POLICY_FILE_NAME) +} + +/// Validate the *shape* of a configured policy path before ever touching disk: it must +/// be an absolute path naming a `.json` (case-insensitive) leaf file, with no `.`/`..` +/// component anywhere and no trailing directory separator. Never applied to the default +/// path, which this crate builds and fully controls itself. +/// +/// This is deliberately independent of any filesystem access (a relative path must never +/// be silently resolved against the process's current directory by some later `open` +/// call) and independent of JSON-vs-other-format content sniffing: the extension alone +/// decides, so a legacy `.yaml`/`.yml` (or extensionless) configured path is rejected +/// up front rather than discovered only when its content fails to parse as JSON. +pub(super) fn validate_configured_path_shape(path: &Path) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("configured policy path must be absolute: {}", path.display())); + } + + let raw = path.as_os_str().to_string_lossy(); + if raw.ends_with('\\') || raw.ends_with('/') { + return Err(format!( + "configured policy path must not end with a path separator: {}", + path.display() + )); + } + + // Detected on the *raw* configured string, not via `path.components()`: per + // `Path::components()`'s own documented normalization, an intermediate `.` segment + // (e.g. `C:\foo\.\bar.json`) is silently normalized away and never surfaces as a + // `Component::CurDir` at all, so a components-based check would never catch it. + for segment in raw.split(['\\', '/']) { + if segment == "." { + return Err(format!( + "configured policy path must not contain a '.' component: {}", + path.display() + )); + } + if segment == ".." { + return Err(format!( + "configured policy path must not contain a '..' component: {}", + path.display() + )); + } + } + + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return Err(format!("configured policy path must name a file: {}", path.display())); + }; + + let has_json_extension = Path::new(file_name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")); + if !has_json_extension { + return Err(format!( + "configured policy path must name a '.json' file (case-insensitive), got '{file_name}'; \ + the package broker no longer supports any other format" + )); + } + + Ok(()) +} + +/// Outcome of the one-time filesystem atomic-replace capability probe, classified into +/// the advisory reason it would map to if unwritable. +type ProbeResult = Result<(), (PolicyReadOnlyReason, String)>; + +/// Caches the one-time, side-effecting filesystem atomic-replace capability probe +/// ([`probe_write_capability`]) so it is not repeated on every re-observation/publication +/// (item 20): only the cheap, side-effect-free security/shape/ancestor verification in +/// [`observe`] happens every time. Invalidated (and re-probed) automatically whenever the +/// verified directory's own identity changes (e.g. it was deleted and recreated, possibly +/// on a different volume), so a stale probe result can never be trusted past the exact +/// directory object it was actually measured against. +pub(super) struct AtomicityProbeCache { + cached: std::sync::Mutex>, +} + +impl AtomicityProbeCache { + pub(super) fn new() -> Self { + Self { + cached: std::sync::Mutex::new(None), + } + } + + /// Returns the cached probe result for `dir`/`dir_identity`, re-probing (and + /// updating the cache) if this is the first call or the directory's identity no + /// longer matches what was last cached. + fn get_or_probe(&self, dir: &Path, dir_identity: FileIdentity) -> ProbeResult { + let mut cached = self.cached.lock().expect("atomicity probe cache lock poisoned"); + + if let Some((cached_identity, result)) = cached.as_ref() + && *cached_identity == dir_identity + { + return result.clone(); + } + + let result = probe_write_capability(dir).map_err(|error| { + let reason = if error.downcast_ref::().is_some() { + PolicyReadOnlyReason::UnsupportedFileSystem + } else { + PolicyReadOnlyReason::InsufficientPermissions + }; + (reason, format!("{error:#}")) + }); + *cached = Some((dir_identity, result.clone())); + result + } +} + +/// Open a directory without following reparse points, sharing read/write but not delete, +/// so the object cannot be renamed or deleted while this handle (and any later handle +/// derived from re-verifying it) is alive. +fn open_directory_no_reparse(path: &Path) -> anyhow::Result { + OpenOptions::new() + .access_mode((FILE_READ_ATTRIBUTES | READ_CONTROL).0) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(path) + .with_context(|| format!("failed to open {}", path.display())) +} + +/// Open `path`, confirm it is a genuine directory (not a reparse point standing in for +/// one), and resolve its final path from the handle. +/// +/// Fails closed on any ambiguity: missing path, wrong object type, or reparse point. +/// +/// This only verifies `path` itself; callers additionally verify the ancestor chain with +/// [`policy_security::verify_directory_ancestor_chain`], so an untrusted principal further +/// up the tree (e.g. on the shared `%ProgramData%\Devolutions\Agent` parent, where the +/// installer grants `LOCAL SERVICE` write access for unrelated Agent features) cannot +/// delete or replace this directory out from under an already-verified identity check. +fn open_and_verify_directory_identity(path: &Path) -> anyhow::Result<(File, PathBuf)> { + let handle = open_directory_no_reparse(path)?; + + let attributes = handle + .metadata() + .with_context(|| format!("failed to query metadata for {}", path.display()))? + .file_attributes(); + + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + bail!( + "{} is a reparse point (symlink/junction); the policy directory must be a real directory", + path.display() + ); + } + if attributes & FILE_ATTRIBUTE_DIRECTORY.0 == 0 { + bail!("{} is not a directory", path.display()); + } + + let final_path = policy_security::final_path_from_handle(&handle) + .with_context(|| format!("failed to resolve {}", path.display()))?; + + Ok((handle, final_path)) +} + +/// Create the dedicated default policy directory (if it does not already exist) with an +/// admin-only ACL established atomically at creation, then verify it. +/// +/// The ACL is passed as explicit `SECURITY_ATTRIBUTES` to `CreateDirectoryW` itself (see +/// [`policy_security::admin_only_security_attributes`]), so there is no window between +/// creation and securing it during which an untrusted principal could race the directory. +/// +/// The broker owns this directory end-to-end, but unlike a naive "create, then chmod" +/// approach, an *existing* directory (e.g. from a previous run) is only ever verified, +/// never rewritten: if it already exists with an insecure ACL (inherited, tampered with, +/// or planted by a race/reparse before this call ever ran), this fails closed instead of +/// silently repairing it, since repairing would extend trust to whatever object happened +/// to already occupy the path. +/// +/// Returns the canonical directory path resolved from the verified handle (item 22) and +/// a digest summarizing the verified ancestor chain (item 20), for folding into +/// [`DiskFingerprint`]. +fn ensure_default_directory_secured(dir: &Path) -> anyhow::Result<(PathBuf, [u8; 32])> { + if let Some(parent) = dir.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create ancestor directories for {}", dir.display()))?; + } + + let security_attributes = policy_security::admin_only_security_attributes(true) + .context("build admin-only security attributes for the policy directory")?; + + if let Err(create_error) = win_api_wrappers::fs::create_directory(dir, Some(&security_attributes)) { + // Whatever the reason `CreateDirectoryW` failed (already exists, or lost a race + // with another process), only a directory actually present at this exact path + // changes the outcome, and even then only via the verification below -- never by + // trusting the create call's failure reason alone. + if !dir.is_dir() { + return Err(create_error).with_context(|| format!("failed to create {}", dir.display())); + } + } + + let (handle, final_path) = open_and_verify_directory_identity(dir)?; + policy_security::verify_policy_directory_security(&handle).context( + "an existing policy directory does not meet the required security bar; \ + it was not created by this call and will not be silently repaired", + )?; + let ancestor_security_digest = policy_security::verify_policy_ancestor_chain(dir, "policy directory")?; + + Ok((final_path, ancestor_security_digest)) +} + +/// Verify (never rewrite) that a custom-configured policy directory already meets the +/// same security bar as the dedicated default directory, including its ancestor chain. +/// +/// Returns the canonical directory path resolved from the verified handle (item 22) and +/// a digest summarizing the verified ancestor chain (item 20), for folding into +/// [`DiskFingerprint`]. +fn verify_custom_directory_secure(dir: &Path) -> anyhow::Result<(PathBuf, [u8; 32])> { + let (handle, final_path) = open_and_verify_directory_identity(dir)?; + policy_security::verify_policy_directory_security(&handle)?; + let ancestor_security_digest = policy_security::verify_policy_ancestor_chain(dir, "policy directory")?; + Ok((final_path, ancestor_security_digest)) +} + +/// Marker error indicating [`probe_write_capability`] failed because the hosting +/// filesystem is not known to support the atomic same-directory replacement semantics +/// `atomic_replace` depends on (as opposed to an ACL/quota/permission problem). +#[derive(Debug)] +struct UnsupportedFilesystem(String); + +impl std::fmt::Display for UnsupportedFilesystem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "filesystem '{}' is not known to support atomic same-directory replacement", + self.0 + ) + } +} + +impl std::error::Error for UnsupportedFilesystem {} + +/// Filesystem names known to support the exact same-directory atomic-replace-by-rename +/// semantics (`MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`) that [`atomic_replace`] +/// relies on. Conservative by design: an unrecognized filesystem is treated as +/// unsupported rather than assumed compatible. +const ATOMIC_REPLACE_CAPABLE_FILESYSTEMS: &[&str] = &["NTFS", "ReFS"]; + +/// Prove (rather than merely assume from ACL/create/delete access alone) that `dir`'s +/// filesystem supports the same-directory atomic-replace-by-rename semantics +/// [`atomic_replace`] depends on. +/// +/// Two layers: a conservative filesystem-name classification (some filesystems and filter +/// drivers accept a rename call but silently fall back to non-atomic/copy-then-delete +/// behavior), followed by a fully nondestructive probe that exercises the exact rename +/// primitive `atomic_replace` uses against disposable, uniquely named temporary files -- +/// never the configured policy file itself. +fn probe_write_capability(dir: &Path) -> anyhow::Result<()> { + let filesystem = volume_filesystem_name(dir).context("query volume filesystem")?; + if !ATOMIC_REPLACE_CAPABLE_FILESYSTEMS + .iter() + .any(|name| name.eq_ignore_ascii_case(&filesystem)) + { + return Err(UnsupportedFilesystem(filesystem).into()); + } + + let probe_id = uuid::Uuid::new_v4(); + let source_path = dir.join(format!(".package-broker-write-probe-{probe_id}-a.tmp")); + let target_path = dir.join(format!(".package-broker-write-probe-{probe_id}-b.tmp")); + + let probe_result = (|| -> anyhow::Result<()> { + std::fs::write(&source_path, b"probe-source").context("create write-capability probe source file")?; + std::fs::write(&target_path, b"probe-target").context("create write-capability probe target file")?; + // Exercise the exact primitive `atomic_replace` depends on, not just create/delete. + move_replace(&source_path, &target_path).context("probe atomic same-directory replacement")?; + let replaced = std::fs::read(&target_path).context("read write-capability probe result")?; + ensure!( + replaced == b"probe-source", + "atomic replacement did not take effect on this filesystem" + ); + Ok(()) + })(); + + // Cleanup is mandatory, not best-effort (item 28): both paths are always attempted + // regardless of the probe's own outcome or each other, and any leftover probe file + // -- other than one that was never actually created (tolerated only as `NotFound`) + // -- itself disqualifies this directory from `Writable`, aggregated into the overall + // result rather than silently logged and ignored. A probe that leaves a stray file + // behind in the configured policy directory is not actually side-effect-free, + // whatever its rename result reported. + let source_cleanup = cleanup_probe_file(&source_path); + let target_cleanup = cleanup_probe_file(&target_path); + + probe_result.and(source_cleanup).and(target_cleanup) +} + +/// Remove a write-capability probe file, tolerating only the file already being absent +/// (the expected outcome for `source_path` after a successful replace, which consumes +/// it). Any other failure (permission denied, sharing violation, ...) means a stray +/// probe file was left behind in the configured policy directory, which must itself +/// disqualify the directory from `Writable` (item 28): the specific OS error is only +/// ever traced, and the returned error is a single sanitized, aggregated message never +/// exposed through the management API. +fn cleanup_probe_file(path: &Path) -> anyhow::Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + tracing::warn!(path = %path.display(), %error, "Failed to remove write-capability probe file"); + bail!("failed to remove a write-capability probe file left behind in the policy directory") + } + } +} + +/// Classify the filesystem hosting `dir` (e.g. `"NTFS"`, `"ReFS"`, `"FAT32"`). +fn volume_filesystem_name(dir: &Path) -> anyhow::Result { + let dir_wide = U16CString::from_os_str(dir.as_os_str()).context("directory path contains an interior NUL")?; + + let mut volume_root = vec![0u16; 512]; + // SAFETY: `dir_wide` is a valid NUL-terminated wide string live for the call, and + // `volume_root` is a live, writable buffer. + unsafe { GetVolumePathNameW(dir_wide.as_pcwstr(), &mut volume_root) }.context("GetVolumePathNameW failed")?; + + let mut filesystem_name = vec![0u16; 261]; + // SAFETY: `volume_root` is a valid, NUL-terminated wide root path as returned by + // `GetVolumePathNameW` above, live for the call; `filesystem_name` is a live, writable + // buffer; every other output parameter is `None`, which the API accepts. + unsafe { + GetVolumeInformationW( + windows::core::PCWSTR(volume_root.as_ptr()), + None, + None, + None, + None, + Some(&mut filesystem_name), + ) + } + .context("GetVolumeInformationW failed")?; + + let nul_at = filesystem_name + .iter() + .position(|&unit| unit == 0) + .unwrap_or(filesystem_name.len()); + Ok(String::from_utf16_lossy(&filesystem_name[..nul_at])) +} + +/// Internal, never-serialized identity of exactly what was observed on disk: resolved +/// target and parent object identity, exact content digest, and security-relevant state +/// (including a summary of the *ancestor chain*, not just the immediate parent; see item +/// 20). Two observations comparing equal here are guaranteed indistinguishable from the +/// store's perspective; anything else (content byte-swap, ACL/security change anywhere +/// from the leaf up through its ancestor chain, the parent directory replaced out from +/// under the leaf, a leaf appearing where it was absent, ...) compares unequal. This is +/// the only signal that drives the opaque store token to rotate (see +/// `PolicyStore::token_for`); the fingerprint itself never leaves the process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum DiskFingerprint { + /// A successfully parsed, security-verified, and semantically-valid policy file. + Active { + parent: FileIdentity, + target: FileIdentity, + content_digest: [u8; 32], + security_digest: [u8; 32], + ancestor_security_digest: [u8; 32], + }, + /// No file at the resolved path. Carries the verified identity of the parent + /// directory (and its ancestor chain's security summary), so a parent replacement + /// (or a differently identified custom path) is still distinguishable even though + /// there is no leaf to identify. `parent`/`ancestor_security_digest` are `None` when + /// even the directory itself could not be verified (its own security/ancestor check + /// failed): still Missing -- there is no leaf to distrust either way -- but `path` + /// (the canonical, or best-effort literal, configured path) still prevents two + /// distinct configured paths in that situation from colliding (mirrors + /// `Invalid::path`; item 15). + Missing { + path: PathBuf, + parent: Option, + ancestor_security_digest: Option<[u8; 32]>, + }, + /// A file exists but could not be trusted or activated: unreadable, failed storage + /// security validation, not valid JSON matching the expected schema, or (structurally + /// valid JSON that is nonetheless) semantically invalid. + /// + /// Every component is independently optional because how far observation got before + /// failing determines what could actually be resolved (e.g. a target that cannot + /// even be opened has no identity or content digest yet). `path` -- the canonical + /// configured path, or the best-effort literal one when it could not be + /// canonicalized at all -- is always present precisely so that two distinct + /// configured paths that both fail identically (e.g. both "parent cannot be opened", + /// with no identity available to distinguish them) never collide (item 15). + Invalid { + path: PathBuf, + parent: Option, + ancestor_security_digest: Option<[u8; 32]>, + target: Option, + content_digest: Option<[u8; 32]>, + security_digest: Option<[u8; 32]>, + /// Stable internal failure reason (never itself exposed by the management API; + /// see `validation::disk_failure_finding`), included so distinct reasons at the + /// exact same path/identity still rotate the token (e.g. a file that was + /// insecurely-stored becomes merely malformed after its ACL is fixed). + reason: validation::DiskFailureReason, + }, +} + +#[cfg(test)] +impl DiskFingerprint { + /// Build a synthetic fingerprint for the in-memory `FakePolicyStorage` test double, + /// which has no real Windows file handles to derive identity from. + /// + /// `target_generation` and `parent_generation` stand in for [`FileIdentity`]: bump + /// either to simulate the corresponding real-world object being deleted and recreated + /// (even with byte-identical content), and `acl_generation` to simulate a + /// security-descriptor change with no content change (folded into both the target's + /// own security digest and the ancestor-chain summary, since the fake models "some + /// security-relevant state changed" as a single dimension rather than distinguishing + /// which level of the tree). + pub(super) fn test_active( + content: &[u8], + target_generation: u32, + parent_generation: u32, + acl_generation: u32, + ) -> Self { + Self::Active { + parent: test_identity(parent_generation), + target: test_identity(target_generation), + content_digest: sha256_digest(content), + security_digest: sha256_digest(&acl_generation.to_le_bytes()), + ancestor_security_digest: sha256_digest(&acl_generation.to_le_bytes()), + } + } + + pub(super) fn test_missing(parent_generation: u32) -> Self { + Self::Missing { + path: PathBuf::from(r"C:\fake\package-broker-policy.json"), + parent: Some(test_identity(parent_generation)), + ancestor_security_digest: Some(sha256_digest(b"test-ancestor-security")), + } + } + + pub(super) fn test_invalid(content: &[u8], target_generation: u32) -> Self { + Self::Invalid { + path: PathBuf::from(r"C:\fake\package-broker-policy.json"), + parent: Some(test_identity(0)), + ancestor_security_digest: Some(sha256_digest(b"test-ancestor-security")), + target: Some(test_identity(target_generation)), + content_digest: Some(sha256_digest(content)), + security_digest: Some(sha256_digest(b"test-security")), + reason: validation::DiskFailureReason::MalformedContent, + } + } +} + +#[cfg(test)] +fn test_identity(generation: u32) -> FileIdentity { + let mut file_id = [0u8; 16]; + file_id[..4].copy_from_slice(&generation.to_le_bytes()); + FileIdentity { + volume_serial: 0, + file_id, + } +} + +fn sha256_digest(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher.finalize().into() +} + +/// Exact observed state of the policy file on disk, together with the write capability +/// resolved *as part of the same observation* (item 20/26): capability is never derived +/// from a separately cached snapshot, so it can never silently drift from the state it +/// describes. A malformed-but-securely-stored file (capability follows the directory's +/// own resolved capability, allowing Repair) is distinguished from an insecure/unreadable +/// target (capability is forced to `ReadOnly`/`UnsafePath` regardless of the directory's +/// own capability, and Repair therefore fails): see item 26. +pub(super) struct DiskObservation { + pub state: PolicyManagementState, + pub policy: Option, + pub invalid_diagnostics: Option, + pub fingerprint: DiskFingerprint, + pub write_capability: PolicyWriteCapability, + pub read_only_reason: Option, + /// Canonical resolved path (parent resolved from a verified handle, joined with the + /// exact configured `.json` leaf name; see item 22), or the best-effort literal + /// configured path when it could not be canonicalized at all (an unsupported shape, + /// or a directory/ancestor chain that failed verification before any handle could be + /// resolved). `PolicyStore` stores/displays/uses only this value from here on -- + /// never re-deriving it from the original configuration string -- for observation, + /// the watcher, the store token, audit, and writes. + pub canonical_path: PathBuf, +} + +/// Context accumulated while observation fails partway through, for building the most +/// complete [`DiskFingerprint::Invalid`] the failure allows (item 15): every field is +/// optional because how far observation got before failing determines what could +/// actually be resolved (e.g. a directory that cannot even be opened has no parent +/// identity to report). +#[derive(Default)] +struct InvalidContext { + parent: Option, + ancestor_security_digest: Option<[u8; 32]>, + target: Option, + content_digest: Option<[u8; 32]>, + security_digest: Option<[u8; 32]>, +} + +/// Observe the exact current disk state of the configured policy file. +/// +/// Resolves (and, for the default path, idempotently creates) the canonical directory +/// and re-verifies its shape/security/ancestor chain and write capability on every call +/// (item 20): only the one-time, side-effecting filesystem atomic-replace probe is +/// cached (`probe_cache`; see [`AtomicityProbeCache`]), never the cheap security checks. +/// +/// The hosting directory is opened without delete sharing and held open for the whole +/// observation: both to fold its identity into the fingerprint (detecting the directory +/// itself being replaced) and so it cannot be deleted or renamed out from under the +/// target file while it is being examined. The leaf file is opened without following +/// reparse points, and its own handle-resolved final path must match the canonical +/// directory and expected leaf name, case-insensitively (item 22): a reparse point or +/// hard-link alias standing in for the configured file is never trusted, whatever its +/// content, but a leaf whose on-disk casing merely differs from the configured path +/// (Windows filesystems are case-insensitive but case-preserving) is accepted as the same +/// file. Security +/// is verified on the target's open handle before any content is trusted, and content is +/// read from that same handle, so the verified security descriptor always belongs to the +/// exact bytes subsequently parsed (no TOCTOU window via file replacement). A +/// structurally valid document is additionally, authoritatively revalidated the same +/// deterministic way a submitted draft is (item 30): a committed file is never activated +/// on structural parseability alone. +/// +/// A configured path whose shape/extension is unsupported (item 18/22) -- relative, +/// empty/non-file leaf, trailing separator, `.`/`..` component, or an extension other +/// than `.json` -- is reported with the shared contract's dedicated +/// [`PolicyReadOnlyReason::UnsupportedFormat`]. +pub(super) fn observe( + source: PolicyConfigurationSource, + configured_path: &Path, + probe_cache: &AtomicityProbeCache, +) -> DiskObservation { + if let Err(diagnostic) = validate_configured_path_shape(configured_path) { + tracing::warn!( + path = %configured_path.display(), reason = %diagnostic, + "Configured policy path has an unsupported shape or extension" + ); + return invalid_observation( + configured_path, + validation::DiskFailureReason::UnsupportedFormat, + InvalidContext::default(), + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsupportedFormat), + ); + } + + let dir = configured_path.parent().unwrap_or_else(|| Path::new(".")); + let leaf_name = configured_path + .file_name() + .expect("shape validation already required a named leaf file"); + + let secured = match source { + PolicyConfigurationSource::DefaultPath => ensure_default_directory_secured(dir), + PolicyConfigurationSource::ConfiguredPath => verify_custom_directory_secure(dir), + }; + + let (canonical_dir, ancestor_security_digest) = match secured { + Ok(resolved) => resolved, + Err(error) => { + tracing::warn!( + path = %dir.display(), error = %format!("{error:#}"), + "Configured policy directory failed security verification" + ); + let (write_capability, read_only_reason) = match source { + PolicyConfigurationSource::DefaultPath => ( + PolicyWriteCapability::Unsupported, + PolicyReadOnlyReason::InsufficientPermissions, + ), + PolicyConfigurationSource::ConfiguredPath => { + (PolicyWriteCapability::ReadOnly, PolicyReadOnlyReason::UnsafePath) + } + }; + // An insecure/unverifiable directory must never be trusted to host a policy + // -- but that alone does not mean there *is* a policy to distrust. If no + // leaf exists there at all, the correct state is Missing (nothing to + // activate or reject), not Invalid (which implies some untrusted content is + // actually present); capability is ReadOnly/Unsupported either way, since + // Create/Repair both still require a directory that passes verification. + // This is a best-effort existence probe only (on the literal configured + // path, since the directory itself could not be canonically verified): it + // never trusts, reads, or reports the leaf's content. + return match std::fs::metadata(configured_path) { + Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => DiskObservation { + state: PolicyManagementState::Missing, + policy: None, + invalid_diagnostics: None, + fingerprint: DiskFingerprint::Missing { + path: configured_path.to_owned(), + parent: None, + ancestor_security_digest: None, + }, + write_capability, + read_only_reason: Some(read_only_reason), + canonical_path: configured_path.to_owned(), + }, + _ => invalid_observation( + configured_path, + validation::DiskFailureReason::Unreadable, + InvalidContext::default(), + write_capability, + Some(read_only_reason), + ), + }; + } + }; + let canonical_path = canonical_dir.join(leaf_name); + + // Held open for the entire observation (see the doc comment above); dropped when this + // function returns. + let dir_handle = match open_directory_no_reparse(&canonical_dir) { + Ok(handle) => handle, + Err(error) => { + tracing::warn!(path = %canonical_dir.display(), %error, "Failed to open the configured policy directory"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + InvalidContext::default(), + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let parent = match policy_security::file_identity(&dir_handle) { + Ok(identity) => identity, + Err(error) => { + tracing::warn!( + path = %canonical_dir.display(), %error, + "Failed to query the configured policy directory identity" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + InvalidContext::default(), + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + + // The one-time, side-effecting atomic-replace capability probe (item 20): cached per + // verified directory identity, never repeated on every observation. + let (base_write_capability, base_read_only_reason) = match probe_cache.get_or_probe(&canonical_dir, parent) { + Ok(()) => (PolicyWriteCapability::Writable, None), + Err((reason, diagnostic)) => { + tracing::warn!( + path = %canonical_dir.display(), %diagnostic, + "Policy directory is not writable through the management API" + ); + (PolicyWriteCapability::ReadOnly, Some(reason)) + } + }; + + let invalid_ctx = InvalidContext { + parent: Some(parent), + ancestor_security_digest: Some(ancestor_security_digest), + ..Default::default() + }; + + let file = match OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(&canonical_path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return DiskObservation { + state: PolicyManagementState::Missing, + policy: None, + invalid_diagnostics: None, + fingerprint: DiskFingerprint::Missing { + path: canonical_path.clone(), + parent: Some(parent), + ancestor_security_digest: Some(ancestor_security_digest), + }, + write_capability: base_write_capability, + read_only_reason: base_read_only_reason, + canonical_path, + }; + } + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to open the configured policy file"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + + let attributes = match file.metadata() { + Ok(metadata) => metadata.file_attributes(), + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to query the configured policy file metadata"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + if attributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 { + tracing::warn!( + path = %canonical_path.display(), + "Configured policy file is a reparse point (symlink); refusing to trust a retargeted file" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + if attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 { + tracing::warn!(path = %canonical_path.display(), "Configured policy path resolved to a directory, not a file"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + // Reject a hard-link alias (item 22): the handle's own resolved final path must + // match the canonical directory and expected leaf name, so a name that happens to + // appear inside the verified directory but is actually a link to a different, + // untrusted object elsewhere is never trusted. The comparison is case-insensitive + // (same as the parent-directory comparison above it): Windows filesystems are + // case-insensitive but case-preserving, so the on-disk leaf may legitimately differ in + // case from the operator's configured path without being a different object at all. + match policy_security::final_path_from_handle(&file) { + Ok(resolved) => { + let resolved_matches = policy_security::paths_match_case_insensitive(&resolved, &canonical_path); + if !resolved_matches { + tracing::warn!( + path = %canonical_path.display(), resolved = %resolved.display(), + "Configured policy file resolved to an unexpected location; refusing to trust a hard-link alias" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + Err(error) => { + tracing::warn!( + path = %canonical_path.display(), %error, + "Failed to resolve the configured policy file's final path" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + + let target = match policy_security::file_identity(&file) { + Ok(identity) => identity, + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to query the configured policy file identity"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let invalid_ctx = InvalidContext { + target: Some(target), + ..invalid_ctx + }; + + if let Err(security_error) = policy_security::verify_policy_file_security(&file) { + // Fail closed without ever reading content past a failed security check, exactly + // like the legacy loader: an insecurely-stored file is never trusted, whatever it + // contains. Forced ReadOnly regardless of the directory's own writable capability + // (item 26): an untrustworthy existing file must never be blindly overwritten + // through the management API either. The detailed reason is only ever traced, + // never exposed through the management API (see `validation::disk_failure_finding`). + tracing::warn!( + path = %canonical_path.display(), + error = %format!("{security_error:#}"), + "Configured policy file failed storage security validation" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + let security_digest = match policy_security::security_state_digest(&file) { + Ok(digest) => digest, + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to compute the configured policy file's security digest"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let invalid_ctx = InvalidContext { + security_digest: Some(security_digest), + ..invalid_ctx + }; + + let mut content = Vec::new(); + { + use std::io::Read as _; + if let Err(read_error) = (&file).read_to_end(&mut content) { + tracing::warn!(path = %canonical_path.display(), %read_error, "Failed to read the configured policy file"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + } + + // The file itself is securely stored (whatever its content turns out to be): a + // malformed/semantically-invalid document past this point still allows Repair + // through the directory's own (already resolved) capability -- item 26. + observation_from_parts( + &canonical_path, + &content, + VerifiedIdentity { + parent, + ancestor_security_digest, + target, + security_digest, + }, + base_write_capability, + base_read_only_reason, + ) +} + +/// Verified identity/security components already resolved for the current observation, +/// grouped so [`observation_from_parts`] does not need one parameter per field. +struct VerifiedIdentity { + parent: FileIdentity, + ancestor_security_digest: [u8; 32], + target: FileIdentity, + security_digest: [u8; 32], +} + +/// Parse already-obtained (already security-verified) policy file bytes into a +/// [`DiskObservation`], given the fingerprint's already-resolved identity components and +/// the directory's already-resolved write capability. +/// +/// A structurally valid [`PolicyDocument`] is additionally, authoritatively revalidated +/// the same deterministic way a submitted draft is (item 30: see +/// [`validation::validate_committed_policy`]): a committed file is never activated on +/// structural parseability alone. Warnings alone (audit mode, default-allow, sensitive +/// options) do not block activation. +fn observation_from_parts( + path: &Path, + content: &[u8], + identity: VerifiedIdentity, + write_capability: PolicyWriteCapability, + read_only_reason: Option, +) -> DiskObservation { + let VerifiedIdentity { + parent, + ancestor_security_digest, + target, + security_digest, + } = identity; + + let content_digest = sha256_digest(content); + + let invalid_with = |reason: validation::DiskFailureReason| DiskObservation { + state: PolicyManagementState::Invalid, + policy: None, + invalid_diagnostics: Some(InvalidPolicyDiagnostics { + diagnostics_version: API_VERSION_STR.into(), + findings: vec![validation::disk_failure_finding(reason)], + }), + fingerprint: DiskFingerprint::Invalid { + path: path.to_owned(), + parent: Some(parent), + ancestor_security_digest: Some(ancestor_security_digest), + target: Some(target), + content_digest: Some(content_digest), + security_digest: Some(security_digest), + reason, + }, + write_capability, + read_only_reason, + canonical_path: path.to_owned(), + }; + + let policy = match serde_json::from_slice::(content) { + Ok(policy) => policy, + Err(parse_error) => { + // Detailed parse error only ever traced, never exposed through the management + // API: it is heuristically derived from attacker/corruption-controlled bytes + // and could otherwise leak content fragments to any authenticated (but not + // necessarily elevated) caller of `GET /v1/policy/management`. + tracing::warn!(%parse_error, "Configured policy file content failed to parse"); + return invalid_with(validation::DiskFailureReason::MalformedContent); + } + }; + + let committed_validation = validation::validate_committed_policy(&policy); + if !committed_validation.is_valid { + // Specific findings only ever traced, for the same reason raw parse errors are + // not exposed: they are derived from the committed file's own content, which + // `GET /v1/policy/management` exposes to any authenticated (but not necessarily + // elevated/Administrator, and not necessarily the file's author) caller. + tracing::warn!( + findings = ?committed_validation.findings, + "Configured policy file failed authoritative semantic validation" + ); + return invalid_with(validation::DiskFailureReason::FailedSemanticValidation); + } + + DiskObservation { + state: PolicyManagementState::Active, + policy: Some(policy), + invalid_diagnostics: None, + fingerprint: DiskFingerprint::Active { + parent, + target, + content_digest, + security_digest, + ancestor_security_digest, + }, + write_capability, + read_only_reason, + canonical_path: path.to_owned(), + } +} + +/// Build a generic, sanitized [`DiskObservation`] for a storage-level failure (shape, +/// I/O, or security) that prevented the configured policy file from even being read as +/// JSON. Never includes raw OS/security error text: see [`validation::disk_failure_finding`]. +fn invalid_observation( + path: &Path, + reason: validation::DiskFailureReason, + context: InvalidContext, + write_capability: PolicyWriteCapability, + read_only_reason: Option, +) -> DiskObservation { + DiskObservation { + state: PolicyManagementState::Invalid, + policy: None, + invalid_diagnostics: Some(InvalidPolicyDiagnostics { + diagnostics_version: API_VERSION_STR.into(), + findings: vec![validation::disk_failure_finding(reason)], + }), + fingerprint: DiskFingerprint::Invalid { + path: path.to_owned(), + parent: context.parent, + ancestor_security_digest: context.ancestor_security_digest, + target: context.target, + content_digest: context.content_digest, + security_digest: context.security_digest, + reason, + }, + write_capability, + read_only_reason, + canonical_path: path.to_owned(), + } +} + +/// Mint a fresh, process-random, opaque token conforming to `PolicyStoreToken`'s own +/// safe-ASCII/length contract. Tokens never encode or derive from disk content/identity: +/// [`PolicyStore::token_for`](super::PolicyStore) is the only place a token is ever +/// produced, and it only ever calls this when the observed [`DiskFingerprint`] changed. +pub(super) fn random_store_token() -> PolicyStoreToken { + uuid::Uuid::new_v4().hyphenated().to_string().into() +} + +/// Result of a successful atomic write. +pub(super) struct PersistedPolicy { + pub policy: PolicyDocument, + pub fingerprint: DiskFingerprint, +} + +/// A write failure, distinguishing whether the atomic rename that publishes new content +/// had already happened when the failure occurred (item 27). Windows offers no +/// transactional rollback across that rename: once it succeeds, the new bytes are live, +/// so a failure discovered only afterward (post-write reopen/identity/security/parse +/// verification) is a fundamentally different situation from one discovered before it +/// (temporary file creation/write/flush, or the rename call itself failing) -- the +/// caller must never assume the previously active policy is still what is being served +/// just because *a* later step failed. +pub(super) enum WriteFailure { + /// Failed before the rename: disk state is provably unchanged, so the previously + /// active/invalid/missing policy (if any) is still exactly what it was. Maps to + /// `ErrorCode::PolicyPersistenceFailed`. + PrePublication(anyhow::Error), + /// Failed after the rename made the new content live: the caller must synchronously + /// reobserve disk under the same write lock and publish whatever that reveals rather + /// than trusting the previous in-memory snapshot. Maps to + /// `ErrorCode::PolicyActivationFailed`. + PostPublication(anyhow::Error), +} + +/// Atomically persist `bytes` (the canonical serialization of the new active policy) to +/// `final_path`, replacing whatever is currently there, then reopen and verify it. +/// +/// Used for every replacement operation except `Create` (see [`atomic_create`]): the +/// store already observed an Active or Invalid policy at `final_path` under its write +/// lock immediately before calling this, so an existing destination is expected and +/// intentionally replaced. +pub(super) fn atomic_replace(dir: &Path, final_path: &Path, bytes: &[u8]) -> Result { + write_temp_then(dir, bytes, |temp_path| move_replace(temp_path, final_path)) + .map_err(WriteFailure::PrePublication)?; + reopen_and_verify_persisted(dir, final_path, bytes).map_err(WriteFailure::PostPublication) +} + +/// Atomically persist `bytes` to `final_path` only if nothing exists there yet: unlike +/// [`atomic_replace`], this never overwrites an existing destination. +/// +/// Used for `Create`, where the store already observed Missing under its write lock. If a +/// leaf has raced into existence between that observation and this call, the rename fails +/// (a [`WriteFailure::PrePublication`], since the destination was never touched) and the +/// caller must re-observe and report a stale token (see `PolicyStore::replace`) rather +/// than ever silently overwriting a file it never actually observed as absent. +pub(super) fn atomic_create(dir: &Path, final_path: &Path, bytes: &[u8]) -> Result { + write_temp_then(dir, bytes, |temp_path| move_create_new(temp_path, final_path)) + .map_err(WriteFailure::PrePublication)?; + reopen_and_verify_persisted(dir, final_path, bytes).map_err(WriteFailure::PostPublication) +} + +/// Create a uniquely named temporary file in `dir` with an admin-only ACL established at +/// creation (`CreateFileW` with explicit `SECURITY_ATTRIBUTES`, never a create-then-ACL +/// window; see [`policy_security::admin_only_security_attributes`]), verify that security +/// on the just-opened handle before writing anything to it, write and flush `bytes`, then +/// hand the temporary path to `commit` to make it visible at its final location (a +/// same-directory, same-volume rename, so it is atomic). The temporary file is never +/// visible to untrusted principals even before that rename: its ACL is explicit from +/// creation, not inherited. +fn write_temp_then(dir: &Path, bytes: &[u8], commit: impl FnOnce(&Path) -> anyhow::Result<()>) -> anyhow::Result<()> { + let temp_path = dir.join(format!(".{POLICY_FILE_NAME}.tmp-{}", uuid::Uuid::new_v4())); + + let result = (|| -> anyhow::Result<()> { + use std::io::Write as _; + + let security_attributes = policy_security::admin_only_security_attributes(false) + .context("build admin-only security attributes for the temporary policy file")?; + let mut temp_file = win_api_wrappers::fs::create_file(&temp_path, Some(&security_attributes)) + .with_context(|| format!("failed to create temporary policy file {}", temp_path.display()))?; + + // Verify identity/security on the just-created handle before writing anything to + // it: never trust that the requested SECURITY_ATTRIBUTES actually took effect + // without independently re-checking it, the same way every other trusted read in + // this crate does. + policy_security::verify_policy_file_security(&temp_file) + .context("temporary policy file failed security verification immediately after creation")?; + + temp_file + .write_all(bytes) + .context("failed to write temporary policy file")?; + // Strongest supported durability: flush both data and metadata before the rename. + temp_file + .sync_all() + .context("failed to flush temporary policy file to disk")?; + drop(temp_file); + + commit(&temp_path) + })(); + + if result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + result +} + +/// Reopen `final_path` after a successful atomic write and re-verify its identity, +/// security, ancestor chain, exact bytes, and parse, so the returned [`PersistedPolicy`] +/// always reflects the exact bytes actually active on disk rather than trusting the +/// write path alone. Also authoritatively (re)validates the persisted document the same +/// deterministic way a submitted draft is (item 30), even though the store already +/// validated the draft moments earlier: this is the single path every committed document +/// is trusted through, whether freshly written or later re-observed. +/// +/// This narrows -- it does not eliminate -- the residual race with a *different*, already +/// SYSTEM/Administrators-trusted writer (including an external editor) concurrently +/// replacing the same file between the rename and this reopen: Windows provides no +/// identity-conditioned replace primitive, so re-verifying the strongest available +/// handle/content evidence immediately afterward is the strongest supported +/// approximation, not a complete fix. +fn reopen_and_verify_persisted( + dir: &Path, + final_path: &Path, + expected_bytes: &[u8], +) -> anyhow::Result { + let dir_handle = + open_directory_no_reparse(dir).context("failed to reopen policy directory for post-write verification")?; + let parent = policy_security::file_identity(&dir_handle) + .context("failed to query policy directory identity for post-write verification")?; + let ancestor_security_digest = policy_security::verify_policy_ancestor_chain(dir, "policy directory") + .context("policy directory ancestor chain failed verification immediately after writing")?; + + let final_file = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(final_path) + .context("failed to reopen policy file for post-write verification")?; + + let target = policy_security::file_identity(&final_file) + .context("failed to query policy file identity for post-write verification")?; + + policy_security::verify_policy_file_security(&final_file) + .context("policy file failed security verification immediately after being written")?; + + let security_digest = policy_security::security_state_digest(&final_file) + .context("failed to compute policy file security digest immediately after being written")?; + + let mut persisted = Vec::new(); + { + use std::io::Read as _; + (&final_file) + .read_to_end(&mut persisted) + .context("failed to re-read persisted policy file")?; + } + + if persisted != expected_bytes { + bail!("persisted policy file content does not match what was written"); + } + + let policy = serde_json::from_slice::(&persisted) + .context("failed to reparse the freshly persisted policy file")?; + + let committed_validation = validation::validate_committed_policy(&policy); + ensure!( + committed_validation.is_valid, + "freshly persisted policy file failed authoritative semantic validation: {:?}", + committed_validation.findings + ); + + let content_digest = sha256_digest(&persisted); + + Ok(PersistedPolicy { + policy, + fingerprint: DiskFingerprint::Active { + parent, + target, + content_digest, + security_digest, + ancestor_security_digest, + }, + }) +} + +fn move_file(from: &Path, to: &Path, flags: MOVE_FILE_FLAGS) -> anyhow::Result<()> { + let from = U16CString::from_os_str(from.as_os_str()).context("temporary path contains an interior NUL")?; + let to = U16CString::from_os_str(to.as_os_str()).context("final path contains an interior NUL")?; + + // SAFETY: `from` and `to` are valid, NUL-terminated UTF-16 strings live for the call. + unsafe { MoveFileExW(from.as_pcwstr(), to.as_pcwstr(), flags) }.context("MoveFileExW failed")?; + + Ok(()) +} + +/// Atomically rename `from` onto `to`, replacing `to` if it already exists. +fn move_replace(from: &Path, to: &Path) -> anyhow::Result<()> { + move_file(from, to, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) + .context("failed to atomically replace policy file") +} + +/// Atomically rename `from` onto `to`, failing if `to` already exists (no +/// `MOVEFILE_REPLACE_EXISTING`): this is the primitive [`atomic_create`] relies on to +/// never silently overwrite a destination it never observed. +fn move_create_new(from: &Path, to: &Path) -> anyhow::Result<()> { + move_file(from, to, MOVEFILE_WRITE_THROUGH).context("failed to atomically create policy file") +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + fn temp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("create temp dir") + } + + // ─── move_replace / move_create_new: the real Windows race primitives ───── + // + // These exercise the actual `MoveFileExW` calls `atomic_replace`/`atomic_create` rely + // on directly, without needing the admin-only `SECURITY_ATTRIBUTES` machinery (which + // requires an elevated/SYSTEM token to assign SYSTEM as owner and so cannot run in an + // arbitrary, non-elevated developer/CI shell): real temporary files owned by whatever + // account runs the test are enough to prove the rename semantics themselves. + + #[test] + fn move_create_new_never_replaces_an_existing_destination() { + let dir = temp_dir(); + let source = dir.path().join("source.tmp"); + let destination = dir.path().join("destination.json"); + + std::fs::write(&destination, b"original").unwrap(); + std::fs::write(&source, b"attempted-overwrite").unwrap(); + + let error = move_create_new(&source, &destination).unwrap_err(); + assert!(!format!("{error:#}").is_empty()); + + // Neither file was touched: the failed rename must be a complete no-op. + assert_eq!(std::fs::read(&destination).unwrap(), b"original"); + assert_eq!(std::fs::read(&source).unwrap(), b"attempted-overwrite"); + } + + #[test] + fn move_create_new_succeeds_against_a_missing_destination() { + let dir = temp_dir(); + let source = dir.path().join("source.tmp"); + let destination = dir.path().join("destination.json"); + + std::fs::write(&source, b"content").unwrap(); + move_create_new(&source, &destination).expect("create-new rename against a missing destination succeeds"); + + assert!(!source.exists(), "the source is consumed by a successful rename"); + assert_eq!(std::fs::read(&destination).unwrap(), b"content"); + } + + #[test] + fn move_replace_overwrites_an_existing_destination() { + let dir = temp_dir(); + let source = dir.path().join("source.tmp"); + let destination = dir.path().join("destination.json"); + + std::fs::write(&destination, b"original").unwrap(); + std::fs::write(&source, b"replacement").unwrap(); + + move_replace(&source, &destination).expect("replace rename succeeds against an existing destination"); + + assert!(!source.exists()); + assert_eq!(std::fs::read(&destination).unwrap(), b"replacement"); + } + + // ─── probe_write_capability / volume_filesystem_name ────────────────────── + // + // No elevation required: these never touch `admin_only_security_attributes`. + + #[test] + fn volume_filesystem_name_reports_a_known_filesystem_for_a_temp_directory() { + let dir = temp_dir(); + let filesystem = volume_filesystem_name(dir.path()).expect("query temp directory filesystem"); + assert!(!filesystem.is_empty()); + } + + #[test] + fn probe_write_capability_succeeds_on_an_ordinary_writable_temp_directory() { + let dir = temp_dir(); + probe_write_capability(dir.path()) + .expect("an ordinary user-writable NTFS temp directory must probe as capable"); + + // Nondestructive: the probe must never leave stray files behind. + let leftover: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|entry| entry.ok()) + .collect(); + assert!(leftover.is_empty(), "probe left files behind: {leftover:?}"); + } + + // ─── DiskFingerprint rotation/stability semantics ───────────────────────── + + #[test] + fn active_fingerprint_is_stable_for_identical_inputs() { + let a = DiskFingerprint::test_active(b"same bytes", 1, 1, 1); + let b = DiskFingerprint::test_active(b"same bytes", 1, 1, 1); + assert_eq!(a, b); + } + + #[test] + fn active_fingerprint_rotates_on_same_byte_target_replacement() { + // Same content, but a different target generation (the file object itself was + // replaced, e.g. deleted and recreated with identical bytes). + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 2, 1, 1); + assert_ne!(before, after); + } + + #[test] + fn active_fingerprint_rotates_on_acl_change() { + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 1, 1, 2); + assert_ne!(before, after); + } + + #[test] + fn active_fingerprint_rotates_on_parent_replacement() { + let before = DiskFingerprint::test_active(b"same bytes", 1, 1, 1); + let after = DiskFingerprint::test_active(b"same bytes", 1, 2, 1); + assert_ne!(before, after); + } + + #[test] + fn missing_fingerprints_differ_for_different_parents() { + let a = DiskFingerprint::test_missing(1); + let b = DiskFingerprint::test_missing(2); + assert_ne!(a, b); + } + + #[test] + fn missing_fingerprint_is_stable_for_the_same_parent() { + let a = DiskFingerprint::test_missing(7); + let b = DiskFingerprint::test_missing(7); + assert_eq!(a, b); + } + + // ─── Real, privilege-sensitive Windows behavior ─────────────────────────── + // + // The Agent service runs as LocalSystem in production, so setting a newly created + // object's owner to SYSTEM is unprivileged there; a non-elevated developer/CI shell + // cannot assign an owner it does not itself hold a enabling privilege for. Mirrors the + // existing `winget_app_exec_alias_passes_elevated_verification` pattern: attempt the + // real operation, and require the failure (when one occurs) to be exactly the + // anticipated privilege limitation rather than silently skipping the test. + #[test] + fn default_directory_is_created_secured_or_fails_on_the_expected_privilege_limitation() { + let dir = temp_dir(); + let candidate = dir.path().join("package-broker"); + + match ensure_default_directory_secured(&candidate) { + Ok((canonical, _ancestor_security_digest)) => { + // Elevated/SYSTEM test host: verify the directory really is admin-only and + // that a *second* call (existing-directory path) does not need to (and does + // not) fail. + assert!( + canonical.is_absolute(), + "canonical directory must be an absolute, handle-resolved path" + ); + let (handle, _) = open_and_verify_directory_identity(&candidate).unwrap(); + policy_security::verify_policy_directory_security(&handle) + .expect("freshly created directory must already be admin-only secured"); + drop(handle); + ensure_default_directory_secured(&candidate) + .expect("re-verifying an already-secured directory succeeds"); + } + Err(error) => { + let message = format!("{error:#}"); + assert!( + message.contains("owner") || message.contains("privilege") || message.contains("Owner"), + "unexpected error creating the default directory: {message}" + ); + } + } + } + + // ─── validate_configured_path_shape (item 18/22) ────────────────────────── + + #[test] + fn relative_path_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"relative\policy.json")).unwrap_err(); + assert!(error.contains("absolute"), "{error}"); + } + + #[test] + fn trailing_separator_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\")).unwrap_err(); + assert!(error.contains("separator"), "{error}"); + } + + #[test] + fn dot_component_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\.\policy.json")).unwrap_err(); + assert!(error.contains("'.'"), "{error}"); + } + + #[test] + fn dotdot_component_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\..\policy.json")).unwrap_err(); + assert!(error.contains("'..'"), "{error}"); + } + + #[test] + fn yaml_extension_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.yaml")).unwrap_err(); + assert!(error.contains(".json"), "{error}"); + } + + #[test] + fn yml_extension_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.yml")).unwrap_err(); + assert!(error.contains(".json"), "{error}"); + } + + #[test] + fn extensionless_path_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy")).unwrap_err(); + assert!(error.contains(".json"), "{error}"); + } + + #[test] + fn other_extension_is_rejected() { + let error = validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.txt")).unwrap_err(); + assert!(error.contains(".json"), "{error}"); + } + + #[test] + fn uppercase_json_extension_is_accepted() { + validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\policy.JSON")) + .expect("extension check is case-insensitive"); + } + + #[test] + fn well_formed_absolute_json_path_is_accepted() { + validate_configured_path_shape(Path::new(r"C:\ProgramData\Devolutions\PackageBroker\policy.json")) + .expect("well-formed absolute .json path must be accepted"); + } + + /// End-to-end (item 18/31): a configured path with an unsupported extension must be + /// reported through the *real* `observe` with the shared contract's dedicated + /// [`PolicyReadOnlyReason::UnsupportedFormat`], and the file must never even be + /// opened, whatever it (if anything) actually contains at that path. + fn assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end(file_name: &str) { + let dir = temp_dir(); + let path = dir.path().join(file_name); + // If shape validation were ever skipped, this well-formed JSON content would + // make the file parse as Active; its presence proves the rejection is really + // about the extension, not a coincidentally-unreadable/absent file. + std::fs::write(&path, br#"{"not": "even close to a policy, but that's not the point"}"#).unwrap(); + + let probe_cache = AtomicityProbeCache::new(); + let observation = observe(PolicyConfigurationSource::ConfiguredPath, &path, &probe_cache); + + assert_eq!(observation.state, PolicyManagementState::Invalid); + assert_eq!(observation.write_capability, PolicyWriteCapability::ReadOnly); + assert_eq!( + observation.read_only_reason, + Some(PolicyReadOnlyReason::UnsupportedFormat) + ); + assert!(observation.policy.is_none()); + } + + #[test] + fn yaml_extension_is_reported_invalid_and_read_only_end_to_end() { + assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy.yaml"); + } + + #[test] + fn yml_extension_is_reported_invalid_and_read_only_end_to_end() { + assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy.yml"); + } + + #[test] + fn extensionless_path_is_reported_invalid_and_read_only_end_to_end() { + assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy"); + } + + #[test] + fn other_extension_is_reported_invalid_and_read_only_end_to_end() { + assert_unsupported_format_is_reported_invalid_and_read_only_end_to_end("policy.txt"); + } + + // ─── Strict policy ancestor walk: reparse rejection (item 16) ───────────── + // + // Directory junctions (unlike symlinks) require no special privilege to create, so + // this exercises the real reparse-point rejection without needing an elevated shell. + + #[test] + fn junction_standing_in_for_an_ancestor_is_rejected() { + let root = temp_dir(); + let real_ancestor = root.path().join("real-ancestor"); + std::fs::create_dir(&real_ancestor).unwrap(); + let junction = root.path().join("junction-ancestor"); + create_directory_junction(&junction, &real_ancestor); + + let candidate_dir = junction.join("policy-dir"); + std::fs::create_dir(&candidate_dir).unwrap(); + + let error = policy_security::verify_policy_ancestor_chain(&candidate_dir, "policy directory").unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("reparse point"), "unexpected error: {message}"); + } + + /// Create a directory junction (`mklink /J`) without requiring elevation. + fn create_directory_junction(link: &Path, target: &Path) { + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("spawn mklink"); + assert!( + status.success(), + "failed to create junction {} -> {}", + link.display(), + target.display() + ); + } + + // ─── Hard-link alias rejection for the leaf file (item 22) ──────────────── + // + // Hard links (unlike symlinks) require no special privilege to create on the same + // volume, so this exercises the real alias-rejection path directly. + + #[test] + fn hard_link_alias_is_rejected_by_final_path_comparison() { + let dir = temp_dir(); + let real_file = dir.path().join("real-policy.json"); + std::fs::write(&real_file, b"{}").unwrap(); + let alias = dir.path().join("alias-policy.json"); + std::fs::hard_link(&real_file, &alias).expect("create hard link"); + + // Opening the alias name resolves, via its own handle, to a final path this + // process (deliberately) treats as *not* matching the alias name itself: the + // canonical directory/leaf-name comparison in `observe` must reject it. This + // proves the comparison primitive itself: `GetFinalPathNameByHandleW` reports + // one specific link for a multiply-linked file, and it need not be the name used + // to open it. + let handle = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(&alias) + .unwrap(); + let resolved = policy_security::final_path_from_handle(&handle).unwrap(); + + // Whichever of the two equally-valid names Windows reports, it must match + // *exactly one* of them, proving the comparison is meaningful (able to both + // accept a genuine match and reject a genuine mismatch) rather than vacuous. + let matches_real = policy_security::paths_match_case_insensitive(&resolved, &real_file); + let matches_alias = policy_security::paths_match_case_insensitive(&resolved, &alias); + assert!( + matches_real || matches_alias, + "resolved path {} matched neither hard-linked name", + resolved.display() + ); + } + + /// A leaf whose on-disk casing merely differs from the configured path must be + /// accepted as the same file, not rejected as though it were a hard-link alias to a + /// different object (item 22): Windows filesystems are case-insensitive but + /// case-preserving, so `GetFinalPathNameByHandleW` reports whatever casing was used + /// when the file was actually created on disk, which need not match the casing an + /// operator later configures. This exercises the exact comparison `observe` performs + /// (`paths_match_case_insensitive` over the full resolved path vs. the canonical + /// directory joined with the configured leaf name), directly proving the fix for a + /// prior exact (case-sensitive) `OsStr` leaf-name comparison that would have + /// wrongly rejected this legitimate case. + #[test] + fn leaf_casing_difference_from_configured_name_is_accepted_by_final_path_comparison() { + let dir = temp_dir(); + // Create the file on disk with one casing... + let on_disk_path = dir.path().join("Policy-Casing.json"); + std::fs::write(&on_disk_path, b"{}").unwrap(); + + // ...but open it (as `observe` does) through a *different* casing of the same + // leaf name, as would happen if the operator configures the path with different + // casing than the file was originally created with. + let configured_path = dir.path().join("policy-casing.json"); + let handle = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(&configured_path) + .unwrap(); + let resolved = policy_security::final_path_from_handle(&handle).unwrap(); + + // The comparison `observe` performs against the canonical directory joined with + // the *configured* leaf name must accept this: it is the same file, differing + // only in casing, never a different, untrusted object. + assert!( + policy_security::paths_match_case_insensitive(&resolved, &configured_path), + "resolved path {} must match the differently-cased configured path {} \ + case-insensitively; a mere casing difference must never be treated as a \ + hard-link alias", + resolved.display(), + configured_path.display() + ); + + // The genuine alias-rejection case (a real hard link to a differently *named* + // file, not merely differently-cased) must still be rejected by the same + // comparison, proving it is meaningful rather than vacuously permissive. + let unrelated_path = dir.path().join("unrelated-name.json"); + std::fs::hard_link(&on_disk_path, &unrelated_path).expect("create hard link"); + assert!( + !policy_security::paths_match_case_insensitive(&resolved, &unrelated_path), + "resolved path {} must not match an unrelated hard-linked name {}", + resolved.display(), + unrelated_path.display() + ); + } + + // ─── DiskFingerprint::Invalid enrichment (item 15) ──────────────────────── + + fn invalid_fingerprint_for_path(path: &str, reason: validation::DiskFailureReason) -> DiskFingerprint { + DiskFingerprint::Invalid { + path: PathBuf::from(path), + parent: None, + ancestor_security_digest: None, + target: None, + content_digest: None, + security_digest: None, + reason, + } + } + + #[test] + fn invalid_fingerprints_for_distinct_paths_never_collide() { + // Two different configured paths that both fail identically (e.g. neither + // parent could even be opened, so no identity is available to distinguish them) + // must still never be mistaken for each other. + let a = invalid_fingerprint_for_path(r"C:\a\policy.json", validation::DiskFailureReason::Unreadable); + let b = invalid_fingerprint_for_path(r"C:\b\policy.json", validation::DiskFailureReason::Unreadable); + assert_ne!(a, b); + } + + #[test] + fn invalid_fingerprint_is_stable_for_the_same_path_and_reason() { + let a = invalid_fingerprint_for_path(r"C:\a\policy.json", validation::DiskFailureReason::Unreadable); + let b = invalid_fingerprint_for_path(r"C:\a\policy.json", validation::DiskFailureReason::Unreadable); + assert_eq!(a, b); + } + + /// Build a fully-populated `DiskFingerprint::Invalid` for the rotation/stability + /// tests below, so each test only has to vary the one field it is proving rotates + /// (or, for the "unchanged" test, none at all). + fn full_invalid_fingerprint( + path: &str, + parent_generation: u32, + ancestor_marker: &[u8], + target_generation: u32, + content: &[u8], + security_marker: &[u8], + reason: validation::DiskFailureReason, + ) -> DiskFingerprint { + DiskFingerprint::Invalid { + path: PathBuf::from(path), + parent: Some(test_identity(parent_generation)), + ancestor_security_digest: Some(sha256_digest(ancestor_marker)), + target: Some(test_identity(target_generation)), + content_digest: Some(sha256_digest(content)), + security_digest: Some(sha256_digest(security_marker)), + reason, + } + } + + #[test] + fn invalid_fingerprint_rotates_on_parent_replacement() { + let before = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + let after = full_invalid_fingerprint( + r"C:\a\policy.json", + 2, // only the parent generation differs + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + assert_ne!(before, after); + } + + #[test] + fn invalid_fingerprint_rotates_on_same_content_target_replacement() { + // Same path and same byte-for-byte content digest, but a different target + // identity (the invalid file object itself was replaced, e.g. deleted and + // recreated with identical bytes): must still rotate. + let before = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + let after = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 2, // only the target generation differs + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + assert_ne!(before, after); + } + + #[test] + fn invalid_fingerprint_rotates_on_acl_change() { + let before = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security-a", + validation::DiskFailureReason::MalformedContent, + ); + let after = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security-b", // only the security digest marker differs + validation::DiskFailureReason::MalformedContent, + ); + assert_ne!(before, after); + } + + #[test] + fn invalid_fingerprint_is_stable_when_truly_unchanged() { + let a = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + let b = full_invalid_fingerprint( + r"C:\a\policy.json", + 1, + b"ancestors", + 1, + b"same content", + b"security", + validation::DiskFailureReason::MalformedContent, + ); + assert_eq!(a, b); + } + + // ─── Mandatory probe cleanup (item 28) ───────────────────────────────────── + + #[test] + fn cleanup_probe_file_tolerates_an_already_absent_file() { + let dir = temp_dir(); + let path = dir.path().join("never-created.tmp"); + cleanup_probe_file(&path).expect("removing an already-absent file must be tolerated"); + } + + #[test] + fn cleanup_probe_file_fails_when_removal_is_blocked() { + let dir = temp_dir(); + let path = dir.path().join("locked.tmp"); + std::fs::write(&path, b"content").unwrap(); + + // Hold the file open without FILE_SHARE_DELETE so the removal attempt below + // fails with something other than NotFound. + let _locked = OpenOptions::new() + .read(true) + .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0) + .open(&path) + .unwrap(); + + let error = cleanup_probe_file(&path).unwrap_err(); + assert!(!format!("{error:#}").is_empty()); + assert!(path.exists(), "the file must still be present after a failed cleanup"); + } +} diff --git a/crates/now-package-broker/src/policy_watcher.rs b/crates/now-package-broker/src/policy_watcher.rs deleted file mode 100644 index a30bda5e3..000000000 --- a/crates/now-package-broker/src/policy_watcher.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Policy file watcher with live reload. -//! -//! Watches the policy file for changes and reloads it when modified. -//! If the file becomes unavailable or corrupted, the broker pauses -//! (denies all requests) until a valid policy is available again. - -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use now_policy::PolicyDocument; -use tokio::sync::watch; -use tokio_util::sync::CancellationToken; -use tracing::{error, info, warn}; - -use crate::policy_loader; - -/// State of the policy: either loaded and active, or unavailable. -#[derive(Debug, Clone)] -pub enum PolicyState { - /// A valid policy is loaded and active. - Active(Arc), - /// The policy file is missing or corrupted; broker should deny all requests. - Unavailable { reason: String }, -} - -/// Watches a policy file (JSON or YAML) and sends updates via a channel. -/// -/// On startup, attempts to load the policy. If it fails, starts in `Unavailable` state. -/// When the file is modified, reloads it. If reload fails, transitions to `Unavailable`. -/// When a valid file becomes available again, transitions back to `Active`. -pub struct PolicyWatcher { - path: PathBuf, - state_tx: watch::Sender, -} - -impl PolicyWatcher { - /// Create a new watcher for the given policy file path. - /// - /// Returns the watcher and a receiver for policy state changes. - pub fn new(path: PathBuf) -> (Self, watch::Receiver) { - let initial_state = match policy_loader::load_policy(&path) { - Ok(policy) => PolicyState::Active(Arc::new(policy)), - Err(e) => PolicyState::Unavailable { reason: e.to_string() }, - }; - - let (state_tx, state_rx) = watch::channel(initial_state); - - let watcher = Self { path, state_tx }; - - (watcher, state_rx) - } - - /// Start watching the policy file for changes. - /// - /// This spawns a background task that watches the policy file's parent directory - /// and reloads the policy when the file is modified, created, or removed. - /// The task runs until the shutdown notify is triggered. - pub async fn watch(self, shutdown: CancellationToken) { - let path = self.path.clone(); - let state_tx = self.state_tx; - let dir = path.parent().unwrap_or_else(|| Path::new(".")).to_owned(); - - let (fs_tx, mut fs_rx) = tokio::sync::mpsc::channel::<()>(16); - let (watcher_stop_tx, watcher_stop_rx) = std::sync::mpsc::channel::<()>(); - - // Set up file watcher in a blocking context. - let watch_path = dir.clone(); - let setup_state_tx = state_tx.clone(); - let _watcher_handle = tokio::task::spawn_blocking(move || { - let rt_tx = fs_tx; - let mut watcher: RecommendedWatcher = - match notify::recommended_watcher(move |res: notify::Result| { - if let Ok(event) = res { - // Only react to modify/create/remove events. - use notify::EventKind; - match event.kind { - EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) => { - let _ = rt_tx.blocking_send(()); - } - _ => {} - } - } - }) { - Ok(watcher) => watcher, - Err(error) => { - error!(%error, "Failed to create policy file watcher"); - let _ = setup_state_tx.send(PolicyState::Unavailable { - reason: format!("failed to create policy file watcher: {error}"), - }); - return; - } - }; - - if let Err(error) = watcher.watch(&watch_path, RecursiveMode::NonRecursive) { - error!(%error, path = %watch_path.display(), "Failed to watch policy directory"); - let _ = setup_state_tx.send(PolicyState::Unavailable { - reason: format!("failed to watch policy directory {}: {error}", watch_path.display()), - }); - return; - } - - let _ = watcher_stop_rx.recv(); - }); - - // Debounce interval to avoid rapid reloads. - let debounce = Duration::from_millis(500); - - loop { - tokio::select! { - _ = shutdown.cancelled() => { - info!("Policy watcher shutting down"); - let _ = watcher_stop_tx.send(()); - break; - } - Some(()) = fs_rx.recv() => { - // Debounce: drain any additional events that arrived. - tokio::time::sleep(debounce).await; - while fs_rx.try_recv().is_ok() {} - - // Attempt reload. - match policy_loader::load_policy(&path) { - Ok(policy) => { - info!( - policy_id = %policy.metadata.id, - revision = policy.metadata.revision, - "Policy reloaded successfully" - ); - let _ = state_tx.send(PolicyState::Active(Arc::new(policy))); - } - Err(e) => { - warn!(error = %e, "Policy reload failed; broker paused"); - let _ = state_tx.send(PolicyState::Unavailable { - reason: e.to_string(), - }); - } - } - } - } - } - } -} diff --git a/crates/now-package-broker/src/scenario_tests.rs b/crates/now-package-broker/src/scenario_tests.rs index c0c16e4b8..07ea04627 100644 --- a/crates/now-package-broker/src/scenario_tests.rs +++ b/crates/now-package-broker/src/scenario_tests.rs @@ -45,13 +45,7 @@ fn load_json_file(path: &Path) -> serde_json::Value { fn load_policy(path: &Path) -> PolicyDocument { let content = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - match ext { - "yaml" | "yml" => serde_yaml::from_str(&content) - .unwrap_or_else(|e| panic!("failed to deserialize YAML policy {}: {e}", path.display())), - _ => serde_json::from_str(&content) - .unwrap_or_else(|e| panic!("failed to deserialize policy {}: {e}", path.display())), - } + serde_json::from_str(&content).unwrap_or_else(|e| panic!("failed to deserialize policy {}: {e}", path.display())) } fn load_request(path: &Path) -> PackageRequest { diff --git a/crates/now-package-broker/src/server/mod.rs b/crates/now-package-broker/src/server/mod.rs index 27d9b0c46..38e59263d 100644 --- a/crates/now-package-broker/src/server/mod.rs +++ b/crates/now-package-broker/src/server/mod.rs @@ -1,7 +1,14 @@ //! Runtime implementation of the shared NOW package broker server facade. +// See `responses.rs` for why `ErrorResponse` is large and not boxed. +#![expect( + clippy::result_large_err, + reason = "ErrorResponse's size is dictated by the shared now-policy-api contract, not under this crate's control" +)] + use std::collections::HashMap; -use std::sync::{Arc, RwLock}; +use std::path::PathBuf; +use std::sync::Arc; use std::time::{Duration, Instant}; use async_trait::async_trait; @@ -11,8 +18,10 @@ use now_policy_api::{ CancelRequest, CancelResponse, CancelResponseKind, CapabilitiesResponse, CapabilitiesResponseKind, Decision, DecisionInfo, Elevation, ErrorCode, ErrorResponse, EvaluationResponse, EvaluationResponseKind, ExecutionResponse, ExecutionResponseKind, HealthResponse, HealthResponseKind, HealthStatus, ManagerCapability, ManagerName, - OperationStatus, OperationSubmission, PackageRequest, PolicyResponse, PolicyResponseKind, Scope, StatusRequest, - StatusResponse, StatusResponseKind, Transport, + OperationStatus, OperationSubmission, PackageRequest, PolicyManagementResponse, PolicyManagementResponseKind, + PolicyReplacementRequest, PolicyReplacementResponse, PolicyReplacementResponseKind, PolicyResponse, + PolicyResponseKind, PolicyValidationRequest, PolicyValidationResponse, PolicyValidationResponseKind, Scope, + StatusRequest, StatusResponse, StatusResponseKind, Transport, }; use now_policy_server_template::{MAX_REQUEST_BODY_BYTES, PackageBrokerServer, SharedPackageBrokerServer}; use tracing::{info, trace, warn}; @@ -23,10 +32,11 @@ use crate::command_builder::build_command; use crate::evaluator; use crate::executor::{CommandExecutor, ExecutionContext}; use crate::operation_tracker::OperationTracker; +use crate::policy_store::{PolicyStore, PolicyWriteActor}; mod connection; mod execution; -mod responses; +pub(crate) mod responses; pub use connection::serve_connection; use responses::{ @@ -76,8 +86,9 @@ impl ManagerProbeCache { /// Shared server state. pub struct BrokerState { - /// Current policy. `None` means the broker is paused (policy file missing or corrupted). - pub policy: RwLock>>, + /// Owns the configured/resolved policy path, observed state, and transactional + /// replacement; the store's state is Missing/Invalid when the broker is paused. + pub policy_store: Arc, pub executor: Arc, pub pipe_name: String, pub tracker: OperationTracker, @@ -95,6 +106,18 @@ struct EvaluatedRequest { } /// Build the axum router for a single authenticated pipe client. +/// +/// Body-size limiting is entirely owned by `now_policy_server_template::api_router_from_shared` +/// (route ownership stays there; see the shared-contract pin comment in the workspace +/// `Cargo.toml`): it applies [`MAX_REQUEST_BODY_BYTES`] (256 KiB) to every operation +/// endpoint (`POST /v1/package-operations/*`) and the larger, dedicated +/// `MAX_POLICY_MANAGEMENT_BODY_BYTES` (16 MiB) to the two policy-management routes, +/// `POST /v1/policy/validate` and `PUT /v1/policy`. This broker has nothing to add for +/// either limit -- both are applied inside `api_router_from_shared` itself, not here -- +/// and must never re-apply a body-size layer of its own on top, which would only risk +/// silently drifting from the shared contract's own limits. See +/// `agent_policy_tester::windows::policy_management_body_size_limits` for the end-to-end +/// `>256 KiB` valid / `>16 MiB` reject coverage. pub(crate) fn build_router_for_client(state: Arc, client: PipeClient) -> axum::Router { let server: SharedPackageBrokerServer = Arc::new(BrokerConnection { state, client }); axum::Router::from(now_policy_server_template::api_router_from_shared(server)) @@ -118,21 +141,118 @@ impl PackageBrokerServer for BrokerConnection { async fn active_policy(&self) -> Result { self.client .validate_connection(self.state.skip_signature_validation) - .map_err(|error| { - warn!(error = format!("{error:#}"), "Rejected package broker policy request"); - error_response(ErrorCode::Unauthorized, "pipe client authentication failed") - })?; + .map_err(|error| auth_error("policy", error))?; self.state.policy_response() } + async fn policy_management(&self) -> Result { + self.client + .validate_connection(self.state.skip_signature_validation) + .map_err(|error| auth_error("policy management", error))?; + + Ok(PolicyManagementResponse { + response_kind: PolicyManagementResponseKind, + response_version: api_version(), + server: server_context(), + management: self.state.policy_store.management_snapshot(), + }) + } + + async fn validate_policy( + &self, + request: PolicyValidationRequest, + ) -> Result { + self.client + .validate_connection(self.state.skip_signature_validation) + .map_err(|error| auth_error("policy validation", error))?; + + // Bound to the same process-random key `replace_policy`'s transaction verifies + // against, so a receipt issued here is always accepted there. + let validation = self.state.policy_store.validate_draft(&request.draft); + + Ok(PolicyValidationResponse { + response_kind: PolicyValidationResponseKind, + response_version: api_version(), + server: server_context(), + validation, + }) + } + + async fn replace_policy( + &self, + request: PolicyReplacementRequest, + ) -> Result { + let intent = format!("{:?}", request.operation); + let configured_path = PathBuf::from(self.state.policy_store.management_snapshot().configured_path); + + // One attempted sysevent+trace for the whole write lifecycle, recorded here at + // the server boundary (where the OS-verified pipe client SID/executable, request + // intent, and configured path are all in hand) rather than duplicated again once + // the request reaches `PolicyStore::replace`. + crate::audit::write_attempted( + self.client.user_sid(), + self.client.executable_path(), + &intent, + &configured_path, + ); + + if let Err(error) = self.client.validate_connection(self.state.skip_signature_validation) { + // Sanitized reason to the tamper-evident sysevent trail; the detailed + // Authenticode failure is only ever traced (inside `auth_error`), never + // logged as a security-audit event. + let reason = "pipe client authentication failed"; + crate::audit::write_denied( + self.client.user_sid(), + self.client.executable_path(), + &intent, + &configured_path, + reason, + ); + return Err(auth_error("policy replacement", error)); + } + + // Authenticode validation only proves *which* signed client is calling; policy + // writes additionally require the actual named-pipe process token to be both + // elevated and an enabled member of the built-in Administrators group. This is + // captured from the OS token at connect time and is never derived from request + // fields, so a client cannot self-declare its way into write access. + if !self.client.is_elevated_administrator() { + let reason = "pipe client token is not an elevated Administrator"; + crate::audit::write_denied( + self.client.user_sid(), + self.client.executable_path(), + &intent, + &configured_path, + reason, + ); + warn!(user_sid = %self.client.user_sid(), "Rejected package broker policy replacement request: {reason}"); + return Err(error_response(ErrorCode::AdministratorRequired, reason)); + } + + let actor = PolicyWriteActor { + sid: self.client.user_sid(), + executable: self.client.executable_path(), + }; + + self.state + .policy_store + .replace(request, actor) + .await + .map(|success| PolicyReplacementResponse { + response_kind: PolicyReplacementResponseKind, + response_version: api_version(), + server: server_context(), + policy: success.policy, + validation: success.validation, + management: success.management, + }) + } + async fn evaluate(&self, request: PackageRequest) -> Result { self.client .validate_request(&request, self.state.skip_signature_validation) - .map_err(|error| { - warn!(error = format!("{error:#}"), "Rejected package broker evaluate request"); - error_response(ErrorCode::Unauthorized, "pipe client authentication failed") - })?; + .map_err(|error| auth_error("evaluate", error))?; self.state.evaluate(request).await } @@ -140,10 +260,7 @@ impl PackageBrokerServer for BrokerConnection { async fn execute(&self, request: PackageRequest) -> Result { self.client .validate_request(&request, self.state.skip_signature_validation) - .map_err(|error| { - warn!(error = format!("{error:#}"), "Rejected package broker execute request"); - error_response(ErrorCode::Unauthorized, "pipe client authentication failed") - })?; + .map_err(|error| auth_error("execute", error))?; self.state.execute(request, self.client.user_sid()).await } @@ -151,10 +268,7 @@ impl PackageBrokerServer for BrokerConnection { async fn status(&self, request: StatusRequest) -> Result { self.client .validate_status_request(&request, self.state.skip_signature_validation) - .map_err(|error| { - warn!(error = format!("{error:#}"), "Rejected package broker status request"); - error_response(ErrorCode::Unauthorized, "pipe client authentication failed") - })?; + .map_err(|error| auth_error("status", error))?; let owner_key = request.client.owner_key(); self.state.status_for_client(request, owner_key).await @@ -163,22 +277,29 @@ impl PackageBrokerServer for BrokerConnection { async fn cancel(&self, request: CancelRequest) -> Result { self.client .validate_cancel_request(&request, self.state.skip_signature_validation) - .map_err(|error| { - warn!(error = format!("{error:#}"), "Rejected package broker cancel request"); - error_response(ErrorCode::Unauthorized, "pipe client authentication failed") - })?; + .map_err(|error| auth_error("cancel", error))?; let owner_key = request.client.owner_key(); self.state.cancel_for_client(request, owner_key).await } } +/// Reject a request whose pipe client failed Authenticode/identity validation +/// (`PipeClient::validate_connection` and friends): trace the detailed underlying error +/// for diagnosis, and return the sanitized, consistent `Unauthorized` response every +/// route uses for this condition. +fn auth_error(context: &str, error: anyhow::Error) -> ErrorResponse { + warn!( + error = format!("{error:#}"), + "Rejected package broker {context} request" + ); + error_response(ErrorCode::Unauthorized, "pipe client authentication failed") +} + impl BrokerState { fn active_policy(&self) -> Result, ErrorResponse> { - let guard = self.policy.read().expect("policy lock poisoned"); - guard - .as_ref() - .map(Arc::clone) + self.policy_store + .active_policy() .ok_or_else(|| error_response(ErrorCode::BrokerPaused, "active policy is unavailable")) } @@ -194,8 +315,8 @@ impl BrokerState { } async fn health(&self) -> HealthResponse { - let policy_guard = self.policy.read().expect("policy lock poisoned"); - let (status, policy_id) = match policy_guard.as_ref() { + let policy = self.policy_store.active_policy(); + let (status, policy_id) = match &policy { Some(policy) => (HealthStatus::Ready, policy.metadata.id.to_string()), None => (HealthStatus::Paused, String::new()), }; @@ -216,6 +337,11 @@ impl BrokerState { server: server_context(), transports: vec![Transport::HttpNamedPipe], managers: self.probed_manager_capabilities(user_sid).await, + // The shared contract exposes a single `max_request_body_bytes` figure, with + // no separate field for the larger policy-management limit (see + // `build_router_for_client`): this always reflects the general per-operation + // limit, which is what every `POST /v1/package-operations/*` caller actually + // needs to know to size its own requests. max_request_body_bytes: MAX_REQUEST_BODY_BYTES as u64, } } @@ -555,6 +681,7 @@ mod tests { use super::*; use crate::executor::{ExecutionOutput, OperationCanceled, ProcessStartedCallback}; + use crate::test_support::system_sid; struct NoopExecutor; @@ -617,7 +744,7 @@ mod tests { fn state() -> BrokerState { BrokerState { - policy: RwLock::new(Some(Arc::new(permissive_policy()))), + policy_store: PolicyStore::for_tests(Some(permissive_policy())), executor: Arc::new(NoopExecutor), pipe_name: "test-pipe".to_owned(), tracker: OperationTracker::new(), @@ -628,7 +755,7 @@ mod tests { fn shared_state(policy: Option) -> Arc { let mut state = state(); - state.policy = RwLock::new(policy.map(Arc::new)); + state.policy_store = PolicyStore::for_tests(policy); Arc::new(state) } @@ -669,6 +796,239 @@ mod tests { assert!(body.get("Policy").is_none()); } + // ─── Elevation/administrator gating at the HTTP route layer (item 23/25) ── + // + // Only meaningful with the `dev-skip-broker-signature` feature: without it, every + // pipe client (including these synthetic ones, whose `executable_path` does not + // point at a real Devolutions-signed binary) fails Authenticode validation + // regardless of elevation, so these tests would only ever observe 401 and never + // actually reach the elevation gate they exist to exercise. See + // `crate::auth::PipeClient::{test_elevated_administrator, test_unelevated}`. + #[cfg(feature = "dev-skip-broker-signature")] + mod elevation_gating { + use super::*; + + async fn route_request_as( + state: Arc, + client: PipeClient, + method: Method, + uri: &str, + body: Option, + ) -> axum::response::Response { + let mut router = build_router_for_client(state, client); + let request = match body { + Some(value) => Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&value).expect("serialize test body"))) + .expect("valid test request"), + None => Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("valid test request"), + }; + router.call(request).await.expect("router is infallible") + } + + fn draft_json(id: &str) -> serde_json::Value { + serde_json::json!({ + "$schema": now_policy::POLICY_SCHEMA_URI, + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": id, "Publisher": "Test" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [], + }) + } + + fn dev_state() -> Arc { + let mut broker_state = state(); + broker_state.skip_signature_validation = true; + Arc::new(broker_state) + } + + #[tokio::test] + async fn management_and_validation_succeed_without_elevation() { + let state = dev_state(); + let client = PipeClient::test_unelevated(system_sid(), PathBuf::from("unelevated.exe")); + + let management = route_request_as( + Arc::clone(&state), + client.clone(), + Method::GET, + "/v1/policy/management", + None, + ) + .await; + assert_eq!(management.status(), StatusCode::OK); + + let validate_body = serde_json::json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": draft_json("policy-a"), + }); + let validate = + route_request_as(state, client, Method::POST, "/v1/policy/validate", Some(validate_body)).await; + assert_eq!(validate.status(), StatusCode::OK); + } + + #[tokio::test] + async fn replace_requires_administrator_even_with_signature_bypass_active() { + let state = dev_state(); + let client = PipeClient::test_unelevated(system_sid(), PathBuf::from("unelevated.exe")); + + let management = response_json( + route_request_as( + Arc::clone(&state), + client.clone(), + Method::GET, + "/v1/policy/management", + None, + ) + .await, + ) + .await; + let store_token = management["Management"]["StoreToken"].as_str().unwrap().to_owned(); + + let replace_body = serde_json::json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": store_token, + "Operation": "Update", + "ConflictHandling": "Reject", + "WarningsAcknowledged": true, + "Draft": draft_json("test-policy"), + "ValidationReceipt": "hmac-sha256:0000", + }); + let replace = route_request_as(state, client, Method::PUT, "/v1/policy", Some(replace_body)).await; + + // The dev-only signature bypass proves it took effect (this is not a 401), + // but it must never also bypass elevation/Administrators membership: the + // request is rejected before the store (and therefore the bogus receipt) + // is ever consulted. + assert_eq!(replace.status(), StatusCode::FORBIDDEN); + let body = response_json(replace).await; + let error: ErrorResponse = serde_json::from_value(body).expect("deserialize error response"); + assert_eq!(error.code, ErrorCode::AdministratorRequired); + } + + #[tokio::test] + async fn replace_reaches_the_store_for_an_elevated_administrator() { + let state = dev_state(); + let unelevated = PipeClient::test_unelevated(system_sid(), PathBuf::from("unelevated.exe")); + let elevated = PipeClient::test_elevated_administrator(system_sid(), PathBuf::from("elevated.exe")); + + let management = response_json( + route_request_as( + Arc::clone(&state), + unelevated, + Method::GET, + "/v1/policy/management", + None, + ) + .await, + ) + .await; + let store_token = management["Management"]["StoreToken"].as_str().unwrap().to_owned(); + + // A deliberately bogus receipt: this proves the request passed the + // elevation/Administrators gate (it is rejected by store-level validation, + // not by `AdministratorRequired`), without needing the full validate-then- + // replace round trip. + let replace_body = serde_json::json!({ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": store_token, + "Operation": "Update", + "ConflictHandling": "Reject", + "WarningsAcknowledged": true, + "Draft": draft_json("test-policy"), + "ValidationReceipt": "hmac-sha256:0000", + }); + let replace = route_request_as(state, elevated, Method::PUT, "/v1/policy", Some(replace_body)).await; + + let body = response_json(replace).await; + let error: ErrorResponse = serde_json::from_value(body).expect("deserialize error response"); + assert_ne!( + error.code, + ErrorCode::AdministratorRequired, + "an elevated Administrator's request must reach the store, not be denied at the auth gate" + ); + } + } + + // ─── Policy-management body-size limit, applied by the shared router ────── + // + // Same feature-gating rationale as `elevation_gating` above: without the dev + // signature bypass, every request (regardless of size) is rejected with 401 before + // the body-size layer is ever reached, so these tests would not actually exercise + // it. The router used here is the exact same `build_router_for_client` the real + // named-pipe server serves every connection through (see its doc comment): the + // 16 MiB policy-management limit and 256 KiB operation-endpoint limit are both + // applied entirely inside `now_policy_server_template::api_router_from_shared`, so + // this proves the *final* (not this broker's own) limit end to end. The Agent E2E + // suite (`agent_policy_tester::windows::policy_management_body_size_limits`) + // exercises the same two limits again over the real named-pipe HTTP transport. + #[cfg(feature = "dev-skip-broker-signature")] + mod policy_management_body_limits { + use now_policy_server_template::MAX_POLICY_MANAGEMENT_BODY_BYTES; + + use super::*; + + /// Post a `/v1/policy/validate` request whose serialized body is at least + /// `target_len` bytes, via a single large filler string in `Draft` (not a + /// well-formed policy draft): `Draft` is a raw `serde_json::Value`, so any valid + /// JSON value deserializes, and the body-size limit is enforced by the router + /// before the draft's content is ever inspected. One contiguous allocation for + /// the filler plus one for its serialized form, instead of building a large tree + /// of many small values. + async fn route_oversized_validate(state: Arc, target_len: usize) -> axum::response::Response { + let client = PipeClient::test_unelevated(system_sid(), PathBuf::from("unelevated.exe")); + let mut router = build_router_for_client(state, client); + let body = serde_json::json!({ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": "a".repeat(target_len), + }); + let request = Request::builder() + .method(Method::POST) + .uri("/v1/policy/validate") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).expect("serialize test body"))) + .expect("valid test request"); + router.call(request).await.expect("router is infallible") + } + + fn dev_state() -> Arc { + let mut broker_state = state(); + broker_state.skip_signature_validation = true; + Arc::new(broker_state) + } + + #[tokio::test] + async fn validate_accepts_a_body_over_the_operation_limit_but_under_the_management_limit() { + // Comfortably above the 256 KiB operation-endpoint limit + // (`MAX_REQUEST_BODY_BYTES`) but still well inside the dedicated 16 MiB + // policy-management limit: proves `/v1/policy/validate` does not share the + // smaller operation-endpoint limit. + let response = route_oversized_validate(dev_state(), MAX_REQUEST_BODY_BYTES * 2).await; + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn validate_rejects_a_body_over_the_management_limit() { + let response = + route_oversized_validate(dev_state(), MAX_POLICY_MANAGEMENT_BODY_BYTES + MAX_REQUEST_BODY_BYTES).await; + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + let error: ErrorResponse = + serde_json::from_value(response_json(response).await).expect("deserialize error response"); + assert_eq!(error.code, ErrorCode::PayloadTooLarge); + } + } + #[test] fn concurrent_policy_replacement_returns_only_complete_snapshots() { let policy_a = permissive_policy(); @@ -683,7 +1043,7 @@ mod tests { let policy_a = Arc::new(policy_a); let policy_b = Arc::new(policy_b); let state = shared_state(None); - *state.policy.write().expect("policy lock") = Some(Arc::clone(&policy_a)); + state.policy_store.test_set_active(Arc::clone(&policy_a)); const READER_COUNT: usize = 4; const ITERATIONS: usize = 1_000; @@ -716,7 +1076,7 @@ mod tests { } else { Arc::clone(&policy_a) }; - *state.policy.write().expect("policy lock") = Some(replacement); + state.policy_store.test_set_active(replacement); std::thread::yield_now(); } }); @@ -839,7 +1199,7 @@ mod tests { probe_count: AtomicUsize::new(0), }); let state = Arc::new(BrokerState { - policy: RwLock::new(None), + policy_store: PolicyStore::for_tests(None), executor: Arc::clone(&executor) as Arc, pipe_name: "test-pipe".to_owned(), tracker: OperationTracker::new(), @@ -849,15 +1209,11 @@ mod tests { (state, executor) } - fn test_sid() -> Sid { - Sid::from_well_known(windows::Win32::Security::WinLocalSystemSid, None).unwrap() - } - #[tokio::test] async fn capabilities_only_advertise_probed_managers() { let (state, _) = make_state(vec![ManagerName::Winget, ManagerName::PowerShell]); - let response = state.capabilities(&test_sid()).await; + let response = state.capabilities(&system_sid()).await; let managers: Vec = response.managers.iter().map(|capability| capability.manager).collect(); assert_eq!(managers, vec![ManagerName::Winget, ManagerName::PowerShell]); @@ -867,15 +1223,27 @@ mod tests { async fn capabilities_empty_when_no_manager_available() { let (state, _) = make_state(Vec::new()); - let response = state.capabilities(&test_sid()).await; + let response = state.capabilities(&system_sid()).await; assert!(response.managers.is_empty()); } + /// The capabilities response advertises the general per-operation body-size limit: + /// the shared contract has no separate field for the larger policy-management + /// limit, so this must never be conflated with `MAX_POLICY_MANAGEMENT_BODY_BYTES`. + #[tokio::test] + async fn capabilities_advertise_the_operation_endpoint_body_limit() { + let (state, _) = make_state(Vec::new()); + + let response = state.capabilities(&system_sid()).await; + + assert_eq!(response.max_request_body_bytes, MAX_REQUEST_BODY_BYTES as u64); + } + #[tokio::test] async fn manager_probe_is_cached_per_user() { let (state, executor) = make_state(vec![ManagerName::Winget]); - let sid = test_sid(); + let sid = system_sid(); state.capabilities(&sid).await; state.capabilities(&sid).await; @@ -887,7 +1255,7 @@ mod tests { async fn manager_probe_is_refreshed_after_ttl_expiry() { let (state, executor) = make_state_with_cache(vec![ManagerName::Winget], ManagerProbeCache::with_ttl(Duration::ZERO)); - let sid = test_sid(); + let sid = system_sid(); state.capabilities(&sid).await; state.capabilities(&sid).await; @@ -946,7 +1314,7 @@ mod tests { fn state_with_executor(executor: Arc) -> BrokerState { BrokerState { - policy: RwLock::new(Some(Arc::new(permissive_policy()))), + policy_store: PolicyStore::for_tests(Some(permissive_policy())), executor, pipe_name: "test-pipe".to_owned(), tracker: OperationTracker::new(), diff --git a/crates/now-package-broker/src/server/responses.rs b/crates/now-package-broker/src/server/responses.rs index 02758756f..268bc763c 100644 --- a/crates/now-package-broker/src/server/responses.rs +++ b/crates/now-package-broker/src/server/responses.rs @@ -1,20 +1,57 @@ //! NOW API response mapping helpers. +// `ErrorResponse` (from the shared `now-policy-api` contract) carries the atomic +// management snapshot and validation result needed by `StalePolicyStoreToken` and +// policy-management errors, which makes it large; every helper here that can fail +// returns it directly rather than boxing, to match the trait's fixed method signatures. +#![expect( + clippy::result_large_err, + reason = "ErrorResponse's size is dictated by the shared now-policy-api contract, not under this crate's control" +)] + use chrono::{DateTime, Utc}; use now_policy::PolicyDocument; use now_policy_api::{ API_VERSION_STR, ApiVersion, Architecture, ErrorCode, ErrorResponse, ErrorResponseKind, ManagerCapability, - ManagerName, Operation, OperationDiagnostics, PackageRequest, RequestSummary, ResourceId, ResponsePolicyInfo, - RuleId, Scope, SemanticVersion, ServerContext, Transport, + ManagerName, Operation, OperationDiagnostics, PackageRequest, PolicyReadOnlyReason, RequestSummary, ResourceId, + ResponsePolicyInfo, RuleId, Scope, SemanticVersion, ServerContext, Transport, }; use crate::operation_tracker::OperationTracker; -pub(super) fn api_version() -> ApiVersion { +pub(crate) fn api_version() -> ApiVersion { API_VERSION_STR.into() } -pub(super) fn server_context() -> ServerContext { +/// Map a [`PolicyReadOnlyReason`] (the store's own advisory reason a configured policy +/// path cannot currently be written) to the closest explicit final [`ErrorCode`] for a +/// blocked `PUT /v1/policy` (item 31). +/// +/// `UnsafePath`, `UnsupportedFileSystem`, and `UnsupportedFormat` all have dedicated codes +/// exported by the final pinned `now-policy-api` revision (`UnsafePolicyPath` maps to HTTP +/// 409, `UnsupportedPolicyFilesystem` and `UnsupportedPolicyFormat` both to 422; see +/// `now_policy_server_template::error_status`). `ManagementDisabled`, `PathNotConfigured`, +/// and `InsufficientPermissions` describe a server-side environment/configuration +/// condition -- never the caller's own identity or permissions -- so they must never be +/// mapped to an authentication/authorization code (`Unauthorized`/`Forbidden`/ +/// `AdministratorRequired`), which would falsely imply the problem is something the +/// caller could fix by presenting different credentials. `UnsafePolicyPath` is the +/// closest existing code that does not make that false claim. +pub(crate) fn policy_read_only_error_code(reason: Option) -> ErrorCode { + match reason { + Some(PolicyReadOnlyReason::UnsupportedFileSystem) => ErrorCode::UnsupportedPolicyFilesystem, + Some(PolicyReadOnlyReason::UnsupportedFormat) => ErrorCode::UnsupportedPolicyFormat, + Some( + PolicyReadOnlyReason::UnsafePath + | PolicyReadOnlyReason::InsufficientPermissions + | PolicyReadOnlyReason::ManagementDisabled + | PolicyReadOnlyReason::PathNotConfigured, + ) + | None => ErrorCode::UnsafePolicyPath, + } +} + +pub(crate) fn server_context() -> ServerContext { ServerContext { server_version: env!("CARGO_PKG_VERSION").to_owned(), transport: Transport::HttpNamedPipe, @@ -243,7 +280,7 @@ pub(super) fn policy_validity_failure(policy: &PolicyDocument, now: DateTime) -> ErrorResponse { +pub(crate) fn error_response(code: ErrorCode, message: impl Into) -> ErrorResponse { ErrorResponse { response_kind: ErrorResponseKind, response_version: api_version(), @@ -251,5 +288,139 @@ pub(super) fn error_response(code: ErrorCode, message: impl Into) -> Err code, message: message.into(), details: Vec::new(), + validation: None, + management: None, + } +} + +/// Build an error response carrying the authoritative validation result, used for +/// `InvalidPolicy`, `ValidationFailed` (a stale or mismatched validation receipt), and +/// `WarningConfirmationRequired`, so the caller can inspect the exact findings without +/// resubmitting to `POST /v1/policy/validate`. +pub(crate) fn validation_error_response( + code: ErrorCode, + message: impl Into, + validation: now_policy_api::PolicyValidationResult, +) -> ErrorResponse { + ErrorResponse { + response_kind: ErrorResponseKind, + response_version: api_version(), + server: server_context(), + code, + message: message.into(), + details: Vec::new(), + validation: Some(validation), + management: None, + } +} + +/// Build a `StalePolicyStoreToken` error response, which must carry the atomic current +/// management snapshot so the caller can retry with `ConfirmOverwrite` against the exact +/// newly observed token. +pub(crate) fn stale_token_response( + message: impl Into, + management: now_policy_api::PolicyManagementSnapshot, +) -> ErrorResponse { + ErrorResponse { + response_kind: ErrorResponseKind, + response_version: api_version(), + server: server_context(), + code: ErrorCode::StalePolicyStoreToken, + message: message.into(), + details: Vec::new(), + validation: None, + management: Some(management), + } +} + +/// Build an error response for an arbitrary `code` that also carries a management +/// snapshot (item 27). +/// +/// Used for `PolicyActivationFailed`: a post-publication verification failure means the +/// atomic rename already made new content live, so this crate synchronously reobserves +/// and publishes the actual resulting disk state under the write lock before building +/// this response. The shared `ErrorResponse.management` field is generic -- not +/// restricted to `StalePolicyStoreToken` -- so attaching the freshly recomputed snapshot +/// here lets the caller see the true current state immediately, without depending on a +/// follow-up `GET` to observe what this same request already learned. +pub(crate) fn error_response_with_management( + code: ErrorCode, + message: impl Into, + management: now_policy_api::PolicyManagementSnapshot, +) -> ErrorResponse { + ErrorResponse { + response_kind: ErrorResponseKind, + response_version: api_version(), + server: server_context(), + code, + message: message.into(), + details: Vec::new(), + validation: None, + management: Some(management), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── policy_read_only_error_code (item 31) ───────────────────────────────── + + #[test] + fn unsafe_path_maps_to_unsafe_policy_path() { + assert_eq!( + policy_read_only_error_code(Some(PolicyReadOnlyReason::UnsafePath)), + ErrorCode::UnsafePolicyPath + ); + } + + #[test] + fn unsupported_file_system_maps_to_unsupported_policy_filesystem() { + assert_eq!( + policy_read_only_error_code(Some(PolicyReadOnlyReason::UnsupportedFileSystem)), + ErrorCode::UnsupportedPolicyFilesystem + ); + } + + #[test] + fn insufficient_permissions_maps_to_unsafe_policy_path_not_an_auth_code() { + // Never `Forbidden`/`Unauthorized`/`AdministratorRequired`: this describes a + // server-side environment condition, not the caller's own identity/permissions. + let code = policy_read_only_error_code(Some(PolicyReadOnlyReason::InsufficientPermissions)); + assert_eq!(code, ErrorCode::UnsafePolicyPath); + assert_ne!(code, ErrorCode::Forbidden); + assert_ne!(code, ErrorCode::Unauthorized); + assert_ne!(code, ErrorCode::AdministratorRequired); + } + + #[test] + fn management_disabled_maps_to_unsafe_policy_path_not_an_auth_code() { + let code = policy_read_only_error_code(Some(PolicyReadOnlyReason::ManagementDisabled)); + assert_eq!(code, ErrorCode::UnsafePolicyPath); + assert_ne!(code, ErrorCode::Forbidden); + assert_ne!(code, ErrorCode::Unauthorized); + assert_ne!(code, ErrorCode::AdministratorRequired); + } + + #[test] + fn path_not_configured_maps_to_unsafe_policy_path_not_an_auth_code() { + let code = policy_read_only_error_code(Some(PolicyReadOnlyReason::PathNotConfigured)); + assert_eq!(code, ErrorCode::UnsafePolicyPath); + assert_ne!(code, ErrorCode::Forbidden); + assert_ne!(code, ErrorCode::Unauthorized); + assert_ne!(code, ErrorCode::AdministratorRequired); + } + + #[test] + fn absent_reason_maps_to_unsafe_policy_path() { + assert_eq!(policy_read_only_error_code(None), ErrorCode::UnsafePolicyPath); + } + + #[test] + fn unsupported_format_maps_to_unsupported_policy_format() { + assert_eq!( + policy_read_only_error_code(Some(PolicyReadOnlyReason::UnsupportedFormat)), + ErrorCode::UnsupportedPolicyFormat + ); } } diff --git a/crates/now-package-broker/src/task.rs b/crates/now-package-broker/src/task.rs index 490ee5ab5..f3eb7fdd5 100644 --- a/crates/now-package-broker/src/task.rs +++ b/crates/now-package-broker/src/task.rs @@ -1,17 +1,16 @@ //! Package broker entry point -use std::sync::{Arc, RwLock}; +use std::sync::Arc; use anyhow::Context as _; use async_trait::async_trait; use devolutions_gateway_task::{ShutdownSignal, Task}; use tokio_util::sync::CancellationToken; -use tracing::{info, warn}; +use tracing::info; use crate::executor::{self, CommandExecutor}; use crate::pipe::DEFAULT_PIPE_NAME; -use crate::policy_loader; -use crate::policy_watcher::{PolicyState, PolicyWatcher}; +use crate::policy_store::PolicyStore; use crate::server::BrokerState; /// Configuration for the broker task. @@ -19,8 +18,8 @@ use crate::server::BrokerState; pub struct BrokerTaskConfig { /// Named pipe name to listen on. pub pipe_name: String, - /// Path to the policy file. If `None`, uses the default location. - /// Supports `.json`, `.yaml`, and `.yml` extensions. + /// Path to the policy JSON file. If `None`, uses the default location + /// (`%PROGRAMDATA%\Devolutions\PackageBroker\package-broker-policy.json`). pub policy_path: Option, /// Skip Authenticode signature validation for the broker client executable. pub skip_signature_validation: bool, @@ -53,53 +52,12 @@ impl Task for BrokerTask { const NAME: &'static str = "package-broker"; async fn run(self, mut shutdown_signal: ShutdownSignal) -> Self::Output { - // Resolve policy file path. - - let policy_path = match &self.config.policy_path { - Some(path) => std::path::PathBuf::from(path), - None => policy_loader::find_default_policy().unwrap_or_else(|error| { - let candidate = policy_loader::default_policy_candidate(); - warn!( - %error, - path = %candidate.display(), - "Default broker policy is unavailable; broker will pause until this file is provided" - ); - candidate - }), - }; - - // Create policy watcher with initial load attempt. - let (watcher, mut state_rx) = PolicyWatcher::new(policy_path.clone()); - - // Log initial state. - match &*state_rx.borrow() { - PolicyState::Active(policy) => { - info!( - policy_id = %policy.metadata.id, - policy_revision = %policy.metadata.revision, - path = %policy_path.display(), - "Loaded package broker policy" - ); - } - PolicyState::Unavailable { reason } => { - warn!( - %reason, - path = %policy_path.display(), - "Policy unavailable at startup; broker will pause until a valid policy is provided" - ); - } - } + let policy_store = PolicyStore::load(self.config.policy_path.clone().map(std::path::PathBuf::from)); let executor: Arc = executor::create_platform_executor().into(); - // Initialize BrokerState with current policy (or None if unavailable). - let initial_policy = match &*state_rx.borrow() { - PolicyState::Active(policy) => Some(Arc::clone(policy)), - PolicyState::Unavailable { .. } => None, - }; - let state = Arc::new(BrokerState { - policy: RwLock::new(initial_policy), + policy_store: Arc::clone(&policy_store), executor, pipe_name: self.config.pipe_name.clone(), tracker: crate::operation_tracker::OperationTracker::new(), @@ -111,42 +69,11 @@ impl Task for BrokerTask { let shutdown = CancellationToken::new(); state.tracker.clone().spawn_eviction_task(shutdown.clone()); - // Spawn policy watcher task. + // Spawn the policy store's file watcher, coordinating external edits with the + // management API through the store's own write lock. let watcher_shutdown = shutdown.clone(); tokio::spawn(async move { - watcher.watch(watcher_shutdown).await; - }); - - // Spawn policy state relay: updates BrokerState when policy watcher reports changes. - let relay_state = Arc::clone(&state); - let relay_shutdown = shutdown.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - _ = relay_shutdown.cancelled() => break, - result = state_rx.changed() => { - if result.is_err() { - // Sender dropped (watcher exited). - break; - } - let new_policy = match &*state_rx.borrow_and_update() { - PolicyState::Active(policy) => { - info!( - policy_id = %policy.metadata.id, - revision = policy.metadata.revision, - "Policy hot-reloaded; broker resumed" - ); - Some(Arc::clone(policy)) - } - PolicyState::Unavailable { reason } => { - warn!(%reason, "Policy became unavailable; broker paused"); - None - } - }; - *relay_state.policy.write().expect("policy lock poisoned") = new_policy; - } - } - } + policy_store.watch(watcher_shutdown).await; }); // Spawn pipe server. diff --git a/crates/now-package-broker/src/test_support.rs b/crates/now-package-broker/src/test_support.rs new file mode 100644 index 000000000..c29d65d81 --- /dev/null +++ b/crates/now-package-broker/src/test_support.rs @@ -0,0 +1,11 @@ +//! Shared test-only helpers used across this crate's unit tests. + +use win_api_wrappers::identity::sid::Sid; +use windows::Win32::Security::WinLocalSystemSid; + +/// The well-known SYSTEM SID: a deterministic, privilege-independent stand-in for a +/// trusted actor used throughout this crate's tests (SYSTEM is always a valid, resolvable +/// well-known SID, regardless of which account actually runs the tests). +pub(crate) fn system_sid() -> Sid { + Sid::from_well_known(WinLocalSystemSid, None).expect("well-known SYSTEM SID") +} diff --git a/crates/sysevent-codes/src/lib.rs b/crates/sysevent-codes/src/lib.rs index e2eaad987..dacd9eb5d 100644 --- a/crates/sysevent-codes/src/lib.rs +++ b/crates/sysevent-codes/src/lib.rs @@ -380,6 +380,167 @@ pub fn recording_storage_low(remaining_bytes: u64, threshold_bytes: u64) -> Entr .field("threshold_bytes", threshold_bytes) } +// 8000-8099 **Package Broker / Policy Management** +// +// Audit trail for the Agent package-broker managed policy store (`PUT /v1/policy` and +// external edits detected by the store's file watcher). Never carries full policy +// content: only actor identity, intent, path, old/new policy id/revision, and outcome. + +/// A write was attempted by an authenticated, elevated Administrator. +pub const POLICY_WRITE_ATTEMPTED: u32 = 8000; +/// A write was rejected before reaching the store (signature, elevation, or +/// Administrators-membership check failed). +pub const POLICY_WRITE_DENIED: u32 = 8001; +/// A write was rejected because the expected store token no longer matched. +pub const POLICY_WRITE_CONFLICT: u32 = 8002; +/// A write with `ConfirmOverwrite` succeeded against a freshly observed token. +pub const POLICY_WRITE_CONFIRMED_OVERWRITE: u32 = 8003; +/// A write reached the store but did not complete (validation, precondition, or +/// persistence failure). +pub const POLICY_WRITE_FAILED: u32 = 8004; +/// A write completed and the new policy is now active. +pub const POLICY_WRITE_SUCCEEDED: u32 = 8005; +/// An external edit (outside the management API) was detected and adopted. +pub const POLICY_EXTERNAL_CHANGE_APPLIED: u32 = 8010; +/// An external edit (outside the management API) was detected but rejected (fails +/// closed: the configured policy becomes Invalid/paused rather than served). +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: impl AsRef, +) -> 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.as_ref().display()) +} + +pub fn policy_write_denied( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + 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.as_ref().display()) + .field("reason", reason) +} + +pub fn policy_write_conflict( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, +) -> Entry { + Entry::new("Policy management write conflict") + .event_code(POLICY_WRITE_CONFLICT) + .severity(Severity::Notice) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) +} + +#[expect( + clippy::too_many_arguments, + reason = "audit event needs the full old/new identity for a security trail" +)] +pub fn policy_write_confirmed_overwrite( + 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, +) -> Entry { + Entry::new("Policy management confirmed overwrite") + .event_code(POLICY_WRITE_CONFIRMED_OVERWRITE) + .severity(Severity::Notice) + .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) +} + +pub fn policy_write_failed( + actor_sid: impl ToString, + actor_exe: impl ToString, + intent: impl ToString, + path: impl AsRef, + reason: impl ToString, +) -> Entry { + Entry::new("Policy management write failed") + .event_code(POLICY_WRITE_FAILED) + .severity(Severity::Error) + .field("actor_sid", actor_sid) + .field("actor_exe", actor_exe) + .field("intent", intent) + .field("path", path.as_ref().display()) + .field("reason", reason) +} + +#[expect( + clippy::too_many_arguments, + reason = "audit event needs the full old/new identity for a security trail" +)] +pub fn policy_write_succeeded( + 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, +) -> Entry { + Entry::new("Policy management write succeeded") + .event_code(POLICY_WRITE_SUCCEEDED) + .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) +} + +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) +} + // 9000-9099 **Diagnostics** pub const DEBUG_OPTIONS_ENABLED: u32 = 9001; diff --git a/crates/sysevent-codes/tests/message_catalog_parity.rs b/crates/sysevent-codes/tests/message_catalog_parity.rs new file mode 100644 index 000000000..fa67e8377 --- /dev/null +++ b/crates/sysevent-codes/tests/message_catalog_parity.rs @@ -0,0 +1,133 @@ +//! Cross-checks that every event code declared in `sysevent-codes` has a matching +//! `MessageId`/`SymbolicName` entry in each product's Windows Event Log message catalog +//! (`.mc` file). +//! +//! Installer registration of an `EventMessageFile` alone is not enough: if the linked +//! binary's compiled message-table resource does not actually define an event ID, the +//! Windows Event Viewer shows "the description for Event ID ... cannot be found" for +//! every occurrence of that event. This test catches a code added to this crate without a +//! matching catalog update at CI time instead. + +use std::path::Path; + +/// (symbolic name as it appears in the `.mc` files, numeric event code) for every event +/// code declared in `sysevent-codes`. Deliberately explicit and manually maintained: this +/// makes adding a new event code a conscious two-step change (declare the constant in +/// `src/lib.rs`, list it here) that this test then verifies against every `.mc` catalog. +const EVENT_CODES: &[(&str, u32)] = &[ + ("SERVICE_STARTED", sysevent_codes::SERVICE_STARTED), + ("SERVICE_STOPPING", sysevent_codes::SERVICE_STOPPING), + ("CONFIG_INVALID", sysevent_codes::CONFIG_INVALID), + ("START_FAILED", sysevent_codes::START_FAILED), + ("BOOT_STACKTRACE_WRITTEN", sysevent_codes::BOOT_STACKTRACE_WRITTEN), + ("LISTENER_STARTED", sysevent_codes::LISTENER_STARTED), + ("LISTENER_BIND_FAILED", sysevent_codes::LISTENER_BIND_FAILED), + ("LISTENER_STOPPED", sysevent_codes::LISTENER_STOPPED), + ("TLS_CONFIGURED", sysevent_codes::TLS_CONFIGURED), + ("TLS_VERIFY_STRICT_DISABLED", sysevent_codes::TLS_VERIFY_STRICT_DISABLED), + ("TLS_CERTIFICATE_REJECTED", sysevent_codes::TLS_CERTIFICATE_REJECTED), + ("SYSTEM_CERT_SELECTED", sysevent_codes::SYSTEM_CERT_SELECTED), + ("TLS_KEY_LOAD_FAILED", sysevent_codes::TLS_KEY_LOAD_FAILED), + ( + "TLS_CERTIFICATE_NAME_MISMATCH", + sysevent_codes::TLS_CERTIFICATE_NAME_MISMATCH, + ), + ( + "TLS_NO_SUITABLE_CERTIFICATE", + sysevent_codes::TLS_NO_SUITABLE_CERTIFICATE, + ), + ("SESSION_OPENED", sysevent_codes::SESSION_OPENED), + ("SESSION_CLOSED", sysevent_codes::SESSION_CLOSED), + ("TOKEN_PROVISIONED", sysevent_codes::TOKEN_PROVISIONED), + ("TOKEN_REUSED", sysevent_codes::TOKEN_REUSED), + ("TOKEN_REUSE_LIMIT_EXCEEDED", sysevent_codes::TOKEN_REUSE_LIMIT_EXCEEDED), + ("RECORDING_STARTED", sysevent_codes::RECORDING_STARTED), + ("RECORDING_STOPPED", sysevent_codes::RECORDING_STOPPED), + ("RECORDING_ERROR", sysevent_codes::RECORDING_ERROR), + ("JWT_REJECTED", sysevent_codes::JWT_REJECTED), + ("JWT_ANOMALY", sysevent_codes::JWT_ANOMALY), + ("AUTHORIZATION_DENIED", sysevent_codes::AUTHORIZATION_DENIED), + ("AUTH_SUMMARY", sysevent_codes::AUTH_SUMMARY), + ( + "USER_SESSION_PROCESS_STARTED", + sysevent_codes::USER_SESSION_PROCESS_STARTED, + ), + ( + "USER_SESSION_PROCESS_TERMINATED", + sysevent_codes::USER_SESSION_PROCESS_TERMINATED, + ), + ("UPDATER_TASK_ENABLED", sysevent_codes::UPDATER_TASK_ENABLED), + ("UPDATER_ERROR", sysevent_codes::UPDATER_ERROR), + ("PEDM_ENABLED", sysevent_codes::PEDM_ENABLED), + ("RECORDING_STORAGE_LOW", sysevent_codes::RECORDING_STORAGE_LOW), + ("POLICY_WRITE_ATTEMPTED", sysevent_codes::POLICY_WRITE_ATTEMPTED), + ("POLICY_WRITE_DENIED", sysevent_codes::POLICY_WRITE_DENIED), + ("POLICY_WRITE_CONFLICT", sysevent_codes::POLICY_WRITE_CONFLICT), + ( + "POLICY_WRITE_CONFIRMED_OVERWRITE", + sysevent_codes::POLICY_WRITE_CONFIRMED_OVERWRITE, + ), + ("POLICY_WRITE_FAILED", sysevent_codes::POLICY_WRITE_FAILED), + ("POLICY_WRITE_SUCCEEDED", sysevent_codes::POLICY_WRITE_SUCCEEDED), + ( + "POLICY_EXTERNAL_CHANGE_APPLIED", + sysevent_codes::POLICY_EXTERNAL_CHANGE_APPLIED, + ), + ( + "POLICY_EXTERNAL_CHANGE_REJECTED", + sysevent_codes::POLICY_EXTERNAL_CHANGE_REJECTED, + ), + ("DEBUG_OPTIONS_ENABLED", sysevent_codes::DEBUG_OPTIONS_ENABLED), + ("XMF_NOT_FOUND", sysevent_codes::XMF_NOT_FOUND), +]; + +/// Every `.mc` catalog that must define the entire `EVENT_CODES` table above. Both +/// products link the full shared event-code catalog into their own message-table +/// resource, even for events the specific binary never itself emits (see each `.mc` +/// file's own header comment), so both are held to the exact same complete set. +const MESSAGE_CATALOGS: &[&str] = &[ + "../../devolutions-gateway/devolutions-gateway.mc", + "../../devolutions-agent/devolutions-agent.mc", +]; + +#[test] +fn every_event_code_is_defined_in_every_message_catalog() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + + for catalog in MESSAGE_CATALOGS { + let path = manifest_dir.join(catalog); + let content = + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + + for (name, code) in EVENT_CODES { + let expected_id = format!("MessageId={code}"); + let expected_name = format!("SymbolicName={name}"); + + // A duplicate/missing `MessageId` is itself a bug (mc.exe would reject a + // duplicate at build time), so fail fast here with a clearer message instead + // of waiting for that far-less-obvious downstream failure. + let id_positions: Vec<_> = content.match_indices(&expected_id).collect(); + assert_eq!( + id_positions.len(), + 1, + "{}: expected exactly one '{expected_id}' entry, found {}", + path.display(), + id_positions.len() + ); + + // `MessageId=N` must be immediately followed by `SymbolicName=NAME`, matching + // every existing entry's layout: this is what actually binds the numeric + // code emitted at runtime to this catalog's localized message text. + let (id_offset, _) = id_positions[0]; + let after_id = &content[id_offset..]; + let next_line_start = after_id.find('\n').map_or(after_id.len(), |index| index + 1); + let name_line = after_id[next_line_start..].lines().next().unwrap_or_default(); + assert_eq!( + name_line.trim(), + expected_name, + "{}: '{expected_id}' must be immediately followed by '{expected_name}', found '{name_line}'", + path.display() + ); + } + } +} diff --git a/crates/sysevent-winevent/src/lib.rs b/crates/sysevent-winevent/src/lib.rs index 1ba2d7718..af0b6fe5d 100644 --- a/crates/sysevent-winevent/src/lib.rs +++ b/crates/sysevent-winevent/src/lib.rs @@ -25,7 +25,7 @@ impl WinEvent { // SAFETY: Proper UTF-16, null-terminated string. let handle = unsafe { EventLog::RegisterEventSourceW(std::ptr::null(), source_name_utf16.as_ptr()) }; - if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + if is_null_handle(handle) { return Err(SysEventError::Platform(format!( "failed to register event source '{source_name}'" ))); @@ -132,7 +132,7 @@ impl SystemEventSink for WinEvent { impl Drop for WinEvent { fn drop(&mut self) { - if *self.handle != windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE as EventLogHandle { + if !is_null_handle(*self.handle as windows_sys::Win32::Foundation::HANDLE) { // SAFETY: DeregisterEventSource is thread-safe and idempotent. unsafe { EventLog::DeregisterEventSource(*self.handle as windows_sys::Win32::Foundation::HANDLE); @@ -141,6 +141,19 @@ impl Drop for WinEvent { } } +/// Returns whether a `HANDLE` returned by a Win32 API call represents failure to +/// register/open, i.e. is `NULL`. +/// +/// `RegisterEventSourceW` signals failure with a `NULL` handle -- *not* +/// `INVALID_HANDLE_VALUE` (`(HANDLE)-1`), which is what some other Win32 APIs (e.g. +/// `CreateFileW`) use instead; see the `RegisterEventSourceW` documentation. Treating a +/// `NULL` handle as valid would let every subsequent `ReportEventW` call silently fail +/// against a handle that was never actually registered, rather than surfacing the real +/// registration failure at construction time. +fn is_null_handle(handle: windows_sys::Win32::Foundation::HANDLE) -> bool { + handle.is_null() +} + fn severity_to_event_type(severity: Severity) -> u16 { match severity { Severity::Critical => EventLog::EVENTLOG_ERROR_TYPE, @@ -159,6 +172,35 @@ fn to_null_terminated_utf16(input: &str) -> Vec { mod tests { use super::*; + // ─── is_null_handle: the RegisterEventSourceW NULL-vs-INVALID_HANDLE_VALUE seam ─── + // + // `RegisterEventSourceW`/`DeregisterEventSource` themselves are not mockable here + // (they are direct FFI calls into `advapi32.dll`), but the failure-classification + // logic they depend on is a pure function over a raw handle value, so it is fully + // testable without touching the real Windows Event Log. + + #[test] + fn null_handle_is_detected_as_failure() { + assert!(is_null_handle(std::ptr::null_mut())); + } + + #[test] + fn invalid_handle_value_is_not_treated_as_a_null_handle() { + // Some Win32 APIs (e.g. `CreateFileW`) signal failure with `INVALID_HANDLE_VALUE` + // (`(HANDLE)-1`), but `RegisterEventSourceW` never returns it: pins that + // `is_null_handle` checks specifically for `NULL`, not `INVALID_HANDLE_VALUE`, + // which is the exact distinction this function exists to get right. + let invalid = windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + assert!(!invalid.is_null(), "sanity check: INVALID_HANDLE_VALUE is not NULL"); + assert!(!is_null_handle(invalid)); + } + + #[test] + fn nonzero_handle_is_not_a_failure() { + let handle = 0x1234usize as windows_sys::Win32::Foundation::HANDLE; + assert!(!is_null_handle(handle)); + } + #[test] fn severity_to_event_type_mapping() { assert_eq!( diff --git a/crates/win-api-wrappers/src/token.rs b/crates/win-api-wrappers/src/token.rs index deefeddba..384bea7cf 100644 --- a/crates/win-api-wrappers/src/token.rs +++ b/crates/win-api-wrappers/src/token.rs @@ -397,6 +397,36 @@ impl Token { Ok(is_elevated) } + /// Determines whether `sid` is an enabled group in this token, using the same + /// semantics as the Windows `IsUserAnAdmin`/UAC checks (deny-only and + /// not-enabled group memberships do not count). + /// + /// `CheckTokenMembership` requires an impersonation-level token, so this duplicates + /// the token (query-only access, `SecurityIdentification` level, which is sufficient + /// for a membership check and does not grant the duplicate any impersonation rights). + pub fn is_member(&self, sid: &Sid) -> anyhow::Result { + use windows::Win32::Security::CheckTokenMembership; + + let impersonation_token = self + .duplicate(TOKEN_QUERY, None, SecurityIdentification, Security::TokenImpersonation) + .context("duplicate token to impersonation level for membership check")?; + + let mut is_member = windows::core::BOOL(0); + + // SAFETY: `impersonation_token` is a valid, open impersonation-level token handle + // with TOKEN_QUERY access; `sid` is a valid SID; `is_member` is a live out-pointer. + unsafe { + CheckTokenMembership( + Some(impersonation_token.handle.raw()), + sid.as_psid_const(), + &mut is_member, + ) + } + .context("CheckTokenMembership failed")?; + + Ok(is_member.as_bool()) + } + pub fn linked_token(&self) -> anyhow::Result { // SAFETY: The TokenLinkedToken info class is associated to a HANDLE. let handle = unsafe { diff --git a/devolutions-agent/build.rs b/devolutions-agent/build.rs index b8d9ad669..b9217af93 100644 --- a/devolutions-agent/build.rs +++ b/devolutions-agent/build.rs @@ -3,6 +3,9 @@ fn main() { #[cfg(target_os = "windows")] win::embed_version_rc(); + + #[cfg(target_os = "windows")] + win::embed_devolutions_agent_mc(); } fn generate_psu_agent_proto() { @@ -100,4 +103,116 @@ END"#, version_rc } + + /// Compile `devolutions-agent.mc` (the Windows Event Log message catalog for the + /// package-broker policy audit trail; see `crates/sysevent-codes`) and link its + /// generated resources into the binary registered as this event source's + /// `EventMessageFile` (see `package/AgentWindowsManaged/Program.cs`). + /// + /// Mirrors `devolutions-gateway/build.rs`'s `embed_devolutions_gateway_mc`. + /// + /// Prerequisite (release builds only; debug builds never need this): `mc.exe` from + /// the Windows SDK must be resolvable, either already on `PATH` (e.g. by building + /// from a "Developer Command Prompt/PowerShell for VS"), or via the + /// `WindowsSdkVerBinPath`/`WindowsSdkDir` environment variable. See [`find_mc`] and + /// the CI workflow's "Find mc.exe" step for how this is resolved automatically there. + pub(super) fn embed_devolutions_agent_mc() { + use std::env; + use std::path::PathBuf; + use std::process::Command; + + // --- gate: only release builds ------------------------------------- + let profile = env::var("PROFILE").unwrap_or_default(); + if profile != "release" { + return; + } + + // --- gate: missing mc.exe is a hard failure for release builds ----- + // + // A release build silently missing the Windows Event Log message-table resource + // would ship without formatted audit messages (the package-broker policy audit + // trail; see `crates/sysevent-codes`) and without anyone noticing until an + // operator inspects Event Viewer. Debug builds stay free of this requirement + // (gated above) so an ordinary `cargo build`/`cargo check` never needs the + // Windows SDK, but a release build must fail loudly and actionably instead of + // silently producing an incomplete binary. + let mc_exe_path = find_mc().unwrap_or_else(|| { + panic!( + "mc.exe not found, but it is required to embed the Windows Event Log message catalog \ + in a release build of Devolutions Agent (see `embed_devolutions_agent_mc` in this build \ + script). Build from a \"Developer Command Prompt/PowerShell for VS\" (or otherwise put the \ + Windows SDK `bin\\\\x64` directory on PATH), or set the `WindowsSdkVerBinPath` or \ + `WindowsSdkDir` environment variable to the Windows SDK installation. This is not required \ + for debug builds." + ) + }); + + // --- inputs/paths --------------------------------------------------- + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let mc_file = manifest_dir.join("devolutions-agent.mc"); + + // Always tell Cargo to re-run if the .mc changes. + println!("cargo:rerun-if-changed={}", mc_file.display()); + + // --- prepare OUT_DIR ------------------------------------------------ + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")); + // We'll run mc.exe with current_dir = OUT_DIR so the generated .rc lands in OUT_DIR. + let rc_path = out_dir.join("devolutions-agent.rc"); + + // --- run mc.exe ----------------------------------------------------- + // Requires Windows SDK tools in PATH (use a "x64 Native Tools Command Prompt for VS"). + // Flags: + // -u : Unicode + // -m : Generate message resource .bin files + // -h : header output dir (we put it in OUT_DIR; the header is unused by Rust) + // -r : message .bin output dir (OUT_DIR) + let status = Command::new(mc_exe_path) + .current_dir(&out_dir) + .arg("-um") + .arg("-h") + .arg(".") + .arg("-r") + .arg(".") + .arg(mc_file.canonicalize().expect("failed to canonicalize .mc path")) + .status() + .expect("failed to spawn mc.exe"); + if !status.success() { + panic!("mc.exe failed with status {status}"); + } + + // --- compile the generated .rc via embed-resource ------------------- + if !rc_path.exists() { + panic!("mc.exe did not produce expected .rc file at {}", rc_path.display()); + } + + embed_resource::compile(rc_path, embed_resource::NONE) + .manifest_required() + .expect("BUG: failed to embed devolutions-agent.rc"); + + // Optional: make Cargo re-run if locale bins change (paranoid but harmless). + // These are standard names emitted by mc.exe for EN/FR/DE in our .mc. + for loc in &["MSG00409.bin", "MSG0040c.bin", "MSG00407.bin"] { + let p = out_dir.join(loc); + println!("cargo:rerun-if-changed={}", p.display()); + } + } + + fn find_mc() -> Option { + if let Ok(sdk_bin) = env::var("WindowsSdkVerBinPath") { + let p = std::path::Path::new(&sdk_bin).join("mc.exe"); + if p.exists() { + return Some(p); + } + } + + if let Ok(sdk_dir) = env::var("WindowsSdkDir") { + // e.g. C:\Program Files (x86)\Windows Kits\10\ + let candidate = std::path::Path::new(&sdk_dir).join("bin").join("x64").join("mc.exe"); + if candidate.exists() { + return Some(candidate); + } + } + + None + } } diff --git a/devolutions-agent/devolutions-agent.mc b/devolutions-agent/devolutions-agent.mc new file mode 100644 index 000000000..c62d7c870 --- /dev/null +++ b/devolutions-agent/devolutions-agent.mc @@ -0,0 +1,498 @@ +; ---------------------------------------------------------------------- +; Devolutions Agent - Windows Event Log message definitions (.mc) +; English (0x409), French (0x40c), German (0x407) +; +; Mirrors the shared `sysevent-codes` crate's event catalog (see +; `devolutions-gateway/devolutions-gateway.mc` for the analogous Gateway catalog): every +; `pub const *: u32` code declared there has a matching `MessageId`/`SymbolicName` entry +; here, kept in sync by the `message_catalog_parity` test in `crates/sysevent-codes`. +; ---------------------------------------------------------------------- + +MessageIdTypedef=DWORD + +SeverityNames=( + Success=0x0:STATUS_SEVERITY_SUCCESS + Informational=0x1:STATUS_SEVERITY_INFORMATIONAL + Warning=0x2:STATUS_SEVERITY_WARNING + Error=0x3:STATUS_SEVERITY_ERROR +) + +FacilityNames=( + Application=0x0:FACILITY_APPLICATION +) + +LanguageNames=( + English=0x409:MSG00409 + French=0x40c:MSG0040c + German=0x407:MSG00407 +) + +; ====================================================================== +; 1000-1099 Service / Lifecycle +; ====================================================================== + +MessageId=1000 +SymbolicName=SERVICE_STARTED +Language=English +Service started. Context=%1 Version=%2 +Language=French +Service démarré. Contexte=%1 Version=%2 +Language=German +Dienst gestartet. Kontext=%1 Version=%2 +. + +MessageId=1001 +SymbolicName=SERVICE_STOPPING +Language=English +Service stopping. Context=%1 Reason=%2 +Language=French +Arrêt du service. Contexte=%1 Raison=%2 +Language=German +Dienst wird gestoppt. Kontext=%1 Grund=%2 +. + +MessageId=1010 +SymbolicName=CONFIG_INVALID +Language=English +Configuration invalid. Context=%1 Path=%2 Error=%3 Reason=%4 +Language=French +Configuration invalide. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +Language=German +Ungültige Konfiguration. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=1020 +SymbolicName=START_FAILED +Language=English +Start failed. Context=%1 Cause=%2 Error=%3 +Language=French +Échec du démarrage. Contexte=%1 Cause=%2 Erreur=%3 +Language=German +Start fehlgeschlagen. Kontext=%1 Ursache=%2 Fehler=%3 +. + +MessageId=1030 +SymbolicName=BOOT_STACKTRACE_WRITTEN +Language=English +Boot stacktrace written. Context=%1 Path=%2 +Language=French +Trace d’amorçage écrite. Contexte=%1 Chemin=%2 +Language=German +Boot-Stacktrace geschrieben. Kontext=%1 Pfad=%2 +. + +; ====================================================================== +; 2000-2099 Listeners & Networking +; ====================================================================== + +MessageId=2000 +SymbolicName=LISTENER_STARTED +Language=English +Listener started. Context=%1 Address=%2 Proto=%3 +Language=French +Écouteur démarré. Contexte=%1 Adresse=%2 Protocole=%3 +Language=German +Listener gestartet. Kontext=%1 Adresse=%2 Protokoll=%3 +. + +MessageId=2001 +SymbolicName=LISTENER_BIND_FAILED +Language=English +Listener bind failed. Context=%1 Address=%2 Error=%3 +Language=French +Échec de l’attachement de l’écouteur. Contexte=%1 Adresse=%2 Erreur=%3 +Language=German +Listener-Bind fehlgeschlagen. Kontext=%1 Adresse=%2 Fehler=%3 +. + +MessageId=2002 +SymbolicName=LISTENER_STOPPED +Language=English +Listener stopped. Context=%1 Address=%2 Reason=%3 +Language=French +Écouteur arrêté. Contexte=%1 Adresse=%2 Raison=%3 +Language=German +Listener gestoppt. Kontext=%1 Adresse=%2 Grund=%3 +. + +; ====================================================================== +; 3000-3099 TLS / Certificates +; ====================================================================== + +MessageId=3000 +SymbolicName=TLS_CONFIGURED +Language=English +TLS configured. Context=%1 Source=%2 +Language=French +TLS configuré. Contexte=%1 Source=%2 +Language=German +TLS konfiguriert. Kontext=%1 Quelle=%2 +. + +MessageId=3001 +SymbolicName=TLS_VERIFY_STRICT_DISABLED +Language=English +TLS strict verification disabled. Context=%1 Mode=%2 +Language=French +Vérification stricte TLS désactivée. Contexte=%1 Mode=%2 +Language=German +Strikte TLS-Überprüfung deaktiviert. Kontext=%1 Modus=%2 +. + +MessageId=3002 +SymbolicName=TLS_CERTIFICATE_REJECTED +Language=English +Certificate rejected. Context=%1 Subject=%2 Reason=%3 +Language=French +Certificat rejeté. Contexte=%1 Sujet=%2 Raison=%3 +Language=German +Zertifikat abgelehnt. Kontext=%1 Betreff=%2 Grund=%3 +. + +MessageId=3003 +SymbolicName=SYSTEM_CERT_SELECTED +Language=English +System certificate selected. Context=%1 Thumbprint=%2 Subject=%3 +Language=French +Certificat système sélectionné. Contexte=%1 Empreinte=%2 Sujet=%3 +Language=German +Systemzertifikat ausgewählt. Kontext=%1 Fingerabdruck=%2 Betreff=%3 +. + +MessageId=3004 +SymbolicName=TLS_KEY_LOAD_FAILED +Language=English +TLS key/cert load failed. Context=%1 Path=%2 Error=%3 Reason=%4 +Language=French +Échec du chargement de la clé/cert TLS. Contexte=%1 Chemin=%2 Erreur=%3 Raison=%4 +Language=German +TLS-Schlüssel/Zertifikat konnte nicht geladen werden. Kontext=%1 Pfad=%2 Fehler=%3 Grund=%4 +. + +MessageId=3005 +SymbolicName=TLS_CERTIFICATE_NAME_MISMATCH +Language=English +TLS certificate name mismatch. Context=%1 Hostname=%2 Subject=%3 Reason=%4 +Language=French +Nom du certificat TLS non concordant. Contexte=%1 Hôte=%2 Sujet=%3 Raison=%4 +Language=German +TLS-Zertifikat-Namen stimmt nicht überein. Kontext=%1 Hostname=%2 Betreff=%3 Grund=%4 +. + +MessageId=3006 +SymbolicName=TLS_NO_SUITABLE_CERTIFICATE +Language=English +No suitable certificate found. Context=%1 Error=%2 Issues=%3 +Language=French +Aucun certificat approprié trouvé. Contexte=%1 Erreur=%2 Problèmes=%3 +Language=German +Kein geeignetes Zertifikat gefunden. Kontext=%1 Fehler=%2 Probleme=%3 +. + +; ====================================================================== +; 4000-4099 Sessions, Tokens & Recording +; ====================================================================== + +MessageId=4000 +SymbolicName=SESSION_OPENED +Language=English +Session opened. Context=%1 Protocol=%2 Client=%3 Target=%4 TokenId=%5 +Language=French +Session ouverte. Contexte=%1 Protocole=%2 Client=%3 Cible=%4 Jeton=%5 +Language=German +Sitzung geöffnet. Kontext=%1 Protokoll=%2 Client=%3 Ziel=%4 Token=%5 +. + +MessageId=4001 +SymbolicName=SESSION_CLOSED +Language=English +Session closed. Context=%1 DurationMs=%2 BytesTx=%3 BytesRx=%4 Outcome=%5 +Language=French +Session fermée. Contexte=%1 DuréeMs=%2 OctetsTx=%3 OctetsRx=%4 Résultat=%5 +Language=German +Sitzung geschlossen. Kontext=%1 DauerMs=%2 BytesTx=%3 BytesRx=%4 Ergebnis=%5 +. + +MessageId=4010 +SymbolicName=TOKEN_PROVISIONED +Language=English +Token provisioned. Context=%1 TokenId=%2 +Language=French +Jeton provisionné. Contexte=%1 Jeton=%2 +Language=German +Token bereitgestellt. Kontext=%1 Token=%2 +. + +MessageId=4011 +SymbolicName=TOKEN_REUSED +Language=English +Token reused. Context=%1 TokenId=%2 ReuseCount=%3 +Language=French +Jeton réutilisé. Contexte=%1 Jeton=%2 Réutilisations=%3 +Language=German +Token wiederverwendet. Kontext=%1 Token=%2 Anzahl=%3 +. + +MessageId=4012 +SymbolicName=TOKEN_REUSE_LIMIT_EXCEEDED +Language=English +Token reuse limit exceeded. Context=%1 TokenId=%2 Limit=%3 Reason=%4 +Language=French +Limite de réutilisation du jeton dépassée. Contexte=%1 Jeton=%2 Limite=%3 Raison=%4 +Language=German +Token-Wiederverwendungsgrenze überschritten. Kontext=%1 Token=%2 Limit=%3 Grund=%4 +. + +MessageId=4030 +SymbolicName=RECORDING_STARTED +Language=English +Recording started. Context=%1 Destination=%2 +Language=French +Enregistrement démarré. Contexte=%1 Destination=%2 +Language=German +Aufnahme gestartet. Kontext=%1 Ziel=%2 +. + +MessageId=4031 +SymbolicName=RECORDING_STOPPED +Language=English +Recording stopped. Context=%1 Bytes=%2 Files=%3 +Language=French +Enregistrement arrêté. Contexte=%1 Octets=%2 Fichiers=%3 +Language=German +Aufnahme gestoppt. Kontext=%1 Bytes=%2 Dateien=%3 +. + +MessageId=4032 +SymbolicName=RECORDING_ERROR +Language=English +Recording error. Context=%1 Path=%2 Error=%3 +Language=French +Erreur d’enregistrement. Contexte=%1 Chemin=%2 Erreur=%3 +Language=German +Aufnahmefehler. Kontext=%1 Pfad=%2 Fehler=%3 +. + +; ====================================================================== +; 5000-5099 Authentication / Authorization +; ====================================================================== + +MessageId=5001 +SymbolicName=JWT_REJECTED +Language=English +JWT rejected. Context=%1 ReasonCode=%2 Reason=%3 +Language=French +JWT rejeté. Contexte=%1 CodeRaison=%2 Raison=%3 +Language=German +JWT abgelehnt. Kontext=%1 GrundCode=%2 Grund=%3 +. + +MessageId=5002 +SymbolicName=JWT_ANOMALY +Language=English +JWT anomaly. Context=%1 Issuer=%2 Audience=%3 Kid=%4 Kind=%5 Detail=%6 +Language=French +Anomalie JWT. Contexte=%1 Émetteur=%2 Audience=%3 Kid=%4 Type=%5 Détail=%6 +Language=German +JWT-Anomalie. Kontext=%1 Aussteller=%2 Audience=%3 Kid=%4 Typ=%5 Detail=%6 +. + +MessageId=5010 +SymbolicName=AUTHORIZATION_DENIED +Language=English +Authorization denied. Context=%1 Subject=%2 Action=%3 Resource=%4 Rule=%5 Reason=%6 +Language=French +Autorisation refusée. Contexte=%1 Sujet=%2 Action=%3 Ressource=%4 Règle=%5 Raison=%6 +Language=German +Autorisierung verweigert. Kontext=%1 Subjekt=%2 Aktion=%3 Ressource=%4 Regel=%5 Grund=%6 +. + +MessageId=5090 +SymbolicName=AUTH_SUMMARY +Language=English +Auth summary. Context=%1 IntervalSec=%2 JwtOk=%3 JwtRejected=%4 Denied=%5 ByReason=%6 +Language=French +Résumé d’auth. Contexte=%1 IntervalSec=%2 JwtOk=%3 JwtRejeté=%4 Refusé=%5 ParRaison=%6 +Language=German +Auth-Zusammenfassung. Kontext=%1 IntervallSek=%2 JwtOk=%3 JwtAbgelehnt=%4 Verweigert=%5 NachGrund=%6 +. + +; ====================================================================== +; 6000-6099 Agent Integration +; ====================================================================== + +MessageId=6000 +SymbolicName=USER_SESSION_PROCESS_STARTED +Language=English +User session process started. Context=%1 SessionId=%2 Kind=%3 Exe=%4 +Language=French +Processus de session utilisateur démarré. Contexte=%1 SessionId=%2 Type=%3 Exe=%4 +Language=German +Benutzersitzungsprozess gestartet. Kontext=%1 SessionId=%2 Typ=%3 Exe=%4 +. + +MessageId=6001 +SymbolicName=USER_SESSION_PROCESS_TERMINATED +Language=English +User session process terminated. Context=%1 SessionId=%2 ExitCode=%3 By=%4 +Language=French +Processus de session utilisateur terminé. Contexte=%1 SessionId=%2 CodeSortie=%3 Par=%4 +Language=German +Benutzersitzungsprozess beendet. Kontext=%1 SessionId=%2 ExitCode=%3 Durch=%4 +. + +MessageId=6010 +SymbolicName=UPDATER_TASK_ENABLED +Language=English +Updater task enabled. Context=%1 +Language=French +Tâche de mise à jour activée. Contexte=%1 +Language=German +Update-Aufgabe aktiviert. Kontext=%1 +. + +MessageId=6011 +SymbolicName=UPDATER_ERROR +Language=English +Updater error. Context=%1 Step=%2 Error=%3 +Language=French +Erreur de mise à jour. Contexte=%1 Étape=%2 Erreur=%3 +Language=German +Update-Fehler. Kontext=%1 Schritt=%2 Fehler=%3 +. + +MessageId=6020 +SymbolicName=PEDM_ENABLED +Language=English +PEDM enabled. Context=%1 +Language=French +PEDM activé. Contexte=%1 +Language=German +PEDM aktiviert. Kontext=%1 +. + +; ====================================================================== +; 7000-7099 Health +; ====================================================================== + +MessageId=7010 +SymbolicName=RECORDING_STORAGE_LOW +Language=English +Recording storage low. Context=%1 RemainingBytes=%2 ThresholdBytes=%3 +Language=French +Espace d’enregistrement faible. Contexte=%1 OctetsRestants=%2 Seuil=%3 +Language=German +Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 +. + +; ====================================================================== +; 8000-8099 Package Broker / Policy Management +; +; Audit trail for the Agent package-broker managed policy store (`PUT /v1/policy` and +; external edits detected by the store's file watcher). Never carries full policy +; content: only actor identity, intent, path, old/new policy id/revision, and outcome. +; ====================================================================== + +MessageId=8000 +SymbolicName=POLICY_WRITE_ATTEMPTED +Language=English +Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +Language=French +Tentative d’écriture de gestion de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +Language=German +Richtlinienverwaltungsschreibung versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 +. + +MessageId=8001 +SymbolicName=POLICY_WRITE_DENIED +Language=English +Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +Language=French +Écriture de gestion de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +Language=German +Richtlinienverwaltungsschreibung verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 +. + +MessageId=8002 +SymbolicName=POLICY_WRITE_CONFLICT +Language=English +Policy management write conflict. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +Language=French +Conflit d’écriture de gestion de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +Language=German +Richtlinienverwaltungsschreibungskonflikt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 +. + +MessageId=8003 +SymbolicName=POLICY_WRITE_CONFIRMED_OVERWRITE +Language=English +Policy management confirmed overwrite. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 +Language=French +Écrasement confirmé de la gestion de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 +Language=German +Richtlinienverwaltung bestätigtes Überschreiben. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 +. + +MessageId=8004 +SymbolicName=POLICY_WRITE_FAILED +Language=English +Policy management write failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +Language=French +Échec de l’écriture de gestion de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +Language=German +Richtlinienverwaltungsschreibung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 +. + +MessageId=8005 +SymbolicName=POLICY_WRITE_SUCCEEDED +Language=English +Policy management write succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 +Language=French +Écriture de gestion de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 +Language=German +Richtlinienverwaltungsschreibung erfolgreich. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 +. + +MessageId=8010 +SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED +Language=English +External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +Language=French +Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +Language=German +Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 +. + +MessageId=8011 +SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED +Language=English +External policy change rejected. Context=%1 Path=%2 Reason=%3 +Language=French +Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +Language=German +Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 +. + +; ====================================================================== +; 9000-9099 Diagnostics +; ====================================================================== + +MessageId=9001 +SymbolicName=DEBUG_OPTIONS_ENABLED +Language=English +Debug options enabled. Context=%1 Options=%2 +Language=French +Options de débogage activées. Contexte=%1 Options=%2 +Language=German +Debug-Optionen aktiviert. Kontext=%1 Optionen=%2 +. + +MessageId=9002 +SymbolicName=XMF_NOT_FOUND +Language=English +XMF not found. Context=%1 Path=%2 Error=%3 +Language=French +XMF introuvable. Contexte=%1 Chemin=%2 Erreur=%3 +Language=German +XMF nicht gefunden. Kontext=%1 Pfad=%2 Fehler=%3 +. diff --git a/devolutions-gateway/devolutions-gateway.mc b/devolutions-gateway/devolutions-gateway.mc index 4a9b99f8a..8db5043a5 100644 --- a/devolutions-gateway/devolutions-gateway.mc +++ b/devolutions-gateway/devolutions-gateway.mc @@ -380,6 +380,95 @@ Language=German Aufnahmespeicher niedrig. Kontext=%1 VerbleibendeBytes=%2 Schwelle=%3 . +; ====================================================================== +; 8000-8099 Package Broker / Policy Management +; +; Emitted by the Agent's package-broker policy store, not by the Gateway itself; kept +; here (like 6000-6099 Agent Integration) so this catalog stays a complete mirror of the +; shared `sysevent-codes` crate. See `devolutions-agent/devolutions-agent.mc` for the +; catalog actually linked into the Agent binary that emits these. +; ====================================================================== + +MessageId=8000 +SymbolicName=POLICY_WRITE_ATTEMPTED +Language=English +Policy management write attempted. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +Language=French +Tentative d’écriture de gestion de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +Language=German +Richtlinienverwaltungsschreibung versucht. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 +. + +MessageId=8001 +SymbolicName=POLICY_WRITE_DENIED +Language=English +Policy management write denied. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +Language=French +Écriture de gestion de politique refusée. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +Language=German +Richtlinienverwaltungsschreibung verweigert. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 +. + +MessageId=8002 +SymbolicName=POLICY_WRITE_CONFLICT +Language=English +Policy management write conflict. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 +Language=French +Conflit d’écriture de gestion de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 +Language=German +Richtlinienverwaltungsschreibungskonflikt. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 +. + +MessageId=8003 +SymbolicName=POLICY_WRITE_CONFIRMED_OVERWRITE +Language=English +Policy management confirmed overwrite. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 +Language=French +Écrasement confirmé de la gestion de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 +Language=German +Richtlinienverwaltung bestätigtes Überschreiben. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 +. + +MessageId=8004 +SymbolicName=POLICY_WRITE_FAILED +Language=English +Policy management write failed. Context=%1 ActorSid=%2 ActorExe=%3 Intent=%4 Path=%5 Reason=%6 +Language=French +Échec de l’écriture de gestion de politique. Contexte=%1 SidActeur=%2 ExeActeur=%3 Intention=%4 Chemin=%5 Raison=%6 +Language=German +Richtlinienverwaltungsschreibung fehlgeschlagen. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Absicht=%4 Pfad=%5 Grund=%6 +. + +MessageId=8005 +SymbolicName=POLICY_WRITE_SUCCEEDED +Language=English +Policy management write succeeded. Context=%1 ActorSid=%2 ActorExe=%3 Path=%4 OldId=%5 OldRevision=%6 NewId=%7 NewRevision=%8 Intent=%9 +Language=French +Écriture de gestion de politique réussie. Contexte=%1 SidActeur=%2 ExeActeur=%3 Chemin=%4 AncienId=%5 AncienneRévision=%6 NouvelId=%7 NouvelleRévision=%8 Intention=%9 +Language=German +Richtlinienverwaltungsschreibung erfolgreich. Kontext=%1 AkteurSid=%2 AkteurExe=%3 Pfad=%4 AlteId=%5 AlteRevision=%6 NeueId=%7 NeueRevision=%8 Absicht=%9 +. + +MessageId=8010 +SymbolicName=POLICY_EXTERNAL_CHANGE_APPLIED +Language=English +External policy change applied. Context=%1 Path=%2 NewId=%3 NewRevision=%4 +Language=French +Modification externe de la politique appliquée. Contexte=%1 Chemin=%2 NouvelId=%3 NouvelleRévision=%4 +Language=German +Externe Richtlinienänderung angewendet. Kontext=%1 Pfad=%2 NeueId=%3 NeueRevision=%4 +. + +MessageId=8011 +SymbolicName=POLICY_EXTERNAL_CHANGE_REJECTED +Language=English +External policy change rejected. Context=%1 Path=%2 Reason=%3 +Language=French +Modification externe de la politique rejetée. Contexte=%1 Chemin=%2 Raison=%3 +Language=German +Externe Richtlinienänderung abgelehnt. Kontext=%1 Pfad=%2 Grund=%3 +. + ; ====================================================================== ; 9000-9099 Diagnostics ; ====================================================================== diff --git a/package/AgentWindowsManaged/Actions/AgentActions.cs b/package/AgentWindowsManaged/Actions/AgentActions.cs index c167ee43c..7f1430aae 100644 --- a/package/AgentWindowsManaged/Actions/AgentActions.cs +++ b/package/AgentWindowsManaged/Actions/AgentActions.cs @@ -121,6 +121,21 @@ internal static class AgentActions Features.PEDM_FEATURE.BeingInstall(), Sequence.InstallExecuteSequence); + /// + /// Create the dedicated package-broker policy directory %ProgramData%\Devolutions\PackageBroker + /// if it does not exist. Always created (not gated on a feature), mirroring + /// : the runtime itself also creates and + /// secures this directory on demand (see now-package-broker::policy_store::windows), + /// so this is a best-effort head start rather than the only place it can happen. + /// + private static readonly ElevatedManagedAction createProgramDataPackageBrokerDirectory = new( + new Id($"CA.{nameof(createProgramDataPackageBrokerDirectory)}"), + CustomActions.CreateProgramDataPackageBrokerDirectory, + Return.check, + When.After, Step.CreateFolders, + Condition.Always, + Sequence.InstallExecuteSequence); + /// /// Set or reset the ACL on %ProgramData%\Devolutions\Agent /// @@ -151,6 +166,27 @@ internal static class AgentActions Impersonate = false, }; + /// + /// Set or reset the ACL on %ProgramData%\Devolutions\PackageBroker to + /// SYSTEM/Administrators-only. Deliberately its own dedicated action rather than + /// reusing : that one's + /// additionally grants LOCAL SERVICE and + /// Users access for unrelated Agent features, which the policy directory's own + /// strict ancestor-security check must never see (see + /// ). + /// + private static readonly ElevatedManagedAction setProgramDataPackageBrokerDirectoryPermissions = new( + new Id($"CA.{nameof(setProgramDataPackageBrokerDirectoryPermissions)}"), + CustomActions.SetProgramDataPackageBrokerDirectoryPermissions, + Return.ignore, + When.After, new Step(createProgramDataPackageBrokerDirectory.Id), + Condition.Always, + Sequence.InstallExecuteSequence) + { + Execute = Execute.deferred, + Impersonate = false, + }; + private static readonly ElevatedManagedAction cleanAgentConfigIfNeeded = new( new Id($"CA.{nameof(cleanAgentConfigIfNeeded)}"), CustomActions.CleanAgentConfig, @@ -499,6 +535,8 @@ private static string UseProperties(IEnumerable properties) setProgramDataDirectoryPermissions, createProgramDataPedmDirectories, setProgramDataPedmDirectoryPermissions, + createProgramDataPackageBrokerDirectory, + setProgramDataPackageBrokerDirectoryPermissions, initAgentConfigIfNeeded, registerExplorerCommand, registerExplorerCommandRollback, diff --git a/package/AgentWindowsManaged/Actions/CustomActions.cs b/package/AgentWindowsManaged/Actions/CustomActions.cs index 012809320..2dd3760f5 100644 --- a/package/AgentWindowsManaged/Actions/CustomActions.cs +++ b/package/AgentWindowsManaged/Actions/CustomActions.cs @@ -42,6 +42,15 @@ public class CustomActions Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Devolutions", "Agent"); + /// + /// Dedicated root hosting the package-broker managed policy file: a top-level + /// sibling of , not a subdirectory of it (see + /// for why). + /// + private static string ProgramDataPackageBrokerDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Devolutions", "PackageBroker"); + [CustomAction] public static ActionResult CheckInstalledNetFx45Version(Session session) { @@ -185,6 +194,30 @@ public static ActionResult CreateProgramDataPedmDirectories(Session session) return ActionResult.Success; } + /// + /// Create the dedicated %ProgramData%\Devolutions\PackageBroker directory (if it + /// does not already exist) hosting the package-broker managed policy file. Its + /// ACL is applied separately by . + /// + [CustomAction] + public static ActionResult CreateProgramDataPackageBrokerDirectory(Session session) + { + string path = ProgramDataPackageBrokerDirectory; + + try + { + DirectoryInfo di = Directory.CreateDirectory(path); + session.Log($"created directory at {di.FullName} or already exists"); + } + catch (Exception e) + { + session.Log($"failed to evaluate or create path {path}: {e}"); + return ActionResult.Failure; + } + + return ActionResult.Success; + } + [CustomAction] public static ActionResult GetInstallDirFromRegistry(Session session) { @@ -1717,6 +1750,26 @@ public static ActionResult SetProgramDataPedmDirectoryPermissions(Session sessio } } + /// + /// Set or reset the ACL on %ProgramData%\Devolutions\PackageBroker to + /// SYSTEM/Administrators-only (no LOCAL SERVICE, no Users), matching what the + /// package broker's own strict ancestor-security check requires at runtime. + /// + [CustomAction] + public static ActionResult SetProgramDataPackageBrokerDirectoryPermissions(Session session) + { + try + { + SetFileSecurity(session, ProgramDataPackageBrokerDirectory, Includes.PROGRAM_DATA_PACKAGE_BROKER_SDDL); + return ActionResult.Success; + } + catch (Exception e) + { + session.Log($"failed to set permissions: {e}"); + return ActionResult.Failure; + } + } + [CustomAction] public static ActionResult ShutdownDesktopApp(Session session) { diff --git a/package/AgentWindowsManaged/Program.cs b/package/AgentWindowsManaged/Program.cs index d2a246305..c3d34d199 100644 --- a/package/AgentWindowsManaged/Program.cs +++ b/package/AgentWindowsManaged/Program.cs @@ -348,6 +348,16 @@ static void Main() Win64 = project.Platform == Platform.x64, RegistryKeyAction = RegistryKeyAction.create, Feature = Features.PSU_FEATURE, + }, + // Registers "Devolutions Agent" as a Windows Event Log source, so the package + // broker's policy-management audit trail (attempts/denials/conflicts/writes) + // reported through sysevent-winevent is attributed to a named source instead + // of an unregistered one. Mirrors the equivalent Gateway installer entry. + new (RegistryHive.LocalMachine, $"SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\{Includes.PRODUCT_NAME}", "EventMessageFile", $"[{AgentProperties.InstallDir}]{Includes.EXECUTABLE_NAME}") + { + AttributesDefinition = "Type=string", + Win64 = project.Platform == Platform.x64, + RegistryKeyAction = RegistryKeyAction.createAndRemoveOnUninstall, } }; diff --git a/package/AgentWindowsManaged/Resources/Includes.cs b/package/AgentWindowsManaged/Resources/Includes.cs index 5ee9e12ae..06ca7253d 100644 --- a/package/AgentWindowsManaged/Resources/Includes.cs +++ b/package/AgentWindowsManaged/Resources/Includes.cs @@ -60,5 +60,27 @@ internal static class Includes /// NT AUTHORITY\SYSTEM Allow FullControl /// internal static readonly string PROGRAM_DATA_PEDM_SDDL = "O:SYG:SYD:(A;OICI;FA;;;SY)"; + + /// + /// SDDL string representing desired %programdata%\devolutions\packagebroker ACL + /// Easiest way to generate an SDDL is to configure the required access, and then query the path with PowerShell: `Get-Acl | Format-List` + /// + /// + /// Owner : NT AUTHORITY\SYSTEM + /// Group : NT AUTHORITY\SYSTEM + /// Access : + /// NT AUTHORITY\SYSTEM Allow FullControl + /// BUILTIN\Administrators Allow FullControl + /// + /// + /// Deliberately a dedicated, top-level sibling of %programdata%\devolutions\agent + /// rather than a subdirectory of it, and deliberately narrower than + /// PROGRAM_DATA_SDDL: it grants neither LOCAL SERVICE nor Users any access at + /// all, so the package-broker managed policy directory's own strict + /// ancestor-security check (SYSTEM/Administrators only) is never defeated by a + /// grant that only makes sense for the shared Agent directory's unrelated + /// features. See now-package-broker::policy_store::windows::default_policy_dir. + /// + internal static readonly string PROGRAM_DATA_PACKAGE_BROKER_SDDL = "O:SYG:SYD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; } } From be3a9edf5e6be4fbe7a294303333da31eccb8d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 29 Aug 2026 21:11:39 +0900 Subject: [PATCH 2/5] fix(agent): stabilize policy validation on CI Resolve policy parents through held handles before comparing leaf names, reject multi-link files using link-count metadata, and run unelevated E2E under a verified restricted token. Select one validated Windows message compiler path so release resource builds receive a usable SDK directory. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 25 ++- Cargo.lock | 2 + crates/agent-policy-tester/Cargo.toml | 2 + crates/agent-policy-tester/run-unelevated.ps1 | 16 ++ crates/agent-policy-tester/src/windows.rs | 28 +++ .../now-package-broker/src/policy_security.rs | 22 +++ .../src/policy_store/windows.rs | 178 ++++++++++-------- 7 files changed, 182 insertions(+), 91 deletions(-) create mode 100644 crates/agent-policy-tester/run-unelevated.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25cb0af7a..b6432fe03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -699,7 +699,11 @@ jobs: if: ${{ matrix.os == 'windows' }} run: | Enter-VsDevShell - $path = (Get-Command -Type Application mc).Source | Split-Path -Parent + $mc = Get-Command -Name mc.exe -CommandType Application -All | Select-Object -First 1 + if ($null -eq $mc -or -not (Test-Path -LiteralPath $mc.Source -PathType Leaf)) { + throw "mc.exe was not found in the Visual Studio developer environment" + } + $path = Split-Path -Parent $mc.Source Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 shell: pwsh @@ -974,7 +978,11 @@ jobs: if: ${{ matrix.os == 'windows' }} run: | Enter-VsDevShell - $path = (Get-Command -Type Application mc).Source | Split-Path -Parent + $mc = Get-Command -Name mc.exe -CommandType Application -All | Select-Object -First 1 + if ($null -eq $mc -or -not (Test-Path -LiteralPath $mc.Source -PathType Leaf)) { + throw "mc.exe was not found in the Visual Studio developer environment" + } + $path = Split-Path -Parent $mc.Source Write-Output "windows_sdk_ver_bin_path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 shell: pwsh @@ -1326,15 +1334,16 @@ jobs: exit $LASTEXITCODE } - # Runs as the ordinary, unelevated CI runner account (never SYSTEM/elevated): the - # complementary half of the split test suite (item 23). Running the whole tester - # only under `psexec -s` (as the step below still does, for the privileged half) - # made the unelevated `PUT` denial assertion contradictory, since that process - # actually *is* elevated/SYSTEM. - name: Run Agent policy tester (unelevated) shell: pwsh run: | - cargo run --locked -p agent-policy-tester -- (Resolve-Path "./target/debug/devolutions-agent.exe") unelevated + $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-unelevated.ps1" + psexec -accepteula -l pwsh.exe -NoProfile -File $scriptPath + $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 (elevated) shell: pwsh diff --git a/Cargo.lock b/Cargo.lock index 1c9e38cf6..c7dddb256 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,8 @@ dependencies = [ "serde_json", "tempfile", "tokio 1.52.3", + "win-api-wrappers", + "windows 0.61.3", ] [[package]] diff --git a/crates/agent-policy-tester/Cargo.toml b/crates/agent-policy-tester/Cargo.toml index 071eb26df..c15076a6f 100644 --- a/crates/agent-policy-tester/Cargo.toml +++ b/crates/agent-policy-tester/Cargo.toml @@ -13,6 +13,8 @@ now-policy-server-template = "0.3" 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"] } [lints] workspace = true diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 new file mode 100644 index 000000000..523c019d4 --- /dev/null +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference = "Stop" + +$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" + +try { + & $testerPath $agentPath unelevated 2>&1 | Out-File $outputPath + $exitCode = $LASTEXITCODE +} catch { + $_ | Out-File $outputPath -Append + exit 1 +} + +exit $exitCode diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 885c4cc51..57258fa60 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -7,6 +7,9 @@ use now_policy_server_template::{MAX_POLICY_MANAGEMENT_BODY_BYTES, MAX_REQUEST_B 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::Security::{TOKEN_DUPLICATE, TOKEN_QUERY, WinBuiltinAdministratorsSid}; const FULL_POLICY: &str = include_str!("../../now-package-broker/src/assets/samples/corporate-allowlist.policy.json"); @@ -278,6 +281,7 @@ pub(crate) async fn run() -> anyhow::Result<()> { .context("usage: agent-policy-tester [unelevated|elevated]")?; let mode_arg = args.next(); let mode = Mode::parse(mode_arg.as_deref().and_then(|arg| arg.to_str()))?; + verify_process_token(mode)?; ensure!( agent_path.is_file(), @@ -318,6 +322,30 @@ pub(crate) async fn run() -> anyhow::Result<()> { Ok(()) } +fn verify_process_token(mode: Mode) -> anyhow::Result<()> { + if mode != Mode::Unelevated { + return Ok(()); + } + + let token = Process::current_process() + .token(TOKEN_QUERY | TOKEN_DUPLICATE) + .context("open tester process token")?; + ensure!( + !token.is_elevated().context("query tester token elevation")?, + "unelevated test mode requires a non-elevated process token" + ); + let administrators = + Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("construct built-in Administrators SID")?; + ensure!( + !token + .is_member(&administrators) + .context("query tester Administrators membership")?, + "unelevated test mode requires Administrators membership to be disabled" + ); + + Ok(()) +} + async fn request(pipe_name: &str, method: &str, path: &str) -> anyhow::Result { let deadline = Instant::now() + Duration::from_secs(10); let mut pipe = loop { diff --git a/crates/now-package-broker/src/policy_security.rs b/crates/now-package-broker/src/policy_security.rs index f806569ee..af54f6e94 100644 --- a/crates/now-package-broker/src/policy_security.rs +++ b/crates/now-package-broker/src/policy_security.rs @@ -376,6 +376,28 @@ pub(crate) fn file_identity(file: &File) -> anyhow::Result { }) } +/// Query how many directory entries link to the open file. +pub(crate) fn file_link_count(file: &File) -> anyhow::Result { + use windows::Win32::Storage::FileSystem::{FILE_STANDARD_INFO, FileStandardInfo, GetFileInformationByHandleEx}; + + let mut info = FILE_STANDARD_INFO::default(); + let info_size = u32::try_from(size_of::()).expect("FILE_STANDARD_INFO size fits in u32"); + + // SAFETY: `file` is an open file handle, and the output pointer points to a properly + // sized FILE_STANDARD_INFO valid for the duration of the call. + unsafe { + GetFileInformationByHandleEx( + HANDLE(file.as_raw_handle()), + FileStandardInfo, + (&raw mut info).cast(), + info_size, + ) + } + .context("GetFileInformationByHandleEx(FileStandardInfo) failed")?; + + Ok(info.NumberOfLinks) +} + /// A package-manager executable that was verified for elevated execution. /// /// The held file handle was opened without write or delete sharing, so the verified file diff --git a/crates/now-package-broker/src/policy_store/windows.rs b/crates/now-package-broker/src/policy_store/windows.rs index 3aecf6efc..86859f8eb 100644 --- a/crates/now-package-broker/src/policy_store/windows.rs +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -21,6 +21,7 @@ //! SYSTEM/Administrators-trusted writer (including an external editor) acting on the same //! file at the same time; Windows offers no primitive that closes that specific gap. +use std::ffi::OsStr; use std::fs::{File, OpenOptions}; use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; use std::path::{Path, PathBuf}; @@ -262,7 +263,7 @@ fn ensure_default_directory_secured(dir: &Path) -> anyhow::Result<(PathBuf, [u8; "an existing policy directory does not meet the required security bar; \ it was not created by this call and will not be silently repaired", )?; - let ancestor_security_digest = policy_security::verify_policy_ancestor_chain(dir, "policy directory")?; + let ancestor_security_digest = policy_security::verify_policy_ancestor_chain(&final_path, "policy directory")?; Ok((final_path, ancestor_security_digest)) } @@ -276,7 +277,7 @@ fn ensure_default_directory_secured(dir: &Path) -> anyhow::Result<(PathBuf, [u8; fn verify_custom_directory_secure(dir: &Path) -> anyhow::Result<(PathBuf, [u8; 32])> { let (handle, final_path) = open_and_verify_directory_identity(dir)?; policy_security::verify_policy_directory_security(&handle)?; - let ancestor_security_digest = policy_security::verify_policy_ancestor_chain(dir, "policy directory")?; + let ancestor_security_digest = policy_security::verify_policy_ancestor_chain(&final_path, "policy directory")?; Ok((final_path, ancestor_security_digest)) } @@ -563,6 +564,25 @@ struct InvalidContext { security_digest: Option<[u8; 32]>, } +fn resolved_policy_path_matches(resolved: &Path, canonical_parent: &Path, configured_leaf: &OsStr) -> bool { + let Some(resolved_parent) = resolved.parent() else { + return false; + }; + let Some(resolved_leaf) = resolved.file_name() else { + return false; + }; + + policy_security::paths_match_case_insensitive(resolved_parent, canonical_parent) + && paths_component_matches_case_insensitive(resolved_leaf, configured_leaf) +} + +fn paths_component_matches_case_insensitive(a: &OsStr, b: &OsStr) -> bool { + match (a.to_str(), b.to_str()) { + (Some(a), Some(b)) => a.eq_ignore_ascii_case(b), + _ => a == b, + } +} + /// Observe the exact current disk state of the configured policy file. /// /// Resolves (and, for the default path, idempotently creates) the canonical directory @@ -669,7 +689,7 @@ pub(super) fn observe( }; } }; - let canonical_path = canonical_dir.join(leaf_name); + let initial_canonical_path = canonical_dir.join(leaf_name); // Held open for the entire observation (see the doc comment above); dropped when this // function returns. @@ -678,7 +698,23 @@ pub(super) fn observe( Err(error) => { tracing::warn!(path = %canonical_dir.display(), %error, "Failed to open the configured policy directory"); return invalid_observation( - &canonical_path, + &initial_canonical_path, + validation::DiskFailureReason::Unreadable, + InvalidContext::default(), + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let canonical_dir = match policy_security::final_path_from_handle(&dir_handle) { + Ok(path) => path, + Err(error) => { + tracing::warn!( + path = %canonical_dir.display(), %error, + "Failed to resolve the configured policy directory's final path" + ); + return invalid_observation( + &initial_canonical_path, validation::DiskFailureReason::Unreadable, InvalidContext::default(), PolicyWriteCapability::ReadOnly, @@ -686,6 +722,7 @@ pub(super) fn observe( ); } }; + let canonical_path = canonical_dir.join(leaf_name); let parent = match policy_security::file_identity(&dir_handle) { Ok(identity) => identity, Err(error) => { @@ -793,20 +830,47 @@ pub(super) fn observe( ); } - // Reject a hard-link alias (item 22): the handle's own resolved final path must - // match the canonical directory and expected leaf name, so a name that happens to - // appear inside the verified directory but is actually a link to a different, - // untrusted object elsewhere is never trusted. The comparison is case-insensitive - // (same as the parent-directory comparison above it): Windows filesystems are - // case-insensitive but case-preserving, so the on-disk leaf may legitimately differ in - // case from the operator's configured path without being a different object at all. + // A policy leaf with multiple names is ambiguous regardless of which name + // GetFinalPathNameByHandleW happens to report. Reject it using file metadata rather + // than inferring link identity from that reported path. + let link_count = match policy_security::file_link_count(&file) { + Ok(link_count) => link_count, + Err(error) => { + tracing::warn!(path = %canonical_path.display(), %error, "Failed to query the configured policy file link count"); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::Unreadable, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + if link_count != 1 { + tracing::warn!( + path = %canonical_path.display(), + link_count, + "Configured policy file has multiple hard links" + ); + return invalid_observation( + &canonical_path, + validation::DiskFailureReason::InsecureStorage, + invalid_ctx, + PolicyWriteCapability::ReadOnly, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + + // Resolve both the parent and leaf from their held handles. This tolerates a lexical + // 8.3 alias in the configured parent while still requiring the resolved leaf to be + // exactly the configured name modulo Windows casing. match policy_security::final_path_from_handle(&file) { Ok(resolved) => { - let resolved_matches = policy_security::paths_match_case_insensitive(&resolved, &canonical_path); + let resolved_matches = resolved_policy_path_matches(&resolved, &canonical_dir, leaf_name); if !resolved_matches { tracing::warn!( path = %canonical_path.display(), resolved = %resolved.display(), - "Configured policy file resolved to an unexpected location; refusing to trust a hard-link alias" + "Configured policy file resolved to an unexpected location" ); return invalid_observation( &canonical_path, @@ -817,6 +881,7 @@ pub(super) fn observe( ); } } + Err(error) => { tracing::warn!( path = %canonical_path.display(), %error, @@ -1580,91 +1645,38 @@ mod tests { // volume, so this exercises the real alias-rejection path directly. #[test] - fn hard_link_alias_is_rejected_by_final_path_comparison() { + fn policy_leaf_with_multiple_hard_links_is_rejected() { let dir = temp_dir(); let real_file = dir.path().join("real-policy.json"); std::fs::write(&real_file, b"{}").unwrap(); let alias = dir.path().join("alias-policy.json"); std::fs::hard_link(&real_file, &alias).expect("create hard link"); - // Opening the alias name resolves, via its own handle, to a final path this - // process (deliberately) treats as *not* matching the alias name itself: the - // canonical directory/leaf-name comparison in `observe` must reject it. This - // proves the comparison primitive itself: `GetFinalPathNameByHandleW` reports - // one specific link for a multiply-linked file, and it need not be the name used - // to open it. let handle = OpenOptions::new() .read(true) .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) .open(&alias) .unwrap(); - let resolved = policy_security::final_path_from_handle(&handle).unwrap(); - - // Whichever of the two equally-valid names Windows reports, it must match - // *exactly one* of them, proving the comparison is meaningful (able to both - // accept a genuine match and reject a genuine mismatch) rather than vacuous. - let matches_real = policy_security::paths_match_case_insensitive(&resolved, &real_file); - let matches_alias = policy_security::paths_match_case_insensitive(&resolved, &alias); - assert!( - matches_real || matches_alias, - "resolved path {} matched neither hard-linked name", - resolved.display() - ); + assert_eq!(policy_security::file_link_count(&handle).unwrap(), 2); } - /// A leaf whose on-disk casing merely differs from the configured path must be - /// accepted as the same file, not rejected as though it were a hard-link alias to a - /// different object (item 22): Windows filesystems are case-insensitive but - /// case-preserving, so `GetFinalPathNameByHandleW` reports whatever casing was used - /// when the file was actually created on disk, which need not match the casing an - /// operator later configures. This exercises the exact comparison `observe` performs - /// (`paths_match_case_insensitive` over the full resolved path vs. the canonical - /// directory joined with the configured leaf name), directly proving the fix for a - /// prior exact (case-sensitive) `OsStr` leaf-name comparison that would have - /// wrongly rejected this legitimate case. #[test] - fn leaf_casing_difference_from_configured_name_is_accepted_by_final_path_comparison() { - let dir = temp_dir(); - // Create the file on disk with one casing... - let on_disk_path = dir.path().join("Policy-Casing.json"); - std::fs::write(&on_disk_path, b"{}").unwrap(); - - // ...but open it (as `observe` does) through a *different* casing of the same - // leaf name, as would happen if the operator configures the path with different - // casing than the file was originally created with. - let configured_path = dir.path().join("policy-casing.json"); - let handle = OpenOptions::new() - .read(true) - .share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) - .open(&configured_path) - .unwrap(); - let resolved = policy_security::final_path_from_handle(&handle).unwrap(); - - // The comparison `observe` performs against the canonical directory joined with - // the *configured* leaf name must accept this: it is the same file, differing - // only in casing, never a different, untrusted object. - assert!( - policy_security::paths_match_case_insensitive(&resolved, &configured_path), - "resolved path {} must match the differently-cased configured path {} \ - case-insensitively; a mere casing difference must never be treated as a \ - hard-link alias", - resolved.display(), - configured_path.display() - ); - - // The genuine alias-rejection case (a real hard link to a differently *named* - // file, not merely differently-cased) must still be rejected by the same - // comparison, proving it is meaningful rather than vacuously permissive. - let unrelated_path = dir.path().join("unrelated-name.json"); - std::fs::hard_link(&on_disk_path, &unrelated_path).expect("create hard link"); - assert!( - !policy_security::paths_match_case_insensitive(&resolved, &unrelated_path), - "resolved path {} must not match an unrelated hard-linked name {}", - resolved.display(), - unrelated_path.display() - ); + fn resolved_parent_alias_and_leaf_casing_are_compared_independently() { + let configured = Path::new(r"C:\RUNNER~1\AppData\Local\Temp\policy.json"); + let resolved_parent = Path::new(r"C:\actions\runneradmin\AppData\Local\Temp"); + let resolved_file = resolved_parent.join("Policy.JSON"); + + assert!(resolved_policy_path_matches( + &resolved_file, + resolved_parent, + configured.file_name().unwrap() + )); + assert!(!resolved_policy_path_matches( + &resolved_parent.join("other.json"), + resolved_parent, + configured.file_name().unwrap() + )); } // ─── DiskFingerprint::Invalid enrichment (item 15) ──────────────────────── From 9c85810a557f1c2931bfbc3280c6c2dc65798045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 29 Aug 2026 21:32:13 +0900 Subject: [PATCH 3/5] fix(agent): capture restricted E2E output Run the low-integrity test workspace from LocalLow and stream PsExec output through the CI parent instead of writing into the protected checkout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- crates/agent-policy-tester/run-unelevated.ps1 | 15 ++++++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6432fe03..ce36182e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1338,9 +1338,9 @@ jobs: shell: pwsh run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-unelevated.ps1" - psexec -accepteula -l pwsh.exe -NoProfile -File $scriptPath + $output = psexec -accepteula -l pwsh.exe -NoProfile -File $scriptPath 2>&1 $exitCode = $LASTEXITCODE - Get-Content -Path ./crates/agent-policy-tester/agent-policy-tester-unelevated.out + $output | Write-Output if ($exitCode -ne 0) { exit $exitCode } diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 523c019d4..0b4f45d75 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -3,14 +3,11 @@ $ErrorActionPreference = "Stop" $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" +$lowIntegrityTemp = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" -try { - & $testerPath $agentPath unelevated 2>&1 | Out-File $outputPath - $exitCode = $LASTEXITCODE -} catch { - $_ | Out-File $outputPath -Append - exit 1 -} +New-Item -ItemType Directory -Path $lowIntegrityTemp -Force | Out-Null +$env:TEMP = $lowIntegrityTemp +$env:TMP = $lowIntegrityTemp -exit $exitCode +& $testerPath $agentPath unelevated +exit $LASTEXITCODE From ecab43f7e60f62087731111bc08f75e1bd31631c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 29 Aug 2026 21:48:53 +0900 Subject: [PATCH 4/5] fix(agent): persist restricted E2E diagnostics Precreate a low-integrity writable transcript path and pass it explicitly to the restricted tester wrapper so CI can read child failures and results. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++-- crates/agent-policy-tester/run-unelevated.ps1 | 25 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce36182e2..07714d250 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1338,9 +1338,13 @@ jobs: shell: pwsh run: | $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-unelevated.ps1" - $output = psexec -accepteula -l pwsh.exe -NoProfile -File $scriptPath 2>&1 + $tempPath = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" + $outputPath = Join-Path $tempPath "agent-policy-tester-unelevated.out" + New-Item -ItemType Directory -Path $tempPath -Force | Out-Null + Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue + psexec -accepteula -l pwsh.exe -NoProfile -File $scriptPath -TempPath $tempPath -OutputPath $outputPath $exitCode = $LASTEXITCODE - $output | Write-Output + Get-Content -LiteralPath $outputPath if ($exitCode -ne 0) { exit $exitCode } diff --git a/crates/agent-policy-tester/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 index 0b4f45d75..8515377b8 100644 --- a/crates/agent-policy-tester/run-unelevated.ps1 +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -1,13 +1,26 @@ +param( + [Parameter(Mandatory = $true)] + [string] $TempPath, + + [Parameter(Mandatory = $true)] + [string] $OutputPath +) + $ErrorActionPreference = "Stop" $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" -$lowIntegrityTemp = Join-Path $env:USERPROFILE "AppData\LocalLow\Temp" -New-Item -ItemType Directory -Path $lowIntegrityTemp -Force | Out-Null -$env:TEMP = $lowIntegrityTemp -$env:TMP = $lowIntegrityTemp +try { + $env:TEMP = $TempPath + $env:TMP = $TempPath + + & $testerPath $agentPath unelevated 2>&1 | Out-File -LiteralPath $OutputPath + $exitCode = $LASTEXITCODE +} catch { + $_ | Out-File -LiteralPath $OutputPath -Append + exit 1 +} -& $testerPath $agentPath unelevated -exit $LASTEXITCODE +exit $exitCode From c1b72fe33b36dc1d80d79ee6d8d759d76ef9e275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 29 Aug 2026 22:04:51 +0900 Subject: [PATCH 5/5] fix(agent): assert effective write authorization Verify that the restricted E2E token has disabled Administrators membership and cannot satisfy the same elevation-plus-membership gate used by policy writes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/agent-policy-tester/src/windows.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 57258fa60..8e04a64b8 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -330,18 +330,23 @@ fn verify_process_token(mode: Mode) -> anyhow::Result<()> { let token = Process::current_process() .token(TOKEN_QUERY | TOKEN_DUPLICATE) .context("open tester process token")?; - ensure!( - !token.is_elevated().context("query tester token elevation")?, - "unelevated test mode requires a non-elevated process token" - ); + let is_elevated = token.is_elevated().context("query tester token elevation")?; let administrators = Sid::from_well_known(WinBuiltinAdministratorsSid, None).context("construct built-in Administrators SID")?; - ensure!( - !token - .is_member(&administrators) - .context("query tester Administrators membership")?, + let is_administrator = token + .is_member(&administrators) + .context("query tester Administrators membership")?; + // PsExec -l disables the Administrators group and lowers integrity, but Windows may + // retain TokenElevation from the source token. Match the server's real authorization + // rule instead of treating that informational flag alone as write authority. + ensure!( + !is_administrator, "unelevated test mode requires Administrators membership to be disabled" ); + ensure!( + !(is_elevated && is_administrator), + "unelevated test mode must not satisfy the policy-write authorization gate" + ); Ok(()) }