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..7afcb4d 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. @@ -19,14 +21,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()); @@ -34,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) : @@ -43,6 +48,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 +62,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) : @@ -65,10 +72,20 @@ 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( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, WriteIndented = false)] [JsonSerializable(typeof(PackageRequest))] [JsonSerializable(typeof(StatusRequest))] @@ -87,6 +104,7 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, WriteIndented = false, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(PackageRequest))] @@ -102,4 +120,21 @@ 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( + Converters = new[] { typeof(ExactCaseTransportConverter) }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, + WriteIndented = false)] +[JsonSerializable(typeof(PolicyResponse))] +internal sealed partial class BrokerPolicyJsonSerializerContext : JsonSerializerContext; + +[JsonSourceGenerationOptions( + Converters = new[] { typeof(ExactCaseTransportConverter) }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, + 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/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs index 4959a6c..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,6 +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 class ExactCaseStringEnumConverter : JsonConverter + where TEnum : struct, Enum +{ + 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 diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 3898aa8..4180214 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,11 +25,11 @@ 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. -- `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 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 @@ -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..3d59925 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs @@ -1,7 +1,36 @@ 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")] + [JsonRequired] + public string ResponseVersion { get; set; } = BrokerApi.Version; + + [JsonPropertyName("Server")] + [JsonRequired] + public ServerContext Server { get; set; } = new(); + + [JsonPropertyName("Policy")] + [JsonRequired] + public PolicyDocument Policy { get; set; } = new(); +} + /// Canonical response returned by the broker after evaluating a request. public sealed class EvaluationResponse { @@ -95,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 8cd59ea..00b57e6 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; @@ -299,6 +300,182 @@ 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); + } + + [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); + } + + [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")] + 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() + { + 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); + } + + [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); + + 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("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("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")] + 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() { @@ -319,6 +496,70 @@ 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('.'); + 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 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; + foreach (var segment in segments[..^1]) + { + parent = int.TryParse(segment, out var index) + ? parent.AsArray()[index]! + : parent[segment]!; + } + + if (int.TryParse(segments[^1], out var finalIndex)) + { + Assert.NotNull(parent.AsArray()[finalIndex]); + parent.AsArray()[finalIndex] = value; + } + else + { + Assert.NotNull(parent.AsObject()[segments[^1]]); + parent.AsObject()[segments[^1]] = value; + } + } + + 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; @@ -340,6 +581,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..dfd4539 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs @@ -1,4 +1,7 @@ using System.Text.Json; +using System.Text.Json.Nodes; + +using Devolutions.Now.Policy.Model; using Xunit; @@ -37,6 +40,124 @@ 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 void PolicyResponse_requires_policy_on_deserialization() + { + const string json = + """ + {"ResponseKind":"PolicyResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"}} + """; + + 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)); + } + + [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() + { + 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() { @@ -73,6 +194,29 @@ 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]!; + } + + 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) { var schema = await TestData.SchemaAsync(componentName); 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..a30dec5 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -72,6 +72,18 @@ 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", + strictSuccessBody: true); + } + /// Evaluate a package operation against policy without executing it (dry-run). public async Task Evaluate(PackageOperationRequest request, CancellationToken cancellationToken = default) { @@ -414,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)) { @@ -447,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 043f6f3..72d3ac9 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 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. @@ -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/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 1a7c3ac..5ba4221 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,76 @@ 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())); + } + + [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())); + } + + [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); @@ -163,4 +234,41 @@ 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}"); + } + + 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]!; + } + + 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/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..8b4655f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/Enums.cs @@ -1,9 +1,39 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace Devolutions.Now.Policy.Model; +internal sealed class ExactCaseStringEnumConverter : JsonConverter + where TEnum : struct, Enum +{ + 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(JsonStringEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Operation { Install, @@ -12,7 +42,7 @@ public enum Operation } /// Supported package manager names. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum ManagerName { Winget, @@ -35,7 +65,7 @@ public enum ManagerName } /// Installation scope. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Scope { User, @@ -43,7 +73,7 @@ public enum Scope } /// Target architecture. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Architecture { X86, @@ -53,7 +83,7 @@ public enum Architecture } /// Requested elevation level. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Elevation { Standard, @@ -61,7 +91,7 @@ public enum Elevation } /// Policy decision. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum Decision { Allow, @@ -69,7 +99,7 @@ public enum Decision } /// Rule precedence strategy. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(ExactCaseStringEnumConverter))] public enum RulePrecedence { PriorityThenDeny, diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs index 21d8100..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) : @@ -56,7 +114,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 +128,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/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/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index 9613138..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. +- `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. 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..1138613 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,29 @@ 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: + default: + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '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' /v1/package-operations/evaluate: post: summary: Evaluate package operation @@ -827,6 +850,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 +1153,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..7eec719 100644 --- a/policies/rust/now-policy-server-template/README.md +++ b/policies/rust/now-policy-server-template/README.md @@ -39,16 +39,22 @@ 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; async fn status(&self, request: StatusRequest) -> Result; } ``` +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` - `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 +64,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 +86,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..af14e3c 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,20 +144,62 @@ 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 { Json(server.health().await) } @@ -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,17 @@ 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>() + .default_response::>() +} + fn evaluate_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { op.summary("Evaluate package operation") .description("Evaluates a package operation against policy without requiring elevated execution.") @@ -240,3 +324,55 @@ 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}" + ); + } + } + + #[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" + ); + } +} 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..5c27101 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,20 @@ 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); +} + #[test] fn invalid_request_missing_package_id_fails_deserialization() { let path = samples_dir().join("requests/missing-package-id.request.json"); @@ -286,6 +342,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 +480,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!");