From 961e833c929793116b697e0943a7573ad6e21398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 18 Aug 2026 02:34:29 +0900 Subject: [PATCH 01/11] feat(now-policy-api): add active policy inspection contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerApi.cs | 1 + .../Devolutions.Now.Policy.Api/BrokerJson.cs | 17 +- .../Devolutions.Now.Policy.Api/README.md | 8 +- .../ResponseModels.cs | 26 + .../BrokerClientTests.cs | 70 +++ .../DtoRoundTripTests.cs | 5 + .../MetaModelTests.cs | 9 + .../SchemaValidationTests.cs | 7 + .../TestData.cs | 6 + .../BrokerClient.cs | 8 + .../Devolutions.Now.Policy.Client/README.md | 3 + policies/rust/now-policy-api/README.md | 5 +- .../openapi/now-policy-api.yaml | 512 ++++++++++++++++++ policies/rust/now-policy-api/src/lib.rs | 8 + policies/rust/now-policy-api/src/policy.rs | 41 ++ .../now-policy-server-template/Cargo.toml | 1 + .../rust/now-policy-server-template/README.md | 8 +- .../samples/responses/policy.response.json | 142 +++++ .../now-policy-server-template/src/mock.rs | 46 ++ .../now-policy-server-template/src/server.rs | 138 ++++- .../tests/sample_documents.rs | 199 +++++++ xtask/src/rust.rs | 10 + 22 files changed, 1253 insertions(+), 17 deletions(-) create mode 100644 policies/rust/now-policy-api/src/policy.rs create mode 100644 policies/rust/now-policy-server-template/assets/samples/responses/policy.response.json diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs index 387db94..1c2e5f3 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs @@ -19,6 +19,7 @@ public static class BrokerApi public const string ExecutionResponseKind = "ExecutionResponse"; public const string StatusResponseKind = "StatusResponse"; public const string CancelResponseKind = "CancelResponse"; + public const string PolicyResponseKind = "PolicyResponse"; public const string ErrorResponseKind = "ErrorResponse"; internal static string ValidateMessageKind(string? value, string expected, string propertyName) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index 5d87806..c9eaea2 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -43,6 +43,7 @@ private static JsonTypeInfo TypeInfo() => typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonSerializerContext.Default.CancelRequest) : typeof(T) == typeof(HealthResponse) ? Cast(BrokerJsonSerializerContext.Default.HealthResponse) : typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerJsonSerializerContext.Default.CapabilitiesResponse) : + typeof(T) == typeof(PolicyResponse) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyResponse) : typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonSerializerContext.Default.EvaluationResponse) : typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonSerializerContext.Default.ExecutionResponse) : typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonSerializerContext.Default.StatusResponse) : @@ -56,6 +57,7 @@ private static JsonTypeInfo StrictTypeInfo() => typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.CancelRequest) : typeof(T) == typeof(HealthResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.HealthResponse) : typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.CapabilitiesResponse) : + typeof(T) == typeof(PolicyResponse) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyResponse) : typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.EvaluationResponse) : typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.ExecutionResponse) : typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.StatusResponse) : @@ -102,4 +104,17 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(JsonObject))] [JsonSerializable(typeof(JsonArray))] -internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializerContext; \ No newline at end of file +internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializerContext; + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +[JsonSerializable(typeof(PolicyResponse))] +internal sealed partial class BrokerPolicyJsonSerializerContext : JsonSerializerContext; + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] +[JsonSerializable(typeof(PolicyResponse))] +internal sealed partial class BrokerPolicyJsonStrictSerializerContext : JsonSerializerContext; \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 3898aa8..2fe879d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -6,7 +6,7 @@ Devolutions NOW package broker API for .NET Purpose ------- -This package contains request, response, status, health, capabilities, and error DTOs for package broker clients and implementations. It does not perform HTTP transport, named-pipe I/O, policy evaluation, or package-manager execution. +This package contains request, response, status, health, capabilities, active-policy inspection, and error DTOs for package broker clients and implementations. It does not perform HTTP transport, named-pipe I/O, policy evaluation, or package-manager execution. Top-level request DTOs carry `RequestKind` and `RequestVersion`; top-level response DTOs carry `ResponseKind` and `ResponseVersion`. Kind properties are fixed discriminators that serialize @@ -17,7 +17,7 @@ the wire schema. The DTOs are used to: - serialize package broker requests from .NET clients; -- deserialize broker health, capability, evaluation, execution, status, and error responses; +- deserialize broker health, capability, active-policy, evaluation, execution, status, and error responses; - share the same JSON wire shape as the Rust source-of-truth model; - provide compatibility conversions between package broker API enums and the `Devolutions.Now.Policy.Model` policy enums. @@ -25,7 +25,7 @@ Architecture ------------ - `RequestModels.cs` defines `PackageRequest` and request context/options. -- `ResponseModels.cs` defines evaluation and execution responses plus shared response context, summaries, decisions, policy info, diagnostics, and operation submission. +- `ResponseModels.cs` defines active-policy, evaluation, and execution responses plus shared response context, summaries, decisions, policy info, diagnostics, and operation submission. `PolicyResponse` embeds the canonical `Devolutions.Now.Policy.Model.PolicyDocument`. - `StatusModels.cs` defines status query request/response DTOs. - `MetaModels.cs` defines health, capabilities, manager capability, and error DTOs. - `Enums.cs` defines package broker API enums and JSON string enum converters. @@ -44,7 +44,7 @@ policies\rust\now-policy-api\openapi\now-policy-api.yaml Regenerate it with: ```powershell -cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi --locked +cargo run -p now-policy-server-template --features policy-compat --bin generate-now-policy-api-openapi --locked ``` After schema changes, run the .NET client tests to verify these DTOs still match the Rust contract. diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs index d86dd57..dff535d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs @@ -1,7 +1,33 @@ using System.Text.Json.Serialization; +using Devolutions.Now.Policy.Model; + namespace Devolutions.Now.Policy.Api; +/// Response containing the broker's active parsed policy document. +public sealed class PolicyResponse +{ + private const string Kind = BrokerApi.PolicyResponseKind; + private string _responseKind = Kind; + + [JsonPropertyName("ResponseKind")] + [JsonRequired] + public string ResponseKind + { + get => _responseKind; + set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind)); + } + + [JsonPropertyName("ResponseVersion")] + public string ResponseVersion { get; set; } = BrokerApi.Version; + + [JsonPropertyName("Server")] + public ServerContext Server { get; set; } = new(); + + [JsonPropertyName("Policy")] + public PolicyDocument Policy { get; set; } = new(); +} + /// Canonical response returned by the broker after evaluating a request. public sealed class EvaluationResponse { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 8cd59ea..c821094 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -299,6 +299,75 @@ public async Task GetHealth_throws_typed_error_for_broker_error_response() Assert.Contains("mock failure", exception.Message); } + [Fact] + public async Task GetPolicy_sends_json_get_and_deserializes_response() + { + var body = await File.ReadAllTextAsync(Path.Combine(TestData.SamplesDir, "responses", "policy.response.json")); + var transport = new FakeBrokerTransport(body); + var client = CreateClient(transport); + + var response = await client.GetPolicy(); + + var request = Assert.Single(transport.Requests); + Assert.Equal("GET", request.Method); + Assert.Equal("/v1/policy", request.Path); + Assert.Null(request.Body); + Assert.Equal("application/json", request.Headers["Accept"]); + Assert.Equal(BrokerApi.PolicyResponseKind, response.ResponseKind); + Assert.Equal("contoso.desktop.standard-allowlist", response.Policy.Metadata.Id); + Assert.Equal(4u, response.Policy.Metadata.Revision); + } + + [Fact] + public async Task GetPolicy_propagates_cancellation() + { + var transport = new FakeBrokerTransport(Array.Empty()); + var client = CreateClient(transport); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => client.GetPolicy(cancellation.Token)); + Assert.Empty(transport.Requests); + } + + [Fact] + public async Task GetPolicy_preserves_structured_unsupported_error() + { + var transport = new FakeBrokerTransport(new BrokerTransportResponse + { + StatusCode = 404, + Body = """ + {"ResponseKind":"ErrorResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"Code":"NotFound","Message":"active policy inspection is not supported"} + """, + }); + var client = CreateClient(transport); + + var exception = await Assert.ThrowsAsync(() => client.GetPolicy()); + + Assert.Equal(BrokerClientErrorKind.BrokerError, exception.Kind); + Assert.Equal("/v1/policy", exception.Endpoint); + Assert.Equal(404, exception.StatusCode); + Assert.Equal(ErrorCode.NotFound, exception.BrokerError?.Code); + } + + [Theory] + [InlineData("")] + [InlineData("not found")] + public async Task GetPolicy_identifies_legacy_unstructured_not_found(string body) + { + var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 404, Body = body }); + var client = CreateClient(transport); + + var exception = await Assert.ThrowsAsync(() => client.GetPolicy()); + + Assert.True( + exception.Kind is BrokerClientErrorKind.EmptyResponse or BrokerClientErrorKind.BrokerError, + $"unexpected legacy 404 error kind: {exception.Kind}"); + Assert.Equal("/v1/policy", exception.Endpoint); + Assert.Equal(404, exception.StatusCode); + Assert.Null(exception.BrokerError); + } + [Fact] public void Constructor_can_resolve_effective_user_automatically() { @@ -340,6 +409,7 @@ public FakeBrokerTransport(params BrokerTransportResponse[] responses) public Task Send(BrokerTransportRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); Requests.Add(request); if (_responses.Count == 0) { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs index 8bac24d..6cfd91c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs @@ -57,6 +57,11 @@ public async Task HealthResponse_round_trips_and_validates(string path) public async Task CapabilitiesResponse_round_trips_and_validates(string path) => await AssertRoundTrip(path, await TestData.SchemaAsync("CapabilitiesResponse")); + [Theory] + [MemberData(nameof(TestData.PolicyResponseSamples), MemberType = typeof(TestData))] + public async Task PolicyResponse_round_trips_and_validates(string path) + => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyResponse")); + private static async Task AssertRoundTrip(string samplePath, JsonSchema schema) { var original = await File.ReadAllTextAsync(samplePath); diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs index 38e6249..7809a7d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs @@ -37,6 +37,15 @@ public void ResponseKind_rejects_wrong_value_on_deserialization() Assert.Throws(() => BrokerJson.DeserializeStrict(json)); } + [Fact] + public void PolicyResponseKind_rejects_wrong_value_on_deserialization() + { + var json = File.ReadAllText(Path.Combine(TestData.SamplesDir, "responses", "policy.response.json")) + .Replace(BrokerApi.PolicyResponseKind, BrokerApi.ErrorResponseKind, StringComparison.Ordinal); + + Assert.Throws(() => BrokerJson.DeserializeStrict(json)); + } + [Fact] public async Task ErrorResponse_serializes_to_schema_valid_output() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs index d10c73b..e01d110 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs @@ -32,6 +32,8 @@ public async Task Broker_api_version_matches_openapi_and_message_versions() Assert.Equal(BrokerApi.Version, new HealthResponse().ResponseVersion); Assert.Equal(BrokerApi.CapabilitiesResponseKind, new CapabilitiesResponse().ResponseKind); Assert.Equal(BrokerApi.Version, new CapabilitiesResponse().ResponseVersion); + Assert.Equal(BrokerApi.PolicyResponseKind, new PolicyResponse().ResponseKind); + Assert.Equal(BrokerApi.Version, new PolicyResponse().ResponseVersion); Assert.Equal(BrokerApi.ErrorResponseKind, new ErrorResponse().ResponseKind); Assert.Equal(BrokerApi.Version, new ErrorResponse().ResponseVersion); } @@ -71,6 +73,11 @@ public async Task Health_response_samples_are_schema_valid(string path) public async Task Capabilities_response_samples_are_schema_valid(string path) => await AssertValid(path, await TestData.SchemaAsync("CapabilitiesResponse")); + [Theory] + [MemberData(nameof(TestData.PolicyResponseSamples), MemberType = typeof(TestData))] + public async Task Policy_response_samples_are_schema_valid(string path) + => await AssertValid(path, await TestData.SchemaAsync("PolicyResponse")); + [Fact] public async Task Invalid_request_is_rejected_by_schema() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs index bc6007f..df115cc 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs @@ -163,6 +163,7 @@ public static IEnumerable ResponseSamples() => .Where(f => !Path.GetFileName(f).StartsWith("execution-", StringComparison.Ordinal)) .Where(f => !Path.GetFileName(f).StartsWith("health-", StringComparison.Ordinal)) .Where(f => !Path.GetFileName(f).StartsWith("capabilities", StringComparison.Ordinal)) + .Where(f => !Path.GetFileName(f).StartsWith("policy", StringComparison.Ordinal)) .Select(f => new object[] { f }); public static IEnumerable ExecutionResponseSamples() => @@ -190,6 +191,11 @@ public static IEnumerable CapabilitiesResponseSamples() => .Where(f => Path.GetFileName(f).StartsWith("capabilities", StringComparison.Ordinal)) .Select(f => new object[] { f }); + public static IEnumerable PolicyResponseSamples() => + JsonFiles(Path.Combine(SamplesDir, "responses")) + .Where(f => Path.GetFileName(f).StartsWith("policy", StringComparison.Ordinal)) + .Select(f => new object[] { f }); + private static IEnumerable JsonFiles(string dir) => Directory.Exists(dir) ? Directory.GetFiles(dir, "*.json") : []; diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs index 1ee52bd..cfecac1 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -72,6 +72,14 @@ public async Task GetCapabilities(CancellationToken cancel return _capabilities; } + /// Get the broker's active parsed policy document. + public async Task GetPolicy(CancellationToken cancellationToken = default) + { + var headers = new Dictionary { ["Accept"] = JsonMediaType }; + var response = await SendRequest("GET", "/v1/policy", null, headers, cancellationToken).ConfigureAwait(false); + return DeserializeResponse(response, "policy", "/v1/policy"); + } + /// Evaluate a package operation against policy without executing it (dry-run). public async Task Evaluate(PackageOperationRequest request, CancellationToken cancellationToken = default) { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index 043f6f3..bde02b5 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -45,6 +45,7 @@ The main surface is `BrokerClient`: - `IsAvailable` probes the health endpoint. - `GetHealth` and `GetCapabilities` query broker metadata. +- `GetPolicy` sends `GET /v1/policy` and returns the active parsed `PolicyDocument`. - `Evaluate` sends `POST /v1/package-operations/evaluate`. - `Execute` sends `POST /v1/package-operations/execute`. - `ExecuteAndWait` submits an operation and polls status until a terminal state. @@ -105,6 +106,8 @@ Response-oriented methods return successful DTOs or throw `BrokerClientException `IsAvailable` remains a boolean probe and reports diagnostics through `BrokerClient.Trace`. Other methods do not silently convert failures into `null`. +`GetPolicy` preserves both legacy and structured unsupported-endpoint behavior. Old Agents may return an empty or non-JSON 404, which is exposed with `StatusCode == 404` and no `BrokerError`. Rebuilt implementations may return a structured `ErrorResponse` with `Code == NotFound`. A supported Agent that cannot provide its active policy returns a structured non-404 error. + Schema relationship ------------------- diff --git a/policies/rust/now-policy-api/README.md b/policies/rust/now-policy-api/README.md index 7a20c35..25dc2ff 100644 --- a/policies/rust/now-policy-api/README.md +++ b/policies/rust/now-policy-api/README.md @@ -27,6 +27,7 @@ Library structure overview: - `event_channel.rs` contains the per-operation event channel descriptor returned in execution responses and the `NOW_BROKER` binary frame protocol codec (see `policies/docs/event-channel-protocol.md`). - `health.rs` contains health endpoint models for `GET /v1/health`. - `capabilities.rs` contains capability endpoint models for `GET /v1/capabilities`. +- `policy.rs`, enabled by `policy-compat`, contains the active `PolicyDocument` response for `GET /v1/policy`. - `enums.rs` contains shared protocol enums. - `lib.rs` contains constrained string newtypes, validation helpers, etc. - `policy_compat.rs` is enabled by the `policy-compat` feature and maps selected API model types to the `now-policy` crate's package policy types. @@ -49,9 +50,11 @@ The route-aware generator lives in `now-policy-server-template`, because OpenAPI Regenerate it with: ```powershell -cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi --locked +cargo run -p now-policy-server-template --features policy-compat --bin generate-now-policy-api-openapi --locked ``` +The generator requires `policy-compat` so the published document always contains the policy inspection route and canonical `PolicyDocument` schema. + Validation ---------- 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 ba875ec..ee64014 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -26,6 +26,35 @@ paths: application/json: schema: $ref: '#/components/schemas/CapabilitiesResponse' + /v1/policy: + get: + summary: Get active policy + description: Returns the active parsed policy document. A 404 response means policy inspection is unsupported. + responses: + '200': + description: Response body for `GET /v1/policy`. + content: + application/json: + schema: + $ref: '#/components/schemas/PolicyResponse' + '404': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /v1/package-operations/evaluate: post: summary: Evaluate package operation @@ -827,6 +856,30 @@ components: PackageRequestKind: type: string pattern: ^PackageRequest$ + PolicyResponse: + description: Response body for `GET /v1/policy`. + type: object + required: + - Policy + - ResponseKind + - ResponseVersion + - Server + properties: + Policy: + description: Active parsed policy document. + $ref: '#/components/schemas/PolicyDocument' + ResponseKind: + description: Response discriminator. + $ref: '#/components/schemas/PolicyResponseKind' + ResponseVersion: + description: Server-side API version used to construct the response. + $ref: '#/components/schemas/ApiVersion' + Server: + description: Server context. + $ref: '#/components/schemas/ServerContext' + PolicyResponseKind: + type: string + pattern: ^PolicyResponse$ ProcessName: description: A process name string. type: string @@ -1106,3 +1159,462 @@ components: type: string maxLength: 128 minLength: 1 + PolicyDocument: + title: PolicyDocument + description: A policy document governing which package operations are allowed or denied. + type: object + required: + - $schema + - Enforcement + - Metadata + - PolicyType + - PolicyVersion + - Rules + properties: + $schema: + description: Policy schema URI constant. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicySchemaUri' + Enforcement: + description: Enforcement configuration. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyEnforcement' + Metadata: + description: Policy metadata. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyMetadata' + PolicyType: + description: Must be `"PackageBrokerPolicy"`. + allOf: + - $ref: '#/components/schemas/PolicyModelPackageBrokerPolicy' + PolicyVersion: + description: Policy syntax version (semver). + allOf: + - $ref: '#/components/schemas/PolicyModelSemanticVersion' + Rules: + description: Ordered list of policy rules (may be empty; enforcement defaults apply). + type: array + items: + $ref: '#/components/schemas/PolicyModelPolicyRule' + maxItems: 1024 + additionalProperties: false + PolicyModelArchitecture: + description: Target architecture. + type: string + enum: + - X86 + - X64 + - Arm64 + - Neutral + PolicyModelCustomParameterString: + description: A custom parameter string. + type: string + maxLength: 512 + minLength: 1 + PolicyModelDecision: + description: Policy decision. + type: string + enum: + - Allow + - Deny + PolicyModelElevation: + description: Requested elevation level. + type: string + enum: + - Standard + - Elevated + PolicyModelHttpUrl: + description: |- + HTTP(S) URL string. + + Validated at deserialization time using the `url` crate. + type: string + maxLength: 2048 + pattern: ^([Hh][Tt][Tt][Pp][Ss]?)://.+$ + PolicyModelManagerName: + description: Supported package manager names. + type: string + enum: + - Winget + - PowerShell + - PowerShell7 + - Apt + - Bun + - Cargo + - Chocolatey + - Dnf + - Dotnet + - Flatpak + - Homebrew + - Npm + - Pacman + - Pip + - Scoop + - Snap + - Vcpkg + PolicyModelOperation: + description: Package operation type. + type: string + enum: + - Install + - Update + - Uninstall + PolicyModelPackageBrokerPolicy: + type: string + enum: + - PackageBrokerPolicy + PolicyModelPolicyConstraints: + description: Constraints applied after a rule matches. + type: object + properties: + AllowCustomInstallLocation: + description: Allow custom install location. + type: boolean + AllowCustomParameters: + description: Allow custom parameters. + type: boolean + AllowInteractive: + description: Allow interactive mode. + type: boolean + AllowKillBeforeOperation: + description: Allow killing processes before operation. + type: boolean + AllowPrePostCommands: + description: Allow pre/post operation commands. + type: boolean + AllowPreRelease: + description: Allow pre-release versions. + type: boolean + AllowSkipHashCheck: + description: Allow skipping hash verification. + type: boolean + AllowUninstallPrevious: + description: Allow uninstalling previous version before installing update. + type: boolean + AllowUpgrade: + description: Allow skipping upgrade on install operations if an existing version is detected (for install operations). + type: boolean + AllowedCustomParameterPatterns: + description: Glob patterns for allowed custom parameters. + type: array + items: + $ref: '#/components/schemas/PolicyModelCustomParameterString' + maxItems: 128 + AllowedCustomParameters: + description: Exact allowed custom parameters. + type: array + items: + $ref: '#/components/schemas/PolicyModelCustomParameterString' + maxItems: 128 + AllowedInstallLocationPatterns: + description: Glob patterns for allowed install locations. + type: array + items: + $ref: '#/components/schemas/PolicyModelStringPattern' + maxItems: 64 + DeniedCustomParameters: + description: Denied custom parameters (deny takes precedence over allow). + type: array + items: + $ref: '#/components/schemas/PolicyModelCustomParameterString' + maxItems: 128 + additionalProperties: false + PolicyModelPolicyEnforcement: + description: Enforcement configuration. + type: object + required: + - DefaultDecision + - RulePrecedence + properties: + AuditMode: + description: When true, broker logs decisions but does not enforce. + type: boolean + nullable: true + DefaultDecision: + description: Decision when no rule matches. + allOf: + - $ref: '#/components/schemas/PolicyModelDecision' + RulePrecedence: + description: Rule precedence strategy (must be "PriorityThenDeny"). + allOf: + - $ref: '#/components/schemas/PolicyModelRulePrecedence' + additionalProperties: false + PolicyModelPolicyMatch: + description: Match criteria for a policy rule. All specified fields must match. At least one field must be present. + type: object + properties: + Architectures: + description: Allowed architectures. + type: array + items: + $ref: '#/components/schemas/PolicyModelArchitecture' + maxItems: 5 + uniqueItems: true + Elevation: + description: Allowed elevation levels. + type: array + items: + $ref: '#/components/schemas/PolicyModelElevation' + maxItems: 2 + uniqueItems: true + HasCustomInstallLocation: + description: Whether request has custom install location. + type: array + items: + type: boolean + maxItems: 2 + uniqueItems: true + HasCustomParameters: + description: Whether request has custom parameters. + type: array + items: + type: boolean + maxItems: 2 + uniqueItems: true + HasKillBeforeOperation: + description: Whether request has kill-before-operation entries. + type: array + items: + type: boolean + maxItems: 2 + uniqueItems: true + HasPrePostCommands: + description: Whether request has pre/post operation commands. + type: array + items: + type: boolean + maxItems: 2 + uniqueItems: true + HasUninstallPrevious: + description: Whether request has uninstall-previous flag set. + type: array + items: + type: boolean + uniqueItems: true + Interactive: + description: Allowed interactive values. + type: array + items: + type: boolean + maxItems: 2 + uniqueItems: true + Managers: + description: Allowed managers. + type: array + items: + $ref: '#/components/schemas/PolicyModelManagerName' + maxItems: 16 + uniqueItems: true + Operations: + description: Allowed operations. + type: array + items: + $ref: '#/components/schemas/PolicyModelOperation' + maxItems: 3 + uniqueItems: true + PackageIdentifiers: + description: Package identifier patterns (wildcard). + type: array + items: + $ref: '#/components/schemas/PolicyModelStringPattern' + maxItems: 1024 + uniqueItems: true + PackageNames: + description: Package name patterns (wildcard). + type: array + items: + $ref: '#/components/schemas/PolicyModelStringPattern' + maxItems: 1024 + uniqueItems: true + PreRelease: + description: Allowed preRelease values. + type: array + items: + type: boolean + maxItems: 2 + uniqueItems: true + Scopes: + description: Allowed scopes. + type: array + items: + $ref: '#/components/schemas/PolicyModelScope' + maxItems: 2 + uniqueItems: true + SkipHashCheck: + description: Allowed skipHashCheck values. + type: array + items: + type: boolean + maxItems: 2 + uniqueItems: true + Sources: + description: Source patterns (wildcard). + type: array + items: + $ref: '#/components/schemas/PolicyModelStringPattern' + maxItems: 128 + uniqueItems: true + VersionRange: + description: Semantic version range. + allOf: + - $ref: '#/components/schemas/PolicyModelVersionRange' + nullable: true + Versions: + description: Exact version list. + type: array + items: + $ref: '#/components/schemas/PolicyModelVersionString' + maxItems: 256 + uniqueItems: true + additionalProperties: false + PolicyModelPolicyMetadata: + description: Policy metadata. + type: object + required: + - Id + - PublishedAt + - Publisher + - Revision + properties: + Description: + description: Human-readable description. + type: string + maxLength: 512 + nullable: true + Id: + description: Unique policy identifier. + allOf: + - $ref: '#/components/schemas/PolicyModelResourceId' + PublishedAt: + description: ISO 8601 publication timestamp (RFC 3339). + type: string + format: date-time + Publisher: + description: Organization that published the policy. + type: string + maxLength: 128 + minLength: 1 + Revision: + description: Monotonically increasing revision number. + type: integer + format: uint32 + maximum: 2147483647.0 + minimum: 1.0 + SupportUrl: + description: URL for support or documentation. + allOf: + - $ref: '#/components/schemas/PolicyModelHttpUrl' + nullable: true + ValidFrom: + description: Policy becomes active at this time. + type: string + format: date-time + nullable: true + ValidUntil: + description: Policy expires at this time. + type: string + format: date-time + nullable: true + additionalProperties: false + PolicyModelPolicyRule: + description: A single policy rule. + type: object + required: + - Decision + - Id + - Match + - Priority + properties: + Constraints: + description: Additional constraints applied after matching. When absent, no constraints are enforced beyond the match criteria. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyConstraints' + nullable: true + Decision: + description: Decision if this rule matches. + allOf: + - $ref: '#/components/schemas/PolicyModelDecision' + Enabled: + description: Whether the rule is active. + default: true + type: boolean + Id: + description: Unique rule identifier. + allOf: + - $ref: '#/components/schemas/PolicyModelResourceId' + Match: + description: Match criteria — request must satisfy all specified fields. At least one criterion must be present. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyMatch' + minProperties: 1 + Priority: + description: Priority (lower = higher precedence). + type: integer + format: uint32 + maximum: 2147483647.0 + minimum: 0.0 + Reason: + description: Reason reported to the client. + type: string + maxLength: 512 + nullable: true + additionalProperties: false + PolicyModelPolicySchemaUri: + type: string + enum: + - https://devolutions.net/schemas/now-policy.schema.1.0.json + PolicyModelResourceId: + description: Resource identifier (policy IDs, rule IDs, request IDs, audit IDs). + type: string + maxLength: 128 + pattern: ^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$ + PolicyModelRulePrecedence: + description: Rule precedence strategy — always PriorityThenDeny. + type: string + enum: + - PriorityThenDeny + PolicyModelScope: + description: Package installation scope. + type: string + enum: + - User + - Machine + PolicyModelSemanticVersion: + description: |- + Semantic version string (SemVer 2.0.0). + + Validated at deserialization time using the `semver` crate. + type: string + maxLength: 128 + pattern: ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$ + PolicyModelStringPattern: + description: Case-insensitive exact value or wildcard pattern. + type: string + maxLength: 256 + minLength: 1 + PolicyModelVersionRange: + description: Semantic version range for matching. + type: object + properties: + IncludePrerelease: + description: Whether to include pre-release versions. + default: false + type: boolean + MaxVersion: + description: Maximum version (inclusive). + type: string + maxLength: 128 + minLength: 1 + nullable: true + MinVersion: + description: Minimum version (inclusive). + type: string + maxLength: 128 + minLength: 1 + nullable: true + additionalProperties: false + PolicyModelVersionString: + description: A short constrained string for version values. + type: string + maxLength: 128 + minLength: 1 diff --git a/policies/rust/now-policy-api/src/lib.rs b/policies/rust/now-policy-api/src/lib.rs index 4b6b8d3..d2a0856 100644 --- a/policies/rust/now-policy-api/src/lib.rs +++ b/policies/rust/now-policy-api/src/lib.rs @@ -12,6 +12,8 @@ pub mod event_channel; pub mod execute; pub mod health; #[cfg(feature = "policy-compat")] +pub mod policy; +#[cfg(feature = "policy-compat")] mod policy_compat; pub mod status; @@ -23,6 +25,8 @@ pub use evaluate::*; pub use event_channel::*; pub use execute::*; pub use health::*; +#[cfg(feature = "policy-compat")] +pub use policy::*; pub use status::*; pub const API_VERSION_STR: &str = "1.0"; @@ -38,6 +42,8 @@ pub const EVALUATION_RESPONSE_KIND: &str = "EvaluationResponse"; pub const EXECUTION_RESPONSE_KIND: &str = "ExecutionResponse"; pub const STATUS_RESPONSE_KIND: &str = "StatusResponse"; pub const CANCEL_RESPONSE_KIND: &str = "CancelResponse"; +#[cfg(feature = "policy-compat")] +pub const POLICY_RESPONSE_KIND: &str = "PolicyResponse"; pub const ERROR_RESPONSE_KIND: &str = "ErrorResponse"; macro_rules! fixed_string_marker { @@ -102,6 +108,8 @@ fixed_string_marker!(EvaluationResponseKind, EVALUATION_RESPONSE_KIND); fixed_string_marker!(ExecutionResponseKind, EXECUTION_RESPONSE_KIND); fixed_string_marker!(StatusResponseKind, STATUS_RESPONSE_KIND); fixed_string_marker!(CancelResponseKind, CANCEL_RESPONSE_KIND); +#[cfg(feature = "policy-compat")] +fixed_string_marker!(PolicyResponseKind, POLICY_RESPONSE_KIND); fixed_string_marker!(ErrorResponseKind, ERROR_RESPONSE_KIND); /// Error returned when a broker protocol newtype fails deserialization validation. diff --git a/policies/rust/now-policy-api/src/policy.rs b/policies/rust/now-policy-api/src/policy.rs new file mode 100644 index 0000000..d52bd78 --- /dev/null +++ b/policies/rust/now-policy-api/src/policy.rs @@ -0,0 +1,41 @@ +//! Active policy inspection endpoint models. + +#![allow( + unused_qualifications, + reason = "schemars schema_with expansion triggers this lint for an unqualified function name" +)] + +use now_policy::PolicyDocument; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::{Schema, SchemaObject}; +use serde::{Deserialize, Serialize}; + +use super::api::ServerContext; +use super::{ApiVersion, PolicyResponseKind}; + +/// Response body for `GET /v1/policy`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyResponse")] +#[serde(rename_all = "PascalCase")] +pub struct PolicyResponse { + /// Response discriminator. + pub response_kind: PolicyResponseKind, + + /// Server-side API version used to construct the response. + pub response_version: ApiVersion, + + /// Server context. + pub server: ServerContext, + + /// Active parsed policy document. + #[schemars(schema_with = "policy_document_schema")] + pub policy: PolicyDocument, +} + +fn policy_document_schema(_generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + reference: Some("#/components/schemas/PolicyDocument".to_owned()), + ..SchemaObject::default() + }) +} diff --git a/policies/rust/now-policy-server-template/Cargo.toml b/policies/rust/now-policy-server-template/Cargo.toml index f8b03f5..f632e7d 100644 --- a/policies/rust/now-policy-server-template/Cargo.toml +++ b/policies/rust/now-policy-server-template/Cargo.toml @@ -35,3 +35,4 @@ tower = { version = "0.5", features = ["util"] } [[bin]] name = "generate-now-policy-api-openapi" path = "tools/generate_openapi.rs" +required-features = ["policy-compat"] diff --git a/policies/rust/now-policy-server-template/README.md b/policies/rust/now-policy-server-template/README.md index 1f8a5c9..fac6e50 100644 --- a/policies/rust/now-policy-server-template/README.md +++ b/policies/rust/now-policy-server-template/README.md @@ -39,6 +39,7 @@ Runtime implementations implement: pub trait PackageBrokerServer: Send + Sync { async fn health(&self) -> HealthResponse; async fn capabilities(&self) -> CapabilitiesResponse; + async fn policy(&self) -> Result; async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; async fn status(&self, request: StatusRequest) -> Result; @@ -49,6 +50,7 @@ Then they pass the implementation to `api_router` or `api_router_from_shared`. T - `GET /v1/health` - `GET /v1/capabilities` +- `GET /v1/policy` (with `policy-compat`) - `POST /v1/package-operations/evaluate` - `POST /v1/package-operations/execute` - `POST /v1/package-operations/get-status` @@ -58,7 +60,7 @@ This keeps route dispatch, error responses, and OpenAPI operation metadata in on Mock and fixtures ----------------- -`MockPackageBrokerServer` is intended for protocol tests, sample validation, and client development. It returns deterministic health/capabilities responses and can be configured with evaluation, execution, and status responses loaded from fixture files. +`MockPackageBrokerServer` is intended for protocol tests, sample validation, and client development. It returns deterministic health/capabilities responses and can be configured with policy, evaluation, execution, and status responses loaded from fixture files. Sample documents live under: @@ -80,10 +82,10 @@ OpenAPI generation lives here because it requires the HTTP route binding from `s Regenerate it with: ```powershell -cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi --locked +cargo run -p now-policy-server-template --features policy-compat --bin generate-now-policy-api-openapi --locked ``` -With the `policy-compat` feature enabled, the generated components also include the policy document schema from `now-policy`. +The generator requires `policy-compat`; the generated route and components include the policy response and policy document schema from `now-policy`. Validation ---------- diff --git a/policies/rust/now-policy-server-template/assets/samples/responses/policy.response.json b/policies/rust/now-policy-server-template/assets/samples/responses/policy.response.json new file mode 100644 index 0000000..e1efb1d --- /dev/null +++ b/policies/rust/now-policy-server-template/assets/samples/responses/policy.response.json @@ -0,0 +1,142 @@ +{ + "ResponseKind": "PolicyResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "0.1.0", + "Transport": "HttpNamedPipe" + }, + "Policy": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.standard-allowlist", + "Publisher": "Contoso IT", + "Revision": 4, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Fail-closed policy for standard workstation package installs." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Enabled": true, + "Priority": 10, + "Decision": "Deny", + "Reason": "Integrity and publisher checks cannot be bypassed by brokered requests.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.custom-parameters", + "Enabled": true, + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters are not allowed in the workstation allow list.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Enabled": true, + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are not allowed in the workstation allow list.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "allow.winget.vscode", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "Visual Studio Code is approved for managed workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.powertoys", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "PowerToys is approved for developer workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] + } +} diff --git a/policies/rust/now-policy-server-template/src/mock.rs b/policies/rust/now-policy-server-template/src/mock.rs index f574206..693a3bc 100644 --- a/policies/rust/now-policy-server-template/src/mock.rs +++ b/policies/rust/now-policy-server-template/src/mock.rs @@ -5,6 +5,8 @@ use std::collections::BTreeMap; use async_trait::async_trait; use crate::server::{MAX_REQUEST_BODY_BYTES, PackageBrokerServer}; +#[cfg(feature = "policy-compat")] +use now_policy_api::PolicyResponse; use now_policy_api::{ API_VERSION_STR, Architecture, CancelRequest, CancelResponse, CapabilitiesResponse, CapabilitiesResponseKind, ErrorCode, ErrorResponse, ErrorResponseKind, EvaluationResponse, ExecutionResponse, HealthResponse, @@ -17,6 +19,10 @@ use now_policy_api::{ pub struct MockPackageBrokerServer { health: HealthResponse, capabilities: CapabilitiesResponse, + #[cfg(feature = "policy-compat")] + policy_response: Option, + #[cfg(feature = "policy-compat")] + policy_error: Option, evaluation_responses: BTreeMap, execution_responses: BTreeMap, status_responses: BTreeMap, @@ -42,6 +48,10 @@ impl MockPackageBrokerServer { managers: default_manager_capabilities(), max_request_body_bytes: MAX_REQUEST_BODY_BYTES as u64, }, + #[cfg(feature = "policy-compat")] + policy_response: None, + #[cfg(feature = "policy-compat")] + policy_error: None, evaluation_responses: BTreeMap::new(), execution_responses: BTreeMap::new(), status_responses: BTreeMap::new(), @@ -56,6 +66,22 @@ impl MockPackageBrokerServer { self } + #[cfg(feature = "policy-compat")] + #[must_use] + pub fn with_policy_response(mut self, response: PolicyResponse) -> Self { + self.policy_response = Some(response); + self.policy_error = None; + self + } + + #[cfg(feature = "policy-compat")] + #[must_use] + pub fn with_policy_error(mut self, error: ErrorResponse) -> Self { + self.policy_response = None; + self.policy_error = Some(error); + self + } + #[must_use] pub fn with_execution_response(mut self, response: ExecutionResponse) -> Self { self.execution_responses @@ -99,6 +125,26 @@ impl PackageBrokerServer for MockPackageBrokerServer { self.capabilities.clone() } + #[cfg(feature = "policy-compat")] + async fn policy(&self) -> Result { + if let Some(response) = &self.policy_response { + return Ok(response.clone()); + } + + if let Some(error) = &self.policy_error { + return Err(error.clone()); + } + + Err(ErrorResponse { + response_kind: ErrorResponseKind, + response_version: API_VERSION_STR.into(), + server: self.capabilities.server.clone(), + code: ErrorCode::NotFound, + message: "active policy inspection is not supported".to_owned(), + details: Vec::new(), + }) + } + async fn evaluate(&self, request: PackageRequest) -> Result { self.evaluation_responses .get(&request.request_id.to_string()) diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index a1e532c..8e6d35b 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -17,6 +17,8 @@ use now_policy_api::{ API_VERSION_STR, CancelRequest, CancelResponse, CapabilitiesResponse, ErrorCode, ErrorResponse, EvaluationResponse, ExecutionResponse, HealthResponse, PackageRequest, StatusRequest, StatusResponse, }; +#[cfg(feature = "policy-compat")] +use now_policy_api::{ErrorResponseKind, PolicyResponse}; use schemars::SchemaGenerator; pub const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; @@ -26,6 +28,17 @@ pub const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; pub trait PackageBrokerServer: Send + Sync { async fn health(&self) -> HealthResponse; async fn capabilities(&self) -> CapabilitiesResponse; + #[cfg(feature = "policy-compat")] + async fn policy(&self) -> Result { + Err(ErrorResponse { + response_kind: ErrorResponseKind, + response_version: API_VERSION_STR.into(), + server: self.capabilities().await.server, + code: ErrorCode::NotFound, + message: "active policy inspection is not supported".to_owned(), + details: Vec::new(), + }) + } async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; async fn status(&self, request: StatusRequest) -> Result; @@ -49,9 +62,14 @@ pub fn api_router_from_shared(server: SharedPackageBrokerServer) -> ApiRouter<() } fn api_routes() -> ApiRouter { - ApiRouter::new() + let router = ApiRouter::new() .api_route("/v1/health", get_with(health_handler, health_docs)) - .api_route("/v1/capabilities", get_with(capabilities_handler, capabilities_docs)) + .api_route("/v1/capabilities", get_with(capabilities_handler, capabilities_docs)); + + #[cfg(feature = "policy-compat")] + let router = router.api_route("/v1/policy", get_with(policy_handler, policy_docs)); + + router .api_route( "/v1/package-operations/evaluate", post_with(evaluate_handler, evaluate_docs) @@ -106,11 +124,19 @@ fn openapi_schema_generator() -> SchemaGenerator { #[cfg(feature = "policy-compat")] fn register_policy_schema(api: &mut OpenApi) { + use std::collections::BTreeMap; + use aide::openapi::{Components, SchemaObject}; use now_policy::PolicyDocument; use schemars::schema::Schema; let root = openapi_schema_generator().into_root_schema_for::(); + let renames: BTreeMap<_, _> = root + .definitions + .keys() + .map(|name| (name.clone(), format!("PolicyModel{name}"))) + .collect(); + let root_schema = rewrite_policy_schema_refs(Schema::Object(root.schema), &renames); let components = api.components.get_or_insert_with(Components::default); @@ -118,18 +144,60 @@ fn register_policy_schema(api: &mut OpenApi) { .schemas .entry("PolicyDocument".to_owned()) .or_insert_with(|| SchemaObject { - json_schema: Schema::Object(root.schema), + json_schema: root_schema, external_docs: None, example: None, }); for (name, schema) in root.definitions { - components.schemas.entry(name).or_insert_with(|| SchemaObject { - json_schema: schema, - external_docs: None, - example: None, - }); + let component_name = renames + .get(&name) + .expect("BUG: every policy schema definition should have a namespaced component"); + components + .schemas + .entry(component_name.clone()) + .or_insert_with(|| SchemaObject { + json_schema: rewrite_policy_schema_refs(schema, &renames), + external_docs: None, + example: None, + }); + } +} + +#[cfg(feature = "policy-compat")] +fn rewrite_policy_schema_refs( + schema: schemars::schema::Schema, + renames: &std::collections::BTreeMap, +) -> schemars::schema::Schema { + fn rewrite(value: &mut serde_json::Value, renames: &std::collections::BTreeMap) { + match value { + serde_json::Value::String(reference) => { + for prefix in ["#/components/schemas/", "#/definitions/"] { + if let Some(name) = reference.strip_prefix(prefix) + && let Some(replacement) = renames.get(name) + { + *reference = format!("#/components/schemas/{replacement}"); + break; + } + } + } + serde_json::Value::Array(values) => { + for value in values { + rewrite(value, renames); + } + } + serde_json::Value::Object(values) => { + for value in values.values_mut() { + rewrite(value, renames); + } + } + _ => {} + } } + + let mut value = serde_json::to_value(schema).expect("BUG: policy schema should serialize"); + rewrite(&mut value, renames); + serde_json::from_value(value).expect("BUG: rewritten policy schema should deserialize") } async fn health_handler(State(server): State) -> Json { @@ -140,6 +208,11 @@ async fn capabilities_handler(State(server): State) - Json(server.capabilities().await) } +#[cfg(feature = "policy-compat")] +async fn policy_handler(State(server): State) -> Response { + broker_result(server.policy().await) +} + async fn evaluate_handler( State(server): State, Json(request): Json, @@ -203,6 +276,18 @@ fn capabilities_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { .response::<200, Json>() } +#[cfg(feature = "policy-compat")] +fn policy_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { + op.summary("Get active policy") + .description( + "Returns the active parsed policy document. A 404 response means policy inspection is unsupported.", + ) + .response::<200, Json>() + .response::<404, Json>() + .response::<500, Json>() + .response::<503, Json>() +} + fn evaluate_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { op.summary("Evaluate package operation") .description("Evaluates a package operation against policy without requiring elevated execution.") @@ -240,3 +325,40 @@ fn cancel_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { .response::<400, Json>() .response::<404, Json>() } + +#[cfg(all(test, feature = "policy-compat"))] +mod tests { + use super::openapi; + + #[test] + fn policy_schemas_do_not_rename_existing_api_components() { + let api = openapi(); + let schemas = &api.components.expect("OpenAPI components should exist").schemas; + + for name in [ + "Architecture", + "CustomParameterString", + "Decision", + "Elevation", + "ManagerName", + "Operation", + "ResourceId", + "Scope", + "SemanticVersion", + "VersionString", + ] { + assert!( + schemas.contains_key(name), + "existing API component {name} should remain" + ); + assert!( + schemas.contains_key(&format!("PolicyModel{name}")), + "embedded policy component {name} should be namespaced" + ); + assert!( + !schemas.contains_key(&format!("{name}2")), + "component collision must not rename {name}" + ); + } + } +} diff --git a/policies/rust/now-policy-server-template/tests/sample_documents.rs b/policies/rust/now-policy-server-template/tests/sample_documents.rs index 8d6a0e6..64305ce 100644 --- a/policies/rust/now-policy-server-template/tests/sample_documents.rs +++ b/policies/rust/now-policy-server-template/tests/sample_documents.rs @@ -12,6 +12,42 @@ use now_policy_server_template::{ }; use tower::ServiceExt; +#[cfg(feature = "policy-compat")] +use now_policy_server_template::{ + ErrorCode, ErrorResponse, ErrorResponseKind, PolicyResponse, PolicyResponseKind, ServerContext, +}; + +#[cfg(feature = "policy-compat")] +struct DefaultPolicyServer(MockPackageBrokerServer); + +#[cfg(feature = "policy-compat")] +#[async_trait::async_trait] +impl PackageBrokerServer for DefaultPolicyServer { + async fn health(&self) -> HealthResponse { + self.0.health().await + } + + async fn capabilities(&self) -> CapabilitiesResponse { + self.0.capabilities().await + } + + async fn evaluate(&self, request: PackageRequest) -> Result { + self.0.evaluate(request).await + } + + async fn execute(&self, request: PackageRequest) -> Result { + self.0.execute(request).await + } + + async fn status(&self, request: StatusRequest) -> Result { + self.0.status(request).await + } + + async fn cancel(&self, request: CancelRequest) -> Result { + self.0.cancel(request).await + } +} + fn samples_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/samples") } @@ -72,6 +108,12 @@ fn assert_response_sample_deserializes(path: &Path) { } else if name.starts_with("capabilities") { let _: CapabilitiesResponse = serde_json::from_value(load_json_file(path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); + } else if name.starts_with("policy") { + #[cfg(feature = "policy-compat")] + { + let _: PolicyResponse = serde_json::from_value(load_json_file(path)) + .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); + } } else { let _: EvaluationResponse = serde_json::from_value(load_json_file(path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); @@ -152,6 +194,31 @@ fn capabilities_response_sample_matches_api_contract() { assert!(winget.supports_details); } +#[cfg(feature = "policy-compat")] +#[test] +fn policy_response_sample_matches_api_contract() { + let content = load_text_file(&response_sample_path("policy.response.json")); + let policy: PolicyResponse = serde_json::from_str(&content).unwrap(); + + assert_eq!(policy.response_kind, PolicyResponseKind); + assert_eq!(&*policy.response_version, API_VERSION_STR); + assert_eq!(policy.server.transport, Transport::HttpNamedPipe); + assert_eq!(&*policy.policy.metadata.id, "contoso.desktop.standard-allowlist"); + assert_eq!(policy.policy.metadata.revision, 4); + assert_eq!(policy.policy.rules.len(), 5); +} + +#[cfg(feature = "policy-compat")] +#[test] +fn policy_response_embeds_the_canonical_policy_fixture() { + let response = load_json_file(&response_sample_path("policy.response.json")); + let policy_path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../now-policy/assets/samples/corporate-allowlist.policy.json"); + let policy = load_json_file(&policy_path); + + assert_eq!(response.get("Policy"), Some(&policy)); +} + #[test] fn invalid_request_missing_package_id_fails_deserialization() { let path = samples_dir().join("requests/missing-package-id.request.json"); @@ -286,6 +353,32 @@ async fn mock_health_and_capabilities_match_response_samples() { assert_eq!(actual_capabilities.managers.len(), expected_capabilities.managers.len()); } +#[cfg(feature = "policy-compat")] +#[tokio::test] +async fn mock_server_returns_registered_policy_response() { + let content = load_text_file(&response_sample_path("policy.response.json")); + let expected: PolicyResponse = serde_json::from_str(&content).unwrap(); + let server = MockPackageBrokerServer::new(DEFAULT_PIPE_NAME).with_policy_response(expected.clone()); + + let actual = server.policy().await.unwrap(); + + assert_eq!(actual.response_kind, expected.response_kind); + assert_eq!(&*actual.policy.metadata.id, &*expected.policy.metadata.id); + assert_eq!(actual.policy.metadata.revision, expected.policy.metadata.revision); +} + +#[cfg(feature = "policy-compat")] +#[tokio::test] +async fn package_broker_server_default_policy_method_is_source_compatible() { + let server = DefaultPolicyServer(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); + + let error = server.policy().await.unwrap_err(); + + assert_eq!(error.code, ErrorCode::NotFound); + assert_eq!(error.response_kind, ErrorResponseKind); + assert_eq!(error.server.transport, Transport::HttpNamedPipe); +} + #[tokio::test] async fn api_router_dispatches_to_package_broker_server() { let request_path = samples_dir().join("requests/winget-vscode-install.request.json"); @@ -398,6 +491,112 @@ async fn api_router_maps_broker_errors_to_http_status() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } +#[cfg(feature = "policy-compat")] +#[tokio::test] +async fn api_router_returns_active_policy_as_json() { + let content = load_text_file(&response_sample_path("policy.response.json")); + let expected: PolicyResponse = serde_json::from_str(&content).unwrap(); + let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME).with_policy_response(expected.clone())); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/policy") + .header("accept", "application/json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let actual: PolicyResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(actual.response_kind, PolicyResponseKind); + assert_eq!(&*actual.policy.metadata.id, &*expected.policy.metadata.id); +} + +#[cfg(feature = "policy-compat")] +#[tokio::test] +async fn api_router_returns_structured_not_found_when_policy_inspection_is_unsupported() { + let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/policy") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let error: ErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(error.response_kind, ErrorResponseKind); + assert_eq!(error.code, ErrorCode::NotFound); +} + +#[cfg(feature = "policy-compat")] +#[tokio::test] +async fn api_router_preserves_supported_policy_failure() { + let error = ErrorResponse { + response_kind: ErrorResponseKind, + response_version: API_VERSION_STR.into(), + server: ServerContext { + server_version: "0.1.0".to_owned(), + transport: Transport::HttpNamedPipe, + }, + code: ErrorCode::BrokerPaused, + message: "active policy is temporarily unavailable".to_owned(), + details: Vec::new(), + }; + let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME).with_policy_error(error)); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/policy") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let error: ErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(error.code, ErrorCode::BrokerPaused); +} + +#[cfg(feature = "policy-compat")] +#[tokio::test] +async fn api_router_does_not_expose_a_policy_write_route() { + let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/policy") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); +} + #[tokio::test] async fn api_router_rejects_request_bodies_larger_than_capability_limit() { let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); diff --git a/xtask/src/rust.rs b/xtask/src/rust.rs index 4448d52..ed8512b 100644 --- a/xtask/src/rust.rs +++ b/xtask/src/rust.rs @@ -22,6 +22,11 @@ pub fn lints(sh: &Shell) -> anyhow::Result<()> { "{CARGO} clippy --workspace --all-targets --locked --keep-going -- -D warnings" ) .run()?; + cmd!( + sh, + "{CARGO} clippy -p now-policy-api -p now-policy-server-template --all-targets --all-features --locked -- -D warnings" + ) + .run()?; println!("All good!"); @@ -42,6 +47,11 @@ pub fn tests_run(sh: &Shell) -> anyhow::Result<()> { let _s = Section::new("RUST-TESTS-RUN"); cmd!(sh, "{CARGO} test --workspace --locked").run()?; + cmd!( + sh, + "{CARGO} test -p now-policy-api -p now-policy-server-template --all-features --locked" + ) + .run()?; println!("All good!"); From cf963d818b0b770830da6296fdbda991aa62f09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 18 Aug 2026 02:50:49 +0900 Subject: [PATCH 02/11] fix(now-policy-api): address policy contract review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/ResponseModels.cs | 1 + .../MetaModelTests.cs | 11 +++++++++++ policies/rust/now-policy-server-template/README.md | 4 ++++ .../tests/sample_documents.rs | 11 ----------- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs index dff535d..e798b1c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs @@ -25,6 +25,7 @@ public string ResponseKind public ServerContext Server { get; set; } = new(); [JsonPropertyName("Policy")] + [JsonRequired] public PolicyDocument Policy { get; set; } = new(); } diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs index 7809a7d..32e94f8 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs @@ -46,6 +46,17 @@ public void PolicyResponseKind_rejects_wrong_value_on_deserialization() Assert.Throws(() => BrokerJson.DeserializeStrict(json)); } + [Fact] + public void PolicyResponse_requires_policy_on_deserialization() + { + const string json = + """ + {"ResponseKind":"PolicyResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"}} + """; + + Assert.Throws(() => BrokerJson.Deserialize(json)); + } + [Fact] public async Task ErrorResponse_serializes_to_schema_valid_output() { diff --git a/policies/rust/now-policy-server-template/README.md b/policies/rust/now-policy-server-template/README.md index fac6e50..7eec719 100644 --- a/policies/rust/now-policy-server-template/README.md +++ b/policies/rust/now-policy-server-template/README.md @@ -39,6 +39,8 @@ Runtime implementations implement: pub trait PackageBrokerServer: Send + Sync { async fn health(&self) -> HealthResponse; async fn capabilities(&self) -> CapabilitiesResponse; + // The trait provides a structured NotFound default for this feature-gated method. + #[cfg(feature = "policy-compat")] async fn policy(&self) -> Result; async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; @@ -46,6 +48,8 @@ pub trait PackageBrokerServer: Send + Sync { } ``` +Implementations built with `policy-compat` override `policy` to return the active policy. Implementations that do not override it inherit the structured 404 response; builds without the feature do not expose the method or route. + Then they pass the implementation to `api_router` or `api_router_from_shared`. The template owns the HTTP paths: - `GET /v1/health` diff --git a/policies/rust/now-policy-server-template/tests/sample_documents.rs b/policies/rust/now-policy-server-template/tests/sample_documents.rs index 64305ce..5c27101 100644 --- a/policies/rust/now-policy-server-template/tests/sample_documents.rs +++ b/policies/rust/now-policy-server-template/tests/sample_documents.rs @@ -208,17 +208,6 @@ fn policy_response_sample_matches_api_contract() { assert_eq!(policy.policy.rules.len(), 5); } -#[cfg(feature = "policy-compat")] -#[test] -fn policy_response_embeds_the_canonical_policy_fixture() { - let response = load_json_file(&response_sample_path("policy.response.json")); - let policy_path = - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../now-policy/assets/samples/corporate-allowlist.policy.json"); - let policy = load_json_file(&policy_path); - - assert_eq!(response.get("Policy"), Some(&policy)); -} - #[test] fn invalid_request_missing_package_id_fails_deserialization() { let path = samples_dir().join("requests/missing-package-id.request.json"); From 125c2b43b8fb1e71e6540ed85e0cc68591d2af39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 18 Aug 2026 05:01:01 +0900 Subject: [PATCH 03/11] fix(now-policy-api): harden policy response contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerJson.cs | 18 +++--- .../Devolutions.Now.Policy.Api/README.md | 2 +- .../ResponseModels.cs | 4 ++ .../BrokerClientTests.cs | 40 ++++++++++++ .../MetaModelTests.cs | 63 +++++++++++++++++++ .../PolicyTests.cs | 43 +++++++++++++ .../PolicyModels.cs | 16 +++++ .../openapi/now-policy-api.yaml | 16 ++--- .../now-policy-server-template/src/server.rs | 18 +++++- 9 files changed, 199 insertions(+), 21 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index c9eaea2..c940dee 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -19,14 +19,9 @@ public static class BrokerJson /// (via explicit [JsonPropertyName] attributes), PascalCase enum values, and /// null optionals omitted (mirroring the Rust skip_serializing_if = "Option::is_none"). /// - public static readonly JsonSerializerOptions Options = new(BrokerJsonSerializerContext.Default.Options) - { - }; + public static readonly JsonSerializerOptions Options = CreateOptions(writeIndented: false); - public static readonly JsonSerializerOptions PrettyOptions = new(Options) - { - WriteIndented = true, - }; + public static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true); public static string Serialize(T value) => JsonSerializer.Serialize(value, TypeInfo()); @@ -67,6 +62,15 @@ private static JsonTypeInfo StrictTypeInfo() => private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) => (JsonTypeInfo)jsonTypeInfo; + + private static JsonSerializerOptions CreateOptions(bool writeIndented) => + new(BrokerJsonSerializerContext.Default.Options) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + BrokerJsonSerializerContext.Default, + BrokerPolicyJsonSerializerContext.Default), + WriteIndented = writeIndented, + }; } [JsonSourceGenerationOptions( diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 2fe879d..17fdd44 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -29,7 +29,7 @@ Architecture - `StatusModels.cs` defines status query request/response DTOs. - `MetaModels.cs` defines health, capabilities, manager capability, and error DTOs. - `Enums.cs` defines package broker API enums and JSON string enum converters. -- `BrokerJson.cs` defines serializer options for the broker wire format. +- `BrokerJson.cs` defines source-generated serializer options for the broker wire format. Public `BrokerJson.Options` and `BrokerJson.PrettyOptions` support every broker DTO, including the embedded policy model, without reflection. - `PolicyCompatibility.cs` maps compatible API enums to and from `Devolutions.Now.Policy.Model` enums. OpenAPI relationship diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs index e798b1c..3d59925 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs @@ -19,9 +19,11 @@ public string ResponseKind } [JsonPropertyName("ResponseVersion")] + [JsonRequired] public string ResponseVersion { get; set; } = BrokerApi.Version; [JsonPropertyName("Server")] + [JsonRequired] public ServerContext Server { get; set; } = new(); [JsonPropertyName("Policy")] @@ -122,9 +124,11 @@ public string ResponseKind public sealed class ServerContext { [JsonPropertyName("ServerVersion")] + [JsonRequired] public string ServerVersion { get; set; } = ""; [JsonPropertyName("Transport")] + [JsonRequired] public Transport Transport { get; set; } } diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index c821094..e24a965 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using Devolutions.Now.Policy.Client; @@ -350,6 +351,31 @@ public async Task GetPolicy_preserves_structured_unsupported_error() Assert.Equal(ErrorCode.NotFound, exception.BrokerError?.Code); } + [Theory] + [InlineData("ResponseVersion")] + [InlineData("Server")] + [InlineData("Server.ServerVersion")] + [InlineData("Server.Transport")] + [InlineData("Policy.$schema")] + [InlineData("Policy.Metadata.Id")] + [InlineData("Policy.Enforcement.DefaultDecision")] + [InlineData("Policy.Rules")] + [InlineData("Policy.Rules.0.Match")] + public async Task GetPolicy_rejects_missing_required_property(string propertyPath) + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path)) + ?? throw new InvalidOperationException("policy response sample should parse"); + RemoveProperty(document, propertyPath); + var client = CreateClient(new FakeBrokerTransport(document.ToJsonString())); + + var exception = await Assert.ThrowsAsync(() => client.GetPolicy()); + + Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind); + Assert.Equal("/v1/policy", exception.Endpoint); + Assert.Equal(200, exception.StatusCode); + } + [Theory] [InlineData("")] [InlineData("not found")] @@ -388,6 +414,20 @@ public void Constructor_can_resolve_effective_user_automatically() ClientVersion = "9.8.7", }); + private static void RemoveProperty(JsonNode document, string propertyPath) + { + var segments = propertyPath.Split('.'); + var parent = document; + foreach (var segment in segments[..^1]) + { + parent = int.TryParse(segment, out var index) + ? parent.AsArray()[index]! + : parent[segment]!; + } + + Assert.True(parent.AsObject().Remove(segments[^1]), $"missing fixture property {propertyPath}"); + } + private sealed class FakeBrokerTransport : IBrokerTransport { private readonly Queue _responses; diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs index 32e94f8..109cc5f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using Devolutions.Now.Policy.Model; + using Xunit; namespace Devolutions.Now.Policy.Client.Tests; @@ -57,6 +59,67 @@ public void PolicyResponse_requires_policy_on_deserialization() Assert.Throws(() => BrokerJson.Deserialize(json)); } + [Fact] + public void Public_json_options_source_generate_all_broker_dtos() + { + Type[] dtoTypes = + [ + typeof(PackageRequest), + typeof(RequestSource), + typeof(RequestPackage), + typeof(RequestOptions), + typeof(ClientContext), + typeof(PolicyResponse), + typeof(EvaluationResponse), + typeof(ExecutionResponse), + typeof(ServerContext), + typeof(RequestSummary), + typeof(DecisionInfo), + typeof(ResponsePolicyInfo), + typeof(OperationDiagnostics), + typeof(OperationSubmission), + typeof(StatusRequest), + typeof(StatusResponse), + typeof(CancelRequest), + typeof(CancelResponse), + typeof(HealthResponse), + typeof(CapabilitiesResponse), + typeof(ManagerCapability), + typeof(ErrorResponse), + typeof(ErrorDetail), + typeof(EventChannel), + typeof(PolicyDocument), + typeof(PolicyMetadata), + typeof(PolicyEnforcement), + typeof(PolicyRule), + typeof(PolicyMatch), + typeof(VersionRange), + typeof(PolicyConstraints), + ]; + + foreach (var dtoType in dtoTypes) + { + Assert.NotNull(BrokerJson.Options.GetTypeInfo(dtoType)); + Assert.NotNull(BrokerJson.PrettyOptions.GetTypeInfo(dtoType)); + } + } + + [Fact] + public void Public_json_options_round_trip_policy_response_without_reflection() + { + var json = File.ReadAllText(Path.Combine(TestData.SamplesDir, "responses", "policy.response.json")); + var response = JsonSerializer.Deserialize(json, BrokerJson.Options); + + Assert.NotNull(response); + + var compact = JsonSerializer.Serialize(response, BrokerJson.Options); + var pretty = JsonSerializer.Serialize(response, BrokerJson.PrettyOptions); + + Assert.NotNull(JsonSerializer.Deserialize(compact, BrokerJson.Options)); + Assert.NotNull(JsonSerializer.Deserialize(pretty, BrokerJson.PrettyOptions)); + Assert.Contains(Environment.NewLine, pretty); + } + [Fact] public async Task ErrorResponse_serializes_to_schema_valid_output() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 1a7c3ac..fbeafec 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.Json.Nodes; using NJsonSchema; @@ -126,6 +127,34 @@ public void Negative_priority_is_rejected_by_parser() Assert.Throws(() => PolicyDocument.ParseJson(json)); } + [Theory] + [InlineData("$schema")] + [InlineData("PolicyVersion")] + [InlineData("PolicyType")] + [InlineData("Metadata")] + [InlineData("Enforcement")] + [InlineData("Rules")] + [InlineData("Metadata.Id")] + [InlineData("Metadata.Publisher")] + [InlineData("Metadata.Revision")] + [InlineData("Metadata.PublishedAt")] + [InlineData("Enforcement.DefaultDecision")] + [InlineData("Enforcement.RulePrecedence")] + [InlineData("Rules.0.Id")] + [InlineData("Rules.0.Priority")] + [InlineData("Rules.0.Decision")] + [InlineData("Rules.0.Match")] + public void Missing_rust_required_property_is_rejected_by_parser(string propertyPath) + { + var path = Path.Combine(SamplesDir, "corporate-allowlist.policy.json"); + var document = JsonNode.Parse(File.ReadAllText(path)) + ?? throw new InvalidOperationException("policy sample should parse"); + + RemoveProperty(document, propertyPath); + + Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); + } + private static PolicyDocument ParsePolicy(string path) { var content = File.ReadAllText(path); @@ -163,4 +192,18 @@ private static string MinimalPolicyJson(string revision, string rules) } """; } + + private static void RemoveProperty(JsonNode document, string propertyPath) + { + var segments = propertyPath.Split('.'); + var parent = document; + foreach (var segment in segments[..^1]) + { + parent = int.TryParse(segment, out var index) + ? parent.AsArray()[index]! + : parent[segment]!; + } + + Assert.True(parent.AsObject().Remove(segments[^1]), $"missing fixture property {propertyPath}"); + } } \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index 76dbad6..d433554 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -17,21 +17,27 @@ public static class SchemaUris public sealed class PolicyDocument { [JsonPropertyName("$schema")] + [JsonRequired] public string Schema { get; set; } = SchemaUris.Policy; [JsonPropertyName("PolicyVersion")] + [JsonRequired] public string PolicyVersion { get; set; } = "1.0.0"; [JsonPropertyName("PolicyType")] + [JsonRequired] public string PolicyType { get; set; } = "PackageBrokerPolicy"; [JsonPropertyName("Metadata")] + [JsonRequired] public PolicyMetadata Metadata { get; set; } = new(); [JsonPropertyName("Enforcement")] + [JsonRequired] public PolicyEnforcement Enforcement { get; set; } = new(); [JsonPropertyName("Rules")] + [JsonRequired] public List Rules { get; set; } = []; public static PolicyDocument Create(string id, string publisher, Decision defaultDecision = Decision.Deny) @@ -138,15 +144,19 @@ _ when double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, public sealed class PolicyMetadata { [JsonPropertyName("Id")] + [JsonRequired] public string Id { get; set; } = ""; [JsonPropertyName("Publisher")] + [JsonRequired] public string Publisher { get; set; } = ""; [JsonPropertyName("Revision")] + [JsonRequired] public uint Revision { get; set; } [JsonPropertyName("PublishedAt")] + [JsonRequired] public DateTimeOffset PublishedAt { get; set; } [JsonPropertyName("ValidFrom")] @@ -165,9 +175,11 @@ public sealed class PolicyMetadata public sealed class PolicyEnforcement { [JsonPropertyName("DefaultDecision")] + [JsonRequired] public Decision DefaultDecision { get; set; } [JsonPropertyName("RulePrecedence")] + [JsonRequired] public RulePrecedence RulePrecedence { get; set; } [JsonPropertyName("AuditMode")] @@ -177,21 +189,25 @@ public sealed class PolicyEnforcement public sealed class PolicyRule { [JsonPropertyName("Id")] + [JsonRequired] public string Id { get; set; } = ""; [JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true; [JsonPropertyName("Priority")] + [JsonRequired] public uint Priority { get; set; } [JsonPropertyName("Decision")] + [JsonRequired] public Decision Decision { get; set; } [JsonPropertyName("Reason")] public string? Reason { get; set; } [JsonPropertyName("Match")] + [JsonRequired] public PolicyMatch Match { get; set; } = new(); [JsonPropertyName("Constraints")] 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 ee64014..1138613 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -31,25 +31,19 @@ paths: summary: Get active policy description: Returns the active parsed policy document. A 404 response means policy inspection is unsupported. responses: - '200': - description: Response body for `GET /v1/policy`. - content: - application/json: - schema: - $ref: '#/components/schemas/PolicyResponse' - '404': + default: description: Generic error body returned for non-2xx responses. content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - '500': - description: Generic error body returned for non-2xx responses. + '200': + description: Response body for `GET /v1/policy`. content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' - '503': + $ref: '#/components/schemas/PolicyResponse' + '404': description: Generic error body returned for non-2xx responses. content: application/json: diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index 8e6d35b..af14e3c 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -284,8 +284,7 @@ fn policy_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { ) .response::<200, Json>() .response::<404, Json>() - .response::<500, Json>() - .response::<503, Json>() + .default_response::>() } fn evaluate_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { @@ -361,4 +360,19 @@ mod tests { ); } } + + #[test] + fn policy_openapi_documents_structured_errors_for_other_statuses() { + let api = serde_json::to_value(openapi()).expect("OpenAPI should serialize"); + let responses = &api["paths"]["/v1/policy"]["get"]["responses"]; + + assert_eq!( + responses["default"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/ErrorResponse" + ); + assert_eq!( + responses["404"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/ErrorResponse" + ); + } } From 150f65d5e6c13446b7ccdc3d8d0f5317716f5e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 18 Aug 2026 05:22:20 +0900 Subject: [PATCH 04/11] fix(now-policy): reject null contract fields Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerJson.cs | 4 ++ .../Devolutions.Now.Policy.Api/README.md | 2 +- .../BrokerClientTests.cs | 42 ++++++++++++++++ .../MetaModelTests.cs | 41 ++++++++++++++++ .../PolicyTests.cs | 49 +++++++++++++++++++ .../PolicyJson.cs | 4 +- .../Devolutions.Now.Policy.Model/README.md | 2 +- 7 files changed, 141 insertions(+), 3 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index c940dee..e54300a 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -75,6 +75,7 @@ private static JsonSerializerOptions CreateOptions(bool writeIndented) => [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, WriteIndented = false)] [JsonSerializable(typeof(PackageRequest))] [JsonSerializable(typeof(StatusRequest))] @@ -93,6 +94,7 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, WriteIndented = false, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(PackageRequest))] @@ -112,12 +114,14 @@ internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializer [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, WriteIndented = false)] [JsonSerializable(typeof(PolicyResponse))] internal sealed partial class BrokerPolicyJsonSerializerContext : JsonSerializerContext; [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, WriteIndented = false, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(PolicyResponse))] diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 17fdd44..4180214 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -29,7 +29,7 @@ Architecture - `StatusModels.cs` defines status query request/response DTOs. - `MetaModels.cs` defines health, capabilities, manager capability, and error DTOs. - `Enums.cs` defines package broker API enums and JSON string enum converters. -- `BrokerJson.cs` defines source-generated serializer options for the broker wire format. Public `BrokerJson.Options` and `BrokerJson.PrettyOptions` support every broker DTO, including the embedded policy model, without reflection. +- `BrokerJson.cs` defines source-generated serializer options for the broker wire format. Public `BrokerJson.Options` and `BrokerJson.PrettyOptions` support every broker DTO, including the embedded policy model, without reflection and reject JSON null for non-nullable contract members. - `PolicyCompatibility.cs` maps compatible API enums to and from `Devolutions.Now.Policy.Model` enums. OpenAPI relationship diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index e24a965..85f419f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -376,6 +376,32 @@ public async Task GetPolicy_rejects_missing_required_property(string propertyPat Assert.Equal(200, exception.StatusCode); } + [Theory] + [InlineData("ResponseVersion")] + [InlineData("Server")] + [InlineData("Server.ServerVersion")] + [InlineData("Policy")] + [InlineData("Policy.Metadata")] + [InlineData("Policy.Metadata.Id")] + [InlineData("Policy.Enforcement.DefaultDecision")] + [InlineData("Policy.Rules")] + [InlineData("Policy.Rules.0.Match")] + [InlineData("Policy.Rules.0.Match.Operations")] + public async Task GetPolicy_rejects_null_non_nullable_property(string propertyPath) + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path)) + ?? throw new InvalidOperationException("policy response sample should parse"); + SetPropertyToNull(document, propertyPath); + var client = CreateClient(new FakeBrokerTransport(document.ToJsonString())); + + var exception = await Assert.ThrowsAsync(() => client.GetPolicy()); + + Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind); + Assert.Equal("/v1/policy", exception.Endpoint); + Assert.Equal(200, exception.StatusCode); + } + [Theory] [InlineData("")] [InlineData("not found")] @@ -428,6 +454,22 @@ private static void RemoveProperty(JsonNode document, string propertyPath) Assert.True(parent.AsObject().Remove(segments[^1]), $"missing fixture property {propertyPath}"); } + private static void SetPropertyToNull(JsonNode document, string propertyPath) + { + var segments = propertyPath.Split('.'); + var parent = document; + foreach (var segment in segments[..^1]) + { + parent = int.TryParse(segment, out var index) + ? parent.AsArray()[index]! + : parent[segment]!; + } + + var property = parent.AsObject(); + Assert.NotNull(property[segments[^1]]); + property[segments[^1]] = null; + } + private sealed class FakeBrokerTransport : IBrokerTransport { private readonly Queue _responses; diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs index 109cc5f..e8ac73d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using Devolutions.Now.Policy.Model; @@ -59,6 +60,30 @@ public void PolicyResponse_requires_policy_on_deserialization() Assert.Throws(() => BrokerJson.Deserialize(json)); } + [Theory] + [InlineData("ResponseVersion")] + [InlineData("Server")] + [InlineData("Server.ServerVersion")] + [InlineData("Policy")] + [InlineData("Policy.Metadata")] + [InlineData("Policy.Metadata.Id")] + [InlineData("Policy.Enforcement.DefaultDecision")] + [InlineData("Policy.Rules")] + [InlineData("Policy.Rules.0.Match")] + [InlineData("Policy.Rules.0.Match.Operations")] + public void PolicyResponse_rejects_null_non_nullable_property(string propertyPath) + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var document = JsonNode.Parse(File.ReadAllText(path)) + ?? throw new InvalidOperationException("policy response sample should parse"); + SetPropertyToNull(document, propertyPath); + var json = document.ToJsonString(); + + Assert.Throws(() => BrokerJson.Deserialize(json)); + Assert.Throws(() => BrokerJson.DeserializeStrict(json)); + Assert.Throws(() => JsonSerializer.Deserialize(json, BrokerJson.Options)); + } + [Fact] public void Public_json_options_source_generate_all_broker_dtos() { @@ -156,6 +181,22 @@ public async Task ErrorResponse_serializes_to_schema_valid_output() Transport = Transport.HttpNamedPipe, }; + private static void SetPropertyToNull(JsonNode document, string propertyPath) + { + var segments = propertyPath.Split('.'); + var parent = document; + foreach (var segment in segments[..^1]) + { + parent = int.TryParse(segment, out var index) + ? parent.AsArray()[index]! + : parent[segment]!; + } + + var property = parent.AsObject(); + Assert.NotNull(property[segments[^1]]); + property[segments[^1]] = null; + } + private static async Task AssertSerializesValid(T dto, string componentName) { var schema = await TestData.SchemaAsync(componentName); diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index fbeafec..f9f4e64 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -155,6 +155,34 @@ public void Missing_rust_required_property_is_rejected_by_parser(string property Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); } + [Theory] + [InlineData("$schema")] + [InlineData("PolicyVersion")] + [InlineData("PolicyType")] + [InlineData("Metadata")] + [InlineData("Enforcement")] + [InlineData("Rules")] + [InlineData("Metadata.Id")] + [InlineData("Metadata.Publisher")] + [InlineData("Metadata.Revision")] + [InlineData("Metadata.PublishedAt")] + [InlineData("Enforcement.DefaultDecision")] + [InlineData("Enforcement.RulePrecedence")] + [InlineData("Rules.0.Id")] + [InlineData("Rules.0.Priority")] + [InlineData("Rules.0.Decision")] + [InlineData("Rules.0.Match")] + public void Null_rust_required_property_is_rejected_by_parser(string propertyPath) + { + var path = Path.Combine(SamplesDir, "corporate-allowlist.policy.json"); + var document = JsonNode.Parse(File.ReadAllText(path)) + ?? throw new InvalidOperationException("policy sample should parse"); + + SetPropertyToNull(document, propertyPath); + + Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); + } + private static PolicyDocument ParsePolicy(string path) { var content = File.ReadAllText(path); @@ -206,4 +234,25 @@ private static void RemoveProperty(JsonNode document, string propertyPath) Assert.True(parent.AsObject().Remove(segments[^1]), $"missing fixture property {propertyPath}"); } + + private static void SetPropertyToNull(JsonNode document, string propertyPath) + { + var (parent, propertyName) = ResolveProperty(document, propertyPath); + Assert.NotNull(parent[propertyName]); + parent[propertyName] = null; + } + + private static (JsonObject Parent, string PropertyName) ResolveProperty(JsonNode document, string propertyPath) + { + var segments = propertyPath.Split('.'); + var parent = document; + foreach (var segment in segments[..^1]) + { + parent = int.TryParse(segment, out var index) + ? parent.AsArray()[index]! + : parent[segment]!; + } + + return (parent.AsObject(), segments[^1]); + } } \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs index 21d8100..658a447 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs @@ -56,7 +56,8 @@ private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) => [JsonSourceGenerationOptions( WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true)] [JsonSerializable(typeof(PolicyDocument))] [JsonSerializable(typeof(PolicyMetadata))] [JsonSerializable(typeof(PolicyEnforcement))] @@ -69,6 +70,7 @@ internal sealed partial class PolicyJsonSerializerContext : JsonSerializerContex [JsonSourceGenerationOptions( WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(PolicyDocument))] [JsonSerializable(typeof(PolicyMetadata))] diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index 9613138..b106af6 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -21,7 +21,7 @@ Architecture - `PolicyModels.cs` defines `PolicyDocument`, metadata, enforcement, rules, match criteria, constraints, and version range types. - `Enums.cs` defines policy-level enums such as operation, manager, scope, architecture, elevation, decision, and rule precedence. -- `PolicyJson.cs` defines shared `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members. +- `PolicyJson.cs` defines shared `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members and JSON null for non-nullable policy members. `PolicyDocument.Create` provides a simple helper for constructing a new policy document with metadata and default enforcement. `PolicyDocument.ParseJson` and `PolicyDocument.ParseYaml` are the main entry points for reading policy documents. From 946e5a6597f9ab8c6cd3617053cef71d30703bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 18 Aug 2026 12:53:29 +0900 Subject: [PATCH 05/11] fix(now-policy-client): validate policy responses strictly Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/Enums.cs | 11 ++++- .../BrokerClientTests.cs | 49 +++++++++++++++++++ .../BrokerClient.cs | 16 ++++-- .../Devolutions.Now.Policy.Client/README.md | 2 +- .../AssemblyInfo.cs | 3 ++ .../Devolutions.Now.Policy.Model/Enums.cs | 23 ++++++--- 6 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 policies/dotnet/Devolutions.Now.Policy.Model/AssemblyInfo.cs diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs index 4959a6c..0c3681a 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs @@ -5,6 +5,15 @@ namespace Devolutions.Now.Policy.Api; // Enum members are spelled exactly as they appear on the wire (PascalCase), so the // default JsonStringEnumConverter round-trips them without a naming policy. +internal sealed class StringOnlyEnumConverter : JsonStringEnumConverter + where TEnum : struct, Enum +{ + public StringOnlyEnumConverter() + : base(namingPolicy: null, allowIntegerValues: false) + { + } +} + /// Package operation type. [JsonConverter(typeof(JsonStringEnumConverter))] public enum Operation @@ -72,7 +81,7 @@ public enum Decision } /// Broker transport type. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StringOnlyEnumConverter))] public enum Transport { HttpNamedPipe, diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 85f419f..a880062 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -319,6 +319,31 @@ public async Task GetPolicy_sends_json_get_and_deserializes_response() Assert.Equal(4u, response.Policy.Metadata.Revision); } + [Theory] + [InlineData("")] + [InlineData("Policy.Metadata")] + public async Task GetPolicy_rejects_unmapped_property(string objectPath) + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path)) + ?? throw new InvalidOperationException("policy response sample should parse"); + var target = string.IsNullOrEmpty(objectPath) ? document : ResolveNode(document, objectPath); + target.AsObject()["Unexpected"] = true; + + await AssertInvalidPolicyResponse(document); + } + + [Fact] + public async Task GetPolicy_rejects_integer_policy_enum_token() + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path)) + ?? throw new InvalidOperationException("policy response sample should parse"); + ResolveNode(document, "Policy.Rules.0.Match.Operations").AsArray()[0] = 0; + + await AssertInvalidPolicyResponse(document); + } + [Fact] public async Task GetPolicy_propagates_cancellation() { @@ -440,6 +465,17 @@ public void Constructor_can_resolve_effective_user_automatically() ClientVersion = "9.8.7", }); + private static async Task AssertInvalidPolicyResponse(JsonNode document) + { + var client = CreateClient(new FakeBrokerTransport(document.ToJsonString())); + + var exception = await Assert.ThrowsAsync(() => client.GetPolicy()); + + Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind); + Assert.Equal("/v1/policy", exception.Endpoint); + Assert.Equal(200, exception.StatusCode); + } + private static void RemoveProperty(JsonNode document, string propertyPath) { var segments = propertyPath.Split('.'); @@ -470,6 +506,19 @@ private static void SetPropertyToNull(JsonNode document, string propertyPath) property[segments[^1]] = null; } + private static JsonNode ResolveNode(JsonNode document, string propertyPath) + { + var node = document; + foreach (var segment in propertyPath.Split('.')) + { + node = int.TryParse(segment, out var index) + ? node.AsArray()[index]! + : node[segment]!; + } + + return node; + } + private sealed class FakeBrokerTransport : IBrokerTransport { private readonly Queue _responses; diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs index cfecac1..a30dec5 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -77,7 +77,11 @@ public async Task GetPolicy(CancellationToken cancellationToken { var headers = new Dictionary { ["Accept"] = JsonMediaType }; var response = await SendRequest("GET", "/v1/policy", null, headers, cancellationToken).ConfigureAwait(false); - return DeserializeResponse(response, "policy", "/v1/policy"); + return DeserializeResponse( + response, + "policy", + "/v1/policy", + strictSuccessBody: true); } /// Evaluate a package operation against policy without executing it (dry-run). @@ -422,7 +426,11 @@ private async Task GetCachedCapabilities(CancellationToken return _capabilities; } - private TResponse DeserializeResponse(BrokerTransportResponse response, string context, string endpoint) + private TResponse DeserializeResponse( + BrokerTransportResponse response, + string context, + string endpoint, + bool strictSuccessBody = false) { if (string.IsNullOrWhiteSpace(response.Body)) { @@ -455,7 +463,9 @@ private TResponse DeserializeResponse(BrokerTransportResponse respons try { - var value = BrokerJson.Deserialize(response.Body); + var value = strictSuccessBody + ? BrokerJson.DeserializeStrict(response.Body) + : BrokerJson.Deserialize(response.Body); if (value is null) { throw new BrokerClientException( diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index bde02b5..420acb0 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -45,7 +45,7 @@ The main surface is `BrokerClient`: - `IsAvailable` probes the health endpoint. - `GetHealth` and `GetCapabilities` query broker metadata. -- `GetPolicy` sends `GET /v1/policy` and returns the active parsed `PolicyDocument`. +- `GetPolicy` sends `GET /v1/policy` and returns the active parsed `PolicyDocument` after strict source-generated validation of the successful response. - `Evaluate` sends `POST /v1/package-operations/evaluate`. - `Execute` sends `POST /v1/package-operations/execute`. - `ExecuteAndWait` submits an operation and polls status until a terminal state. diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/AssemblyInfo.cs b/policies/dotnet/Devolutions.Now.Policy.Model/AssemblyInfo.cs new file mode 100644 index 0000000..a57f153 --- /dev/null +++ b/policies/dotnet/Devolutions.Now.Policy.Model/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Devolutions.Now.Policy.Api")] \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs index aa93641..cd4bc29 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs @@ -2,8 +2,17 @@ namespace Devolutions.Now.Policy.Model; +internal sealed class StringOnlyEnumConverter : JsonStringEnumConverter + where TEnum : struct, Enum +{ + public StringOnlyEnumConverter() + : base(namingPolicy: null, allowIntegerValues: false) + { + } +} + /// Package operation type. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StringOnlyEnumConverter))] public enum Operation { Install, @@ -12,7 +21,7 @@ public enum Operation } /// Supported package manager names. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StringOnlyEnumConverter))] public enum ManagerName { Winget, @@ -35,7 +44,7 @@ public enum ManagerName } /// Installation scope. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StringOnlyEnumConverter))] public enum Scope { User, @@ -43,7 +52,7 @@ public enum Scope } /// Target architecture. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StringOnlyEnumConverter))] public enum Architecture { X86, @@ -53,7 +62,7 @@ public enum Architecture } /// Requested elevation level. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StringOnlyEnumConverter))] public enum Elevation { Standard, @@ -61,7 +70,7 @@ public enum Elevation } /// Policy decision. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StringOnlyEnumConverter))] public enum Decision { Allow, @@ -69,7 +78,7 @@ public enum Decision } /// Rule precedence strategy. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StringOnlyEnumConverter))] public enum RulePrecedence { PriorityThenDeny, From 26a5734c8a344143631cbd574dd154f66a5a4966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 18 Aug 2026 13:10:41 +0900 Subject: [PATCH 06/11] fix(now-policy): reject null collection elements Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerJson.cs | 14 +++- .../BrokerClientTests.cs | 26 +++++++- .../MetaModelTests.cs | 26 +++++++- .../Devolutions.Now.Policy.Client/README.md | 2 +- .../PolicyTests.cs | 32 ++++++--- .../PolicyJson.cs | 66 +++++++++++++++++-- .../Devolutions.Now.Policy.Model/README.md | 2 +- 7 files changed, 146 insertions(+), 22 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index e54300a..173b873 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -3,6 +3,8 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using Devolutions.Now.Policy.Model; + namespace Devolutions.Now.Policy.Api; /// Canonical schema URI used in the $schema field of policy documents. @@ -29,8 +31,16 @@ public static string Serialize(T value) => public static T? Deserialize(string json) => JsonSerializer.Deserialize(json, TypeInfo()); - public static T? DeserializeStrict(string json) => - JsonSerializer.Deserialize(json, StrictTypeInfo()); + public static T? DeserializeStrict(string json) + { + var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); + if (value is PolicyResponse response) + { + PolicyJson.ValidateRequiredCollectionElements(response.Policy); + } + + return value; + } private static JsonTypeInfo TypeInfo() => typeof(T) == typeof(PackageRequest) ? Cast(BrokerJsonSerializerContext.Default.PackageRequest) : diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index a880062..2d61690 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -344,6 +344,19 @@ public async Task GetPolicy_rejects_integer_policy_enum_token() await AssertInvalidPolicyResponse(document); } + [Theory] + [InlineData("Policy.Rules.0")] + [InlineData("Policy.Rules.3.Match.Sources.0")] + public async Task GetPolicy_rejects_null_collection_element(string elementPath) + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path)) + ?? throw new InvalidOperationException("policy response sample should parse"); + SetPropertyToNull(document, elementPath); + + await AssertInvalidPolicyResponse(document); + } + [Fact] public async Task GetPolicy_propagates_cancellation() { @@ -501,9 +514,16 @@ private static void SetPropertyToNull(JsonNode document, string propertyPath) : parent[segment]!; } - var property = parent.AsObject(); - Assert.NotNull(property[segments[^1]]); - property[segments[^1]] = null; + if (int.TryParse(segments[^1], out var finalIndex)) + { + Assert.NotNull(parent.AsArray()[finalIndex]); + parent.AsArray()[finalIndex] = null; + } + else + { + Assert.NotNull(parent.AsObject()[segments[^1]]); + parent.AsObject()[segments[^1]] = null; + } } private static JsonNode ResolveNode(JsonNode document, string propertyPath) diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs index e8ac73d..dfd4539 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs @@ -84,6 +84,19 @@ public void PolicyResponse_rejects_null_non_nullable_property(string propertyPat Assert.Throws(() => JsonSerializer.Deserialize(json, BrokerJson.Options)); } + [Theory] + [InlineData("Policy.Rules.0")] + [InlineData("Policy.Rules.3.Match.Sources.0")] + public void Strict_policy_response_rejects_null_collection_element(string elementPath) + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var document = JsonNode.Parse(File.ReadAllText(path)) + ?? throw new InvalidOperationException("policy response sample should parse"); + SetPropertyToNull(document, elementPath); + + Assert.Throws(() => BrokerJson.DeserializeStrict(document.ToJsonString())); + } + [Fact] public void Public_json_options_source_generate_all_broker_dtos() { @@ -192,9 +205,16 @@ private static void SetPropertyToNull(JsonNode document, string propertyPath) : parent[segment]!; } - var property = parent.AsObject(); - Assert.NotNull(property[segments[^1]]); - property[segments[^1]] = null; + if (int.TryParse(segments[^1], out var finalIndex)) + { + Assert.NotNull(parent.AsArray()[finalIndex]); + parent.AsArray()[finalIndex] = null; + } + else + { + Assert.NotNull(parent.AsObject()[segments[^1]]); + parent.AsObject()[segments[^1]] = null; + } } private static async Task AssertSerializesValid(T dto, string componentName) diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index 420acb0..72d3ac9 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -45,7 +45,7 @@ The main surface is `BrokerClient`: - `IsAvailable` probes the health endpoint. - `GetHealth` and `GetCapabilities` query broker metadata. -- `GetPolicy` sends `GET /v1/policy` and returns the active parsed `PolicyDocument` after strict source-generated validation of the successful response. +- `GetPolicy` sends `GET /v1/policy` and returns a `PolicyResponse` containing the active parsed `PolicyDocument` after strict validation of the successful response. - `Evaluate` sends `POST /v1/package-operations/evaluate`. - `Execute` sends `POST /v1/package-operations/execute`. - `ExecuteAndWait` submits an operation and polls status until a terminal state. diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index f9f4e64..5ba4221 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -183,6 +183,20 @@ public void Null_rust_required_property_is_rejected_by_parser(string propertyPat Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); } + [Theory] + [InlineData("Rules.0")] + [InlineData("Rules.3.Match.Sources.0")] + [InlineData("Rules.3.Match.PackageIdentifiers.0")] + public void Null_policy_collection_element_is_rejected_by_parser(string elementPath) + { + var path = Path.Combine(SamplesDir, "corporate-allowlist.policy.json"); + var document = JsonNode.Parse(File.ReadAllText(path)) + ?? throw new InvalidOperationException("policy sample should parse"); + SetPropertyToNull(document, elementPath); + + Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); + } + private static PolicyDocument ParsePolicy(string path) { var content = File.ReadAllText(path); @@ -236,13 +250,6 @@ private static void RemoveProperty(JsonNode document, string propertyPath) } private static void SetPropertyToNull(JsonNode document, string propertyPath) - { - var (parent, propertyName) = ResolveProperty(document, propertyPath); - Assert.NotNull(parent[propertyName]); - parent[propertyName] = null; - } - - private static (JsonObject Parent, string PropertyName) ResolveProperty(JsonNode document, string propertyPath) { var segments = propertyPath.Split('.'); var parent = document; @@ -253,6 +260,15 @@ private static (JsonObject Parent, string PropertyName) ResolveProperty(JsonNode : parent[segment]!; } - return (parent.AsObject(), segments[^1]); + if (int.TryParse(segments[^1], out var finalIndex)) + { + Assert.NotNull(parent.AsArray()[finalIndex]); + parent.AsArray()[finalIndex] = null; + } + else + { + Assert.NotNull(parent.AsObject()[segments[^1]]); + parent.AsObject()[segments[^1]] = null; + } } } \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs index 658a447..693840c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs @@ -19,16 +19,74 @@ public static string Serialize(PolicyDocument value) => JsonSerializer.Serialize(value, PolicyJsonSerializerContext.Default.PolicyDocument); public static PolicyDocument? DeserializePolicyDocument(string json) => - JsonSerializer.Deserialize(json, PolicyJsonSerializerContext.Default.PolicyDocument); + Validate(JsonSerializer.Deserialize(json, PolicyJsonSerializerContext.Default.PolicyDocument)); public static PolicyDocument? DeserializePolicyDocumentStrict(string json) => - JsonSerializer.Deserialize(json, PolicyJsonStrictSerializerContext.Default.PolicyDocument); + Validate(JsonSerializer.Deserialize(json, PolicyJsonStrictSerializerContext.Default.PolicyDocument)); public static string Serialize(T value) => JsonSerializer.Serialize(value, TypeInfo()); - public static T? DeserializeStrict(string json) => - JsonSerializer.Deserialize(json, StrictTypeInfo()); + public static T? DeserializeStrict(string json) + { + var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); + if (value is PolicyDocument policy) + { + ValidateRequiredCollectionElements(policy); + } + + return value; + } + + internal static void ValidateRequiredCollectionElements(PolicyDocument policy) + { + RejectNullElements(policy.Rules, "$.Rules"); + + for (var ruleIndex = 0; ruleIndex < policy.Rules.Count; ruleIndex++) + { + var rule = policy.Rules[ruleIndex]; + var matchPath = $"$.Rules[{ruleIndex}].Match"; + RejectNullElements(rule.Match.Sources, $"{matchPath}.Sources"); + RejectNullElements(rule.Match.PackageIdentifiers, $"{matchPath}.PackageIdentifiers"); + RejectNullElements(rule.Match.PackageNames, $"{matchPath}.PackageNames"); + RejectNullElements(rule.Match.Versions, $"{matchPath}.Versions"); + + if (rule.Constraints is { } constraints) + { + var constraintsPath = $"$.Rules[{ruleIndex}].Constraints"; + RejectNullElements( + constraints.AllowedInstallLocationPatterns, + $"{constraintsPath}.AllowedInstallLocationPatterns"); + RejectNullElements(constraints.AllowedCustomParameters, $"{constraintsPath}.AllowedCustomParameters"); + RejectNullElements( + constraints.AllowedCustomParameterPatterns, + $"{constraintsPath}.AllowedCustomParameterPatterns"); + RejectNullElements(constraints.DeniedCustomParameters, $"{constraintsPath}.DeniedCustomParameters"); + } + } + } + + private static PolicyDocument? Validate(PolicyDocument? policy) + { + if (policy is not null) + { + ValidateRequiredCollectionElements(policy); + } + + return policy; + } + + private static void RejectNullElements(IReadOnlyList values, string path) + where T : class + { + for (var index = 0; index < values.Count; index++) + { + if (values[index] is null) + { + throw new JsonException($"The JSON value at {path}[{index}] must not be null."); + } + } + } private static JsonTypeInfo TypeInfo() => typeof(T) == typeof(PolicyDocument) ? Cast(PolicyJsonSerializerContext.Default.PolicyDocument) : diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index b106af6..c21023b 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -21,7 +21,7 @@ Architecture - `PolicyModels.cs` defines `PolicyDocument`, metadata, enforcement, rules, match criteria, constraints, and version range types. - `Enums.cs` defines policy-level enums such as operation, manager, scope, architecture, elevation, decision, and rule precedence. -- `PolicyJson.cs` defines shared `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members and JSON null for non-nullable policy members. +- `PolicyJson.cs` defines shared `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members and JSON null for non-nullable policy members or collection elements. `PolicyDocument.Create` provides a simple helper for constructing a new policy document with metadata and default enforcement. `PolicyDocument.ParseJson` and `PolicyDocument.ParseYaml` are the main entry points for reading policy documents. From 61a1af21d6771a1c85d48dcdb033f8e912651d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 18 Aug 2026 14:10:25 +0900 Subject: [PATCH 07/11] fix(now-policy): enforce canonical enum casing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerJson.cs | 2 + .../Devolutions.Now.Policy.Api/Enums.cs | 31 ++++++++++++-- .../BrokerClientTests.cs | 31 +++++++++++--- .../Devolutions.Now.Policy.Model/Enums.cs | 41 ++++++++++++++----- 4 files changed, 86 insertions(+), 19 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index 173b873..7afcb4d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -123,6 +123,7 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializerContext; [JsonSourceGenerationOptions( + Converters = new[] { typeof(ExactCaseTransportConverter) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, RespectNullableAnnotations = true, WriteIndented = false)] @@ -130,6 +131,7 @@ internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializer internal sealed partial class BrokerPolicyJsonSerializerContext : JsonSerializerContext; [JsonSourceGenerationOptions( + Converters = new[] { typeof(ExactCaseTransportConverter) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, RespectNullableAnnotations = true, WriteIndented = false, diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs index 0c3681a..38fe46e 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace Devolutions.Now.Policy.Api; @@ -5,15 +6,37 @@ namespace Devolutions.Now.Policy.Api; // Enum members are spelled exactly as they appear on the wire (PascalCase), so the // default JsonStringEnumConverter round-trips them without a naming policy. -internal sealed class StringOnlyEnumConverter : JsonStringEnumConverter +internal class ExactCaseStringEnumConverter : JsonConverter where TEnum : struct, Enum { - public StringOnlyEnumConverter() - : base(namingPolicy: null, allowIntegerValues: false) + public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException($"Expected a string value for {typeof(TEnum).Name}."); + } + + var name = reader.GetString(); + if (name is null || + !Enum.TryParse(name, ignoreCase: false, out var value) || + Enum.GetName(value) != name) + { + throw new JsonException($"'{name}' is not a canonical {typeof(TEnum).Name} value."); + } + + return value; + } + + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + { + var name = Enum.GetName(value) + ?? throw new JsonException($"'{value}' is not a defined {typeof(TEnum).Name} value."); + writer.WriteStringValue(name); } } +internal sealed class ExactCaseTransportConverter : ExactCaseStringEnumConverter; + /// Package operation type. [JsonConverter(typeof(JsonStringEnumConverter))] public enum Operation @@ -81,7 +104,7 @@ public enum Decision } /// Broker transport type. -[JsonConverter(typeof(StringOnlyEnumConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum Transport { HttpNamedPipe, diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 2d61690..00b57e6 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -344,6 +344,22 @@ public async Task GetPolicy_rejects_integer_policy_enum_token() await AssertInvalidPolicyResponse(document); } + [Theory] + [InlineData("Server.Transport", "httpnamedpipe")] + [InlineData("Policy.Enforcement.DefaultDecision", "deny")] + [InlineData("Policy.Enforcement.RulePrecedence", "prioritythendeny")] + [InlineData("Policy.Rules.0.Decision", "deny")] + [InlineData("Policy.Rules.0.Match.Operations.0", "install")] + public async Task GetPolicy_rejects_noncanonical_enum_casing(string propertyPath, string value) + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path)) + ?? throw new InvalidOperationException("policy response sample should parse"); + SetPropertyValue(document, propertyPath, value); + + await AssertInvalidPolicyResponse(document); + } + [Theory] [InlineData("Policy.Rules.0")] [InlineData("Policy.Rules.3.Match.Sources.0")] @@ -369,15 +385,17 @@ public async Task GetPolicy_propagates_cancellation() Assert.Empty(transport.Requests); } - [Fact] - public async Task GetPolicy_preserves_structured_unsupported_error() + [Theory] + [InlineData("HttpNamedPipe")] + [InlineData("httpnamedpipe")] + public async Task GetPolicy_preserves_structured_unsupported_error(string transportValue) { var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 404, Body = """ {"ResponseKind":"ErrorResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"Code":"NotFound","Message":"active policy inspection is not supported"} - """, + """.Replace("HttpNamedPipe", transportValue, StringComparison.Ordinal), }); var client = CreateClient(transport); @@ -504,6 +522,9 @@ private static void RemoveProperty(JsonNode document, string propertyPath) } private static void SetPropertyToNull(JsonNode document, string propertyPath) + => SetPropertyValue(document, propertyPath, null); + + private static void SetPropertyValue(JsonNode document, string propertyPath, JsonNode? value) { var segments = propertyPath.Split('.'); var parent = document; @@ -517,12 +538,12 @@ private static void SetPropertyToNull(JsonNode document, string propertyPath) if (int.TryParse(segments[^1], out var finalIndex)) { Assert.NotNull(parent.AsArray()[finalIndex]); - parent.AsArray()[finalIndex] = null; + parent.AsArray()[finalIndex] = value; } else { Assert.NotNull(parent.AsObject()[segments[^1]]); - parent.AsObject()[segments[^1]] = null; + parent.AsObject()[segments[^1]] = value; } } diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs index cd4bc29..8b4655f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs @@ -1,18 +1,39 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace Devolutions.Now.Policy.Model; -internal sealed class StringOnlyEnumConverter : JsonStringEnumConverter +internal sealed class ExactCaseStringEnumConverter : JsonConverter where TEnum : struct, Enum { - public StringOnlyEnumConverter() - : base(namingPolicy: null, allowIntegerValues: false) + public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException($"Expected a string value for {typeof(TEnum).Name}."); + } + + var name = reader.GetString(); + if (name is null || + !Enum.TryParse(name, ignoreCase: false, out var value) || + Enum.GetName(value) != name) + { + throw new JsonException($"'{name}' is not a canonical {typeof(TEnum).Name} value."); + } + + return value; + } + + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + { + var name = Enum.GetName(value) + ?? throw new JsonException($"'{value}' is not a defined {typeof(TEnum).Name} value."); + writer.WriteStringValue(name); } } /// Package operation type. -[JsonConverter(typeof(StringOnlyEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Operation { Install, @@ -21,7 +42,7 @@ public enum Operation } /// Supported package manager names. -[JsonConverter(typeof(StringOnlyEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum ManagerName { Winget, @@ -44,7 +65,7 @@ public enum ManagerName } /// Installation scope. -[JsonConverter(typeof(StringOnlyEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Scope { User, @@ -52,7 +73,7 @@ public enum Scope } /// Target architecture. -[JsonConverter(typeof(StringOnlyEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Architecture { X86, @@ -62,7 +83,7 @@ public enum Architecture } /// Requested elevation level. -[JsonConverter(typeof(StringOnlyEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Elevation { Standard, @@ -70,7 +91,7 @@ public enum Elevation } /// Policy decision. -[JsonConverter(typeof(StringOnlyEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Decision { Allow, @@ -78,7 +99,7 @@ public enum Decision } /// Rule precedence strategy. -[JsonConverter(typeof(StringOnlyEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum RulePrecedence { PriorityThenDeny, From 0ccaa1ec43901d49008d0387ab84b419de6be953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 26 Aug 2026 16:45:32 +0900 Subject: [PATCH 08/11] test: run workspace with all features Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- xtask/src/rust.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/xtask/src/rust.rs b/xtask/src/rust.rs index ed8512b..248b280 100644 --- a/xtask/src/rust.rs +++ b/xtask/src/rust.rs @@ -46,12 +46,7 @@ pub fn tests_compile(sh: &Shell) -> anyhow::Result<()> { pub fn tests_run(sh: &Shell) -> anyhow::Result<()> { let _s = Section::new("RUST-TESTS-RUN"); - cmd!(sh, "{CARGO} test --workspace --locked").run()?; - cmd!( - sh, - "{CARGO} test -p now-policy-api -p now-policy-server-template --all-features --locked" - ) - .run()?; + cmd!(sh, "{CARGO} test --workspace --all-features --locked").run()?; println!("All good!"); From af6fef466aa252e72b9d3355ec48c6203332a77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 26 Aug 2026 16:47:19 +0900 Subject: [PATCH 09/11] lint: check workspace with all features Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- xtask/src/rust.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/xtask/src/rust.rs b/xtask/src/rust.rs index 248b280..1a2a8c1 100644 --- a/xtask/src/rust.rs +++ b/xtask/src/rust.rs @@ -19,12 +19,7 @@ pub fn lints(sh: &Shell) -> anyhow::Result<()> { cmd!( sh, - "{CARGO} clippy --workspace --all-targets --locked --keep-going -- -D warnings" - ) - .run()?; - cmd!( - sh, - "{CARGO} clippy -p now-policy-api -p now-policy-server-template --all-targets --all-features --locked -- -D warnings" + "{CARGO} clippy --workspace --all-targets --all-features --locked --keep-going -- -D warnings" ) .run()?; From 0cfd422fa92b114de799193d805ee5377b87d410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 26 Aug 2026 17:02:24 +0900 Subject: [PATCH 10/11] refactor(now-policy-api): make policy endpoint canonical Remove the policy-compat feature and conversion implementations. The API now composes the canonical policy document directly, while runtime adapters remain owned by broker implementations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/README.md | 2 +- policies/rust/now-policy-api/Cargo.toml | 6 +- policies/rust/now-policy-api/README.md | 9 +- .../openapi/now-policy-api.yaml | 777 +++++++++++------- policies/rust/now-policy-api/src/lib.rs | 6 - .../rust/now-policy-api/src/policy_compat.rs | 152 ---- .../now-policy-server-template/Cargo.toml | 7 +- .../rust/now-policy-server-template/README.md | 10 +- .../now-policy-server-template/src/mock.rs | 15 +- .../now-policy-server-template/src/server.rs | 37 +- .../tests/sample_documents.rs | 68 +- 11 files changed, 495 insertions(+), 594 deletions(-) delete mode 100644 policies/rust/now-policy-api/src/policy_compat.rs diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 4180214..f8ff2cf 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -44,7 +44,7 @@ policies\rust\now-policy-api\openapi\now-policy-api.yaml Regenerate it with: ```powershell -cargo run -p now-policy-server-template --features policy-compat --bin generate-now-policy-api-openapi --locked +cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi --locked ``` After schema changes, run the .NET client tests to verify these DTOs still match the Rust contract. diff --git a/policies/rust/now-policy-api/Cargo.toml b/policies/rust/now-policy-api/Cargo.toml index fa73d94..b01c9ff 100644 --- a/policies/rust/now-policy-api/Cargo.toml +++ b/policies/rust/now-policy-api/Cargo.toml @@ -13,14 +13,10 @@ publish = true [lints] workspace = true -[features] -default = [] -policy-compat = ["dep:now-policy"] - [dependencies] 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.2", path = "../now-policy" } schemars = { version = "0.9", features = ["chrono04"] } semver = "1" serde = { version = "1", features = ["derive"] } diff --git a/policies/rust/now-policy-api/README.md b/policies/rust/now-policy-api/README.md index 25dc2ff..13fb4e0 100644 --- a/policies/rust/now-policy-api/README.md +++ b/policies/rust/now-policy-api/README.md @@ -27,10 +27,11 @@ Library structure overview: - `event_channel.rs` contains the per-operation event channel descriptor returned in execution responses and the `NOW_BROKER` binary frame protocol codec (see `policies/docs/event-channel-protocol.md`). - `health.rs` contains health endpoint models for `GET /v1/health`. - `capabilities.rs` contains capability endpoint models for `GET /v1/capabilities`. -- `policy.rs`, enabled by `policy-compat`, contains the active `PolicyDocument` response for `GET /v1/policy`. +- `policy.rs` contains the active `PolicyDocument` response for the canonical `GET /v1/policy` endpoint. - `enums.rs` contains shared protocol enums. - `lib.rs` contains constrained string newtypes, validation helpers, etc. -- `policy_compat.rs` is enabled by the `policy-compat` feature and maps selected API model types to the `now-policy` crate's package policy types. + +`now-policy` owns the canonical policy document and schema. This API crate composes that domain contract into `PolicyResponse`; runtime-specific mappings between policy evaluation types and API DTOs belong to the consuming broker implementation. Top-level requests carry `RequestKind` and `RequestVersion`; top-level responses carry `ResponseKind` and `ResponseVersion`. Kind fields are marker types that serialize to fixed strings and reject mismatched values during deserialization; this is required for further protocol evolution and allows the client to switch transport from HTTP to other @@ -50,10 +51,10 @@ The route-aware generator lives in `now-policy-server-template`, because OpenAPI Regenerate it with: ```powershell -cargo run -p now-policy-server-template --features policy-compat --bin generate-now-policy-api-openapi --locked +cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi --locked ``` -The generator requires `policy-compat` so the published document always contains the policy inspection route and canonical `PolicyDocument` schema. +The generated document always contains the policy inspection route and canonical `PolicyDocument` schema. Validation ---------- 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 1138613..3f04c9c 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -29,7 +29,7 @@ paths: /v1/policy: get: summary: Get active policy - description: Returns the active parsed policy document. A 404 response means policy inspection is unsupported. + description: Returns the active parsed policy document. A 404 response means no active policy is configured. responses: default: description: Generic error body returned for non-2xx responses. @@ -173,7 +173,10 @@ paths: description: |- Response to a cancel request. - Cancelation is asynchronous and idempotent: the broker acknowledges the request by moving a non-terminal operation to `Canceling` and reports the resulting status. Clients should poll the status endpoint until the operation reaches a terminal status (`Canceled`, or `Completed`/`Failed` when the process ends first). + Cancelation is asynchronous and idempotent: the broker acknowledges the request by + moving a non-terminal operation to `Canceling` and reports the resulting status. + Clients should poll the status endpoint until the operation reaches a terminal + status (`Canceled`, or `Completed`/`Failed` when the process ends first). content: application/json: schema: @@ -207,25 +210,29 @@ components: CancelRequest: description: Request body for canceling a previously submitted operation. type: object - required: - - Client - - OperationId - - RequestKind - - RequestVersion properties: Client: description: Client context used to authenticate the cancel request. - $ref: '#/components/schemas/ClientContext' + allOf: + - $ref: '#/components/schemas/ClientContext' OperationId: description: Server-issued stable operation identifier. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' RequestKind: description: Request discriminator. - $ref: '#/components/schemas/CancelRequestKind' + allOf: + - $ref: '#/components/schemas/CancelRequestKind' RequestVersion: description: Client-side API version used to construct the request. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' additionalProperties: false + required: + - RequestKind + - RequestVersion + - OperationId + - Client CancelRequestKind: type: string pattern: ^CancelRequest$ @@ -233,15 +240,11 @@ components: description: |- Response to a cancel request. - Cancelation is asynchronous and idempotent: the broker acknowledges the request by moving a non-terminal operation to `Canceling` and reports the resulting status. Clients should poll the status endpoint until the operation reaches a terminal status (`Canceled`, or `Completed`/`Failed` when the process ends first). + Cancelation is asynchronous and idempotent: the broker acknowledges the request by + moving a non-terminal operation to `Canceling` and reports the resulting status. + Clients should poll the status endpoint until the operation reaches a terminal + status (`Canceled`, or `Completed`/`Failed` when the process ends first). type: object - required: - - OperationId - - RequestId - - ResponseKind - - ResponseVersion - - Server - - Status properties: Message: description: Human-readable message about the cancelation outcome. @@ -250,39 +253,46 @@ components: nullable: true OperationId: description: Server-issued stable operation identifier. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' RequestId: description: The original request id associated with the operation. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' ResponseKind: description: Response discriminator. - $ref: '#/components/schemas/CancelResponseKind' + allOf: + - $ref: '#/components/schemas/CancelResponseKind' ResponseVersion: description: Server-side API version used to construct the response. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' Server: description: Server context. - $ref: '#/components/schemas/ServerContext' + allOf: + - $ref: '#/components/schemas/ServerContext' Status: description: |- Status of the operation after the cancel request was applied. - `Canceling` when the cancelation was accepted for an in-flight operation; the terminal status when the operation already finished. - $ref: '#/components/schemas/OperationStatus' + `Canceling` when the cancelation was accepted for an in-flight operation; + the terminal status when the operation already finished. + allOf: + - $ref: '#/components/schemas/OperationStatus' additionalProperties: false + required: + - ResponseKind + - ResponseVersion + - Server + - OperationId + - RequestId + - Status CancelResponseKind: type: string pattern: ^CancelResponse$ CapabilitiesResponse: description: Response body for `GET /v1/capabilities`. type: object - required: - - Managers - - MaxRequestBodyBytes - - ResponseKind - - ResponseVersion - - Server - - Transports properties: Managers: description: Package-manager-specific capabilities. @@ -293,33 +303,37 @@ components: description: Maximum accepted request body size, in bytes. type: integer format: uint64 - minimum: 0.0 + minimum: 0 ResponseKind: description: Response discriminator. - $ref: '#/components/schemas/CapabilitiesResponseKind' + allOf: + - $ref: '#/components/schemas/CapabilitiesResponseKind' ResponseVersion: description: Server-side API version used to construct the response. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' Server: description: Server context. - $ref: '#/components/schemas/ServerContext' + allOf: + - $ref: '#/components/schemas/ServerContext' Transports: description: Supported transports. type: array items: $ref: '#/components/schemas/Transport' + required: + - ResponseKind + - ResponseVersion + - Server + - Transports + - Managers + - MaxRequestBodyBytes CapabilitiesResponseKind: type: string pattern: ^CapabilitiesResponse$ ClientContext: description: Context provided by the client. type: object - required: - - ClientExecutablePath - - ClientVersion - - EffectiveUser - - RequestedElevation - - Transport properties: ClientExecutablePath: description: File path of the client executable authenticated by the broker. @@ -338,11 +352,19 @@ components: minLength: 1 RequestedElevation: description: Elevation level requested. - $ref: '#/components/schemas/Elevation' + allOf: + - $ref: '#/components/schemas/Elevation' Transport: description: Transport used by the client to access the broker. - $ref: '#/components/schemas/Transport' + allOf: + - $ref: '#/components/schemas/Transport' additionalProperties: false + required: + - Transport + - RequestedElevation + - EffectiveUser + - ClientExecutablePath + - ClientVersion CommandString: description: A command string. type: string @@ -362,14 +384,11 @@ components: DecisionInfo: description: Policy decision information. type: object - required: - - Decision - - Reason - - RuleId properties: Decision: description: The evaluation decision. - $ref: '#/components/schemas/Decision' + allOf: + - $ref: '#/components/schemas/Decision' Reason: description: Human-readable reason for the decision. type: string @@ -377,8 +396,13 @@ components: minLength: 1 RuleId: description: The rule that produced the decision. - $ref: '#/components/schemas/RuleId' + allOf: + - $ref: '#/components/schemas/RuleId' additionalProperties: false + required: + - Decision + - RuleId + - Reason Elevation: description: Requested elevation level. type: string @@ -403,8 +427,6 @@ components: ErrorDetail: description: Structured error detail, typically used for validation failures. type: object - required: - - Message properties: Code: description: Machine-stable detail code. @@ -423,19 +445,16 @@ components: maxLength: 512 minLength: 1 nullable: true + required: + - Message ErrorResponse: description: Generic error body returned for non-2xx responses. type: object - required: - - Code - - Message - - ResponseKind - - ResponseVersion - - Server properties: Code: description: Machine-readable error code. - $ref: '#/components/schemas/ErrorCode' + allOf: + - $ref: '#/components/schemas/ErrorCode' Details: description: Structured error details. type: array @@ -448,30 +467,28 @@ components: minLength: 1 ResponseKind: description: Response discriminator. - $ref: '#/components/schemas/ErrorResponseKind' + allOf: + - $ref: '#/components/schemas/ErrorResponseKind' ResponseVersion: description: Server-side API version used to construct the response. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' Server: description: Server context. - $ref: '#/components/schemas/ServerContext' + allOf: + - $ref: '#/components/schemas/ServerContext' + required: + - ResponseKind + - ResponseVersion + - Server + - Code + - Message ErrorResponseKind: type: string pattern: ^ErrorResponse$ EvaluationResponse: description: Canonical response returned by the broker after evaluating a request. type: object - required: - - CompletedAt - - Decision - - Policy - - ReceivedAt - - Request - - RequestId - - ResponseKind - - ResponseVersion - - Server - - WouldExecute properties: CompletedAt: description: UTC timestamp when broker completed evaluation (RFC 3339). @@ -479,56 +496,80 @@ components: format: date-time Decision: description: Policy decision details. - $ref: '#/components/schemas/DecisionInfo' + allOf: + - $ref: '#/components/schemas/DecisionInfo' Diagnostics: description: Optional diagnostics. Command preview is omitted unless explicitly requested. - $ref: '#/components/schemas/OperationDiagnostics' - nullable: true + anyOf: + - $ref: '#/components/schemas/OperationDiagnostics' + - enum: + - null + nullable: true Policy: description: Summary of the policy used. - $ref: '#/components/schemas/ResponsePolicyInfo' + allOf: + - $ref: '#/components/schemas/ResponsePolicyInfo' ReceivedAt: description: UTC timestamp when broker received the request (RFC 3339). type: string format: date-time Request: description: Parsed request summary. - $ref: '#/components/schemas/RequestSummary' + allOf: + - $ref: '#/components/schemas/RequestSummary' RequestId: description: Echoed request id. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' ResponseKind: description: Response discriminator. - $ref: '#/components/schemas/EvaluationResponseKind' + allOf: + - $ref: '#/components/schemas/EvaluationResponseKind' ResponseVersion: description: Server-side API version used to construct the response. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' Server: description: Server context. - $ref: '#/components/schemas/ServerContext' + allOf: + - $ref: '#/components/schemas/ServerContext' WouldExecute: description: Whether the broker would execute a command for this decision. type: boolean additionalProperties: false + required: + - ResponseKind + - ResponseVersion + - Server + - RequestId + - ReceivedAt + - CompletedAt + - Request + - Decision + - WouldExecute + - Policy EvaluationResponseKind: type: string pattern: ^EvaluationResponse$ EventChannel: description: Descriptor of a per-operation event channel returned in the execution response. type: object - required: - - Kind - - Path properties: Kind: description: Transport kind of the channel. - $ref: '#/components/schemas/EventChannelKind' + allOf: + - $ref: '#/components/schemas/EventChannelKind' Path: - description: Transport-specific path. For the `LocalPipe` kind this is the pipe name/path the client should connect to. + description: |- + Transport-specific path. For the `LocalPipe` kind this is the pipe + name/path the client should connect to. type: string maxLength: 1024 minLength: 1 additionalProperties: false + required: + - Kind + - Path EventChannelKind: description: Transport kind of a per-operation event channel. oneOf: @@ -539,16 +580,6 @@ components: ExecutionResponse: description: Response returned after an execute request is evaluated and, when allowed, submitted. type: object - required: - - CompletedAt - - Decision - - Policy - - ReceivedAt - - Request - - RequestId - - ResponseKind - - ResponseVersion - - Server properties: CompletedAt: description: UTC timestamp when broker completed evaluation/submission (RFC 3339). @@ -556,66 +587,93 @@ components: format: date-time Decision: description: Policy decision details. - $ref: '#/components/schemas/DecisionInfo' + allOf: + - $ref: '#/components/schemas/DecisionInfo' Diagnostics: description: Optional diagnostics. Command preview is omitted unless explicitly requested. - $ref: '#/components/schemas/OperationDiagnostics' - nullable: true + anyOf: + - $ref: '#/components/schemas/OperationDiagnostics' + - enum: + - null + nullable: true Operation: description: Submitted operation. Omitted when the decision is deny. - $ref: '#/components/schemas/OperationSubmission' - nullable: true + anyOf: + - $ref: '#/components/schemas/OperationSubmission' + - enum: + - null + nullable: true Policy: description: Summary of the policy used. - $ref: '#/components/schemas/ResponsePolicyInfo' + allOf: + - $ref: '#/components/schemas/ResponsePolicyInfo' ReceivedAt: description: UTC timestamp when broker received the request (RFC 3339). type: string format: date-time Request: description: Parsed request summary. - $ref: '#/components/schemas/RequestSummary' + allOf: + - $ref: '#/components/schemas/RequestSummary' RequestId: description: Echoed request id. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' ResponseKind: description: Response discriminator. - $ref: '#/components/schemas/ExecutionResponseKind' + allOf: + - $ref: '#/components/schemas/ExecutionResponseKind' ResponseVersion: description: Server-side API version used to construct the response. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' Server: description: Server context. - $ref: '#/components/schemas/ServerContext' + allOf: + - $ref: '#/components/schemas/ServerContext' additionalProperties: false + required: + - ResponseKind + - ResponseVersion + - Server + - RequestId + - ReceivedAt + - CompletedAt + - Request + - Decision + - Policy ExecutionResponseKind: type: string pattern: ^ExecutionResponse$ HealthResponse: description: Response body for `GET /v1/health`. type: object - required: - - PolicyId - - ResponseKind - - ResponseVersion - - Server - - Status properties: PolicyId: description: Identifier of the active policy (empty when paused). type: string ResponseKind: description: Response discriminator. - $ref: '#/components/schemas/HealthResponseKind' + allOf: + - $ref: '#/components/schemas/HealthResponseKind' ResponseVersion: description: Server-side API version used to construct the response. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' Server: description: Server context. - $ref: '#/components/schemas/ServerContext' + allOf: + - $ref: '#/components/schemas/ServerContext' Status: description: Whether the broker is ready or paused. - $ref: '#/components/schemas/HealthStatus' + allOf: + - $ref: '#/components/schemas/HealthStatus' + required: + - ResponseKind + - ResponseVersion + - Server + - Status + - PolicyId HealthResponseKind: type: string pattern: ^HealthResponse$ @@ -633,15 +691,6 @@ components: ManagerCapability: description: Package-manager-specific capability declaration. type: object - required: - - Architectures - - Manager - - Operations - - Scopes - - SupportsCaptureOutput - - SupportsCustomInstallLocation - - SupportsCustomParameters - - SupportsDetails properties: Architectures: description: Architectures supported for this manager. @@ -650,12 +699,13 @@ components: $ref: '#/components/schemas/Architecture' Manager: description: Package manager name. - $ref: '#/components/schemas/ManagerName' + allOf: + - $ref: '#/components/schemas/ManagerName' MaxOperationTimeoutSeconds: description: Maximum operation runtime before the broker may time out the process. type: integer format: uint64 - minimum: 0.0 + minimum: 0 nullable: true Operations: description: Operations supported for this manager. @@ -679,6 +729,15 @@ components: SupportsDetails: description: Whether operation status may include manager-specific JSON details. type: boolean + required: + - Manager + - Operations + - Scopes + - Architectures + - SupportsCustomParameters + - SupportsCustomInstallLocation + - SupportsCaptureOutput + - SupportsDetails ManagerName: description: Supported package manager names. type: string @@ -748,43 +807,70 @@ components: OperationSubmission: description: Execution submission returned for allowed execute requests. type: object - required: - - OperationId - - Status - - SubmittedAt properties: EventChannel: - description: Per-operation event channel carrying `NOW_BROKER` event frames (status change notifications and, when the execute request opted in via `CaptureOutput`, stdout/stderr data). Present whenever the broker supports event channels; absent otherwise. - $ref: '#/components/schemas/EventChannel' - nullable: true + description: |- + Per-operation event channel carrying `NOW_BROKER` event frames (status + change notifications and, when the execute request opted in via + `CaptureOutput`, stdout/stderr data). Present whenever the broker + supports event channels; absent otherwise. + anyOf: + - $ref: '#/components/schemas/EventChannel' + - enum: + - null + nullable: true OperationId: description: Server-issued stable operation identifier. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' Status: description: Initial operation status. - $ref: '#/components/schemas/OperationStatus' + allOf: + - $ref: '#/components/schemas/OperationStatus' SubmittedAt: description: UTC timestamp when the operation was accepted. type: string format: date-time additionalProperties: false + required: + - OperationId + - Status + - SubmittedAt PackageIdentifier: description: |- Package identifier string. - Validated against an explicit allowlist of characters: ASCII alphanumerics plus `. - _ + @ / : [ ] , # $ % { }`. + Validated against an explicit allowlist of characters: ASCII alphanumerics + plus `. - _ + @ / : [ ] , # $ % { }`. - - `.`, `-`, `_`, `+`: winget (`Notepad++.Notepad++`), chocolatey, pip, cargo, dotnet, apt/dnf/pacman (`g++`, `libstdc++6`), PowerShell modules; + - `.`, `-`, `_`, `+`: winget (`Notepad++.Notepad++`), chocolatey, pip, cargo, + dotnet, apt/dnf/pacman (`g++`, `libstdc++6`), PowerShell modules; - - `@`, `/`: scoped npm/Bun packages (`@scope/package`), homebrew and scoop `tap/formula` paths, versioned formulas (`python@3.11`); + - `@`, `/`: scoped npm/Bun packages (`@scope/package`), homebrew and scoop + `tap/formula` paths, versioned formulas (`python@3.11`); - - `:`: npm aliases (`alias:@scope/package@1.0.0`), vcpkg triplets (`curl:x64-windows`); + - `:`: npm aliases (`alias:@scope/package@1.0.0`), vcpkg triplets + (`curl:x64-windows`); - - `[`, `]`, `,`: vcpkg features (`curl[ssl,http2]:x64-windows`), pip extras (`requests[socks]`); + - `[`, `]`, `,`: vcpkg features (`curl[ssl,http2]:x64-windows`), pip extras + (`requests[socks]`); - - `#`, `$`, `%`, `{`, `}`: additional identifier punctuation (accepted by product decision for forward compatibility). Caveat: these characters carry expansion semantics in some shells (`${VAR}`, `%VAR%`, brace expansion), so downstream command builders must pass identifiers as discrete process arguments and never interpolate them into a shell command line. + - `#`, `$`, `%`, `{`, `}`: additional identifier punctuation (accepted by + product decision for forward compatibility). Caveat: these characters + carry expansion semantics in some shells (`${VAR}`, `%VAR%`, brace + expansion), so downstream command builders must pass identifiers as + discrete process arguments and never interpolate them into a shell + command line. - Version range/pin operators (`<`, `>`, `=`, `!`, `|`, `^`, `~`) are rejected: the broker matches against a specific, exact version carried in the request's separate `Package.Version` field, so range expressions do not belong in the identifier (npm aliases must use exact versions, e.g. `alias:pkg@7.20.0`). The wildcards `*` and `?` are also rejected: policy-side package identifier matching is wildcard-based, so wildcards in request identifiers would be ambiguous. Everything else — whitespace, control characters, `"`, `\`, backtick, `& ' ( ) ;`, and non-ASCII — is rejected as well. + Version range/pin operators (`<`, `>`, `=`, `!`, `|`, `^`, `~`) are + rejected: the broker matches against a specific, exact version carried in + the request's separate `Package.Version` field, so range expressions do + not belong in the identifier (npm aliases must use exact versions, e.g. + `alias:pkg@7.20.0`). The wildcards `*` and `?` are also rejected: + policy-side package identifier matching is wildcard-based, so wildcards in + request identifiers would be ambiguous. Everything else — whitespace, + control characters, `"`, `\`, backtick, `& ' ( ) ;`, and non-ASCII — is + rejected as well. type: string maxLength: 256 minLength: 1 @@ -792,85 +878,108 @@ components: PackageRequest: description: Canonical request sent by a package broker client to the elevated broker. type: object - required: - - Client - - CreatedAt - - Manager - - Operation - - Options - - Package - - RequestId - - RequestKind - - RequestVersion - - Source properties: CaptureOutput: - description: When true, the operation's stdout/stderr data is pushed over the per-operation event channel (see the `EventChannel` descriptor in the execution response). The channel itself is opened unconditionally when supported and always carries status change notifications; this flag only controls whether output data frames are sent. Off by default to avoid the overhead when the client does not need the output. - default: false + description: |- + When true, the operation's stdout/stderr data is pushed over the + per-operation event channel (see the `EventChannel` descriptor in the + execution response). The channel itself is opened unconditionally when + supported and always carries status change notifications; this flag only + controls whether output data frames are sent. Off by default to avoid the + overhead when the client does not need the output. type: boolean + default: false Client: description: Client context. - $ref: '#/components/schemas/ClientContext' + allOf: + - $ref: '#/components/schemas/ClientContext' CreatedAt: description: UTC timestamp when the client created the request (RFC 3339). type: string format: date-time IncludeCommandPreview: - description: When true, evaluation and execution responses may include a command preview for diagnostics. Off by default because command previews can expose paths or arguments. - default: false + description: |- + When true, evaluation and execution responses may include a command preview for diagnostics. + Off by default because command previews can expose paths or arguments. type: boolean + default: false Manager: description: Package manager type. - $ref: '#/components/schemas/ManagerName' + allOf: + - $ref: '#/components/schemas/ManagerName' Operation: description: The package operation to perform. - $ref: '#/components/schemas/Operation' + allOf: + - $ref: '#/components/schemas/Operation' Options: description: Operation options. - $ref: '#/components/schemas/RequestOptions' + allOf: + - $ref: '#/components/schemas/RequestOptions' Package: description: Package information. - $ref: '#/components/schemas/RequestPackage' + allOf: + - $ref: '#/components/schemas/RequestPackage' RequestId: description: |- Unique client-generated request id for request/response correlation. - Execute requests are idempotent by this identifier: retrying the same request id must return the existing submission rather than creating a second operation. - $ref: '#/components/schemas/ResourceId' + Execute requests are idempotent by this identifier: retrying the same + request id must return the existing submission rather than creating a + second operation. + allOf: + - $ref: '#/components/schemas/ResourceId' RequestKind: description: Request discriminator. - $ref: '#/components/schemas/PackageRequestKind' + allOf: + - $ref: '#/components/schemas/PackageRequestKind' RequestVersion: description: Client-side API version used to construct the request. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' Source: description: Source/repository information. - $ref: '#/components/schemas/RequestSource' + allOf: + - $ref: '#/components/schemas/RequestSource' additionalProperties: false + required: + - RequestKind + - RequestVersion + - RequestId + - CreatedAt + - Operation + - Manager + - Source + - Package + - Options + - Client PackageRequestKind: type: string pattern: ^PackageRequest$ PolicyResponse: description: Response body for `GET /v1/policy`. type: object - required: - - Policy - - ResponseKind - - ResponseVersion - - Server properties: Policy: description: Active parsed policy document. - $ref: '#/components/schemas/PolicyDocument' + allOf: + - $ref: '#/components/schemas/PolicyDocument' ResponseKind: description: Response discriminator. - $ref: '#/components/schemas/PolicyResponseKind' + allOf: + - $ref: '#/components/schemas/PolicyResponseKind' ResponseVersion: description: Server-side API version used to construct the response. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' Server: description: Server context. - $ref: '#/components/schemas/ServerContext' + allOf: + - $ref: '#/components/schemas/ServerContext' + required: + - ResponseKind + - ResponseVersion + - Server + - Policy PolicyResponseKind: type: string pattern: ^PolicyResponse$ @@ -882,10 +991,6 @@ components: RequestOptions: description: Options controlling the package operation. type: object - required: - - Interactive - - PreRelease - - SkipHashCheck properties: CustomInstallLocation: description: Custom install directory path. @@ -909,8 +1014,8 @@ components: maxItems: 64 NoUpgrade: description: Whether to skip upgrade if an existing version is detected (for install operations). - default: false type: boolean + default: false PostOperationCommand: description: Command to execute after the package operation. type: string @@ -926,26 +1031,34 @@ components: type: boolean Scope: description: Installation scope. - $ref: '#/components/schemas/Scope' - nullable: true + anyOf: + - $ref: '#/components/schemas/Scope' + - enum: + - null + nullable: true SkipHashCheck: description: Skip package hash verification. type: boolean UninstallPrevious: description: Whether to uninstall previous version before installing update. - default: false type: boolean + default: false additionalProperties: false + required: + - Interactive + - SkipHashCheck + - PreRelease RequestPackage: description: Package information. type: object - required: - - Id properties: Architecture: description: Target architecture. - $ref: '#/components/schemas/Architecture' - nullable: true + anyOf: + - $ref: '#/components/schemas/Architecture' + - enum: + - null + nullable: true Channel: description: Release channel. type: string @@ -954,20 +1067,26 @@ components: nullable: true Id: description: Package identifier (e.g., "Publisher.Package" for WinGet). - $ref: '#/components/schemas/PackageIdentifier' + allOf: + - $ref: '#/components/schemas/PackageIdentifier' Version: description: |- Target version (for update/install operations). - A lenient version string rather than strict SemVer: real package versions are frequently not SemVer (e.g. PowerShell modules use 4-part .NET versions like `5.6.0.0`, and some winget packages use 2-part or date-based versions). - $ref: '#/components/schemas/VersionString' - nullable: true + A lenient version string rather than strict SemVer: real package versions + are frequently not SemVer (e.g. PowerShell modules use 4-part .NET versions + like `5.6.0.0`, and some winget packages use 2-part or date-based versions). + anyOf: + - $ref: '#/components/schemas/VersionString' + - enum: + - null + nullable: true additionalProperties: false + required: + - Id RequestSource: description: Package source/repository information. type: object - required: - - Name properties: Name: description: Source name. @@ -980,22 +1099,33 @@ components: maxLength: 2048 nullable: true additionalProperties: false + required: + - Name RequestSummary: description: Parsed request summary included in decision responses. type: object properties: Manager: description: Manager from the request (null if not parsed). - $ref: '#/components/schemas/ManagerName' - nullable: true + anyOf: + - $ref: '#/components/schemas/ManagerName' + - enum: + - null + nullable: true Operation: description: Operation from the request (null if not parsed). - $ref: '#/components/schemas/Operation' - nullable: true + anyOf: + - $ref: '#/components/schemas/Operation' + - enum: + - null + nullable: true PackageId: description: Package identifier from the request (null if not parsed). - $ref: '#/components/schemas/PackageIdentifier' - nullable: true + anyOf: + - $ref: '#/components/schemas/PackageIdentifier' + - enum: + - null + nullable: true Source: description: Source name from the request (null if not parsed). type: string @@ -1011,24 +1141,26 @@ components: ResponsePolicyInfo: description: Summary of policy used for the decision. type: object - required: - - Id - - PolicyVersion - - Revision properties: Id: description: Policy document identifier. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' PolicyVersion: description: Policy syntax version. - $ref: '#/components/schemas/SemanticVersion' + allOf: + - $ref: '#/components/schemas/SemanticVersion' Revision: description: Policy revision number. type: integer format: uint32 - maximum: 2147483647.0 - minimum: 1.0 + maximum: 2147483647 + minimum: 1 additionalProperties: false + required: + - Id + - Revision + - PolicyVersion RuleId: description: Rule ID in responses. Includes sentinel values not valid as policy rule IDs. type: string @@ -1048,9 +1180,6 @@ components: ServerContext: description: Server context included in responses. type: object - required: - - ServerVersion - - Transport properties: ServerVersion: description: Version of the package broker server binary. @@ -1059,43 +1188,44 @@ components: minLength: 1 Transport: description: Transport mechanism. - $ref: '#/components/schemas/Transport' + allOf: + - $ref: '#/components/schemas/Transport' additionalProperties: false + required: + - ServerVersion + - Transport StatusRequest: description: Request body for querying an operation status. type: object - required: - - Client - - OperationId - - RequestKind - - RequestVersion properties: Client: description: Client context used to authenticate the status query. - $ref: '#/components/schemas/ClientContext' + allOf: + - $ref: '#/components/schemas/ClientContext' OperationId: description: Server-issued stable operation identifier. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' RequestKind: description: Request discriminator. - $ref: '#/components/schemas/StatusRequestKind' + allOf: + - $ref: '#/components/schemas/StatusRequestKind' RequestVersion: description: Client-side API version used to construct the request. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' additionalProperties: false + required: + - RequestKind + - RequestVersion + - OperationId + - Client StatusRequestKind: type: string pattern: ^StatusRequest$ StatusResponse: description: Response to a status query. type: object - required: - - OperationId - - RequestId - - ResponseKind - - ResponseVersion - - Server - - Status properties: CompletedAt: description: UTC timestamp when the operation completed or failed (null if still running). @@ -1104,32 +1234,38 @@ components: nullable: true Details: description: Manager-specific structured status details. - nullable: true ExitCode: description: Process exit code (present when status is `completed`, or `failed` due to non-zero exit). type: integer format: int32 nullable: true Message: - description: Human-readable message about the status. For failures this carries the short error summary (e.g. "winget.exe exited with code 0x8A150011", or a process-launch error). + description: |- + Human-readable message about the status. For failures this carries the short error + summary (e.g. "winget.exe exited with code 0x8A150011", or a process-launch error). type: string maxLength: 2048 nullable: true OperationId: description: Server-issued stable operation identifier. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' RequestId: description: The original request id associated with the operation. - $ref: '#/components/schemas/ResourceId' + allOf: + - $ref: '#/components/schemas/ResourceId' ResponseKind: description: Response discriminator. - $ref: '#/components/schemas/StatusResponseKind' + allOf: + - $ref: '#/components/schemas/StatusResponseKind' ResponseVersion: description: Server-side API version used to construct the response. - $ref: '#/components/schemas/ApiVersion' + allOf: + - $ref: '#/components/schemas/ApiVersion' Server: description: Server context. - $ref: '#/components/schemas/ServerContext' + allOf: + - $ref: '#/components/schemas/ServerContext' StartedAt: description: UTC timestamp when the process was actually launched (null if not yet started). type: string @@ -1137,8 +1273,16 @@ components: nullable: true Status: description: Current status of the operation. - $ref: '#/components/schemas/OperationStatus' + allOf: + - $ref: '#/components/schemas/OperationStatus' additionalProperties: false + required: + - ResponseKind + - ResponseVersion + - Server + - OperationId + - RequestId + - Status StatusResponseKind: type: string pattern: ^StatusResponse$ @@ -1153,45 +1297,6 @@ components: type: string maxLength: 128 minLength: 1 - PolicyDocument: - title: PolicyDocument - description: A policy document governing which package operations are allowed or denied. - type: object - required: - - $schema - - Enforcement - - Metadata - - PolicyType - - PolicyVersion - - Rules - properties: - $schema: - description: Policy schema URI constant. - allOf: - - $ref: '#/components/schemas/PolicyModelPolicySchemaUri' - Enforcement: - description: Enforcement configuration. - allOf: - - $ref: '#/components/schemas/PolicyModelPolicyEnforcement' - Metadata: - description: Policy metadata. - allOf: - - $ref: '#/components/schemas/PolicyModelPolicyMetadata' - PolicyType: - description: Must be `"PackageBrokerPolicy"`. - allOf: - - $ref: '#/components/schemas/PolicyModelPackageBrokerPolicy' - PolicyVersion: - description: Policy syntax version (semver). - allOf: - - $ref: '#/components/schemas/PolicyModelSemanticVersion' - Rules: - description: Ordered list of policy rules (may be empty; enforcement defaults apply). - type: array - items: - $ref: '#/components/schemas/PolicyModelPolicyRule' - maxItems: 1024 - additionalProperties: false PolicyModelArchitecture: description: Target architecture. type: string @@ -1221,7 +1326,7 @@ components: description: |- HTTP(S) URL string. - Validated at deserialization time using the `url` crate. + Validated at deserialization time using the `url` crate. type: string maxLength: 2048 pattern: ^([Hh][Tt][Tt][Pp][Ss]?)://.+$ @@ -1286,7 +1391,9 @@ components: description: Allow uninstalling previous version before installing update. type: boolean AllowUpgrade: - description: Allow skipping upgrade on install operations if an existing version is detected (for install operations). + description: |- + Allow skipping upgrade on install operations if an existing version + is detected (for install operations). type: boolean AllowedCustomParameterPatterns: description: Glob patterns for allowed custom parameters. @@ -1313,12 +1420,47 @@ components: $ref: '#/components/schemas/PolicyModelCustomParameterString' maxItems: 128 additionalProperties: false + PolicyDocument: + description: A policy document governing which package operations are allowed or denied. + type: object + properties: + $schema: + description: Policy schema URI constant. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicySchemaUri' + Enforcement: + description: Enforcement configuration. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyEnforcement' + Metadata: + description: Policy metadata. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyMetadata' + PolicyType: + description: Must be `"PackageBrokerPolicy"`. + allOf: + - $ref: '#/components/schemas/PolicyModelPackageBrokerPolicy' + PolicyVersion: + description: Policy syntax version (semver). + allOf: + - $ref: '#/components/schemas/PolicyModelSemanticVersion' + Rules: + description: Ordered list of policy rules (may be empty; enforcement defaults apply). + type: array + items: + $ref: '#/components/schemas/PolicyModelPolicyRule' + maxItems: 1024 + additionalProperties: false + required: + - $schema + - PolicyVersion + - PolicyType + - Metadata + - Enforcement + - Rules PolicyModelPolicyEnforcement: description: Enforcement configuration. type: object - required: - - DefaultDecision - - RulePrecedence properties: AuditMode: description: When true, broker logs decisions but does not enforce. @@ -1333,8 +1475,13 @@ components: allOf: - $ref: '#/components/schemas/PolicyModelRulePrecedence' additionalProperties: false + required: + - DefaultDecision + - RulePrecedence PolicyModelPolicyMatch: - description: Match criteria for a policy rule. All specified fields must match. At least one field must be present. + description: |- + Match criteria for a policy rule. All specified fields must match. + At least one field must be present. type: object properties: Architectures: @@ -1450,9 +1597,11 @@ components: uniqueItems: true VersionRange: description: Semantic version range. - allOf: + anyOf: - $ref: '#/components/schemas/PolicyModelVersionRange' - nullable: true + - enum: + - null + nullable: true Versions: description: Exact version list. type: array @@ -1464,11 +1613,6 @@ components: PolicyModelPolicyMetadata: description: Policy metadata. type: object - required: - - Id - - PublishedAt - - Publisher - - Revision properties: Description: description: Human-readable description. @@ -1492,13 +1636,15 @@ components: description: Monotonically increasing revision number. type: integer format: uint32 - maximum: 2147483647.0 - minimum: 1.0 + maximum: 2147483647 + minimum: 1 SupportUrl: description: URL for support or documentation. - allOf: + anyOf: - $ref: '#/components/schemas/PolicyModelHttpUrl' - nullable: true + - enum: + - null + nullable: true ValidFrom: description: Policy becomes active at this time. type: string @@ -1510,34 +1656,40 @@ components: format: date-time nullable: true additionalProperties: false + required: + - Id + - Publisher + - Revision + - PublishedAt PolicyModelPolicyRule: description: A single policy rule. type: object - required: - - Decision - - Id - - Match - - Priority properties: Constraints: - description: Additional constraints applied after matching. When absent, no constraints are enforced beyond the match criteria. - allOf: + description: |- + Additional constraints applied after matching. + When absent, no constraints are enforced beyond the match criteria. + anyOf: - $ref: '#/components/schemas/PolicyModelPolicyConstraints' - nullable: true + - enum: + - null + nullable: true Decision: description: Decision if this rule matches. allOf: - $ref: '#/components/schemas/PolicyModelDecision' Enabled: description: Whether the rule is active. - default: true type: boolean + default: true Id: description: Unique rule identifier. allOf: - $ref: '#/components/schemas/PolicyModelResourceId' Match: - description: Match criteria — request must satisfy all specified fields. At least one criterion must be present. + description: |- + Match criteria — request must satisfy all specified fields. + At least one criterion must be present. allOf: - $ref: '#/components/schemas/PolicyModelPolicyMatch' minProperties: 1 @@ -1545,14 +1697,19 @@ components: description: Priority (lower = higher precedence). type: integer format: uint32 - maximum: 2147483647.0 - minimum: 0.0 + maximum: 2147483647 + minimum: 0 Reason: description: Reason reported to the client. type: string maxLength: 512 nullable: true additionalProperties: false + required: + - Id + - Priority + - Decision + - Match PolicyModelPolicySchemaUri: type: string enum: @@ -1577,7 +1734,7 @@ components: description: |- Semantic version string (SemVer 2.0.0). - Validated at deserialization time using the `semver` crate. + Validated at deserialization time using the `semver` crate. type: string maxLength: 128 pattern: ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$ @@ -1592,8 +1749,8 @@ components: properties: IncludePrerelease: description: Whether to include pre-release versions. - default: false type: boolean + default: false MaxVersion: description: Maximum version (inclusive). type: string diff --git a/policies/rust/now-policy-api/src/lib.rs b/policies/rust/now-policy-api/src/lib.rs index d7ab9aa..652e09b 100644 --- a/policies/rust/now-policy-api/src/lib.rs +++ b/policies/rust/now-policy-api/src/lib.rs @@ -11,10 +11,7 @@ pub mod evaluate; pub mod event_channel; pub mod execute; pub mod health; -#[cfg(feature = "policy-compat")] pub mod policy; -#[cfg(feature = "policy-compat")] -mod policy_compat; pub mod status; pub use api::*; @@ -25,7 +22,6 @@ pub use evaluate::*; pub use event_channel::*; pub use execute::*; pub use health::*; -#[cfg(feature = "policy-compat")] pub use policy::*; pub use status::*; @@ -42,7 +38,6 @@ pub const EVALUATION_RESPONSE_KIND: &str = "EvaluationResponse"; pub const EXECUTION_RESPONSE_KIND: &str = "ExecutionResponse"; pub const STATUS_RESPONSE_KIND: &str = "StatusResponse"; pub const CANCEL_RESPONSE_KIND: &str = "CancelResponse"; -#[cfg(feature = "policy-compat")] pub const POLICY_RESPONSE_KIND: &str = "PolicyResponse"; pub const ERROR_RESPONSE_KIND: &str = "ErrorResponse"; @@ -102,7 +97,6 @@ fixed_string_marker!(EvaluationResponseKind, EVALUATION_RESPONSE_KIND); fixed_string_marker!(ExecutionResponseKind, EXECUTION_RESPONSE_KIND); fixed_string_marker!(StatusResponseKind, STATUS_RESPONSE_KIND); fixed_string_marker!(CancelResponseKind, CANCEL_RESPONSE_KIND); -#[cfg(feature = "policy-compat")] fixed_string_marker!(PolicyResponseKind, POLICY_RESPONSE_KIND); fixed_string_marker!(ErrorResponseKind, ERROR_RESPONSE_KIND); diff --git a/policies/rust/now-policy-api/src/policy_compat.rs b/policies/rust/now-policy-api/src/policy_compat.rs deleted file mode 100644 index 33d17d1..0000000 --- a/policies/rust/now-policy-api/src/policy_compat.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Compatibility conversions for the `now-policy` crate. - -use super::{ - Architecture, CustomParameterString, Decision, Elevation, ManagerName, Operation, ResourceId, RuleId, Scope, - SemanticVersion, VersionString, -}; - -macro_rules! bidirectional_enum_conversion { - ($local:ty, $policy:ty, [$($variant:ident),+ $(,)?]) => { - impl From<$policy> for $local { - fn from(value: $policy) -> Self { - match value { - $(<$policy>::$variant => Self::$variant,)+ - } - } - } - - impl From<$local> for $policy { - fn from(value: $local) -> Self { - match value { - $(<$local>::$variant => Self::$variant,)+ - } - } - } - }; -} - -macro_rules! bidirectional_newtype_conversion { - ($local:ty, $policy:ty) => { - impl From<$policy> for $local { - fn from(value: $policy) -> Self { - Self(value.0) - } - } - - impl From<$local> for $policy { - fn from(value: $local) -> Self { - Self(value.0) - } - } - }; -} - -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, - 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]); - -bidirectional_newtype_conversion!(ResourceId, now_policy::ResourceId); -bidirectional_newtype_conversion!(SemanticVersion, now_policy::SemanticVersion); -bidirectional_newtype_conversion!(VersionString, now_policy::VersionString); -bidirectional_newtype_conversion!(CustomParameterString, now_policy::CustomParameterString); - -impl From for RuleId { - fn from(value: now_policy::ResourceId) -> Self { - Self(value.0) - } -} - -impl TryFrom for now_policy::ResourceId { - type Error = now_policy::ModelValidationError; - - fn try_from(value: RuleId) -> Result { - now_policy::ResourceId::parse(&value.0) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn policy_enum_conversions_round_trip() { - assert_eq!(now_policy::Operation::Install, Operation::Install.into()); - assert_eq!(Operation::Update, now_policy::Operation::Update.into()); - - assert_eq!(now_policy::Scope::Machine, Scope::Machine.into()); - assert_eq!(Scope::User, now_policy::Scope::User.into()); - - assert_eq!(now_policy::Architecture::Arm64, Architecture::Arm64.into()); - assert_eq!(Architecture::Neutral, now_policy::Architecture::Neutral.into()); - - assert_eq!(now_policy::ManagerName::PowerShell7, ManagerName::PowerShell7.into()); - assert_eq!(ManagerName::Winget, now_policy::ManagerName::Winget.into()); - - assert_eq!(now_policy::Decision::Deny, Decision::Deny.into()); - assert_eq!(Decision::Allow, now_policy::Decision::Allow.into()); - - assert_eq!(now_policy::Elevation::Elevated, Elevation::Elevated.into()); - assert_eq!(Elevation::Standard, now_policy::Elevation::Standard.into()); - } - - #[test] - fn policy_newtype_conversions_round_trip() { - let policy_resource = now_policy::ResourceId::parse("policy:rule-1").expect("valid policy resource id"); - let broker_resource = ResourceId::from(policy_resource.clone()); - assert_eq!("policy:rule-1", broker_resource.as_ref()); - assert_eq!(policy_resource, broker_resource.into()); - - let policy_version = now_policy::SemanticVersion::parse("1.2.3").expect("valid semantic version"); - let broker_version = SemanticVersion::from(policy_version.clone()); - assert_eq!("1.2.3", broker_version.as_ref()); - assert_eq!(policy_version, broker_version.into()); - - let policy_package_version = now_policy::VersionString::parse("2.0.0-preview").expect("valid package version"); - let broker_package_version = VersionString::from(policy_package_version.clone()); - assert_eq!("2.0.0-preview", broker_package_version.as_ref()); - assert_eq!(policy_package_version, broker_package_version.into()); - - let policy_parameters = now_policy::CustomParameterString::parse("--silent").expect("valid custom parameters"); - let broker_parameters = CustomParameterString::from(policy_parameters.clone()); - assert_eq!("--silent", broker_parameters.as_ref()); - assert_eq!(policy_parameters, broker_parameters.into()); - } - - #[test] - fn rule_id_converts_from_policy_resource_id() { - let policy_rule_id = now_policy::ResourceId::parse("rule-1").expect("valid policy rule id"); - let broker_rule_id = RuleId::from(policy_rule_id.clone()); - assert_eq!("rule-1", broker_rule_id.as_ref()); - assert_eq!( - policy_rule_id, - broker_rule_id - .try_into() - .expect("broker rule id converts back to policy id") - ); - } -} diff --git a/policies/rust/now-policy-server-template/Cargo.toml b/policies/rust/now-policy-server-template/Cargo.toml index 1c1ff42..ed6ead8 100644 --- a/policies/rust/now-policy-server-template/Cargo.toml +++ b/policies/rust/now-policy-server-template/Cargo.toml @@ -13,16 +13,12 @@ publish = true [lints] workspace = true -[features] -default = [] -policy-compat = ["dep:now-policy", "now-policy-api/policy-compat"] - [dependencies] aide = { version = "0.15", features = ["axum", "axum-json"] } async-trait = "0.1" axum = { version = "0.8", default-features = false, features = ["json"] } now-policy-api = { version = "0.3", path = "../now-policy-api" } -now-policy = { version = "0.2", path = "../now-policy", optional = true } +now-policy = { version = "0.2", path = "../now-policy" } schemars = "0.9" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -35,4 +31,3 @@ tower = { version = "0.5", features = ["util"] } [[bin]] name = "generate-now-policy-api-openapi" path = "tools/generate_openapi.rs" -required-features = ["policy-compat"] diff --git a/policies/rust/now-policy-server-template/README.md b/policies/rust/now-policy-server-template/README.md index 7eec719..f20e3c8 100644 --- a/policies/rust/now-policy-server-template/README.md +++ b/policies/rust/now-policy-server-template/README.md @@ -39,8 +39,6 @@ Runtime implementations implement: pub trait PackageBrokerServer: Send + Sync { async fn health(&self) -> HealthResponse; async fn capabilities(&self) -> CapabilitiesResponse; - // The trait provides a structured NotFound default for this feature-gated method. - #[cfg(feature = "policy-compat")] async fn policy(&self) -> Result; async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; @@ -48,13 +46,13 @@ pub trait PackageBrokerServer: Send + Sync { } ``` -Implementations built with `policy-compat` override `policy` to return the active policy. Implementations that do not override it inherit the structured 404 response; builds without the feature do not expose the method or route. +Implementations return the active policy from `policy`. A broker with no active policy may return a structured `NotFound` error. Then they pass the implementation to `api_router` or `api_router_from_shared`. The template owns the HTTP paths: - `GET /v1/health` - `GET /v1/capabilities` -- `GET /v1/policy` (with `policy-compat`) +- `GET /v1/policy` - `POST /v1/package-operations/evaluate` - `POST /v1/package-operations/execute` - `POST /v1/package-operations/get-status` @@ -86,10 +84,10 @@ OpenAPI generation lives here because it requires the HTTP route binding from `s Regenerate it with: ```powershell -cargo run -p now-policy-server-template --features policy-compat --bin generate-now-policy-api-openapi --locked +cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi --locked ``` -The generator requires `policy-compat`; the generated route and components include the policy response and policy document schema from `now-policy`. +The generated route and components include the policy response and policy document schema from `now-policy`. Validation ---------- diff --git a/policies/rust/now-policy-server-template/src/mock.rs b/policies/rust/now-policy-server-template/src/mock.rs index 693a3bc..80d11cd 100644 --- a/policies/rust/now-policy-server-template/src/mock.rs +++ b/policies/rust/now-policy-server-template/src/mock.rs @@ -5,13 +5,11 @@ use std::collections::BTreeMap; use async_trait::async_trait; use crate::server::{MAX_REQUEST_BODY_BYTES, PackageBrokerServer}; -#[cfg(feature = "policy-compat")] -use now_policy_api::PolicyResponse; use now_policy_api::{ API_VERSION_STR, Architecture, CancelRequest, CancelResponse, CapabilitiesResponse, CapabilitiesResponseKind, ErrorCode, ErrorResponse, ErrorResponseKind, EvaluationResponse, ExecutionResponse, HealthResponse, - HealthResponseKind, HealthStatus, ManagerCapability, ManagerName, Operation, PackageRequest, Scope, ServerContext, - StatusRequest, StatusResponse, Transport, + HealthResponseKind, HealthStatus, ManagerCapability, ManagerName, Operation, PackageRequest, PolicyResponse, Scope, + ServerContext, StatusRequest, StatusResponse, Transport, }; /// Deterministic mock broker backed by caller-provided sample responses. @@ -19,9 +17,7 @@ use now_policy_api::{ pub struct MockPackageBrokerServer { health: HealthResponse, capabilities: CapabilitiesResponse, - #[cfg(feature = "policy-compat")] policy_response: Option, - #[cfg(feature = "policy-compat")] policy_error: Option, evaluation_responses: BTreeMap, execution_responses: BTreeMap, @@ -48,9 +44,7 @@ impl MockPackageBrokerServer { managers: default_manager_capabilities(), max_request_body_bytes: MAX_REQUEST_BODY_BYTES as u64, }, - #[cfg(feature = "policy-compat")] policy_response: None, - #[cfg(feature = "policy-compat")] policy_error: None, evaluation_responses: BTreeMap::new(), execution_responses: BTreeMap::new(), @@ -66,7 +60,6 @@ impl MockPackageBrokerServer { self } - #[cfg(feature = "policy-compat")] #[must_use] pub fn with_policy_response(mut self, response: PolicyResponse) -> Self { self.policy_response = Some(response); @@ -74,7 +67,6 @@ impl MockPackageBrokerServer { self } - #[cfg(feature = "policy-compat")] #[must_use] pub fn with_policy_error(mut self, error: ErrorResponse) -> Self { self.policy_response = None; @@ -125,7 +117,6 @@ impl PackageBrokerServer for MockPackageBrokerServer { self.capabilities.clone() } - #[cfg(feature = "policy-compat")] async fn policy(&self) -> Result { if let Some(response) = &self.policy_response { return Ok(response.clone()); @@ -140,7 +131,7 @@ impl PackageBrokerServer for MockPackageBrokerServer { response_version: API_VERSION_STR.into(), server: self.capabilities.server.clone(), code: ErrorCode::NotFound, - message: "active policy inspection is not supported".to_owned(), + message: "no active policy is configured".to_owned(), details: Vec::new(), }) } diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index 6adcb50..54926de 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -15,10 +15,8 @@ use serde::Serialize; use now_policy_api::{ API_VERSION_STR, CancelRequest, CancelResponse, CapabilitiesResponse, ErrorCode, ErrorResponse, EvaluationResponse, - ExecutionResponse, HealthResponse, PackageRequest, StatusRequest, StatusResponse, + ExecutionResponse, HealthResponse, PackageRequest, PolicyResponse, StatusRequest, StatusResponse, }; -#[cfg(feature = "policy-compat")] -use now_policy_api::{ErrorResponseKind, PolicyResponse}; use schemars::SchemaGenerator; pub const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; @@ -28,17 +26,7 @@ pub const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; pub trait PackageBrokerServer: Send + Sync { async fn health(&self) -> HealthResponse; async fn capabilities(&self) -> CapabilitiesResponse; - #[cfg(feature = "policy-compat")] - async fn policy(&self) -> Result { - Err(ErrorResponse { - response_kind: ErrorResponseKind, - response_version: API_VERSION_STR.into(), - server: self.capabilities().await.server, - code: ErrorCode::NotFound, - message: "active policy inspection is not supported".to_owned(), - details: Vec::new(), - }) - } + async fn policy(&self) -> Result; async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; async fn status(&self, request: StatusRequest) -> Result; @@ -62,14 +50,10 @@ pub fn api_router_from_shared(server: SharedPackageBrokerServer) -> ApiRouter<() } fn api_routes() -> ApiRouter { - let router = ApiRouter::new() + ApiRouter::new() .api_route("/v1/health", get_with(health_handler, health_docs)) - .api_route("/v1/capabilities", get_with(capabilities_handler, capabilities_docs)); - - #[cfg(feature = "policy-compat")] - let router = router.api_route("/v1/policy", get_with(policy_handler, policy_docs)); - - router + .api_route("/v1/capabilities", get_with(capabilities_handler, capabilities_docs)) + .api_route("/v1/policy", get_with(policy_handler, policy_docs)) .api_route( "/v1/package-operations/evaluate", post_with(evaluate_handler, evaluate_docs) @@ -111,7 +95,6 @@ pub fn openapi() -> OpenApi { }); let _ = api_routes().finish_api(&mut api); - #[cfg(feature = "policy-compat")] register_policy_schema(&mut api); api } @@ -122,7 +105,6 @@ fn openapi_schema_generator() -> SchemaGenerator { SchemaSettings::openapi3().into() } -#[cfg(feature = "policy-compat")] fn register_policy_schema(api: &mut OpenApi) { use std::collections::BTreeMap; @@ -161,7 +143,6 @@ fn register_policy_schema(api: &mut OpenApi) { } } -#[cfg(feature = "policy-compat")] fn rewrite_policy_schema_refs( schema: serde_json::Value, renames: &std::collections::BTreeMap, @@ -205,7 +186,6 @@ async fn capabilities_handler(State(server): State) - Json(server.capabilities().await) } -#[cfg(feature = "policy-compat")] async fn policy_handler(State(server): State) -> Response { broker_result(server.policy().await) } @@ -273,12 +253,9 @@ fn capabilities_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { .response::<200, Json>() } -#[cfg(feature = "policy-compat")] fn policy_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { op.summary("Get active policy") - .description( - "Returns the active parsed policy document. A 404 response means policy inspection is unsupported.", - ) + .description("Returns the active parsed policy document. A 404 response means no active policy is configured.") .response::<200, Json>() .response::<404, Json>() .default_response::>() @@ -322,7 +299,7 @@ fn cancel_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { .response::<404, Json>() } -#[cfg(all(test, feature = "policy-compat"))] +#[cfg(test)] mod tests { use super::openapi; diff --git a/policies/rust/now-policy-server-template/tests/sample_documents.rs b/policies/rust/now-policy-server-template/tests/sample_documents.rs index 5c27101..83ffb91 100644 --- a/policies/rust/now-policy-server-template/tests/sample_documents.rs +++ b/policies/rust/now-policy-server-template/tests/sample_documents.rs @@ -6,48 +6,13 @@ use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use now_policy_server_template::{ API_VERSION_STR, CancelRequest, CancelResponse, CapabilitiesResponse, CapabilitiesResponseKind, DEFAULT_PIPE_NAME, - EvaluationResponse, ExecutionResponse, HealthResponse, HealthResponseKind, HealthStatus, MAX_REQUEST_BODY_BYTES, - ManagerName, MockPackageBrokerServer, Operation, PackageBrokerServer, PackageRequest, Scope, StatusRequest, + ErrorCode, ErrorResponse, ErrorResponseKind, EvaluationResponse, ExecutionResponse, HealthResponse, + HealthResponseKind, HealthStatus, MAX_REQUEST_BODY_BYTES, ManagerName, MockPackageBrokerServer, Operation, + PackageBrokerServer, PackageRequest, PolicyResponse, PolicyResponseKind, Scope, ServerContext, StatusRequest, StatusRequestKind, StatusResponse, Transport, api_router, }; use tower::ServiceExt; -#[cfg(feature = "policy-compat")] -use now_policy_server_template::{ - ErrorCode, ErrorResponse, ErrorResponseKind, PolicyResponse, PolicyResponseKind, ServerContext, -}; - -#[cfg(feature = "policy-compat")] -struct DefaultPolicyServer(MockPackageBrokerServer); - -#[cfg(feature = "policy-compat")] -#[async_trait::async_trait] -impl PackageBrokerServer for DefaultPolicyServer { - async fn health(&self) -> HealthResponse { - self.0.health().await - } - - async fn capabilities(&self) -> CapabilitiesResponse { - self.0.capabilities().await - } - - async fn evaluate(&self, request: PackageRequest) -> Result { - self.0.evaluate(request).await - } - - async fn execute(&self, request: PackageRequest) -> Result { - self.0.execute(request).await - } - - async fn status(&self, request: StatusRequest) -> Result { - self.0.status(request).await - } - - async fn cancel(&self, request: CancelRequest) -> Result { - self.0.cancel(request).await - } -} - fn samples_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/samples") } @@ -109,11 +74,8 @@ fn assert_response_sample_deserializes(path: &Path) { let _: CapabilitiesResponse = serde_json::from_value(load_json_file(path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); } else if name.starts_with("policy") { - #[cfg(feature = "policy-compat")] - { - let _: PolicyResponse = serde_json::from_value(load_json_file(path)) - .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); - } + let _: PolicyResponse = serde_json::from_value(load_json_file(path)) + .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); } else { let _: EvaluationResponse = serde_json::from_value(load_json_file(path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); @@ -194,7 +156,6 @@ fn capabilities_response_sample_matches_api_contract() { assert!(winget.supports_details); } -#[cfg(feature = "policy-compat")] #[test] fn policy_response_sample_matches_api_contract() { let content = load_text_file(&response_sample_path("policy.response.json")); @@ -342,7 +303,6 @@ async fn mock_health_and_capabilities_match_response_samples() { assert_eq!(actual_capabilities.managers.len(), expected_capabilities.managers.len()); } -#[cfg(feature = "policy-compat")] #[tokio::test] async fn mock_server_returns_registered_policy_response() { let content = load_text_file(&response_sample_path("policy.response.json")); @@ -356,18 +316,6 @@ async fn mock_server_returns_registered_policy_response() { assert_eq!(actual.policy.metadata.revision, expected.policy.metadata.revision); } -#[cfg(feature = "policy-compat")] -#[tokio::test] -async fn package_broker_server_default_policy_method_is_source_compatible() { - let server = DefaultPolicyServer(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); - - let error = server.policy().await.unwrap_err(); - - assert_eq!(error.code, ErrorCode::NotFound); - assert_eq!(error.response_kind, ErrorResponseKind); - assert_eq!(error.server.transport, Transport::HttpNamedPipe); -} - #[tokio::test] async fn api_router_dispatches_to_package_broker_server() { let request_path = samples_dir().join("requests/winget-vscode-install.request.json"); @@ -480,7 +428,6 @@ async fn api_router_maps_broker_errors_to_http_status() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } -#[cfg(feature = "policy-compat")] #[tokio::test] async fn api_router_returns_active_policy_as_json() { let content = load_text_file(&response_sample_path("policy.response.json")); @@ -508,9 +455,8 @@ async fn api_router_returns_active_policy_as_json() { assert_eq!(&*actual.policy.metadata.id, &*expected.policy.metadata.id); } -#[cfg(feature = "policy-compat")] #[tokio::test] -async fn api_router_returns_structured_not_found_when_policy_inspection_is_unsupported() { +async fn api_router_returns_structured_not_found_when_no_policy_is_configured() { let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); let response = app @@ -532,7 +478,6 @@ async fn api_router_returns_structured_not_found_when_policy_inspection_is_unsup assert_eq!(error.code, ErrorCode::NotFound); } -#[cfg(feature = "policy-compat")] #[tokio::test] async fn api_router_preserves_supported_policy_failure() { let error = ErrorResponse { @@ -566,7 +511,6 @@ async fn api_router_preserves_supported_policy_failure() { assert_eq!(error.code, ErrorCode::BrokerPaused); } -#[cfg(feature = "policy-compat")] #[tokio::test] async fn api_router_does_not_expose_a_policy_write_route() { let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); From 032a8f2376bf4ef32e94f04fe672651dd22227a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 26 Aug 2026 21:08:48 +0900 Subject: [PATCH 11/11] refactor(now-policy-server): clarify policy accessor Rename the server trait method to active_policy so it remains distinct from a future replace_policy operation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- policies/rust/now-policy-server-template/README.md | 4 ++-- policies/rust/now-policy-server-template/src/mock.rs | 2 +- policies/rust/now-policy-server-template/src/server.rs | 4 ++-- .../rust/now-policy-server-template/tests/sample_documents.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/policies/rust/now-policy-server-template/README.md b/policies/rust/now-policy-server-template/README.md index f20e3c8..3e3f484 100644 --- a/policies/rust/now-policy-server-template/README.md +++ b/policies/rust/now-policy-server-template/README.md @@ -39,14 +39,14 @@ Runtime implementations implement: pub trait PackageBrokerServer: Send + Sync { async fn health(&self) -> HealthResponse; async fn capabilities(&self) -> CapabilitiesResponse; - async fn policy(&self) -> Result; + async fn active_policy(&self) -> Result; async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; async fn status(&self, request: StatusRequest) -> Result; } ``` -Implementations return the active policy from `policy`. A broker with no active policy may return a structured `NotFound` error. +Implementations return the active policy from `active_policy`. A broker with no active policy may return a structured `NotFound` error. Then they pass the implementation to `api_router` or `api_router_from_shared`. The template owns the HTTP paths: diff --git a/policies/rust/now-policy-server-template/src/mock.rs b/policies/rust/now-policy-server-template/src/mock.rs index 80d11cd..32ae256 100644 --- a/policies/rust/now-policy-server-template/src/mock.rs +++ b/policies/rust/now-policy-server-template/src/mock.rs @@ -117,7 +117,7 @@ impl PackageBrokerServer for MockPackageBrokerServer { self.capabilities.clone() } - async fn policy(&self) -> Result { + async fn active_policy(&self) -> Result { if let Some(response) = &self.policy_response { return Ok(response.clone()); } diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index 54926de..63d3fe5 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -26,7 +26,7 @@ pub const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; pub trait PackageBrokerServer: Send + Sync { async fn health(&self) -> HealthResponse; async fn capabilities(&self) -> CapabilitiesResponse; - async fn policy(&self) -> Result; + async fn active_policy(&self) -> Result; async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; async fn status(&self, request: StatusRequest) -> Result; @@ -187,7 +187,7 @@ async fn capabilities_handler(State(server): State) - } async fn policy_handler(State(server): State) -> Response { - broker_result(server.policy().await) + broker_result(server.active_policy().await) } async fn evaluate_handler( diff --git a/policies/rust/now-policy-server-template/tests/sample_documents.rs b/policies/rust/now-policy-server-template/tests/sample_documents.rs index 83ffb91..37f31df 100644 --- a/policies/rust/now-policy-server-template/tests/sample_documents.rs +++ b/policies/rust/now-policy-server-template/tests/sample_documents.rs @@ -309,7 +309,7 @@ async fn mock_server_returns_registered_policy_response() { let expected: PolicyResponse = serde_json::from_str(&content).unwrap(); let server = MockPackageBrokerServer::new(DEFAULT_PIPE_NAME).with_policy_response(expected.clone()); - let actual = server.policy().await.unwrap(); + let actual = server.active_policy().await.unwrap(); assert_eq!(actual.response_kind, expected.response_kind); assert_eq!(&*actual.policy.metadata.id, &*expected.policy.metadata.id);