From 2873059b24294a4caa5ecc0eb764bf7ccc7390f9 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Mon, 27 Jul 2026 21:42:14 +0300 Subject: [PATCH 1/4] feat(policies): add UniGetUI package managers with version-gated support Add Apt, Bun, Cargo, Chocolatey, Dnf, Dotnet, Flatpak, Homebrew, Npm, Pacman, Pip, Scoop, Snap, and Vcpkg to the policy and broker API manager enums (Rust and .NET), bump the broker API version to 1.1, and regenerate the OpenAPI document and policy JSON schema. Managers introduced in 1.1 are version-gated: ManagerName exposes its minimum API version, CapabilitiesResponse gains manager support check methods distinguishing a protocol-version gap from a missing capability, and the .NET client refuses to send a too-new manager to an older broker with a distinct UnsupportedApiVersion error kind. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 6 +- .../Devolutions.Now.Policy.Api/BrokerApi.cs | 56 +++- .../Devolutions.Now.Policy.Api/Enums.cs | 14 + .../Devolutions.Now.Policy.Api/MetaModels.cs | 44 +++ .../PolicyCompatibility.cs | 28 ++ .../BrokerClientTests.cs | 41 +++ .../BrokerClient.cs | 12 + .../BrokerClientErrorKind.cs | 8 + .../Devolutions.Now.Policy.Client/README.md | 2 + .../Devolutions.Now.Policy.Model/Enums.cs | 14 + policies/rust/now-policy-api/Cargo.toml | 4 +- .../openapi/now-policy-api.yaml | 16 +- .../rust/now-policy-api/src/capabilities.rs | 132 ++++++++ policies/rust/now-policy-api/src/enums.rs | 63 ++++ policies/rust/now-policy-api/src/lib.rs | 20 +- .../rust/now-policy-api/src/policy_compat.rs | 24 +- .../now-policy-server-template/Cargo.toml | 6 +- .../chocolatey-git-install.request.json | 32 ++ .../responses/capabilities.response.json | 293 +++++++++++++++++- .../responses/health-ready.response.json | 4 +- .../now-policy-server-template/src/mock.rs | 172 ++++++++-- policies/rust/now-policy/Cargo.toml | 2 +- .../schema/devolutions.now-policy.schema.json | 16 +- policies/rust/now-policy/src/enums.rs | 28 ++ 24 files changed, 979 insertions(+), 58 deletions(-) create mode 100644 policies/rust/now-policy-server-template/assets/samples/requests/chocolatey-git-install.request.json diff --git a/Cargo.lock b/Cargo.lock index 30285e0..a19cd1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,7 +618,7 @@ dependencies = [ [[package]] name = "now-policy" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "schemars", @@ -632,7 +632,7 @@ dependencies = [ [[package]] name = "now-policy-api" -version = "0.1.0" +version = "0.2.0" dependencies = [ "base64", "chrono", @@ -648,7 +648,7 @@ dependencies = [ [[package]] name = "now-policy-server-template" -version = "0.1.0" +version = "0.2.0" dependencies = [ "aide", "async-trait", diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs index af57c12..a4f65f2 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs @@ -5,7 +5,7 @@ namespace Devolutions.Now.Policy.Api; /// Constants shared by package broker API clients and implementations. public static class BrokerApi { - public const string Version = "1.0"; + public const string Version = "1.1"; public const string DefaultPipeName = "Devolutions.Now.PackageBroker.v1"; @@ -28,4 +28,58 @@ internal static string ValidateMessageKind(string? value, string expected, strin throw new JsonException($"{propertyName} must be '{expected}', but was '{value ?? ""}'."); } + + /// + /// Minimum broker API version that understands the given package manager name. + /// Clients must not send a manager to a broker whose advertised API version is + /// older than this value: the older broker would reject the unknown enum value + /// as a validation failure instead of reporting a meaningful capability error. + /// + public static string GetMinimumApiVersion(this ManagerName manager) => manager switch + { + ManagerName.Winget or ManagerName.PowerShell or ManagerName.PowerShell7 => "1.0", + ManagerName.Apt + or ManagerName.Bun + or ManagerName.Cargo + or ManagerName.Chocolatey + or ManagerName.Dnf + or ManagerName.Dotnet + or ManagerName.Flatpak + or ManagerName.Homebrew + or ManagerName.Npm + or ManagerName.Pacman + or ManagerName.Pip + or ManagerName.Scoop + or ManagerName.Snap + or ManagerName.Vcpkg => "1.1", + _ => throw new ArgumentOutOfRangeException(nameof(manager), manager, null), + }; + + /// + /// Whether API version (e.g. a broker's advertised + /// version) supports a feature introduced in + /// (same major version, minor at least as new). + /// + public static bool ApiVersionSupports(string version, string required) + { + return TryParseApiVersion(version, out var major, out var minor) + && TryParseApiVersion(required, out var requiredMajor, out var requiredMinor) + && major == requiredMajor + && minor >= requiredMinor; + } + + private static bool TryParseApiVersion(string value, out int major, out int minor) + { + major = 0; + minor = 0; + + var separator = value.IndexOf('.', StringComparison.Ordinal); + if (separator <= 0 || separator == value.Length - 1) + { + return false; + } + + return int.TryParse(value.AsSpan(0, separator), out major) + && int.TryParse(value.AsSpan(separator + 1), out minor); + } } \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs index 9efba8a..8e13592 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs @@ -21,6 +21,20 @@ public enum ManagerName Winget, PowerShell, PowerShell7, + Apt, + Bun, + Cargo, + Chocolatey, + Dnf, + Dotnet, + Flatpak, + Homebrew, + Npm, + Pacman, + Pip, + Scoop, + Snap, + Vcpkg, } /// Installation scope. diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs index 7c9d03e..d4d4d1b 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs @@ -58,6 +58,50 @@ public string ResponseKind [JsonPropertyName("MaxRequestBodyBytes")] public long MaxRequestBodyBytes { get; set; } + + /// Capability entry advertised for , if any. + public ManagerCapability? GetManagerCapability(ManagerName manager) + => Managers.FirstOrDefault(capability => capability.Manager == manager); + + /// Whether the broker advertises support for . + public bool SupportsManager(ManagerName manager) => GetManagerCapability(manager) is not null; + + /// + /// Classify support for , distinguishing a broker + /// whose protocol version predates the manager from one that merely does not + /// advertise it in its capabilities. + /// + public ManagerSupport GetManagerSupport(ManagerName manager) + { + if (!BrokerApi.ApiVersionSupports(ResponseVersion, manager.GetMinimumApiVersion())) + { + return ManagerSupport.RequiresNewerApiVersion; + } + + return SupportsManager(manager) ? ManagerSupport.Supported : ManagerSupport.NotAdvertised; + } +} + +/// +/// Result of checking whether a package manager can be used with a broker, +/// distinguishing protocol-version gaps from missing capability advertisement. +/// +public enum ManagerSupport +{ + /// The broker advertises a capability entry for the manager. + Supported, + + /// + /// The broker's API version predates the manager; sending it would fail + /// request validation on the broker. See . + /// + RequiresNewerApiVersion, + + /// + /// The broker speaks a recent-enough API version but does not advertise + /// the manager in its capabilities. + /// + NotAdvertised, } public sealed class ManagerCapability diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyCompatibility.cs b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyCompatibility.cs index c102ff8..c1b402f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyCompatibility.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyCompatibility.cs @@ -31,6 +31,20 @@ public static class PolicyCompatibility ManagerName.Winget => PolicyManagerName.Winget, ManagerName.PowerShell => PolicyManagerName.PowerShell, ManagerName.PowerShell7 => PolicyManagerName.PowerShell7, + ManagerName.Apt => PolicyManagerName.Apt, + ManagerName.Bun => PolicyManagerName.Bun, + ManagerName.Cargo => PolicyManagerName.Cargo, + ManagerName.Chocolatey => PolicyManagerName.Chocolatey, + ManagerName.Dnf => PolicyManagerName.Dnf, + ManagerName.Dotnet => PolicyManagerName.Dotnet, + ManagerName.Flatpak => PolicyManagerName.Flatpak, + ManagerName.Homebrew => PolicyManagerName.Homebrew, + ManagerName.Npm => PolicyManagerName.Npm, + ManagerName.Pacman => PolicyManagerName.Pacman, + ManagerName.Pip => PolicyManagerName.Pip, + ManagerName.Scoop => PolicyManagerName.Scoop, + ManagerName.Snap => PolicyManagerName.Snap, + ManagerName.Vcpkg => PolicyManagerName.Vcpkg, _ => throw new ArgumentOutOfRangeException(nameof(value), value, null), }; @@ -39,6 +53,20 @@ public static class PolicyCompatibility PolicyManagerName.Winget => ManagerName.Winget, PolicyManagerName.PowerShell => ManagerName.PowerShell, PolicyManagerName.PowerShell7 => ManagerName.PowerShell7, + PolicyManagerName.Apt => ManagerName.Apt, + PolicyManagerName.Bun => ManagerName.Bun, + PolicyManagerName.Cargo => ManagerName.Cargo, + PolicyManagerName.Chocolatey => ManagerName.Chocolatey, + PolicyManagerName.Dnf => ManagerName.Dnf, + PolicyManagerName.Dotnet => ManagerName.Dotnet, + PolicyManagerName.Flatpak => ManagerName.Flatpak, + PolicyManagerName.Homebrew => ManagerName.Homebrew, + PolicyManagerName.Npm => ManagerName.Npm, + PolicyManagerName.Pacman => ManagerName.Pacman, + PolicyManagerName.Pip => ManagerName.Pip, + PolicyManagerName.Scoop => ManagerName.Scoop, + PolicyManagerName.Snap => ManagerName.Snap, + PolicyManagerName.Vcpkg => ManagerName.Vcpkg, _ => throw new ArgumentOutOfRangeException(nameof(value), value, null), }; diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 698c165..2a74b21 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -170,6 +170,47 @@ public async Task Evaluate_rejects_unsupported_capability_before_operation_reque Assert.Equal("/v1/capabilities", transport.Requests[0].Path); } + [Fact] + public async Task Evaluate_rejects_manager_requiring_newer_broker_api_version_before_sending() + { + // Broker speaks API 1.0; Chocolatey was introduced in 1.1. + var transport = new FakeBrokerTransport(CapabilitiesResponse); + var client = CreateClient(transport); + + var exception = await Assert.ThrowsAsync(() => client.Evaluate(new PackageOperationRequest + { + Operation = Operation.Install, + Manager = ManagerName.Chocolatey, + Source = new RequestSource { Name = "chocolatey" }, + Package = new RequestPackage { Id = "git" }, + })); + + Assert.Equal(BrokerClientErrorKind.UnsupportedApiVersion, exception.Kind); + Assert.Contains("requires broker API version 1.1", exception.Message); + Assert.Single(transport.Requests); + Assert.Equal("/v1/capabilities", transport.Requests[0].Path); + } + + [Fact] + public async Task Evaluate_reports_missing_capability_distinctly_when_broker_api_version_is_new_enough() + { + // Broker speaks API 1.1 but does not advertise Chocolatey. + var transport = new FakeBrokerTransport(CapabilitiesResponse.Replace("\"ResponseVersion\":\"1.0\"", "\"ResponseVersion\":\"1.1\"")); + var client = CreateClient(transport); + + var exception = await Assert.ThrowsAsync(() => client.Evaluate(new PackageOperationRequest + { + Operation = Operation.Install, + Manager = ManagerName.Chocolatey, + Source = new RequestSource { Name = "chocolatey" }, + Package = new RequestPackage { Id = "git" }, + })); + + Assert.Equal(BrokerClientErrorKind.UnsupportedCapability, exception.Kind); + Assert.Contains("does not advertise support", exception.Message); + Assert.Single(transport.Requests); + } + [Fact] public async Task GetHealth_throws_typed_error_for_broker_error_response() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs index 3c16963..a02d06b 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -376,6 +376,18 @@ private void EnsurePackageRequestSupported(PackageRequest request, CapabilitiesR { EnsureTransportSupported(capabilities, _transport.Kind, $"send package operation requests to {endpoint}", endpoint); + // Never send a manager the broker's protocol version cannot parse: an older + // broker would reject the unknown enum value as an opaque validation failure. + if (capabilities.GetManagerSupport(request.Manager) == ManagerSupport.RequiresNewerApiVersion) + { + throw new BrokerClientException( + BrokerClientErrorKind.UnsupportedApiVersion, + $"Package manager '{request.Manager}' requires broker API version " + + $"{request.Manager.GetMinimumApiVersion()} or newer, but the broker speaks version " + + $"{capabilities.ResponseVersion}.", + endpoint); + } + var manager = capabilities.Managers.FirstOrDefault(manager => manager.Manager == request.Manager); if (manager is null) { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClientErrorKind.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClientErrorKind.cs index 1a03903..38407ee 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClientErrorKind.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClientErrorKind.cs @@ -11,5 +11,13 @@ public enum BrokerClientErrorKind BrokerError, PolicyDenied, UnsupportedCapability, + + /// + /// The broker's protocol version predates the requested feature (e.g. a + /// package manager introduced in a newer API version). Distinct from + /// , which means the broker understands + /// the feature but does not advertise support for it. + /// + UnsupportedApiVersion, RequestTooLarge, } \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index 716dea7..bcab235 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -64,6 +64,8 @@ switch transport from HTTP to other mechanisms without changing the wire schema. Before sending package operation and status requests, the client implicitly queries `GetCapabilities` once and caches the result. The cached capabilities are used as a local preflight gate: unsupported transports, package managers, operations, scopes, architectures, request body sizes, custom parameters, custom install locations, or captured output requests fail before the client sends the operation/status request. +Package managers are additionally gated by the broker's advertised protocol version: a manager introduced in a newer API version than the broker's `ResponseVersion` is rejected locally with `BrokerClientErrorKind.UnsupportedApiVersion` (the request is never sent, since an older broker would fail to parse the unknown manager value). This is distinct from `BrokerClientErrorKind.UnsupportedCapability`, which means the broker understands the manager but does not advertise support for it. Use `CapabilitiesResponse.GetManagerSupport(ManagerName)` to check manager support (`Supported`, `RequiresNewerApiVersion`, or `NotAdvertised`) ahead of time. + Before sending package operation requests, the client fills missing request metadata: - `RequestId` is generated with `BrokerClient.GenerateRequestId()` when empty. Request IDs are normalized to lowercase dashed GUIDs without braces. diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs index f1a1fe3..aa93641 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs @@ -18,6 +18,20 @@ public enum ManagerName Winget, PowerShell, PowerShell7, + Apt, + Bun, + Cargo, + Chocolatey, + Dnf, + Dotnet, + Flatpak, + Homebrew, + Npm, + Pacman, + Pip, + Scoop, + Snap, + Vcpkg, } /// Installation scope. diff --git a/policies/rust/now-policy-api/Cargo.toml b/policies/rust/now-policy-api/Cargo.toml index b583b47..41b9789 100644 --- a/policies/rust/now-policy-api/Cargo.toml +++ b/policies/rust/now-policy-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy-api" -version = "0.1.0" +version = "0.2.0" edition = "2024" license.workspace = true homepage.workspace = true @@ -21,7 +21,7 @@ policy-compat = ["dep:now-policy"] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } derive_more = { version = "2", features = ["as_ref", "deref", "display", "from"] } -now-policy = { version = "0.1", path = "../now-policy", optional = true } +now-policy = { version = "0.2", path = "../now-policy", optional = true } schemars = { version = "0.8", features = ["chrono"] } semver = "1" serde = { version = "1", features = ["derive"] } diff --git a/policies/rust/now-policy-api/openapi/now-policy-api.yaml b/policies/rust/now-policy-api/openapi/now-policy-api.yaml index 223f94a..66cce9f 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Devolutions NOW Package Broker API description: HTTP API exposed by a Devolutions NOW package broker facade over a Windows named pipe. - version: '1.0' + version: '1.1' paths: /v1/health: get: @@ -543,6 +543,20 @@ components: - Winget - PowerShell - PowerShell7 + - Apt + - Bun + - Cargo + - Chocolatey + - Dnf + - Dotnet + - Flatpak + - Homebrew + - Npm + - Pacman + - Pip + - Scoop + - Snap + - Vcpkg Operation: description: Package operation type. type: string diff --git a/policies/rust/now-policy-api/src/capabilities.rs b/policies/rust/now-policy-api/src/capabilities.rs index 4377372..456b9a8 100644 --- a/policies/rust/now-policy-api/src/capabilities.rs +++ b/policies/rust/now-policy-api/src/capabilities.rs @@ -31,6 +31,48 @@ pub struct CapabilitiesResponse { pub max_request_body_bytes: u64, } +/// Result of checking whether a package manager can be used with a broker, +/// distinguishing protocol-version gaps from missing capability advertisement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManagerSupport { + /// The broker advertises a capability entry for the manager. + Supported, + /// The broker's API version predates the manager; sending it would fail + /// request validation on the broker. `required` is the minimum broker API + /// version that understands the manager. + RequiresNewerApiVersion { required: ApiVersion }, + /// The broker speaks a recent-enough API version but does not advertise + /// the manager in its capabilities. + NotAdvertised, +} + +impl CapabilitiesResponse { + /// Capability entry advertised for `manager`, if any. + pub fn manager_capability(&self, manager: ManagerName) -> Option<&ManagerCapability> { + self.managers.iter().find(|capability| capability.manager == manager) + } + + /// Whether the broker advertises support for `manager`. + pub fn supports_manager(&self, manager: ManagerName) -> bool { + self.manager_capability(manager).is_some() + } + + /// Classify support for `manager`, distinguishing a broker that is too old + /// to understand the manager from one that merely does not advertise it. + pub fn manager_support(&self, manager: ManagerName) -> ManagerSupport { + let required = manager.minimum_api_version(); + if !self.response_version.supports(&required) { + return ManagerSupport::RequiresNewerApiVersion { required }; + } + + if self.supports_manager(manager) { + ManagerSupport::Supported + } else { + ManagerSupport::NotAdvertised + } + } +} + /// Package-manager-specific capability declaration. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[schemars(rename = "ManagerCapability")] @@ -64,3 +106,93 @@ pub struct ManagerCapability { #[serde(default, skip_serializing_if = "Option::is_none")] pub max_operation_timeout_seconds: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{API_VERSION_STR, ServerContext, Transport}; + + fn capabilities(response_version: &str, managers: Vec) -> CapabilitiesResponse { + CapabilitiesResponse { + response_kind: CapabilitiesResponseKind, + response_version: response_version.into(), + server: ServerContext { + server_version: "0.2.0".to_owned(), + transport: Transport::HttpNamedPipe, + }, + transports: vec![Transport::HttpNamedPipe], + managers, + max_request_body_bytes: 262144, + } + } + + fn manager_capability(manager: ManagerName) -> ManagerCapability { + ManagerCapability { + manager, + operations: vec![Operation::Install], + scopes: vec![Scope::Machine], + architectures: vec![Architecture::Neutral], + supports_custom_parameters: false, + supports_custom_install_location: false, + supports_capture_output: false, + supports_details: false, + max_operation_timeout_seconds: None, + } + } + + #[test] + fn managers_added_in_1_1_are_version_gated_behind_older_brokers() { + let old_broker = capabilities("1.0", vec![manager_capability(ManagerName::Winget)]); + + assert_eq!( + old_broker.manager_support(ManagerName::Winget), + ManagerSupport::Supported + ); + assert_eq!( + old_broker.manager_support(ManagerName::Chocolatey), + ManagerSupport::RequiresNewerApiVersion { required: "1.1".into() } + ); + // Version gap is reported even if an old broker somehow advertised the manager. + assert!(!old_broker.supports_manager(ManagerName::Chocolatey)); + } + + #[test] + fn current_broker_distinguishes_not_advertised_from_version_gap() { + let broker = capabilities(API_VERSION_STR, vec![manager_capability(ManagerName::Chocolatey)]); + + assert_eq!( + broker.manager_support(ManagerName::Chocolatey), + ManagerSupport::Supported + ); + assert_eq!( + broker.manager_support(ManagerName::Scoop), + ManagerSupport::NotAdvertised + ); + assert_eq!( + broker.manager_support(ManagerName::Winget), + ManagerSupport::NotAdvertised + ); + } + + #[test] + fn all_managers_have_a_valid_minimum_api_version() { + for &manager in ManagerName::ALL { + let required = manager.minimum_api_version(); + assert!(required.major_minor().is_some(), "{manager} minimum version must parse"); + assert!( + ApiVersion::from(API_VERSION_STR).supports(&required), + "{manager} must be usable with the current API version" + ); + } + } + + #[test] + fn api_version_supports_compares_major_and_minor() { + let v1_1 = ApiVersion::from("1.1"); + assert!(v1_1.supports(&"1.0".into())); + assert!(v1_1.supports(&"1.1".into())); + assert!(!v1_1.supports(&"1.2".into())); + assert!(!v1_1.supports(&"2.0".into())); + assert!(!ApiVersion::from("2.0").supports(&"1.1".into())); + } +} diff --git a/policies/rust/now-policy-api/src/enums.rs b/policies/rust/now-policy-api/src/enums.rs index a7ea0db..16c04dc 100644 --- a/policies/rust/now-policy-api/src/enums.rs +++ b/policies/rust/now-policy-api/src/enums.rs @@ -37,6 +37,69 @@ pub enum ManagerName { Winget, PowerShell, PowerShell7, + Apt, + Bun, + Cargo, + Chocolatey, + Dnf, + Dotnet, + Flatpak, + Homebrew, + Npm, + Pacman, + Pip, + Scoop, + Snap, + Vcpkg, +} + +impl ManagerName { + /// All known package manager names. + pub const ALL: &[Self] = &[ + Self::Winget, + Self::PowerShell, + Self::PowerShell7, + Self::Apt, + Self::Bun, + Self::Cargo, + Self::Chocolatey, + Self::Dnf, + Self::Dotnet, + Self::Flatpak, + Self::Homebrew, + Self::Npm, + Self::Pacman, + Self::Pip, + Self::Scoop, + Self::Snap, + Self::Vcpkg, + ]; + + /// Minimum broker API version that understands this manager name. + /// + /// Clients must not send a manager to a broker whose advertised API + /// version is older than this value: the older broker would reject the + /// unknown enum value as a validation failure instead of reporting a + /// meaningful capability error. + pub fn minimum_api_version(self) -> crate::ApiVersion { + match self { + Self::Winget | Self::PowerShell | Self::PowerShell7 => crate::ApiVersion::from("1.0"), + Self::Apt + | Self::Bun + | Self::Cargo + | Self::Chocolatey + | Self::Dnf + | Self::Dotnet + | Self::Flatpak + | Self::Homebrew + | Self::Npm + | Self::Pacman + | Self::Pip + | Self::Scoop + | Self::Snap + | Self::Vcpkg => crate::ApiVersion::from("1.1"), + } + } } /// Policy decision. diff --git a/policies/rust/now-policy-api/src/lib.rs b/policies/rust/now-policy-api/src/lib.rs index cedf644..81e9e84 100644 --- a/policies/rust/now-policy-api/src/lib.rs +++ b/policies/rust/now-policy-api/src/lib.rs @@ -21,7 +21,7 @@ pub use execute::*; pub use health::*; pub use status::*; -pub const API_VERSION_STR: &str = "1.0"; +pub const API_VERSION_STR: &str = "1.1"; pub const DEFAULT_PIPE_NAME: &str = "Devolutions.Now.PackageBroker.v1"; pub const PACKAGE_REQUEST_KIND: &str = "PackageRequest"; @@ -471,6 +471,24 @@ impl ApiVersion { Ok(Self(s.to_owned())) } + + /// Numeric `(major, minor)` components, or `None` when the value is not a + /// well-formed API version. + pub fn major_minor(&self) -> Option<(u64, u64)> { + let (major, minor) = self.0.split_once('.')?; + Some((major.parse().ok()?, minor.parse().ok()?)) + } + + /// Whether this version (e.g. a server's advertised version) supports a + /// feature introduced in `required` (same major, minor at least as new). + pub fn supports(&self, required: &ApiVersion) -> bool { + match (self.major_minor(), required.major_minor()) { + (Some((major, minor)), Some((required_major, required_minor))) => { + major == required_major && minor >= required_minor + } + _ => false, + } + } } impl<'de> Deserialize<'de> for ApiVersion { diff --git a/policies/rust/now-policy-api/src/policy_compat.rs b/policies/rust/now-policy-api/src/policy_compat.rs index b8406a2..33d17d1 100644 --- a/policies/rust/now-policy-api/src/policy_compat.rs +++ b/policies/rust/now-policy-api/src/policy_compat.rs @@ -44,7 +44,29 @@ macro_rules! bidirectional_newtype_conversion { bidirectional_enum_conversion!(Operation, now_policy::Operation, [Install, Update, Uninstall]); bidirectional_enum_conversion!(Scope, now_policy::Scope, [User, Machine]); bidirectional_enum_conversion!(Architecture, now_policy::Architecture, [X86, X64, Arm64, Neutral]); -bidirectional_enum_conversion!(ManagerName, now_policy::ManagerName, [Winget, PowerShell, PowerShell7]); +bidirectional_enum_conversion!( + ManagerName, + now_policy::ManagerName, + [ + Winget, + PowerShell, + PowerShell7, + Apt, + Bun, + Cargo, + Chocolatey, + Dnf, + Dotnet, + Flatpak, + Homebrew, + Npm, + Pacman, + Pip, + Scoop, + Snap, + Vcpkg, + ] +); bidirectional_enum_conversion!(Decision, now_policy::Decision, [Allow, Deny]); bidirectional_enum_conversion!(Elevation, now_policy::Elevation, [Standard, Elevated]); diff --git a/policies/rust/now-policy-server-template/Cargo.toml b/policies/rust/now-policy-server-template/Cargo.toml index ec0393d..28aa41f 100644 --- a/policies/rust/now-policy-server-template/Cargo.toml +++ b/policies/rust/now-policy-server-template/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy-server-template" -version = "0.1.0" +version = "0.2.0" edition = "2024" license.workspace = true homepage.workspace = true @@ -21,8 +21,8 @@ policy-compat = ["dep:now-policy", "now-policy-api/policy-compat"] aide = { version = "0.14", features = ["axum", "axum-json"] } async-trait = "0.1" axum = { version = "0.8", default-features = false, features = ["json"] } -now-policy-api = { version = "0.1", path = "../now-policy-api" } -now-policy = { version = "0.1", path = "../now-policy", optional = true } +now-policy-api = { version = "0.2", path = "../now-policy-api" } +now-policy = { version = "0.2", path = "../now-policy", optional = true } schemars = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/policies/rust/now-policy-server-template/assets/samples/requests/chocolatey-git-install.request.json b/policies/rust/now-policy-server-template/assets/samples/requests/chocolatey-git-install.request.json new file mode 100644 index 0000000..8deabcc --- /dev/null +++ b/policies/rust/now-policy-server-template/assets/samples/requests/chocolatey-git-install.request.json @@ -0,0 +1,32 @@ +{ + "RequestKind": "PackageRequest", + "RequestVersion": "1.1", + "RequestId": "req-chocolatey-git-install", + "CreatedAt": "2026-05-05T12:00:00Z", + "Operation": "Install", + "Manager": "Chocolatey", + "Source": { + "Name": "chocolatey", + "Url": "https://community.chocolatey.org/api/v2/" + }, + "Package": { + "Id": "git", + "Architecture": "X64" + }, + "Options": { + "Scope": "Machine", + "Interactive": false, + "SkipHashCheck": false, + "PreRelease": false, + "CustomParameters": [], + "KillBeforeOperation": [] + }, + "Client": { + "RequestedElevation": "Elevated", + "EffectiveUser": "CONTOSO\\alice", + "ClientVersion": "3.2.0", + "Transport": "HttpNamedPipe", + "ClientExecutablePath": "C:\\Program Files\\Devolutions\\NOW\\now-client.exe" + }, + "IncludeCommandPreview": true +} diff --git a/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json b/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json index 0744c95..2b635d3 100644 --- a/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json +++ b/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json @@ -1,8 +1,8 @@ { "ResponseKind": "CapabilitiesResponse", - "ResponseVersion": "1.0", + "ResponseVersion": "1.1", "Server": { - "ServerVersion": "0.1.0", + "ServerVersion": "0.2.0", "Transport": "HttpNamedPipe" }, "Transports": [ @@ -29,8 +29,8 @@ "SupportsCustomParameters": true, "SupportsCustomInstallLocation": true, "SupportsCaptureOutput": true, - "MaxOperationTimeoutSeconds": 1800, - "SupportsDetails": true + "SupportsDetails": true, + "MaxOperationTimeoutSeconds": 1800 }, { "Manager": "PowerShell", @@ -49,8 +49,8 @@ "SupportsCustomParameters": true, "SupportsCustomInstallLocation": false, "SupportsCaptureOutput": true, - "MaxOperationTimeoutSeconds": 1800, - "SupportsDetails": false + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 }, { "Manager": "PowerShell7", @@ -69,8 +69,285 @@ "SupportsCustomParameters": true, "SupportsCustomInstallLocation": false, "SupportsCaptureOutput": true, - "MaxOperationTimeoutSeconds": 1800, - "SupportsDetails": false + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Apt", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Bun", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "User" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Cargo", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "User" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Chocolatey", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "X86", + "X64", + "Arm64", + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": true, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Dnf", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Dotnet", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": true, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Flatpak", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Homebrew", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "User" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Npm", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Pacman", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Pip", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Scoop", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Snap", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 + }, + { + "Manager": "Vcpkg", + "Operations": [ + "Install", + "Update", + "Uninstall" + ], + "Scopes": [ + "User" + ], + "Architectures": [ + "X86", + "X64", + "Arm64", + "Neutral" + ], + "SupportsCustomParameters": true, + "SupportsCustomInstallLocation": false, + "SupportsCaptureOutput": true, + "SupportsDetails": false, + "MaxOperationTimeoutSeconds": 1800 } ], "MaxRequestBodyBytes": 262144 diff --git a/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json b/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json index 874ccd4..d9823dd 100644 --- a/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json +++ b/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json @@ -1,8 +1,8 @@ { "ResponseKind": "HealthResponse", - "ResponseVersion": "1.0", + "ResponseVersion": "1.1", "Server": { - "ServerVersion": "0.1.0", + "ServerVersion": "0.2.0", "Transport": "HttpNamedPipe" }, "Status": "Ready", diff --git a/policies/rust/now-policy-server-template/src/mock.rs b/policies/rust/now-policy-server-template/src/mock.rs index a20ddd5..4285057 100644 --- a/policies/rust/now-policy-server-template/src/mock.rs +++ b/policies/rust/now-policy-server-template/src/mock.rs @@ -120,44 +120,148 @@ fn server_context() -> ServerContext { } fn default_manager_capabilities() -> Vec { - vec![ - ManagerCapability { - manager: ManagerName::Winget, - operations: vec![Operation::Install, Operation::Update, Operation::Uninstall], - scopes: vec![Scope::User, Scope::Machine], - architectures: vec![ - Architecture::X86, - Architecture::X64, - Architecture::Arm64, - Architecture::Neutral, - ], - supports_custom_parameters: true, - supports_custom_install_location: true, - supports_capture_output: true, - supports_details: true, - max_operation_timeout_seconds: Some(1800), - }, - ManagerCapability { - manager: ManagerName::PowerShell, - operations: vec![Operation::Install, Operation::Update, Operation::Uninstall], - scopes: vec![Scope::User, Scope::Machine], - architectures: vec![Architecture::Neutral], - supports_custom_parameters: true, - supports_custom_install_location: false, - supports_capture_output: true, - supports_details: false, - max_operation_timeout_seconds: Some(1800), - }, + fn manager( + manager: ManagerName, + scopes: Vec, + architectures: Vec, + supports_custom_install_location: bool, + supports_details: bool, + ) -> ManagerCapability { ManagerCapability { - manager: ManagerName::PowerShell7, + manager, operations: vec![Operation::Install, Operation::Update, Operation::Uninstall], - scopes: vec![Scope::User, Scope::Machine], - architectures: vec![Architecture::Neutral], + scopes, + architectures, supports_custom_parameters: true, - supports_custom_install_location: false, + supports_custom_install_location, supports_capture_output: true, - supports_details: false, + supports_details, max_operation_timeout_seconds: Some(1800), - }, + } + } + + let all_architectures = || { + vec![ + Architecture::X86, + Architecture::X64, + Architecture::Arm64, + Architecture::Neutral, + ] + }; + + vec![ + manager( + ManagerName::Winget, + vec![Scope::User, Scope::Machine], + all_architectures(), + true, + true, + ), + manager( + ManagerName::PowerShell, + vec![Scope::User, Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::PowerShell7, + vec![Scope::User, Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Apt, + vec![Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Bun, + vec![Scope::User], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Cargo, + vec![Scope::User], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Chocolatey, + vec![Scope::Machine], + all_architectures(), + true, + false, + ), + manager( + ManagerName::Dnf, + vec![Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Dotnet, + vec![Scope::User, Scope::Machine], + vec![Architecture::Neutral], + true, + false, + ), + manager( + ManagerName::Flatpak, + vec![Scope::User, Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Homebrew, + vec![Scope::User], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Npm, + vec![Scope::User, Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Pacman, + vec![Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Pip, + vec![Scope::User, Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Scoop, + vec![Scope::User, Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager( + ManagerName::Snap, + vec![Scope::Machine], + vec![Architecture::Neutral], + false, + false, + ), + manager(ManagerName::Vcpkg, vec![Scope::User], all_architectures(), false, false), ] } diff --git a/policies/rust/now-policy/Cargo.toml b/policies/rust/now-policy/Cargo.toml index 9aee784..aba5080 100644 --- a/policies/rust/now-policy/Cargo.toml +++ b/policies/rust/now-policy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy" -version = "0.1.0" +version = "0.2.0" edition = "2024" license.workspace = true homepage.workspace = true diff --git a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json index cc059bf..c983aa9 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -46,7 +46,21 @@ "enum": [ "Winget", "PowerShell", - "PowerShell7" + "PowerShell7", + "Apt", + "Bun", + "Cargo", + "Chocolatey", + "Dnf", + "Dotnet", + "Flatpak", + "Homebrew", + "Npm", + "Pacman", + "Pip", + "Scoop", + "Snap", + "Vcpkg" ], "type": "string" }, diff --git a/policies/rust/now-policy/src/enums.rs b/policies/rust/now-policy/src/enums.rs index f363a03..35c926a 100644 --- a/policies/rust/now-policy/src/enums.rs +++ b/policies/rust/now-policy/src/enums.rs @@ -37,6 +37,20 @@ pub enum ManagerName { Winget, PowerShell, PowerShell7, + Apt, + Bun, + Cargo, + Chocolatey, + Dnf, + Dotnet, + Flatpak, + Homebrew, + Npm, + Pacman, + Pip, + Scoop, + Snap, + Vcpkg, } /// Policy decision. @@ -80,6 +94,20 @@ impl std::fmt::Display for ManagerName { Self::Winget => f.write_str("Winget"), Self::PowerShell => f.write_str("PowerShell"), Self::PowerShell7 => f.write_str("PowerShell7"), + Self::Apt => f.write_str("Apt"), + Self::Bun => f.write_str("Bun"), + Self::Cargo => f.write_str("Cargo"), + Self::Chocolatey => f.write_str("Chocolatey"), + Self::Dnf => f.write_str("Dnf"), + Self::Dotnet => f.write_str("Dotnet"), + Self::Flatpak => f.write_str("Flatpak"), + Self::Homebrew => f.write_str("Homebrew"), + Self::Npm => f.write_str("Npm"), + Self::Pacman => f.write_str("Pacman"), + Self::Pip => f.write_str("Pip"), + Self::Scoop => f.write_str("Scoop"), + Self::Snap => f.write_str("Snap"), + Self::Vcpkg => f.write_str("Vcpkg"), } } } From 1654823846d0c5a7f20137bf507f1b4d188bd549 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Mon, 27 Jul 2026 22:04:49 +0300 Subject: [PATCH 2/4] fix(policies): address PR review feedback - Revert crate versions to 0.1.0 (managed by cargo-release) and fixture ServerVersion back to 0.1.0 - Add IncompatibleApiVersion to ManagerSupport (Rust + .NET) so a broker with a different major API version or malformed version is no longer misclassified as RequiresNewerApiVersion; client raises a distinct message for it - Version the policy schema: bump canonical URI to now-policy.schema.1.1.json, keep accepting the 1.0 URI on read (Rust marker + .NET consts), default PolicyVersion to 1.1.0, regenerate schema JSON - Strengthen version-gating test: old broker now advertises Chocolatey and the version gate still wins; add incompatible-major tests in both languages - Reword 'broker speaks version' to 'broker advertises version' Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 6 +- .../Devolutions.Now.Policy.Api/BrokerApi.cs | 5 +- .../Devolutions.Now.Policy.Api/BrokerJson.cs | 6 +- .../Devolutions.Now.Policy.Api/MetaModels.cs | 21 ++++++- .../ResponseModels.cs | 2 +- .../BrokerClientTests.cs | 24 +++++++- .../BrokerClient.cs | 21 ++++--- .../Devolutions.Now.Policy.Client/README.md | 2 +- .../PolicyModels.cs | 8 ++- policies/rust/now-policy-api/Cargo.toml | 4 +- .../rust/now-policy-api/src/capabilities.rs | 55 ++++++++++++++++--- .../now-policy-server-template/Cargo.toml | 6 +- .../responses/capabilities.response.json | 2 +- .../responses/health-ready.response.json | 2 +- policies/rust/now-policy/Cargo.toml | 2 +- .../samples/scenario-coverage.policy.json | 4 +- .../schema/devolutions.now-policy.schema.json | 3 +- policies/rust/now-policy/src/markers.rs | 55 +++++++++++++++++-- .../rust/now-policy/tests/policy_samples.rs | 30 ++++++++++ 19 files changed, 211 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a19cd1f..30285e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,7 +618,7 @@ dependencies = [ [[package]] name = "now-policy" -version = "0.2.0" +version = "0.1.0" dependencies = [ "chrono", "schemars", @@ -632,7 +632,7 @@ dependencies = [ [[package]] name = "now-policy-api" -version = "0.2.0" +version = "0.1.0" dependencies = [ "base64", "chrono", @@ -648,7 +648,7 @@ dependencies = [ [[package]] name = "now-policy-server-template" -version = "0.2.0" +version = "0.1.0" dependencies = [ "aide", "async-trait", diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs index a4f65f2..900308d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs @@ -68,7 +68,10 @@ public static bool ApiVersionSupports(string version, string required) && minor >= requiredMinor; } - private static bool TryParseApiVersion(string value, out int major, out int minor) + /// + /// Parse a wire API version of the form "major.minor" (e.g. "1.1"). + /// + public static bool TryParseApiVersion(string value, out int major, out int minor) { major = 0; minor = 0; diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index cdf9550..21be222 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -8,7 +8,11 @@ namespace Devolutions.Now.Policy.Api; /// Canonical schema URI used in the $schema field of policy documents. public static class SchemaUris { - public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; + /// Current policy schema URI (1.1 syntax, adds the expanded manager set). + public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.1.json"; + + /// Original 1.0 policy schema URI, still accepted on read. + public const string PolicyV1_0 = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; } /// Shared for broker documents. diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs index d4d4d1b..5bedceb 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs @@ -69,11 +69,19 @@ public string ResponseKind /// /// Classify support for , distinguishing a broker /// whose protocol version predates the manager from one that merely does not - /// advertise it in its capabilities. + /// advertise it in its capabilities, and from one whose API version is + /// incompatible altogether. /// public ManagerSupport GetManagerSupport(ManagerName manager) { - if (!BrokerApi.ApiVersionSupports(ResponseVersion, manager.GetMinimumApiVersion())) + if (!BrokerApi.TryParseApiVersion(ResponseVersion, out var major, out var minor) + || !BrokerApi.TryParseApiVersion(manager.GetMinimumApiVersion(), out var requiredMajor, out var requiredMinor) + || major != requiredMajor) + { + return ManagerSupport.IncompatibleApiVersion; + } + + if (minor < requiredMinor) { return ManagerSupport.RequiresNewerApiVersion; } @@ -98,7 +106,14 @@ public enum ManagerSupport RequiresNewerApiVersion, /// - /// The broker speaks a recent-enough API version but does not advertise + /// The broker advertises an API version with a different major version than + /// the manager's requirement (e.g. a 2.x broker checked against a 1.x + /// requirement), or a malformed version; compatibility cannot be established. + /// + IncompatibleApiVersion, + + /// + /// The broker advertises a recent-enough API version but does not advertise /// the manager in its capabilities. /// NotAdvertised, diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs index d686782..3404c7a 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs @@ -137,7 +137,7 @@ public sealed class ResponsePolicyInfo public int Revision { get; set; } [JsonPropertyName("PolicyVersion")] - public string PolicyVersion { get; set; } = "1.0.0"; + public string PolicyVersion { get; set; } = "1.1.0"; } public sealed class OperationDiagnostics diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 2a74b21..97d387e 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -173,7 +173,7 @@ public async Task Evaluate_rejects_unsupported_capability_before_operation_reque [Fact] public async Task Evaluate_rejects_manager_requiring_newer_broker_api_version_before_sending() { - // Broker speaks API 1.0; Chocolatey was introduced in 1.1. + // Broker advertises API 1.0; Chocolatey was introduced in 1.1. var transport = new FakeBrokerTransport(CapabilitiesResponse); var client = CreateClient(transport); @@ -194,7 +194,7 @@ public async Task Evaluate_rejects_manager_requiring_newer_broker_api_version_be [Fact] public async Task Evaluate_reports_missing_capability_distinctly_when_broker_api_version_is_new_enough() { - // Broker speaks API 1.1 but does not advertise Chocolatey. + // Broker advertises API 1.1 but does not advertise Chocolatey. var transport = new FakeBrokerTransport(CapabilitiesResponse.Replace("\"ResponseVersion\":\"1.0\"", "\"ResponseVersion\":\"1.1\"")); var client = CreateClient(transport); @@ -211,6 +211,26 @@ public async Task Evaluate_reports_missing_capability_distinctly_when_broker_api Assert.Single(transport.Requests); } + [Fact] + public async Task Evaluate_rejects_broker_with_incompatible_major_api_version_before_sending() + { + // Broker advertises API 2.0: a different major version than this client understands. + var transport = new FakeBrokerTransport(CapabilitiesResponse.Replace("\"ResponseVersion\":\"1.0\"", "\"ResponseVersion\":\"2.0\"")); + var client = CreateClient(transport); + + var exception = await Assert.ThrowsAsync(() => client.Evaluate(new PackageOperationRequest + { + Operation = Operation.Install, + Manager = ManagerName.Winget, + Source = new RequestSource { Name = "winget" }, + Package = new RequestPackage { Id = "Git.Git" }, + })); + + Assert.Equal(BrokerClientErrorKind.UnsupportedApiVersion, exception.Kind); + Assert.Contains("incompatible", exception.Message); + Assert.Single(transport.Requests); + } + [Fact] public async Task GetHealth_throws_typed_error_for_broker_error_response() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs index a02d06b..ea6a8db 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -378,14 +378,21 @@ private void EnsurePackageRequestSupported(PackageRequest request, CapabilitiesR // Never send a manager the broker's protocol version cannot parse: an older // broker would reject the unknown enum value as an opaque validation failure. - if (capabilities.GetManagerSupport(request.Manager) == ManagerSupport.RequiresNewerApiVersion) + switch (capabilities.GetManagerSupport(request.Manager)) { - throw new BrokerClientException( - BrokerClientErrorKind.UnsupportedApiVersion, - $"Package manager '{request.Manager}' requires broker API version " + - $"{request.Manager.GetMinimumApiVersion()} or newer, but the broker speaks version " + - $"{capabilities.ResponseVersion}.", - endpoint); + case ManagerSupport.RequiresNewerApiVersion: + throw new BrokerClientException( + BrokerClientErrorKind.UnsupportedApiVersion, + $"Package manager '{request.Manager}' requires broker API version " + + $"{request.Manager.GetMinimumApiVersion()} or newer, but the broker advertises version " + + $"{capabilities.ResponseVersion}.", + endpoint); + case ManagerSupport.IncompatibleApiVersion: + throw new BrokerClientException( + BrokerClientErrorKind.UnsupportedApiVersion, + $"The broker advertises API version '{capabilities.ResponseVersion}', which is " + + $"incompatible with this client's API version {BrokerApi.Version}.", + endpoint); } var manager = capabilities.Managers.FirstOrDefault(manager => manager.Manager == request.Manager); diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index bcab235..fd2efc7 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -64,7 +64,7 @@ switch transport from HTTP to other mechanisms without changing the wire schema. Before sending package operation and status requests, the client implicitly queries `GetCapabilities` once and caches the result. The cached capabilities are used as a local preflight gate: unsupported transports, package managers, operations, scopes, architectures, request body sizes, custom parameters, custom install locations, or captured output requests fail before the client sends the operation/status request. -Package managers are additionally gated by the broker's advertised protocol version: a manager introduced in a newer API version than the broker's `ResponseVersion` is rejected locally with `BrokerClientErrorKind.UnsupportedApiVersion` (the request is never sent, since an older broker would fail to parse the unknown manager value). This is distinct from `BrokerClientErrorKind.UnsupportedCapability`, which means the broker understands the manager but does not advertise support for it. Use `CapabilitiesResponse.GetManagerSupport(ManagerName)` to check manager support (`Supported`, `RequiresNewerApiVersion`, or `NotAdvertised`) ahead of time. +Package managers are additionally gated by the broker's advertised protocol version: a manager introduced in a newer API version than the broker's `ResponseVersion` is rejected locally with `BrokerClientErrorKind.UnsupportedApiVersion` (the request is never sent, since an older broker would fail to parse the unknown manager value). This is distinct from `BrokerClientErrorKind.UnsupportedCapability`, which means the broker understands the manager but does not advertise support for it. Use `CapabilitiesResponse.GetManagerSupport(ManagerName)` to check manager support (`Supported`, `RequiresNewerApiVersion`, `IncompatibleApiVersion`, or `NotAdvertised`) ahead of time. Before sending package operation requests, the client fills missing request metadata: diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index 76dbad6..b001b1d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -10,7 +10,11 @@ namespace Devolutions.Now.Policy.Model; public static class SchemaUris { - public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; + /// Current policy schema URI (1.1 syntax, adds the expanded manager set). + public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.1.json"; + + /// Original 1.0 policy schema URI, still accepted on read. + public const string PolicyV1_0 = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; } /// A policy document governing which package operations are allowed or denied. @@ -20,7 +24,7 @@ public sealed class PolicyDocument public string Schema { get; set; } = SchemaUris.Policy; [JsonPropertyName("PolicyVersion")] - public string PolicyVersion { get; set; } = "1.0.0"; + public string PolicyVersion { get; set; } = "1.1.0"; [JsonPropertyName("PolicyType")] public string PolicyType { get; set; } = "PackageBrokerPolicy"; diff --git a/policies/rust/now-policy-api/Cargo.toml b/policies/rust/now-policy-api/Cargo.toml index 41b9789..b583b47 100644 --- a/policies/rust/now-policy-api/Cargo.toml +++ b/policies/rust/now-policy-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy-api" -version = "0.2.0" +version = "0.1.0" edition = "2024" license.workspace = true homepage.workspace = true @@ -21,7 +21,7 @@ policy-compat = ["dep:now-policy"] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } derive_more = { version = "2", features = ["as_ref", "deref", "display", "from"] } -now-policy = { version = "0.2", path = "../now-policy", optional = true } +now-policy = { version = "0.1", path = "../now-policy", optional = true } schemars = { version = "0.8", features = ["chrono"] } semver = "1" serde = { version = "1", features = ["derive"] } diff --git a/policies/rust/now-policy-api/src/capabilities.rs b/policies/rust/now-policy-api/src/capabilities.rs index 456b9a8..c87141b 100644 --- a/policies/rust/now-policy-api/src/capabilities.rs +++ b/policies/rust/now-policy-api/src/capabilities.rs @@ -41,8 +41,13 @@ pub enum ManagerSupport { /// request validation on the broker. `required` is the minimum broker API /// version that understands the manager. RequiresNewerApiVersion { required: ApiVersion }, - /// The broker speaks a recent-enough API version but does not advertise - /// the manager in its capabilities. + /// The broker advertises an API version with a different major version + /// than the manager's requirement (e.g. a 2.x broker checked against a + /// 1.x requirement), or a malformed version; compatibility cannot be + /// established either way. + IncompatibleApiVersion, + /// The broker advertises a recent-enough API version but does not + /// advertise the manager in its capabilities. NotAdvertised, } @@ -58,11 +63,17 @@ impl CapabilitiesResponse { } /// Classify support for `manager`, distinguishing a broker that is too old - /// to understand the manager from one that merely does not advertise it. + /// to understand the manager from one that merely does not advertise it, + /// and from one whose API version is incompatible altogether. pub fn manager_support(&self, manager: ManagerName) -> ManagerSupport { let required = manager.minimum_api_version(); - if !self.response_version.supports(&required) { - return ManagerSupport::RequiresNewerApiVersion { required }; + match (self.response_version.major_minor(), required.major_minor()) { + (Some((major, minor)), Some((required_major, required_minor))) if major == required_major => { + if minor < required_minor { + return ManagerSupport::RequiresNewerApiVersion { required }; + } + } + _ => return ManagerSupport::IncompatibleApiVersion, } if self.supports_manager(manager) { @@ -117,7 +128,7 @@ mod tests { response_kind: CapabilitiesResponseKind, response_version: response_version.into(), server: ServerContext { - server_version: "0.2.0".to_owned(), + server_version: "0.1.0".to_owned(), transport: Transport::HttpNamedPipe, }, transports: vec![Transport::HttpNamedPipe], @@ -142,18 +153,44 @@ mod tests { #[test] fn managers_added_in_1_1_are_version_gated_behind_older_brokers() { - let old_broker = capabilities("1.0", vec![manager_capability(ManagerName::Winget)]); + // The old broker even (implausibly) advertises Chocolatey: the version + // gate must win over capability advertisement. + let old_broker = capabilities( + "1.0", + vec![ + manager_capability(ManagerName::Winget), + manager_capability(ManagerName::Chocolatey), + ], + ); assert_eq!( old_broker.manager_support(ManagerName::Winget), ManagerSupport::Supported ); + assert!(old_broker.supports_manager(ManagerName::Chocolatey)); assert_eq!( old_broker.manager_support(ManagerName::Chocolatey), ManagerSupport::RequiresNewerApiVersion { required: "1.1".into() } ); - // Version gap is reported even if an old broker somehow advertised the manager. - assert!(!old_broker.supports_manager(ManagerName::Chocolatey)); + } + + #[test] + fn brokers_with_different_major_version_are_reported_incompatible() { + let future_broker = capabilities("2.0", vec![manager_capability(ManagerName::Chocolatey)]); + assert_eq!( + future_broker.manager_support(ManagerName::Chocolatey), + ManagerSupport::IncompatibleApiVersion + ); + assert_eq!( + future_broker.manager_support(ManagerName::Winget), + ManagerSupport::IncompatibleApiVersion + ); + + let malformed_broker = capabilities("not-a-version", vec![manager_capability(ManagerName::Winget)]); + assert_eq!( + malformed_broker.manager_support(ManagerName::Winget), + ManagerSupport::IncompatibleApiVersion + ); } #[test] diff --git a/policies/rust/now-policy-server-template/Cargo.toml b/policies/rust/now-policy-server-template/Cargo.toml index 28aa41f..ec0393d 100644 --- a/policies/rust/now-policy-server-template/Cargo.toml +++ b/policies/rust/now-policy-server-template/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy-server-template" -version = "0.2.0" +version = "0.1.0" edition = "2024" license.workspace = true homepage.workspace = true @@ -21,8 +21,8 @@ policy-compat = ["dep:now-policy", "now-policy-api/policy-compat"] aide = { version = "0.14", features = ["axum", "axum-json"] } async-trait = "0.1" axum = { version = "0.8", default-features = false, features = ["json"] } -now-policy-api = { version = "0.2", path = "../now-policy-api" } -now-policy = { version = "0.2", path = "../now-policy", optional = true } +now-policy-api = { version = "0.1", path = "../now-policy-api" } +now-policy = { version = "0.1", path = "../now-policy", optional = true } schemars = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json b/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json index 2b635d3..a1b65f4 100644 --- a/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json +++ b/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json @@ -2,7 +2,7 @@ "ResponseKind": "CapabilitiesResponse", "ResponseVersion": "1.1", "Server": { - "ServerVersion": "0.2.0", + "ServerVersion": "0.1.0", "Transport": "HttpNamedPipe" }, "Transports": [ diff --git a/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json b/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json index d9823dd..cda76f3 100644 --- a/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json +++ b/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json @@ -2,7 +2,7 @@ "ResponseKind": "HealthResponse", "ResponseVersion": "1.1", "Server": { - "ServerVersion": "0.2.0", + "ServerVersion": "0.1.0", "Transport": "HttpNamedPipe" }, "Status": "Ready", diff --git a/policies/rust/now-policy/Cargo.toml b/policies/rust/now-policy/Cargo.toml index aba5080..9aee784 100644 --- a/policies/rust/now-policy/Cargo.toml +++ b/policies/rust/now-policy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy" -version = "0.2.0" +version = "0.1.0" edition = "2024" license.workspace = true homepage.workspace = true diff --git a/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json b/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json index 77ba46c..ec1a7ec 100644 --- a/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json +++ b/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json @@ -1,6 +1,6 @@ { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.1.json", + "PolicyVersion": "1.1.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.scenario-coverage", diff --git a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json index c983aa9..8dcbd8b 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -1,5 +1,5 @@ { - "$id": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "$id": "https://devolutions.net/schemas/now-policy.schema.1.1.json", "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { @@ -503,6 +503,7 @@ }, "PolicySchemaUri": { "enum": [ + "https://devolutions.net/schemas/now-policy.schema.1.1.json", "https://devolutions.net/schemas/now-policy.schema.1.0.json" ], "type": "string" diff --git a/policies/rust/now-policy/src/markers.rs b/policies/rust/now-policy/src/markers.rs index 0ad8db6..dbac184 100644 --- a/policies/rust/now-policy/src/markers.rs +++ b/policies/rust/now-policy/src/markers.rs @@ -56,11 +56,54 @@ fixed_string_marker! { pub struct PackageBrokerPolicy => "PackageBrokerPolicy"; } -/// Schema URI for package policy documents. -pub const POLICY_SCHEMA_URI: &str = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; +/// Schema URI for package policy documents (current syntax version). +pub const POLICY_SCHEMA_URI: &str = "https://devolutions.net/schemas/now-policy.schema.1.1.json"; -fixed_string_marker! { - /// Marker type for the policy `$schema` field. - /// Serializes to the canonical policy schema URI. - pub struct PolicySchemaUri => POLICY_SCHEMA_URI; +/// Schema URI of the original 1.0 policy syntax, still accepted on read. +pub const POLICY_SCHEMA_URI_V1_0: &str = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; + +/// Marker type for the policy `$schema` field. +/// +/// Serializes to the current canonical policy schema URI +/// ([`POLICY_SCHEMA_URI`]); accepts any known policy schema URI on +/// deserialization so documents written against an older syntax version +/// still parse. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PolicySchemaUri; + +impl Serialize for PolicySchemaUri { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(POLICY_SCHEMA_URI) + } +} + +impl<'de> Deserialize<'de> for PolicySchemaUri { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value == POLICY_SCHEMA_URI || value == POLICY_SCHEMA_URI_V1_0 { + Ok(Self) + } else { + Err(serde::de::Error::custom(format_args!( + "expected {POLICY_SCHEMA_URI:?} or {POLICY_SCHEMA_URI_V1_0:?}, got {value:?}" + ))) + } + } +} + +impl JsonSchema for PolicySchemaUri { + fn schema_name() -> String { + "PolicySchemaUri".to_owned() + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + enum_values: Some(vec![ + serde_json::Value::String(POLICY_SCHEMA_URI.to_owned()), + serde_json::Value::String(POLICY_SCHEMA_URI_V1_0.to_owned()), + ]), + ..Default::default() + } + .into() + } } diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index c44ef7a..4667b87 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -92,3 +92,33 @@ fn policy_match_schema_requires_at_least_one_property() { assert_eq!(min_properties, Some(1)); } + +#[test] +fn legacy_1_0_schema_uri_is_accepted_and_upgraded_on_write() { + // corporate-allowlist still declares the 1.0 schema URI. + let path = samples_dir().join("corporate-allowlist.policy.json"); + let raw = std::fs::read_to_string(&path).unwrap(); + assert!(raw.contains(now_policy::POLICY_SCHEMA_URI_V1_0)); + + let policy = load_policy(&path); + let serialized = serde_json::to_string(&policy).unwrap(); + assert!( + serialized.contains(now_policy::POLICY_SCHEMA_URI), + "re-serialized policies must declare the current schema URI" + ); +} + +#[test] +fn unknown_schema_uri_is_rejected() { + let value = serde_json::json!({ + "$schema": "https://devolutions.net/schemas/now-policy.schema.9.9.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": "test", "Publisher": "test", "Revision": 1 }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + }); + + let error = serde_json::from_value::(value).unwrap_err(); + assert!(error.to_string().contains("expected"), "unexpected error: {error}"); +} From a78510e25d2d00ceaad5db071e1f8e6a47f4e6fa Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Mon, 27 Jul 2026 22:13:19 +0300 Subject: [PATCH 3/4] refactor(policies): drop IncompatibleApiVersion from ManagerSupport A broker with a different major API version fails much earlier, when parsing the response envelope, so classifying it per-manager is not meaningful. Keep ManagerSupport focused on Supported / RequiresNewerApiVersion / NotAdvertised in both Rust and .NET. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerApi.cs | 5 +-- .../Devolutions.Now.Policy.Api/MetaModels.cs | 21 ++-------- .../BrokerClientTests.cs | 20 ---------- .../BrokerClient.cs | 21 ++++------ .../Devolutions.Now.Policy.Client/README.md | 2 +- .../rust/now-policy-api/src/capabilities.rs | 39 +++---------------- 6 files changed, 19 insertions(+), 89 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs index 900308d..a4f65f2 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs @@ -68,10 +68,7 @@ public static bool ApiVersionSupports(string version, string required) && minor >= requiredMinor; } - /// - /// Parse a wire API version of the form "major.minor" (e.g. "1.1"). - /// - public static bool TryParseApiVersion(string value, out int major, out int minor) + private static bool TryParseApiVersion(string value, out int major, out int minor) { major = 0; minor = 0; diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs index 5bedceb..3e9907a 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs @@ -69,19 +69,13 @@ public string ResponseKind /// /// Classify support for , distinguishing a broker /// whose protocol version predates the manager from one that merely does not - /// advertise it in its capabilities, and from one whose API version is - /// incompatible altogether. + /// advertise it in its capabilities. A different major API version is not + /// handled here: such a broker fails much earlier, when parsing the + /// response envelope. /// public ManagerSupport GetManagerSupport(ManagerName manager) { - if (!BrokerApi.TryParseApiVersion(ResponseVersion, out var major, out var minor) - || !BrokerApi.TryParseApiVersion(manager.GetMinimumApiVersion(), out var requiredMajor, out var requiredMinor) - || major != requiredMajor) - { - return ManagerSupport.IncompatibleApiVersion; - } - - if (minor < requiredMinor) + if (!BrokerApi.ApiVersionSupports(ResponseVersion, manager.GetMinimumApiVersion())) { return ManagerSupport.RequiresNewerApiVersion; } @@ -105,13 +99,6 @@ public enum ManagerSupport /// RequiresNewerApiVersion, - /// - /// The broker advertises an API version with a different major version than - /// the manager's requirement (e.g. a 2.x broker checked against a 1.x - /// requirement), or a malformed version; compatibility cannot be established. - /// - IncompatibleApiVersion, - /// /// The broker advertises a recent-enough API version but does not advertise /// the manager in its capabilities. diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 97d387e..4805f8d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -211,26 +211,6 @@ public async Task Evaluate_reports_missing_capability_distinctly_when_broker_api Assert.Single(transport.Requests); } - [Fact] - public async Task Evaluate_rejects_broker_with_incompatible_major_api_version_before_sending() - { - // Broker advertises API 2.0: a different major version than this client understands. - var transport = new FakeBrokerTransport(CapabilitiesResponse.Replace("\"ResponseVersion\":\"1.0\"", "\"ResponseVersion\":\"2.0\"")); - var client = CreateClient(transport); - - var exception = await Assert.ThrowsAsync(() => client.Evaluate(new PackageOperationRequest - { - Operation = Operation.Install, - Manager = ManagerName.Winget, - Source = new RequestSource { Name = "winget" }, - Package = new RequestPackage { Id = "Git.Git" }, - })); - - Assert.Equal(BrokerClientErrorKind.UnsupportedApiVersion, exception.Kind); - Assert.Contains("incompatible", exception.Message); - Assert.Single(transport.Requests); - } - [Fact] public async Task GetHealth_throws_typed_error_for_broker_error_response() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs index ea6a8db..0c71a3b 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -378,21 +378,14 @@ private void EnsurePackageRequestSupported(PackageRequest request, CapabilitiesR // Never send a manager the broker's protocol version cannot parse: an older // broker would reject the unknown enum value as an opaque validation failure. - switch (capabilities.GetManagerSupport(request.Manager)) + if (capabilities.GetManagerSupport(request.Manager) == ManagerSupport.RequiresNewerApiVersion) { - case ManagerSupport.RequiresNewerApiVersion: - throw new BrokerClientException( - BrokerClientErrorKind.UnsupportedApiVersion, - $"Package manager '{request.Manager}' requires broker API version " + - $"{request.Manager.GetMinimumApiVersion()} or newer, but the broker advertises version " + - $"{capabilities.ResponseVersion}.", - endpoint); - case ManagerSupport.IncompatibleApiVersion: - throw new BrokerClientException( - BrokerClientErrorKind.UnsupportedApiVersion, - $"The broker advertises API version '{capabilities.ResponseVersion}', which is " + - $"incompatible with this client's API version {BrokerApi.Version}.", - endpoint); + throw new BrokerClientException( + BrokerClientErrorKind.UnsupportedApiVersion, + $"Package manager '{request.Manager}' requires broker API version " + + $"{request.Manager.GetMinimumApiVersion()} or newer, but the broker advertises version " + + $"{capabilities.ResponseVersion}.", + endpoint); } var manager = capabilities.Managers.FirstOrDefault(manager => manager.Manager == request.Manager); diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index fd2efc7..bcab235 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -64,7 +64,7 @@ switch transport from HTTP to other mechanisms without changing the wire schema. Before sending package operation and status requests, the client implicitly queries `GetCapabilities` once and caches the result. The cached capabilities are used as a local preflight gate: unsupported transports, package managers, operations, scopes, architectures, request body sizes, custom parameters, custom install locations, or captured output requests fail before the client sends the operation/status request. -Package managers are additionally gated by the broker's advertised protocol version: a manager introduced in a newer API version than the broker's `ResponseVersion` is rejected locally with `BrokerClientErrorKind.UnsupportedApiVersion` (the request is never sent, since an older broker would fail to parse the unknown manager value). This is distinct from `BrokerClientErrorKind.UnsupportedCapability`, which means the broker understands the manager but does not advertise support for it. Use `CapabilitiesResponse.GetManagerSupport(ManagerName)` to check manager support (`Supported`, `RequiresNewerApiVersion`, `IncompatibleApiVersion`, or `NotAdvertised`) ahead of time. +Package managers are additionally gated by the broker's advertised protocol version: a manager introduced in a newer API version than the broker's `ResponseVersion` is rejected locally with `BrokerClientErrorKind.UnsupportedApiVersion` (the request is never sent, since an older broker would fail to parse the unknown manager value). This is distinct from `BrokerClientErrorKind.UnsupportedCapability`, which means the broker understands the manager but does not advertise support for it. Use `CapabilitiesResponse.GetManagerSupport(ManagerName)` to check manager support (`Supported`, `RequiresNewerApiVersion`, or `NotAdvertised`) ahead of time. Before sending package operation requests, the client fills missing request metadata: diff --git a/policies/rust/now-policy-api/src/capabilities.rs b/policies/rust/now-policy-api/src/capabilities.rs index c87141b..70a9b80 100644 --- a/policies/rust/now-policy-api/src/capabilities.rs +++ b/policies/rust/now-policy-api/src/capabilities.rs @@ -41,11 +41,6 @@ pub enum ManagerSupport { /// request validation on the broker. `required` is the minimum broker API /// version that understands the manager. RequiresNewerApiVersion { required: ApiVersion }, - /// The broker advertises an API version with a different major version - /// than the manager's requirement (e.g. a 2.x broker checked against a - /// 1.x requirement), or a malformed version; compatibility cannot be - /// established either way. - IncompatibleApiVersion, /// The broker advertises a recent-enough API version but does not /// advertise the manager in its capabilities. NotAdvertised, @@ -63,17 +58,14 @@ impl CapabilitiesResponse { } /// Classify support for `manager`, distinguishing a broker that is too old - /// to understand the manager from one that merely does not advertise it, - /// and from one whose API version is incompatible altogether. + /// to understand the manager from one that merely does not advertise it. + /// + /// A different major API version is not handled here: such a broker fails + /// much earlier, when parsing the response envelope. pub fn manager_support(&self, manager: ManagerName) -> ManagerSupport { let required = manager.minimum_api_version(); - match (self.response_version.major_minor(), required.major_minor()) { - (Some((major, minor)), Some((required_major, required_minor))) if major == required_major => { - if minor < required_minor { - return ManagerSupport::RequiresNewerApiVersion { required }; - } - } - _ => return ManagerSupport::IncompatibleApiVersion, + if !self.response_version.supports(&required) { + return ManagerSupport::RequiresNewerApiVersion { required }; } if self.supports_manager(manager) { @@ -174,25 +166,6 @@ mod tests { ); } - #[test] - fn brokers_with_different_major_version_are_reported_incompatible() { - let future_broker = capabilities("2.0", vec![manager_capability(ManagerName::Chocolatey)]); - assert_eq!( - future_broker.manager_support(ManagerName::Chocolatey), - ManagerSupport::IncompatibleApiVersion - ); - assert_eq!( - future_broker.manager_support(ManagerName::Winget), - ManagerSupport::IncompatibleApiVersion - ); - - let malformed_broker = capabilities("not-a-version", vec![manager_capability(ManagerName::Winget)]); - assert_eq!( - malformed_broker.manager_support(ManagerName::Winget), - ManagerSupport::IncompatibleApiVersion - ); - } - #[test] fn current_broker_distinguishes_not_advertised_from_version_gap() { let broker = capabilities(API_VERSION_STR, vec![manager_capability(ManagerName::Chocolatey)]); From 728b0ef1091db029b1f11518b5332b6b06d009c6 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Mon, 27 Jul 2026 22:36:54 +0300 Subject: [PATCH 4/4] refactor(policies): revert API/schema version bump to 1.0 We are not in a release state, so breaking version compatibility is acceptable: the expanded manager set ships as part of the existing 1.0 contract. Removes the redundant versioning machinery: - API_VERSION_STR / BrokerApi.Version back to 1.0 (fixtures included) - Drop ManagerName::minimum_api_version / GetMinimumApiVersion, ApiVersion::supports / ApiVersionSupports and ManagerSupport - Drop the BrokerClient protocol-version gate and BrokerClientErrorKind.UnsupportedApiVersion - Policy schema URI and PolicyVersion defaults back to 1.0 / 1.0.0; regenerate schema and OpenAPI Capability checking stays via CapabilitiesResponse::supports_manager / manager_capability (SupportsManager / GetManagerCapability in .NET). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerApi.cs | 56 +---------- .../Devolutions.Now.Policy.Api/BrokerJson.cs | 6 +- .../Devolutions.Now.Policy.Api/MetaModels.cs | 39 -------- .../ResponseModels.cs | 2 +- .../BrokerClientTests.cs | 26 +---- .../BrokerClient.cs | 12 --- .../BrokerClientErrorKind.cs | 8 -- .../Devolutions.Now.Policy.Client/README.md | 4 +- .../PolicyModels.cs | 8 +- .../openapi/now-policy-api.yaml | 2 +- .../rust/now-policy-api/src/capabilities.rs | 96 +++---------------- policies/rust/now-policy-api/src/enums.rs | 49 ---------- policies/rust/now-policy-api/src/lib.rs | 20 +--- .../chocolatey-git-install.request.json | 2 +- .../responses/capabilities.response.json | 2 +- .../responses/health-ready.response.json | 2 +- .../samples/scenario-coverage.policy.json | 4 +- .../schema/devolutions.now-policy.schema.json | 3 +- policies/rust/now-policy/src/markers.rs | 55 ++--------- .../rust/now-policy/tests/policy_samples.rs | 30 ------ 20 files changed, 34 insertions(+), 392 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs index a4f65f2..af57c12 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs @@ -5,7 +5,7 @@ namespace Devolutions.Now.Policy.Api; /// Constants shared by package broker API clients and implementations. public static class BrokerApi { - public const string Version = "1.1"; + public const string Version = "1.0"; public const string DefaultPipeName = "Devolutions.Now.PackageBroker.v1"; @@ -28,58 +28,4 @@ internal static string ValidateMessageKind(string? value, string expected, strin throw new JsonException($"{propertyName} must be '{expected}', but was '{value ?? ""}'."); } - - /// - /// Minimum broker API version that understands the given package manager name. - /// Clients must not send a manager to a broker whose advertised API version is - /// older than this value: the older broker would reject the unknown enum value - /// as a validation failure instead of reporting a meaningful capability error. - /// - public static string GetMinimumApiVersion(this ManagerName manager) => manager switch - { - ManagerName.Winget or ManagerName.PowerShell or ManagerName.PowerShell7 => "1.0", - ManagerName.Apt - or ManagerName.Bun - or ManagerName.Cargo - or ManagerName.Chocolatey - or ManagerName.Dnf - or ManagerName.Dotnet - or ManagerName.Flatpak - or ManagerName.Homebrew - or ManagerName.Npm - or ManagerName.Pacman - or ManagerName.Pip - or ManagerName.Scoop - or ManagerName.Snap - or ManagerName.Vcpkg => "1.1", - _ => throw new ArgumentOutOfRangeException(nameof(manager), manager, null), - }; - - /// - /// Whether API version (e.g. a broker's advertised - /// version) supports a feature introduced in - /// (same major version, minor at least as new). - /// - public static bool ApiVersionSupports(string version, string required) - { - return TryParseApiVersion(version, out var major, out var minor) - && TryParseApiVersion(required, out var requiredMajor, out var requiredMinor) - && major == requiredMajor - && minor >= requiredMinor; - } - - private static bool TryParseApiVersion(string value, out int major, out int minor) - { - major = 0; - minor = 0; - - var separator = value.IndexOf('.', StringComparison.Ordinal); - if (separator <= 0 || separator == value.Length - 1) - { - return false; - } - - return int.TryParse(value.AsSpan(0, separator), out major) - && int.TryParse(value.AsSpan(separator + 1), out minor); - } } \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index 21be222..cdf9550 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -8,11 +8,7 @@ namespace Devolutions.Now.Policy.Api; /// Canonical schema URI used in the $schema field of policy documents. public static class SchemaUris { - /// Current policy schema URI (1.1 syntax, adds the expanded manager set). - public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.1.json"; - - /// Original 1.0 policy schema URI, still accepted on read. - public const string PolicyV1_0 = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; + public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; } /// Shared for broker documents. diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs index 3e9907a..871ea38 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs @@ -65,45 +65,6 @@ public string ResponseKind /// Whether the broker advertises support for . public bool SupportsManager(ManagerName manager) => GetManagerCapability(manager) is not null; - - /// - /// Classify support for , distinguishing a broker - /// whose protocol version predates the manager from one that merely does not - /// advertise it in its capabilities. A different major API version is not - /// handled here: such a broker fails much earlier, when parsing the - /// response envelope. - /// - public ManagerSupport GetManagerSupport(ManagerName manager) - { - if (!BrokerApi.ApiVersionSupports(ResponseVersion, manager.GetMinimumApiVersion())) - { - return ManagerSupport.RequiresNewerApiVersion; - } - - return SupportsManager(manager) ? ManagerSupport.Supported : ManagerSupport.NotAdvertised; - } -} - -/// -/// Result of checking whether a package manager can be used with a broker, -/// distinguishing protocol-version gaps from missing capability advertisement. -/// -public enum ManagerSupport -{ - /// The broker advertises a capability entry for the manager. - Supported, - - /// - /// The broker's API version predates the manager; sending it would fail - /// request validation on the broker. See . - /// - RequiresNewerApiVersion, - - /// - /// The broker advertises a recent-enough API version but does not advertise - /// the manager in its capabilities. - /// - NotAdvertised, } public sealed class ManagerCapability diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs index 3404c7a..d686782 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs @@ -137,7 +137,7 @@ public sealed class ResponsePolicyInfo public int Revision { get; set; } [JsonPropertyName("PolicyVersion")] - public string PolicyVersion { get; set; } = "1.1.0"; + public string PolicyVersion { get; set; } = "1.0.0"; } public sealed class OperationDiagnostics diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 4805f8d..4c360ab 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -171,9 +171,9 @@ public async Task Evaluate_rejects_unsupported_capability_before_operation_reque } [Fact] - public async Task Evaluate_rejects_manager_requiring_newer_broker_api_version_before_sending() + public async Task Evaluate_rejects_manager_not_advertised_by_broker_before_sending() { - // Broker advertises API 1.0; Chocolatey was introduced in 1.1. + // The broker does not advertise Chocolatey in its capabilities. var transport = new FakeBrokerTransport(CapabilitiesResponse); var client = CreateClient(transport); @@ -185,30 +185,10 @@ public async Task Evaluate_rejects_manager_requiring_newer_broker_api_version_be Package = new RequestPackage { Id = "git" }, })); - Assert.Equal(BrokerClientErrorKind.UnsupportedApiVersion, exception.Kind); - Assert.Contains("requires broker API version 1.1", exception.Message); - Assert.Single(transport.Requests); - Assert.Equal("/v1/capabilities", transport.Requests[0].Path); - } - - [Fact] - public async Task Evaluate_reports_missing_capability_distinctly_when_broker_api_version_is_new_enough() - { - // Broker advertises API 1.1 but does not advertise Chocolatey. - var transport = new FakeBrokerTransport(CapabilitiesResponse.Replace("\"ResponseVersion\":\"1.0\"", "\"ResponseVersion\":\"1.1\"")); - var client = CreateClient(transport); - - var exception = await Assert.ThrowsAsync(() => client.Evaluate(new PackageOperationRequest - { - Operation = Operation.Install, - Manager = ManagerName.Chocolatey, - Source = new RequestSource { Name = "chocolatey" }, - Package = new RequestPackage { Id = "git" }, - })); - Assert.Equal(BrokerClientErrorKind.UnsupportedCapability, exception.Kind); Assert.Contains("does not advertise support", exception.Message); Assert.Single(transport.Requests); + Assert.Equal("/v1/capabilities", transport.Requests[0].Path); } [Fact] diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs index 0c71a3b..3c16963 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -376,18 +376,6 @@ private void EnsurePackageRequestSupported(PackageRequest request, CapabilitiesR { EnsureTransportSupported(capabilities, _transport.Kind, $"send package operation requests to {endpoint}", endpoint); - // Never send a manager the broker's protocol version cannot parse: an older - // broker would reject the unknown enum value as an opaque validation failure. - if (capabilities.GetManagerSupport(request.Manager) == ManagerSupport.RequiresNewerApiVersion) - { - throw new BrokerClientException( - BrokerClientErrorKind.UnsupportedApiVersion, - $"Package manager '{request.Manager}' requires broker API version " + - $"{request.Manager.GetMinimumApiVersion()} or newer, but the broker advertises version " + - $"{capabilities.ResponseVersion}.", - endpoint); - } - var manager = capabilities.Managers.FirstOrDefault(manager => manager.Manager == request.Manager); if (manager is null) { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClientErrorKind.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClientErrorKind.cs index 38407ee..1a03903 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClientErrorKind.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClientErrorKind.cs @@ -11,13 +11,5 @@ public enum BrokerClientErrorKind BrokerError, PolicyDenied, UnsupportedCapability, - - /// - /// The broker's protocol version predates the requested feature (e.g. a - /// package manager introduced in a newer API version). Distinct from - /// , which means the broker understands - /// the feature but does not advertise support for it. - /// - UnsupportedApiVersion, RequestTooLarge, } \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index bcab235..65c54af 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -62,9 +62,7 @@ discriminators automatically while the client fills `RequestVersion` at the top and `ResponseVersion`; this is required for further protocol evolution and allows the client to switch transport from HTTP to other mechanisms without changing the wire schema. -Before sending package operation and status requests, the client implicitly queries `GetCapabilities` once and caches the result. The cached capabilities are used as a local preflight gate: unsupported transports, package managers, operations, scopes, architectures, request body sizes, custom parameters, custom install locations, or captured output requests fail before the client sends the operation/status request. - -Package managers are additionally gated by the broker's advertised protocol version: a manager introduced in a newer API version than the broker's `ResponseVersion` is rejected locally with `BrokerClientErrorKind.UnsupportedApiVersion` (the request is never sent, since an older broker would fail to parse the unknown manager value). This is distinct from `BrokerClientErrorKind.UnsupportedCapability`, which means the broker understands the manager but does not advertise support for it. Use `CapabilitiesResponse.GetManagerSupport(ManagerName)` to check manager support (`Supported`, `RequiresNewerApiVersion`, or `NotAdvertised`) ahead of time. +Before sending package operation and status requests, the client implicitly queries `GetCapabilities` once and caches the result. The cached capabilities are used as a local preflight gate: unsupported transports, package managers, operations, scopes, architectures, request body sizes, custom parameters, custom install locations, or captured output requests fail before the client sends the operation/status request. Use `CapabilitiesResponse.SupportsManager(ManagerName)` or `GetManagerCapability(ManagerName)` to check package manager support ahead of time. Before sending package operation requests, the client fills missing request metadata: diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index b001b1d..76dbad6 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -10,11 +10,7 @@ namespace Devolutions.Now.Policy.Model; public static class SchemaUris { - /// Current policy schema URI (1.1 syntax, adds the expanded manager set). - public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.1.json"; - - /// Original 1.0 policy schema URI, still accepted on read. - public const string PolicyV1_0 = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; + public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; } /// A policy document governing which package operations are allowed or denied. @@ -24,7 +20,7 @@ public sealed class PolicyDocument public string Schema { get; set; } = SchemaUris.Policy; [JsonPropertyName("PolicyVersion")] - public string PolicyVersion { get; set; } = "1.1.0"; + public string PolicyVersion { get; set; } = "1.0.0"; [JsonPropertyName("PolicyType")] public string PolicyType { get; set; } = "PackageBrokerPolicy"; diff --git a/policies/rust/now-policy-api/openapi/now-policy-api.yaml b/policies/rust/now-policy-api/openapi/now-policy-api.yaml index 66cce9f..f057d30 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Devolutions NOW Package Broker API description: HTTP API exposed by a Devolutions NOW package broker facade over a Windows named pipe. - version: '1.1' + version: '1.0' paths: /v1/health: get: diff --git a/policies/rust/now-policy-api/src/capabilities.rs b/policies/rust/now-policy-api/src/capabilities.rs index 70a9b80..1984941 100644 --- a/policies/rust/now-policy-api/src/capabilities.rs +++ b/policies/rust/now-policy-api/src/capabilities.rs @@ -31,21 +31,6 @@ pub struct CapabilitiesResponse { pub max_request_body_bytes: u64, } -/// Result of checking whether a package manager can be used with a broker, -/// distinguishing protocol-version gaps from missing capability advertisement. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ManagerSupport { - /// The broker advertises a capability entry for the manager. - Supported, - /// The broker's API version predates the manager; sending it would fail - /// request validation on the broker. `required` is the minimum broker API - /// version that understands the manager. - RequiresNewerApiVersion { required: ApiVersion }, - /// The broker advertises a recent-enough API version but does not - /// advertise the manager in its capabilities. - NotAdvertised, -} - impl CapabilitiesResponse { /// Capability entry advertised for `manager`, if any. pub fn manager_capability(&self, manager: ManagerName) -> Option<&ManagerCapability> { @@ -56,24 +41,6 @@ impl CapabilitiesResponse { pub fn supports_manager(&self, manager: ManagerName) -> bool { self.manager_capability(manager).is_some() } - - /// Classify support for `manager`, distinguishing a broker that is too old - /// to understand the manager from one that merely does not advertise it. - /// - /// A different major API version is not handled here: such a broker fails - /// much earlier, when parsing the response envelope. - pub fn manager_support(&self, manager: ManagerName) -> ManagerSupport { - let required = manager.minimum_api_version(); - if !self.response_version.supports(&required) { - return ManagerSupport::RequiresNewerApiVersion { required }; - } - - if self.supports_manager(manager) { - ManagerSupport::Supported - } else { - ManagerSupport::NotAdvertised - } - } } /// Package-manager-specific capability declaration. @@ -144,65 +111,24 @@ mod tests { } #[test] - fn managers_added_in_1_1_are_version_gated_behind_older_brokers() { - // The old broker even (implausibly) advertises Chocolatey: the version - // gate must win over capability advertisement. - let old_broker = capabilities( - "1.0", + fn supports_manager_reflects_advertised_capabilities() { + let broker = capabilities( + API_VERSION_STR, vec![ manager_capability(ManagerName::Winget), manager_capability(ManagerName::Chocolatey), ], ); + assert!(broker.supports_manager(ManagerName::Winget)); + assert!(broker.supports_manager(ManagerName::Chocolatey)); + assert!(!broker.supports_manager(ManagerName::Scoop)); + assert!(broker.manager_capability(ManagerName::Scoop).is_none()); assert_eq!( - old_broker.manager_support(ManagerName::Winget), - ManagerSupport::Supported - ); - assert!(old_broker.supports_manager(ManagerName::Chocolatey)); - assert_eq!( - old_broker.manager_support(ManagerName::Chocolatey), - ManagerSupport::RequiresNewerApiVersion { required: "1.1".into() } - ); - } - - #[test] - fn current_broker_distinguishes_not_advertised_from_version_gap() { - let broker = capabilities(API_VERSION_STR, vec![manager_capability(ManagerName::Chocolatey)]); - - assert_eq!( - broker.manager_support(ManagerName::Chocolatey), - ManagerSupport::Supported - ); - assert_eq!( - broker.manager_support(ManagerName::Scoop), - ManagerSupport::NotAdvertised + broker + .manager_capability(ManagerName::Chocolatey) + .map(|capability| capability.manager), + Some(ManagerName::Chocolatey) ); - assert_eq!( - broker.manager_support(ManagerName::Winget), - ManagerSupport::NotAdvertised - ); - } - - #[test] - fn all_managers_have_a_valid_minimum_api_version() { - for &manager in ManagerName::ALL { - let required = manager.minimum_api_version(); - assert!(required.major_minor().is_some(), "{manager} minimum version must parse"); - assert!( - ApiVersion::from(API_VERSION_STR).supports(&required), - "{manager} must be usable with the current API version" - ); - } - } - - #[test] - fn api_version_supports_compares_major_and_minor() { - let v1_1 = ApiVersion::from("1.1"); - assert!(v1_1.supports(&"1.0".into())); - assert!(v1_1.supports(&"1.1".into())); - assert!(!v1_1.supports(&"1.2".into())); - assert!(!v1_1.supports(&"2.0".into())); - assert!(!ApiVersion::from("2.0").supports(&"1.1".into())); } } diff --git a/policies/rust/now-policy-api/src/enums.rs b/policies/rust/now-policy-api/src/enums.rs index 16c04dc..7b3413c 100644 --- a/policies/rust/now-policy-api/src/enums.rs +++ b/policies/rust/now-policy-api/src/enums.rs @@ -53,55 +53,6 @@ pub enum ManagerName { Vcpkg, } -impl ManagerName { - /// All known package manager names. - pub const ALL: &[Self] = &[ - Self::Winget, - Self::PowerShell, - Self::PowerShell7, - Self::Apt, - Self::Bun, - Self::Cargo, - Self::Chocolatey, - Self::Dnf, - Self::Dotnet, - Self::Flatpak, - Self::Homebrew, - Self::Npm, - Self::Pacman, - Self::Pip, - Self::Scoop, - Self::Snap, - Self::Vcpkg, - ]; - - /// Minimum broker API version that understands this manager name. - /// - /// Clients must not send a manager to a broker whose advertised API - /// version is older than this value: the older broker would reject the - /// unknown enum value as a validation failure instead of reporting a - /// meaningful capability error. - pub fn minimum_api_version(self) -> crate::ApiVersion { - match self { - Self::Winget | Self::PowerShell | Self::PowerShell7 => crate::ApiVersion::from("1.0"), - Self::Apt - | Self::Bun - | Self::Cargo - | Self::Chocolatey - | Self::Dnf - | Self::Dotnet - | Self::Flatpak - | Self::Homebrew - | Self::Npm - | Self::Pacman - | Self::Pip - | Self::Scoop - | Self::Snap - | Self::Vcpkg => crate::ApiVersion::from("1.1"), - } - } -} - /// Policy decision. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, strum::Display)] #[schemars(rename = "Decision")] diff --git a/policies/rust/now-policy-api/src/lib.rs b/policies/rust/now-policy-api/src/lib.rs index 81e9e84..cedf644 100644 --- a/policies/rust/now-policy-api/src/lib.rs +++ b/policies/rust/now-policy-api/src/lib.rs @@ -21,7 +21,7 @@ pub use execute::*; pub use health::*; pub use status::*; -pub const API_VERSION_STR: &str = "1.1"; +pub const API_VERSION_STR: &str = "1.0"; pub const DEFAULT_PIPE_NAME: &str = "Devolutions.Now.PackageBroker.v1"; pub const PACKAGE_REQUEST_KIND: &str = "PackageRequest"; @@ -471,24 +471,6 @@ impl ApiVersion { Ok(Self(s.to_owned())) } - - /// Numeric `(major, minor)` components, or `None` when the value is not a - /// well-formed API version. - pub fn major_minor(&self) -> Option<(u64, u64)> { - let (major, minor) = self.0.split_once('.')?; - Some((major.parse().ok()?, minor.parse().ok()?)) - } - - /// Whether this version (e.g. a server's advertised version) supports a - /// feature introduced in `required` (same major, minor at least as new). - pub fn supports(&self, required: &ApiVersion) -> bool { - match (self.major_minor(), required.major_minor()) { - (Some((major, minor)), Some((required_major, required_minor))) => { - major == required_major && minor >= required_minor - } - _ => false, - } - } } impl<'de> Deserialize<'de> for ApiVersion { diff --git a/policies/rust/now-policy-server-template/assets/samples/requests/chocolatey-git-install.request.json b/policies/rust/now-policy-server-template/assets/samples/requests/chocolatey-git-install.request.json index 8deabcc..4a287ec 100644 --- a/policies/rust/now-policy-server-template/assets/samples/requests/chocolatey-git-install.request.json +++ b/policies/rust/now-policy-server-template/assets/samples/requests/chocolatey-git-install.request.json @@ -1,6 +1,6 @@ { "RequestKind": "PackageRequest", - "RequestVersion": "1.1", + "RequestVersion": "1.0", "RequestId": "req-chocolatey-git-install", "CreatedAt": "2026-05-05T12:00:00Z", "Operation": "Install", diff --git a/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json b/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json index a1b65f4..e854714 100644 --- a/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json +++ b/policies/rust/now-policy-server-template/assets/samples/responses/capabilities.response.json @@ -1,6 +1,6 @@ { "ResponseKind": "CapabilitiesResponse", - "ResponseVersion": "1.1", + "ResponseVersion": "1.0", "Server": { "ServerVersion": "0.1.0", "Transport": "HttpNamedPipe" diff --git a/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json b/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json index cda76f3..874ccd4 100644 --- a/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json +++ b/policies/rust/now-policy-server-template/assets/samples/responses/health-ready.response.json @@ -1,6 +1,6 @@ { "ResponseKind": "HealthResponse", - "ResponseVersion": "1.1", + "ResponseVersion": "1.0", "Server": { "ServerVersion": "0.1.0", "Transport": "HttpNamedPipe" diff --git a/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json b/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json index ec1a7ec..77ba46c 100644 --- a/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json +++ b/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json @@ -1,6 +1,6 @@ { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.1.json", - "PolicyVersion": "1.1.0", + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.scenario-coverage", diff --git a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json index 8dcbd8b..c983aa9 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -1,5 +1,5 @@ { - "$id": "https://devolutions.net/schemas/now-policy.schema.1.1.json", + "$id": "https://devolutions.net/schemas/now-policy.schema.1.0.json", "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { @@ -503,7 +503,6 @@ }, "PolicySchemaUri": { "enum": [ - "https://devolutions.net/schemas/now-policy.schema.1.1.json", "https://devolutions.net/schemas/now-policy.schema.1.0.json" ], "type": "string" diff --git a/policies/rust/now-policy/src/markers.rs b/policies/rust/now-policy/src/markers.rs index dbac184..0ad8db6 100644 --- a/policies/rust/now-policy/src/markers.rs +++ b/policies/rust/now-policy/src/markers.rs @@ -56,54 +56,11 @@ fixed_string_marker! { pub struct PackageBrokerPolicy => "PackageBrokerPolicy"; } -/// Schema URI for package policy documents (current syntax version). -pub const POLICY_SCHEMA_URI: &str = "https://devolutions.net/schemas/now-policy.schema.1.1.json"; +/// Schema URI for package policy documents. +pub const POLICY_SCHEMA_URI: &str = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; -/// Schema URI of the original 1.0 policy syntax, still accepted on read. -pub const POLICY_SCHEMA_URI_V1_0: &str = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; - -/// Marker type for the policy `$schema` field. -/// -/// Serializes to the current canonical policy schema URI -/// ([`POLICY_SCHEMA_URI`]); accepts any known policy schema URI on -/// deserialization so documents written against an older syntax version -/// still parse. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PolicySchemaUri; - -impl Serialize for PolicySchemaUri { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(POLICY_SCHEMA_URI) - } -} - -impl<'de> Deserialize<'de> for PolicySchemaUri { - fn deserialize>(deserializer: D) -> Result { - let value = String::deserialize(deserializer)?; - if value == POLICY_SCHEMA_URI || value == POLICY_SCHEMA_URI_V1_0 { - Ok(Self) - } else { - Err(serde::de::Error::custom(format_args!( - "expected {POLICY_SCHEMA_URI:?} or {POLICY_SCHEMA_URI_V1_0:?}, got {value:?}" - ))) - } - } -} - -impl JsonSchema for PolicySchemaUri { - fn schema_name() -> String { - "PolicySchemaUri".to_owned() - } - - fn json_schema(_gen: &mut SchemaGenerator) -> Schema { - SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), - enum_values: Some(vec![ - serde_json::Value::String(POLICY_SCHEMA_URI.to_owned()), - serde_json::Value::String(POLICY_SCHEMA_URI_V1_0.to_owned()), - ]), - ..Default::default() - } - .into() - } +fixed_string_marker! { + /// Marker type for the policy `$schema` field. + /// Serializes to the canonical policy schema URI. + pub struct PolicySchemaUri => POLICY_SCHEMA_URI; } diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 4667b87..c44ef7a 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -92,33 +92,3 @@ fn policy_match_schema_requires_at_least_one_property() { assert_eq!(min_properties, Some(1)); } - -#[test] -fn legacy_1_0_schema_uri_is_accepted_and_upgraded_on_write() { - // corporate-allowlist still declares the 1.0 schema URI. - let path = samples_dir().join("corporate-allowlist.policy.json"); - let raw = std::fs::read_to_string(&path).unwrap(); - assert!(raw.contains(now_policy::POLICY_SCHEMA_URI_V1_0)); - - let policy = load_policy(&path); - let serialized = serde_json::to_string(&policy).unwrap(); - assert!( - serialized.contains(now_policy::POLICY_SCHEMA_URI), - "re-serialized policies must declare the current schema URI" - ); -} - -#[test] -fn unknown_schema_uri_is_rejected() { - let value = serde_json::json!({ - "$schema": "https://devolutions.net/schemas/now-policy.schema.9.9.json", - "PolicyVersion": "1.0.0", - "PolicyType": "PackageBrokerPolicy", - "Metadata": { "Id": "test", "Publisher": "test", "Revision": 1 }, - "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, - "Rules": [] - }); - - let error = serde_json::from_value::(value).unwrap_err(); - assert!(error.to_string().contains("expected"), "unexpected error: {error}"); -}