From 8561d58467e066fda1ec162072fbb216b7eef26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 29 Aug 2026 02:52:42 +0900 Subject: [PATCH 1/4] feat!: add package policy management contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 - .../Devolutions.Now.Policy.Api/BrokerApi.cs | 5 + .../Devolutions.Now.Policy.Api/BrokerJson.cs | 105 +++- .../Devolutions.Now.Policy.Api/Enums.cs | 14 +- .../Devolutions.Now.Policy.Api/MetaModels.cs | 4 + .../PolicyManagementModels.cs | 405 ++++++++++++ .../Devolutions.Now.Policy.Api/README.md | 3 +- .../DtoRoundTripTests.cs | 32 + .../MetaModelTests.cs | 11 + .../PolicyManagementClientTests.cs | 237 +++++++ .../TestData.cs | 28 +- .../BrokerClient.cs | 77 +++ .../Devolutions.Now.Policy.Client/README.md | 3 + .../PolicyTests.cs | 67 +- .../Devolutions.Now.Policy.Model.csproj | 4 - .../PolicyJson.cs | 61 +- .../PolicyModels.cs | 248 ++++++-- .../Devolutions.Now.Policy.Model/README.md | 11 +- policies/rust/now-policy-api/CHANGELOG.md | 6 + policies/rust/now-policy-api/README.md | 5 +- .../openapi/now-policy-api.yaml | 585 +++++++++++++++++- policies/rust/now-policy-api/src/api.rs | 4 + policies/rust/now-policy-api/src/enums.rs | 11 + policies/rust/now-policy-api/src/lib.rs | 12 + .../rust/now-policy-api/src/management.rs | 445 +++++++++++++ policies/rust/now-policy-api/src/policy.rs | 8 +- .../now-policy-server-template/CHANGELOG.md | 6 + .../rust/now-policy-server-template/README.md | 6 + .../now-policy-server-template/src/server.rs | 169 ++++- .../tests/sample_documents.rs | 156 ++++- .../tests/support/mock.rs | 67 +- policies/rust/now-policy/CHANGELOG.md | 8 + policies/rust/now-policy/Cargo.toml | 1 - policies/rust/now-policy/README.md | 6 +- .../samples/corporate-allowlist.policy.yaml | 52 -- .../schema/devolutions.now-policy.schema.json | 51 +- policies/rust/now-policy/src/policy.rs | 202 +++++- policies/rust/now-policy/src/schema.rs | 21 +- .../rust/now-policy/tests/policy_samples.rs | 73 ++- .../policy-replacement.create.request.json | 17 + .../policy-replacement.overwrite.request.json | 17 + .../policy-replacement.repair.request.json | 17 + ...-replacement.replace-identity.request.json | 17 + .../policy-replacement.update.request.json | 17 + .../requests/policy-validation.request.json | 22 + .../policy-management.active.response.json | 32 + .../policy-management.invalid.response.json | 33 + .../policy-management.missing.response.json | 16 + .../policy-replacement.response.json | 69 +++ .../responses/policy-stale-token.error.json | 27 + .../policy-validation.invalid.response.json | 30 + .../policy-validation.valid.response.json | 68 ++ .../scenarios/baseline.scenarios.json | 16 - 53 files changed, 3326 insertions(+), 282 deletions(-) create mode 100644 policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs create mode 100644 policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs create mode 100644 policies/rust/now-policy-api/src/management.rs delete mode 100644 policies/rust/now-policy/assets/samples/corporate-allowlist.policy.yaml create mode 100644 policies/test-data/package-broker/requests/policy-replacement.create.request.json create mode 100644 policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json create mode 100644 policies/test-data/package-broker/requests/policy-replacement.repair.request.json create mode 100644 policies/test-data/package-broker/requests/policy-replacement.replace-identity.request.json create mode 100644 policies/test-data/package-broker/requests/policy-replacement.update.request.json create mode 100644 policies/test-data/package-broker/requests/policy-validation.request.json create mode 100644 policies/test-data/package-broker/responses/policy-management.active.response.json create mode 100644 policies/test-data/package-broker/responses/policy-management.invalid.response.json create mode 100644 policies/test-data/package-broker/responses/policy-management.missing.response.json create mode 100644 policies/test-data/package-broker/responses/policy-replacement.response.json create mode 100644 policies/test-data/package-broker/responses/policy-stale-token.error.json create mode 100644 policies/test-data/package-broker/responses/policy-validation.invalid.response.json create mode 100644 policies/test-data/package-broker/responses/policy-validation.valid.response.json diff --git a/Cargo.lock b/Cargo.lock index 7177c60..06e8e1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,7 +619,6 @@ dependencies = [ "semver", "serde", "serde_json", - "serde_yaml", "thiserror", "url", ] diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs index 1c2e5f3..12b5736 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs @@ -12,6 +12,8 @@ public static class BrokerApi public const string PackageRequestKind = "PackageRequest"; public const string StatusRequestKind = "StatusRequest"; public const string CancelRequestKind = "CancelRequest"; + public const string PolicyValidationRequestKind = "PolicyValidationRequest"; + public const string PolicyReplacementRequestKind = "PolicyReplacementRequest"; public const string HealthResponseKind = "HealthResponse"; public const string CapabilitiesResponseKind = "CapabilitiesResponse"; @@ -20,6 +22,9 @@ public static class BrokerApi public const string StatusResponseKind = "StatusResponse"; public const string CancelResponseKind = "CancelResponse"; public const string PolicyResponseKind = "PolicyResponse"; + public const string PolicyManagementResponseKind = "PolicyManagementResponse"; + public const string PolicyValidationResponseKind = "PolicyValidationResponse"; + public const string PolicyReplacementResponseKind = "PolicyReplacementResponse"; 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 7afcb4d..97c7f0d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -28,15 +28,39 @@ public static class BrokerJson public static string Serialize(T value) => JsonSerializer.Serialize(value, TypeInfo()); - public static T? Deserialize(string json) => - JsonSerializer.Deserialize(json, TypeInfo()); + public static T? Deserialize(string json) + { + var value = JsonSerializer.Deserialize(json, TypeInfo()); + if (value is ErrorResponse { Validation: { } validation }) + { + ValidateValidation(validation); + } + + return value; + } public static T? DeserializeStrict(string json) { var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); - if (value is PolicyResponse response) + switch (value) { - PolicyJson.ValidateRequiredCollectionElements(response.Policy); + case PolicyResponse response: + PolicyJson.ValidateRequiredCollectionElements(response.Policy); + break; + case PolicyManagementResponse response: + ValidateManagement(response.Management); + break; + case PolicyValidationResponse response: + ValidateValidation(response.Validation); + break; + case PolicyReplacementResponse response: + PolicyJson.ValidateRequiredCollectionElements(response.Policy); + ValidateValidation(response.Validation); + ValidateManagement(response.Management); + break; + case ErrorResponse { Validation: { } validation }: + ValidateValidation(validation); + break; } return value; @@ -46,39 +70,77 @@ private static JsonTypeInfo TypeInfo() => typeof(T) == typeof(PackageRequest) ? Cast(BrokerJsonSerializerContext.Default.PackageRequest) : typeof(T) == typeof(StatusRequest) ? Cast(BrokerJsonSerializerContext.Default.StatusRequest) : typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonSerializerContext.Default.CancelRequest) : + typeof(T) == typeof(PolicyValidationRequest) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyValidationRequest) : + typeof(T) == typeof(PolicyReplacementRequest) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyReplacementRequest) : 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(PolicyManagementResponse) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyManagementResponse) : + typeof(T) == typeof(PolicyValidationResponse) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyValidationResponse) : + typeof(T) == typeof(PolicyReplacementResponse) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyReplacementResponse) : typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonSerializerContext.Default.EvaluationResponse) : typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonSerializerContext.Default.ExecutionResponse) : typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonSerializerContext.Default.StatusResponse) : typeof(T) == typeof(CancelResponse) ? Cast(BrokerJsonSerializerContext.Default.CancelResponse) : - typeof(T) == typeof(ErrorResponse) ? Cast(BrokerJsonSerializerContext.Default.ErrorResponse) : + typeof(T) == typeof(ErrorResponse) ? Cast(BrokerErrorJsonSerializerContext.Default.ErrorResponse) : throw new NotSupportedException($"Broker JSON serialization for {typeof(T).FullName} is not source-generated."); private static JsonTypeInfo StrictTypeInfo() => typeof(T) == typeof(PackageRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.PackageRequest) : typeof(T) == typeof(StatusRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.StatusRequest) : typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.CancelRequest) : + typeof(T) == typeof(PolicyValidationRequest) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyValidationRequest) : + typeof(T) == typeof(PolicyReplacementRequest) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyReplacementRequest) : 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(PolicyManagementResponse) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyManagementResponse) : + typeof(T) == typeof(PolicyValidationResponse) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyValidationResponse) : + typeof(T) == typeof(PolicyReplacementResponse) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyReplacementResponse) : typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.EvaluationResponse) : typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.ExecutionResponse) : typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.StatusResponse) : typeof(T) == typeof(CancelResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.CancelResponse) : - typeof(T) == typeof(ErrorResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.ErrorResponse) : + typeof(T) == typeof(ErrorResponse) ? Cast(BrokerErrorJsonStrictSerializerContext.Default.ErrorResponse) : throw new NotSupportedException($"Strict broker JSON deserialization for {typeof(T).FullName} is not source-generated."); private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) => (JsonTypeInfo)jsonTypeInfo; + private static void ValidateManagement(PolicyManagementSnapshot management) + { + if (management.Policy is { } policy) + { + PolicyJson.ValidateRequiredCollectionElements(policy); + } + } + + private static void ValidateValidation(PolicyValidationResult validation) + { + if (validation.IsValid) + { + if (validation.CanonicalDraft is null || validation.ValidationReceipt is null) + { + throw new JsonException( + "Valid policy validation results require CanonicalDraft and ValidationReceipt."); + } + + PolicyJson.ValidateRequiredCollectionElements(validation.CanonicalDraft); + } + else if (validation.CanonicalDraft is not null || validation.ValidationReceipt is not null) + { + throw new JsonException( + "Invalid policy validation results must not contain CanonicalDraft or ValidationReceipt."); + } + } + private static JsonSerializerOptions CreateOptions(bool writeIndented) => new(BrokerJsonSerializerContext.Default.Options) { TypeInfoResolver = JsonTypeInfoResolver.Combine( BrokerJsonSerializerContext.Default, - BrokerPolicyJsonSerializerContext.Default), + BrokerPolicyJsonSerializerContext.Default, + BrokerErrorJsonSerializerContext.Default), WriteIndented = writeIndented, }; } @@ -96,7 +158,6 @@ private static JsonSerializerOptions CreateOptions(bool writeIndented) => [JsonSerializable(typeof(ExecutionResponse))] [JsonSerializable(typeof(StatusResponse))] [JsonSerializable(typeof(CancelResponse))] -[JsonSerializable(typeof(ErrorResponse))] [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(JsonObject))] [JsonSerializable(typeof(JsonArray))] @@ -116,7 +177,6 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex [JsonSerializable(typeof(ExecutionResponse))] [JsonSerializable(typeof(StatusResponse))] [JsonSerializable(typeof(CancelResponse))] -[JsonSerializable(typeof(ErrorResponse))] [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(JsonObject))] [JsonSerializable(typeof(JsonArray))] @@ -128,6 +188,11 @@ internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializer RespectNullableAnnotations = true, WriteIndented = false)] [JsonSerializable(typeof(PolicyResponse))] +[JsonSerializable(typeof(PolicyManagementResponse))] +[JsonSerializable(typeof(PolicyValidationRequest))] +[JsonSerializable(typeof(PolicyValidationResponse))] +[JsonSerializable(typeof(PolicyReplacementRequest))] +[JsonSerializable(typeof(PolicyReplacementResponse))] internal sealed partial class BrokerPolicyJsonSerializerContext : JsonSerializerContext; [JsonSourceGenerationOptions( @@ -137,4 +202,24 @@ internal sealed partial class BrokerPolicyJsonSerializerContext : JsonSerializer WriteIndented = false, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(PolicyResponse))] -internal sealed partial class BrokerPolicyJsonStrictSerializerContext : JsonSerializerContext; \ No newline at end of file +[JsonSerializable(typeof(PolicyManagementResponse))] +[JsonSerializable(typeof(PolicyValidationRequest))] +[JsonSerializable(typeof(PolicyValidationResponse))] +[JsonSerializable(typeof(PolicyReplacementRequest))] +[JsonSerializable(typeof(PolicyReplacementResponse))] +internal sealed partial class BrokerPolicyJsonStrictSerializerContext : JsonSerializerContext; + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, + WriteIndented = false)] +[JsonSerializable(typeof(ErrorResponse))] +internal sealed partial class BrokerErrorJsonSerializerContext : JsonSerializerContext; + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectNullableAnnotations = true, + WriteIndented = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] +[JsonSerializable(typeof(ErrorResponse))] +internal sealed partial class BrokerErrorJsonStrictSerializerContext : 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 38fe46e..36aecd3 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs @@ -36,6 +36,7 @@ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOpt } internal sealed class ExactCaseTransportConverter : ExactCaseStringEnumConverter; +internal sealed class ExactCaseErrorCodeConverter : ExactCaseStringEnumConverter; /// Package operation type. [JsonConverter(typeof(JsonStringEnumConverter))] @@ -132,7 +133,7 @@ public enum HealthStatus } /// Structured machine-readable error code. -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(ExactCaseErrorCodeConverter))] public enum ErrorCode { BadRequest, @@ -146,6 +147,17 @@ public enum ErrorCode BrokerPaused, InternalError, Timeout, + UnsupportedEndpoint, + MalformedDraft, + InvalidPolicy, + WarningConfirmationRequired, + Unauthenticated, + AdministratorRequired, + UnsafePolicyPath, + StalePolicyStoreToken, + UnsupportedPolicyFilesystem, + PolicyPersistenceFailed, + PolicyActivationFailed, } /// Transport kind of a per-operation event channel. diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs index 871ea38..1d14021 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs @@ -126,6 +126,10 @@ public string ResponseKind [JsonPropertyName("Details")] public List Details { get; set; } = []; + /// Current authoritative policy findings for management errors. + [JsonPropertyName("Validation")] + public PolicyValidationResult? Validation { get; set; } + } public sealed class ErrorDetail diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs new file mode 100644 index 0000000..f252951 --- /dev/null +++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs @@ -0,0 +1,405 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Devolutions.Now.Policy.Model; + +namespace Devolutions.Now.Policy.Api; + +internal sealed class ExactCasePolicyManagementStateConverter : ExactCaseStringEnumConverter; +internal sealed class ExactCasePolicyConfigurationSourceConverter : ExactCaseStringEnumConverter; +internal sealed class ExactCasePolicyWriteCapabilityConverter : ExactCaseStringEnumConverter; +internal sealed class ExactCasePolicyReadOnlyReasonConverter : ExactCaseStringEnumConverter; +internal sealed class ExactCasePolicyReplacementOperationConverter : ExactCaseStringEnumConverter; +internal sealed class ExactCasePolicyConflictHandlingConverter : ExactCaseStringEnumConverter; +internal sealed class ExactCasePolicyFindingSeverityConverter : ExactCaseStringEnumConverter; +internal sealed class ExactCasePolicyFindingCodeConverter : ExactCaseStringEnumConverter; + +internal abstract class BoundedStringJsonConverter(int maxLength, string typeName) : JsonConverter +{ + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException($"{typeName} must be a string."); + } + + return Validate(reader.GetString()); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + => writer.WriteStringValue(Validate(value)); + + private string Validate(string? value) + { + if (string.IsNullOrEmpty(value) || value.Length > maxLength) + { + throw new JsonException($"{typeName} must contain between 1 and {maxLength} characters."); + } + + return value; + } +} + +internal sealed class PolicyStoreTokenJsonConverter() + : BoundedStringJsonConverter(512, "PolicyStoreToken"); + +internal sealed class PolicyValidationReceiptJsonConverter() + : BoundedStringJsonConverter(2048, "PolicyValidationReceipt"); + +/// Current configured-policy state. +[JsonConverter(typeof(ExactCasePolicyManagementStateConverter))] +public enum PolicyManagementState +{ + Active, + Missing, + Invalid, +} + +/// Origin of the resolved policy path. +[JsonConverter(typeof(ExactCasePolicyConfigurationSourceConverter))] +public enum PolicyConfigurationSource +{ + DefaultPath, + ConfiguredPath, +} + +/// Advisory ability to write the configured policy through the management API. +[JsonConverter(typeof(ExactCasePolicyWriteCapabilityConverter))] +public enum PolicyWriteCapability +{ + Writable, + ReadOnly, + Unsupported, +} + +/// Stable reason why the configured policy cannot be written. +[JsonConverter(typeof(ExactCasePolicyReadOnlyReasonConverter))] +public enum PolicyReadOnlyReason +{ + ManagementDisabled, + PathNotConfigured, + UnsafePath, + InsufficientPermissions, + UnsupportedFileSystem, +} + +/// Requested identity/revision behavior for a policy replacement. +[JsonConverter(typeof(ExactCasePolicyReplacementOperationConverter))] +public enum PolicyReplacementOperation +{ + Update, + ReplaceIdentity, + Create, + Repair, +} + +/// Optimistic-conflict behavior for policy replacement. +[JsonConverter(typeof(ExactCasePolicyConflictHandlingConverter))] +public enum PolicyConflictHandling +{ + Reject, + ConfirmOverwrite, +} + +/// Severity of a policy validation finding. +[JsonConverter(typeof(ExactCasePolicyFindingSeverityConverter))] +public enum PolicyFindingSeverity +{ + Error, + Warning, +} + +/// Stable policy validation finding code. +[JsonConverter(typeof(ExactCasePolicyFindingCodeConverter))] +public enum PolicyFindingCode +{ + SchemaViolation, + UnknownField, + MissingRequiredField, + InvalidFieldType, + InvalidFieldValue, + DuplicateRuleId, + IneffectiveBooleanMatch, + InvalidVersionRange, + EmptyVersionRange, + InvalidWildcardPattern, + ContradictoryConstraints, + InvalidValidityInterval, + UnsupportedSchema, + UnsupportedPolicyType, + UnsupportedPolicyVersion, + AuditModeEnabled, + DefaultAllow, + SensitiveOptionAllowed, +} + +/// Versioned, structured policy validation finding. +public sealed class PolicyFinding +{ + [JsonPropertyName("FindingVersion")] + [JsonRequired] + public string FindingVersion { get; set; } = BrokerApi.Version; + + [JsonPropertyName("Severity")] + [JsonRequired] + public PolicyFindingSeverity Severity { get; set; } + + [JsonPropertyName("Code")] + [JsonRequired] + public PolicyFindingCode Code { get; set; } + + /// RFC 6901 JSON Pointer into the submitted draft. + [JsonPropertyName("Path")] + [JsonRequired] + public string Path { get; set; } = ""; + + [JsonPropertyName("RuleId")] + public string? RuleId { get; set; } + + /// Machine-readable message arguments for localization. + [JsonPropertyName("Arguments")] + public Dictionary Arguments { get; set; } = []; + + /// Human-readable fallback for clients that do not recognize the code. + [JsonPropertyName("Message")] + [JsonRequired] + public string Message { get; set; } = ""; +} + +/// Authoritative policy validation output. +public sealed class PolicyValidationResult +{ + [JsonPropertyName("ResultVersion")] + [JsonRequired] + public string ResultVersion { get; set; } = BrokerApi.Version; + + [JsonPropertyName("ValidatorVersion")] + [JsonRequired] + public string ValidatorVersion { get; set; } = ""; + + [JsonPropertyName("IsValid")] + [JsonRequired] + public bool IsValid { get; set; } + + [JsonPropertyName("CanonicalDraft")] + public PolicyDraftDocument? CanonicalDraft { get; set; } + + [JsonPropertyName("ValidationReceipt")] + [JsonConverter(typeof(PolicyValidationReceiptJsonConverter))] + public string? ValidationReceipt { get; set; } + + [JsonPropertyName("Findings")] + [JsonRequired] + public List Findings { get; set; } = []; +} + +/// Sanitized diagnostics for an invalid configured policy. +public sealed class InvalidPolicyDiagnostics +{ + [JsonPropertyName("DiagnosticsVersion")] + [JsonRequired] + public string DiagnosticsVersion { get; set; } = BrokerApi.Version; + + [JsonPropertyName("Findings")] + [JsonRequired] + public List Findings { get; set; } = []; +} + +/// Atomic view of configured policy state and management guidance. +public sealed class PolicyManagementSnapshot +{ + [JsonPropertyName("State")] + [JsonRequired] + public PolicyManagementState State { get; set; } + + [JsonPropertyName("ConfiguredPath")] + [JsonRequired] + public string ConfiguredPath { get; set; } = ""; + + [JsonPropertyName("StoreToken")] + [JsonRequired] + [JsonConverter(typeof(PolicyStoreTokenJsonConverter))] + public string StoreToken { get; set; } = ""; + + [JsonPropertyName("Source")] + [JsonRequired] + public PolicyConfigurationSource Source { get; set; } + + [JsonPropertyName("WriteCapability")] + [JsonRequired] + public PolicyWriteCapability WriteCapability { get; set; } + + [JsonPropertyName("ReadOnlyReason")] + public PolicyReadOnlyReason? ReadOnlyReason { get; set; } + + [JsonPropertyName("ElevationRequired")] + [JsonRequired] + public bool ElevationRequired { get; set; } + + [JsonPropertyName("Policy")] + public PolicyDocument? Policy { get; set; } + + [JsonPropertyName("InvalidDiagnostics")] + public InvalidPolicyDiagnostics? InvalidDiagnostics { get; set; } +} + +/// Response body for GET /v1/policy/management. +public sealed class PolicyManagementResponse +{ + private const string Kind = BrokerApi.PolicyManagementResponseKind; + 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("Management")] + [JsonRequired] + public PolicyManagementSnapshot Management { get; set; } = new(); +} + +/// Request body for POST /v1/policy/validate. +public sealed class PolicyValidationRequest +{ + private const string Kind = BrokerApi.PolicyValidationRequestKind; + private string _requestKind = Kind; + + [JsonPropertyName("RequestKind")] + [JsonRequired] + public string RequestKind + { + get => _requestKind; + set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind)); + } + + [JsonPropertyName("RequestVersion")] + [JsonRequired] + public string RequestVersion { get; set; } = BrokerApi.Version; + + /// Raw draft JSON retained without dropping unknown members. + [JsonPropertyName("Draft")] + [JsonRequired] + public JsonElement Draft { get; set; } +} + +/// Response body for POST /v1/policy/validate. +public sealed class PolicyValidationResponse +{ + private const string Kind = BrokerApi.PolicyValidationResponseKind; + 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("Validation")] + [JsonRequired] + public PolicyValidationResult Validation { get; set; } = new(); +} + +/// Request body for PUT /v1/policy. +public sealed class PolicyReplacementRequest +{ + private const string Kind = BrokerApi.PolicyReplacementRequestKind; + private string _requestKind = Kind; + + [JsonPropertyName("RequestKind")] + [JsonRequired] + public string RequestKind + { + get => _requestKind; + set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind)); + } + + [JsonPropertyName("RequestVersion")] + [JsonRequired] + public string RequestVersion { get; set; } = BrokerApi.Version; + + [JsonPropertyName("ExpectedStoreToken")] + [JsonRequired] + [JsonConverter(typeof(PolicyStoreTokenJsonConverter))] + public string ExpectedStoreToken { get; set; } = ""; + + [JsonPropertyName("Operation")] + [JsonRequired] + public PolicyReplacementOperation Operation { get; set; } + + [JsonPropertyName("ConflictHandling")] + [JsonRequired] + public PolicyConflictHandling ConflictHandling { get; set; } + + [JsonPropertyName("WarningsAcknowledged")] + [JsonRequired] + public bool WarningsAcknowledged { get; set; } + + /// Raw draft JSON retained for transaction-time reparsing and revalidation. + [JsonPropertyName("Draft")] + [JsonRequired] + public JsonElement Draft { get; set; } + + [JsonPropertyName("ValidationReceipt")] + [JsonRequired] + [JsonConverter(typeof(PolicyValidationReceiptJsonConverter))] + public string ValidationReceipt { get; set; } = ""; +} + +/// Response body for PUT /v1/policy. +public sealed class PolicyReplacementResponse +{ + private const string Kind = BrokerApi.PolicyReplacementResponseKind; + 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(); + + /// Exact committed active policy, including server-assigned metadata. + [JsonPropertyName("Policy")] + [JsonRequired] + public PolicyDocument Policy { get; set; } = new(); + + [JsonPropertyName("Validation")] + [JsonRequired] + public PolicyValidationResult Validation { get; set; } = new(); + + /// Newly observed management state and store token. + [JsonPropertyName("Management")] + [JsonRequired] + public PolicyManagementSnapshot Management { get; set; } = new(); +} \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index f8ff2cf..e5c03d6 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, 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. +This package contains request, response, status, health, capabilities, active-policy inspection and management, and error DTOs for package broker clients and implementations. It does not perform HTTP transport, named-pipe I/O, policy validation/evaluation, persistence, 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 @@ -26,6 +26,7 @@ Architecture - `RequestModels.cs` defines `PackageRequest` and request context/options. - `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`. +- `PolicyManagementModels.cs` defines atomic management snapshots, raw JSON draft requests, versioned validation findings/receipts, optimistic replacement intents, and management responses. - `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. diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs index 6cfd91c..843ee72 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs @@ -62,6 +62,38 @@ public async Task CapabilitiesResponse_round_trips_and_validates(string path) public async Task PolicyResponse_round_trips_and_validates(string path) => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyResponse")); + [Theory] + [MemberData(nameof(TestData.PolicyManagementResponseSamples), MemberType = typeof(TestData))] + public async Task PolicyManagementResponse_round_trips_and_validates(string path) + => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyManagementResponse")); + + [Theory] + [MemberData(nameof(TestData.PolicyValidationRequestSamples), MemberType = typeof(TestData))] + public async Task PolicyValidationRequest_round_trips_and_validates(string path) + => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyValidationRequest")); + + [Theory] + [MemberData(nameof(TestData.PolicyValidationResponseSamples), MemberType = typeof(TestData))] + public async Task PolicyValidationResponse_round_trips_and_validates(string path) + => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyValidationResponse")); + + [Theory] + [MemberData(nameof(TestData.PolicyReplacementRequestSamples), MemberType = typeof(TestData))] + public async Task PolicyReplacementRequest_round_trips_and_validates(string path) + => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyReplacementRequest")); + + [Theory] + [MemberData(nameof(TestData.PolicyReplacementResponseSamples), MemberType = typeof(TestData))] + public async Task PolicyReplacementResponse_round_trips_and_validates(string path) + => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyReplacementResponse")); + + [Fact] + public async Task PolicyManagementError_round_trips_and_validates() + { + var path = Path.Combine(TestData.SamplesDir, "responses", "policy-stale-token.error.json"); + await AssertRoundTrip(path, await TestData.SchemaAsync("ErrorResponse")); + } + 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 dfd4539..d14567b 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs @@ -108,6 +108,15 @@ public void Public_json_options_source_generate_all_broker_dtos() typeof(RequestOptions), typeof(ClientContext), typeof(PolicyResponse), + typeof(PolicyManagementResponse), + typeof(PolicyManagementSnapshot), + typeof(InvalidPolicyDiagnostics), + typeof(PolicyValidationRequest), + typeof(PolicyValidationResponse), + typeof(PolicyValidationResult), + typeof(PolicyFinding), + typeof(PolicyReplacementRequest), + typeof(PolicyReplacementResponse), typeof(EvaluationResponse), typeof(ExecutionResponse), typeof(ServerContext), @@ -127,7 +136,9 @@ public void Public_json_options_source_generate_all_broker_dtos() typeof(ErrorDetail), typeof(EventChannel), typeof(PolicyDocument), + typeof(PolicyDraftDocument), typeof(PolicyMetadata), + typeof(PolicyDraftMetadata), typeof(PolicyEnforcement), typeof(PolicyRule), typeof(PolicyMatch), diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs new file mode 100644 index 0000000..4d12a5e --- /dev/null +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -0,0 +1,237 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +using Devolutions.Now.Policy.Client; + +using Xunit; + +namespace Devolutions.Now.Policy.Client.Tests; + +public class PolicyManagementClientTests +{ + [Fact] + public async Task GetPolicyManagement_sends_json_get_and_strictly_parses_snapshot() + { + var body = await ReadFixture("responses", "policy-management.active.response.json"); + var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 200, Body = body }); + var response = await CreateClient(transport).GetPolicyManagement(); + + var request = Assert.Single(transport.Requests); + Assert.Equal("GET", request.Method); + Assert.Equal("/v1/policy/management", request.Path); + Assert.Equal(PolicyManagementState.Active, response.Management.State); + Assert.Equal("store:active:7", response.Management.StoreToken); + } + + [Fact] + public async Task ValidatePolicy_preserves_raw_unknown_fields_and_returns_exact_warnings() + { + var requestJson = await ReadFixture("requests", "policy-validation.request.json"); + using var requestDocument = JsonDocument.Parse(requestJson); + var body = await ReadFixture("responses", "policy-validation.valid.response.json"); + var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 200, Body = body }); + + var response = await CreateClient(transport).ValidatePolicy(requestDocument.RootElement.GetProperty("Draft")); + + var request = Assert.Single(transport.Requests); + Assert.Equal("POST", request.Method); + Assert.Equal("/v1/policy/validate", request.Path); + using var sent = JsonDocument.Parse(request.Body!); + Assert.True(sent.RootElement.GetProperty("Draft").GetProperty("EditorExtension").GetProperty("preserved").GetBoolean()); + Assert.Equal("receipt:sha256:valid-warning-set", response.Validation.ValidationReceipt); + Assert.Equal(3, response.Validation.Findings.Count); + } + + [Theory] + [InlineData("policy-replacement.update.request.json", PolicyReplacementOperation.Update, PolicyConflictHandling.Reject)] + [InlineData("policy-replacement.replace-identity.request.json", PolicyReplacementOperation.ReplaceIdentity, PolicyConflictHandling.Reject)] + [InlineData("policy-replacement.create.request.json", PolicyReplacementOperation.Create, PolicyConflictHandling.Reject)] + [InlineData("policy-replacement.repair.request.json", PolicyReplacementOperation.Repair, PolicyConflictHandling.Reject)] + [InlineData("policy-replacement.overwrite.request.json", PolicyReplacementOperation.Update, PolicyConflictHandling.ConfirmOverwrite)] + public async Task ReplacePolicy_sends_every_operation_intent( + string fixture, + PolicyReplacementOperation operation, + PolicyConflictHandling conflictHandling) + { + var request = BrokerJson.DeserializeStrict(await ReadFixture("requests", fixture))!; + var body = await ReadFixture("responses", "policy-replacement.response.json"); + var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 200, Body = body }); + + var response = await CreateClient(transport).ReplacePolicy(request); + + var sentRequest = Assert.Single(transport.Requests); + Assert.Equal("PUT", sentRequest.Method); + Assert.Equal("/v1/policy", sentRequest.Path); + using var sent = JsonDocument.Parse(sentRequest.Body!); + Assert.Equal(operation.ToString(), sent.RootElement.GetProperty("Operation").GetString()); + Assert.Equal(conflictHandling.ToString(), sent.RootElement.GetProperty("ConflictHandling").GetString()); + Assert.Equal(8U, response.Policy.Metadata.Revision); + Assert.Equal("store:active:8", response.Management.StoreToken); + } + + [Fact] + public async Task ReplacePolicy_preserves_structured_stale_token_findings() + { + var request = BrokerJson.DeserializeStrict( + await ReadFixture("requests", "policy-replacement.update.request.json"))!; + var errorBody = await ReadFixture("responses", "policy-stale-token.error.json"); + var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 409, Body = errorBody }); + + var exception = await Assert.ThrowsAsync( + () => CreateClient(transport).ReplacePolicy(request)); + + Assert.Equal(ErrorCode.StalePolicyStoreToken, exception.BrokerError?.Code); + Assert.Equal(PolicyFindingCode.InvalidFieldValue, exception.BrokerError?.Validation?.Findings[0].Code); + } + + [Theory] + [InlineData("management")] + [InlineData("validation")] + [InlineData("replacement")] + public async Task Management_success_responses_reject_unknown_members(string operation) + { + var (directory, fixture) = operation switch + { + "management" => ("responses", "policy-management.active.response.json"), + "validation" => ("responses", "policy-validation.valid.response.json"), + _ => ("responses", "policy-replacement.response.json"), + }; + var document = JsonNode.Parse(await ReadFixture(directory, fixture))!; + document["Unexpected"] = true; + var transport = new FakeBrokerTransport( + new BrokerTransportResponse { StatusCode = 200, Body = document.ToJsonString() }); + var client = CreateClient(transport); + + var exception = operation switch + { + "management" => await Assert.ThrowsAsync(() => client.GetPolicyManagement()), + "validation" => await Assert.ThrowsAsync( + () => client.ValidatePolicy(JsonDocument.Parse("{}").RootElement)), + _ => await Assert.ThrowsAsync( + async () => await client.ReplacePolicy(BrokerJson.DeserializeStrict( + await ReadFixture("requests", "policy-replacement.update.request.json"))!)), + }; + + Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind); + } + + [Fact] + public async Task Management_methods_propagate_cancellation() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var transport = new FakeBrokerTransport(); + var client = CreateClient(transport); + + await Assert.ThrowsAnyAsync( + () => client.GetPolicyManagement(cancellation.Token)); + await Assert.ThrowsAnyAsync( + () => client.ValidatePolicy(JsonDocument.Parse("{}").RootElement, cancellation.Token)); + Assert.Empty(transport.Requests); + } + + [Theory] + [InlineData("State", "active")] + [InlineData("State", 0)] + public async Task Strict_management_response_rejects_noncanonical_enums(string property, object value) + { + var document = JsonNode.Parse(await ReadFixture("responses", "policy-management.active.response.json"))!; + document["Management"]![property] = value is int number + ? JsonValue.Create(number) + : JsonValue.Create((string)value); + + Assert.Throws( + () => BrokerJson.DeserializeStrict(document.ToJsonString())); + } + + [Fact] + public async Task Strict_management_contract_rejects_empty_tokens_and_receipts() + { + var management = JsonNode.Parse(await ReadFixture("responses", "policy-management.active.response.json"))!; + management["Management"]!["StoreToken"] = ""; + Assert.Throws( + () => BrokerJson.DeserializeStrict(management.ToJsonString())); + + var replacement = JsonNode.Parse(await ReadFixture("requests", "policy-replacement.update.request.json"))!; + replacement["ExpectedStoreToken"] = ""; + Assert.Throws( + () => BrokerJson.DeserializeStrict(replacement.ToJsonString())); + replacement["ExpectedStoreToken"] = "store:active:7"; + replacement["ValidationReceipt"] = ""; + Assert.Throws( + () => BrokerJson.DeserializeStrict(replacement.ToJsonString())); + } + + [Fact] + public async Task Strict_validation_response_enforces_success_artifact_invariant() + { + var valid = JsonNode.Parse(await ReadFixture("responses", "policy-validation.valid.response.json"))!; + valid["Validation"]!.AsObject().Remove("CanonicalDraft"); + Assert.Throws( + () => BrokerJson.DeserializeStrict(valid.ToJsonString())); + + var invalid = JsonNode.Parse(await ReadFixture("responses", "policy-validation.invalid.response.json"))!; + invalid["Validation"]!["ValidationReceipt"] = "unexpected-receipt"; + Assert.Throws( + () => BrokerJson.DeserializeStrict(invalid.ToJsonString())); + } + + [Theory] + [InlineData("\"stalepolicystoretoken\"")] + [InlineData("16")] + public async Task Management_error_rejects_noncanonical_error_code(string value) + { + var error = JsonNode.Parse(await ReadFixture("responses", "policy-stale-token.error.json"))!; + error["Code"] = JsonNode.Parse(value); + + Assert.Throws(() => BrokerJson.Deserialize(error.ToJsonString())); + } + + [Fact] + public async Task Management_error_enforces_validation_result_invariant() + { + var error = JsonNode.Parse(await ReadFixture("responses", "policy-stale-token.error.json"))!; + error["Validation"]!["IsValid"] = true; + + Assert.Throws(() => BrokerJson.Deserialize(error.ToJsonString())); + } + + private static async Task ReadFixture(string directory, string file) => + await File.ReadAllTextAsync(Path.Combine(TestData.SamplesDir, directory, file)); + + private static BrokerClient CreateClient(FakeBrokerTransport transport) => new(new BrokerClientOptions + { + Transport = transport, + EffectiveUser = "DEVOLUTIONS\\bob", + RequestedElevation = Elevation.Standard, + ClientExecutablePath = "C:\\Tools\\client.exe", + ClientVersion = "9.8.7", + }); + + private sealed class FakeBrokerTransport(params BrokerTransportResponse[] responses) : IBrokerTransport + { + private readonly Queue _responses = new(responses); + + public Transport Kind => Transport.HttpNamedPipe; + + public List Requests { get; } = []; + + public Task Send( + BrokerTransportRequest request, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Requests.Add(request); + if (_responses.Count == 0) + { + throw new InvalidOperationException($"No fake broker response queued for {request.Path}."); + } + + return Task.FromResult(_responses.Dequeue()); + } + + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs index fa16f1a..85bca00 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs @@ -142,6 +142,7 @@ public static IEnumerable RequestSamples() => JsonFiles(Path.Combine(SamplesDir, "requests")) .Where(f => !Path.GetFileName(f).StartsWith("status-", StringComparison.Ordinal)) .Where(f => !Path.GetFileName(f).StartsWith("cancel-", StringComparison.Ordinal)) + .Where(f => !Path.GetFileName(f).StartsWith("policy-", StringComparison.Ordinal)) .Where(f => !IsInvalidRequestSample(f)) .Select(f => new object[] { f }); @@ -192,7 +193,32 @@ public static IEnumerable CapabilitiesResponseSamples() => public static IEnumerable PolicyResponseSamples() => JsonFiles(Path.Combine(SamplesDir, "responses")) - .Where(f => Path.GetFileName(f).StartsWith("policy", StringComparison.Ordinal)) + .Where(f => Path.GetFileName(f).Equals("policy.response.json", StringComparison.Ordinal)) + .Select(f => new object[] { f }); + + public static IEnumerable PolicyManagementResponseSamples() => + JsonFiles(Path.Combine(SamplesDir, "responses")) + .Where(f => Path.GetFileName(f).StartsWith("policy-management.", StringComparison.Ordinal)) + .Select(f => new object[] { f }); + + public static IEnumerable PolicyValidationRequestSamples() => + JsonFiles(Path.Combine(SamplesDir, "requests")) + .Where(f => Path.GetFileName(f).Equals("policy-validation.request.json", StringComparison.Ordinal)) + .Select(f => new object[] { f }); + + public static IEnumerable PolicyValidationResponseSamples() => + JsonFiles(Path.Combine(SamplesDir, "responses")) + .Where(f => Path.GetFileName(f).StartsWith("policy-validation.", StringComparison.Ordinal)) + .Select(f => new object[] { f }); + + public static IEnumerable PolicyReplacementRequestSamples() => + JsonFiles(Path.Combine(SamplesDir, "requests")) + .Where(f => Path.GetFileName(f).StartsWith("policy-replacement.", StringComparison.Ordinal)) + .Select(f => new object[] { f }); + + public static IEnumerable PolicyReplacementResponseSamples() => + JsonFiles(Path.Combine(SamplesDir, "responses")) + .Where(f => Path.GetFileName(f).Equals("policy-replacement.response.json", StringComparison.Ordinal)) .Select(f => new object[] { f }); private static IEnumerable JsonFiles(string dir) => diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs index a30dec5..af6ea0c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -84,6 +84,69 @@ public async Task GetPolicy(CancellationToken cancellationToken strictSuccessBody: true); } + /// Get the atomic configured-policy management snapshot. + public async Task GetPolicyManagement(CancellationToken cancellationToken = default) + { + var headers = new Dictionary { ["Accept"] = JsonMediaType }; + var response = await SendRequest( + "GET", + "/v1/policy/management", + null, + headers, + cancellationToken).ConfigureAwait(false); + return DeserializeResponse( + response, + "policy management", + "/v1/policy/management", + strictSuccessBody: true); + } + + /// + /// Authoritatively validate raw draft JSON without discarding unknown members. + /// + public async Task ValidatePolicy( + JsonElement draft, + CancellationToken cancellationToken = default) + { + var request = new PolicyValidationRequest + { + RequestVersion = BrokerApi.Version, + Draft = draft.Clone(), + }; + + var response = await SendPolicyManagementRequest( + "POST", + "/v1/policy/validate", + BrokerJson.Serialize(request), + cancellationToken).ConfigureAwait(false); + return DeserializeResponse( + response, + "policy validation", + "/v1/policy/validate", + strictSuccessBody: true); + } + + /// + /// Replace the configured policy using optimistic concurrency and a validation receipt. + /// + public async Task ReplacePolicy( + PolicyReplacementRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var response = await SendPolicyManagementRequest( + "PUT", + "/v1/policy", + BrokerJson.Serialize(request), + cancellationToken).ConfigureAwait(false); + return DeserializeResponse( + response, + "policy replacement", + "/v1/policy", + strictSuccessBody: true); + } + /// Evaluate a package operation against policy without executing it (dry-run). public async Task Evaluate(PackageOperationRequest request, CancellationToken cancellationToken = default) { @@ -407,6 +470,20 @@ private Task SendRequest( }, cancellationToken); + private Task SendPolicyManagementRequest( + string method, + string endpoint, + string body, + CancellationToken cancellationToken) + { + var headers = new Dictionary + { + ["Content-Type"] = JsonMediaType, + ["Accept"] = JsonMediaType, + }; + return SendRequest(method, endpoint, body, headers, cancellationToken); + } + private CapabilitiesResponse? CachedCapabilities => _capabilities; private async Task FetchCapabilities(CancellationToken cancellationToken) diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index 72d3ac9..99a3bfd 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -46,6 +46,9 @@ 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. +- `GetPolicyManagement` gets the atomic active/missing/invalid management snapshot and advisory write capability. +- `ValidatePolicy` preserves raw `JsonElement` draft content for authoritative validation and returns a canonical draft, exact findings, and receipt. +- `ReplacePolicy` performs a token- and receipt-bound optimistic replacement; confirmed overwrite still targets an exact newly observed token and is never unconditional. - `Evaluate` sends `POST /v1/package-operations/evaluate`. - `Execute` sends `POST /v1/package-operations/execute`. - `ExecuteAndWait` submits an operation and polls status until a terminal state. diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 5ba4221..c50bc83 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -17,7 +17,7 @@ public class PolicyTests private static string PolicySchema => Path.Combine(PolicyCrateRoot, "schema", "devolutions.now-policy.schema.json"); public static IEnumerable PolicySamples() => - Directory.GetFiles(SamplesDir, "*.policy.*").Select(f => new object[] { f }); + Directory.GetFiles(SamplesDir, "*.policy.json").Select(f => new object[] { f }); [Fact] public void Tests_run_with_reflection_json_disabled() @@ -74,25 +74,6 @@ public void Invalid_policy_fixture_is_rejected_by_parser() Assert.ThrowsAny(() => PolicyDocument.ParseJson(content)); } - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Empty_yaml_is_rejected_with_json_exception(string yaml) - { - Assert.Throws(() => PolicyDocument.ParseYaml(yaml)); - } - - [Fact] - public void Yaml_with_non_scalar_mapping_key_is_rejected_with_json_exception() - { - const string yaml = """ - ? [PolicyVersion] - : 1.0.0 - """; - - Assert.Throws(() => PolicyDocument.ParseYaml(yaml)); - } - [Fact] public void Negative_revision_is_rejected_by_parser() { @@ -197,14 +178,50 @@ public void Null_policy_collection_element_is_rejected_by_parser(string elementP Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); } + [Fact] + public void Draft_conversion_omits_and_restores_server_metadata_without_aliasing() + { + var committed = PolicyDocument.ParseJson( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json"))); + + var draft = committed.ToDraft(); + var draftJson = JsonNode.Parse(draft.ToJson())!; + Assert.Null(draftJson["Metadata"]!["Revision"]); + Assert.Null(draftJson["Metadata"]!["PublishedAt"]); + + draft.Rules[0].Id = "changed"; + Assert.NotEqual(draft.Rules[0].Id, committed.Rules[0].Id); + + var publishedAt = DateTimeOffset.Parse("2026-08-29T00:00:00Z"); + var recommitted = draft.ToPolicyDocument(7, publishedAt); + Assert.Equal(7U, recommitted.Metadata.Revision); + Assert.Equal(publishedAt, recommitted.Metadata.PublishedAt); + Assert.Equal("changed", recommitted.Rules[0].Id); + } + + [Fact] + public void Mixed_boolean_match_values_are_rejected() + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; + document["Rules"]![0]!["Match"]!["Interactive"] = new JsonArray(false, true); + + Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); + } + + [Fact] + public void Draft_rejects_server_managed_metadata() + { + var committed = JsonNode.Parse( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; + + Assert.Throws(() => PolicyDraftDocument.ParseJson(committed.ToJsonString())); + } + private static PolicyDocument ParsePolicy(string path) { var content = File.ReadAllText(path); - var extension = Path.GetExtension(path); - return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) - || extension.Equals(".yml", StringComparison.OrdinalIgnoreCase) - ? PolicyDocument.ParseYaml(content) - : PolicyDocument.ParseJson(content); + return PolicyDocument.ParseJson(content); } private static string ResolvePolicyCrateRoot([CallerFilePath] string thisFile = "") diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/Devolutions.Now.Policy.Model.csproj b/policies/dotnet/Devolutions.Now.Policy.Model/Devolutions.Now.Policy.Model.csproj index 1a48044..53f8091 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/Devolutions.Now.Policy.Model.csproj +++ b/policies/dotnet/Devolutions.Now.Policy.Model/Devolutions.Now.Policy.Model.csproj @@ -27,10 +27,6 @@ README.md - - - - diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs index 693840c..b4eb650 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs @@ -18,38 +18,63 @@ public static class PolicyJson public static string Serialize(PolicyDocument value) => JsonSerializer.Serialize(value, PolicyJsonSerializerContext.Default.PolicyDocument); + public static string Serialize(PolicyDraftDocument value) => + JsonSerializer.Serialize(value, PolicyJsonSerializerContext.Default.PolicyDraftDocument); + public static PolicyDocument? DeserializePolicyDocument(string json) => Validate(JsonSerializer.Deserialize(json, PolicyJsonSerializerContext.Default.PolicyDocument)); public static PolicyDocument? DeserializePolicyDocumentStrict(string json) => Validate(JsonSerializer.Deserialize(json, PolicyJsonStrictSerializerContext.Default.PolicyDocument)); + public static PolicyDraftDocument? DeserializePolicyDraftDocumentStrict(string json) => + Validate(JsonSerializer.Deserialize(json, PolicyJsonStrictSerializerContext.Default.PolicyDraftDocument)); + public static string Serialize(T value) => JsonSerializer.Serialize(value, TypeInfo()); public static T? DeserializeStrict(string json) { var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); - if (value is PolicyDocument policy) + switch (value) { - ValidateRequiredCollectionElements(policy); + case PolicyDocument policy: + ValidateRequiredCollectionElements(policy); + break; + case PolicyDraftDocument draft: + ValidateRequiredCollectionElements(draft); + break; } return value; } internal static void ValidateRequiredCollectionElements(PolicyDocument policy) + => ValidateRequiredCollectionElements(policy.Rules); + + internal static void ValidateRequiredCollectionElements(PolicyDraftDocument policy) + => ValidateRequiredCollectionElements(policy.Rules); + + private static void ValidateRequiredCollectionElements(IReadOnlyList rules) { - RejectNullElements(policy.Rules, "$.Rules"); + RejectNullElements(rules, "$.Rules"); - for (var ruleIndex = 0; ruleIndex < policy.Rules.Count; ruleIndex++) + for (var ruleIndex = 0; ruleIndex < rules.Count; ruleIndex++) { - var rule = policy.Rules[ruleIndex]; + var rule = 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"); + RejectBooleanMatch(rule.Match.Interactive, $"{matchPath}.Interactive"); + RejectBooleanMatch(rule.Match.SkipHashCheck, $"{matchPath}.SkipHashCheck"); + RejectBooleanMatch(rule.Match.PreRelease, $"{matchPath}.PreRelease"); + RejectBooleanMatch(rule.Match.HasCustomParameters, $"{matchPath}.HasCustomParameters"); + RejectBooleanMatch(rule.Match.HasCustomInstallLocation, $"{matchPath}.HasCustomInstallLocation"); + RejectBooleanMatch(rule.Match.HasPrePostCommands, $"{matchPath}.HasPrePostCommands"); + RejectBooleanMatch(rule.Match.HasKillBeforeOperation, $"{matchPath}.HasKillBeforeOperation"); + RejectBooleanMatch(rule.Match.HasUninstallPrevious, $"{matchPath}.HasUninstallPrevious"); if (rule.Constraints is { } constraints) { @@ -76,6 +101,24 @@ internal static void ValidateRequiredCollectionElements(PolicyDocument policy) return policy; } + private static PolicyDraftDocument? Validate(PolicyDraftDocument? policy) + { + if (policy is not null) + { + ValidateRequiredCollectionElements(policy); + } + + return policy; + } + + private static void RejectBooleanMatch(IReadOnlyList values, string path) + { + if (values.Count > 1) + { + throw new JsonException($"The JSON array at {path} must contain exactly one value when present."); + } + } + private static void RejectNullElements(IReadOnlyList values, string path) where T : class { @@ -90,7 +133,9 @@ private static void RejectNullElements(IReadOnlyList values, string path) private static JsonTypeInfo TypeInfo() => typeof(T) == typeof(PolicyDocument) ? Cast(PolicyJsonSerializerContext.Default.PolicyDocument) : + typeof(T) == typeof(PolicyDraftDocument) ? Cast(PolicyJsonSerializerContext.Default.PolicyDraftDocument) : typeof(T) == typeof(PolicyMetadata) ? Cast(PolicyJsonSerializerContext.Default.PolicyMetadata) : + typeof(T) == typeof(PolicyDraftMetadata) ? Cast(PolicyJsonSerializerContext.Default.PolicyDraftMetadata) : typeof(T) == typeof(PolicyEnforcement) ? Cast(PolicyJsonSerializerContext.Default.PolicyEnforcement) : typeof(T) == typeof(PolicyRule) ? Cast(PolicyJsonSerializerContext.Default.PolicyRule) : typeof(T) == typeof(PolicyMatch) ? Cast(PolicyJsonSerializerContext.Default.PolicyMatch) : @@ -100,7 +145,9 @@ private static JsonTypeInfo TypeInfo() => private static JsonTypeInfo StrictTypeInfo() => typeof(T) == typeof(PolicyDocument) ? Cast(PolicyJsonStrictSerializerContext.Default.PolicyDocument) : + typeof(T) == typeof(PolicyDraftDocument) ? Cast(PolicyJsonStrictSerializerContext.Default.PolicyDraftDocument) : typeof(T) == typeof(PolicyMetadata) ? Cast(PolicyJsonStrictSerializerContext.Default.PolicyMetadata) : + typeof(T) == typeof(PolicyDraftMetadata) ? Cast(PolicyJsonStrictSerializerContext.Default.PolicyDraftMetadata) : typeof(T) == typeof(PolicyEnforcement) ? Cast(PolicyJsonStrictSerializerContext.Default.PolicyEnforcement) : typeof(T) == typeof(PolicyRule) ? Cast(PolicyJsonStrictSerializerContext.Default.PolicyRule) : typeof(T) == typeof(PolicyMatch) ? Cast(PolicyJsonStrictSerializerContext.Default.PolicyMatch) : @@ -117,7 +164,9 @@ private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) => DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, RespectNullableAnnotations = true)] [JsonSerializable(typeof(PolicyDocument))] +[JsonSerializable(typeof(PolicyDraftDocument))] [JsonSerializable(typeof(PolicyMetadata))] +[JsonSerializable(typeof(PolicyDraftMetadata))] [JsonSerializable(typeof(PolicyEnforcement))] [JsonSerializable(typeof(PolicyRule))] [JsonSerializable(typeof(PolicyMatch))] @@ -131,7 +180,9 @@ internal sealed partial class PolicyJsonSerializerContext : JsonSerializerContex RespectNullableAnnotations = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(PolicyDocument))] +[JsonSerializable(typeof(PolicyDraftDocument))] [JsonSerializable(typeof(PolicyMetadata))] +[JsonSerializable(typeof(PolicyDraftMetadata))] [JsonSerializable(typeof(PolicyEnforcement))] [JsonSerializable(typeof(PolicyRule))] [JsonSerializable(typeof(PolicyMatch))] diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index d433554..812c6a5 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -1,11 +1,6 @@ -using System.Globalization; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using YamlDotNet.Core; -using YamlDotNet.RepresentationModel; - namespace Devolutions.Now.Policy.Model; public static class SchemaUris @@ -65,80 +60,94 @@ public static PolicyDocument ParseJson(string json) ?? throw new JsonException("policy document was null"); } - public static PolicyDocument ParseYaml(string yaml) + public PolicyDraftDocument ToDraft() { - var stream = new YamlStream(); - stream.Load(new StringReader(yaml)); - if (stream.Documents.Count == 0) + return new PolicyDraftDocument { - throw new JsonException("policy YAML document was empty"); - } - - var json = YamlToJson(stream.Documents[0].RootNode)?.ToJsonString() - ?? throw new JsonException("policy YAML document was empty"); - return ParseJson(json); + Schema = Schema, + PolicyVersion = PolicyVersion, + PolicyType = PolicyType, + Metadata = PolicyModelClone.ToDraftMetadata(Metadata), + Enforcement = PolicyModelClone.Enforcement(Enforcement), + Rules = PolicyModelClone.Rules(Rules), + }; } public string ToJson() => PolicyJson.Serialize(this); +} + +/// An editable policy document without server-managed commit metadata. +public sealed class PolicyDraftDocument +{ + [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 PolicyDraftMetadata Metadata { get; set; } = new(); - private static JsonNode? YamlToJson(YamlNode node) + [JsonPropertyName("Enforcement")] + [JsonRequired] + public PolicyEnforcement Enforcement { get; set; } = new(); + + [JsonPropertyName("Rules")] + [JsonRequired] + public List Rules { get; set; } = []; + + public static PolicyDraftDocument Create( + string id, + string publisher, + Decision defaultDecision = Decision.Deny) { - switch (node) + return new PolicyDraftDocument { - case YamlMappingNode map: - var obj = new JsonObject(); - foreach (var (key, value) in map.Children) - { - if (key is not YamlScalarNode scalarKey || scalarKey.Value is null) - { - throw new JsonException("policy YAML mapping keys must be scalar strings"); - } - - obj[scalarKey.Value] = YamlToJson(value); - } - - return obj; - - case YamlSequenceNode seq: - var arr = new JsonArray(); - foreach (var item in seq.Children) - { - arr.Add(YamlToJson(item)); - } - - return arr; - - case YamlScalarNode scalar: - return ScalarToJson(scalar); - - default: - return null; - } + Metadata = new PolicyDraftMetadata + { + Id = id, + Publisher = publisher, + }, + Enforcement = new PolicyEnforcement + { + DefaultDecision = defaultDecision, + RulePrecedence = RulePrecedence.PriorityThenDeny, + }, + }; } - private static JsonNode? ScalarToJson(YamlScalarNode scalar) + public static PolicyDraftDocument ParseJson(string json) { - var value = scalar.Value; - if (value is null) - { - return null; - } + return PolicyJson.DeserializePolicyDraftDocumentStrict(json) + ?? throw new JsonException("policy draft document was null"); + } - if (scalar.Style is ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted) + public PolicyDocument ToPolicyDocument(uint revision, DateTimeOffset publishedAt) + { + if (revision == 0) { - return JsonValue.Create(value); + throw new ArgumentOutOfRangeException(nameof(revision), "Policy revisions start at 1."); } - return value switch + return new PolicyDocument { - "" or "null" or "~" => null, - "true" or "True" => JsonValue.Create(true), - "false" or "False" => JsonValue.Create(false), - _ when long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => JsonValue.Create(l), - _ when double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => JsonValue.Create(d), - _ => JsonValue.Create(value), + Schema = Schema, + PolicyVersion = PolicyVersion, + PolicyType = PolicyType, + Metadata = PolicyModelClone.ToCommittedMetadata(Metadata, revision, publishedAt), + Enforcement = PolicyModelClone.Enforcement(Enforcement), + Rules = PolicyModelClone.Rules(Rules), }; } + + public string ToJson() => PolicyJson.Serialize(this); } public sealed class PolicyMetadata @@ -172,6 +181,29 @@ public sealed class PolicyMetadata public string? SupportUrl { get; set; } } +public sealed class PolicyDraftMetadata +{ + [JsonPropertyName("Id")] + [JsonRequired] + public string Id { get; set; } = ""; + + [JsonPropertyName("Publisher")] + [JsonRequired] + public string Publisher { get; set; } = ""; + + [JsonPropertyName("ValidFrom")] + public DateTimeOffset? ValidFrom { get; set; } + + [JsonPropertyName("ValidUntil")] + public DateTimeOffset? ValidUntil { get; set; } + + [JsonPropertyName("Description")] + public string? Description { get; set; } + + [JsonPropertyName("SupportUrl")] + public string? SupportUrl { get; set; } +} + public sealed class PolicyEnforcement { [JsonPropertyName("DefaultDecision")] @@ -323,4 +355,98 @@ public sealed class PolicyConstraints [JsonPropertyName("AllowUpgrade")] public bool AllowUpgrade { get; set; } = true; +} + +internal static class PolicyModelClone +{ + internal static PolicyDraftMetadata ToDraftMetadata(PolicyMetadata value) => new() + { + Id = value.Id, + Publisher = value.Publisher, + ValidFrom = value.ValidFrom, + ValidUntil = value.ValidUntil, + Description = value.Description, + SupportUrl = value.SupportUrl, + }; + + internal static PolicyMetadata ToCommittedMetadata( + PolicyDraftMetadata value, + uint revision, + DateTimeOffset publishedAt) => new() + { + Id = value.Id, + Publisher = value.Publisher, + Revision = revision, + PublishedAt = publishedAt, + ValidFrom = value.ValidFrom, + ValidUntil = value.ValidUntil, + Description = value.Description, + SupportUrl = value.SupportUrl, + }; + + internal static PolicyEnforcement Enforcement(PolicyEnforcement value) => new() + { + DefaultDecision = value.DefaultDecision, + RulePrecedence = value.RulePrecedence, + AuditMode = value.AuditMode, + }; + + internal static List Rules(IEnumerable values) => values.Select(Rule).ToList(); + + private static PolicyRule Rule(PolicyRule value) => new() + { + Id = value.Id, + Enabled = value.Enabled, + Priority = value.Priority, + Decision = value.Decision, + Reason = value.Reason, + Match = Match(value.Match), + Constraints = value.Constraints is null ? null : Constraints(value.Constraints), + }; + + private static PolicyMatch Match(PolicyMatch value) => new() + { + Operations = [.. value.Operations], + Managers = [.. value.Managers], + Sources = [.. value.Sources], + PackageIdentifiers = [.. value.PackageIdentifiers], + PackageNames = [.. value.PackageNames], + Versions = [.. value.Versions], + VersionRange = value.VersionRange is null + ? null + : new VersionRange + { + MinVersion = value.VersionRange.MinVersion, + MaxVersion = value.VersionRange.MaxVersion, + IncludePrerelease = value.VersionRange.IncludePrerelease, + }, + Scopes = [.. value.Scopes], + Architectures = [.. value.Architectures], + Elevation = [.. value.Elevation], + Interactive = [.. value.Interactive], + SkipHashCheck = [.. value.SkipHashCheck], + PreRelease = [.. value.PreRelease], + HasCustomParameters = [.. value.HasCustomParameters], + HasCustomInstallLocation = [.. value.HasCustomInstallLocation], + HasPrePostCommands = [.. value.HasPrePostCommands], + HasKillBeforeOperation = [.. value.HasKillBeforeOperation], + HasUninstallPrevious = [.. value.HasUninstallPrevious], + }; + + private static PolicyConstraints Constraints(PolicyConstraints value) => new() + { + AllowInteractive = value.AllowInteractive, + AllowSkipHashCheck = value.AllowSkipHashCheck, + AllowPreRelease = value.AllowPreRelease, + AllowCustomInstallLocation = value.AllowCustomInstallLocation, + AllowedInstallLocationPatterns = [.. value.AllowedInstallLocationPatterns], + AllowCustomParameters = value.AllowCustomParameters, + AllowedCustomParameters = [.. value.AllowedCustomParameters], + AllowedCustomParameterPatterns = [.. value.AllowedCustomParameterPatterns], + DeniedCustomParameters = [.. value.DeniedCustomParameters], + AllowPrePostCommands = value.AllowPrePostCommands, + AllowKillBeforeOperation = value.AllowKillBeforeOperation, + AllowUninstallPrevious = value.AllowUninstallPrevious, + AllowUpgrade = value.AllowUpgrade, + }; } \ No newline at end of file diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index c21023b..8b5639e 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -12,18 +12,23 @@ The model is used to: - represent package broker policy documents in C#; - parse strict JSON policy documents; -- parse YAML policy documents by converting them to the same JSON model; +- represent editable drafts separately from committed policy documents; - serialize policy documents with the canonical JSON shape; - share policy enums and document types with the package broker API compatibility layer. Architecture ------------ -- `PolicyModels.cs` defines `PolicyDocument`, metadata, enforcement, rules, match criteria, constraints, and version range types. +- `PolicyModels.cs` defines committed `PolicyDocument`, editable `PolicyDraftDocument`, their metadata, explicit conversions, enforcement, rules, match criteria, constraints, and version range types. - `Enums.cs` defines policy-level enums such as operation, manager, scope, architecture, elevation, decision, and rule precedence. - `PolicyJson.cs` defines shared `JsonSerializerOptions`, including strict parsing that rejects unknown JSON members and JSON null for non-nullable policy members 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. +`PolicyDocument.Create` constructs a committed policy and `PolicyDraftDocument.Create` constructs an editable draft. `PolicyDocument.ToDraft` removes server-managed `Revision` and `PublishedAt`; `PolicyDraftDocument.ToPolicyDocument` requires those values when committing. `ParseJson` is the only policy parsing entry point. + +Breaking change +--------------- + +Policy documents are JSON-only. `PolicyDocument.ParseYaml`, which was public in `Devolutions.Now.Policy.Model` 2026.8.13, has been removed intentionally. Consumers must migrate stored policies to JSON before upgrading; OpenAPI YAML and unrelated YAML documents are unaffected. Validation ---------- diff --git a/policies/rust/now-policy-api/CHANGELOG.md b/policies/rust/now-policy-api/CHANGELOG.md index 95ddc3a..e6a49f3 100644 --- a/policies/rust/now-policy-api/CHANGELOG.md +++ b/policies/rust/now-policy-api/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Add versioned policy management, raw-draft validation, structured findings/receipts, optimistic replacement, and management error contracts. + ## [[0.3.1](https://github.com/Devolutions/now-libraries/compare/now-policy-api-v0.3.0...now-policy-api-v0.3.1)] - 2026-08-13 diff --git a/policies/rust/now-policy-api/README.md b/policies/rust/now-policy-api/README.md index 13fb4e0..b9f9559 100644 --- a/policies/rust/now-policy-api/README.md +++ b/policies/rust/now-policy-api/README.md @@ -28,10 +28,11 @@ Library structure overview: - `health.rs` contains health endpoint models for `GET /v1/health`. - `capabilities.rs` contains capability endpoint models for `GET /v1/capabilities`. - `policy.rs` contains the active `PolicyDocument` response for the canonical `GET /v1/policy` endpoint. +- `management.rs` contains atomic management snapshots, raw-draft validation, versioned findings and receipts, optimistic replacement intents, and management responses. - `enums.rs` contains shared protocol enums. - `lib.rs` contains constrained string newtypes, validation helpers, etc. -`now-policy` owns the canonical policy document and schema. This API crate composes that domain contract into `PolicyResponse`; runtime-specific mappings between policy evaluation types and API DTOs belong to the consuming broker implementation. +`now-policy` owns the canonical committed and draft policy documents and schema. This API crate composes that domain contract into inspection and management responses; runtime-specific validation, persistence, and policy-evaluation logic belongs to the consuming broker implementation. Top-level requests carry `RequestKind` and `RequestVersion`; top-level responses carry `ResponseKind` and `ResponseVersion`. Kind fields are marker types that serialize to fixed strings and reject mismatched values during deserialization; this is required for further protocol evolution and allows the client to switch transport from HTTP to other @@ -54,7 +55,7 @@ Regenerate it with: cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi --locked ``` -The generated document always contains the policy inspection route and canonical `PolicyDocument` schema. +The generated document contains the unchanged policy inspection route, the management/validation/replacement routes, and canonical committed and draft policy schemas. 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 3f04c9c..21997a6 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -49,6 +49,112 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + put: + summary: Replace the configured policy + description: Reparses and revalidates the raw draft inside the write transaction, then atomically commits it only when the expected opaque store token and validation receipt still match. + requestBody: + description: Request body for `PUT /v1/policy`. + content: + application/json: + schema: + $ref: '#/components/schemas/PolicyReplacementRequest' + required: true + responses: + '200': + description: Response body for `PUT /v1/policy`. + content: + application/json: + schema: + $ref: '#/components/schemas/PolicyReplacementResponse' + '400': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '501': + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /v1/policy/management: + get: + summary: Get policy management state + description: Atomically returns configured policy state and advisory write capability. Capability fields are UX guidance and are rechecked during replacement. + 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/management`. + content: + application/json: + schema: + $ref: '#/components/schemas/PolicyManagementResponse' + /v1/policy/validate: + post: + summary: Validate a policy draft + description: Authoritatively validates raw draft JSON without discarding unknown fields. Validation findings are returned with HTTP 200; malformed envelopes use ErrorResponse. + requestBody: + description: Request body for `POST /v1/policy/validate`. + content: + application/json: + schema: + $ref: '#/components/schemas/PolicyValidationRequest' + required: true + responses: + default: + description: Generic error body returned for non-2xx responses. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '200': + description: Response body for `POST /v1/policy/validate`. + content: + application/json: + schema: + $ref: '#/components/schemas/PolicyValidationResponse' + '400': + 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 @@ -424,6 +530,17 @@ components: - BrokerPaused - InternalError - Timeout + - UnsupportedEndpoint + - MalformedDraft + - InvalidPolicy + - WarningConfirmationRequired + - Unauthenticated + - AdministratorRequired + - UnsafePolicyPath + - StalePolicyStoreToken + - UnsupportedPolicyFilesystem + - PolicyPersistenceFailed + - PolicyActivationFailed ErrorDetail: description: Structured error detail, typically used for validation failures. type: object @@ -477,6 +594,13 @@ components: description: Server context. allOf: - $ref: '#/components/schemas/ServerContext' + Validation: + description: Current authoritative policy findings for management errors. + anyOf: + - $ref: '#/components/schemas/PolicyValidationResult' + - enum: + - null + nullable: true required: - ResponseKind - ResponseVersion @@ -688,6 +812,20 @@ components: type: string enum: - Paused + InvalidPolicyDiagnostics: + description: Sanitized diagnostics for an invalid configured policy. + type: object + properties: + DiagnosticsVersion: + $ref: '#/components/schemas/ApiVersion' + Findings: + type: array + items: + $ref: '#/components/schemas/PolicyFinding' + additionalProperties: false + required: + - DiagnosticsVersion + - Findings ManagerCapability: description: Package-manager-specific capability declaration. type: object @@ -955,6 +1093,252 @@ components: PackageRequestKind: type: string pattern: ^PackageRequest$ + PolicyConfigurationSource: + description: Origin of the resolved policy path. + type: string + enum: + - DefaultPath + - ConfiguredPath + PolicyConflictHandling: + description: Optimistic-conflict behavior for policy replacement. + oneOf: + - type: string + enum: + - Reject + - description: Overwrite only the exact newly observed store token carried by this request. + type: string + enum: + - ConfirmOverwrite + PolicyFinding: + description: Versioned, structured policy validation finding. + type: object + properties: + Arguments: + description: Machine-readable message arguments for localization. + type: object + additionalProperties: true + Code: + $ref: '#/components/schemas/PolicyFindingCode' + FindingVersion: + description: Finding shape version. + allOf: + - $ref: '#/components/schemas/ApiVersion' + Message: + description: Human-readable fallback for clients that do not recognize the code. + type: string + maxLength: 2048 + minLength: 1 + Path: + description: RFC 6901 JSON Pointer into the submitted draft. + type: string + maxLength: 2048 + RuleId: + anyOf: + - $ref: '#/components/schemas/ResourceId' + - enum: + - null + nullable: true + Severity: + $ref: '#/components/schemas/PolicyFindingSeverity' + additionalProperties: false + required: + - FindingVersion + - Severity + - Code + - Path + - Message + PolicyFindingCode: + description: Stable policy validation finding code. + type: string + enum: + - SchemaViolation + - UnknownField + - MissingRequiredField + - InvalidFieldType + - InvalidFieldValue + - DuplicateRuleId + - IneffectiveBooleanMatch + - InvalidVersionRange + - EmptyVersionRange + - InvalidWildcardPattern + - ContradictoryConstraints + - InvalidValidityInterval + - UnsupportedSchema + - UnsupportedPolicyType + - UnsupportedPolicyVersion + - AuditModeEnabled + - DefaultAllow + - SensitiveOptionAllowed + PolicyFindingSeverity: + description: Severity of a policy validation finding. + type: string + enum: + - Error + - Warning + PolicyManagementResponse: + description: Response body for `GET /v1/policy/management`. + type: object + properties: + Management: + $ref: '#/components/schemas/PolicyManagementSnapshot' + ResponseKind: + $ref: '#/components/schemas/PolicyManagementResponseKind' + ResponseVersion: + $ref: '#/components/schemas/ApiVersion' + Server: + $ref: '#/components/schemas/ServerContext' + additionalProperties: false + required: + - ResponseKind + - ResponseVersion + - Server + - Management + PolicyManagementResponseKind: + type: string + pattern: ^PolicyManagementResponse$ + PolicyManagementSnapshot: + description: Atomic view of configured policy state and management guidance. + type: object + properties: + ConfiguredPath: + description: Fully resolved configured path. + type: string + maxLength: 32767 + minLength: 1 + ElevationRequired: + type: boolean + InvalidDiagnostics: + anyOf: + - $ref: '#/components/schemas/InvalidPolicyDiagnostics' + - enum: + - null + nullable: true + Policy: + $ref: '#/components/schemas/PolicyDocument' + ReadOnlyReason: + anyOf: + - $ref: '#/components/schemas/PolicyReadOnlyReason' + - enum: + - null + nullable: true + Source: + $ref: '#/components/schemas/PolicyConfigurationSource' + State: + $ref: '#/components/schemas/PolicyManagementState' + StoreToken: + $ref: '#/components/schemas/PolicyStoreToken' + WriteCapability: + $ref: '#/components/schemas/PolicyWriteCapability' + additionalProperties: false + required: + - State + - ConfiguredPath + - StoreToken + - Source + - WriteCapability + - ElevationRequired + PolicyManagementState: + description: Current configured-policy state. + type: string + enum: + - Active + - Missing + - Invalid + PolicyReadOnlyReason: + description: Stable reason why the configured policy cannot be written. + type: string + enum: + - ManagementDisabled + - PathNotConfigured + - UnsafePath + - InsufficientPermissions + - UnsupportedFileSystem + PolicyReplacementOperation: + description: Requested identity/revision behavior for a policy replacement. + oneOf: + - description: Update the active policy while retaining its identity and incrementing its revision. + type: string + enum: + - Update + - description: Replace the active policy with an explicitly different identity at revision 1. + type: string + enum: + - ReplaceIdentity + - description: Create the first policy at revision 1. + type: string + enum: + - Create + - description: Replace an invalid configured document at revision 1. + type: string + enum: + - Repair + PolicyReplacementRequest: + description: Request body for `PUT /v1/policy`. + type: object + properties: + ConflictHandling: + $ref: '#/components/schemas/PolicyConflictHandling' + Draft: + description: Raw draft JSON retained for transaction-time reparsing and revalidation. + ExpectedStoreToken: + $ref: '#/components/schemas/PolicyStoreToken' + Operation: + $ref: '#/components/schemas/PolicyReplacementOperation' + RequestKind: + $ref: '#/components/schemas/PolicyReplacementRequestKind' + RequestVersion: + $ref: '#/components/schemas/ApiVersion' + ValidationReceipt: + $ref: '#/components/schemas/PolicyValidationReceipt' + WarningsAcknowledged: + description: Explicit acknowledgement of every warning bound into the validation receipt. + type: boolean + additionalProperties: false + required: + - RequestKind + - RequestVersion + - ExpectedStoreToken + - Operation + - ConflictHandling + - WarningsAcknowledged + - Draft + - ValidationReceipt + PolicyReplacementRequestKind: + type: string + pattern: ^PolicyReplacementRequest$ + PolicyReplacementResponse: + description: Response body for `PUT /v1/policy`. + type: object + properties: + Management: + description: Newly observed management state and store token. + allOf: + - $ref: '#/components/schemas/PolicyManagementSnapshot' + Policy: + description: Exact committed active policy, including server-assigned metadata. + allOf: + - $ref: '#/components/schemas/PolicyDocument' + ResponseKind: + $ref: '#/components/schemas/PolicyReplacementResponseKind' + ResponseVersion: + $ref: '#/components/schemas/ApiVersion' + Server: + $ref: '#/components/schemas/ServerContext' + Validation: + description: Transaction-time validation result for the exact committed draft. + allOf: + - $ref: '#/components/schemas/PolicyValidationResult' + additionalProperties: false + required: + - ResponseKind + - ResponseVersion + - Server + - Policy + - Validation + - Management + PolicyReplacementResponseKind: + type: string + pattern: ^PolicyReplacementResponse$ PolicyResponse: description: Response body for `GET /v1/policy`. type: object @@ -983,6 +1367,115 @@ components: PolicyResponseKind: type: string pattern: ^PolicyResponse$ + PolicyStoreToken: + description: Opaque token identifying the exact state observed in the policy store. + type: string + maxLength: 512 + minLength: 1 + PolicyValidationReceipt: + description: Opaque receipt bound to a canonical draft, validator version, and exact warning set. + type: string + maxLength: 2048 + minLength: 1 + PolicyValidationRequest: + description: Request body for `POST /v1/policy/validate`. + type: object + properties: + Draft: + description: Raw draft JSON retained without dropping unknown members. + RequestKind: + $ref: '#/components/schemas/PolicyValidationRequestKind' + RequestVersion: + $ref: '#/components/schemas/ApiVersion' + additionalProperties: false + required: + - RequestKind + - RequestVersion + - Draft + PolicyValidationRequestKind: + type: string + pattern: ^PolicyValidationRequest$ + PolicyValidationResponse: + description: Response body for `POST /v1/policy/validate`. + type: object + properties: + ResponseKind: + $ref: '#/components/schemas/PolicyValidationResponseKind' + ResponseVersion: + $ref: '#/components/schemas/ApiVersion' + Server: + $ref: '#/components/schemas/ServerContext' + Validation: + $ref: '#/components/schemas/PolicyValidationResult' + additionalProperties: false + required: + - ResponseKind + - ResponseVersion + - Server + - Validation + PolicyValidationResponseKind: + type: string + pattern: ^PolicyValidationResponse$ + PolicyValidationResult: + allOf: + - $ref: '#/components/schemas/PolicyValidationResultFields' + oneOf: + - properties: + IsValid: + enum: + - true + required: + - CanonicalDraft + - ValidationReceipt + - properties: + IsValid: + enum: + - false + not: + anyOf: + - required: + - CanonicalDraft + - required: + - ValidationReceipt + PolicyValidationResultFields: + type: object + properties: + CanonicalDraft: + allOf: + - $ref: '#/components/schemas/PolicyDraftDocument' + default: null + Findings: + type: array + items: + $ref: '#/components/schemas/PolicyFinding' + IsValid: + type: boolean + ResultVersion: + $ref: '#/components/schemas/ApiVersion' + ValidationReceipt: + anyOf: + - $ref: '#/components/schemas/PolicyValidationReceipt' + - enum: + - null + nullable: true + default: null + ValidatorVersion: + type: string + maxLength: 128 + minLength: 1 + additionalProperties: false + required: + - ResultVersion + - ValidatorVersion + - IsValid + - Findings + PolicyWriteCapability: + description: Advisory ability to write the configured policy through the management API. + type: string + enum: + - Writable + - ReadOnly + - Unsupported ProcessName: description: A process name string. type: string @@ -1458,6 +1951,83 @@ components: - Metadata - Enforcement - Rules + PolicyDraftDocument: + description: An editable policy document without server-managed commit metadata. + type: object + properties: + $schema: + description: Policy schema URI constant. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicySchemaUri' + Enforcement: + description: Enforcement configuration. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyEnforcement' + Metadata: + description: Editable policy metadata. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyDraftMetadata' + PolicyType: + description: Must be `"PackageBrokerPolicy"`. + allOf: + - $ref: '#/components/schemas/PolicyModelPackageBrokerPolicy' + PolicyVersion: + description: Policy syntax version (semver). + allOf: + - $ref: '#/components/schemas/PolicyModelSemanticVersion' + Rules: + description: Ordered list of policy rules (may be empty; enforcement defaults apply). + type: array + items: + $ref: '#/components/schemas/PolicyModelPolicyRule' + maxItems: 1024 + additionalProperties: false + required: + - $schema + - PolicyVersion + - PolicyType + - Metadata + - Enforcement + - Rules + PolicyModelPolicyDraftMetadata: + description: Editable policy metadata without server-managed revision and publication time. + type: object + properties: + Description: + description: Human-readable description. + type: string + maxLength: 512 + nullable: true + Id: + description: Unique policy identifier. + allOf: + - $ref: '#/components/schemas/PolicyModelResourceId' + Publisher: + description: Organization that publishes the policy. + type: string + maxLength: 128 + minLength: 1 + SupportUrl: + description: URL for support or documentation. + anyOf: + - $ref: '#/components/schemas/PolicyModelHttpUrl' + - enum: + - null + 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 + required: + - Id + - Publisher PolicyModelPolicyEnforcement: description: Enforcement configuration. type: object @@ -1503,41 +2073,42 @@ components: type: array items: type: boolean - maxItems: 2 + maxItems: 1 uniqueItems: true HasCustomParameters: description: Whether request has custom parameters. type: array items: type: boolean - maxItems: 2 + maxItems: 1 uniqueItems: true HasKillBeforeOperation: description: Whether request has kill-before-operation entries. type: array items: type: boolean - maxItems: 2 + maxItems: 1 uniqueItems: true HasPrePostCommands: description: Whether request has pre/post operation commands. type: array items: type: boolean - maxItems: 2 + maxItems: 1 uniqueItems: true HasUninstallPrevious: description: Whether request has uninstall-previous flag set. type: array items: type: boolean + maxItems: 1 uniqueItems: true Interactive: description: Allowed interactive values. type: array items: type: boolean - maxItems: 2 + maxItems: 1 uniqueItems: true Managers: description: Allowed managers. @@ -1572,7 +2143,7 @@ components: type: array items: type: boolean - maxItems: 2 + maxItems: 1 uniqueItems: true Scopes: description: Allowed scopes. @@ -1586,7 +2157,7 @@ components: type: array items: type: boolean - maxItems: 2 + maxItems: 1 uniqueItems: true Sources: description: Source patterns (wildcard). diff --git a/policies/rust/now-policy-api/src/api.rs b/policies/rust/now-policy-api/src/api.rs index 00ec0b4..0421db4 100644 --- a/policies/rust/now-policy-api/src/api.rs +++ b/policies/rust/now-policy-api/src/api.rs @@ -314,4 +314,8 @@ pub struct ErrorResponse { /// Structured error details. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub details: Vec, + + /// Current authoritative policy findings for management errors. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub validation: Option, } diff --git a/policies/rust/now-policy-api/src/enums.rs b/policies/rust/now-policy-api/src/enums.rs index 29f0dcb..8c1d50b 100644 --- a/policies/rust/now-policy-api/src/enums.rs +++ b/policies/rust/now-policy-api/src/enums.rs @@ -125,4 +125,15 @@ pub enum ErrorCode { BrokerPaused, InternalError, Timeout, + UnsupportedEndpoint, + MalformedDraft, + InvalidPolicy, + WarningConfirmationRequired, + Unauthenticated, + AdministratorRequired, + UnsafePolicyPath, + StalePolicyStoreToken, + UnsupportedPolicyFilesystem, + PolicyPersistenceFailed, + PolicyActivationFailed, } diff --git a/policies/rust/now-policy-api/src/lib.rs b/policies/rust/now-policy-api/src/lib.rs index 652e09b..2e8cef1 100644 --- a/policies/rust/now-policy-api/src/lib.rs +++ b/policies/rust/now-policy-api/src/lib.rs @@ -11,6 +11,7 @@ pub mod evaluate; pub mod event_channel; pub mod execute; pub mod health; +pub mod management; pub mod policy; pub mod status; @@ -22,6 +23,7 @@ pub use evaluate::*; pub use event_channel::*; pub use execute::*; pub use health::*; +pub use management::*; pub use policy::*; pub use status::*; @@ -31,6 +33,8 @@ pub const DEFAULT_PIPE_NAME: &str = "Devolutions.Now.PackageBroker.v1"; pub const PACKAGE_REQUEST_KIND: &str = "PackageRequest"; pub const STATUS_REQUEST_KIND: &str = "StatusRequest"; pub const CANCEL_REQUEST_KIND: &str = "CancelRequest"; +pub const POLICY_VALIDATION_REQUEST_KIND: &str = "PolicyValidationRequest"; +pub const POLICY_REPLACEMENT_REQUEST_KIND: &str = "PolicyReplacementRequest"; pub const HEALTH_RESPONSE_KIND: &str = "HealthResponse"; pub const CAPABILITIES_RESPONSE_KIND: &str = "CapabilitiesResponse"; @@ -39,6 +43,9 @@ pub const EXECUTION_RESPONSE_KIND: &str = "ExecutionResponse"; pub const STATUS_RESPONSE_KIND: &str = "StatusResponse"; pub const CANCEL_RESPONSE_KIND: &str = "CancelResponse"; pub const POLICY_RESPONSE_KIND: &str = "PolicyResponse"; +pub const POLICY_MANAGEMENT_RESPONSE_KIND: &str = "PolicyManagementResponse"; +pub const POLICY_VALIDATION_RESPONSE_KIND: &str = "PolicyValidationResponse"; +pub const POLICY_REPLACEMENT_RESPONSE_KIND: &str = "PolicyReplacementResponse"; pub const ERROR_RESPONSE_KIND: &str = "ErrorResponse"; macro_rules! fixed_string_marker { @@ -91,6 +98,8 @@ macro_rules! fixed_string_marker { fixed_string_marker!(PackageRequestKind, PACKAGE_REQUEST_KIND); fixed_string_marker!(StatusRequestKind, STATUS_REQUEST_KIND); fixed_string_marker!(CancelRequestKind, CANCEL_REQUEST_KIND); +fixed_string_marker!(PolicyValidationRequestKind, POLICY_VALIDATION_REQUEST_KIND); +fixed_string_marker!(PolicyReplacementRequestKind, POLICY_REPLACEMENT_REQUEST_KIND); fixed_string_marker!(HealthResponseKind, HEALTH_RESPONSE_KIND); fixed_string_marker!(CapabilitiesResponseKind, CAPABILITIES_RESPONSE_KIND); fixed_string_marker!(EvaluationResponseKind, EVALUATION_RESPONSE_KIND); @@ -98,6 +107,9 @@ fixed_string_marker!(ExecutionResponseKind, EXECUTION_RESPONSE_KIND); fixed_string_marker!(StatusResponseKind, STATUS_RESPONSE_KIND); fixed_string_marker!(CancelResponseKind, CANCEL_RESPONSE_KIND); fixed_string_marker!(PolicyResponseKind, POLICY_RESPONSE_KIND); +fixed_string_marker!(PolicyManagementResponseKind, POLICY_MANAGEMENT_RESPONSE_KIND); +fixed_string_marker!(PolicyValidationResponseKind, POLICY_VALIDATION_RESPONSE_KIND); +fixed_string_marker!(PolicyReplacementResponseKind, POLICY_REPLACEMENT_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/management.rs b/policies/rust/now-policy-api/src/management.rs new file mode 100644 index 0000000..9876740 --- /dev/null +++ b/policies/rust/now-policy-api/src/management.rs @@ -0,0 +1,445 @@ +//! Policy management, validation, and replacement endpoint models. + +#![allow( + unused_qualifications, + reason = "schemars schema_with expansion triggers this lint for a qualified function name" +)] + +use std::collections::BTreeMap; + +use now_policy::{PolicyDocument, PolicyDraftDocument}; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Serialize}; + +use super::api::ServerContext; +use super::{ + ApiVersion, PolicyManagementResponseKind, PolicyReplacementRequestKind, PolicyReplacementResponseKind, + PolicyValidationRequestKind, PolicyValidationResponseKind, ResourceId, validate_bounded_string, +}; + +/// Current configured-policy state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyManagementState")] +pub enum PolicyManagementState { + Active, + Missing, + Invalid, +} + +/// Origin of the resolved policy path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyConfigurationSource")] +pub enum PolicyConfigurationSource { + DefaultPath, + ConfiguredPath, +} + +/// Advisory ability to write the configured policy through the management API. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyWriteCapability")] +pub enum PolicyWriteCapability { + Writable, + ReadOnly, + Unsupported, +} + +/// Stable reason why the configured policy cannot be written. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyReadOnlyReason")] +pub enum PolicyReadOnlyReason { + ManagementDisabled, + PathNotConfigured, + UnsafePath, + InsufficientPermissions, + UnsupportedFileSystem, +} + +/// Requested identity/revision behavior for a policy replacement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyReplacementOperation")] +pub enum PolicyReplacementOperation { + /// Update the active policy while retaining its identity and incrementing its revision. + Update, + /// Replace the active policy with an explicitly different identity at revision 1. + ReplaceIdentity, + /// Create the first policy at revision 1. + Create, + /// Replace an invalid configured document at revision 1. + Repair, +} + +/// Optimistic-conflict behavior for policy replacement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyConflictHandling")] +pub enum PolicyConflictHandling { + Reject, + /// Overwrite only the exact newly observed store token carried by this request. + ConfirmOverwrite, +} + +/// Severity of a policy validation finding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyFindingSeverity")] +pub enum PolicyFindingSeverity { + Error, + Warning, +} + +/// Stable policy validation finding code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyFindingCode")] +pub enum PolicyFindingCode { + SchemaViolation, + UnknownField, + MissingRequiredField, + InvalidFieldType, + InvalidFieldValue, + DuplicateRuleId, + IneffectiveBooleanMatch, + InvalidVersionRange, + EmptyVersionRange, + InvalidWildcardPattern, + ContradictoryConstraints, + InvalidValidityInterval, + UnsupportedSchema, + UnsupportedPolicyType, + UnsupportedPolicyVersion, + AuditModeEnabled, + DefaultAllow, + SensitiveOptionAllowed, +} + +/// Opaque token identifying the exact state observed in the policy store. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Serialize, + JsonSchema, + derive_more::AsRef, + derive_more::Deref, + derive_more::Display, + derive_more::From, +)] +#[as_ref(str)] +#[deref(forward)] +#[display("{_0}")] +pub struct PolicyStoreToken(#[schemars(length(min = 1, max = 512))] pub String); + +impl<'de> Deserialize<'de> for PolicyStoreToken { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + validate_bounded_string(&value, 1, 512, "PolicyStoreToken").map_err(serde::de::Error::custom)?; + Ok(Self(value)) + } +} + +impl From<&str> for PolicyStoreToken { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } +} + +/// Opaque receipt bound to a canonical draft, validator version, and exact warning set. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Serialize, + JsonSchema, + derive_more::AsRef, + derive_more::Deref, + derive_more::Display, + derive_more::From, +)] +#[as_ref(str)] +#[deref(forward)] +#[display("{_0}")] +pub struct PolicyValidationReceipt(#[schemars(length(min = 1, max = 2048))] pub String); + +impl<'de> Deserialize<'de> for PolicyValidationReceipt { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + validate_bounded_string(&value, 1, 2048, "PolicyValidationReceipt").map_err(serde::de::Error::custom)?; + Ok(Self(value)) + } +} + +impl From<&str> for PolicyValidationReceipt { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } +} + +/// Versioned, structured policy validation finding. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyFinding")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyFinding { + /// Finding shape version. + pub finding_version: ApiVersion, + + pub severity: PolicyFindingSeverity, + pub code: PolicyFindingCode, + + /// RFC 6901 JSON Pointer into the submitted draft. + #[schemars(length(max = 2048))] + pub path: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rule_id: Option, + + /// Machine-readable message arguments for localization. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub arguments: BTreeMap, + + /// Human-readable fallback for clients that do not recognize the code. + #[schemars(length(min = 1, max = 2048))] + pub message: String, +} + +/// Authoritative validation output. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PolicyValidationResultWire")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyValidationResult { + /// Validation result shape version. + pub result_version: ApiVersion, + + /// Version of the implementation validator that produced the receipt. + pub validator_version: String, + + pub is_valid: bool, + + /// Canonical typed draft, present only when validation succeeds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canonical_draft: Option, + + /// Receipt bound to the canonical draft, validator version, and exact warning set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub validation_receipt: Option, + + pub findings: Vec, +} + +#[derive(Deserialize, JsonSchema)] +#[schemars(rename = "PolicyValidationResultFields")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +struct PolicyValidationResultWire { + pub result_version: ApiVersion, + #[schemars(length(min = 1, max = 128))] + pub validator_version: String, + pub is_valid: bool, + #[serde(default)] + #[schemars(schema_with = "super::policy::policy_draft_document_schema")] + pub canonical_draft: Option, + #[serde(default)] + pub validation_receipt: Option, + pub findings: Vec, +} + +impl TryFrom for PolicyValidationResult { + type Error = String; + + fn try_from(value: PolicyValidationResultWire) -> Result { + let has_success_artifacts = value.canonical_draft.is_some() && value.validation_receipt.is_some(); + let has_any_success_artifact = value.canonical_draft.is_some() || value.validation_receipt.is_some(); + if value.is_valid && !has_success_artifacts { + return Err("valid policy validation results require CanonicalDraft and ValidationReceipt".to_owned()); + } + if !value.is_valid && has_any_success_artifact { + return Err( + "invalid policy validation results must not contain CanonicalDraft or ValidationReceipt".to_owned(), + ); + } + + Ok(Self { + result_version: value.result_version, + validator_version: value.validator_version, + is_valid: value.is_valid, + canonical_draft: value.canonical_draft, + validation_receipt: value.validation_receipt, + findings: value.findings, + }) + } +} + +impl JsonSchema for PolicyValidationResult { + fn schema_name() -> std::borrow::Cow<'static, str> { + "PolicyValidationResult".into() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + let fields = generator.subschema_for::(); + json_schema!({ + "allOf": [fields], + "oneOf": [ + { + "properties": { + "IsValid": { "const": true } + }, + "required": ["CanonicalDraft", "ValidationReceipt"] + }, + { + "properties": { + "IsValid": { "const": false } + }, + "not": { + "anyOf": [ + { "required": ["CanonicalDraft"] }, + { "required": ["ValidationReceipt"] } + ] + } + } + ] + }) + } +} + +/// Sanitized diagnostics for an invalid configured policy. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "InvalidPolicyDiagnostics")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct InvalidPolicyDiagnostics { + pub diagnostics_version: ApiVersion, + pub findings: Vec, +} + +/// Atomic view of configured policy state and management guidance. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyManagementSnapshot")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyManagementSnapshot { + pub state: PolicyManagementState, + + /// Fully resolved configured path. + #[schemars(length(min = 1, max = 32767))] + pub configured_path: String, + + pub store_token: PolicyStoreToken, + pub source: PolicyConfigurationSource, + pub write_capability: PolicyWriteCapability, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_only_reason: Option, + + pub elevation_required: bool, + + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "super::policy::policy_document_schema")] + pub policy: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invalid_diagnostics: Option, +} + +/// Response body for `GET /v1/policy/management`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyManagementResponse")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyManagementResponse { + pub response_kind: PolicyManagementResponseKind, + pub response_version: ApiVersion, + pub server: ServerContext, + pub management: PolicyManagementSnapshot, +} + +/// Request body for `POST /v1/policy/validate`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyValidationRequest")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyValidationRequest { + pub request_kind: PolicyValidationRequestKind, + pub request_version: ApiVersion, + + /// Raw draft JSON retained without dropping unknown members. + pub draft: serde_json::Value, +} + +/// Response body for `POST /v1/policy/validate`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyValidationResponse")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyValidationResponse { + pub response_kind: PolicyValidationResponseKind, + pub response_version: ApiVersion, + pub server: ServerContext, + pub validation: PolicyValidationResult, +} + +/// Request body for `PUT /v1/policy`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyReplacementRequest")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyReplacementRequest { + pub request_kind: PolicyReplacementRequestKind, + pub request_version: ApiVersion, + pub expected_store_token: PolicyStoreToken, + pub operation: PolicyReplacementOperation, + pub conflict_handling: PolicyConflictHandling, + + /// Explicit acknowledgement of every warning bound into the validation receipt. + pub warnings_acknowledged: bool, + + /// Raw draft JSON retained for transaction-time reparsing and revalidation. + pub draft: serde_json::Value, + + pub validation_receipt: PolicyValidationReceipt, +} + +/// Response body for `PUT /v1/policy`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyReplacementResponse")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyReplacementResponse { + pub response_kind: PolicyReplacementResponseKind, + pub response_version: ApiVersion, + pub server: ServerContext, + + /// Exact committed active policy, including server-assigned metadata. + #[schemars(schema_with = "super::policy::policy_document_schema")] + pub policy: PolicyDocument, + + /// Transaction-time validation result for the exact committed draft. + pub validation: PolicyValidationResult, + + /// Newly observed management state and store token. + pub management: PolicyManagementSnapshot, +} + +#[cfg(test)] +mod tests { + use super::PolicyValidationResult; + + #[test] + fn validation_result_requires_success_artifacts_exactly_when_valid() { + let valid_without_artifacts = serde_json::json!({ + "ResultVersion": "1.0", + "ValidatorVersion": "validator/1", + "IsValid": true, + "Findings": [] + }); + assert!(serde_json::from_value::(valid_without_artifacts).is_err()); + + let invalid_with_receipt = serde_json::json!({ + "ResultVersion": "1.0", + "ValidatorVersion": "validator/1", + "IsValid": false, + "ValidationReceipt": "receipt", + "Findings": [] + }); + assert!(serde_json::from_value::(invalid_with_receipt).is_err()); + } +} diff --git a/policies/rust/now-policy-api/src/policy.rs b/policies/rust/now-policy-api/src/policy.rs index 1fe4623..e1cc9b9 100644 --- a/policies/rust/now-policy-api/src/policy.rs +++ b/policies/rust/now-policy-api/src/policy.rs @@ -31,8 +31,14 @@ pub struct PolicyResponse { pub policy: PolicyDocument, } -fn policy_document_schema(_generator: &mut SchemaGenerator) -> Schema { +pub(crate) fn policy_document_schema(_generator: &mut SchemaGenerator) -> Schema { schemars::json_schema!({ "$ref": "#/components/schemas/PolicyDocument", }) } + +pub(crate) fn policy_draft_document_schema(_generator: &mut SchemaGenerator) -> Schema { + schemars::json_schema!({ + "$ref": "#/components/schemas/PolicyDraftDocument", + }) +} diff --git a/policies/rust/now-policy-server-template/CHANGELOG.md b/policies/rust/now-policy-server-template/CHANGELOG.md index 1726eb2..6a86ba5 100644 --- a/policies/rust/now-policy-server-template/CHANGELOG.md +++ b/policies/rust/now-policy-server-template/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- [**breaking**] Add required policy management, validation, and optimistic replacement trait methods, routes, status mappings, and OpenAPI operations. + ## [[0.3.0](https://github.com/Devolutions/now-libraries/compare/now-policy-server-template-v0.2.0...now-policy-server-template-v0.3.0)] - 2026-08-05 diff --git a/policies/rust/now-policy-server-template/README.md b/policies/rust/now-policy-server-template/README.md index e1e0600..17c09f5 100644 --- a/policies/rust/now-policy-server-template/README.md +++ b/policies/rust/now-policy-server-template/README.md @@ -37,6 +37,9 @@ pub trait PackageBrokerServer: Send + Sync { async fn health(&self) -> HealthResponse; async fn capabilities(&self) -> CapabilitiesResponse; async fn active_policy(&self) -> Result; + async fn policy_management(&self) -> Result; + async fn validate_policy(&self, request: PolicyValidationRequest) -> Result; + async fn replace_policy(&self, request: PolicyReplacementRequest) -> Result; async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; async fn status(&self, request: StatusRequest) -> Result; @@ -50,6 +53,9 @@ Then they pass the implementation to `api_router` or `api_router_from_shared`. T - `GET /v1/health` - `GET /v1/capabilities` - `GET /v1/policy` +- `GET /v1/policy/management` +- `POST /v1/policy/validate` +- `PUT /v1/policy` - `POST /v1/package-operations/evaluate` - `POST /v1/package-operations/execute` - `POST /v1/package-operations/get-status` diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index 63d3fe5..1f181e5 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -3,19 +3,22 @@ use std::sync::Arc; use aide::axum::ApiRouter; -use aide::axum::routing::{get_with, post_with}; +use aide::axum::routing::{get_with, post_with, put_with}; use aide::openapi::OpenApi; use aide::transform::TransformOperation; use async_trait::async_trait; use axum::Json; use axum::extract::State; +use axum::extract::rejection::JsonRejection; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Serialize; use now_policy_api::{ API_VERSION_STR, CancelRequest, CancelResponse, CapabilitiesResponse, ErrorCode, ErrorResponse, EvaluationResponse, - ExecutionResponse, HealthResponse, PackageRequest, PolicyResponse, StatusRequest, StatusResponse, + ExecutionResponse, HealthResponse, PackageRequest, PolicyManagementResponse, PolicyReplacementRequest, + PolicyReplacementResponse, PolicyResponse, PolicyValidationRequest, PolicyValidationResponse, StatusRequest, + StatusResponse, }; use schemars::SchemaGenerator; @@ -27,6 +30,15 @@ pub trait PackageBrokerServer: Send + Sync { async fn health(&self) -> HealthResponse; async fn capabilities(&self) -> CapabilitiesResponse; async fn active_policy(&self) -> Result; + async fn policy_management(&self) -> Result; + async fn validate_policy( + &self, + request: PolicyValidationRequest, + ) -> Result; + async fn replace_policy( + &self, + request: PolicyReplacementRequest, + ) -> Result; async fn evaluate(&self, request: PackageRequest) -> Result; async fn execute(&self, request: PackageRequest) -> Result; async fn status(&self, request: StatusRequest) -> Result; @@ -54,6 +66,20 @@ fn api_routes() -> ApiRouter { .api_route("/v1/health", get_with(health_handler, health_docs)) .api_route("/v1/capabilities", get_with(capabilities_handler, capabilities_docs)) .api_route("/v1/policy", get_with(policy_handler, policy_docs)) + .api_route( + "/v1/policy/management", + get_with(policy_management_handler, policy_management_docs), + ) + .api_route( + "/v1/policy/validate", + post_with(policy_validation_handler, policy_validation_docs) + .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES)), + ) + .api_route( + "/v1/policy", + put_with(policy_replacement_handler, policy_replacement_docs) + .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES)), + ) .api_route( "/v1/package-operations/evaluate", post_with(evaluate_handler, evaluate_docs) @@ -109,15 +135,16 @@ fn register_policy_schema(api: &mut OpenApi) { use std::collections::BTreeMap; use aide::openapi::{Components, SchemaObject}; - use now_policy::PolicyDocument; + use now_policy::{PolicyDocument, PolicyDraftDocument}; let mut generator = openapi_schema_generator(); let _ = generator.subschema_for::(); + let _ = generator.subschema_for::(); let definitions = generator.take_definitions(true); let renames: BTreeMap<_, _> = definitions .keys() .map(|name| { - let component_name = if name == "PolicyDocument" { + let component_name = if matches!(name.as_str(), "PolicyDocument" | "PolicyDraftDocument") { name.clone() } else { format!("PolicyModel{name}") @@ -190,32 +217,94 @@ async fn policy_handler(State(server): State) -> Resp broker_result(server.active_policy().await) } +async fn policy_management_handler(State(server): State) -> Response { + broker_result(server.policy_management().await) +} + +async fn policy_validation_handler( + State(server): State, + request: Result, JsonRejection>, +) -> Response { + match request { + Ok(Json(request)) => broker_result(server.validate_policy(request).await), + Err(rejection) => request_rejection(&server, rejection, ErrorCode::MalformedDraft).await, + } +} + +async fn policy_replacement_handler( + State(server): State, + request: Result, JsonRejection>, +) -> Response { + match request { + Ok(Json(request)) => broker_result(server.replace_policy(request).await), + Err(rejection) => request_rejection(&server, rejection, ErrorCode::MalformedDraft).await, + } +} + async fn evaluate_handler( State(server): State, - Json(request): Json, + request: Result, JsonRejection>, ) -> Response { - broker_result(server.evaluate(request).await) + match request { + Ok(Json(request)) => broker_result(server.evaluate(request).await), + Err(rejection) => request_rejection(&server, rejection, ErrorCode::BadRequest).await, + } } async fn execute_handler( State(server): State, - Json(request): Json, + request: Result, JsonRejection>, ) -> Response { - broker_result(server.execute(request).await) + match request { + Ok(Json(request)) => broker_result(server.execute(request).await), + Err(rejection) => request_rejection(&server, rejection, ErrorCode::BadRequest).await, + } } async fn status_handler( State(server): State, - Json(request): Json, + request: Result, JsonRejection>, ) -> Response { - broker_result(server.status(request).await) + match request { + Ok(Json(request)) => broker_result(server.status(request).await), + Err(rejection) => request_rejection(&server, rejection, ErrorCode::BadRequest).await, + } } async fn cancel_handler( State(server): State, - Json(request): Json, + request: Result, JsonRejection>, +) -> Response { + match request { + Ok(Json(request)) => broker_result(server.cancel(request).await), + Err(rejection) => request_rejection(&server, rejection, ErrorCode::BadRequest).await, + } +} + +async fn request_rejection( + server: &SharedPackageBrokerServer, + rejection: JsonRejection, + malformed_code: ErrorCode, ) -> Response { - broker_result(server.cancel(request).await) + let status = rejection.status(); + let (code, message) = match status { + StatusCode::PAYLOAD_TOO_LARGE => (ErrorCode::PayloadTooLarge, "request body exceeds the broker limit"), + StatusCode::UNSUPPORTED_MEDIA_TYPE => ( + ErrorCode::UnsupportedMediaType, + "request Content-Type must be application/json", + ), + _ => (malformed_code, "request body is not a valid broker document"), + }; + let error = ErrorResponse { + response_kind: now_policy_api::ErrorResponseKind, + response_version: API_VERSION_STR.into(), + server: server.capabilities().await.server, + code, + message: message.to_owned(), + details: Vec::new(), + validation: None, + }; + (error_status(error.code), Json(error)).into_response() } fn broker_result(result: Result) -> Response { @@ -227,17 +316,24 @@ fn broker_result(result: Result) -> Response { fn error_status(code: ErrorCode) -> StatusCode { match code { - ErrorCode::BadRequest => StatusCode::BAD_REQUEST, - ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED, - ErrorCode::Forbidden => StatusCode::FORBIDDEN, + ErrorCode::BadRequest | ErrorCode::MalformedDraft => StatusCode::BAD_REQUEST, + ErrorCode::Unauthorized | ErrorCode::Unauthenticated => StatusCode::UNAUTHORIZED, + ErrorCode::Forbidden | ErrorCode::AdministratorRequired | ErrorCode::UnsafePolicyPath => StatusCode::FORBIDDEN, ErrorCode::NotFound => StatusCode::NOT_FOUND, - ErrorCode::Conflict => StatusCode::CONFLICT, + ErrorCode::Conflict | ErrorCode::WarningConfirmationRequired | ErrorCode::StalePolicyStoreToken => { + StatusCode::CONFLICT + } ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, ErrorCode::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, - ErrorCode::ValidationFailed => StatusCode::UNPROCESSABLE_ENTITY, + ErrorCode::ValidationFailed | ErrorCode::InvalidPolicy | ErrorCode::UnsupportedPolicyFilesystem => { + StatusCode::UNPROCESSABLE_ENTITY + } ErrorCode::BrokerPaused => StatusCode::SERVICE_UNAVAILABLE, - ErrorCode::InternalError => StatusCode::INTERNAL_SERVER_ERROR, + ErrorCode::InternalError | ErrorCode::PolicyPersistenceFailed | ErrorCode::PolicyActivationFailed => { + StatusCode::INTERNAL_SERVER_ERROR + } ErrorCode::Timeout => StatusCode::GATEWAY_TIMEOUT, + ErrorCode::UnsupportedEndpoint => StatusCode::NOT_IMPLEMENTED, } } @@ -261,6 +357,43 @@ fn policy_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { .default_response::>() } +fn policy_management_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { + op.summary("Get policy management state") + .description( + "Atomically returns configured policy state and advisory write capability. \ + Capability fields are UX guidance and are rechecked during replacement.", + ) + .response::<200, Json>() + .default_response::>() +} + +fn policy_validation_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { + op.summary("Validate a policy draft") + .description( + "Authoritatively validates raw draft JSON without discarding unknown fields. \ + Validation findings are returned with HTTP 200; malformed envelopes use ErrorResponse.", + ) + .response::<200, Json>() + .response::<400, Json>() + .default_response::>() +} + +fn policy_replacement_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { + op.summary("Replace the configured policy") + .description( + "Reparses and revalidates the raw draft inside the write transaction, then atomically \ + commits it only when the expected opaque store token and validation receipt still match.", + ) + .response::<200, Json>() + .response::<400, Json>() + .response::<401, Json>() + .response::<403, Json>() + .response::<409, Json>() + .response::<422, Json>() + .response::<500, Json>() + .response::<501, Json>() +} + fn evaluate_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { op.summary("Evaluate package operation") .description("Evaluates a package operation against policy without requiring elevated execution.") 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 94654b6..6b054ea 100644 --- a/policies/rust/now-policy-server-template/tests/sample_documents.rs +++ b/policies/rust/now-policy-server-template/tests/sample_documents.rs @@ -8,8 +8,9 @@ use now_policy_server_template::{ API_VERSION_STR, CancelRequest, CancelResponse, CapabilitiesResponse, CapabilitiesResponseKind, DEFAULT_PIPE_NAME, ErrorCode, ErrorResponse, ErrorResponseKind, EvaluationResponse, ExecutionResponse, HealthResponse, HealthResponseKind, HealthStatus, MAX_REQUEST_BODY_BYTES, ManagerName, Operation, PackageBrokerServer, - PackageRequest, PolicyResponse, PolicyResponseKind, Scope, ServerContext, StatusRequest, StatusRequestKind, - StatusResponse, Transport, api_router, + PackageRequest, PolicyManagementResponse, PolicyReplacementRequest, PolicyReplacementResponse, PolicyResponse, + PolicyResponseKind, PolicyValidationRequest, PolicyValidationResponse, Scope, ServerContext, StatusRequest, + StatusRequestKind, StatusResponse, Transport, api_router, }; use tower::ServiceExt; @@ -77,7 +78,19 @@ 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") { + } else if name.starts_with("policy-management.") { + let _: PolicyManagementResponse = 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-validation.") { + let _: PolicyValidationResponse = serde_json::from_value(load_json_file(path)) + .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); + } else if name == "policy-replacement.response.json" { + let _: PolicyReplacementResponse = serde_json::from_value(load_json_file(path)) + .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); + } else if name == "policy-stale-token.error.json" { + let _: ErrorResponse = serde_json::from_value(load_json_file(path)) + .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); + } else if name == "policy.response.json" { let _: PolicyResponse = serde_json::from_value(load_json_file(path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); } else { @@ -98,7 +111,14 @@ fn response_sample_path(name: &str) -> PathBuf { fn all_sample_requests_deserialize() { for path in sample_document_files(&samples_dir().join("requests")) { let name = path.file_name().unwrap().to_string_lossy(); - if name.starts_with("status-") { + if name == "policy-validation.request.json" { + let request: PolicyValidationRequest = serde_json::from_value(load_json_file(&path)) + .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); + assert_eq!(request.draft["EditorExtension"]["preserved"], true); + } else if name.starts_with("policy-replacement.") { + let _: PolicyReplacementRequest = serde_json::from_value(load_json_file(&path)) + .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); + } else if name.starts_with("status-") { let _: StatusRequest = serde_json::from_value(load_json_file(&path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); } else if name.starts_with("cancel-") { @@ -494,6 +514,7 @@ async fn api_router_preserves_supported_policy_failure() { code: ErrorCode::BrokerPaused, message: "active policy is temporarily unavailable".to_owned(), details: Vec::new(), + validation: None, }; let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME).with_policy_error(error)); @@ -515,6 +536,133 @@ async fn api_router_preserves_supported_policy_failure() { assert_eq!(error.code, ErrorCode::BrokerPaused); } +#[tokio::test] +async fn api_router_serves_policy_management_validation_and_replacement() { + let management: PolicyManagementResponse = serde_json::from_value(load_json_file(&response_sample_path( + "policy-management.active.response.json", + ))) + .unwrap(); + let validation: PolicyValidationResponse = serde_json::from_value(load_json_file(&response_sample_path( + "policy-validation.valid.response.json", + ))) + .unwrap(); + let replacement: PolicyReplacementResponse = serde_json::from_value(load_json_file(&response_sample_path( + "policy-replacement.response.json", + ))) + .unwrap(); + let validation_request = load_text_file(&samples_dir().join("requests/policy-validation.request.json")); + let replacement_request = load_text_file(&samples_dir().join("requests/policy-replacement.update.request.json")); + let app = api_router( + MockPackageBrokerServer::new(DEFAULT_PIPE_NAME) + .with_policy_management_response(management) + .with_policy_validation_response(validation) + .with_policy_replacement_response(replacement), + ); + + for (method, uri, body) in [ + ("GET", "/v1/policy/management", String::new()), + ("POST", "/v1/policy/validate", validation_request), + ("PUT", "/v1/policy", replacement_request), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{method} {uri}"); + } +} + +#[tokio::test] +async fn policy_management_error_codes_use_stable_http_statuses() { + for (code, expected) in [ + (ErrorCode::UnsupportedEndpoint, StatusCode::NOT_IMPLEMENTED), + (ErrorCode::MalformedDraft, StatusCode::BAD_REQUEST), + (ErrorCode::InvalidPolicy, StatusCode::UNPROCESSABLE_ENTITY), + (ErrorCode::WarningConfirmationRequired, StatusCode::CONFLICT), + (ErrorCode::Unauthenticated, StatusCode::UNAUTHORIZED), + (ErrorCode::AdministratorRequired, StatusCode::FORBIDDEN), + (ErrorCode::UnsafePolicyPath, StatusCode::FORBIDDEN), + (ErrorCode::StalePolicyStoreToken, StatusCode::CONFLICT), + (ErrorCode::UnsupportedPolicyFilesystem, StatusCode::UNPROCESSABLE_ENTITY), + (ErrorCode::PolicyPersistenceFailed, StatusCode::INTERNAL_SERVER_ERROR), + (ErrorCode::PolicyActivationFailed, StatusCode::INTERNAL_SERVER_ERROR), + ] { + 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, + message: "management error".to_owned(), + details: Vec::new(), + validation: None, + }; + 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(), expected, "{code:?}"); + } +} + +#[tokio::test] +async fn policy_management_request_rejections_are_structured() { + let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); + let oversized = " ".repeat(MAX_REQUEST_BODY_BYTES + 1); + + for (content_type, body, expected_status, expected_code) in [ + ( + Some("application/json"), + "{".to_owned(), + StatusCode::BAD_REQUEST, + ErrorCode::MalformedDraft, + ), + ( + None, + "{}".to_owned(), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + ErrorCode::UnsupportedMediaType, + ), + ( + Some("application/json"), + oversized, + StatusCode::PAYLOAD_TOO_LARGE, + ErrorCode::PayloadTooLarge, + ), + ] { + let mut request = Request::builder().method("POST").uri("/v1/policy/validate"); + if let Some(content_type) = content_type { + request = request.header("content-type", content_type); + } + let response = app + .clone() + .oneshot(request.body(Body::from(body)).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected_status); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let error: ErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(error.code, expected_code); + } +} + #[tokio::test] async fn api_router_does_not_expose_a_policy_write_route() { let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); diff --git a/policies/rust/now-policy-server-template/tests/support/mock.rs b/policies/rust/now-policy-server-template/tests/support/mock.rs index 32e91d3..63866da 100644 --- a/policies/rust/now-policy-server-template/tests/support/mock.rs +++ b/policies/rust/now-policy-server-template/tests/support/mock.rs @@ -8,8 +8,9 @@ use now_policy_server_template::{ API_VERSION_STR, Architecture, CancelRequest, CancelResponse, CapabilitiesResponse, CapabilitiesResponseKind, ErrorCode, ErrorResponse, ErrorResponseKind, EvaluationResponse, ExecutionResponse, HealthResponse, HealthResponseKind, HealthStatus, MAX_REQUEST_BODY_BYTES, ManagerCapability, ManagerName, Operation, - PackageBrokerServer, PackageRequest, PolicyResponse, Scope, ServerContext, StatusRequest, StatusResponse, - Transport, + PackageBrokerServer, PackageRequest, PolicyManagementResponse, PolicyReplacementRequest, PolicyReplacementResponse, + PolicyResponse, PolicyValidationRequest, PolicyValidationResponse, Scope, ServerContext, StatusRequest, + StatusResponse, Transport, }; /// Deterministic mock broker backed by caller-provided sample responses. @@ -19,6 +20,9 @@ pub(crate) struct MockPackageBrokerServer { capabilities: CapabilitiesResponse, policy_response: Option, policy_error: Option, + policy_management_response: Option, + policy_validation_response: Option, + policy_replacement_response: Option, evaluation_responses: BTreeMap, execution_responses: BTreeMap, status_responses: BTreeMap, @@ -46,6 +50,9 @@ impl MockPackageBrokerServer { }, policy_response: None, policy_error: None, + policy_management_response: None, + policy_validation_response: None, + policy_replacement_response: None, evaluation_responses: BTreeMap::new(), execution_responses: BTreeMap::new(), status_responses: BTreeMap::new(), @@ -74,6 +81,24 @@ impl MockPackageBrokerServer { self } + #[must_use] + pub(crate) fn with_policy_management_response(mut self, response: PolicyManagementResponse) -> Self { + self.policy_management_response = Some(response); + self + } + + #[must_use] + pub(crate) fn with_policy_validation_response(mut self, response: PolicyValidationResponse) -> Self { + self.policy_validation_response = Some(response); + self + } + + #[must_use] + pub(crate) fn with_policy_replacement_response(mut self, response: PolicyReplacementResponse) -> Self { + self.policy_replacement_response = Some(response); + self + } + #[must_use] pub(crate) fn with_execution_response(mut self, response: ExecutionResponse) -> Self { self.execution_responses @@ -103,6 +128,19 @@ impl MockPackageBrokerServer { code: ErrorCode::NotFound, message: format!("no mock response registered for '{id}'"), details: Vec::new(), + validation: None, + } + } + + fn unsupported_endpoint(&self, endpoint: &str) -> ErrorResponse { + ErrorResponse { + response_kind: ErrorResponseKind, + response_version: API_VERSION_STR.into(), + server: self.capabilities.server.clone(), + code: ErrorCode::UnsupportedEndpoint, + message: format!("{endpoint} is not implemented by this mock"), + details: Vec::new(), + validation: None, } } } @@ -133,9 +171,34 @@ impl PackageBrokerServer for MockPackageBrokerServer { code: ErrorCode::NotFound, message: "no active policy is configured".to_owned(), details: Vec::new(), + validation: None, }) } + async fn policy_management(&self) -> Result { + self.policy_management_response + .clone() + .ok_or_else(|| self.unsupported_endpoint("policy management")) + } + + async fn validate_policy( + &self, + _request: PolicyValidationRequest, + ) -> Result { + self.policy_validation_response + .clone() + .ok_or_else(|| self.unsupported_endpoint("policy validation")) + } + + async fn replace_policy( + &self, + _request: PolicyReplacementRequest, + ) -> Result { + self.policy_replacement_response + .clone() + .ok_or_else(|| self.unsupported_endpoint("policy replacement")) + } + async fn evaluate(&self, request: PackageRequest) -> Result { self.evaluation_responses .get(&request.request_id.to_string()) diff --git a/policies/rust/now-policy/CHANGELOG.md b/policies/rust/now-policy/CHANGELOG.md index 4fe2ac0..d127b92 100644 --- a/policies/rust/now-policy/CHANGELOG.md +++ b/policies/rust/now-policy/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- [**breaking**] Make policy documents JSON-only and remove `parse_policy_yaml`. +- Add editable `PolicyDraftDocument` and explicit committed/draft conversions. +- Reject boolean match arrays containing more than one value. + ## [[0.2.0](https://github.com/Devolutions/now-libraries/compare/now-policy-v0.1.0...now-policy-v0.2.0)] - 2026-07-27 diff --git a/policies/rust/now-policy/Cargo.toml b/policies/rust/now-policy/Cargo.toml index 80884fe..7bd3b55 100644 --- a/policies/rust/now-policy/Cargo.toml +++ b/policies/rust/now-policy/Cargo.toml @@ -19,7 +19,6 @@ schemars = { version = "0.9", features = ["chrono04"] } semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" thiserror = "2" url = "2" diff --git a/policies/rust/now-policy/README.md b/policies/rust/now-policy/README.md index 39887d3..484ddf0 100644 --- a/policies/rust/now-policy/README.md +++ b/policies/rust/now-policy/README.md @@ -1,7 +1,9 @@ Devolutions NOW policy model ============================ -This crate provides the Rust policy model and JSON Schema helpers for Devolutions Agent NOW policy documents. +This crate provides the JSON-only Rust policy model and JSON Schema helpers for Devolutions Agent NOW policy documents. -It contains only admin-authored policy types and schema generation utilities. +It contains committed `PolicyDocument` and editable `PolicyDraftDocument` types, explicit conversions that add or remove server-managed metadata, and schema generation utilities. Broker request, response, server, transport, and execution types are intentionally out of scope. + +`parse_policy_yaml` was intentionally removed as a breaking change. OpenAPI YAML generation and unrelated YAML inputs are unaffected. diff --git a/policies/rust/now-policy/assets/samples/corporate-allowlist.policy.yaml b/policies/rust/now-policy/assets/samples/corporate-allowlist.policy.yaml deleted file mode 100644 index 6dbe84d..0000000 --- a/policies/rust/now-policy/assets/samples/corporate-allowlist.policy.yaml +++ /dev/null @@ -1,52 +0,0 @@ -"$schema": https://devolutions.net/schemas/now-policy.schema.1.0.json -PolicyVersion: 1.0.0 -PolicyType: PackageBrokerPolicy -Metadata: - Id: contoso.desktop.standard-allowlist-yaml - Publisher: Contoso IT - Revision: 1 - PublishedAt: "2026-05-05T00:00:00Z" - Description: Fail-closed YAML 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: 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 diff --git a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json index c983aa9..b4a4f85 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -36,7 +36,7 @@ "type": "string" }, "HttpUrl": { - "description": "HTTP(S) URL string.\n\nValidated at deserialization time using the `url` crate.", + "description": "HTTP(S) URL string.\n\n Validated at deserialization time using the `url` crate.", "maxLength": 2048, "pattern": "^([Hh][Tt][Tt][Pp][Ss]?)://.+$", "type": "string" @@ -116,7 +116,7 @@ "type": "boolean" }, "AllowUpgrade": { - "description": "Allow skipping upgrade on install operations if an existing version is detected (for install operations).", + "description": "Allow skipping upgrade on install operations if an existing version\n is detected (for install operations).", "type": "boolean" }, "AllowedCustomParameterPatterns": { @@ -190,7 +190,7 @@ }, "PolicyMatch": { "additionalProperties": false, - "description": "Match criteria for a policy rule. All specified fields must match. At least one field must be present.", + "description": "Match criteria for a policy rule. All specified fields must match.\n At least one field must be present.", "properties": { "Architectures": { "description": "Allowed architectures.", @@ -215,7 +215,7 @@ "items": { "type": "boolean" }, - "maxItems": 2, + "maxItems": 1, "type": "array", "uniqueItems": true }, @@ -224,7 +224,7 @@ "items": { "type": "boolean" }, - "maxItems": 2, + "maxItems": 1, "type": "array", "uniqueItems": true }, @@ -233,7 +233,7 @@ "items": { "type": "boolean" }, - "maxItems": 2, + "maxItems": 1, "type": "array", "uniqueItems": true }, @@ -242,7 +242,7 @@ "items": { "type": "boolean" }, - "maxItems": 2, + "maxItems": 1, "type": "array", "uniqueItems": true }, @@ -251,6 +251,7 @@ "items": { "type": "boolean" }, + "maxItems": 1, "type": "array", "uniqueItems": true }, @@ -259,7 +260,7 @@ "items": { "type": "boolean" }, - "maxItems": 2, + "maxItems": 1, "type": "array", "uniqueItems": true }, @@ -304,7 +305,7 @@ "items": { "type": "boolean" }, - "maxItems": 2, + "maxItems": 1, "type": "array", "uniqueItems": true }, @@ -322,7 +323,7 @@ "items": { "type": "boolean" }, - "maxItems": 2, + "maxItems": 1, "type": "array", "uniqueItems": true }, @@ -392,8 +393,8 @@ "Revision": { "description": "Monotonically increasing revision number.", "format": "uint32", - "maximum": 2147483647.0, - "minimum": 1.0, + "maximum": 2147483647, + "minimum": 1, "type": "integer" }, "SupportUrl": { @@ -426,9 +427,9 @@ }, "required": [ "Id", - "PublishedAt", "Publisher", - "Revision" + "Revision", + "PublishedAt" ], "type": "object" }, @@ -445,7 +446,7 @@ "type": "null" } ], - "description": "Additional constraints applied after matching. When absent, no constraints are enforced beyond the match criteria." + "description": "Additional constraints applied after matching.\n When absent, no constraints are enforced beyond the match criteria." }, "Decision": { "allOf": [ @@ -474,14 +475,14 @@ "$ref": "#/definitions/PolicyMatch" } ], - "description": "Match criteria — request must satisfy all specified fields. At least one criterion must be present.", + "description": "Match criteria — request must satisfy all specified fields.\n At least one criterion must be present.", "minProperties": 1 }, "Priority": { "description": "Priority (lower = higher precedence).", "format": "uint32", - "maximum": 2147483647.0, - "minimum": 0.0, + "maximum": 2147483647, + "minimum": 0, "type": "integer" }, "Reason": { @@ -494,10 +495,10 @@ } }, "required": [ - "Decision", "Id", - "Match", - "Priority" + "Priority", + "Decision", + "Match" ], "type": "object" }, @@ -529,7 +530,7 @@ "type": "string" }, "SemanticVersion": { - "description": "Semantic version string (SemVer 2.0.0).\n\nValidated at deserialization time using the `semver` crate.", + "description": "Semantic version string (SemVer 2.0.0).\n\n Validated at deserialization time using the `semver` crate.", "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-]+)*))?$", "type": "string" @@ -630,10 +631,10 @@ }, "required": [ "$schema", - "Enforcement", - "Metadata", - "PolicyType", "PolicyVersion", + "PolicyType", + "Metadata", + "Enforcement", "Rules" ], "title": "PolicyDocument", diff --git a/policies/rust/now-policy/src/policy.rs b/policies/rust/now-policy/src/policy.rs index 4c1ed70..79396e4 100644 --- a/policies/rust/now-policy/src/policy.rs +++ b/policies/rust/now-policy/src/policy.rs @@ -7,8 +7,8 @@ use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; use crate::{ - Architecture, CustomParameterString, Decision, Elevation, HttpUrl, ManagerName, Operation, PackageBrokerPolicy, - PolicySchemaUri, ResourceId, Scope, SemanticVersion, StringPattern, VersionString, + Architecture, CustomParameterString, Decision, Elevation, HttpUrl, ManagerName, ModelValidationError, Operation, + PackageBrokerPolicy, PolicySchemaUri, ResourceId, Scope, SemanticVersion, StringPattern, VersionString, }; /// A policy document governing which package operations are allowed or denied. @@ -38,6 +38,71 @@ pub struct PolicyDocument { pub rules: Vec, } +impl From<&PolicyDocument> for PolicyDraftDocument { + fn from(value: &PolicyDocument) -> Self { + Self { + _schema: value._schema, + policy_version: value.policy_version.clone(), + policy_type: value.policy_type, + metadata: PolicyDraftMetadata::from(&value.metadata), + enforcement: value.enforcement.clone(), + rules: value.rules.clone(), + } + } +} + +/// An editable policy document without server-managed commit metadata. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyDraftDocument")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyDraftDocument { + /// Policy schema URI constant. + #[serde(rename = "$schema")] + pub _schema: PolicySchemaUri, + + /// Policy syntax version (semver). + pub policy_version: SemanticVersion, + + /// Must be `"PackageBrokerPolicy"`. + pub policy_type: PackageBrokerPolicy, + + /// Editable policy metadata. + pub metadata: PolicyDraftMetadata, + + /// Enforcement configuration. + pub enforcement: PolicyEnforcement, + + /// Ordered list of policy rules (may be empty; enforcement defaults apply). + #[schemars(length(max = 1024))] + pub rules: Vec, +} + +impl PolicyDraftDocument { + /// Commit this draft with server-managed revision and publication metadata. + pub fn into_policy_document( + self, + revision: u32, + published_at: DateTime, + ) -> Result { + if revision == 0 { + return Err(ModelValidationError::Invalid { + type_name: "PolicyDocument", + reason: "revision must be at least 1".to_owned(), + }); + } + + Ok(PolicyDocument { + _schema: self._schema, + policy_version: self.policy_version, + policy_type: self.policy_type, + metadata: self.metadata.into_policy_metadata(revision, published_at), + enforcement: self.enforcement, + rules: self.rules, + }) + } +} + /// Policy metadata. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[schemars(rename = "PolicyMetadata")] @@ -76,6 +141,65 @@ pub struct PolicyMetadata { pub support_url: Option, } +/// Editable policy metadata without server-managed revision and publication time. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyDraftMetadata")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyDraftMetadata { + /// Unique policy identifier. + pub id: ResourceId, + + /// Organization that publishes the policy. + #[schemars(length(min = 1, max = 128))] + pub publisher: String, + + /// Policy becomes active at this time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_from: Option>, + + /// Policy expires at this time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_until: Option>, + + /// Human-readable description. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 512))] + pub description: Option, + + /// URL for support or documentation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub support_url: Option, +} + +impl From<&PolicyMetadata> for PolicyDraftMetadata { + fn from(value: &PolicyMetadata) -> Self { + Self { + id: value.id.clone(), + publisher: value.publisher.clone(), + valid_from: value.valid_from, + valid_until: value.valid_until, + description: value.description.clone(), + support_url: value.support_url.clone(), + } + } +} + +impl PolicyDraftMetadata { + fn into_policy_metadata(self, revision: u32, published_at: DateTime) -> PolicyMetadata { + PolicyMetadata { + id: self.id, + publisher: self.publisher, + revision, + published_at, + valid_from: self.valid_from, + valid_until: self.valid_until, + description: self.description, + support_url: self.support_url, + } + } +} + /// Enforcement configuration. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[schemars(rename = "PolicyEnforcement")] @@ -225,45 +349,89 @@ pub struct PolicyMatch { pub elevation: BTreeSet, /// Allowed interactive values. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 2))] + #[serde( + default, + skip_serializing_if = "BTreeSet::is_empty", + deserialize_with = "deserialize_boolean_match" + )] + #[schemars(length(max = 1))] pub interactive: BTreeSet, /// Allowed skipHashCheck values. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 2))] + #[serde( + default, + skip_serializing_if = "BTreeSet::is_empty", + deserialize_with = "deserialize_boolean_match" + )] + #[schemars(length(max = 1))] pub skip_hash_check: BTreeSet, /// Allowed preRelease values. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 2))] + #[serde( + default, + skip_serializing_if = "BTreeSet::is_empty", + deserialize_with = "deserialize_boolean_match" + )] + #[schemars(length(max = 1))] pub pre_release: BTreeSet, /// Whether request has custom parameters. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 2))] + #[serde( + default, + skip_serializing_if = "BTreeSet::is_empty", + deserialize_with = "deserialize_boolean_match" + )] + #[schemars(length(max = 1))] pub has_custom_parameters: BTreeSet, /// Whether request has custom install location. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 2))] + #[serde( + default, + skip_serializing_if = "BTreeSet::is_empty", + deserialize_with = "deserialize_boolean_match" + )] + #[schemars(length(max = 1))] pub has_custom_install_location: BTreeSet, /// Whether request has pre/post operation commands. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 2))] + #[serde( + default, + skip_serializing_if = "BTreeSet::is_empty", + deserialize_with = "deserialize_boolean_match" + )] + #[schemars(length(max = 1))] pub has_pre_post_commands: BTreeSet, /// Whether request has kill-before-operation entries. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - #[schemars(length(max = 2))] + #[serde( + default, + skip_serializing_if = "BTreeSet::is_empty", + deserialize_with = "deserialize_boolean_match" + )] + #[schemars(length(max = 1))] pub has_kill_before_operation: BTreeSet, /// Whether request has uninstall-previous flag set. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[serde( + default, + skip_serializing_if = "BTreeSet::is_empty", + deserialize_with = "deserialize_boolean_match" + )] + #[schemars(length(max = 1))] pub has_uninstall_previous: BTreeSet, } +fn deserialize_boolean_match<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let values = Vec::::deserialize(deserializer)?; + if values.len() > 1 { + return Err(serde::de::Error::custom( + "boolean match arrays must contain exactly one value when present", + )); + } + + Ok(values.into_iter().collect()) +} + impl PolicyMatch { /// Returns true if no criteria are specified. pub fn is_empty(&self) -> bool { diff --git a/policies/rust/now-policy/src/schema.rs b/policies/rust/now-policy/src/schema.rs index d2e26d5..1a784cb 100644 --- a/policies/rust/now-policy/src/schema.rs +++ b/policies/rust/now-policy/src/schema.rs @@ -1,12 +1,22 @@ //! Schema generation and parsing helpers for policy documents. -use schemars::schema_for; +use schemars::generate::SchemaSettings; -use crate::PolicyDocument; +use crate::{PolicyDocument, PolicyDraftDocument}; /// Get the generated policy schema as a JSON value. pub fn policy_schema_json() -> serde_json::Value { - let schema = schema_for!(PolicyDocument); + let schema = SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); + serde_json::to_value(&schema).expect("BUG: schema serialization failed") +} + +/// Get the generated editable policy draft schema as a JSON value. +pub fn policy_draft_schema_json() -> serde_json::Value { + let schema = SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); serde_json::to_value(&schema).expect("BUG: schema serialization failed") } @@ -19,8 +29,3 @@ pub fn parse_policy(value: serde_json::Value) -> Result pub fn parse_policy_json(text: &str) -> Result { serde_json::from_str(text).map_err(|e| e.to_string()) } - -/// Validate a policy document by deserializing from YAML text. -pub fn parse_policy_yaml(text: &str) -> Result { - serde_yaml::from_str(text).map_err(|e| e.to_string()) -} diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index bb7bd0c..1de7369 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -2,32 +2,21 @@ #![allow(clippy::std_instead_of_core, clippy::unwrap_used, unused_crate_dependencies)] -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use now_policy::PolicyDocument; +use chrono::{TimeZone, Utc}; +use now_policy::{PolicyDocument, PolicyDraftDocument}; fn samples_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/samples") } -fn load_policy(path: &Path) -> PolicyDocument { - let content = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - match ext { - "yaml" | "yml" => serde_yaml::from_str(&content) - .unwrap_or_else(|e| panic!("failed to deserialize YAML policy {}: {e}", path.display())), - _ => serde_json::from_str(&content) - .unwrap_or_else(|e| panic!("failed to deserialize policy {}: {e}", path.display())), - } -} - #[test] fn all_sample_policies_deserialize() { let dir = samples_dir(); let policy_files = [ "corporate-allowlist.policy.json", - "corporate-allowlist.policy.yaml", "deny-risky-options.policy.json", "powershell-advanced.policy.json", "powershell-current-user.policy.json", @@ -36,10 +25,51 @@ fn all_sample_policies_deserialize() { for file in &policy_files { let path = dir.join(file); - let _policy = load_policy(&path); + let content = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + let _policy: PolicyDocument = serde_json::from_str(&content) + .unwrap_or_else(|e| panic!("failed to deserialize policy {}: {e}", path.display())); } } +#[test] +fn draft_conversion_omits_and_restores_server_metadata() { + let path = samples_dir().join("corporate-allowlist.policy.json"); + let content = std::fs::read_to_string(path).unwrap(); + let committed: PolicyDocument = serde_json::from_str(&content).unwrap(); + + let draft = PolicyDraftDocument::from(&committed); + let draft_json = serde_json::to_value(&draft).unwrap(); + assert!(draft_json["Metadata"].get("Revision").is_none()); + assert!(draft_json["Metadata"].get("PublishedAt").is_none()); + + let published_at = Utc.with_ymd_and_hms(2026, 8, 29, 0, 0, 0).unwrap(); + let recommitted = draft.into_policy_document(7, published_at).unwrap(); + assert_eq!(recommitted.metadata.id.to_string(), committed.metadata.id.to_string()); + assert_eq!(recommitted.metadata.revision, 7); + assert_eq!(recommitted.metadata.published_at, published_at); +} + +#[test] +fn draft_conversion_rejects_zero_revision() { + let path = samples_dir().join("corporate-allowlist.policy.json"); + let committed: PolicyDocument = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let draft = PolicyDraftDocument::from(&committed); + let published_at = Utc.with_ymd_and_hms(2026, 8, 29, 0, 0, 0).unwrap(); + + assert!(draft.into_policy_document(0, published_at).is_err()); +} + +#[test] +fn mixed_boolean_match_values_are_rejected() { + let path = samples_dir().join("corporate-allowlist.policy.json"); + let mut value: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + value["Rules"][0]["Match"]["Interactive"] = serde_json::json!([false, true]); + + let result: Result = serde_json::from_value(value); + assert!(result.is_err()); +} + #[test] fn invalid_policy_unknown_field_fails_deserialization() { let value = serde_json::json!({ @@ -95,3 +125,16 @@ fn policy_match_schema_requires_at_least_one_property() { assert_eq!(min_properties, Some(1)); } + +#[test] +fn policy_match_schema_limits_boolean_arrays_to_one_item() { + let schema = now_policy::schema::policy_schema_json(); + let max_items = [ + "/definitions/PolicyMatch/properties/Interactive/maxItems", + "/$defs/PolicyMatch/properties/Interactive/maxItems", + ] + .into_iter() + .find_map(|path| schema.pointer(path).and_then(serde_json::Value::as_u64)); + + assert_eq!(max_items, Some(1)); +} diff --git a/policies/test-data/package-broker/requests/policy-replacement.create.request.json b/policies/test-data/package-broker/requests/policy-replacement.create.request.json new file mode 100644 index 0000000..e47c70e --- /dev/null +++ b/policies/test-data/package-broker/requests/policy-replacement.create.request.json @@ -0,0 +1,17 @@ +{ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": "store:missing:0", + "Operation": "Create", + "ConflictHandling": "Reject", + "WarningsAcknowledged": false, + "Draft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + }, + "ValidationReceipt": "receipt:sha256:create" +} diff --git a/policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json b/policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json new file mode 100644 index 0000000..ca800e5 --- /dev/null +++ b/policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json @@ -0,0 +1,17 @@ +{ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": "store:active:newly-observed-8", + "Operation": "Update", + "ConflictHandling": "ConfirmOverwrite", + "WarningsAcknowledged": true, + "Draft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + }, + "ValidationReceipt": "receipt:sha256:overwrite" +} diff --git a/policies/test-data/package-broker/requests/policy-replacement.repair.request.json b/policies/test-data/package-broker/requests/policy-replacement.repair.request.json new file mode 100644 index 0000000..3656c94 --- /dev/null +++ b/policies/test-data/package-broker/requests/policy-replacement.repair.request.json @@ -0,0 +1,17 @@ +{ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": "store:invalid:4", + "Operation": "Repair", + "ConflictHandling": "Reject", + "WarningsAcknowledged": false, + "Draft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + }, + "ValidationReceipt": "receipt:sha256:repair" +} diff --git a/policies/test-data/package-broker/requests/policy-replacement.replace-identity.request.json b/policies/test-data/package-broker/requests/policy-replacement.replace-identity.request.json new file mode 100644 index 0000000..b40d627 --- /dev/null +++ b/policies/test-data/package-broker/requests/policy-replacement.replace-identity.request.json @@ -0,0 +1,17 @@ +{ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": "store:active:7", + "Operation": "ReplaceIdentity", + "ConflictHandling": "Reject", + "WarningsAcknowledged": false, + "Draft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": "fabrikam.package-policy", "Publisher": "Fabrikam IT" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + }, + "ValidationReceipt": "receipt:sha256:replace" +} diff --git a/policies/test-data/package-broker/requests/policy-replacement.update.request.json b/policies/test-data/package-broker/requests/policy-replacement.update.request.json new file mode 100644 index 0000000..8d3105a --- /dev/null +++ b/policies/test-data/package-broker/requests/policy-replacement.update.request.json @@ -0,0 +1,17 @@ +{ + "RequestKind": "PolicyReplacementRequest", + "RequestVersion": "1.0", + "ExpectedStoreToken": "store:active:7", + "Operation": "Update", + "ConflictHandling": "Reject", + "WarningsAcknowledged": true, + "Draft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + }, + "ValidationReceipt": "receipt:sha256:update" +} diff --git a/policies/test-data/package-broker/requests/policy-validation.request.json b/policies/test-data/package-broker/requests/policy-validation.request.json new file mode 100644 index 0000000..f1bf882 --- /dev/null +++ b/policies/test-data/package-broker/requests/policy-validation.request.json @@ -0,0 +1,22 @@ +{ + "RequestKind": "PolicyValidationRequest", + "RequestVersion": "1.0", + "Draft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.package-policy", + "Publisher": "Contoso IT" + }, + "Enforcement": { + "DefaultDecision": "Allow", + "RulePrecedence": "PriorityThenDeny", + "AuditMode": true + }, + "Rules": [], + "EditorExtension": { + "preserved": true + } + } +} diff --git a/policies/test-data/package-broker/responses/policy-management.active.response.json b/policies/test-data/package-broker/responses/policy-management.active.response.json new file mode 100644 index 0000000..5a96677 --- /dev/null +++ b/policies/test-data/package-broker/responses/policy-management.active.response.json @@ -0,0 +1,32 @@ +{ + "ResponseKind": "PolicyManagementResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Management": { + "State": "Active", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.json", + "StoreToken": "store:active:7", + "Source": "ConfiguredPath", + "WriteCapability": "Writable", + "ElevationRequired": true, + "Policy": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.package-policy", + "Publisher": "Contoso IT", + "Revision": 7, + "PublishedAt": "2026-08-29T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [] + } + } +} diff --git a/policies/test-data/package-broker/responses/policy-management.invalid.response.json b/policies/test-data/package-broker/responses/policy-management.invalid.response.json new file mode 100644 index 0000000..023e84f --- /dev/null +++ b/policies/test-data/package-broker/responses/policy-management.invalid.response.json @@ -0,0 +1,33 @@ +{ + "ResponseKind": "PolicyManagementResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Management": { + "State": "Invalid", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.json", + "StoreToken": "store:invalid:4", + "Source": "ConfiguredPath", + "WriteCapability": "ReadOnly", + "ReadOnlyReason": "InsufficientPermissions", + "ElevationRequired": true, + "InvalidDiagnostics": { + "DiagnosticsVersion": "1.0", + "Findings": [ + { + "FindingVersion": "1.0", + "Severity": "Error", + "Code": "UnsupportedPolicyVersion", + "Path": "/PolicyVersion", + "Arguments": { + "actual": "2.0.0", + "supported": "1.0.0" + }, + "Message": "The policy version is not supported." + } + ] + } + } +} diff --git a/policies/test-data/package-broker/responses/policy-management.missing.response.json b/policies/test-data/package-broker/responses/policy-management.missing.response.json new file mode 100644 index 0000000..73e826d --- /dev/null +++ b/policies/test-data/package-broker/responses/policy-management.missing.response.json @@ -0,0 +1,16 @@ +{ + "ResponseKind": "PolicyManagementResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Management": { + "State": "Missing", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.json", + "StoreToken": "store:missing:0", + "Source": "DefaultPath", + "WriteCapability": "Writable", + "ElevationRequired": true + } +} diff --git a/policies/test-data/package-broker/responses/policy-replacement.response.json b/policies/test-data/package-broker/responses/policy-replacement.response.json new file mode 100644 index 0000000..0bf87ec --- /dev/null +++ b/policies/test-data/package-broker/responses/policy-replacement.response.json @@ -0,0 +1,69 @@ +{ + "ResponseKind": "PolicyReplacementResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Policy": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.package-policy", + "Publisher": "Contoso IT", + "Revision": 8, + "PublishedAt": "2026-08-29T01:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [] + }, + "Validation": { + "ResultVersion": "1.0", + "ValidatorVersion": "gateway-policy-validator/1", + "IsValid": true, + "CanonicalDraft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.package-policy", + "Publisher": "Contoso IT" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [] + }, + "ValidationReceipt": "receipt:sha256:committed", + "Findings": [] + }, + "Management": { + "State": "Active", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.json", + "StoreToken": "store:active:8", + "Source": "ConfiguredPath", + "WriteCapability": "Writable", + "ElevationRequired": true, + "Policy": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.package-policy", + "Publisher": "Contoso IT", + "Revision": 8, + "PublishedAt": "2026-08-29T01:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [] + } + } +} diff --git a/policies/test-data/package-broker/responses/policy-stale-token.error.json b/policies/test-data/package-broker/responses/policy-stale-token.error.json new file mode 100644 index 0000000..9142c76 --- /dev/null +++ b/policies/test-data/package-broker/responses/policy-stale-token.error.json @@ -0,0 +1,27 @@ +{ + "ResponseKind": "ErrorResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Code": "StalePolicyStoreToken", + "Message": "The configured policy changed after it was read.", + "Validation": { + "ResultVersion": "1.0", + "ValidatorVersion": "gateway-policy-validator/1", + "IsValid": false, + "Findings": [ + { + "FindingVersion": "1.0", + "Severity": "Error", + "Code": "InvalidFieldValue", + "Path": "", + "Arguments": { + "currentStoreToken": "store:active:9" + }, + "Message": "Read the current management snapshot and retry." + } + ] + } +} diff --git a/policies/test-data/package-broker/responses/policy-validation.invalid.response.json b/policies/test-data/package-broker/responses/policy-validation.invalid.response.json new file mode 100644 index 0000000..3b1ec8e --- /dev/null +++ b/policies/test-data/package-broker/responses/policy-validation.invalid.response.json @@ -0,0 +1,30 @@ +{ + "ResponseKind": "PolicyValidationResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Validation": { + "ResultVersion": "1.0", + "ValidatorVersion": "gateway-policy-validator/1", + "IsValid": false, + "Findings": [ + { "FindingVersion": "1.0", "Severity": "Error", "Code": "SchemaViolation", "Path": "", "Message": "The draft does not satisfy the policy schema." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnknownField", "Path": "/Unknown", "Message": "The property is not supported." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "MissingRequiredField", "Path": "/Metadata/Publisher", "Message": "The required property is missing." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidFieldType", "Path": "/Rules", "Message": "The property has the wrong JSON type." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidFieldValue", "Path": "/Rules/0/Priority", "RuleId": "duplicate", "Message": "The property value is invalid." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "DuplicateRuleId", "Path": "/Rules/1/Id", "RuleId": "duplicate", "Message": "Rule identifiers must be unique." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "IneffectiveBooleanMatch", "Path": "/Rules/0/Match/Interactive", "RuleId": "duplicate", "Message": "A boolean match must contain only true or only false." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidVersionRange", "Path": "/Rules/0/Match/VersionRange", "RuleId": "duplicate", "Message": "The version range is invalid." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "EmptyVersionRange", "Path": "/Rules/0/Match/VersionRange", "RuleId": "duplicate", "Message": "The version range must specify a boundary." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidWildcardPattern", "Path": "/Rules/0/Match/PackageIdentifiers/0", "RuleId": "duplicate", "Message": "The wildcard pattern is invalid." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "ContradictoryConstraints", "Path": "/Rules/0/Constraints", "RuleId": "duplicate", "Message": "The constraints contradict each other." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "InvalidValidityInterval", "Path": "/Metadata/ValidUntil", "Message": "ValidUntil must be after ValidFrom." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnsupportedSchema", "Path": "/$schema", "Message": "The policy schema is not supported." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnsupportedPolicyType", "Path": "/PolicyType", "Message": "The policy type is not supported." }, + { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnsupportedPolicyVersion", "Path": "/PolicyVersion", "Message": "The policy version is not supported." } + ] + } +} diff --git a/policies/test-data/package-broker/responses/policy-validation.valid.response.json b/policies/test-data/package-broker/responses/policy-validation.valid.response.json new file mode 100644 index 0000000..adc8f8f --- /dev/null +++ b/policies/test-data/package-broker/responses/policy-validation.valid.response.json @@ -0,0 +1,68 @@ +{ + "ResponseKind": "PolicyValidationResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Validation": { + "ResultVersion": "1.0", + "ValidatorVersion": "gateway-policy-validator/1", + "IsValid": true, + "CanonicalDraft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.package-policy", + "Publisher": "Contoso IT" + }, + "Enforcement": { + "DefaultDecision": "Allow", + "RulePrecedence": "PriorityThenDeny", + "AuditMode": true + }, + "Rules": [ + { + "Id": "allow.vscode.skip-hash", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Match": { + "Managers": ["Winget"], + "PackageIdentifiers": ["Microsoft.VisualStudioCode"], + "SkipHashCheck": [true] + } + } + ] + }, + "ValidationReceipt": "receipt:sha256:valid-warning-set", + "Findings": [ + { + "FindingVersion": "1.0", + "Severity": "Warning", + "Code": "AuditModeEnabled", + "Path": "/Enforcement/AuditMode", + "Message": "Audit mode disables enforcement." + }, + { + "FindingVersion": "1.0", + "Severity": "Warning", + "Code": "DefaultAllow", + "Path": "/Enforcement/DefaultDecision", + "Message": "Unmatched requests are allowed." + }, + { + "FindingVersion": "1.0", + "Severity": "Warning", + "Code": "SensitiveOptionAllowed", + "Path": "/Rules/0/Match/SkipHashCheck", + "RuleId": "allow.vscode.skip-hash", + "Arguments": { + "option": "SkipHashCheck" + }, + "Message": "This allow rule permits an individually identified sensitive option." + } + ] + } +} diff --git a/policies/test-data/package-broker/scenarios/baseline.scenarios.json b/policies/test-data/package-broker/scenarios/baseline.scenarios.json index 568e6ae..749539e 100644 --- a/policies/test-data/package-broker/scenarios/baseline.scenarios.json +++ b/policies/test-data/package-broker/scenarios/baseline.scenarios.json @@ -74,22 +74,6 @@ "ExpectedRuleId": "deny.powershell.machine-scope", "Tags": ["baseline", "powershell", "scope"] }, - { - "Id": "baseline.yaml.policy-yaml.request-yaml.allow", - "Policy": "corporate-allowlist.policy.yaml", - "Request": "requests/winget-vscode-install.request.yaml", - "ExpectedDecision": "Allow", - "ExpectedRuleId": "allow.winget.vscode", - "Tags": ["baseline", "yaml", "winget"] - }, - { - "Id": "baseline.yaml.policy-yaml.request-json.deny", - "Policy": "corporate-allowlist.policy.yaml", - "Request": "requests/winget-vscode-skiphash.request.json", - "ExpectedDecision": "Deny", - "ExpectedRuleId": "deny.integrity-bypass", - "Tags": ["baseline", "yaml", "winget", "risky-options"] - }, { "Id": "baseline.yaml.policy-json.request-yaml.allow", "Policy": "corporate-allowlist.policy.json", From c282ff278c3f5581516c978aa7c4f174ce6e893d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 29 Aug 2026 03:09:36 +0900 Subject: [PATCH 2/4] fix: tighten policy management contract Require atomic stale-token snapshots, enforce validation and management invariants, preserve legacy route 404s, and map unsafe paths to HTTP 409. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerJson.cs | 91 +++- .../Devolutions.Now.Policy.Api/MetaModels.cs | 4 + .../PolicyManagementClientTests.cs | 87 ++++ .../Devolutions.Now.Policy.Client/README.md | 2 + policies/rust/now-policy-api/CHANGELOG.md | 2 +- policies/rust/now-policy-api/README.md | 2 + .../openapi/now-policy-api.yaml | 188 ++++++-- policies/rust/now-policy-api/src/api.rs | 142 +++++- .../rust/now-policy-api/src/management.rs | 420 ++++++++++++++++-- .../now-policy-server-template/CHANGELOG.md | 2 +- .../now-policy-server-template/src/server.rs | 10 +- .../tests/sample_documents.rs | 41 +- .../tests/support/mock.rs | 3 + ...gement.active-without-policy.response.json | 16 + ...agement.invalid-with-warning.response.json | 29 ++ ...ment.readonly-without-reason.response.json | 16 + ....invalid-with-empty-findings.response.json | 14 + ...idation.invalid-with-warning.response.json | 22 + ...-validation.valid-with-error.response.json | 37 ++ .../responses/policy-stale-token.error.json | 26 +- 20 files changed, 1059 insertions(+), 95 deletions(-) create mode 100644 policies/test-data/package-broker/invalid/responses/policy-management.active-without-policy.response.json create mode 100644 policies/test-data/package-broker/invalid/responses/policy-management.invalid-with-warning.response.json create mode 100644 policies/test-data/package-broker/invalid/responses/policy-management.readonly-without-reason.response.json create mode 100644 policies/test-data/package-broker/invalid/responses/policy-validation.invalid-with-empty-findings.response.json create mode 100644 policies/test-data/package-broker/invalid/responses/policy-validation.invalid-with-warning.response.json create mode 100644 policies/test-data/package-broker/invalid/responses/policy-validation.valid-with-error.response.json diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index 97c7f0d..5f86762 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -25,15 +25,18 @@ public static class BrokerJson public static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true); - public static string Serialize(T value) => - JsonSerializer.Serialize(value, TypeInfo()); + public static string Serialize(T value) + { + ValidateStrictValue(value); + return JsonSerializer.Serialize(value, TypeInfo()); + } public static T? Deserialize(string json) { var value = JsonSerializer.Deserialize(json, TypeInfo()); - if (value is ErrorResponse { Validation: { } validation }) + if (value is ErrorResponse error) { - ValidateValidation(validation); + ValidateError(error); } return value; @@ -42,6 +45,12 @@ public static string Serialize(T value) => public static T? DeserializeStrict(string json) { var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); + ValidateStrictValue(value); + return value; + } + + private static void ValidateStrictValue(T value) + { switch (value) { case PolicyResponse response: @@ -58,12 +67,10 @@ public static string Serialize(T value) => ValidateValidation(response.Validation); ValidateManagement(response.Management); break; - case ErrorResponse { Validation: { } validation }: - ValidateValidation(validation); + case ErrorResponse error: + ValidateError(error); break; } - - return value; } private static JsonTypeInfo TypeInfo() => @@ -109,14 +116,64 @@ private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) => private static void ValidateManagement(PolicyManagementSnapshot management) { + switch (management.State) + { + case PolicyManagementState.Active when management.Policy is null || management.InvalidDiagnostics is not null: + throw new JsonException("Active management snapshots require Policy and forbid InvalidDiagnostics."); + case PolicyManagementState.Missing when management.Policy is not null || management.InvalidDiagnostics is not null: + throw new JsonException("Missing management snapshots forbid Policy and InvalidDiagnostics."); + case PolicyManagementState.Invalid: + if (management.Policy is not null) + { + throw new JsonException("Invalid management snapshots forbid Policy."); + } + if (management.InvalidDiagnostics is null + || management.InvalidDiagnostics.Findings.Count == 0 + || !management.InvalidDiagnostics.Findings.Any( + finding => finding.Severity == PolicyFindingSeverity.Error)) + { + throw new JsonException( + "Invalid management snapshots require nonempty diagnostics with an Error finding."); + } + break; + } + + switch (management.WriteCapability) + { + case PolicyWriteCapability.Writable when management.ReadOnlyReason is not null: + throw new JsonException("Writable management snapshots forbid ReadOnlyReason."); + case PolicyWriteCapability.ReadOnly or PolicyWriteCapability.Unsupported + when management.ReadOnlyReason is null: + throw new JsonException("ReadOnly and Unsupported management snapshots require ReadOnlyReason."); + } + if (management.Policy is { } policy) { PolicyJson.ValidateRequiredCollectionElements(policy); } } + private static void ValidateError(ErrorResponse error) + { + if (error.Code == ErrorCode.StalePolicyStoreToken && error.Management is null) + { + throw new JsonException("StalePolicyStoreToken errors require Management."); + } + + if (error.Management is { } management) + { + ValidateManagement(management); + } + + if (error.Validation is { } validation) + { + ValidateValidation(validation); + } + } + private static void ValidateValidation(PolicyValidationResult validation) { + var hasError = validation.Findings.Any(finding => finding.Severity == PolicyFindingSeverity.Error); if (validation.IsValid) { if (validation.CanonicalDraft is null || validation.ValidationReceipt is null) @@ -124,13 +181,25 @@ private static void ValidateValidation(PolicyValidationResult validation) throw new JsonException( "Valid policy validation results require CanonicalDraft and ValidationReceipt."); } + if (hasError) + { + throw new JsonException("Valid policy validation results must not contain Error findings."); + } PolicyJson.ValidateRequiredCollectionElements(validation.CanonicalDraft); } - else if (validation.CanonicalDraft is not null || validation.ValidationReceipt is not null) + else { - throw new JsonException( - "Invalid policy validation results must not contain CanonicalDraft or ValidationReceipt."); + if (validation.CanonicalDraft is not null || validation.ValidationReceipt is not null) + { + throw new JsonException( + "Invalid policy validation results must not contain CanonicalDraft or ValidationReceipt."); + } + if (!hasError) + { + throw new JsonException( + "Invalid policy validation results require at least one Error finding."); + } } } diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs index 1d14021..c4a44c4 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs @@ -130,6 +130,10 @@ public string ResponseKind [JsonPropertyName("Validation")] public PolicyValidationResult? Validation { get; set; } + /// Atomic current policy state, required for stale store-token errors. + [JsonPropertyName("Management")] + public PolicyManagementSnapshot? Management { get; set; } + } public sealed class ErrorDetail diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs index 4d12a5e..b3f38c8 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -82,6 +82,7 @@ public async Task ReplacePolicy_preserves_structured_stale_token_findings() Assert.Equal(ErrorCode.StalePolicyStoreToken, exception.BrokerError?.Code); Assert.Equal(PolicyFindingCode.InvalidFieldValue, exception.BrokerError?.Validation?.Findings[0].Code); + Assert.Equal("store:active:9", exception.BrokerError?.Management?.StoreToken); } [Theory] @@ -176,6 +177,54 @@ public async Task Strict_validation_response_enforces_success_artifact_invariant () => BrokerJson.DeserializeStrict(invalid.ToJsonString())); } + [Theory] + [InlineData("policy-validation.valid-with-error.response.json")] + [InlineData("policy-validation.invalid-with-warning.response.json")] + [InlineData("policy-validation.invalid-with-empty-findings.response.json")] + public async Task Strict_validation_response_rejects_contradictory_findings(string fixture) + { + var json = await ReadFixture(Path.Combine("invalid", "responses"), fixture); + Assert.Throws(() => BrokerJson.DeserializeStrict(json)); + } + + [Theory] + [InlineData("policy-management.active-without-policy.response.json")] + [InlineData("policy-management.invalid-with-warning.response.json")] + [InlineData("policy-management.readonly-without-reason.response.json")] + public async Task Strict_management_response_rejects_contradictory_snapshot(string fixture) + { + var json = await ReadFixture(Path.Combine("invalid", "responses"), fixture); + Assert.Throws(() => BrokerJson.DeserializeStrict(json)); + } + + [Theory] + [InlineData("policy-validation.valid-with-error.response.json", "PolicyValidationResponse")] + [InlineData("policy-validation.invalid-with-warning.response.json", "PolicyValidationResponse")] + [InlineData("policy-validation.invalid-with-empty-findings.response.json", "PolicyValidationResponse")] + [InlineData("policy-management.active-without-policy.response.json", "PolicyManagementResponse")] + [InlineData("policy-management.invalid-with-warning.response.json", "PolicyManagementResponse")] + [InlineData("policy-management.readonly-without-reason.response.json", "PolicyManagementResponse")] + public async Task OpenApi_rejects_contradictory_policy_management_contracts(string fixture, string component) + { + var json = await ReadFixture(Path.Combine("invalid", "responses"), fixture); + var schema = await TestData.SchemaAsync(component); + Assert.NotEmpty(schema.Validate(json)); + } + + [Fact] + public async Task Serialization_rejects_contradictory_policy_management_contracts() + { + var validation = BrokerJson.DeserializeStrict( + await ReadFixture("responses", "policy-validation.invalid.response.json"))!; + validation.Validation.Findings.Clear(); + Assert.Throws(() => BrokerJson.Serialize(validation)); + + var management = BrokerJson.DeserializeStrict( + await ReadFixture("responses", "policy-management.missing.response.json"))!; + management.Management.State = PolicyManagementState.Active; + Assert.Throws(() => BrokerJson.Serialize(management)); + } + [Theory] [InlineData("\"stalepolicystoretoken\"")] [InlineData("16")] @@ -196,6 +245,44 @@ public async Task Management_error_enforces_validation_result_invariant() Assert.Throws(() => BrokerJson.Deserialize(error.ToJsonString())); } + [Fact] + public async Task Stale_token_error_requires_atomic_management_snapshot() + { + var error = JsonNode.Parse(await ReadFixture("responses", "policy-stale-token.error.json"))!; + error.AsObject().Remove("Management"); + + Assert.Throws(() => BrokerJson.Deserialize(error.ToJsonString())); + Assert.NotEmpty((await TestData.SchemaAsync("ErrorResponse")).Validate(error.ToJsonString())); + + var contradictory = JsonNode.Parse(await ReadFixture("responses", "policy-stale-token.error.json"))!; + contradictory["Management"]!.AsObject().Remove("Policy"); + Assert.Throws(() => BrokerJson.Deserialize(contradictory.ToJsonString())); + + var dto = new ErrorResponse + { + Code = ErrorCode.StalePolicyStoreToken, + Message = "stale", + }; + Assert.Throws(() => BrokerJson.Serialize(dto)); + } + + [Theory] + [InlineData("")] + [InlineData("not found")] + public async Task GetPolicyManagement_preserves_legacy_route_not_found(string body) + { + var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 404, Body = body }); + + var exception = await Assert.ThrowsAsync( + () => CreateClient(transport).GetPolicyManagement()); + + Assert.True( + exception.Kind is BrokerClientErrorKind.EmptyResponse or BrokerClientErrorKind.BrokerError, + $"unexpected legacy 404 error kind: {exception.Kind}"); + Assert.Equal(404, exception.StatusCode); + Assert.Null(exception.BrokerError); + } + private static async Task ReadFixture(string directory, string file) => await File.ReadAllTextAsync(Path.Combine(TestData.SamplesDir, directory, file)); diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index 99a3bfd..2505d21 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -111,6 +111,8 @@ Response-oriented methods return successful DTOs or throw `BrokerClientException `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. +The policy management methods preserve the same ordinary 404 behavior when an older Agent does not expose a newer route. A structured `StalePolicyStoreToken` error carries the atomic current `Management` snapshot; use its exact store token for an explicitly confirmed overwrite retry. `UnsafePolicyPath` uses HTTP 409 because it represents the current storage/write-capability state rather than authentication or elevation. + Schema relationship ------------------- diff --git a/policies/rust/now-policy-api/CHANGELOG.md b/policies/rust/now-policy-api/CHANGELOG.md index e6a49f3..1652fba 100644 --- a/policies/rust/now-policy-api/CHANGELOG.md +++ b/policies/rust/now-policy-api/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add versioned policy management, raw-draft validation, structured findings/receipts, optimistic replacement, and management error contracts. +- Add versioned policy management, raw-draft validation, structured findings/receipts, optimistic replacement, and management error contracts, including the atomic current snapshot required on stale-token errors. ## [[0.3.1](https://github.com/Devolutions/now-libraries/compare/now-policy-api-v0.3.0...now-policy-api-v0.3.1)] - 2026-08-13 diff --git a/policies/rust/now-policy-api/README.md b/policies/rust/now-policy-api/README.md index b9f9559..37ab488 100644 --- a/policies/rust/now-policy-api/README.md +++ b/policies/rust/now-policy-api/README.md @@ -57,6 +57,8 @@ cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi -- The generated document contains the unchanged policy inspection route, the management/validation/replacement routes, and canonical committed and draft policy schemas. +`StalePolicyStoreToken` errors include the atomic current `Management` snapshot so a client can explicitly confirm an overwrite against that exact newly observed token. `UnsafePolicyPath` is a 409 state/write-capability conflict, not an authentication failure. An Agent that does not expose a newer route may still return an ordinary unstructured 404; `UnsupportedEndpoint` is only an optional explicit implementation response. + 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 21997a6..f8b6c8e 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -32,7 +32,7 @@ paths: description: Returns the active parsed policy document. A 404 response means no active policy is configured. responses: default: - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -44,7 +44,7 @@ paths: schema: $ref: '#/components/schemas/PolicyResponse' '404': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -67,43 +67,43 @@ paths: schema: $ref: '#/components/schemas/PolicyReplacementResponse' '400': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '403': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '422': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '501': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -114,7 +114,7 @@ paths: description: Atomically returns configured policy state and advisory write capability. Capability fields are UX guidance and are rechecked during replacement. responses: default: - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -138,7 +138,7 @@ paths: required: true responses: default: - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -150,7 +150,7 @@ paths: schema: $ref: '#/components/schemas/PolicyValidationResponse' '400': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -174,19 +174,19 @@ paths: schema: $ref: '#/components/schemas/EvaluationResponse' '400': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '422': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '503': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -210,25 +210,25 @@ paths: schema: $ref: '#/components/schemas/ExecutionResponse' '400': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '409': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '422': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '503': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -252,13 +252,13 @@ paths: schema: $ref: '#/components/schemas/StatusResponse' '400': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -288,13 +288,13 @@ paths: schema: $ref: '#/components/schemas/CancelResponse' '400': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': - description: Generic error body returned for non-2xx responses. + description: '' content: application/json: schema: @@ -565,42 +565,57 @@ components: required: - Message ErrorResponse: - description: Generic error body returned for non-2xx responses. + allOf: + - $ref: '#/components/schemas/ErrorResponseFields' + oneOf: + - properties: + Code: + enum: + - StalePolicyStoreToken + Management: + $ref: '#/components/schemas/PolicyManagementSnapshot' + required: + - Management + - properties: + Code: + not: + enum: + - StalePolicyStoreToken + ErrorResponseFields: type: object properties: Code: - description: Machine-readable error code. - allOf: - - $ref: '#/components/schemas/ErrorCode' + $ref: '#/components/schemas/ErrorCode' Details: - description: Structured error details. type: array + default: [] items: $ref: '#/components/schemas/ErrorDetail' + Management: + anyOf: + - $ref: '#/components/schemas/PolicyManagementSnapshot' + - enum: + - null + nullable: true + default: null Message: - description: Human-readable summary. type: string maxLength: 2048 minLength: 1 ResponseKind: - description: Response discriminator. - allOf: - - $ref: '#/components/schemas/ErrorResponseKind' + $ref: '#/components/schemas/ErrorResponseKind' ResponseVersion: - description: Server-side API version used to construct the response. - allOf: - - $ref: '#/components/schemas/ApiVersion' + $ref: '#/components/schemas/ApiVersion' Server: - description: Server context. - allOf: - - $ref: '#/components/schemas/ServerContext' + $ref: '#/components/schemas/ServerContext' Validation: - description: Current authoritative policy findings for management errors. anyOf: - $ref: '#/components/schemas/PolicyValidationResult' - enum: - null nullable: true + default: null + additionalProperties: false required: - ResponseKind - ResponseVersion @@ -1197,11 +1212,74 @@ components: type: string pattern: ^PolicyManagementResponse$ PolicyManagementSnapshot: - description: Atomic view of configured policy state and management guidance. + allOf: + - $ref: '#/components/schemas/PolicyManagementSnapshotFields' + - oneOf: + - properties: + Policy: + $ref: '#/components/schemas/PolicyDocument' + State: + enum: + - Active + not: + required: + - InvalidDiagnostics + required: + - Policy + - properties: + State: + enum: + - Missing + not: + anyOf: + - required: + - Policy + - required: + - InvalidDiagnostics + - properties: + InvalidDiagnostics: + properties: + Findings: + minItems: 1 + not: + items: + properties: + Severity: + enum: + - Warning + required: + - Severity + allOf: + - $ref: '#/components/schemas/InvalidPolicyDiagnostics' + State: + enum: + - Invalid + not: + required: + - Policy + required: + - InvalidDiagnostics + - oneOf: + - properties: + WriteCapability: + enum: + - Writable + not: + required: + - ReadOnlyReason + - properties: + ReadOnlyReason: + $ref: '#/components/schemas/PolicyReadOnlyReason' + WriteCapability: + enum: + - ReadOnly + - Unsupported + required: + - ReadOnlyReason + PolicyManagementSnapshotFields: type: object properties: ConfiguredPath: - description: Fully resolved configured path. type: string maxLength: 32767 minLength: 1 @@ -1213,14 +1291,18 @@ components: - enum: - null nullable: true + default: null Policy: - $ref: '#/components/schemas/PolicyDocument' + allOf: + - $ref: '#/components/schemas/PolicyDocument' + default: null ReadOnlyReason: anyOf: - $ref: '#/components/schemas/PolicyReadOnlyReason' - enum: - null nullable: true + default: null Source: $ref: '#/components/schemas/PolicyConfigurationSource' State: @@ -1421,13 +1503,35 @@ components: - $ref: '#/components/schemas/PolicyValidationResultFields' oneOf: - properties: + CanonicalDraft: + $ref: '#/components/schemas/PolicyDraftDocument' + Findings: + items: + properties: + Severity: + enum: + - Warning + required: + - Severity IsValid: enum: - true + ValidationReceipt: + $ref: '#/components/schemas/PolicyValidationReceipt' required: - CanonicalDraft - ValidationReceipt - properties: + Findings: + minItems: 1 + not: + items: + properties: + Severity: + enum: + - Warning + required: + - Severity IsValid: enum: - false diff --git a/policies/rust/now-policy-api/src/api.rs b/policies/rust/now-policy-api/src/api.rs index 0421db4..8cdc4ac 100644 --- a/policies/rust/now-policy-api/src/api.rs +++ b/policies/rust/now-policy-api/src/api.rs @@ -1,8 +1,8 @@ //! Shared package broker API models. use chrono::{DateTime, Utc}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::enums::{Architecture, Decision, Elevation, ErrorCode, ManagerName, Operation, Scope, Transport}; use super::{ @@ -291,9 +291,7 @@ pub struct ErrorDetail { } /// Generic error body returned for non-2xx responses. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[schemars(rename = "ErrorResponse")] -#[serde(rename_all = "PascalCase")] +#[derive(Debug, Clone)] pub struct ErrorResponse { /// Response discriminator. pub response_kind: ErrorResponseKind, @@ -308,14 +306,142 @@ pub struct ErrorResponse { pub code: ErrorCode, /// Human-readable summary. - #[schemars(length(min = 1, max = 2048))] pub message: String, /// Structured error details. - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub details: Vec, /// Current authoritative policy findings for management errors. - #[serde(default, skip_serializing_if = "Option::is_none")] pub validation: Option, + + /// Atomic current policy state, required when `Code` is `StalePolicyStoreToken`. + pub management: Option, +} + +#[derive(Deserialize, JsonSchema)] +#[schemars(rename = "ErrorResponseFields")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +struct ErrorResponseWire { + pub response_kind: ErrorResponseKind, + pub response_version: ApiVersion, + pub server: ServerContext, + pub code: ErrorCode, + #[schemars(length(min = 1, max = 2048))] + pub message: String, + #[serde(default)] + pub details: Vec, + #[serde(default)] + pub validation: Option, + #[serde(default)] + pub management: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct ErrorResponseRef<'a> { + response_kind: &'a ErrorResponseKind, + response_version: &'a ApiVersion, + server: &'a ServerContext, + code: ErrorCode, + message: &'a str, + #[serde(skip_serializing_if = "slice_is_empty")] + details: &'a [ErrorDetail], + #[serde(skip_serializing_if = "Option::is_none")] + validation: Option<&'a super::PolicyValidationResult>, + #[serde(skip_serializing_if = "Option::is_none")] + management: Option<&'a super::PolicyManagementSnapshot>, +} + +fn slice_is_empty(value: &[T]) -> bool { + value.is_empty() +} + +impl ErrorResponse { + fn validate(&self) -> Result<(), &'static str> { + if self.code == ErrorCode::StalePolicyStoreToken && self.management.is_none() { + return Err("StalePolicyStoreToken errors require Management"); + } + Ok(()) + } +} + +impl TryFrom for ErrorResponse { + type Error = &'static str; + + fn try_from(value: ErrorResponseWire) -> Result { + let response = Self { + response_kind: value.response_kind, + response_version: value.response_version, + server: value.server, + code: value.code, + message: value.message, + details: value.details, + validation: value.validation, + management: value.management, + }; + response.validate()?; + Ok(response) + } +} + +impl Serialize for ErrorResponse { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.validate().map_err(serde::ser::Error::custom)?; + ErrorResponseRef { + response_kind: &self.response_kind, + response_version: &self.response_version, + server: &self.server, + code: self.code, + message: &self.message, + details: &self.details, + validation: self.validation.as_ref(), + management: self.management.as_ref(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ErrorResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = ErrorResponseWire::deserialize(deserializer)?; + Self::try_from(wire).map_err(serde::de::Error::custom) + } +} + +impl JsonSchema for ErrorResponse { + fn schema_name() -> std::borrow::Cow<'static, str> { + "ErrorResponse".into() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + let fields = generator.subschema_for::(); + json_schema!({ + "allOf": [fields], + "oneOf": [ + { + "properties": { + "Code": { "const": "StalePolicyStoreToken" }, + "Management": { + "$ref": "#/components/schemas/PolicyManagementSnapshot" + } + }, + "required": ["Management"] + }, + { + "properties": { + "Code": { + "not": { "const": "StalePolicyStoreToken" } + } + } + } + ] + }) + } } diff --git a/policies/rust/now-policy-api/src/management.rs b/policies/rust/now-policy-api/src/management.rs index 9876740..5bdfd97 100644 --- a/policies/rust/now-policy-api/src/management.rs +++ b/policies/rust/now-policy-api/src/management.rs @@ -202,7 +202,7 @@ pub struct PolicyFinding { } /// Authoritative validation output. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(try_from = "PolicyValidationResultWire")] #[serde(rename_all = "PascalCase")] #[serde(deny_unknown_fields)] @@ -216,11 +216,11 @@ pub struct PolicyValidationResult { pub is_valid: bool, /// Canonical typed draft, present only when validation succeeds. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default)] pub canonical_draft: Option, /// Receipt bound to the canonical draft, validator version, and exact warning set. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default)] pub validation_receipt: Option, pub findings: Vec, @@ -243,29 +243,75 @@ struct PolicyValidationResultWire { pub findings: Vec, } -impl TryFrom for PolicyValidationResult { - type Error = String; +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct PolicyValidationResultRef<'a> { + result_version: &'a ApiVersion, + validator_version: &'a str, + is_valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + canonical_draft: Option<&'a PolicyDraftDocument>, + #[serde(skip_serializing_if = "Option::is_none")] + validation_receipt: Option<&'a PolicyValidationReceipt>, + findings: &'a [PolicyFinding], +} - fn try_from(value: PolicyValidationResultWire) -> Result { - let has_success_artifacts = value.canonical_draft.is_some() && value.validation_receipt.is_some(); - let has_any_success_artifact = value.canonical_draft.is_some() || value.validation_receipt.is_some(); - if value.is_valid && !has_success_artifacts { - return Err("valid policy validation results require CanonicalDraft and ValidationReceipt".to_owned()); +impl PolicyValidationResult { + fn validate(&self) -> Result<(), &'static str> { + let has_success_artifacts = self.canonical_draft.is_some() && self.validation_receipt.is_some(); + let has_any_success_artifact = self.canonical_draft.is_some() || self.validation_receipt.is_some(); + let has_error = self + .findings + .iter() + .any(|finding| finding.severity == PolicyFindingSeverity::Error); + if self.is_valid && !has_success_artifacts { + return Err("valid policy validation results require CanonicalDraft and ValidationReceipt"); + } + if self.is_valid && has_error { + return Err("valid policy validation results must not contain Error findings"); + } + if !self.is_valid && has_any_success_artifact { + return Err("invalid policy validation results must not contain CanonicalDraft or ValidationReceipt"); } - if !value.is_valid && has_any_success_artifact { - return Err( - "invalid policy validation results must not contain CanonicalDraft or ValidationReceipt".to_owned(), - ); + if !self.is_valid && !has_error { + return Err("invalid policy validation results require at least one Error finding"); } + Ok(()) + } +} - Ok(Self { +impl TryFrom for PolicyValidationResult { + type Error = &'static str; + + fn try_from(value: PolicyValidationResultWire) -> Result { + let result = Self { result_version: value.result_version, validator_version: value.validator_version, is_valid: value.is_valid, canonical_draft: value.canonical_draft, validation_receipt: value.validation_receipt, findings: value.findings, - }) + }; + result.validate()?; + Ok(result) + } +} + +impl Serialize for PolicyValidationResult { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.validate().map_err(serde::ser::Error::custom)?; + PolicyValidationResultRef { + result_version: &self.result_version, + validator_version: &self.validator_version, + is_valid: self.is_valid, + canonical_draft: self.canonical_draft.as_ref(), + validation_receipt: self.validation_receipt.as_ref(), + findings: &self.findings, + } + .serialize(serializer) } } @@ -281,13 +327,38 @@ impl JsonSchema for PolicyValidationResult { "oneOf": [ { "properties": { - "IsValid": { "const": true } + "IsValid": { "const": true }, + "CanonicalDraft": { + "$ref": "#/components/schemas/PolicyDraftDocument" + }, + "ValidationReceipt": { + "$ref": "#/components/schemas/PolicyValidationReceipt" + }, + "Findings": { + "items": { + "properties": { + "Severity": { "const": "Warning" } + }, + "required": ["Severity"] + } + } }, "required": ["CanonicalDraft", "ValidationReceipt"] }, { "properties": { - "IsValid": { "const": false } + "IsValid": { "const": false }, + "Findings": { + "minItems": 1, + "not": { + "items": { + "properties": { + "Severity": { "const": "Warning" } + }, + "required": ["Severity"] + } + } + } }, "not": { "anyOf": [ @@ -312,34 +383,235 @@ pub struct InvalidPolicyDiagnostics { } /// Atomic view of configured policy state and management guidance. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[schemars(rename = "PolicyManagementSnapshot")] +#[derive(Debug, Clone, Deserialize)] +#[serde(try_from = "PolicyManagementSnapshotWire")] #[serde(rename_all = "PascalCase")] #[serde(deny_unknown_fields)] pub struct PolicyManagementSnapshot { pub state: PolicyManagementState, /// Fully resolved configured path. - #[schemars(length(min = 1, max = 32767))] pub configured_path: String, pub store_token: PolicyStoreToken, pub source: PolicyConfigurationSource, pub write_capability: PolicyWriteCapability, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default)] pub read_only_reason: Option, pub elevation_required: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(schema_with = "super::policy::policy_document_schema")] + #[serde(default)] pub policy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default)] pub invalid_diagnostics: Option, } +#[derive(Deserialize, JsonSchema)] +#[schemars(rename = "PolicyManagementSnapshotFields")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +struct PolicyManagementSnapshotWire { + pub state: PolicyManagementState, + #[schemars(length(min = 1, max = 32767))] + pub configured_path: String, + pub store_token: PolicyStoreToken, + pub source: PolicyConfigurationSource, + pub write_capability: PolicyWriteCapability, + #[serde(default)] + pub read_only_reason: Option, + pub elevation_required: bool, + #[serde(default)] + #[schemars(schema_with = "super::policy::policy_document_schema")] + pub policy: Option, + #[serde(default)] + pub invalid_diagnostics: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct PolicyManagementSnapshotRef<'a> { + state: PolicyManagementState, + configured_path: &'a str, + store_token: &'a PolicyStoreToken, + source: PolicyConfigurationSource, + write_capability: PolicyWriteCapability, + #[serde(skip_serializing_if = "Option::is_none")] + read_only_reason: Option, + elevation_required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + policy: Option<&'a PolicyDocument>, + #[serde(skip_serializing_if = "Option::is_none")] + invalid_diagnostics: Option<&'a InvalidPolicyDiagnostics>, +} + +impl PolicyManagementSnapshot { + fn validate(&self) -> Result<(), &'static str> { + match self.state { + PolicyManagementState::Active if self.policy.is_none() || self.invalid_diagnostics.is_some() => { + return Err("Active management snapshots require Policy and forbid InvalidDiagnostics"); + } + PolicyManagementState::Missing if self.policy.is_some() || self.invalid_diagnostics.is_some() => { + return Err("Missing management snapshots forbid Policy and InvalidDiagnostics"); + } + PolicyManagementState::Invalid => { + let Some(diagnostics) = &self.invalid_diagnostics else { + return Err("Invalid management snapshots require InvalidDiagnostics"); + }; + if self.policy.is_some() { + return Err("Invalid management snapshots forbid Policy"); + } + if diagnostics.findings.is_empty() + || !diagnostics + .findings + .iter() + .any(|finding| finding.severity == PolicyFindingSeverity::Error) + { + return Err("Invalid management snapshots require nonempty diagnostics with an Error finding"); + } + } + _ => {} + } + + match self.write_capability { + PolicyWriteCapability::Writable if self.read_only_reason.is_some() => { + return Err("Writable management snapshots forbid ReadOnlyReason"); + } + PolicyWriteCapability::ReadOnly | PolicyWriteCapability::Unsupported if self.read_only_reason.is_none() => { + return Err("ReadOnly and Unsupported management snapshots require ReadOnlyReason"); + } + _ => {} + } + + Ok(()) + } +} + +impl TryFrom for PolicyManagementSnapshot { + type Error = &'static str; + + fn try_from(value: PolicyManagementSnapshotWire) -> Result { + let snapshot = Self { + state: value.state, + configured_path: value.configured_path, + store_token: value.store_token, + source: value.source, + write_capability: value.write_capability, + read_only_reason: value.read_only_reason, + elevation_required: value.elevation_required, + policy: value.policy, + invalid_diagnostics: value.invalid_diagnostics, + }; + snapshot.validate()?; + Ok(snapshot) + } +} + +impl Serialize for PolicyManagementSnapshot { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.validate().map_err(serde::ser::Error::custom)?; + PolicyManagementSnapshotRef { + state: self.state, + configured_path: &self.configured_path, + store_token: &self.store_token, + source: self.source, + write_capability: self.write_capability, + read_only_reason: self.read_only_reason, + elevation_required: self.elevation_required, + policy: self.policy.as_ref(), + invalid_diagnostics: self.invalid_diagnostics.as_ref(), + } + .serialize(serializer) + } +} + +impl JsonSchema for PolicyManagementSnapshot { + fn schema_name() -> std::borrow::Cow<'static, str> { + "PolicyManagementSnapshot".into() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + let fields = generator.subschema_for::(); + json_schema!({ + "allOf": [ + fields, + { + "oneOf": [ + { + "properties": { + "State": { "const": "Active" }, + "Policy": { + "$ref": "#/components/schemas/PolicyDocument" + } + }, + "required": ["Policy"], + "not": { "required": ["InvalidDiagnostics"] } + }, + { + "properties": { "State": { "const": "Missing" } }, + "not": { + "anyOf": [ + { "required": ["Policy"] }, + { "required": ["InvalidDiagnostics"] } + ] + } + }, + { + "properties": { + "State": { "const": "Invalid" }, + "InvalidDiagnostics": { + "allOf": [{ + "$ref": "#/components/schemas/InvalidPolicyDiagnostics" + }], + "properties": { + "Findings": { + "minItems": 1, + "not": { + "items": { + "properties": { + "Severity": { "const": "Warning" } + }, + "required": ["Severity"] + } + } + } + } + } + }, + "required": ["InvalidDiagnostics"], + "not": { "required": ["Policy"] } + } + ] + }, + { + "oneOf": [ + { + "properties": { "WriteCapability": { "const": "Writable" } }, + "not": { "required": ["ReadOnlyReason"] } + }, + { + "properties": { + "WriteCapability": { + "enum": ["ReadOnly", "Unsupported"] + }, + "ReadOnlyReason": { + "$ref": "#/components/schemas/PolicyReadOnlyReason" + } + }, + "required": ["ReadOnlyReason"] + } + ] + } + ] + }) + } +} + /// Response body for `GET /v1/policy/management`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[schemars(rename = "PolicyManagementResponse")] @@ -421,7 +693,7 @@ pub struct PolicyReplacementResponse { #[cfg(test)] mod tests { - use super::PolicyValidationResult; + use super::{PolicyManagementSnapshot, PolicyValidationResult}; #[test] fn validation_result_requires_success_artifacts_exactly_when_valid() { @@ -442,4 +714,100 @@ mod tests { }); assert!(serde_json::from_value::(invalid_with_receipt).is_err()); } + + #[test] + fn validation_result_requires_findings_consistent_with_validity() { + let valid_with_error = serde_json::json!({ + "ResultVersion": "1.0", + "ValidatorVersion": "validator/1", + "IsValid": true, + "CanonicalDraft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { "Id": "test", "Publisher": "test" }, + "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, + "Rules": [] + }, + "ValidationReceipt": "receipt", + "Findings": [{ + "FindingVersion": "1.0", + "Severity": "Error", + "Code": "InvalidFieldValue", + "Path": "", + "Message": "error" + }] + }); + assert!(serde_json::from_value::(valid_with_error).is_err()); + + for findings in [ + serde_json::json!([]), + serde_json::json!([{ + "FindingVersion": "1.0", + "Severity": "Warning", + "Code": "DefaultAllow", + "Path": "/Enforcement/DefaultDecision", + "Message": "warning" + }]), + ] { + let invalid_without_error = serde_json::json!({ + "ResultVersion": "1.0", + "ValidatorVersion": "validator/1", + "IsValid": false, + "Findings": findings + }); + assert!(serde_json::from_value::(invalid_without_error).is_err()); + } + + let mut invalid_for_serialization: PolicyValidationResult = serde_json::from_value(serde_json::json!({ + "ResultVersion": "1.0", + "ValidatorVersion": "validator/1", + "IsValid": false, + "Findings": [{ + "FindingVersion": "1.0", + "Severity": "Error", + "Code": "InvalidFieldValue", + "Path": "", + "Message": "error" + }] + })) + .expect("valid invalid-result fixture"); + invalid_for_serialization.findings.clear(); + assert!(serde_json::to_value(invalid_for_serialization).is_err()); + } + + #[test] + fn management_snapshot_rejects_contradictory_state_and_capability() { + let active_without_policy = serde_json::json!({ + "State": "Active", + "ConfiguredPath": "C:\\policy.json", + "StoreToken": "store:1", + "Source": "ConfiguredPath", + "WriteCapability": "Writable", + "ElevationRequired": true + }); + assert!(serde_json::from_value::(active_without_policy).is_err()); + + let readonly_without_reason = serde_json::json!({ + "State": "Missing", + "ConfiguredPath": "C:\\policy.json", + "StoreToken": "store:1", + "Source": "ConfiguredPath", + "WriteCapability": "ReadOnly", + "ElevationRequired": true + }); + assert!(serde_json::from_value::(readonly_without_reason).is_err()); + + let mut invalid_for_serialization: PolicyManagementSnapshot = serde_json::from_value(serde_json::json!({ + "State": "Missing", + "ConfiguredPath": "C:\\policy.json", + "StoreToken": "store:1", + "Source": "ConfiguredPath", + "WriteCapability": "Writable", + "ElevationRequired": true + })) + .expect("valid missing snapshot fixture"); + invalid_for_serialization.state = super::PolicyManagementState::Active; + assert!(serde_json::to_value(invalid_for_serialization).is_err()); + } } diff --git a/policies/rust/now-policy-server-template/CHANGELOG.md b/policies/rust/now-policy-server-template/CHANGELOG.md index 6a86ba5..a827f59 100644 --- a/policies/rust/now-policy-server-template/CHANGELOG.md +++ b/policies/rust/now-policy-server-template/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- [**breaking**] Add required policy management, validation, and optimistic replacement trait methods, routes, status mappings, and OpenAPI operations. +- [**breaking**] Add required policy management, validation, and optimistic replacement trait methods, routes, status mappings, and OpenAPI operations. Unsafe policy paths map to HTTP 409; absent routes retain ordinary HTTP 404 behavior. ## [[0.3.0](https://github.com/Devolutions/now-libraries/compare/now-policy-server-template-v0.2.0...now-policy-server-template-v0.3.0)] - 2026-08-05 diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index 1f181e5..ca7d693 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -303,6 +303,7 @@ async fn request_rejection( message: message.to_owned(), details: Vec::new(), validation: None, + management: None, }; (error_status(error.code), Json(error)).into_response() } @@ -318,11 +319,12 @@ fn error_status(code: ErrorCode) -> StatusCode { match code { ErrorCode::BadRequest | ErrorCode::MalformedDraft => StatusCode::BAD_REQUEST, ErrorCode::Unauthorized | ErrorCode::Unauthenticated => StatusCode::UNAUTHORIZED, - ErrorCode::Forbidden | ErrorCode::AdministratorRequired | ErrorCode::UnsafePolicyPath => StatusCode::FORBIDDEN, + ErrorCode::Forbidden | ErrorCode::AdministratorRequired => StatusCode::FORBIDDEN, ErrorCode::NotFound => StatusCode::NOT_FOUND, - ErrorCode::Conflict | ErrorCode::WarningConfirmationRequired | ErrorCode::StalePolicyStoreToken => { - StatusCode::CONFLICT - } + ErrorCode::Conflict + | ErrorCode::WarningConfirmationRequired + | ErrorCode::UnsafePolicyPath + | ErrorCode::StalePolicyStoreToken => StatusCode::CONFLICT, ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, ErrorCode::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, ErrorCode::ValidationFailed | ErrorCode::InvalidPolicy | ErrorCode::UnsupportedPolicyFilesystem => { 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 6b054ea..a7d4e02 100644 --- a/policies/rust/now-policy-server-template/tests/sample_documents.rs +++ b/policies/rust/now-policy-server-template/tests/sample_documents.rs @@ -515,6 +515,7 @@ async fn api_router_preserves_supported_policy_failure() { message: "active policy is temporarily unavailable".to_owned(), details: Vec::new(), validation: None, + management: None, }; let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME).with_policy_error(error)); @@ -589,7 +590,7 @@ async fn policy_management_error_codes_use_stable_http_statuses() { (ErrorCode::WarningConfirmationRequired, StatusCode::CONFLICT), (ErrorCode::Unauthenticated, StatusCode::UNAUTHORIZED), (ErrorCode::AdministratorRequired, StatusCode::FORBIDDEN), - (ErrorCode::UnsafePolicyPath, StatusCode::FORBIDDEN), + (ErrorCode::UnsafePolicyPath, StatusCode::CONFLICT), (ErrorCode::StalePolicyStoreToken, StatusCode::CONFLICT), (ErrorCode::UnsupportedPolicyFilesystem, StatusCode::UNPROCESSABLE_ENTITY), (ErrorCode::PolicyPersistenceFailed, StatusCode::INTERNAL_SERVER_ERROR), @@ -606,6 +607,7 @@ async fn policy_management_error_codes_use_stable_http_statuses() { message: "management error".to_owned(), details: Vec::new(), validation: None, + management: None, }; let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME).with_policy_error(error)); let response = app @@ -622,6 +624,43 @@ async fn policy_management_error_codes_use_stable_http_statuses() { } } +#[test] +fn stale_policy_store_token_requires_atomic_management_snapshot() { + let mut stale = load_json_file(&response_sample_path("policy-stale-token.error.json")); + let parsed: ErrorResponse = serde_json::from_value(stale.clone()).unwrap(); + assert_eq!(parsed.management.unwrap().store_token.as_ref(), "store:active:9"); + + stale.as_object_mut().unwrap().remove("Management"); + assert!(serde_json::from_value::(stale).is_err()); + + let mut contradictory = load_json_file(&response_sample_path("policy-stale-token.error.json")); + contradictory["Management"].as_object_mut().unwrap().remove("Policy"); + assert!(serde_json::from_value::(contradictory).is_err()); + + let mut invalid_for_serialization: ErrorResponse = + serde_json::from_value(load_json_file(&response_sample_path("policy-stale-token.error.json"))).unwrap(); + invalid_for_serialization.management = None; + assert!(serde_json::to_value(invalid_for_serialization).is_err()); +} + +#[tokio::test] +async fn absent_legacy_policy_management_route_remains_an_ordinary_404() { + let response = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)) + .oneshot( + Request::builder() + .method("GET") + .uri("/v0/policy/management") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(serde_json::from_slice::(&body).is_err()); +} + #[tokio::test] async fn policy_management_request_rejections_are_structured() { let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); diff --git a/policies/rust/now-policy-server-template/tests/support/mock.rs b/policies/rust/now-policy-server-template/tests/support/mock.rs index 63866da..09524f0 100644 --- a/policies/rust/now-policy-server-template/tests/support/mock.rs +++ b/policies/rust/now-policy-server-template/tests/support/mock.rs @@ -129,6 +129,7 @@ impl MockPackageBrokerServer { message: format!("no mock response registered for '{id}'"), details: Vec::new(), validation: None, + management: None, } } @@ -141,6 +142,7 @@ impl MockPackageBrokerServer { message: format!("{endpoint} is not implemented by this mock"), details: Vec::new(), validation: None, + management: None, } } } @@ -172,6 +174,7 @@ impl PackageBrokerServer for MockPackageBrokerServer { message: "no active policy is configured".to_owned(), details: Vec::new(), validation: None, + management: None, }) } diff --git a/policies/test-data/package-broker/invalid/responses/policy-management.active-without-policy.response.json b/policies/test-data/package-broker/invalid/responses/policy-management.active-without-policy.response.json new file mode 100644 index 0000000..1c01ad6 --- /dev/null +++ b/policies/test-data/package-broker/invalid/responses/policy-management.active-without-policy.response.json @@ -0,0 +1,16 @@ +{ + "ResponseKind": "PolicyManagementResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Management": { + "State": "Active", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.json", + "StoreToken": "store:active:7", + "Source": "ConfiguredPath", + "WriteCapability": "Writable", + "ElevationRequired": true + } +} diff --git a/policies/test-data/package-broker/invalid/responses/policy-management.invalid-with-warning.response.json b/policies/test-data/package-broker/invalid/responses/policy-management.invalid-with-warning.response.json new file mode 100644 index 0000000..05678ee --- /dev/null +++ b/policies/test-data/package-broker/invalid/responses/policy-management.invalid-with-warning.response.json @@ -0,0 +1,29 @@ +{ + "ResponseKind": "PolicyManagementResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Management": { + "State": "Invalid", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.json", + "StoreToken": "store:invalid:4", + "Source": "ConfiguredPath", + "WriteCapability": "ReadOnly", + "ReadOnlyReason": "InsufficientPermissions", + "ElevationRequired": true, + "InvalidDiagnostics": { + "DiagnosticsVersion": "1.0", + "Findings": [ + { + "FindingVersion": "1.0", + "Severity": "Warning", + "Code": "DefaultAllow", + "Path": "/Enforcement/DefaultDecision", + "Message": "Invalid state requires an error." + } + ] + } + } +} diff --git a/policies/test-data/package-broker/invalid/responses/policy-management.readonly-without-reason.response.json b/policies/test-data/package-broker/invalid/responses/policy-management.readonly-without-reason.response.json new file mode 100644 index 0000000..d88c308 --- /dev/null +++ b/policies/test-data/package-broker/invalid/responses/policy-management.readonly-without-reason.response.json @@ -0,0 +1,16 @@ +{ + "ResponseKind": "PolicyManagementResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Management": { + "State": "Missing", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.json", + "StoreToken": "store:missing:0", + "Source": "ConfiguredPath", + "WriteCapability": "ReadOnly", + "ElevationRequired": true + } +} diff --git a/policies/test-data/package-broker/invalid/responses/policy-validation.invalid-with-empty-findings.response.json b/policies/test-data/package-broker/invalid/responses/policy-validation.invalid-with-empty-findings.response.json new file mode 100644 index 0000000..198bb00 --- /dev/null +++ b/policies/test-data/package-broker/invalid/responses/policy-validation.invalid-with-empty-findings.response.json @@ -0,0 +1,14 @@ +{ + "ResponseKind": "PolicyValidationResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Validation": { + "ResultVersion": "1.0", + "ValidatorVersion": "gateway-policy-validator/1", + "IsValid": false, + "Findings": [] + } +} diff --git a/policies/test-data/package-broker/invalid/responses/policy-validation.invalid-with-warning.response.json b/policies/test-data/package-broker/invalid/responses/policy-validation.invalid-with-warning.response.json new file mode 100644 index 0000000..e327a06 --- /dev/null +++ b/policies/test-data/package-broker/invalid/responses/policy-validation.invalid-with-warning.response.json @@ -0,0 +1,22 @@ +{ + "ResponseKind": "PolicyValidationResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Validation": { + "ResultVersion": "1.0", + "ValidatorVersion": "gateway-policy-validator/1", + "IsValid": false, + "Findings": [ + { + "FindingVersion": "1.0", + "Severity": "Warning", + "Code": "DefaultAllow", + "Path": "/Enforcement/DefaultDecision", + "Message": "A warning cannot make a draft invalid." + } + ] + } +} diff --git a/policies/test-data/package-broker/invalid/responses/policy-validation.valid-with-error.response.json b/policies/test-data/package-broker/invalid/responses/policy-validation.valid-with-error.response.json new file mode 100644 index 0000000..09f23fb --- /dev/null +++ b/policies/test-data/package-broker/invalid/responses/policy-validation.valid-with-error.response.json @@ -0,0 +1,37 @@ +{ + "ResponseKind": "PolicyValidationResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Validation": { + "ResultVersion": "1.0", + "ValidatorVersion": "gateway-policy-validator/1", + "IsValid": true, + "CanonicalDraft": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.package-policy", + "Publisher": "Contoso IT" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [] + }, + "ValidationReceipt": "receipt:contradictory", + "Findings": [ + { + "FindingVersion": "1.0", + "Severity": "Error", + "Code": "InvalidFieldValue", + "Path": "/Metadata/Id", + "Message": "An error contradicts IsValid." + } + ] + } +} diff --git a/policies/test-data/package-broker/responses/policy-stale-token.error.json b/policies/test-data/package-broker/responses/policy-stale-token.error.json index 9142c76..3d7cdc0 100644 --- a/policies/test-data/package-broker/responses/policy-stale-token.error.json +++ b/policies/test-data/package-broker/responses/policy-stale-token.error.json @@ -7,6 +7,30 @@ }, "Code": "StalePolicyStoreToken", "Message": "The configured policy changed after it was read.", + "Management": { + "State": "Active", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.json", + "StoreToken": "store:active:9", + "Source": "ConfiguredPath", + "WriteCapability": "Writable", + "ElevationRequired": true, + "Policy": { + "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.package-policy", + "Publisher": "Contoso IT", + "Revision": 9, + "PublishedAt": "2026-08-29T01:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [] + } + }, "Validation": { "ResultVersion": "1.0", "ValidatorVersion": "gateway-policy-validator/1", @@ -20,7 +44,7 @@ "Arguments": { "currentStoreToken": "store:active:9" }, - "Message": "Read the current management snapshot and retry." + "Message": "Retry against the exact state and store token returned in Management." } ] } From c8cfdffd765897e762823a8e1013c16a8381c60f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 29 Aug 2026 12:13:44 +0900 Subject: [PATCH 3/4] fix: align policy contract serialization parity Apply semantic invariants to both C# deserialization modes, restrict opaque values to safe ASCII, and preserve nullable optional schemas without weakening required states. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerJson.cs | 12 +- .../PolicyManagementModels.cs | 15 ++- .../Devolutions.Now.Policy.Api/README.md | 2 + .../PolicyManagementClientTests.cs | 94 +++++++++++++++ policies/rust/now-policy-api/README.md | 2 + .../openapi/now-policy-api.yaml | 61 ++++++---- .../rust/now-policy-api/src/management.rs | 107 +++++++++++++----- policies/rust/now-policy-api/src/policy.rs | 20 ++++ .../now-policy-server-template/src/server.rs | 25 ++++ 9 files changed, 274 insertions(+), 64 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs index 5f86762..54b73d4 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs @@ -27,29 +27,25 @@ public static class BrokerJson public static string Serialize(T value) { - ValidateStrictValue(value); + ValidateSemanticValue(value); return JsonSerializer.Serialize(value, TypeInfo()); } public static T? Deserialize(string json) { var value = JsonSerializer.Deserialize(json, TypeInfo()); - if (value is ErrorResponse error) - { - ValidateError(error); - } - + ValidateSemanticValue(value); return value; } public static T? DeserializeStrict(string json) { var value = JsonSerializer.Deserialize(json, StrictTypeInfo()); - ValidateStrictValue(value); + ValidateSemanticValue(value); return value; } - private static void ValidateStrictValue(T value) + private static void ValidateSemanticValue(T value) { switch (value) { diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs index f252951..7513af8 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs @@ -14,7 +14,7 @@ internal sealed class ExactCasePolicyConflictHandlingConverter : ExactCaseString internal sealed class ExactCasePolicyFindingSeverityConverter : ExactCaseStringEnumConverter; internal sealed class ExactCasePolicyFindingCodeConverter : ExactCaseStringEnumConverter; -internal abstract class BoundedStringJsonConverter(int maxLength, string typeName) : JsonConverter +internal abstract class OpaqueStringJsonConverter(int maxLength, string typeName) : JsonConverter { public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { @@ -35,16 +35,25 @@ private string Validate(string? value) { throw new JsonException($"{typeName} must contain between 1 and {maxLength} characters."); } + if (!IsAsciiAlphanumeric(value[0]) || value.Any(character => + !IsAsciiAlphanumeric(character) && character is not ('.' or '_' or '~' or ':' or '-'))) + { + throw new JsonException( + $"{typeName} must use safe printable ASCII characters and start with an ASCII alphanumeric character."); + } return value; } + + private static bool IsAsciiAlphanumeric(char character) => + character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9'; } internal sealed class PolicyStoreTokenJsonConverter() - : BoundedStringJsonConverter(512, "PolicyStoreToken"); + : OpaqueStringJsonConverter(512, "PolicyStoreToken"); internal sealed class PolicyValidationReceiptJsonConverter() - : BoundedStringJsonConverter(2048, "PolicyValidationReceipt"); + : OpaqueStringJsonConverter(2048, "PolicyValidationReceipt"); /// Current configured-policy state. [JsonConverter(typeof(ExactCasePolicyManagementStateConverter))] diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index e5c03d6..e076474 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -33,6 +33,8 @@ Architecture - `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. +Opaque policy store tokens and validation receipts are restricted to safe printable ASCII (`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `:`, `-`) beginning with an ASCII alphanumeric character, so Rust and .NET enforce identical bounds. + OpenAPI relationship -------------------- diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs index b3f38c8..520b76c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -163,6 +163,34 @@ public async Task Strict_management_contract_rejects_empty_tokens_and_receipts() () => BrokerJson.DeserializeStrict(replacement.ToJsonString())); } + [Fact] + public async Task Opaque_tokens_and_receipts_reject_non_ascii_values() + { + var management = JsonNode.Parse(await ReadFixture("responses", "policy-management.active.response.json"))!; + management["Management"]!["StoreToken"] = "store:activé:7"; + Assert.Throws( + () => BrokerJson.DeserializeStrict(management.ToJsonString())); + Assert.NotEmpty( + (await TestData.SchemaAsync("PolicyManagementResponse")).Validate(management.ToJsonString())); + + var replacement = JsonNode.Parse(await ReadFixture("requests", "policy-replacement.update.request.json"))!; + replacement["ValidationReceipt"] = "receipt:é"; + Assert.Throws( + () => BrokerJson.DeserializeStrict(replacement.ToJsonString())); + Assert.NotEmpty( + (await TestData.SchemaAsync("PolicyReplacementRequest")).Validate(replacement.ToJsonString())); + + var managementDto = BrokerJson.DeserializeStrict( + await ReadFixture("responses", "policy-management.active.response.json"))!; + managementDto.Management.StoreToken = "store:activé:7"; + Assert.Throws(() => BrokerJson.Serialize(managementDto)); + + var replacementDto = BrokerJson.DeserializeStrict( + await ReadFixture("requests", "policy-replacement.update.request.json"))!; + replacementDto.ValidationReceipt = "receipt:é"; + Assert.Throws(() => BrokerJson.Serialize(replacementDto)); + } + [Fact] public async Task Strict_validation_response_enforces_success_artifact_invariant() { @@ -175,6 +203,42 @@ public async Task Strict_validation_response_enforces_success_artifact_invariant invalid["Validation"]!["ValidationReceipt"] = "unexpected-receipt"; Assert.Throws( () => BrokerJson.DeserializeStrict(invalid.ToJsonString())); + + var validWithNull = JsonNode.Parse(await ReadFixture("responses", "policy-validation.valid.response.json"))!; + validWithNull["Validation"]!["CanonicalDraft"] = null; + Assert.Throws( + () => BrokerJson.DeserializeStrict(validWithNull.ToJsonString())); + Assert.NotEmpty( + (await TestData.SchemaAsync("PolicyValidationResponse")).Validate(validWithNull.ToJsonString())); + } + + [Fact] + public async Task Active_management_snapshot_rejects_explicit_null_policy() + { + var active = JsonNode.Parse(await ReadFixture("responses", "policy-management.active.response.json"))!; + active["Management"]!["Policy"] = null; + + Assert.Throws( + () => BrokerJson.DeserializeStrict(active.ToJsonString())); + Assert.NotEmpty( + (await TestData.SchemaAsync("PolicyManagementResponse")).Validate(active.ToJsonString())); + } + + [Fact] + public async Task Optional_nulls_match_absent_values_in_none_states() + { + var invalid = JsonNode.Parse(await ReadFixture("responses", "policy-validation.invalid.response.json"))!; + invalid["Validation"]!["CanonicalDraft"] = null; + invalid["Validation"]!["ValidationReceipt"] = null; + Assert.NotNull(BrokerJson.DeserializeStrict(invalid.ToJsonString())); + Assert.Empty((await TestData.SchemaAsync("PolicyValidationResponse")).Validate(invalid.ToJsonString())); + + var missing = JsonNode.Parse(await ReadFixture("responses", "policy-management.missing.response.json"))!; + missing["Management"]!["Policy"] = null; + missing["Management"]!["InvalidDiagnostics"] = null; + missing["Management"]!["ReadOnlyReason"] = null; + Assert.NotNull(BrokerJson.DeserializeStrict(missing.ToJsonString())); + Assert.Empty((await TestData.SchemaAsync("PolicyManagementResponse")).Validate(missing.ToJsonString())); } [Theory] @@ -197,6 +261,36 @@ public async Task Strict_management_response_rejects_contradictory_snapshot(stri Assert.Throws(() => BrokerJson.DeserializeStrict(json)); } + [Theory] + [InlineData("policy-validation.valid-with-error.response.json", true)] + [InlineData("policy-validation.invalid-with-warning.response.json", true)] + [InlineData("policy-management.active-without-policy.response.json", false)] + [InlineData("policy-management.readonly-without-reason.response.json", false)] + public async Task Non_strict_deserialization_still_enforces_semantic_invariants(string fixture, bool validation) + { + var json = await ReadFixture(Path.Combine("invalid", "responses"), fixture); + if (validation) + { + Assert.Throws(() => BrokerJson.Deserialize(json)); + } + else + { + Assert.Throws(() => BrokerJson.Deserialize(json)); + } + } + + [Fact] + public async Task Non_strict_replacement_deserialization_enforces_nested_invariants() + { + var response = JsonNode.Parse(await ReadFixture("responses", "policy-replacement.response.json"))!; + response["Validation"]!["IsValid"] = false; + response["Validation"]!.AsObject().Remove("CanonicalDraft"); + response["Validation"]!.AsObject().Remove("ValidationReceipt"); + + Assert.Throws( + () => BrokerJson.Deserialize(response.ToJsonString())); + } + [Theory] [InlineData("policy-validation.valid-with-error.response.json", "PolicyValidationResponse")] [InlineData("policy-validation.invalid-with-warning.response.json", "PolicyValidationResponse")] diff --git a/policies/rust/now-policy-api/README.md b/policies/rust/now-policy-api/README.md index 37ab488..b84adfc 100644 --- a/policies/rust/now-policy-api/README.md +++ b/policies/rust/now-policy-api/README.md @@ -59,6 +59,8 @@ The generated document contains the unchanged policy inspection route, the manag `StalePolicyStoreToken` errors include the atomic current `Management` snapshot so a client can explicitly confirm an overwrite against that exact newly observed token. `UnsafePolicyPath` is a 409 state/write-capability conflict, not an authentication failure. An Agent that does not expose a newer route may still return an ordinary unstructured 404; `UnsupportedEndpoint` is only an optional explicit implementation response. +Opaque store tokens and validation receipts use safe printable ASCII (`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `:`, `-`) and begin with an ASCII alphanumeric character. This keeps length and validation behavior identical across Rust UTF-8 and .NET UTF-16 implementations. + 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 f8b6c8e..373ec8f 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1216,26 +1216,29 @@ components: - $ref: '#/components/schemas/PolicyManagementSnapshotFields' - oneOf: - properties: + InvalidDiagnostics: + enum: + - null + nullable: true Policy: $ref: '#/components/schemas/PolicyDocument' State: enum: - Active - not: - required: - - InvalidDiagnostics required: - Policy - properties: + InvalidDiagnostics: + enum: + - null + nullable: true + Policy: + enum: + - null + nullable: true State: enum: - Missing - not: - anyOf: - - required: - - Policy - - required: - - InvalidDiagnostics - properties: InvalidDiagnostics: properties: @@ -1251,22 +1254,24 @@ components: - Severity allOf: - $ref: '#/components/schemas/InvalidPolicyDiagnostics' + Policy: + enum: + - null + nullable: true State: enum: - Invalid - not: - required: - - Policy required: - InvalidDiagnostics - oneOf: - properties: + ReadOnlyReason: + enum: + - null + nullable: true WriteCapability: enum: - Writable - not: - required: - - ReadOnlyReason - properties: ReadOnlyReason: $ref: '#/components/schemas/PolicyReadOnlyReason' @@ -1293,8 +1298,11 @@ components: nullable: true default: null Policy: - allOf: + anyOf: - $ref: '#/components/schemas/PolicyDocument' + - enum: + - null + nullable: true default: null ReadOnlyReason: anyOf: @@ -1454,11 +1462,13 @@ components: type: string maxLength: 512 minLength: 1 + pattern: ^[A-Za-z0-9][A-Za-z0-9._~:\-]{0,511}$ PolicyValidationReceipt: description: Opaque receipt bound to a canonical draft, validator version, and exact warning set. type: string maxLength: 2048 minLength: 1 + pattern: ^[A-Za-z0-9][A-Za-z0-9._~:\-]{0,2047}$ PolicyValidationRequest: description: Request body for `POST /v1/policy/validate`. type: object @@ -1522,6 +1532,10 @@ components: - CanonicalDraft - ValidationReceipt - properties: + CanonicalDraft: + enum: + - null + nullable: true Findings: minItems: 1 not: @@ -1535,18 +1549,19 @@ components: IsValid: enum: - false - not: - anyOf: - - required: - - CanonicalDraft - - required: - - ValidationReceipt + ValidationReceipt: + enum: + - null + nullable: true PolicyValidationResultFields: type: object properties: CanonicalDraft: - allOf: + anyOf: - $ref: '#/components/schemas/PolicyDraftDocument' + - enum: + - null + nullable: true default: null Findings: type: array diff --git a/policies/rust/now-policy-api/src/management.rs b/policies/rust/now-policy-api/src/management.rs index 5bdfd97..bacaa75 100644 --- a/policies/rust/now-policy-api/src/management.rs +++ b/policies/rust/now-policy-api/src/management.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use now_policy::{PolicyDocument, PolicyDraftDocument}; use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; use super::api::ServerContext; use super::{ @@ -115,7 +115,6 @@ pub enum PolicyFindingCode { Clone, PartialEq, Eq, - Serialize, JsonSchema, derive_more::AsRef, derive_more::Deref, @@ -125,16 +124,29 @@ pub enum PolicyFindingCode { #[as_ref(str)] #[deref(forward)] #[display("{_0}")] -pub struct PolicyStoreToken(#[schemars(length(min = 1, max = 512))] pub String); +pub struct PolicyStoreToken( + #[schemars( + length(min = 1, max = 512), + regex(pattern = r"^[A-Za-z0-9][A-Za-z0-9._~:\-]{0,511}$") + )] + pub String, +); impl<'de> Deserialize<'de> for PolicyStoreToken { fn deserialize>(deserializer: D) -> Result { let value = String::deserialize(deserializer)?; - validate_bounded_string(&value, 1, 512, "PolicyStoreToken").map_err(serde::de::Error::custom)?; + validate_opaque_ascii(&value, 512, "PolicyStoreToken").map_err(serde::de::Error::custom)?; Ok(Self(value)) } } +impl Serialize for PolicyStoreToken { + fn serialize(&self, serializer: S) -> Result { + validate_opaque_ascii(&self.0, 512, "PolicyStoreToken").map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&self.0) + } +} + impl From<&str> for PolicyStoreToken { fn from(value: &str) -> Self { Self(value.to_owned()) @@ -147,7 +159,6 @@ impl From<&str> for PolicyStoreToken { Clone, PartialEq, Eq, - Serialize, JsonSchema, derive_more::AsRef, derive_more::Deref, @@ -157,22 +168,54 @@ impl From<&str> for PolicyStoreToken { #[as_ref(str)] #[deref(forward)] #[display("{_0}")] -pub struct PolicyValidationReceipt(#[schemars(length(min = 1, max = 2048))] pub String); +pub struct PolicyValidationReceipt( + #[schemars( + length(min = 1, max = 2048), + regex(pattern = r"^[A-Za-z0-9][A-Za-z0-9._~:\-]{0,2047}$") + )] + pub String, +); impl<'de> Deserialize<'de> for PolicyValidationReceipt { fn deserialize>(deserializer: D) -> Result { let value = String::deserialize(deserializer)?; - validate_bounded_string(&value, 1, 2048, "PolicyValidationReceipt").map_err(serde::de::Error::custom)?; + validate_opaque_ascii(&value, 2048, "PolicyValidationReceipt").map_err(serde::de::Error::custom)?; Ok(Self(value)) } } +impl Serialize for PolicyValidationReceipt { + fn serialize(&self, serializer: S) -> Result { + validate_opaque_ascii(&self.0, 2048, "PolicyValidationReceipt").map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&self.0) + } +} + impl From<&str> for PolicyValidationReceipt { fn from(value: &str) -> Self { Self(value.to_owned()) } } +fn validate_opaque_ascii( + value: &str, + max_length: usize, + type_name: &'static str, +) -> Result<(), super::ModelValidationError> { + validate_bounded_string(value, 1, max_length, type_name)?; + if !value.bytes().enumerate().all(|(index, byte)| { + (index == 0 && byte.is_ascii_alphanumeric()) + || (index > 0 && (byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'~' | b':' | b'-'))) + }) { + return Err(super::ModelValidationError::Invalid { + type_name, + reason: "must use safe printable ASCII characters and start with an ASCII alphanumeric character" + .to_owned(), + }); + } + Ok(()) +} + /// Versioned, structured policy validation finding. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[schemars(rename = "PolicyFinding")] @@ -236,7 +279,7 @@ struct PolicyValidationResultWire { pub validator_version: String, pub is_valid: bool, #[serde(default)] - #[schemars(schema_with = "super::policy::policy_draft_document_schema")] + #[schemars(schema_with = "super::policy::optional_policy_draft_document_schema")] pub canonical_draft: Option, #[serde(default)] pub validation_receipt: Option, @@ -348,6 +391,8 @@ impl JsonSchema for PolicyValidationResult { { "properties": { "IsValid": { "const": false }, + "CanonicalDraft": { "type": "null" }, + "ValidationReceipt": { "type": "null" }, "Findings": { "minItems": 1, "not": { @@ -359,12 +404,6 @@ impl JsonSchema for PolicyValidationResult { } } } - }, - "not": { - "anyOf": [ - { "required": ["CanonicalDraft"] }, - { "required": ["ValidationReceipt"] } - ] } } ] @@ -424,7 +463,7 @@ struct PolicyManagementSnapshotWire { pub read_only_reason: Option, pub elevation_required: bool, #[serde(default)] - #[schemars(schema_with = "super::policy::policy_document_schema")] + #[schemars(schema_with = "super::policy::optional_policy_document_schema")] pub policy: Option, #[serde(default)] pub invalid_diagnostics: Option, @@ -547,18 +586,16 @@ impl JsonSchema for PolicyManagementSnapshot { "State": { "const": "Active" }, "Policy": { "$ref": "#/components/schemas/PolicyDocument" - } + }, + "InvalidDiagnostics": { "type": "null" } }, - "required": ["Policy"], - "not": { "required": ["InvalidDiagnostics"] } + "required": ["Policy"] }, { - "properties": { "State": { "const": "Missing" } }, - "not": { - "anyOf": [ - { "required": ["Policy"] }, - { "required": ["InvalidDiagnostics"] } - ] + "properties": { + "State": { "const": "Missing" }, + "Policy": { "type": "null" }, + "InvalidDiagnostics": { "type": "null" } } }, { @@ -581,18 +618,20 @@ impl JsonSchema for PolicyManagementSnapshot { } } } - } + }, + "Policy": { "type": "null" } }, - "required": ["InvalidDiagnostics"], - "not": { "required": ["Policy"] } + "required": ["InvalidDiagnostics"] } ] }, { "oneOf": [ { - "properties": { "WriteCapability": { "const": "Writable" } }, - "not": { "required": ["ReadOnlyReason"] } + "properties": { + "WriteCapability": { "const": "Writable" }, + "ReadOnlyReason": { "type": "null" } + } }, { "properties": { @@ -693,7 +732,15 @@ pub struct PolicyReplacementResponse { #[cfg(test)] mod tests { - use super::{PolicyManagementSnapshot, PolicyValidationResult}; + use super::{PolicyManagementSnapshot, PolicyStoreToken, PolicyValidationReceipt, PolicyValidationResult}; + + #[test] + fn opaque_tokens_and_receipts_reject_non_ascii_values() { + assert!(serde_json::from_str::("\"store:activé:7\"").is_err()); + assert!(serde_json::from_str::("\"receipt:é\"").is_err()); + assert!(serde_json::to_value(PolicyStoreToken("store:activé:7".to_owned())).is_err()); + assert!(serde_json::to_value(PolicyValidationReceipt("receipt:é".to_owned())).is_err()); + } #[test] fn validation_result_requires_success_artifacts_exactly_when_valid() { diff --git a/policies/rust/now-policy-api/src/policy.rs b/policies/rust/now-policy-api/src/policy.rs index e1cc9b9..15c8ac2 100644 --- a/policies/rust/now-policy-api/src/policy.rs +++ b/policies/rust/now-policy-api/src/policy.rs @@ -42,3 +42,23 @@ pub(crate) fn policy_draft_document_schema(_generator: &mut SchemaGenerator) -> "$ref": "#/components/schemas/PolicyDraftDocument", }) } + +pub(crate) fn optional_policy_document_schema(generator: &mut SchemaGenerator) -> Schema { + let document = policy_document_schema(generator); + schemars::json_schema!({ + "anyOf": [ + document, + { "type": "null" } + ] + }) +} + +pub(crate) fn optional_policy_draft_document_schema(generator: &mut SchemaGenerator) -> Schema { + let document = policy_draft_document_schema(generator); + schemars::json_schema!({ + "anyOf": [ + document, + { "type": "null" } + ] + }) +} diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index ca7d693..52d0828 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -484,4 +484,29 @@ mod tests { "#/components/schemas/ErrorResponse" ); } + + #[test] + fn policy_openapi_preserves_nullable_optional_document_fields() { + let api = serde_json::to_value(openapi()).expect("OpenAPI should serialize"); + for pointer in [ + "/components/schemas/PolicyManagementSnapshotFields/properties/Policy/anyOf", + "/components/schemas/PolicyValidationResultFields/properties/CanonicalDraft/anyOf", + ] { + let variants = api + .pointer(pointer) + .and_then(serde_json::Value::as_array) + .unwrap_or_else(|| panic!("missing nullable variants at {pointer}")); + assert!( + variants.iter().any(|variant| { + variant.get("nullable") == Some(&serde_json::Value::Bool(true)) + || variant.get("type").and_then(serde_json::Value::as_str) == Some("null") + || variant + .get("enum") + .and_then(serde_json::Value::as_array) + .is_some_and(|values| values.iter().any(serde_json::Value::is_null)) + }), + "{pointer} should retain an explicit null variant" + ); + } + } } From ec592a91f95a66c8a79b7d612ed73d931ef74aeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sat, 29 Aug 2026 15:10:57 +0900 Subject: [PATCH 4/4] fix: harden policy management transport contract Add the separate 16 MiB policy-management body limit, align Unicode text bounds, and distinguish unsupported non-JSON policy paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/BrokerApi.cs | 3 + .../Devolutions.Now.Policy.Api/Enums.cs | 1 + .../PolicyManagementModels.cs | 1 + .../Devolutions.Now.Policy.Api/README.md | 8 ++ .../DtoRoundTripTests.cs | 10 +- .../PolicyManagementClientTests.cs | 119 ++++++++++++++++++ .../SchemaValidationTests.cs | 9 ++ .../TestData.cs | 31 +++++ .../BrokerClient.cs | 8 +- .../Devolutions.Now.Policy.Client/README.md | 10 +- .../PolicyTests.cs | 38 ++++++ .../PolicyJson.cs | 61 +++++++-- policies/rust/now-policy-api/CHANGELOG.md | 2 +- policies/rust/now-policy-api/README.md | 2 +- .../openapi/now-policy-api.yaml | 20 ++- policies/rust/now-policy-api/src/enums.rs | 1 + .../rust/now-policy-api/src/management.rs | 1 + .../now-policy-server-template/CHANGELOG.md | 2 +- .../rust/now-policy-server-template/README.md | 7 ++ .../now-policy-server-template/src/server.rs | 64 ++++++++-- .../tests/sample_documents.rs | 111 ++++++++++++++-- policies/rust/now-policy/CHANGELOG.md | 4 + policies/rust/now-policy/src/newtypes.rs | 9 +- .../rust/now-policy/tests/policy_samples.rs | 16 ++- ...anagement.unsupported-format.response.json | 32 +++++ .../policy-unsupported-format.error.json | 34 +++++ 26 files changed, 553 insertions(+), 51 deletions(-) create mode 100644 policies/test-data/package-broker/responses/policy-management.unsupported-format.response.json create mode 100644 policies/test-data/package-broker/responses/policy-unsupported-format.error.json diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs index 12b5736..27b8933 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs @@ -9,6 +9,9 @@ public static class BrokerApi public const string DefaultPipeName = "Devolutions.Now.PackageBroker.v1"; + /// Maximum complete HTTP request-body size for policy validation and replacement. + public const int MaxPolicyManagementBodyBytes = 16 * 1024 * 1024; + public const string PackageRequestKind = "PackageRequest"; public const string StatusRequestKind = "StatusRequest"; public const string CancelRequestKind = "CancelRequest"; diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs index 36aecd3..5e5525f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs @@ -154,6 +154,7 @@ public enum ErrorCode Unauthenticated, AdministratorRequired, UnsafePolicyPath, + UnsupportedPolicyFormat, StalePolicyStoreToken, UnsupportedPolicyFilesystem, PolicyPersistenceFailed, diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs index 7513af8..578061e 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs @@ -87,6 +87,7 @@ public enum PolicyReadOnlyReason { ManagementDisabled, PathNotConfigured, + UnsupportedFormat, UnsafePath, InsufficientPermissions, UnsupportedFileSystem, diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index e076474..f494813 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -35,6 +35,14 @@ Architecture Opaque policy store tokens and validation receipts are restricted to safe printable ASCII (`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `:`, `-`) beginning with an ASCII alphanumeric character, so Rust and .NET enforce identical bounds. +`BrokerApi.MaxPolicyManagementBodyBytes` exposes the fixed 16 MiB limit for the complete serialized +HTTP body of policy validation and replacement requests. It is separate from the package-operation +limit advertised by broker capabilities. + +Because policy documents are JSON-only, configured `.yaml`, `.yml`, extensionless, and other +non-JSON paths use `PolicyReadOnlyReason.UnsupportedFormat` in management snapshots and +`ErrorCode.UnsupportedPolicyFormat` for structured HTTP 422 errors. + OpenAPI relationship -------------------- diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs index 843ee72..7ca1434 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs @@ -87,12 +87,10 @@ public async Task PolicyReplacementRequest_round_trips_and_validates(string path public async Task PolicyReplacementResponse_round_trips_and_validates(string path) => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyReplacementResponse")); - [Fact] - public async Task PolicyManagementError_round_trips_and_validates() - { - var path = Path.Combine(TestData.SamplesDir, "responses", "policy-stale-token.error.json"); - await AssertRoundTrip(path, await TestData.SchemaAsync("ErrorResponse")); - } + [Theory] + [MemberData(nameof(TestData.PolicyManagementErrorSamples), MemberType = typeof(TestData))] + public async Task PolicyManagementError_round_trips_and_validates(string path) + => await AssertRoundTrip(path, await TestData.SchemaAsync("ErrorResponse")); private static async Task AssertRoundTrip(string samplePath, JsonSchema schema) { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs index 520b76c..47b0158 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -85,6 +86,109 @@ public async Task ReplacePolicy_preserves_structured_stale_token_findings() Assert.Equal("store:active:9", exception.BrokerError?.Management?.StoreToken); } + [Fact] + public async Task ReplacePolicy_parses_unsupported_json_path_format() + { + var request = BrokerJson.DeserializeStrict( + await ReadFixture("requests", "policy-replacement.update.request.json"))!; + var errorBody = await ReadFixture("responses", "policy-unsupported-format.error.json"); + var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 422, Body = errorBody }); + + var exception = await Assert.ThrowsAsync( + () => CreateClient(transport).ReplacePolicy(request)); + + Assert.Equal(422, exception.StatusCode); + Assert.Equal(ErrorCode.UnsupportedPolicyFormat, exception.BrokerError?.Code); + Assert.Equal(PolicyReadOnlyReason.UnsupportedFormat, exception.BrokerError?.Management?.ReadOnlyReason); + Assert.EndsWith("now-policy.yaml", exception.BrokerError?.Management?.ConfiguredPath, StringComparison.Ordinal); + } + + [Fact] + public async Task Unsupported_policy_format_values_use_exact_case() + { + var management = JsonNode.Parse( + await ReadFixture("responses", "policy-management.unsupported-format.response.json"))!; + var parsedManagement = BrokerJson.DeserializeStrict(management.ToJsonString())!; + Assert.Equal(PolicyReadOnlyReason.UnsupportedFormat, parsedManagement.Management.ReadOnlyReason); + Assert.Contains("\"ReadOnlyReason\":\"UnsupportedFormat\"", BrokerJson.Serialize(parsedManagement)); + + management["Management"]!["ReadOnlyReason"] = JsonNode.Parse("\"unsupportedformat\""); + Assert.Throws( + () => BrokerJson.DeserializeStrict(management.ToJsonString())); + + var error = JsonNode.Parse(await ReadFixture("responses", "policy-unsupported-format.error.json"))!; + var parsedError = BrokerJson.DeserializeStrict(error.ToJsonString())!; + Assert.Equal(ErrorCode.UnsupportedPolicyFormat, parsedError.Code); + Assert.Contains("\"Code\":\"UnsupportedPolicyFormat\"", BrokerJson.Serialize(parsedError)); + + error["Code"] = JsonNode.Parse("\"unsupportedpolicyformat\""); + Assert.Throws(() => BrokerJson.DeserializeStrict(error.ToJsonString())); + } + + [Fact] + public async Task Policy_management_requests_accept_the_exact_full_body_limit() + { + var validationBody = await ReadFixture("responses", "policy-validation.valid.response.json"); + var validationTransport = new FakeBrokerTransport( + new BrokerTransportResponse { StatusCode = 200, Body = validationBody }); + var validationRequest = new PolicyValidationRequest { RequestVersion = BrokerApi.Version }; + var validationDraft = DraftForSerializedRequestSize( + validationRequest, + static (request, draft) => request.Draft = draft, + BrokerApi.MaxPolicyManagementBodyBytes); + + await CreateClient(validationTransport).ValidatePolicy(validationDraft); + + Assert.Equal( + BrokerApi.MaxPolicyManagementBodyBytes, + Encoding.UTF8.GetByteCount(Assert.Single(validationTransport.Requests).Body!)); + + var replacementRequest = BrokerJson.DeserializeStrict( + await ReadFixture("requests", "policy-replacement.update.request.json"))!; + replacementRequest.Draft = DraftForSerializedRequestSize( + replacementRequest, + static (request, draft) => request.Draft = draft, + BrokerApi.MaxPolicyManagementBodyBytes); + var replacementBody = await ReadFixture("responses", "policy-replacement.response.json"); + var replacementTransport = new FakeBrokerTransport( + new BrokerTransportResponse { StatusCode = 200, Body = replacementBody }); + + await CreateClient(replacementTransport).ReplacePolicy(replacementRequest); + + Assert.Equal( + BrokerApi.MaxPolicyManagementBodyBytes, + Encoding.UTF8.GetByteCount(Assert.Single(replacementTransport.Requests).Body!)); + } + + [Fact] + public async Task Policy_management_requests_reject_one_byte_over_before_transport() + { + var validationTransport = new FakeBrokerTransport(); + var validationRequest = new PolicyValidationRequest { RequestVersion = BrokerApi.Version }; + var validationDraft = DraftForSerializedRequestSize( + validationRequest, + static (request, draft) => request.Draft = draft, + BrokerApi.MaxPolicyManagementBodyBytes + 1); + + var validationException = await Assert.ThrowsAsync( + () => CreateClient(validationTransport).ValidatePolicy(validationDraft)); + Assert.Equal(BrokerClientErrorKind.RequestTooLarge, validationException.Kind); + Assert.Empty(validationTransport.Requests); + + var replacementRequest = BrokerJson.DeserializeStrict( + await ReadFixture("requests", "policy-replacement.update.request.json"))!; + replacementRequest.Draft = DraftForSerializedRequestSize( + replacementRequest, + static (request, draft) => request.Draft = draft, + BrokerApi.MaxPolicyManagementBodyBytes + 1); + var replacementTransport = new FakeBrokerTransport(); + + var replacementException = await Assert.ThrowsAsync( + () => CreateClient(replacementTransport).ReplacePolicy(replacementRequest)); + Assert.Equal(BrokerClientErrorKind.RequestTooLarge, replacementException.Kind); + Assert.Empty(replacementTransport.Requests); + } + [Theory] [InlineData("management")] [InlineData("validation")] @@ -380,6 +484,21 @@ public async Task GetPolicyManagement_preserves_legacy_route_not_found(string bo private static async Task ReadFixture(string directory, string file) => await File.ReadAllTextAsync(Path.Combine(TestData.SamplesDir, directory, file)); + private static JsonElement DraftForSerializedRequestSize( + TRequest request, + Action setDraft, + int targetSize) + { + using var emptyDraft = JsonDocument.Parse("""{"Padding":""}"""); + setDraft(request, emptyDraft.RootElement.Clone()); + var baseSize = Encoding.UTF8.GetByteCount(BrokerJson.Serialize(request)); + var paddingLength = targetSize - baseSize; + Assert.True(paddingLength >= 0); + + using var paddedDraft = JsonDocument.Parse($$"""{"Padding":"{{new string('x', paddingLength)}}"}"""); + return paddedDraft.RootElement.Clone(); + } + private static BrokerClient CreateClient(FakeBrokerTransport transport) => new(new BrokerClientOptions { Transport = transport, diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs index e01d110..9e5d7ec 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs @@ -38,6 +38,15 @@ public async Task Broker_api_version_matches_openapi_and_message_versions() Assert.Equal(BrokerApi.Version, new ErrorResponse().ResponseVersion); } + [Fact] + public async Task Policy_management_body_limit_matches_generated_openapi() + { + var limits = await TestData.OpenApiPolicyManagementBodyLimitsAsync(); + + Assert.Equal(2, limits.Count); + Assert.All(limits, limit => Assert.Equal(BrokerApi.MaxPolicyManagementBodyBytes, limit)); + } + [Theory] [MemberData(nameof(TestData.RequestSamples), MemberType = typeof(TestData))] public async Task Request_samples_are_schema_valid(string path) diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs index 85bca00..434822d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs @@ -51,6 +51,32 @@ public static async Task OpenApiVersionAsync() return doc.Info.Version; } + public static async Task> OpenApiPolicyManagementBodyLimitsAsync() + { + var yamlText = await File.ReadAllTextAsync(OpenApiSpec); + var stream = new YamlStream(); + stream.Load(new StringReader(yamlText)); + + var root = (YamlMappingNode)stream.Documents[0].RootNode; + var paths = GetMapping(root, "paths"); + var limits = new List(); + foreach (var (path, method) in new[] + { + ("/v1/policy/validate", "post"), + ("/v1/policy", "put"), + }) + { + var operation = GetMapping(GetMapping(paths, path), method); + var value = (YamlScalarNode)operation.Children[new YamlScalarNode("x-max-request-body-bytes")]; + limits.Add(int.Parse(value.Value!, CultureInfo.InvariantCulture)); + } + + return limits; + } + + private static YamlMappingNode GetMapping(YamlMappingNode parent, string key) => + (YamlMappingNode)parent.Children[new YamlScalarNode(key)]; + private static async Task LoadOpenApiAsync() { await s_docLock.WaitAsync(); @@ -221,6 +247,11 @@ public static IEnumerable PolicyReplacementResponseSamples() => .Where(f => Path.GetFileName(f).Equals("policy-replacement.response.json", StringComparison.Ordinal)) .Select(f => new object[] { f }); + public static IEnumerable PolicyManagementErrorSamples() => + JsonFiles(Path.Combine(SamplesDir, "responses")) + .Where(f => Path.GetFileName(f).EndsWith(".error.json", 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 af6ea0c..5a38af0 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client/BrokerClient.cs @@ -476,6 +476,7 @@ private Task SendPolicyManagementRequest( string body, CancellationToken cancellationToken) { + EnsureRequestBodySize(body, BrokerApi.MaxPolicyManagementBodyBytes, endpoint); var headers = new Dictionary { ["Content-Type"] = JsonMediaType, @@ -662,16 +663,19 @@ private static void EnsureTransportSupported( } private static void EnsureRequestBodySize(string body, CapabilitiesResponse capabilities, string endpoint) + => EnsureRequestBodySize(body, capabilities.MaxRequestBodyBytes, endpoint); + + private static void EnsureRequestBodySize(string body, long maxBodyBytes, string endpoint) { var bodyLength = Encoding.UTF8.GetByteCount(body); - if (bodyLength <= capabilities.MaxRequestBodyBytes) + if (bodyLength <= maxBodyBytes) { return; } throw new BrokerClientException( BrokerClientErrorKind.RequestTooLarge, - $"Request body for {endpoint} is {bodyLength} bytes, which exceeds broker limit of {capabilities.MaxRequestBodyBytes} bytes.", + $"Request body for {endpoint} is {bodyLength} bytes, which exceeds broker limit of {maxBodyBytes} bytes.", endpoint); } diff --git a/policies/dotnet/Devolutions.Now.Policy.Client/README.md b/policies/dotnet/Devolutions.Now.Policy.Client/README.md index 2505d21..267f94a 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Client/README.md @@ -93,6 +93,14 @@ switch transport from HTTP to other mechanisms without changing the wire schema. Before sending package operation and status requests, the client implicitly queries `GetCapabilities` once and caches the result. The cached capabilities are used as a local preflight gate: unsupported transports, package managers, operations, scopes, architectures, request body sizes, custom parameters, custom install locations, or captured output requests fail before the client sends the operation/status request. Use `CapabilitiesResponse.SupportsManager(ManagerName)` or `GetManagerCapability(ManagerName)` to check package manager support ahead of time. +Policy validation and replacement use the separate fixed `BrokerApi.MaxPolicyManagementBodyBytes` +limit (16 MiB / 16,777,216 bytes). `BrokerClient` measures the serialized UTF-8 request body, +including the complete validation or replacement envelope, before sending it. Transport helpers +must apply the same full-body limit to `POST /v1/policy/validate` and `PUT /v1/policy` only; package +operation requests retain their advertised 256 KiB default. The 16 MiB value is an operational cap +for realistic policies within the 1,024-rule editor model, not the schema's pathological theoretical +maximum. + Before sending package operation requests, the client fills missing request metadata: - `RequestId` is generated with `BrokerClient.GenerateRequestId()` when empty. Request IDs are normalized to lowercase dashed GUIDs without braces. @@ -111,7 +119,7 @@ Response-oriented methods return successful DTOs or throw `BrokerClientException `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. -The policy management methods preserve the same ordinary 404 behavior when an older Agent does not expose a newer route. A structured `StalePolicyStoreToken` error carries the atomic current `Management` snapshot; use its exact store token for an explicitly confirmed overwrite retry. `UnsafePolicyPath` uses HTTP 409 because it represents the current storage/write-capability state rather than authentication or elevation. +The policy management methods preserve the same ordinary 404 behavior when an older Agent does not expose a newer route. A structured `StalePolicyStoreToken` error carries the atomic current `Management` snapshot; use its exact store token for an explicitly confirmed overwrite retry. `UnsafePolicyPath` uses HTTP 409 because it represents the current storage/write-capability state rather than authentication or elevation. Configured `.yaml`, `.yml`, extensionless, and other non-JSON policy paths use `PolicyReadOnlyReason.UnsupportedFormat` and `ErrorCode.UnsupportedPolicyFormat` (HTTP 422). 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 c50bc83..3d6895c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -209,6 +209,42 @@ public void Mixed_boolean_match_values_are_rejected() Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); } + [Theory] + [InlineData("StringPattern", 256)] + [InlineData("VersionString", 128)] + [InlineData("CustomParameterString", 512)] + public void Policy_text_lists_count_unicode_scalars_at_length_boundaries(string valueKind, int maximum) + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; + var rule = document["Rules"]![0]!; + var values = new JsonArray(); + switch (valueKind) + { + case "StringPattern": + rule["Match"]!["PackageNames"] = values; + break; + case "VersionString": + rule["Match"]!["Versions"] = values; + break; + default: + var constraints = rule["Constraints"] as JsonObject ?? new JsonObject(); + rule["Constraints"] = constraints; + constraints["AllowedCustomParameters"] = values; + break; + } + var multibyteScalar = "\U0001F600"; + + values.Add(ParseJsonString(string.Concat(Enumerable.Repeat(multibyteScalar, maximum)))); + Assert.NotNull(PolicyDocument.ParseJson(document.ToJsonString())); + + values[0] = ParseJsonString(string.Concat(Enumerable.Repeat(multibyteScalar, maximum + 1))); + Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); + + values[0] = ParseJsonString(""); + Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); + } + [Fact] public void Draft_rejects_server_managed_metadata() { @@ -224,6 +260,8 @@ private static PolicyDocument ParsePolicy(string path) return PolicyDocument.ParseJson(content); } + private static JsonNode ParseJsonString(string value) => JsonNode.Parse($"\"{value}\"")!; + private static string ResolvePolicyCrateRoot([CallerFilePath] string thisFile = "") { var testsDir = Path.GetDirectoryName(thisFile)!; diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs index b4eb650..7def13c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyJson.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -15,11 +16,17 @@ public static class PolicyJson UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, }; - public static string Serialize(PolicyDocument value) => - JsonSerializer.Serialize(value, PolicyJsonSerializerContext.Default.PolicyDocument); + public static string Serialize(PolicyDocument value) + { + ValidateRequiredCollectionElements(value); + return JsonSerializer.Serialize(value, PolicyJsonSerializerContext.Default.PolicyDocument); + } - public static string Serialize(PolicyDraftDocument value) => - JsonSerializer.Serialize(value, PolicyJsonSerializerContext.Default.PolicyDraftDocument); + public static string Serialize(PolicyDraftDocument value) + { + ValidateRequiredCollectionElements(value); + return JsonSerializer.Serialize(value, PolicyJsonSerializerContext.Default.PolicyDraftDocument); + } public static PolicyDocument? DeserializePolicyDocument(string json) => Validate(JsonSerializer.Deserialize(json, PolicyJsonSerializerContext.Default.PolicyDocument)); @@ -63,10 +70,10 @@ private static void ValidateRequiredCollectionElements(IReadOnlyList { var rule = 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"); + RejectBoundedStrings(rule.Match.Sources, 1, 256, $"{matchPath}.Sources"); + RejectBoundedStrings(rule.Match.PackageIdentifiers, 1, 256, $"{matchPath}.PackageIdentifiers"); + RejectBoundedStrings(rule.Match.PackageNames, 1, 256, $"{matchPath}.PackageNames"); + RejectBoundedStrings(rule.Match.Versions, 1, 128, $"{matchPath}.Versions"); RejectBooleanMatch(rule.Match.Interactive, $"{matchPath}.Interactive"); RejectBooleanMatch(rule.Match.SkipHashCheck, $"{matchPath}.SkipHashCheck"); RejectBooleanMatch(rule.Match.PreRelease, $"{matchPath}.PreRelease"); @@ -79,14 +86,26 @@ private static void ValidateRequiredCollectionElements(IReadOnlyList if (rule.Constraints is { } constraints) { var constraintsPath = $"$.Rules[{ruleIndex}].Constraints"; - RejectNullElements( + RejectBoundedStrings( constraints.AllowedInstallLocationPatterns, + 1, + 256, $"{constraintsPath}.AllowedInstallLocationPatterns"); - RejectNullElements(constraints.AllowedCustomParameters, $"{constraintsPath}.AllowedCustomParameters"); - RejectNullElements( + RejectBoundedStrings( + constraints.AllowedCustomParameters, + 1, + 512, + $"{constraintsPath}.AllowedCustomParameters"); + RejectBoundedStrings( constraints.AllowedCustomParameterPatterns, + 1, + 512, $"{constraintsPath}.AllowedCustomParameterPatterns"); - RejectNullElements(constraints.DeniedCustomParameters, $"{constraintsPath}.DeniedCustomParameters"); + RejectBoundedStrings( + constraints.DeniedCustomParameters, + 1, + 512, + $"{constraintsPath}.DeniedCustomParameters"); } } } @@ -131,6 +150,24 @@ private static void RejectNullElements(IReadOnlyList values, string path) } } + private static void RejectBoundedStrings( + IReadOnlyList values, + int minLength, + int maxLength, + string path) + { + RejectNullElements(values, path); + for (var index = 0; index < values.Count; index++) + { + var length = values[index].EnumerateRunes().Count(); + if (length < minLength || length > maxLength) + { + throw new JsonException( + $"The JSON string at {path}[{index}] must contain between {minLength} and {maxLength} Unicode scalar values; found {length}."); + } + } + } + private static JsonTypeInfo TypeInfo() => typeof(T) == typeof(PolicyDocument) ? Cast(PolicyJsonSerializerContext.Default.PolicyDocument) : typeof(T) == typeof(PolicyDraftDocument) ? Cast(PolicyJsonSerializerContext.Default.PolicyDraftDocument) : diff --git a/policies/rust/now-policy-api/CHANGELOG.md b/policies/rust/now-policy-api/CHANGELOG.md index 1652fba..8672e17 100644 --- a/policies/rust/now-policy-api/CHANGELOG.md +++ b/policies/rust/now-policy-api/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add versioned policy management, raw-draft validation, structured findings/receipts, optimistic replacement, and management error contracts, including the atomic current snapshot required on stale-token errors. +- Add versioned policy management, raw-draft validation, structured findings/receipts, optimistic replacement, and management error contracts, including the atomic current snapshot required on stale-token errors, explicit unsupported non-JSON path semantics, and generated 16 MiB full-request-body metadata for management write endpoints. ## [[0.3.1](https://github.com/Devolutions/now-libraries/compare/now-policy-api-v0.3.0...now-policy-api-v0.3.1)] - 2026-08-13 diff --git a/policies/rust/now-policy-api/README.md b/policies/rust/now-policy-api/README.md index b84adfc..66734f9 100644 --- a/policies/rust/now-policy-api/README.md +++ b/policies/rust/now-policy-api/README.md @@ -57,7 +57,7 @@ cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi -- The generated document contains the unchanged policy inspection route, the management/validation/replacement routes, and canonical committed and draft policy schemas. -`StalePolicyStoreToken` errors include the atomic current `Management` snapshot so a client can explicitly confirm an overwrite against that exact newly observed token. `UnsafePolicyPath` is a 409 state/write-capability conflict, not an authentication failure. An Agent that does not expose a newer route may still return an ordinary unstructured 404; `UnsupportedEndpoint` is only an optional explicit implementation response. +`StalePolicyStoreToken` errors include the atomic current `Management` snapshot so a client can explicitly confirm an overwrite against that exact newly observed token. `UnsafePolicyPath` is a 409 state/write-capability conflict, not an authentication failure. A configured `.yaml`, `.yml`, extensionless, or otherwise non-JSON policy path uses the stable `UnsupportedFormat` read-only reason and `UnsupportedPolicyFormat` error code (HTTP 422), rather than overloading unsafe-path semantics. An Agent that does not expose a newer route may still return an ordinary unstructured 404; `UnsupportedEndpoint` is only an optional explicit implementation response. Opaque store tokens and validation receipts use safe printable ASCII (`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `:`, `-`) and begin with an ASCII alphanumeric character. This keeps length and validation behavior identical across Rust UTF-8 and .NET UTF-16 implementations. 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 373ec8f..c9adfcd 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -51,7 +51,7 @@ paths: $ref: '#/components/schemas/ErrorResponse' put: summary: Replace the configured policy - description: Reparses and revalidates the raw draft inside the write transaction, then atomically commits it only when the expected opaque store token and validation receipt still match. + description: Reparses and revalidates the raw draft inside the write transaction, then atomically commits it only when the expected opaque store token and validation receipt still match. The complete HTTP request body, including the envelope, is limited to 16 MiB (16,777,216 bytes). This operational transport cap is below the schema's theoretical maximum. requestBody: description: Request body for `PUT /v1/policy`. content: @@ -90,6 +90,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '413': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '422': description: '' content: @@ -108,6 +114,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + x-max-request-body-bytes: 16777216 /v1/policy/management: get: summary: Get policy management state @@ -128,7 +135,7 @@ paths: /v1/policy/validate: post: summary: Validate a policy draft - description: Authoritatively validates raw draft JSON without discarding unknown fields. Validation findings are returned with HTTP 200; malformed envelopes use ErrorResponse. + description: Authoritatively validates raw draft JSON without discarding unknown fields. Validation findings are returned with HTTP 200; malformed envelopes use ErrorResponse. The complete HTTP request body, including the envelope, is limited to 16 MiB (16,777,216 bytes). This operational transport cap is below the schema's theoretical maximum. requestBody: description: Request body for `POST /v1/policy/validate`. content: @@ -155,6 +162,13 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '413': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + x-max-request-body-bytes: 16777216 /v1/package-operations/evaluate: post: summary: Evaluate package operation @@ -537,6 +551,7 @@ components: - Unauthenticated - AdministratorRequired - UnsafePolicyPath + - UnsupportedPolicyFormat - StalePolicyStoreToken - UnsupportedPolicyFilesystem - PolicyPersistenceFailed @@ -1340,6 +1355,7 @@ components: enum: - ManagementDisabled - PathNotConfigured + - UnsupportedFormat - UnsafePath - InsufficientPermissions - UnsupportedFileSystem diff --git a/policies/rust/now-policy-api/src/enums.rs b/policies/rust/now-policy-api/src/enums.rs index 8c1d50b..ea41109 100644 --- a/policies/rust/now-policy-api/src/enums.rs +++ b/policies/rust/now-policy-api/src/enums.rs @@ -132,6 +132,7 @@ pub enum ErrorCode { Unauthenticated, AdministratorRequired, UnsafePolicyPath, + UnsupportedPolicyFormat, StalePolicyStoreToken, UnsupportedPolicyFilesystem, PolicyPersistenceFailed, diff --git a/policies/rust/now-policy-api/src/management.rs b/policies/rust/now-policy-api/src/management.rs index bacaa75..46d3812 100644 --- a/policies/rust/now-policy-api/src/management.rs +++ b/policies/rust/now-policy-api/src/management.rs @@ -49,6 +49,7 @@ pub enum PolicyWriteCapability { pub enum PolicyReadOnlyReason { ManagementDisabled, PathNotConfigured, + UnsupportedFormat, UnsafePath, InsufficientPermissions, UnsupportedFileSystem, diff --git a/policies/rust/now-policy-server-template/CHANGELOG.md b/policies/rust/now-policy-server-template/CHANGELOG.md index a827f59..fe42158 100644 --- a/policies/rust/now-policy-server-template/CHANGELOG.md +++ b/policies/rust/now-policy-server-template/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- [**breaking**] Add required policy management, validation, and optimistic replacement trait methods, routes, status mappings, and OpenAPI operations. Unsafe policy paths map to HTTP 409; absent routes retain ordinary HTTP 404 behavior. +- [**breaking**] Add required policy management, validation, and optimistic replacement trait methods, routes, status mappings, and OpenAPI operations. Unsafe policy paths map to HTTP 409, unsupported non-JSON policy paths map to HTTP 422, and absent routes retain ordinary HTTP 404 behavior. Validation and replacement use a separate public 16 MiB full-request-body limit while package operations retain their 256 KiB limit. ## [[0.3.0](https://github.com/Devolutions/now-libraries/compare/now-policy-server-template-v0.2.0...now-policy-server-template-v0.3.0)] - 2026-08-05 diff --git a/policies/rust/now-policy-server-template/README.md b/policies/rust/now-policy-server-template/README.md index 17c09f5..862f042 100644 --- a/policies/rust/now-policy-server-template/README.md +++ b/policies/rust/now-policy-server-template/README.md @@ -62,6 +62,13 @@ Then they pass the implementation to `api_router` or `api_router_from_shared`. T This keeps route dispatch, error responses, and OpenAPI operation metadata in one place. +`POST /v1/policy/validate` and `PUT /v1/policy` accept complete HTTP request bodies up to +`MAX_POLICY_MANAGEMENT_BODY_BYTES` (16 MiB / 16,777,216 bytes), including their JSON envelopes. +This is an operational transport limit for realistic policies within the 1,024-rule editor model; +it does not attempt to accommodate the policy schema's pathological theoretical maximum. Package +operation endpoints continue to use the separate `MAX_REQUEST_BODY_BYTES` limit (256 KiB), which +implementations advertise through `CapabilitiesResponse.MaxRequestBodyBytes`. + OpenAPI generation ------------------ diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index 52d0828..bdda20e 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -23,6 +23,7 @@ use now_policy_api::{ use schemars::SchemaGenerator; pub const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; +pub const MAX_POLICY_MANAGEMENT_BODY_BYTES: usize = 16 * 1024 * 1024; /// Implementation-neutral contract exposed by a package broker server. #[async_trait] @@ -73,12 +74,12 @@ fn api_routes() -> ApiRouter { .api_route( "/v1/policy/validate", post_with(policy_validation_handler, policy_validation_docs) - .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES)), + .layer(axum::extract::DefaultBodyLimit::max(MAX_POLICY_MANAGEMENT_BODY_BYTES)), ) .api_route( "/v1/policy", put_with(policy_replacement_handler, policy_replacement_docs) - .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES)), + .layer(axum::extract::DefaultBodyLimit::max(MAX_POLICY_MANAGEMENT_BODY_BYTES)), ) .api_route( "/v1/package-operations/evaluate", @@ -121,6 +122,7 @@ pub fn openapi() -> OpenApi { }); let _ = api_routes().finish_api(&mut api); + register_policy_management_body_limits(&mut api); register_policy_schema(&mut api); api } @@ -131,6 +133,29 @@ fn openapi_schema_generator() -> SchemaGenerator { SchemaSettings::openapi3().into() } +fn register_policy_management_body_limits(api: &mut OpenApi) { + const EXTENSION: &str = "x-max-request-body-bytes"; + + let paths = api.paths.as_mut().expect("BUG: API routes should generate paths"); + for (path, method) in [("/v1/policy/validate", "post"), ("/v1/policy", "put")] { + let path_item = paths + .paths + .get_mut(path) + .and_then(aide::openapi::ReferenceOr::as_item_mut) + .unwrap_or_else(|| panic!("BUG: missing generated OpenAPI path {path}")); + let operation = match method { + "post" => path_item.post.as_mut(), + "put" => path_item.put.as_mut(), + _ => unreachable!("BUG: unsupported policy management method"), + } + .unwrap_or_else(|| panic!("BUG: missing generated OpenAPI operation {method} {path}")); + operation.extensions.insert( + EXTENSION.to_owned(), + serde_json::json!(MAX_POLICY_MANAGEMENT_BODY_BYTES), + ); + } +} + fn register_policy_schema(api: &mut OpenApi) { use std::collections::BTreeMap; @@ -327,9 +352,10 @@ fn error_status(code: ErrorCode) -> StatusCode { | ErrorCode::StalePolicyStoreToken => StatusCode::CONFLICT, ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, ErrorCode::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, - ErrorCode::ValidationFailed | ErrorCode::InvalidPolicy | ErrorCode::UnsupportedPolicyFilesystem => { - StatusCode::UNPROCESSABLE_ENTITY - } + ErrorCode::ValidationFailed + | ErrorCode::InvalidPolicy + | ErrorCode::UnsupportedPolicyFormat + | ErrorCode::UnsupportedPolicyFilesystem => StatusCode::UNPROCESSABLE_ENTITY, ErrorCode::BrokerPaused => StatusCode::SERVICE_UNAVAILABLE, ErrorCode::InternalError | ErrorCode::PolicyPersistenceFailed | ErrorCode::PolicyActivationFailed => { StatusCode::INTERNAL_SERVER_ERROR @@ -373,10 +399,13 @@ fn policy_validation_docs(op: TransformOperation<'_>) -> TransformOperation<'_> op.summary("Validate a policy draft") .description( "Authoritatively validates raw draft JSON without discarding unknown fields. \ - Validation findings are returned with HTTP 200; malformed envelopes use ErrorResponse.", + Validation findings are returned with HTTP 200; malformed envelopes use ErrorResponse. \ + The complete HTTP request body, including the envelope, is limited to 16 MiB \ + (16,777,216 bytes). This operational transport cap is below the schema's theoretical maximum.", ) .response::<200, Json>() .response::<400, Json>() + .response::<413, Json>() .default_response::>() } @@ -384,13 +413,16 @@ fn policy_replacement_docs(op: TransformOperation<'_>) -> TransformOperation<'_> op.summary("Replace the configured policy") .description( "Reparses and revalidates the raw draft inside the write transaction, then atomically \ - commits it only when the expected opaque store token and validation receipt still match.", + commits it only when the expected opaque store token and validation receipt still match. \ + The complete HTTP request body, including the envelope, is limited to 16 MiB \ + (16,777,216 bytes). This operational transport cap is below the schema's theoretical maximum.", ) .response::<200, Json>() .response::<400, Json>() .response::<401, Json>() .response::<403, Json>() .response::<409, Json>() + .response::<413, Json>() .response::<422, Json>() .response::<500, Json>() .response::<501, Json>() @@ -436,7 +468,7 @@ fn cancel_docs(op: TransformOperation<'_>) -> TransformOperation<'_> { #[cfg(test)] mod tests { - use super::openapi; + use super::{MAX_POLICY_MANAGEMENT_BODY_BYTES, openapi}; #[test] fn policy_schemas_do_not_rename_existing_api_components() { @@ -509,4 +541,20 @@ mod tests { ); } } + + #[test] + fn policy_openapi_exposes_management_request_body_limit() { + let api = serde_json::to_value(openapi()).expect("OpenAPI should serialize"); + + for pointer in [ + "/paths/~1v1~1policy~1validate/post/x-max-request-body-bytes", + "/paths/~1v1~1policy/put/x-max-request-body-bytes", + ] { + assert_eq!( + api.pointer(pointer), + Some(&serde_json::json!(MAX_POLICY_MANAGEMENT_BODY_BYTES)), + "missing management request-body limit at {pointer}" + ); + } + } } 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 a7d4e02..d685834 100644 --- a/policies/rust/now-policy-server-template/tests/sample_documents.rs +++ b/policies/rust/now-policy-server-template/tests/sample_documents.rs @@ -7,10 +7,10 @@ use axum::http::{Request, StatusCode}; use now_policy_server_template::{ API_VERSION_STR, CancelRequest, CancelResponse, CapabilitiesResponse, CapabilitiesResponseKind, DEFAULT_PIPE_NAME, ErrorCode, ErrorResponse, ErrorResponseKind, EvaluationResponse, ExecutionResponse, HealthResponse, - HealthResponseKind, HealthStatus, MAX_REQUEST_BODY_BYTES, ManagerName, Operation, PackageBrokerServer, - PackageRequest, PolicyManagementResponse, PolicyReplacementRequest, PolicyReplacementResponse, PolicyResponse, - PolicyResponseKind, PolicyValidationRequest, PolicyValidationResponse, Scope, ServerContext, StatusRequest, - StatusRequestKind, StatusResponse, Transport, api_router, + HealthResponseKind, HealthStatus, MAX_POLICY_MANAGEMENT_BODY_BYTES, MAX_REQUEST_BODY_BYTES, ManagerName, Operation, + PackageBrokerServer, PackageRequest, PolicyManagementResponse, PolicyReadOnlyReason, PolicyReplacementRequest, + PolicyReplacementResponse, PolicyResponse, PolicyResponseKind, PolicyValidationRequest, PolicyValidationResponse, + Scope, ServerContext, StatusRequest, StatusRequestKind, StatusResponse, Transport, api_router, }; use tower::ServiceExt; @@ -87,7 +87,7 @@ fn assert_response_sample_deserializes(path: &Path) { } else if name == "policy-replacement.response.json" { let _: PolicyReplacementResponse = serde_json::from_value(load_json_file(path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); - } else if name == "policy-stale-token.error.json" { + } else if name.ends_with(".error.json") { let _: ErrorResponse = serde_json::from_value(load_json_file(path)) .unwrap_or_else(|e| panic!("failed to deserialize {}: {e}", path.display())); } else if name == "policy.response.json" { @@ -591,6 +591,7 @@ async fn policy_management_error_codes_use_stable_http_statuses() { (ErrorCode::Unauthenticated, StatusCode::UNAUTHORIZED), (ErrorCode::AdministratorRequired, StatusCode::FORBIDDEN), (ErrorCode::UnsafePolicyPath, StatusCode::CONFLICT), + (ErrorCode::UnsupportedPolicyFormat, StatusCode::UNPROCESSABLE_ENTITY), (ErrorCode::StalePolicyStoreToken, StatusCode::CONFLICT), (ErrorCode::UnsupportedPolicyFilesystem, StatusCode::UNPROCESSABLE_ENTITY), (ErrorCode::PolicyPersistenceFailed, StatusCode::INTERNAL_SERVER_ERROR), @@ -643,6 +644,33 @@ fn stale_policy_store_token_requires_atomic_management_snapshot() { assert!(serde_json::to_value(invalid_for_serialization).is_err()); } +#[test] +fn unsupported_policy_format_contract_uses_exact_case() { + let management_path = response_sample_path("policy-management.unsupported-format.response.json"); + let management: PolicyManagementResponse = serde_json::from_value(load_json_file(&management_path)).unwrap(); + assert_eq!( + management.management.read_only_reason, + Some(PolicyReadOnlyReason::UnsupportedFormat) + ); + assert_eq!( + serde_json::to_value(management).unwrap()["Management"]["ReadOnlyReason"], + "UnsupportedFormat" + ); + + let error_path = response_sample_path("policy-unsupported-format.error.json"); + let error: ErrorResponse = serde_json::from_value(load_json_file(&error_path)).unwrap(); + assert_eq!(error.code, ErrorCode::UnsupportedPolicyFormat); + assert_eq!(serde_json::to_value(error).unwrap()["Code"], "UnsupportedPolicyFormat"); + + let mut noncanonical_management = load_json_file(&management_path); + noncanonical_management["Management"]["ReadOnlyReason"] = serde_json::json!("unsupportedformat"); + assert!(serde_json::from_value::(noncanonical_management).is_err()); + + let mut noncanonical_error = load_json_file(&error_path); + noncanonical_error["Code"] = serde_json::json!("unsupportedpolicyformat"); + assert!(serde_json::from_value::(noncanonical_error).is_err()); +} + #[tokio::test] async fn absent_legacy_policy_management_route_remains_an_ordinary_404() { let response = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)) @@ -664,7 +692,6 @@ async fn absent_legacy_policy_management_route_remains_an_ordinary_404() { #[tokio::test] async fn policy_management_request_rejections_are_structured() { let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); - let oversized = " ".repeat(MAX_REQUEST_BODY_BYTES + 1); for (content_type, body, expected_status, expected_code) in [ ( @@ -679,12 +706,6 @@ async fn policy_management_request_rejections_are_structured() { StatusCode::UNSUPPORTED_MEDIA_TYPE, ErrorCode::UnsupportedMediaType, ), - ( - Some("application/json"), - oversized, - StatusCode::PAYLOAD_TOO_LARGE, - ErrorCode::PayloadTooLarge, - ), ] { let mut request = Request::builder().method("POST").uri("/v1/policy/validate"); if let Some(content_type) = content_type { @@ -702,6 +723,72 @@ async fn policy_management_request_rejections_are_structured() { } } +fn policy_management_body(prefix: &str, suffix: &str, length: usize) -> String { + let padding_length = length + .checked_sub(prefix.len() + suffix.len()) + .expect("target body length should accommodate the request envelope"); + let body = format!("{prefix}{}{suffix}", "x".repeat(padding_length)); + assert_eq!(body.len(), length); + body +} + +#[tokio::test] +async fn policy_management_routes_accept_exact_limit_and_reject_one_byte_over() { + let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); + let cases = [ + ( + "POST", + "/v1/policy/validate", + r#"{"RequestKind":"PolicyValidationRequest","RequestVersion":"1.0","Draft":{"Padding":""#, + r#""}}"#, + ), + ( + "PUT", + "/v1/policy", + r#"{"RequestKind":"PolicyReplacementRequest","RequestVersion":"1.0","ExpectedStoreToken":"store:active:7","Operation":"Update","ConflictHandling":"Reject","WarningsAcknowledged":true,"Draft":{"Padding":""#, + r#""},"ValidationReceipt":"receipt:sha256:test"}"#, + ), + ]; + + for (method, uri, prefix, suffix) in cases { + let exact = policy_management_body(prefix, suffix, MAX_POLICY_MANAGEMENT_BODY_BYTES); + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(exact)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED, "{method} {uri}"); + 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::UnsupportedEndpoint); + + let oversized = policy_management_body(prefix, suffix, MAX_POLICY_MANAGEMENT_BODY_BYTES + 1); + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(oversized)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE, "{method} {uri}"); + 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::PayloadTooLarge); + } +} + #[tokio::test] async fn api_router_does_not_expose_a_policy_write_route() { let app = api_router(MockPackageBrokerServer::new(DEFAULT_PIPE_NAME)); diff --git a/policies/rust/now-policy/CHANGELOG.md b/policies/rust/now-policy/CHANGELOG.md index d127b92..dc081cc 100644 --- a/policies/rust/now-policy/CHANGELOG.md +++ b/policies/rust/now-policy/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- Count Unicode scalar values for `StringPattern`, `VersionString`, and `CustomParameterString` length bounds, matching JSON Schema and .NET policy validation semantics. + ### Changed - [**breaking**] Make policy documents JSON-only and remove `parse_policy_yaml`. diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 5002aeb..9283f55 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -16,17 +16,18 @@ fn validate_bounded_string( max: usize, type_name: &'static str, ) -> Result<(), ModelValidationError> { - if s.len() < min { + let length = s.chars().count(); + if length < min { return Err(ModelValidationError::Invalid { type_name, - reason: format!("length {} is below minimum {min}", s.len()), + reason: format!("length {length} is below minimum {min}"), }); } - if s.len() > max { + if length > max { return Err(ModelValidationError::Invalid { type_name, - reason: format!("length {} exceeds maximum {max}", s.len()), + reason: format!("length {length} exceeds maximum {max}"), }); } diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 1de7369..87582f9 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use chrono::{TimeZone, Utc}; -use now_policy::{PolicyDocument, PolicyDraftDocument}; +use now_policy::{CustomParameterString, PolicyDocument, PolicyDraftDocument, StringPattern, VersionString}; fn samples_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/samples") @@ -70,6 +70,20 @@ fn mixed_boolean_match_values_are_rejected() { assert!(result.is_err()); } +#[test] +fn policy_text_newtypes_count_unicode_scalars_at_length_boundaries() { + let multibyte_scalar = "😀"; + + assert!(StringPattern::parse(&multibyte_scalar.repeat(256)).is_ok()); + assert!(StringPattern::parse(&multibyte_scalar.repeat(257)).is_err()); + + assert!(VersionString::parse(&multibyte_scalar.repeat(128)).is_ok()); + assert!(VersionString::parse(&multibyte_scalar.repeat(129)).is_err()); + + assert!(CustomParameterString::parse(&multibyte_scalar.repeat(512)).is_ok()); + assert!(CustomParameterString::parse(&multibyte_scalar.repeat(513)).is_err()); +} + #[test] fn invalid_policy_unknown_field_fails_deserialization() { let value = serde_json::json!({ diff --git a/policies/test-data/package-broker/responses/policy-management.unsupported-format.response.json b/policies/test-data/package-broker/responses/policy-management.unsupported-format.response.json new file mode 100644 index 0000000..4459ac7 --- /dev/null +++ b/policies/test-data/package-broker/responses/policy-management.unsupported-format.response.json @@ -0,0 +1,32 @@ +{ + "ResponseKind": "PolicyManagementResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Management": { + "State": "Invalid", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.yaml", + "StoreToken": "store:invalid-format:1", + "Source": "ConfiguredPath", + "WriteCapability": "Unsupported", + "ReadOnlyReason": "UnsupportedFormat", + "ElevationRequired": false, + "InvalidDiagnostics": { + "DiagnosticsVersion": "1.0", + "Findings": [ + { + "FindingVersion": "1.0", + "Severity": "Error", + "Code": "SchemaViolation", + "Path": "", + "Arguments": { + "requiredFormat": "json" + }, + "Message": "The configured policy path must identify a JSON document." + } + ] + } + } +} diff --git a/policies/test-data/package-broker/responses/policy-unsupported-format.error.json b/policies/test-data/package-broker/responses/policy-unsupported-format.error.json new file mode 100644 index 0000000..d4f6d27 --- /dev/null +++ b/policies/test-data/package-broker/responses/policy-unsupported-format.error.json @@ -0,0 +1,34 @@ +{ + "ResponseKind": "ErrorResponse", + "ResponseVersion": "1.0", + "Server": { + "ServerVersion": "2026.8.0", + "Transport": "HttpNamedPipe" + }, + "Code": "UnsupportedPolicyFormat", + "Message": "The configured policy path must identify a JSON document.", + "Management": { + "State": "Invalid", + "ConfiguredPath": "C:\\ProgramData\\Devolutions\\Agent\\now-policy.yaml", + "StoreToken": "store:invalid-format:1", + "Source": "ConfiguredPath", + "WriteCapability": "Unsupported", + "ReadOnlyReason": "UnsupportedFormat", + "ElevationRequired": false, + "InvalidDiagnostics": { + "DiagnosticsVersion": "1.0", + "Findings": [ + { + "FindingVersion": "1.0", + "Severity": "Error", + "Code": "SchemaViolation", + "Path": "", + "Arguments": { + "requiredFormat": "json" + }, + "Message": "The configured policy path must identify a JSON document." + } + ] + } + } +}