diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6d8729c9..07714d250 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 @@ -953,6 +957,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 +973,19 @@ jobs: if: ${{ matrix.os == 'windows' }} uses: microsoft/setup-msbuild@v3 + - name: Find mc.exe + id: find_mc + if: ${{ matrix.os == 'windows' }} + run: | + Enter-VsDevShell + $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 + - name: Build run: | if ($Env:RUNNER_OS -eq "Windows") { @@ -976,6 +996,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 +1334,22 @@ jobs: exit $LASTEXITCODE } - - name: Run Agent policy tester as LocalSystem + - name: Run Agent policy tester (unelevated) + shell: pwsh + run: | + $scriptPath = Resolve-Path -Path "./crates/agent-policy-tester/run-unelevated.ps1" + $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 + Get-Content -LiteralPath $outputPath + if ($exitCode -ne 0) { + exit $exitCode + } + + - 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 +1360,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..c7dddb256 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,9 +91,12 @@ version = "0.0.0" dependencies = [ "anyhow", "fastrand", + "now-policy-server-template", "serde_json", "tempfile", "tokio 1.52.3", + "win-api-wrappers", + "windows 0.61.3", ] [[package]] @@ -170,7 +173,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 +1575,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 +1944,7 @@ dependencies = [ name = "devolutions-pedm" version = "2026.2.4" dependencies = [ - "aide", + "aide 0.14.2", "anyhow", "async-trait", "axum 0.8.9", @@ -1941,7 +1965,7 @@ dependencies = [ "hyper-util", "libsql", "parking_lot", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "sha1 0.11.0", @@ -1994,7 +2018,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 +2345,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 +2661,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 +3168,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 +3188,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3683,7 +3707,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4767,6 +4791,7 @@ dependencies = [ "devolutions-agent-shared", "devolutions-gateway-task", "hex", + "hmac", "hyper 1.10.1", "hyper-util", "notify 7.0.0", @@ -4778,8 +4803,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 +4821,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 +4835,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 +4851,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 +4891,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 +6038,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 +6078,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 +6276,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 +6597,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6652,7 +6697,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6730,12 +6775,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 +6808,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 +7252,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 +7556,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 +9016,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..c15076a6f 100644 --- a/crates/agent-policy-tester/Cargo.toml +++ b/crates/agent-policy-tester/Cargo.toml @@ -9,9 +9,12 @@ 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"] } +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-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/run-unelevated.ps1 b/crates/agent-policy-tester/run-unelevated.ps1 new file mode 100644 index 000000000..8515377b8 --- /dev/null +++ b/crates/agent-policy-tester/run-unelevated.ps1 @@ -0,0 +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" + +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 +} + +exit $exitCode diff --git a/crates/agent-policy-tester/src/windows.rs b/crates/agent-policy-tester/src/windows.rs index 3ff4250a5..8e04a64b8 100644 --- a/crates/agent-policy-tester/src/windows.rs +++ b/crates/agent-policy-tester/src/windows.rs @@ -3,28 +3,163 @@ 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; +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"); +/// 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 +179,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 +192,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 +219,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 +248,105 @@ 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()))?; + verify_process_token(mode)?; + 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(()) +} + +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")?; + 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")?; + 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(()) } @@ -141,6 +376,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 +443,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 +483,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 +514,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 +536,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 +605,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..af54f6e94 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,223 @@ 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, + }) +} + +/// 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 @@ -254,7 +486,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 +671,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 +804,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 +964,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 +987,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 +1044,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 +1174,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 +1208,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..86859f8eb --- /dev/null +++ b/crates/now-package-broker/src/policy_store/windows.rs @@ -0,0 +1,1858 @@ +//! 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::ffi::OsStr; +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(&final_path, "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(&final_path, "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]>, +} + +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 +/// 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 initial_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( + &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, + Some(PolicyReadOnlyReason::UnsafePath), + ); + } + }; + let canonical_path = canonical_dir.join(leaf_name); + 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), + ); + } + + // 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 = 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" + ); + 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 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"); + + 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(); + assert_eq!(policy_security::file_link_count(&handle).unwrap(), 2); + } + + #[test] + 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) ──────────────────────── + + 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)"; } }