From 0aae68c09f2a7cda55849e0c77804098540a6d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 18:24:30 +0900 Subject: [PATCH 01/12] feat!: simplify policy document identity Remove document schema URIs and introduce the software-managed PolicyFormatVersion contract across Rust and .NET. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 6 +- .../BrokerSerializer.cs | 7 -- .../PolicyManagementModels.cs | 3 +- .../Devolutions.Now.Policy.Api/README.md | 2 + .../ResponseModels.cs | 5 +- .../BrokerClientTests.cs | 12 +- .../MetaModelTests.cs | 36 ++++++ .../PolicyManagementClientTests.cs | 16 --- .../PolicyTests.cs | 58 +++++++-- .../PolicyModels.cs | 112 +++++++++++++++--- .../PolicySerializer.cs | 10 -- .../Devolutions.Now.Policy.Model/README.md | 2 + policies/rust/now-policy-api/Cargo.toml | 4 +- .../openapi/now-policy-api.yaml | 89 +++++++------- policies/rust/now-policy-api/src/api.rs | 6 +- .../rust/now-policy-api/src/management.rs | 6 +- .../now-policy-server-template/Cargo.toml | 6 +- .../now-policy-server-template/src/server.rs | 5 +- policies/rust/now-policy/Cargo.toml | 2 +- policies/rust/now-policy/README.md | 2 + .../samples/corporate-allowlist.policy.json | 3 +- .../samples/deny-risky-options.policy.json | 3 +- .../invalid-failure-decision.policy.json | 3 +- .../samples/powershell-advanced.policy.json | 3 +- .../powershell-current-user.policy.json | 3 +- .../samples/scenario-coverage.policy.json | 3 +- .../devolutions.now-policy-draft.schema.json | 42 ++----- .../schema/devolutions.now-policy.schema.json | 42 ++----- policies/rust/now-policy/src/markers.rs | 18 --- policies/rust/now-policy/src/newtypes.rs | 79 ++++++++++++ policies/rust/now-policy/src/policy.rs | 30 ++--- .../rust/now-policy/tests/policy_samples.rs | 77 ++++++++++-- .../rust/now-policy/tools/generate_schema.rs | 21 +--- ...-validation.valid-with-error.response.json | 3 +- .../policy-replacement.create.request.json | 3 +- .../policy-replacement.overwrite.request.json | 3 +- .../policy-replacement.repair.request.json | 3 +- ...-replacement.replace-identity.request.json | 3 +- .../policy-replacement.update.request.json | 3 +- .../requests/policy-validation.request.json | 3 +- ...cution-winget-vscode-install.response.json | 2 +- .../policy-management.active.response.json | 3 +- .../policy-management.invalid.response.json | 4 +- .../policy-replacement.response.json | 9 +- .../responses/policy-stale-token.error.json | 3 +- .../policy-validation.invalid.response.json | 3 +- .../policy-validation.valid.response.json | 3 +- .../responses/policy.response.json | 3 +- ...inget-vscode-install.allowed.response.json | 2 +- ...inget-vscode-skiphash.denied.response.json | 2 +- 50 files changed, 465 insertions(+), 306 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70b014a..9868f78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -613,7 +613,7 @@ dependencies = [ [[package]] name = "now-policy" -version = "0.3.0" +version = "0.4.0" dependencies = [ "chrono", "schemars", @@ -626,7 +626,7 @@ dependencies = [ [[package]] name = "now-policy-api" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chrono", "derive_more", @@ -641,7 +641,7 @@ dependencies = [ [[package]] name = "now-policy-server-template" -version = "0.4.0" +version = "0.5.0" dependencies = [ "aide", "async-trait", diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs index 4911ce9..f4e9723 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs @@ -7,13 +7,6 @@ namespace Devolutions.Now.Policy.Api; -/// Canonical schema URI used in the $schema field of policy documents. -public static class SchemaUris -{ - public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; - public const string PolicyDraft = "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json"; -} - /// Shared for broker documents. public static class BrokerSerializer { diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs index 578061e..6875840 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs @@ -135,9 +135,8 @@ public enum PolicyFindingCode InvalidWildcardPattern, ContradictoryConstraints, InvalidValidityInterval, - UnsupportedSchema, UnsupportedPolicyType, - UnsupportedPolicyVersion, + UnsupportedPolicyFormatVersion, AuditModeEnabled, DefaultAllow, SensitiveOptionAllowed, diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 287e0e5..8eb406c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -21,6 +21,8 @@ The DTOs are used to: - share the same JSON wire shape as the Rust source-of-truth model; - provide compatibility conversions between package broker API enums and the `Devolutions.Now.Policy.Model` policy enums. +Embedded policy documents and response policy projections use the software-managed `PolicyFormatVersion` field. New values are stamped as `1.0.0`; compatible SemVer values in the 1.x line are accepted and preserved. + Architecture ------------ diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs index 3d59925..6ec6e5f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs @@ -167,8 +167,9 @@ public sealed class ResponsePolicyInfo [JsonPropertyName("Revision")] public int Revision { get; set; } - [JsonPropertyName("PolicyVersion")] - public string PolicyVersion { get; set; } = "1.0.0"; + [JsonPropertyName("PolicyFormatVersion")] + [JsonRequired] + public PolicyFormatVersion PolicyFormatVersion { get; init; } = PolicyFormatVersion.Current; } public sealed class OperationDiagnostics diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs index 00b57e6..3eb6b8c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs @@ -31,7 +31,7 @@ public async Task Evaluate_populates_client_context_and_missing_metadata_before_ var transport = new FakeBrokerTransport( CapabilitiesResponse, """ - {"ResponseKind":"EvaluationResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"unused","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"WouldExecute":true,"Policy":{"Id":"mock.policy","Revision":1,"PolicyVersion":"1.0.0"}} + {"ResponseKind":"EvaluationResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"unused","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"WouldExecute":true,"Policy":{"Id":"mock.policy","Revision":1,"PolicyFormatVersion":"1.0.0"}} """); var client = new BrokerClient(new BrokerClientOptions { @@ -77,7 +77,7 @@ public async Task Execute_normalizes_explicit_request_id_and_preserves_created_a var transport = new FakeBrokerTransport( CapabilitiesResponse, """ - {"ResponseKind":"ExecutionResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}} + {"ResponseKind":"ExecutionResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyFormatVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}} """); var client = CreateClient(transport); var createdAt = DateTimeOffset.Parse("2026-06-29T12:00:00Z"); @@ -104,7 +104,7 @@ public async Task ExecuteAndWait_throws_typed_error_when_policy_denies_request() var transport = new FakeBrokerTransport( CapabilitiesResponse, """ - {"ResponseKind":"ExecutionResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Deny","RuleId":"block-rule","Reason":"blocked"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyVersion":"1.0.0"}} + {"ResponseKind":"ExecutionResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Deny","RuleId":"block-rule","Reason":"blocked"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyFormatVersion":"1.0.0"}} """); var client = CreateClient(transport); @@ -182,7 +182,7 @@ public async Task ExecuteAndWait_treats_canceled_status_as_terminal() var transport = new FakeBrokerTransport( CapabilitiesResponse, """ - {"ResponseKind":"ExecutionResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}} + {"ResponseKind":"ExecutionResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyFormatVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}} """, """ {"ResponseKind":"StatusResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"OperationId":"operation:123","RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","Status":"Canceled","Message":"operation was canceled"} @@ -208,7 +208,7 @@ public async Task ExecuteAndWait_requests_broker_cancelation_when_token_is_cance var transport = new FakeBrokerTransport( CapabilitiesResponse, """ - {"ResponseKind":"ExecutionResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}} + {"ResponseKind":"ExecutionResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyFormatVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}} """, """ {"ResponseKind":"CancelResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"OperationId":"operation:123","RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","Status":"Canceling"} @@ -412,7 +412,7 @@ public async Task GetPolicy_preserves_structured_unsupported_error(string transp [InlineData("Server")] [InlineData("Server.ServerVersion")] [InlineData("Server.Transport")] - [InlineData("Policy.$schema")] + [InlineData("Policy.PolicyFormatVersion")] [InlineData("Policy.Metadata.Id")] [InlineData("Policy.Enforcement.DefaultDecision")] [InlineData("Policy.Rules")] diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs index c5dbf48..158592d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs @@ -169,6 +169,42 @@ public void Public_json_options_round_trip_policy_response_without_reflection() Assert.Contains(Environment.NewLine, pretty); } + [Fact] + public void Strict_policy_response_rejects_schema_member_and_unsupported_format_version() + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(TestData.SamplesDir, "responses", "policy.response.json")))!; + document["Policy"]!["$schema"] = "https://example.invalid/policy.schema.json"; + Assert.Throws( + () => BrokerSerializer.DeserializeStrict(document.ToJsonString())); + + document["Policy"]!.AsObject().Remove("$schema"); + document["Policy"]!["PolicyFormatVersion"] = "2.0.0"; + Assert.Throws( + () => BrokerSerializer.DeserializeStrict(document.ToJsonString())); + } + + [Theory] + [InlineData("winget-vscode-install.allowed.response.json", "EvaluationResponse")] + [InlineData("execution-winget-vscode-install.response.json", "ExecutionResponse")] + public void Strict_operation_response_requires_policy_format_version(string fixture, string responseType) + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(TestData.SamplesDir, "responses", fixture)))!; + document["Policy"]!.AsObject().Remove("PolicyFormatVersion"); + + if (responseType == "EvaluationResponse") + { + Assert.Throws( + () => BrokerSerializer.DeserializeStrict(document.ToJsonString())); + } + else + { + Assert.Throws( + () => BrokerSerializer.DeserializeStrict(document.ToJsonString())); + } + } + [Fact] public async Task ErrorResponse_serializes_to_schema_valid_output() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs index cc8292c..ec91b92 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs @@ -537,10 +537,6 @@ public async Task Public_serializer_options_enforce_policy_root_semantic_invaria } var committedDto = JsonSerializer.Deserialize(committed.ToJsonString(), options)!; - committedDto.Schema = Devolutions.Now.Policy.Model.SchemaUris.PolicyDraft; - Assert.Throws(() => JsonSerializer.Serialize(committedDto, options)); - - committedDto = JsonSerializer.Deserialize(committed.ToJsonString(), options)!; committedDto.Metadata.Revision = 0; Assert.Throws(() => JsonSerializer.Serialize(committedDto, options)); @@ -553,10 +549,6 @@ public async Task Public_serializer_options_enforce_policy_root_semantic_invaria Assert.Throws(() => JsonSerializer.Serialize(committedDto, options)); var draftDto = JsonSerializer.Deserialize(draft.ToJsonString(), options)!; - draftDto.Schema = Devolutions.Now.Policy.Model.SchemaUris.Policy; - Assert.Throws(() => JsonSerializer.Serialize(draftDto, options)); - - draftDto = JsonSerializer.Deserialize(draft.ToJsonString(), options)!; draftDto.Rules[0].Match.SkipHashCheck = [false, true]; Assert.Throws(() => JsonSerializer.Serialize(draftDto, options)); } @@ -646,10 +638,6 @@ private static JsonNode MismatchedReplacementCanonicalDraft(JsonNode response) private static IEnumerable InvalidCommittedPolicies(JsonNode committed) { - var wrongSchema = committed.DeepClone(); - wrongSchema["$schema"] = Devolutions.Now.Policy.Model.SchemaUris.PolicyDraft; - yield return wrongSchema; - var zeroRevision = committed.DeepClone(); zeroRevision["Metadata"]!["Revision"] = 0; yield return zeroRevision; @@ -665,10 +653,6 @@ private static IEnumerable InvalidCommittedPolicies(JsonNode committed private static IEnumerable InvalidDraftPolicies(JsonNode draft) { - var wrongSchema = draft.DeepClone(); - wrongSchema["$schema"] = Devolutions.Now.Policy.Model.SchemaUris.Policy; - yield return wrongSchema; - var mixedBooleanMatch = draft.DeepClone(); mixedBooleanMatch["Rules"]![0]!["Match"]!["SkipHashCheck"] = new JsonArray(false, true); yield return mixedBooleanMatch; diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 0d236fa..4501360 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -84,21 +84,24 @@ public async Task Draft_conversion_uses_and_validates_against_draft_schema() var schema = await JsonSchema.FromFileAsync(PolicyDraftSchema); var json = draft.ToJson(); - Assert.Equal(SchemaUris.PolicyDraft, draft.Schema); + Assert.Equal(PolicyFormatVersions.Current, draft.PolicyFormatVersion.Value); + Assert.Null(JsonNode.Parse(json)!["$schema"]); Assert.Empty(schema.Validate(json)); - Assert.Equal(SchemaUris.Policy, draft.ToPolicyDocument(1, DateTimeOffset.UtcNow).Schema); + Assert.Equal( + PolicyFormatVersions.Current, + draft.ToPolicyDocument(1, DateTimeOffset.UtcNow).PolicyFormatVersion.Value); } [Fact] - public void Policy_and_draft_parsers_reject_the_other_document_schema() + public void Policy_and_draft_parsers_reject_schema_member() { var policy = PolicyDocument.Create("contoso.policy", "Contoso IT"); var policyJson = JsonNode.Parse(policy.ToJson())!; - policyJson["$schema"] = SchemaUris.PolicyDraft; + policyJson["$schema"] = "https://example.invalid/policy.schema.json"; Assert.Throws(() => PolicyDocument.ParseJson(policyJson.ToJsonString())); var draftJson = JsonNode.Parse(policy.ToDraft().ToJson())!; - draftJson["$schema"] = SchemaUris.Policy; + draftJson["$schema"] = "https://example.invalid/policy-draft.schema.json"; Assert.Throws( () => PolicySerializer.DeserializePolicyDraftDocumentStrict(draftJson.ToJsonString())); } @@ -161,8 +164,7 @@ public void Negative_priority_is_rejected_by_parser() } [Theory] - [InlineData("$schema")] - [InlineData("PolicyVersion")] + [InlineData("PolicyFormatVersion")] [InlineData("PolicyType")] [InlineData("Metadata")] [InlineData("Enforcement")] @@ -189,8 +191,7 @@ public void Missing_rust_required_property_is_rejected_by_parser(string property } [Theory] - [InlineData("$schema")] - [InlineData("PolicyVersion")] + [InlineData("PolicyFormatVersion")] [InlineData("PolicyType")] [InlineData("Metadata")] [InlineData("Enforcement")] @@ -238,6 +239,7 @@ public void Draft_conversion_omits_and_restores_server_metadata_without_aliasing var draft = committed.ToDraft(); var draftJson = JsonNode.Parse(draft.ToJson())!; + Assert.Null(draftJson["$schema"]); Assert.Null(draftJson["Metadata"]!["Revision"]); Assert.Null(draftJson["Metadata"]!["PublishedAt"]); @@ -246,11 +248,46 @@ public void Draft_conversion_omits_and_restores_server_metadata_without_aliasing var publishedAt = DateTimeOffset.Parse("2026-08-29T00:00:00Z"); var recommitted = draft.ToPolicyDocument(7, publishedAt); + Assert.Equal(committed.PolicyFormatVersion, recommitted.PolicyFormatVersion); + Assert.Equal(committed.Metadata.Id, recommitted.Metadata.Id); + Assert.Equal(committed.Metadata.Publisher, recommitted.Metadata.Publisher); + Assert.Equal(committed.Metadata.Description, recommitted.Metadata.Description); + Assert.Equal(committed.Metadata.SupportUrl, recommitted.Metadata.SupportUrl); + Assert.Equal(committed.Metadata.ValidFrom, recommitted.Metadata.ValidFrom); + Assert.Equal(committed.Metadata.ValidUntil, recommitted.Metadata.ValidUntil); Assert.Equal(7U, recommitted.Metadata.Revision); Assert.Equal(publishedAt, recommitted.Metadata.PublishedAt); Assert.Equal("changed", recommitted.Rules[0].Id); } + [Theory] + [InlineData("not-semver")] + [InlineData("2.0.0")] + [InlineData("1.18446744073709551616.0")] + [InlineData("1.2.3-١a")] + [InlineData("1.0.0\n")] + public void Unsupported_policy_format_versions_are_rejected(string value) + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; + document["PolicyFormatVersion"] = value; + + Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); + } + + [Fact] + public void Compatible_policy_format_version_is_preserved_by_conversion() + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; + document["PolicyFormatVersion"] = "1.2.3"; + + var committed = PolicyDocument.ParseJson(document.ToJsonString()); + var recommitted = committed.ToDraft().ToPolicyDocument(8, DateTimeOffset.UtcNow); + + Assert.Equal("1.2.3", recommitted.PolicyFormatVersion.Value); + } + [Fact] public void Draft_conversion_enforces_revision_bounds() { @@ -420,8 +457,7 @@ private static string MinimalPolicyJson(string revision, string rules) { return $$""" { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "test.policy", diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index f371b58..e520a6c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -1,24 +1,100 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; namespace Devolutions.Now.Policy.Model; -public static class SchemaUris +public static class PolicyFormatVersions { - public const string Policy = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; - public const string PolicyDraft = "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json"; + public const string Current = "1.0.0"; + public const ulong SupportedMajor = 1; +} + +[JsonConverter(typeof(PolicyFormatVersionJsonConverter))] +public sealed class PolicyFormatVersion : IEquatable +{ + private static readonly Regex SemVerPattern = new( + @"^(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$", + RegexOptions.CultureInvariant); + + private PolicyFormatVersion(string value) + { + Value = value; + } + + public static PolicyFormatVersion Current { get; } = new(PolicyFormatVersions.Current); + + public string Value { get; } + + public static PolicyFormatVersion Parse(string value) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length > 128) + { + throw new FormatException("PolicyFormatVersion must contain at most 128 characters."); + } + + var match = SemVerPattern.Match(value); + if (!match.Success + || match.Length != value.Length + || !ulong.TryParse(match.Groups["major"].Value, out var major) + || !ulong.TryParse(match.Groups["minor"].Value, out _) + || !ulong.TryParse(match.Groups["patch"].Value, out _)) + { + throw new FormatException("PolicyFormatVersion must be a valid SemVer 2.0.0 string."); + } + if (major != PolicyFormatVersions.SupportedMajor) + { + throw new NotSupportedException( + $"Policy format major version {major} is unsupported; supported major version is {PolicyFormatVersions.SupportedMajor}."); + } + + return new PolicyFormatVersion(value); + } + + public bool Equals(PolicyFormatVersion? other) => + other is not null && string.Equals(Value, other.Value, StringComparison.Ordinal); + + public override bool Equals(object? obj) => Equals(obj as PolicyFormatVersion); + + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); + + public override string ToString() => Value; +} + +internal sealed class PolicyFormatVersionJsonConverter : JsonConverter +{ + public override PolicyFormatVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("PolicyFormatVersion must be a string."); + } + + try + { + return PolicyFormatVersion.Parse(reader.GetString()!); + } + catch (Exception exception) when (exception is FormatException or NotSupportedException) + { + throw new JsonException(exception.Message, exception); + } + } + + public override void Write(Utf8JsonWriter writer, PolicyFormatVersion value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); } /// A policy document governing which package operations are allowed or denied. public sealed class PolicyDocument { - [JsonPropertyName("$schema")] - [JsonRequired] - public string Schema { get; set; } = SchemaUris.Policy; - - [JsonPropertyName("PolicyVersion")] + /// + /// Software-managed document-format version. Applications must not expose + /// this as publisher-authored editable metadata. + /// + [JsonPropertyName("PolicyFormatVersion")] [JsonRequired] - public string PolicyVersion { get; set; } = "1.0.0"; + public PolicyFormatVersion PolicyFormatVersion { get; init; } = PolicyFormatVersion.Current; [JsonPropertyName("PolicyType")] [JsonRequired] @@ -65,8 +141,7 @@ public PolicyDraftDocument ToDraft() { return new PolicyDraftDocument { - Schema = SchemaUris.PolicyDraft, - PolicyVersion = PolicyVersion, + PolicyFormatVersion = PolicyFormatVersion, PolicyType = PolicyType, Metadata = PolicyModelClone.ToDraftMetadata(Metadata), Enforcement = PolicyModelClone.Enforcement(Enforcement), @@ -82,13 +157,13 @@ public sealed class PolicyDraftDocument { private const uint MaxRevision = int.MaxValue; - [JsonPropertyName("$schema")] - [JsonRequired] - public string Schema { get; set; } = SchemaUris.PolicyDraft; - - [JsonPropertyName("PolicyVersion")] + /// + /// Software-managed document-format version. Applications must stamp the + /// current value for new drafts and must not expose it as authored metadata. + /// + [JsonPropertyName("PolicyFormatVersion")] [JsonRequired] - public string PolicyVersion { get; set; } = "1.0.0"; + public PolicyFormatVersion PolicyFormatVersion { get; init; } = PolicyFormatVersion.Current; [JsonPropertyName("PolicyType")] [JsonRequired] @@ -143,8 +218,7 @@ public PolicyDocument ToPolicyDocument(uint revision, DateTimeOffset publishedAt return new PolicyDocument { - Schema = SchemaUris.Policy, - PolicyVersion = PolicyVersion, + PolicyFormatVersion = PolicyFormatVersion, PolicyType = PolicyType, Metadata = PolicyModelClone.ToCommittedMetadata(Metadata, revision, publishedAt), Enforcement = PolicyModelClone.Enforcement(Enforcement), diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs index 1392084..7ad7f7c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicySerializer.cs @@ -72,14 +72,12 @@ private static void ValidateSemanticValue(object? value) internal static void ValidateRequiredCollectionElements(PolicyDocument policy) { - ValidateSchemaUri(policy.Schema, SchemaUris.Policy, "$.$schema"); ValidatePolicyRevision(policy.Metadata.Revision); ValidateRequiredCollectionElements(policy.Rules); } internal static void ValidateRequiredCollectionElements(PolicyDraftDocument policy) { - ValidateSchemaUri(policy.Schema, SchemaUris.PolicyDraft, "$.$schema"); ValidateRequiredCollectionElements(policy.Rules); } @@ -179,14 +177,6 @@ private static void ValidatePolicyRevision(uint revision) } } - private static void ValidateSchemaUri(string actual, string expected, string path) - { - if (!string.Equals(actual, expected, StringComparison.Ordinal)) - { - throw new JsonException($"The JSON string at {path} must be '{expected}'."); - } - } - private static void RejectNullElements(IReadOnlyList values, string path) where T : class { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index eda151c..5a4487d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -25,6 +25,8 @@ Architecture `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. +`PolicyFormatVersion` is software-managed format compatibility metadata, not a publisher release version. New documents stamp `1.0.0`; readers accept and preserve compatible SemVer values in the 1.x line and reject malformed or unsupported-major values. Applications must not expose it as authored metadata. Policy documents contain no `$schema` member, and strict readers reject documents that contain one as unknown-field input. + Breaking change --------------- diff --git a/policies/rust/now-policy-api/Cargo.toml b/policies/rust/now-policy-api/Cargo.toml index 19d8622..1e4293c 100644 --- a/policies/rust/now-policy-api/Cargo.toml +++ b/policies/rust/now-policy-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy-api" -version = "0.4.0" +version = "0.5.0" edition = "2024" license.workspace = true homepage.workspace = true @@ -16,7 +16,7 @@ workspace = true [dependencies] chrono = { version = "0.4", features = ["serde"] } derive_more = { version = "2", features = ["as_ref", "deref", "display", "from"] } -now-policy = { version = "0.3", path = "../now-policy" } +now-policy = { version = "0.4", path = "../now-policy" } schemars = { version = "0.9", features = ["chrono04"] } semver = "1" serde = { version = "1", features = ["derive"] } diff --git a/policies/rust/now-policy-api/openapi/now-policy-api.yaml b/policies/rust/now-policy-api/openapi/now-policy-api.yaml index 61cb694..e8aed5f 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1247,9 +1247,8 @@ components: - InvalidWildcardPattern - ContradictoryConstraints - InvalidValidityInterval - - UnsupportedSchema - UnsupportedPolicyType - - UnsupportedPolicyVersion + - UnsupportedPolicyFormatVersion - AuditModeEnabled - DefaultAllow - SensitiveOptionAllowed @@ -1259,6 +1258,16 @@ components: enum: - Error - Warning + PolicyFormatVersion: + description: |- + Software-managed policy document format version. + + Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications + must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose + this value as publisher-authored editable metadata. + type: string + maxLength: 128 + pattern: ^1\.(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-]+)*))?$ PolicyManagementResponse: description: Response body for `GET /v1/policy/management`. type: object @@ -1839,10 +1848,10 @@ components: description: Policy document identifier. allOf: - $ref: '#/components/schemas/ResourceId' - PolicyVersion: - description: Policy syntax version. + PolicyFormatVersion: + description: Software-managed policy document format version. allOf: - - $ref: '#/components/schemas/SemanticVersion' + - $ref: '#/components/schemas/PolicyFormatVersion' Revision: description: Policy revision number. type: integer @@ -1853,7 +1862,7 @@ components: required: - Id - Revision - - PolicyVersion + - PolicyFormatVersion RuleId: description: Rule ID in responses. Includes sentinel values not valid as policy rule IDs. type: string @@ -1865,11 +1874,6 @@ components: enum: - User - Machine - SemanticVersion: - description: Semantic version string (SemVer 2.0.0). - type: string - maxLength: 128 - pattern: ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$ ServerContext: description: Server context included in responses. type: object @@ -2117,10 +2121,6 @@ components: description: A policy document governing which package operations are allowed or denied. type: object properties: - $schema: - description: Policy schema URI constant. - allOf: - - $ref: '#/components/schemas/PolicyModelPolicySchemaUri' Enforcement: description: Enforcement configuration. allOf: @@ -2129,14 +2129,17 @@ components: description: Policy metadata. allOf: - $ref: '#/components/schemas/PolicyModelPolicyMetadata' + PolicyFormatVersion: + description: |- + Software-managed policy document format version. + + Applications must not expose this field as publisher-authored editable metadata. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyFormatVersion' 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 @@ -2145,8 +2148,7 @@ components: maxItems: 1024 additionalProperties: false required: - - $schema - - PolicyVersion + - PolicyFormatVersion - PolicyType - Metadata - Enforcement @@ -2155,10 +2157,6 @@ components: description: An editable policy document without server-managed commit metadata. type: object properties: - $schema: - description: Policy schema URI constant. - allOf: - - $ref: '#/components/schemas/PolicyModelPolicyDraftSchemaUri' Enforcement: description: Enforcement configuration. allOf: @@ -2167,14 +2165,18 @@ components: description: Editable policy metadata. allOf: - $ref: '#/components/schemas/PolicyModelPolicyDraftMetadata' + PolicyFormatVersion: + description: |- + Software-managed policy document format version. + + Applications must stamp the current value and must not expose this field + as publisher-authored editable metadata. + allOf: + - $ref: '#/components/schemas/PolicyModelPolicyFormatVersion' 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 @@ -2183,8 +2185,7 @@ components: maxItems: 1024 additionalProperties: false required: - - $schema - - PolicyVersion + - PolicyFormatVersion - PolicyType - Metadata - Enforcement @@ -2228,10 +2229,6 @@ components: required: - Id - Publisher - PolicyModelPolicyDraftSchemaUri: - type: string - enum: - - https://devolutions.net/schemas/now-policy-draft.schema.1.0.json PolicyModelPolicyEnforcement: description: Enforcement configuration. type: object @@ -2252,6 +2249,16 @@ components: required: - DefaultDecision - RulePrecedence + PolicyModelPolicyFormatVersion: + description: |- + Software-managed policy document format version. + + Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications + must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose + this value as publisher-authored editable metadata. + type: string + maxLength: 128 + pattern: ^1\.(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-]+)*))?$ PolicyModelPolicyMatch: description: |- Match criteria for a policy rule. All specified fields must match. @@ -2485,10 +2492,6 @@ components: - Priority - Decision - Match - PolicyModelPolicySchemaUri: - type: string - enum: - - https://devolutions.net/schemas/now-policy.schema.1.0.json PolicyModelResourceId: description: Resource identifier (policy IDs, rule IDs, request IDs, audit IDs). type: string @@ -2505,14 +2508,6 @@ components: enum: - User - Machine - PolicyModelSemanticVersion: - description: |- - Semantic version string (SemVer 2.0.0). - - Validated at deserialization time using the `semver` crate. - type: string - maxLength: 128 - pattern: ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$ PolicyModelStringPattern: description: Case-insensitive exact value or wildcard pattern. type: string diff --git a/policies/rust/now-policy-api/src/api.rs b/policies/rust/now-policy-api/src/api.rs index 8cdc4ac..7c4150e 100644 --- a/policies/rust/now-policy-api/src/api.rs +++ b/policies/rust/now-policy-api/src/api.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::enums::{Architecture, Decision, Elevation, ErrorCode, ManagerName, Operation, Scope, Transport}; use super::{ ApiVersion, CommandString, CustomParameterString, ErrorResponseKind, PackageIdentifier, PackageRequestKind, - ProcessName, ResourceId, RuleId, SemanticVersion, VersionString, + ProcessName, ResourceId, RuleId, VersionString, }; /// Canonical request sent by a package broker client to the elevated broker. @@ -254,8 +254,8 @@ pub struct ResponsePolicyInfo { #[schemars(range(min = 1, max = 2147483647))] pub revision: u32, - /// Policy syntax version. - pub policy_version: SemanticVersion, + /// Software-managed policy document format version. + pub policy_format_version: now_policy::PolicyFormatVersion, } /// Optional operation diagnostics. diff --git a/policies/rust/now-policy-api/src/management.rs b/policies/rust/now-policy-api/src/management.rs index ed0afcb..6bb7579 100644 --- a/policies/rust/now-policy-api/src/management.rs +++ b/policies/rust/now-policy-api/src/management.rs @@ -102,9 +102,8 @@ pub enum PolicyFindingCode { InvalidWildcardPattern, ContradictoryConstraints, InvalidValidityInterval, - UnsupportedSchema, UnsupportedPolicyType, - UnsupportedPolicyVersion, + UnsupportedPolicyFormatVersion, AuditModeEnabled, DefaultAllow, SensitiveOptionAllowed, @@ -975,8 +974,7 @@ mod tests { "ValidatorVersion": "validator/1", "IsValid": true, "CanonicalDraft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "test", "Publisher": "test" }, "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, diff --git a/policies/rust/now-policy-server-template/Cargo.toml b/policies/rust/now-policy-server-template/Cargo.toml index eea4c86..43d8ef7 100644 --- a/policies/rust/now-policy-server-template/Cargo.toml +++ b/policies/rust/now-policy-server-template/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy-server-template" -version = "0.4.0" +version = "0.5.0" edition = "2024" license.workspace = true homepage.workspace = true @@ -17,8 +17,8 @@ workspace = true aide = { version = "0.15", features = ["axum", "axum-json"] } async-trait = "0.1" axum = { version = "0.8", default-features = false, features = ["json"] } -now-policy-api = { version = "0.4", path = "../now-policy-api" } -now-policy = { version = "0.3", path = "../now-policy" } +now-policy-api = { version = "0.5", path = "../now-policy-api" } +now-policy = { version = "0.4", path = "../now-policy" } schemars = "0.9" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/policies/rust/now-policy-server-template/src/server.rs b/policies/rust/now-policy-server-template/src/server.rs index 540b64c..65ac716 100644 --- a/policies/rust/now-policy-server-template/src/server.rs +++ b/policies/rust/now-policy-server-template/src/server.rs @@ -493,7 +493,6 @@ mod tests { "Operation", "ResourceId", "Scope", - "SemanticVersion", "VersionString", ] { assert!( @@ -509,6 +508,10 @@ mod tests { "component collision must not rename {name}" ); } + + assert!(schemas.contains_key("PolicyFormatVersion")); + assert!(schemas.contains_key("PolicyModelPolicyFormatVersion")); + assert!(!schemas.contains_key("PolicyFormatVersion2")); } #[test] diff --git a/policies/rust/now-policy/Cargo.toml b/policies/rust/now-policy/Cargo.toml index 54205fa..94ef10a 100644 --- a/policies/rust/now-policy/Cargo.toml +++ b/policies/rust/now-policy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy" -version = "0.3.0" +version = "0.4.0" edition = "2024" license.workspace = true homepage.workspace = true diff --git a/policies/rust/now-policy/README.md b/policies/rust/now-policy/README.md index 484ddf0..9070b04 100644 --- a/policies/rust/now-policy/README.md +++ b/policies/rust/now-policy/README.md @@ -6,4 +6,6 @@ This crate provides the JSON-only Rust policy model and JSON Schema helpers for 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. +`PolicyFormatVersion` is a software-managed document-format marker, not a publisher release version. Applications stamp the current `1.0.0` value when creating documents, accept and preserve compatible SemVer values in the 1.x line, reject malformed or unsupported-major values, and must not expose it as publisher-authored editable metadata. Policy documents do not contain a `$schema` member; documents that contain it are rejected as unknown-field input under the strict contract. + `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.json b/policies/rust/now-policy/assets/samples/corporate-allowlist.policy.json index 4d50bc4..e0da844 100644 --- a/policies/rust/now-policy/assets/samples/corporate-allowlist.policy.json +++ b/policies/rust/now-policy/assets/samples/corporate-allowlist.policy.json @@ -1,6 +1,5 @@ { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.standard-allowlist", diff --git a/policies/rust/now-policy/assets/samples/deny-risky-options.policy.json b/policies/rust/now-policy/assets/samples/deny-risky-options.policy.json index 5ddf8d1..105eebf 100644 --- a/policies/rust/now-policy/assets/samples/deny-risky-options.policy.json +++ b/policies/rust/now-policy/assets/samples/deny-risky-options.policy.json @@ -1,6 +1,5 @@ { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.deny-risky-options", diff --git a/policies/rust/now-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json b/policies/rust/now-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json index 2b65f4c..6a744a9 100644 --- a/policies/rust/now-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json +++ b/policies/rust/now-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json @@ -1,6 +1,5 @@ { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.invalid.failure-decision", diff --git a/policies/rust/now-policy/assets/samples/powershell-advanced.policy.json b/policies/rust/now-policy/assets/samples/powershell-advanced.policy.json index 2e2e3b9..f685df2 100644 --- a/policies/rust/now-policy/assets/samples/powershell-advanced.policy.json +++ b/policies/rust/now-policy/assets/samples/powershell-advanced.policy.json @@ -1,6 +1,5 @@ { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.powershell.advanced-scenarios", diff --git a/policies/rust/now-policy/assets/samples/powershell-current-user.policy.json b/policies/rust/now-policy/assets/samples/powershell-current-user.policy.json index ddb5ae3..ccef4c1 100644 --- a/policies/rust/now-policy/assets/samples/powershell-current-user.policy.json +++ b/policies/rust/now-policy/assets/samples/powershell-current-user.policy.json @@ -1,6 +1,5 @@ { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.powershell.current-user-modules", diff --git a/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json b/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json index 77ba46c..4ddb628 100644 --- a/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json +++ b/policies/rust/now-policy/assets/samples/scenario-coverage.policy.json @@ -1,6 +1,5 @@ { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.scenario-coverage", diff --git a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index 66bd0ea..4255de4 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -1,5 +1,4 @@ { - "$id": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { @@ -214,12 +213,6 @@ ], "type": "object" }, - "PolicyDraftSchemaUri": { - "enum": [ - "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json" - ], - "type": "string" - }, "PolicyEnforcement": { "additionalProperties": false, "description": "Enforcement configuration.", @@ -254,6 +247,12 @@ ], "type": "object" }, + "PolicyFormatVersion": { + "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose\n this value as publisher-authored editable metadata.", + "maxLength": 128, + "pattern": "^1\\.(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" + }, "PolicyMatch": { "additionalProperties": false, "description": "Match criteria for a policy rule. All specified fields must match.\n At least one field must be present.", @@ -515,12 +514,6 @@ ], "type": "string" }, - "SemanticVersion": { - "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" - }, "StringPattern": { "description": "Case-insensitive exact value or wildcard pattern.", "maxLength": 256, @@ -566,14 +559,6 @@ }, "description": "An editable policy document without server-managed commit metadata.", "properties": { - "$schema": { - "allOf": [ - { - "$ref": "#/definitions/PolicyDraftSchemaUri" - } - ], - "description": "Policy schema URI constant." - }, "Enforcement": { "allOf": [ { @@ -590,21 +575,21 @@ ], "description": "Editable policy metadata." }, - "PolicyType": { + "PolicyFormatVersion": { "allOf": [ { - "$ref": "#/definitions/PackageBrokerPolicy" + "$ref": "#/definitions/PolicyFormatVersion" } ], - "description": "Must be `\"PackageBrokerPolicy\"`." + "description": "Software-managed policy document format version.\n\n Applications must stamp the current value and must not expose this field\n as publisher-authored editable metadata." }, - "PolicyVersion": { + "PolicyType": { "allOf": [ { - "$ref": "#/definitions/SemanticVersion" + "$ref": "#/definitions/PackageBrokerPolicy" } ], - "description": "Policy syntax version (semver)." + "description": "Must be `\"PackageBrokerPolicy\"`." }, "Rules": { "description": "Ordered list of policy rules (may be empty; enforcement defaults apply).", @@ -616,8 +601,7 @@ } }, "required": [ - "$schema", - "PolicyVersion", + "PolicyFormatVersion", "PolicyType", "Metadata", "Enforcement", 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 b4a4f85..10bb974 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -1,5 +1,4 @@ { - "$id": "https://devolutions.net/schemas/now-policy.schema.1.0.json", "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "definitions": { @@ -188,6 +187,12 @@ ], "type": "object" }, + "PolicyFormatVersion": { + "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose\n this value as publisher-authored editable metadata.", + "maxLength": 128, + "pattern": "^1\\.(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" + }, "PolicyMatch": { "additionalProperties": false, "description": "Match criteria for a policy rule. All specified fields must match.\n At least one field must be present.", @@ -502,12 +507,6 @@ ], "type": "object" }, - "PolicySchemaUri": { - "enum": [ - "https://devolutions.net/schemas/now-policy.schema.1.0.json" - ], - "type": "string" - }, "ResourceId": { "description": "Resource identifier (policy IDs, rule IDs, request IDs, audit IDs).", "maxLength": 128, @@ -529,12 +528,6 @@ ], "type": "string" }, - "SemanticVersion": { - "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" - }, "StringPattern": { "description": "Case-insensitive exact value or wildcard pattern.", "maxLength": 256, @@ -580,14 +573,6 @@ }, "description": "A policy document governing which package operations are allowed or denied.", "properties": { - "$schema": { - "allOf": [ - { - "$ref": "#/definitions/PolicySchemaUri" - } - ], - "description": "Policy schema URI constant." - }, "Enforcement": { "allOf": [ { @@ -604,21 +589,21 @@ ], "description": "Policy metadata." }, - "PolicyType": { + "PolicyFormatVersion": { "allOf": [ { - "$ref": "#/definitions/PackageBrokerPolicy" + "$ref": "#/definitions/PolicyFormatVersion" } ], - "description": "Must be `\"PackageBrokerPolicy\"`." + "description": "Software-managed policy document format version.\n\n Applications must not expose this field as publisher-authored editable metadata." }, - "PolicyVersion": { + "PolicyType": { "allOf": [ { - "$ref": "#/definitions/SemanticVersion" + "$ref": "#/definitions/PackageBrokerPolicy" } ], - "description": "Policy syntax version (semver)." + "description": "Must be `\"PackageBrokerPolicy\"`." }, "Rules": { "description": "Ordered list of policy rules (may be empty; enforcement defaults apply).", @@ -630,8 +615,7 @@ } }, "required": [ - "$schema", - "PolicyVersion", + "PolicyFormatVersion", "PolicyType", "Metadata", "Enforcement", diff --git a/policies/rust/now-policy/src/markers.rs b/policies/rust/now-policy/src/markers.rs index dcea0b5..be12479 100644 --- a/policies/rust/now-policy/src/markers.rs +++ b/policies/rust/now-policy/src/markers.rs @@ -51,21 +51,3 @@ fixed_string_marker! { /// Marker type for policy type: serializes to `"PackageBrokerPolicy"`. pub struct PackageBrokerPolicy => "PackageBrokerPolicy"; } - -/// Schema URI for package policy documents. -pub const POLICY_SCHEMA_URI: &str = "https://devolutions.net/schemas/now-policy.schema.1.0.json"; - -/// Schema URI for editable package policy draft documents. -pub const POLICY_DRAFT_SCHEMA_URI: &str = "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json"; - -fixed_string_marker! { - /// Marker type for the policy `$schema` field. - /// Serializes to the canonical policy schema URI. - pub struct PolicySchemaUri => POLICY_SCHEMA_URI; -} - -fixed_string_marker! { - /// Marker type for the policy draft `$schema` field. - /// Serializes to the canonical policy draft schema URI. - pub struct PolicyDraftSchemaUri => POLICY_DRAFT_SCHEMA_URI; -} diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 9283f55..41653ef 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -99,6 +99,85 @@ impl From<&str> for SemanticVersion { } } +/// Current policy document format version emitted for new documents. +pub const CURRENT_POLICY_FORMAT_VERSION: &str = "1.0.0"; + +/// Software-managed policy document format version. +/// +/// Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications +/// must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose +/// this value as publisher-authored editable metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct PolicyFormatVersion( + #[schemars( + length(max = 128), + regex( + pattern = r"^1\.(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-]+)*))?$" + ) + )] + String, +); + +impl PolicyFormatVersion { + /// Parse a supported policy document format version. + pub fn parse(s: &str) -> Result { + if s.len() > 128 { + return Err(ModelValidationError::Invalid { + type_name: "PolicyFormatVersion", + reason: format!("length {} exceeds maximum 128", s.len()), + }); + } + + let version = semver::Version::parse(s).map_err(|error| ModelValidationError::Invalid { + type_name: "PolicyFormatVersion", + reason: error.to_string(), + })?; + if version.major != 1 { + return Err(ModelValidationError::Invalid { + type_name: "PolicyFormatVersion", + reason: format!( + "unsupported major version {}; supported major version is 1", + version.major + ), + }); + } + + Ok(Self(s.to_owned())) + } + + /// Return the current version stamped on new documents. + pub fn current() -> Self { + Self(CURRENT_POLICY_FORMAT_VERSION.to_owned()) + } +} + +impl Default for PolicyFormatVersion { + fn default() -> Self { + Self::current() + } +} + +impl<'de> Deserialize<'de> for PolicyFormatVersion { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for PolicyFormatVersion { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for PolicyFormatVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + /// Resource identifier (policy IDs, rule IDs, request IDs, audit IDs). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] pub struct ResourceId( diff --git a/policies/rust/now-policy/src/policy.rs b/policies/rust/now-policy/src/policy.rs index a78e437..43c4968 100644 --- a/policies/rust/now-policy/src/policy.rs +++ b/policies/rust/now-policy/src/policy.rs @@ -8,8 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::{ Architecture, CustomParameterString, Decision, Elevation, HttpUrl, ManagerName, ModelValidationError, Operation, - PackageBrokerPolicy, PolicyDraftSchemaUri, PolicySchemaUri, ResourceId, Scope, SemanticVersion, StringPattern, - VersionString, + PackageBrokerPolicy, PolicyFormatVersion, ResourceId, Scope, StringPattern, VersionString, }; const MAX_POLICY_REVISION: u32 = 2_147_483_647; @@ -20,12 +19,10 @@ const MAX_POLICY_REVISION: u32 = 2_147_483_647; #[serde(rename_all = "PascalCase")] #[serde(deny_unknown_fields)] pub struct PolicyDocument { - /// Policy schema URI constant. - #[serde(rename = "$schema")] - pub _schema: PolicySchemaUri, - - /// Policy syntax version (semver). - pub policy_version: SemanticVersion, + /// Software-managed policy document format version. + /// + /// Applications must not expose this field as publisher-authored editable metadata. + pub policy_format_version: PolicyFormatVersion, /// Must be `"PackageBrokerPolicy"`. pub policy_type: PackageBrokerPolicy, @@ -45,8 +42,7 @@ impl PolicyDocument { /// Create an editable draft, intentionally omitting server-managed commit metadata. pub fn to_draft(&self) -> PolicyDraftDocument { PolicyDraftDocument { - _schema: PolicyDraftSchemaUri, - policy_version: self.policy_version.clone(), + policy_format_version: self.policy_format_version.clone(), policy_type: self.policy_type, metadata: self.metadata.to_draft(), enforcement: self.enforcement.clone(), @@ -61,12 +57,11 @@ impl PolicyDocument { #[serde(rename_all = "PascalCase")] #[serde(deny_unknown_fields)] pub struct PolicyDraftDocument { - /// Policy draft schema URI constant. - #[serde(rename = "$schema")] - pub _schema: PolicyDraftSchemaUri, - - /// Policy syntax version (semver). - pub policy_version: SemanticVersion, + /// Software-managed policy document format version. + /// + /// Applications must stamp the current value and must not expose this field + /// as publisher-authored editable metadata. + pub policy_format_version: PolicyFormatVersion, /// Must be `"PackageBrokerPolicy"`. pub policy_type: PackageBrokerPolicy, @@ -97,8 +92,7 @@ impl PolicyDraftDocument { } Ok(PolicyDocument { - _schema: PolicySchemaUri, - policy_version: self.policy_version, + policy_format_version: self.policy_format_version, policy_type: self.policy_type, metadata: self.metadata.into_policy_metadata(revision, published_at), enforcement: self.enforcement, diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 3314757..4c76be1 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -5,9 +5,7 @@ use std::path::PathBuf; use chrono::{TimeZone, Utc}; -use now_policy::{ - CustomParameterString, POLICY_DRAFT_SCHEMA_URI, POLICY_SCHEMA_URI, PolicyDocument, StringPattern, VersionString, -}; +use now_policy::{CURRENT_POLICY_FORMAT_VERSION, CustomParameterString, PolicyDocument, StringPattern, VersionString}; fn samples_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/samples") @@ -42,17 +40,22 @@ fn draft_conversion_omits_and_restores_server_metadata() { let draft = committed.to_draft(); let draft_json = serde_json::to_value(&draft).unwrap(); - assert_eq!(draft_json["$schema"], POLICY_DRAFT_SCHEMA_URI); + assert!(draft_json.get("$schema").is_none()); + assert_eq!(draft_json["PolicyFormatVersion"], CURRENT_POLICY_FORMAT_VERSION); 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!( - serde_json::to_value(&recommitted).unwrap()["$schema"], - POLICY_SCHEMA_URI - ); + let recommitted_json = serde_json::to_value(&recommitted).unwrap(); + assert!(recommitted_json.get("$schema").is_none()); + assert_eq!(recommitted_json["PolicyFormatVersion"], CURRENT_POLICY_FORMAT_VERSION); assert_eq!(recommitted.metadata.id.to_string(), committed.metadata.id.to_string()); + assert_eq!(recommitted.metadata.publisher, committed.metadata.publisher); + assert_eq!(recommitted.metadata.description, committed.metadata.description); + assert_eq!(recommitted.metadata.support_url, committed.metadata.support_url); + assert_eq!(recommitted.metadata.valid_from, committed.metadata.valid_from); + assert_eq!(recommitted.metadata.valid_until, committed.metadata.valid_until); assert_eq!(recommitted.metadata.revision, 7); assert_eq!(recommitted.metadata.published_at, published_at); } @@ -107,8 +110,7 @@ fn policy_text_newtypes_count_unicode_scalars_at_length_boundaries() { #[test] fn invalid_policy_unknown_field_fails_deserialization() { let value = serde_json::json!({ - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "test", @@ -128,6 +130,44 @@ fn invalid_policy_unknown_field_fails_deserialization() { assert!(result.is_err(), "policy with unknown field should fail deserialization"); } +#[test] +fn schema_field_is_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["$schema"] = serde_json::json!("https://example.invalid/policy.schema.json"); + + let error = serde_json::from_value::(value).unwrap_err().to_string(); + assert!(error.contains("unknown field `$schema`"), "unexpected error: {error}"); +} + +#[test] +fn unsupported_policy_format_version_is_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["PolicyFormatVersion"] = serde_json::json!("2.0.0"); + + let error = serde_json::from_value::(value).unwrap_err().to_string(); + assert!( + error.contains("unsupported major version 2"), + "unexpected error: {error}" + ); +} + +#[test] +fn compatible_policy_format_version_is_preserved_by_conversions() { + 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["PolicyFormatVersion"] = serde_json::json!("1.2.3"); + + let committed = serde_json::from_value::(value).unwrap(); + let draft = committed.to_draft(); + assert_eq!(draft.policy_format_version.to_string(), "1.2.3"); + let recommitted = draft + .into_policy_document(5, Utc.with_ymd_and_hms(2026, 8, 29, 0, 0, 0).unwrap()) + .unwrap(); + assert_eq!(recommitted.policy_format_version.to_string(), "1.2.3"); +} + #[test] fn invalid_policy_fixture_fails_deserialization() { let path = samples_dir().join("invalid/policies/invalid-failure-decision.policy.json"); @@ -147,6 +187,23 @@ fn policy_schema_generates_valid_json() { ); } +#[test] +fn policy_schemas_omit_document_schema_and_fix_policy_format_version() { + for schema in [ + now_policy::schema::policy_schema_json(), + now_policy::schema::policy_draft_schema_json(), + ] { + let properties = schema["properties"].as_object().unwrap(); + let required = schema["required"].as_array().unwrap(); + assert!(!properties.contains_key("$schema")); + assert!(!required.iter().any(|value| value == "$schema")); + + let version_schema = &schema["definitions"]["PolicyFormatVersion"]; + assert_eq!(version_schema["type"], "string"); + assert!(version_schema["pattern"].as_str().unwrap().starts_with("^1\\.")); + } +} + #[test] fn committed_policy_enforces_revision_bounds_during_serialization_and_deserialization() { let path = samples_dir().join("corporate-allowlist.policy.json"); diff --git a/policies/rust/now-policy/tools/generate_schema.rs b/policies/rust/now-policy/tools/generate_schema.rs index a80a80f..ebd4cf6 100644 --- a/policies/rust/now-policy/tools/generate_schema.rs +++ b/policies/rust/now-policy/tools/generate_schema.rs @@ -8,39 +8,24 @@ use std::path::Path; use now_policy::schema::{policy_draft_schema_json, policy_schema_json}; -use now_policy::{POLICY_DRAFT_SCHEMA_URI, POLICY_SCHEMA_URI}; -use serde_json::{Map, Value}; +use serde_json::Value; fn main() { let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); write_schema( &crate_dir.join("schema").join("devolutions.now-policy.schema.json"), policy_schema_json(), - POLICY_SCHEMA_URI, ); write_schema( &crate_dir .join("schema") .join("devolutions.now-policy-draft.schema.json"), policy_draft_schema_json(), - POLICY_DRAFT_SCHEMA_URI, ); } -fn write_schema(path: &Path, schema: Value, id: &str) { - let json = serde_json::to_string_pretty(&with_id(schema, id)).expect("BUG: schema serialization failed"); +fn write_schema(path: &Path, schema: Value) { + let json = serde_json::to_string_pretty(&schema).expect("BUG: schema serialization failed"); std::fs::write(path, &json).unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display())); println!("Wrote {}", path.display()); } - -fn with_id(schema: Value, id: &str) -> Value { - let Value::Object(existing) = schema else { - panic!("BUG: schema root is not an object"); - }; - - let mut object = Map::new(); - object.insert("$id".to_owned(), Value::String(id.to_owned())); - object.extend(existing); - - Value::Object(object) -} 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 index 5ffc01e..0503209 100644 --- 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 @@ -10,8 +10,7 @@ "ValidatorVersion": "gateway-policy-validator/1", "IsValid": true, "CanonicalDraft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", 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 index 04fd05b..c1e94e6 100644 --- a/policies/test-data/package-broker/requests/policy-replacement.create.request.json +++ b/policies/test-data/package-broker/requests/policy-replacement.create.request.json @@ -6,8 +6,7 @@ "ConflictHandling": "Reject", "WarningsAcknowledged": false, "Draft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, 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 index af5c350..8e08b9e 100644 --- a/policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json +++ b/policies/test-data/package-broker/requests/policy-replacement.overwrite.request.json @@ -6,8 +6,7 @@ "ConflictHandling": "ConfirmOverwrite", "WarningsAcknowledged": true, "Draft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, 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 index f0729a3..9e7e6bb 100644 --- a/policies/test-data/package-broker/requests/policy-replacement.repair.request.json +++ b/policies/test-data/package-broker/requests/policy-replacement.repair.request.json @@ -6,8 +6,7 @@ "ConflictHandling": "Reject", "WarningsAcknowledged": false, "Draft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, 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 index 13668af..257cf42 100644 --- 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 @@ -6,8 +6,7 @@ "ConflictHandling": "Reject", "WarningsAcknowledged": false, "Draft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "fabrikam.package-policy", "Publisher": "Fabrikam IT" }, "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, 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 index f4b53b9..ba20df6 100644 --- a/policies/test-data/package-broker/requests/policy-replacement.update.request.json +++ b/policies/test-data/package-broker/requests/policy-replacement.update.request.json @@ -6,8 +6,7 @@ "ConflictHandling": "Reject", "WarningsAcknowledged": true, "Draft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", "Publisher": "Contoso IT" }, "Enforcement": { "DefaultDecision": "Deny", "RulePrecedence": "PriorityThenDeny" }, diff --git a/policies/test-data/package-broker/requests/policy-validation.request.json b/policies/test-data/package-broker/requests/policy-validation.request.json index 641c8d1..2179ea6 100644 --- a/policies/test-data/package-broker/requests/policy-validation.request.json +++ b/policies/test-data/package-broker/requests/policy-validation.request.json @@ -2,8 +2,7 @@ "RequestKind": "PolicyValidationRequest", "RequestVersion": "1.0", "Draft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", diff --git a/policies/test-data/package-broker/responses/execution-winget-vscode-install.response.json b/policies/test-data/package-broker/responses/execution-winget-vscode-install.response.json index feea983..cf08869 100644 --- a/policies/test-data/package-broker/responses/execution-winget-vscode-install.response.json +++ b/policies/test-data/package-broker/responses/execution-winget-vscode-install.response.json @@ -18,7 +18,7 @@ "Policy": { "Id": "contoso.desktop.standard-allowlist", "Revision": 4, - "PolicyVersion": "1.0.0" + "PolicyFormatVersion": "1.0.0" }, "Operation": { "OperationId": "op-winget-vscode-install-000001", 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 index 5a96677..4a73225 100644 --- a/policies/test-data/package-broker/responses/policy-management.active.response.json +++ b/policies/test-data/package-broker/responses/policy-management.active.response.json @@ -13,8 +13,7 @@ "WriteCapability": "Writable", "ElevationRequired": true, "Policy": { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", 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 index 023e84f..1735dd6 100644 --- a/policies/test-data/package-broker/responses/policy-management.invalid.response.json +++ b/policies/test-data/package-broker/responses/policy-management.invalid.response.json @@ -19,8 +19,8 @@ { "FindingVersion": "1.0", "Severity": "Error", - "Code": "UnsupportedPolicyVersion", - "Path": "/PolicyVersion", + "Code": "UnsupportedPolicyFormatVersion", + "Path": "/PolicyFormatVersion", "Arguments": { "actual": "2.0.0", "supported": "1.0.0" diff --git a/policies/test-data/package-broker/responses/policy-replacement.response.json b/policies/test-data/package-broker/responses/policy-replacement.response.json index e072c1e..c1d2d6d 100644 --- a/policies/test-data/package-broker/responses/policy-replacement.response.json +++ b/policies/test-data/package-broker/responses/policy-replacement.response.json @@ -6,8 +6,7 @@ "Transport": "HttpNamedPipe" }, "Policy": { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", @@ -26,8 +25,7 @@ "ValidatorVersion": "gateway-policy-validator/1", "IsValid": true, "CanonicalDraft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", @@ -50,8 +48,7 @@ "WriteCapability": "Writable", "ElevationRequired": true, "Policy": { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", 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 3d7cdc0..300b6e9 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 @@ -15,8 +15,7 @@ "WriteCapability": "Writable", "ElevationRequired": true, "Policy": { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", 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 index 3b1ec8e..5973c55 100644 --- a/policies/test-data/package-broker/responses/policy-validation.invalid.response.json +++ b/policies/test-data/package-broker/responses/policy-validation.invalid.response.json @@ -22,9 +22,8 @@ { "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." } + { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnsupportedPolicyFormatVersion", "Path": "/PolicyFormatVersion", "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 index ad39185..fd5703f 100644 --- a/policies/test-data/package-broker/responses/policy-validation.valid.response.json +++ b/policies/test-data/package-broker/responses/policy-validation.valid.response.json @@ -10,8 +10,7 @@ "ValidatorVersion": "gateway-policy-validator/1", "IsValid": true, "CanonicalDraft": { - "$schema": "https://devolutions.net/schemas/now-policy-draft.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.package-policy", diff --git a/policies/test-data/package-broker/responses/policy.response.json b/policies/test-data/package-broker/responses/policy.response.json index e1efb1d..a21f34f 100644 --- a/policies/test-data/package-broker/responses/policy.response.json +++ b/policies/test-data/package-broker/responses/policy.response.json @@ -6,8 +6,7 @@ "Transport": "HttpNamedPipe" }, "Policy": { - "$schema": "https://devolutions.net/schemas/now-policy.schema.1.0.json", - "PolicyVersion": "1.0.0", + "PolicyFormatVersion": "1.0.0", "PolicyType": "PackageBrokerPolicy", "Metadata": { "Id": "contoso.desktop.standard-allowlist", diff --git a/policies/test-data/package-broker/responses/winget-vscode-install.allowed.response.json b/policies/test-data/package-broker/responses/winget-vscode-install.allowed.response.json index ac01810..7a3f534 100644 --- a/policies/test-data/package-broker/responses/winget-vscode-install.allowed.response.json +++ b/policies/test-data/package-broker/responses/winget-vscode-install.allowed.response.json @@ -19,7 +19,7 @@ "Policy": { "Id": "contoso.desktop.standard-allowlist", "Revision": 4, - "PolicyVersion": "1.0.0" + "PolicyFormatVersion": "1.0.0" }, "Diagnostics": { "CommandPreview": [ diff --git a/policies/test-data/package-broker/responses/winget-vscode-skiphash.denied.response.json b/policies/test-data/package-broker/responses/winget-vscode-skiphash.denied.response.json index 3e17099..67dc132 100644 --- a/policies/test-data/package-broker/responses/winget-vscode-skiphash.denied.response.json +++ b/policies/test-data/package-broker/responses/winget-vscode-skiphash.denied.response.json @@ -19,7 +19,7 @@ "Policy": { "Id": "contoso.desktop.standard-allowlist", "Revision": 4, - "PolicyVersion": "1.0.0" + "PolicyFormatVersion": "1.0.0" }, "Diagnostics": {}, "Server": { From 0a54711ec74cf6a18b62fc350ab3bc93102fee63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 20:01:57 +0900 Subject: [PATCH 02/12] chore: leave package versions to release workflows Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 6 +++--- policies/rust/now-policy-api/Cargo.toml | 4 ++-- policies/rust/now-policy-server-template/Cargo.toml | 6 +++--- policies/rust/now-policy/Cargo.toml | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9868f78..70b014a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -613,7 +613,7 @@ dependencies = [ [[package]] name = "now-policy" -version = "0.4.0" +version = "0.3.0" dependencies = [ "chrono", "schemars", @@ -626,7 +626,7 @@ dependencies = [ [[package]] name = "now-policy-api" -version = "0.5.0" +version = "0.4.0" dependencies = [ "chrono", "derive_more", @@ -641,7 +641,7 @@ dependencies = [ [[package]] name = "now-policy-server-template" -version = "0.5.0" +version = "0.4.0" dependencies = [ "aide", "async-trait", diff --git a/policies/rust/now-policy-api/Cargo.toml b/policies/rust/now-policy-api/Cargo.toml index 1e4293c..19d8622 100644 --- a/policies/rust/now-policy-api/Cargo.toml +++ b/policies/rust/now-policy-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy-api" -version = "0.5.0" +version = "0.4.0" edition = "2024" license.workspace = true homepage.workspace = true @@ -16,7 +16,7 @@ workspace = true [dependencies] chrono = { version = "0.4", features = ["serde"] } derive_more = { version = "2", features = ["as_ref", "deref", "display", "from"] } -now-policy = { version = "0.4", path = "../now-policy" } +now-policy = { version = "0.3", path = "../now-policy" } schemars = { version = "0.9", features = ["chrono04"] } semver = "1" serde = { version = "1", features = ["derive"] } diff --git a/policies/rust/now-policy-server-template/Cargo.toml b/policies/rust/now-policy-server-template/Cargo.toml index 43d8ef7..eea4c86 100644 --- a/policies/rust/now-policy-server-template/Cargo.toml +++ b/policies/rust/now-policy-server-template/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy-server-template" -version = "0.5.0" +version = "0.4.0" edition = "2024" license.workspace = true homepage.workspace = true @@ -17,8 +17,8 @@ workspace = true aide = { version = "0.15", features = ["axum", "axum-json"] } async-trait = "0.1" axum = { version = "0.8", default-features = false, features = ["json"] } -now-policy-api = { version = "0.5", path = "../now-policy-api" } -now-policy = { version = "0.4", path = "../now-policy" } +now-policy-api = { version = "0.4", path = "../now-policy-api" } +now-policy = { version = "0.3", path = "../now-policy" } schemars = "0.9" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/policies/rust/now-policy/Cargo.toml b/policies/rust/now-policy/Cargo.toml index 94ef10a..54205fa 100644 --- a/policies/rust/now-policy/Cargo.toml +++ b/policies/rust/now-policy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "now-policy" -version = "0.4.0" +version = "0.3.0" edition = "2024" license.workspace = true homepage.workspace = true From d08080ee71892f94c83f1d8f4ba7bab06694666d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 20:15:55 +0900 Subject: [PATCH 03/12] fix(policy): clarify format version errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../responses/policy-management.invalid.response.json | 2 +- .../responses/policy-validation.invalid.response.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 1735dd6..e6432a6 100644 --- a/policies/test-data/package-broker/responses/policy-management.invalid.response.json +++ b/policies/test-data/package-broker/responses/policy-management.invalid.response.json @@ -25,7 +25,7 @@ "actual": "2.0.0", "supported": "1.0.0" }, - "Message": "The policy version is not supported." + "Message": "The policy format version is not supported." } ] } 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 index 5973c55..2924228 100644 --- a/policies/test-data/package-broker/responses/policy-validation.invalid.response.json +++ b/policies/test-data/package-broker/responses/policy-validation.invalid.response.json @@ -23,7 +23,7 @@ { "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": "UnsupportedPolicyType", "Path": "/PolicyType", "Message": "The policy type is not supported." }, - { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnsupportedPolicyFormatVersion", "Path": "/PolicyFormatVersion", "Message": "The policy version is not supported." } + { "FindingVersion": "1.0", "Severity": "Error", "Code": "UnsupportedPolicyFormatVersion", "Path": "/PolicyFormatVersion", "Message": "The policy format version is not supported." } ] } } From f86c7c15b6ad968a6fcc7bd795cbc30bcae6a660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 20:27:02 +0900 Subject: [PATCH 04/12] fix(policy): accept valid large format versions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + .../PolicyTests.cs | 13 +++++++- .../PolicyModels.cs | 11 +++---- .../openapi/now-policy-api.yaml | 4 +-- policies/rust/now-policy/Cargo.toml | 1 + .../devolutions.now-policy-draft.schema.json | 2 +- .../schema/devolutions.now-policy.schema.json | 2 +- policies/rust/now-policy/src/newtypes.rs | 32 ++++++++++++------- .../rust/now-policy/tests/policy_samples.rs | 10 ++++++ 9 files changed, 52 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70b014a..31a6f14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,6 +616,7 @@ name = "now-policy" version = "0.3.0" dependencies = [ "chrono", + "regex", "schemars", "semver", "serde", diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 4501360..e6a3f3d 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -263,7 +263,6 @@ public void Draft_conversion_omits_and_restores_server_metadata_without_aliasing [Theory] [InlineData("not-semver")] [InlineData("2.0.0")] - [InlineData("1.18446744073709551616.0")] [InlineData("1.2.3-١a")] [InlineData("1.0.0\n")] public void Unsupported_policy_format_versions_are_rejected(string value) @@ -275,6 +274,18 @@ public void Unsupported_policy_format_versions_are_rejected(string value) Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); } + [Fact] + public void Compatible_policy_format_version_allows_large_numeric_identifiers() + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; + document["PolicyFormatVersion"] = "1.18446744073709551616.0"; + + var policy = PolicyDocument.ParseJson(document.ToJsonString()); + + Assert.Equal("1.18446744073709551616.0", policy.PolicyFormatVersion.Value); + } + [Fact] public void Compatible_policy_format_version_is_preserved_by_conversion() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index e520a6c..a4cf03f 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -7,7 +7,7 @@ namespace Devolutions.Now.Policy.Model; public static class PolicyFormatVersions { public const string Current = "1.0.0"; - public const ulong SupportedMajor = 1; + public const string SupportedMajor = "1"; } [JsonConverter(typeof(PolicyFormatVersionJsonConverter))] @@ -35,15 +35,12 @@ public static PolicyFormatVersion Parse(string value) } var match = SemVerPattern.Match(value); - if (!match.Success - || match.Length != value.Length - || !ulong.TryParse(match.Groups["major"].Value, out var major) - || !ulong.TryParse(match.Groups["minor"].Value, out _) - || !ulong.TryParse(match.Groups["patch"].Value, out _)) + if (!match.Success || match.Length != value.Length) { throw new FormatException("PolicyFormatVersion must be a valid SemVer 2.0.0 string."); } - if (major != PolicyFormatVersions.SupportedMajor) + var major = match.Groups["major"].Value; + if (!string.Equals(major, PolicyFormatVersions.SupportedMajor, StringComparison.Ordinal)) { throw new NotSupportedException( $"Policy format major version {major} is unsupported; supported major version is {PolicyFormatVersions.SupportedMajor}."); 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 e8aed5f..47b2300 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1267,7 +1267,7 @@ components: this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(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-]+)*))?$ + pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$ PolicyManagementResponse: description: Response body for `GET /v1/policy/management`. type: object @@ -2258,7 +2258,7 @@ components: this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(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-]+)*))?$ + pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$ PolicyModelPolicyMatch: description: |- Match criteria for a policy rule. All specified fields must match. diff --git a/policies/rust/now-policy/Cargo.toml b/policies/rust/now-policy/Cargo.toml index 54205fa..2d2275a 100644 --- a/policies/rust/now-policy/Cargo.toml +++ b/policies/rust/now-policy/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] chrono = { version = "0.4", features = ["serde"] } +regex = "1" schemars = { version = "0.9", features = ["chrono04"] } semver = "1" serde = { version = "1", features = ["derive"] } diff --git a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index 4255de4..fe41a32 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -250,7 +250,7 @@ "PolicyFormatVersion": { "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose\n this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(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-]+)*))?$", + "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", "type": "string" }, "PolicyMatch": { 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 10bb974..5106ec6 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -190,7 +190,7 @@ "PolicyFormatVersion": { "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose\n this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(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-]+)*))?$", + "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", "type": "string" }, "PolicyMatch": { diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 41653ef..9bd91c0 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -1,8 +1,17 @@ //! Schema-validated newtypes used by NOW policy documents. +use std::sync::LazyLock; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +const POLICY_FORMAT_VERSION_PATTERN: &str = r"^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"; + +static POLICY_FORMAT_VERSION_REGEX: LazyLock = LazyLock::new(|| { + regex::Regex::new(&format!("{POLICY_FORMAT_VERSION_PATTERN}\\z")) + .expect("BUG: policy format version regex must compile") +}); + /// Error returned when a policy newtype fails deserialization validation. #[derive(Debug, thiserror::Error)] pub enum ModelValidationError { @@ -111,9 +120,7 @@ pub const CURRENT_POLICY_FORMAT_VERSION: &str = "1.0.0"; pub struct PolicyFormatVersion( #[schemars( length(max = 128), - regex( - pattern = r"^1\.(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-]+)*))?$" - ) + regex(pattern = POLICY_FORMAT_VERSION_PATTERN) )] String, ); @@ -128,17 +135,18 @@ impl PolicyFormatVersion { }); } - let version = semver::Version::parse(s).map_err(|error| ModelValidationError::Invalid { - type_name: "PolicyFormatVersion", - reason: error.to_string(), - })?; - if version.major != 1 { + if !POLICY_FORMAT_VERSION_REGEX.is_match(s) { + let major = s.split_once('.').map_or(s, |(major, _)| major); + if major.chars().all(|character| character.is_ascii_digit()) && major != "1" { + return Err(ModelValidationError::Invalid { + type_name: "PolicyFormatVersion", + reason: format!("unsupported major version {major}; supported major version is 1"), + }); + } + return Err(ModelValidationError::Invalid { type_name: "PolicyFormatVersion", - reason: format!( - "unsupported major version {}; supported major version is 1", - version.major - ), + reason: "must be a valid SemVer 2.0.0 string in the compatible 1.x line".to_owned(), }); } diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 4c76be1..4e1e00d 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -168,6 +168,16 @@ fn compatible_policy_format_version_is_preserved_by_conversions() { assert_eq!(recommitted.policy_format_version.to_string(), "1.2.3"); } +#[test] +fn compatible_policy_format_version_allows_large_numeric_identifiers() { + 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["PolicyFormatVersion"] = serde_json::json!("1.18446744073709551616.0"); + + let committed = serde_json::from_value::(value).unwrap(); + assert_eq!(committed.policy_format_version.to_string(), "1.18446744073709551616.0"); +} + #[test] fn invalid_policy_fixture_fails_deserialization() { let path = samples_dir().join("invalid/policies/invalid-failure-decision.policy.json"); From 18d6eca4658e07d7ce90c2c50b622e0a328afd97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 20:32:30 +0900 Subject: [PATCH 05/12] test(policy): cover malformed format versions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- policies/rust/now-policy/tests/policy_samples.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 4e1e00d..6348def 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -153,6 +153,21 @@ fn unsupported_policy_format_version_is_rejected() { ); } +#[test] +fn malformed_policy_format_versions_are_rejected() { + let path = samples_dir().join("corporate-allowlist.policy.json"); + let valid: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + + for version in ["not-semver", "1.02.3", "1.2.3-١a", "1.0.0\n"] { + let mut value = valid.clone(); + value["PolicyFormatVersion"] = serde_json::json!(version); + assert!( + serde_json::from_value::(value).is_err(), + "{version:?} should be rejected" + ); + } +} + #[test] fn compatible_policy_format_version_is_preserved_by_conversions() { let path = samples_dir().join("corporate-allowlist.policy.json"); From fd0c88c17dc36bab3ed180f72a08b38324d99fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 20:38:38 +0900 Subject: [PATCH 06/12] fix(policy): align format compatibility metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- policies/rust/now-policy-api/openapi/now-policy-api.yaml | 4 ++-- .../schema/devolutions.now-policy-draft.schema.json | 2 +- .../rust/now-policy/schema/devolutions.now-policy.schema.json | 2 +- policies/rust/now-policy/src/newtypes.rs | 2 +- policies/rust/now-policy/src/schema.rs | 4 ++-- policies/rust/now-policy/tests/policy_samples.rs | 3 ++- .../responses/policy-management.invalid.response.json | 2 +- 7 files changed, 10 insertions(+), 9 deletions(-) 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 47b2300..476204e 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1263,7 +1263,7 @@ components: Software-managed policy document format version. Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications - must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose + must stamp the current value, `1.0.0`, for new documents and must not expose this value as publisher-authored editable metadata. type: string maxLength: 128 @@ -2254,7 +2254,7 @@ components: Software-managed policy document format version. Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications - must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose + must stamp the current value, `1.0.0`, for new documents and must not expose this value as publisher-authored editable metadata. type: string maxLength: 128 diff --git a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index fe41a32..4b54ccf 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -248,7 +248,7 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose\n this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", "maxLength": 128, "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", "type": "string" 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 5106ec6..1ee5e76 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -188,7 +188,7 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose\n this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", "maxLength": 128, "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", "type": "string" diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 9bd91c0..da11bb4 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -114,7 +114,7 @@ pub const CURRENT_POLICY_FORMAT_VERSION: &str = "1.0.0"; /// Software-managed policy document format version. /// /// Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications -/// must stamp [`CURRENT_POLICY_FORMAT_VERSION`] for new documents and must not expose +/// must stamp the current value, `1.0.0`, for new documents and must not expose /// this value as publisher-authored editable metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] pub struct PolicyFormatVersion( diff --git a/policies/rust/now-policy/src/schema.rs b/policies/rust/now-policy/src/schema.rs index 1a784cb..f6373ee 100644 --- a/policies/rust/now-policy/src/schema.rs +++ b/policies/rust/now-policy/src/schema.rs @@ -4,7 +4,7 @@ use schemars::generate::SchemaSettings; use crate::{PolicyDocument, PolicyDraftDocument}; -/// Get the generated policy schema as a JSON value. +/// Get the generated repository-local policy schema as a JSON value. pub fn policy_schema_json() -> serde_json::Value { let schema = SchemaSettings::draft07() .into_generator() @@ -12,7 +12,7 @@ pub fn policy_schema_json() -> serde_json::Value { serde_json::to_value(&schema).expect("BUG: schema serialization failed") } -/// Get the generated editable policy draft schema as a JSON value. +/// Get the generated repository-local editable policy draft schema as a JSON value. pub fn policy_draft_schema_json() -> serde_json::Value { let schema = SchemaSettings::draft07() .into_generator() diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 6348def..466e6e0 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -213,13 +213,14 @@ fn policy_schema_generates_valid_json() { } #[test] -fn policy_schemas_omit_document_schema_and_fix_policy_format_version() { +fn policy_schemas_are_repository_local_and_omit_document_schema() { for schema in [ now_policy::schema::policy_schema_json(), now_policy::schema::policy_draft_schema_json(), ] { let properties = schema["properties"].as_object().unwrap(); let required = schema["required"].as_array().unwrap(); + assert!(schema.get("$id").is_none()); assert!(!properties.contains_key("$schema")); assert!(!required.iter().any(|value| value == "$schema")); 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 index e6432a6..dd17ffa 100644 --- a/policies/test-data/package-broker/responses/policy-management.invalid.response.json +++ b/policies/test-data/package-broker/responses/policy-management.invalid.response.json @@ -23,7 +23,7 @@ "Path": "/PolicyFormatVersion", "Arguments": { "actual": "2.0.0", - "supported": "1.0.0" + "supported": "1.x" }, "Message": "The policy format version is not supported." } From beb6ef426539b37ba0a34b28870e0e71f0d4ce91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 20:45:10 +0900 Subject: [PATCH 07/12] refactor(policy): use bounded semver parsing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 - .../Devolutions.Now.Policy.Api/README.md | 2 +- .../PolicyTests.cs | 13 +------ .../PolicyModels.cs | 11 +++--- .../Devolutions.Now.Policy.Model/README.md | 2 +- .../openapi/now-policy-api.yaml | 4 +-- policies/rust/now-policy/Cargo.toml | 1 - policies/rust/now-policy/README.md | 2 +- .../devolutions.now-policy-draft.schema.json | 2 +- .../schema/devolutions.now-policy.schema.json | 2 +- policies/rust/now-policy/src/newtypes.rs | 34 +++++++------------ .../rust/now-policy/tests/policy_samples.rs | 5 ++- 12 files changed, 30 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31a6f14..70b014a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,7 +616,6 @@ name = "now-policy" version = "0.3.0" dependencies = [ "chrono", - "regex", "schemars", "semver", "serde", diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 8eb406c..8c290ae 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -21,7 +21,7 @@ The DTOs are used to: - share the same JSON wire shape as the Rust source-of-truth model; - provide compatibility conversions between package broker API enums and the `Devolutions.Now.Policy.Model` policy enums. -Embedded policy documents and response policy projections use the software-managed `PolicyFormatVersion` field. New values are stamped as `1.0.0`; compatible SemVer values in the 1.x line are accepted and preserved. +Embedded policy documents and response policy projections use the software-managed `PolicyFormatVersion` field. New values are stamped as `1.0.0`; supported SemVer values in the 1.x line are accepted and preserved. Architecture ------------ diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index e6a3f3d..4501360 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -263,6 +263,7 @@ public void Draft_conversion_omits_and_restores_server_metadata_without_aliasing [Theory] [InlineData("not-semver")] [InlineData("2.0.0")] + [InlineData("1.18446744073709551616.0")] [InlineData("1.2.3-١a")] [InlineData("1.0.0\n")] public void Unsupported_policy_format_versions_are_rejected(string value) @@ -274,18 +275,6 @@ public void Unsupported_policy_format_versions_are_rejected(string value) Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); } - [Fact] - public void Compatible_policy_format_version_allows_large_numeric_identifiers() - { - var document = JsonNode.Parse( - File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; - document["PolicyFormatVersion"] = "1.18446744073709551616.0"; - - var policy = PolicyDocument.ParseJson(document.ToJsonString()); - - Assert.Equal("1.18446744073709551616.0", policy.PolicyFormatVersion.Value); - } - [Fact] public void Compatible_policy_format_version_is_preserved_by_conversion() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index a4cf03f..e520a6c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -7,7 +7,7 @@ namespace Devolutions.Now.Policy.Model; public static class PolicyFormatVersions { public const string Current = "1.0.0"; - public const string SupportedMajor = "1"; + public const ulong SupportedMajor = 1; } [JsonConverter(typeof(PolicyFormatVersionJsonConverter))] @@ -35,12 +35,15 @@ public static PolicyFormatVersion Parse(string value) } var match = SemVerPattern.Match(value); - if (!match.Success || match.Length != value.Length) + if (!match.Success + || match.Length != value.Length + || !ulong.TryParse(match.Groups["major"].Value, out var major) + || !ulong.TryParse(match.Groups["minor"].Value, out _) + || !ulong.TryParse(match.Groups["patch"].Value, out _)) { throw new FormatException("PolicyFormatVersion must be a valid SemVer 2.0.0 string."); } - var major = match.Groups["major"].Value; - if (!string.Equals(major, PolicyFormatVersions.SupportedMajor, StringComparison.Ordinal)) + if (major != PolicyFormatVersions.SupportedMajor) { throw new NotSupportedException( $"Policy format major version {major} is unsupported; supported major version is {PolicyFormatVersions.SupportedMajor}."); diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index 5a4487d..542d0a3 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -25,7 +25,7 @@ Architecture `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. -`PolicyFormatVersion` is software-managed format compatibility metadata, not a publisher release version. New documents stamp `1.0.0`; readers accept and preserve compatible SemVer values in the 1.x line and reject malformed or unsupported-major values. Applications must not expose it as authored metadata. Policy documents contain no `$schema` member, and strict readers reject documents that contain one as unknown-field input. +`PolicyFormatVersion` is software-managed format compatibility metadata, not a publisher release version. New documents stamp `1.0.0`; readers accept and preserve supported SemVer values in the 1.x line and reject malformed or unsupported-major values. Applications must not expose it as authored metadata. Policy documents contain no `$schema` member, and strict readers reject documents that contain one as unknown-field input. Breaking change --------------- 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 476204e..b01e56f 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1262,7 +1262,7 @@ components: description: |- Software-managed policy document format version. - Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications + Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, `1.0.0`, for new documents and must not expose this value as publisher-authored editable metadata. type: string @@ -2253,7 +2253,7 @@ components: description: |- Software-managed policy document format version. - Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications + Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, `1.0.0`, for new documents and must not expose this value as publisher-authored editable metadata. type: string diff --git a/policies/rust/now-policy/Cargo.toml b/policies/rust/now-policy/Cargo.toml index 2d2275a..54205fa 100644 --- a/policies/rust/now-policy/Cargo.toml +++ b/policies/rust/now-policy/Cargo.toml @@ -15,7 +15,6 @@ workspace = true [dependencies] chrono = { version = "0.4", features = ["serde"] } -regex = "1" schemars = { version = "0.9", features = ["chrono04"] } semver = "1" serde = { version = "1", features = ["derive"] } diff --git a/policies/rust/now-policy/README.md b/policies/rust/now-policy/README.md index 9070b04..c03f002 100644 --- a/policies/rust/now-policy/README.md +++ b/policies/rust/now-policy/README.md @@ -6,6 +6,6 @@ This crate provides the JSON-only Rust policy model and JSON Schema helpers for 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. -`PolicyFormatVersion` is a software-managed document-format marker, not a publisher release version. Applications stamp the current `1.0.0` value when creating documents, accept and preserve compatible SemVer values in the 1.x line, reject malformed or unsupported-major values, and must not expose it as publisher-authored editable metadata. Policy documents do not contain a `$schema` member; documents that contain it are rejected as unknown-field input under the strict contract. +`PolicyFormatVersion` is a software-managed document-format marker, not a publisher release version. Applications stamp the current `1.0.0` value when creating documents, accept and preserve supported SemVer values in the 1.x line, reject malformed or unsupported-major values, and must not expose it as publisher-authored editable metadata. Policy documents do not contain a `$schema` member; documents that contain it are rejected as unknown-field input under the strict contract. `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/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index 4b54ccf..da186d4 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -248,7 +248,7 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", "maxLength": 128, "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", "type": "string" 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 1ee5e76..1b080e1 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -188,7 +188,7 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", "maxLength": 128, "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", "type": "string" diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index da11bb4..442b8d5 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -1,17 +1,8 @@ //! Schema-validated newtypes used by NOW policy documents. -use std::sync::LazyLock; - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -const POLICY_FORMAT_VERSION_PATTERN: &str = r"^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"; - -static POLICY_FORMAT_VERSION_REGEX: LazyLock = LazyLock::new(|| { - regex::Regex::new(&format!("{POLICY_FORMAT_VERSION_PATTERN}\\z")) - .expect("BUG: policy format version regex must compile") -}); - /// Error returned when a policy newtype fails deserialization validation. #[derive(Debug, thiserror::Error)] pub enum ModelValidationError { @@ -113,14 +104,16 @@ pub const CURRENT_POLICY_FORMAT_VERSION: &str = "1.0.0"; /// Software-managed policy document format version. /// -/// Readers accept SemVer 2.0.0 values in the compatible 1.x line. Applications +/// Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications /// must stamp the current value, `1.0.0`, for new documents and must not expose /// this value as publisher-authored editable metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] pub struct PolicyFormatVersion( #[schemars( length(max = 128), - regex(pattern = POLICY_FORMAT_VERSION_PATTERN) + regex( + pattern = r"^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" + ) )] String, ); @@ -135,18 +128,17 @@ impl PolicyFormatVersion { }); } - if !POLICY_FORMAT_VERSION_REGEX.is_match(s) { - let major = s.split_once('.').map_or(s, |(major, _)| major); - if major.chars().all(|character| character.is_ascii_digit()) && major != "1" { - return Err(ModelValidationError::Invalid { - type_name: "PolicyFormatVersion", - reason: format!("unsupported major version {major}; supported major version is 1"), - }); - } - + let version = semver::Version::parse(s).map_err(|error| ModelValidationError::Invalid { + type_name: "PolicyFormatVersion", + reason: error.to_string(), + })?; + if version.major != 1 { return Err(ModelValidationError::Invalid { type_name: "PolicyFormatVersion", - reason: "must be a valid SemVer 2.0.0 string in the compatible 1.x line".to_owned(), + reason: format!( + "unsupported major version {}; supported major version is 1", + version.major + ), }); } diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 466e6e0..9b89da7 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -184,13 +184,12 @@ fn compatible_policy_format_version_is_preserved_by_conversions() { } #[test] -fn compatible_policy_format_version_allows_large_numeric_identifiers() { +fn policy_format_version_rejects_numeric_identifiers_outside_supported_range() { 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["PolicyFormatVersion"] = serde_json::json!("1.18446744073709551616.0"); - let committed = serde_json::from_value::(value).unwrap(); - assert_eq!(committed.policy_format_version.to_string(), "1.18446744073709551616.0"); + assert!(serde_json::from_value::(value).is_err()); } #[test] From 0b39e64ea990485e0bf3f8e393b4ff1b3de7f7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 20:51:51 +0900 Subject: [PATCH 08/12] fix(policy): align schema version bounds Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PolicyTests.cs | 13 ++++++++ .../openapi/now-policy-api.yaml | 18 +++------- .../devolutions.now-policy-draft.schema.json | 4 +-- .../schema/devolutions.now-policy.schema.json | 4 +-- policies/rust/now-policy/src/newtypes.rs | 33 ++++++++++++------- 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 4501360..e2bb36c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -275,6 +275,19 @@ public void Unsupported_policy_format_versions_are_rejected(string value) Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); } + [Theory] + [InlineData("1.18446744073709551616.0")] + [InlineData("1.0.0\n")] + public async Task Rust_schema_rejects_policy_format_versions_outside_runtime_contract(string value) + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; + document["PolicyFormatVersion"] = value; + var schema = await JsonSchema.FromFileAsync(PolicySchema); + + Assert.NotEmpty(schema.Validate(document.ToJsonString())); + } + [Fact] public void Compatible_policy_format_version_is_preserved_by_conversion() { 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 b01e56f..b1ad72d 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1259,15 +1259,10 @@ components: - Error - Warning PolicyFormatVersion: - description: |- - Software-managed policy document format version. - - Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications - must stamp the current value, `1.0.0`, for new documents and must not expose - this value as publisher-authored editable metadata. + description: Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$ + pattern: ^1\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?![\s\S]) PolicyManagementResponse: description: Response body for `GET /v1/policy/management`. type: object @@ -2250,15 +2245,10 @@ components: - DefaultDecision - RulePrecedence PolicyModelPolicyFormatVersion: - description: |- - Software-managed policy document format version. - - Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications - must stamp the current value, `1.0.0`, for new documents and must not expose - this value as publisher-authored editable metadata. + description: Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$ + pattern: ^1\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?![\s\S]) PolicyModelPolicyMatch: description: |- Match criteria for a policy rule. All specified fields must match. diff --git a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index da186d4..cb675ee 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -248,9 +248,9 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", + "pattern": "^1\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?![\\s\\S])", "type": "string" }, "PolicyMatch": { 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 1b080e1..3862024 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -188,9 +188,9 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", + "pattern": "^1\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?![\\s\\S])", "type": "string" }, "PolicyMatch": { diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 442b8d5..ec4b89a 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -1,6 +1,6 @@ //! Schema-validated newtypes used by NOW policy documents. -use schemars::JsonSchema; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; /// Error returned when a policy newtype fails deserialization validation. @@ -107,16 +107,27 @@ pub const CURRENT_POLICY_FORMAT_VERSION: &str = "1.0.0"; /// Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications /// must stamp the current value, `1.0.0`, for new documents and must not expose /// this value as publisher-authored editable metadata. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -pub struct PolicyFormatVersion( - #[schemars( - length(max = 128), - regex( - pattern = r"^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" - ) - )] - String, -); +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PolicyFormatVersion(String); + +impl JsonSchema for PolicyFormatVersion { + fn schema_name() -> std::borrow::Cow<'static, str> { + "PolicyFormatVersion".into() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + const U64_COMPONENT: &str = r"(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])"; + const SUFFIX: &str = r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"; + let pattern = format!(r"^1\.{U64_COMPONENT}\.{U64_COMPONENT}{SUFFIX}(?![\s\S])"); + + json_schema!({ + "description": "Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata.", + "type": "string", + "maxLength": 128, + "pattern": pattern, + }) + } +} impl PolicyFormatVersion { /// Parse a supported policy document format version. From 27d609308b50a1fd3f819fd529c54a99e36c2db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 21:25:54 +0900 Subject: [PATCH 09/12] refactor(policy): simplify format version schema Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PolicyTests.cs | 13 -------- .../openapi/now-policy-api.yaml | 18 ++++++++--- .../devolutions.now-policy-draft.schema.json | 4 +-- .../schema/devolutions.now-policy.schema.json | 4 +-- policies/rust/now-policy/src/newtypes.rs | 31 ++++++------------- 5 files changed, 27 insertions(+), 43 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index e2bb36c..4501360 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -275,19 +275,6 @@ public void Unsupported_policy_format_versions_are_rejected(string value) Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); } - [Theory] - [InlineData("1.18446744073709551616.0")] - [InlineData("1.0.0\n")] - public async Task Rust_schema_rejects_policy_format_versions_outside_runtime_contract(string value) - { - var document = JsonNode.Parse( - File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; - document["PolicyFormatVersion"] = value; - var schema = await JsonSchema.FromFileAsync(PolicySchema); - - Assert.NotEmpty(schema.Validate(document.ToJsonString())); - } - [Fact] public void Compatible_policy_format_version_is_preserved_by_conversion() { 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 b1ad72d..0fdb484 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1259,10 +1259,15 @@ components: - Error - Warning PolicyFormatVersion: - description: Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata. + description: |- + Software-managed policy document format version. + + Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications + must stamp the current value, `1.0.0`, for new documents and must not expose + this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?![\s\S]) + pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$ PolicyManagementResponse: description: Response body for `GET /v1/policy/management`. type: object @@ -2245,10 +2250,15 @@ components: - DefaultDecision - RulePrecedence PolicyModelPolicyFormatVersion: - description: Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata. + description: |- + Software-managed policy document format version. + + Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications + must stamp the current value, `1.0.0`, for new documents and must not expose + this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?![\s\S]) + pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$ PolicyModelPolicyMatch: description: |- Match criteria for a policy rule. All specified fields must match. diff --git a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index cb675ee..3234cf8 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -248,9 +248,9 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?![\\s\\S])", + "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$", "type": "string" }, "PolicyMatch": { 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 3862024..1e8c4db 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -188,9 +188,9 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])\\.(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?![\\s\\S])", + "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$", "type": "string" }, "PolicyMatch": { diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index ec4b89a..3396e13 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -1,6 +1,6 @@ //! Schema-validated newtypes used by NOW policy documents. -use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Error returned when a policy newtype fails deserialization validation. @@ -107,27 +107,14 @@ pub const CURRENT_POLICY_FORMAT_VERSION: &str = "1.0.0"; /// Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications /// must stamp the current value, `1.0.0`, for new documents and must not expose /// this value as publisher-authored editable metadata. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct PolicyFormatVersion(String); - -impl JsonSchema for PolicyFormatVersion { - fn schema_name() -> std::borrow::Cow<'static, str> { - "PolicyFormatVersion".into() - } - - fn json_schema(_generator: &mut SchemaGenerator) -> Schema { - const U64_COMPONENT: &str = r"(?:0|[1-9][0-9]{0,18}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|18446744073709550[0-9]{3}|18446744073709551[0-5][0-9]{2}|1844674407370955160[0-9]|1844674407370955161[0-5])"; - const SUFFIX: &str = r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"; - let pattern = format!(r"^1\.{U64_COMPONENT}\.{U64_COMPONENT}{SUFFIX}(?![\s\S])"); - - json_schema!({ - "description": "Software-managed policy document format version. Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications must stamp the current value, 1.0.0, for new documents and must not expose this value as publisher-authored editable metadata.", - "type": "string", - "maxLength": 128, - "pattern": pattern, - }) - } -} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct PolicyFormatVersion( + #[schemars( + length(max = 128), + regex(pattern = r"^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$") + )] + String, +); impl PolicyFormatVersion { /// Parse a supported policy document format version. From 4f73ca17f5f02fb8bf1a1ca0ca1922337e34de41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 21:34:13 +0900 Subject: [PATCH 10/12] refactor(policy): simplify format version contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Api/README.md | 2 +- .../PolicyTests.cs | 17 +++++- .../PolicyModels.cs | 31 ++++++----- .../Devolutions.Now.Policy.Model/README.md | 2 +- .../openapi/now-policy-api.yaml | 16 +++--- policies/rust/now-policy/README.md | 2 +- .../devolutions.now-policy-draft.schema.json | 4 +- .../schema/devolutions.now-policy.schema.json | 4 +- policies/rust/now-policy/src/newtypes.rs | 52 ++++++++++++++----- .../rust/now-policy/tests/policy_samples.rs | 2 +- 10 files changed, 90 insertions(+), 42 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md index 8c290ae..9dc5edd 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md @@ -21,7 +21,7 @@ The DTOs are used to: - share the same JSON wire shape as the Rust source-of-truth model; - provide compatibility conversions between package broker API enums and the `Devolutions.Now.Policy.Model` policy enums. -Embedded policy documents and response policy projections use the software-managed `PolicyFormatVersion` field. New values are stamped as `1.0.0`; supported SemVer values in the 1.x line are accepted and preserved. +Embedded policy documents and response policy projections use the software-managed `PolicyFormatVersion` field. New values are stamped as `1.0.0`; supported numeric versions in the 1.x line are accepted and preserved. Architecture ------------ diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 4501360..88b88d1 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -264,7 +264,7 @@ public void Draft_conversion_omits_and_restores_server_metadata_without_aliasing [InlineData("not-semver")] [InlineData("2.0.0")] [InlineData("1.18446744073709551616.0")] - [InlineData("1.2.3-١a")] + [InlineData("1.2.3-beta")] [InlineData("1.0.0\n")] public void Unsupported_policy_format_versions_are_rejected(string value) { @@ -275,6 +275,21 @@ public void Unsupported_policy_format_versions_are_rejected(string value) Assert.Throws(() => PolicyDocument.ParseJson(document.ToJsonString())); } + [Theory] + [InlineData("1.18446744073709551616.0")] + [InlineData("1.0.0-01")] + [InlineData("1.0.0-.")] + [InlineData("1.0.0\n")] + public async Task Rust_schema_rejects_unsupported_policy_format_versions(string value) + { + var document = JsonNode.Parse( + File.ReadAllText(Path.Combine(SamplesDir, "corporate-allowlist.policy.json")))!; + document["PolicyFormatVersion"] = value; + var schema = await JsonSchema.FromFileAsync(PolicySchema); + + Assert.NotEmpty(schema.Validate(document.ToJsonString())); + } + [Fact] public void Compatible_policy_format_version_is_preserved_by_conversion() { diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index e520a6c..7e9730c 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -1,6 +1,5 @@ using System.Text.Json; using System.Text.Json.Serialization; -using System.Text.RegularExpressions; namespace Devolutions.Now.Policy.Model; @@ -13,10 +12,6 @@ public static class PolicyFormatVersions [JsonConverter(typeof(PolicyFormatVersionJsonConverter))] public sealed class PolicyFormatVersion : IEquatable { - private static readonly Regex SemVerPattern = new( - @"^(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$", - RegexOptions.CultureInvariant); - private PolicyFormatVersion(string value) { Value = value; @@ -34,14 +29,14 @@ public static PolicyFormatVersion Parse(string value) throw new FormatException("PolicyFormatVersion must contain at most 128 characters."); } - var match = SemVerPattern.Match(value); - if (!match.Success - || match.Length != value.Length - || !ulong.TryParse(match.Groups["major"].Value, out var major) - || !ulong.TryParse(match.Groups["minor"].Value, out _) - || !ulong.TryParse(match.Groups["patch"].Value, out _)) + var components = value.Split('.'); + if (components.Length != 3 + || !TryParseComponent(components[0], out var major) + || !TryParseComponent(components[1], out _) + || !TryParseComponent(components[2], out _)) { - throw new FormatException("PolicyFormatVersion must be a valid SemVer 2.0.0 string."); + throw new FormatException( + "PolicyFormatVersion must contain three canonical unsigned integer components of at most 18 digits."); } if (major != PolicyFormatVersions.SupportedMajor) { @@ -52,6 +47,18 @@ public static PolicyFormatVersion Parse(string value) return new PolicyFormatVersion(value); } + private static bool TryParseComponent(string component, out ulong value) + { + value = 0; + return component.Length is >= 1 and <= 18 + && (component.Length == 1 || component[0] != '0') + && ulong.TryParse( + component, + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out value); + } + public bool Equals(PolicyFormatVersion? other) => other is not null && string.Equals(Value, other.Value, StringComparison.Ordinal); diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/README.md b/policies/dotnet/Devolutions.Now.Policy.Model/README.md index 542d0a3..2e552dd 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/README.md +++ b/policies/dotnet/Devolutions.Now.Policy.Model/README.md @@ -25,7 +25,7 @@ Architecture `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. -`PolicyFormatVersion` is software-managed format compatibility metadata, not a publisher release version. New documents stamp `1.0.0`; readers accept and preserve supported SemVer values in the 1.x line and reject malformed or unsupported-major values. Applications must not expose it as authored metadata. Policy documents contain no `$schema` member, and strict readers reject documents that contain one as unknown-field input. +`PolicyFormatVersion` is software-managed format compatibility metadata, not a publisher release version. New documents stamp `1.0.0`; readers accept and preserve supported numeric versions in the 1.x line and reject malformed or unsupported-major values. Applications must not expose it as authored metadata. Policy documents contain no `$schema` member, and strict readers reject documents that contain one as unknown-field input. Breaking change --------------- 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 0fdb484..80cb7a6 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1262,12 +1262,12 @@ components: description: |- Software-managed policy document format version. - Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications - must stamp the current value, `1.0.0`, for new documents and must not expose - this value as publisher-authored editable metadata. + Readers accept canonical numeric versions in the compatible 1.x line. + Applications must stamp the current value, `1.0.0`, for new documents and + must not expose this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$ + pattern: ^1\.(0|[1-9][0-9]{0,17})\.(0|[1-9][0-9]{0,17})(?![\s\S]) PolicyManagementResponse: description: Response body for `GET /v1/policy/management`. type: object @@ -2253,12 +2253,12 @@ components: description: |- Software-managed policy document format version. - Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications - must stamp the current value, `1.0.0`, for new documents and must not expose - this value as publisher-authored editable metadata. + Readers accept canonical numeric versions in the compatible 1.x line. + Applications must stamp the current value, `1.0.0`, for new documents and + must not expose this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$ + pattern: ^1\.(0|[1-9][0-9]{0,17})\.(0|[1-9][0-9]{0,17})(?![\s\S]) PolicyModelPolicyMatch: description: |- Match criteria for a policy rule. All specified fields must match. diff --git a/policies/rust/now-policy/README.md b/policies/rust/now-policy/README.md index c03f002..ee3ecdb 100644 --- a/policies/rust/now-policy/README.md +++ b/policies/rust/now-policy/README.md @@ -6,6 +6,6 @@ This crate provides the JSON-only Rust policy model and JSON Schema helpers for 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. -`PolicyFormatVersion` is a software-managed document-format marker, not a publisher release version. Applications stamp the current `1.0.0` value when creating documents, accept and preserve supported SemVer values in the 1.x line, reject malformed or unsupported-major values, and must not expose it as publisher-authored editable metadata. Policy documents do not contain a `$schema` member; documents that contain it are rejected as unknown-field input under the strict contract. +`PolicyFormatVersion` is a software-managed document-format marker, not a publisher release version. Applications stamp the current `1.0.0` value when creating documents, accept and preserve supported numeric versions in the 1.x line, reject malformed or unsupported-major values, and must not expose it as publisher-authored editable metadata. Policy documents do not contain a `$schema` member; documents that contain it are rejected as unknown-field input under the strict contract. `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/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index 3234cf8..0d3d22c 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -248,9 +248,9 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept canonical numeric versions in the compatible 1.x line.\n Applications must stamp the current value, `1.0.0`, for new documents and\n must not expose this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$", + "pattern": "^1\\.(0|[1-9][0-9]{0,17})\\.(0|[1-9][0-9]{0,17})(?![\\s\\S])", "type": "string" }, "PolicyMatch": { 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 1e8c4db..49fa1b8 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -188,9 +188,9 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications\n must stamp the current value, `1.0.0`, for new documents and must not expose\n this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept canonical numeric versions in the compatible 1.x line.\n Applications must stamp the current value, `1.0.0`, for new documents and\n must not expose this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$", + "pattern": "^1\\.(0|[1-9][0-9]{0,17})\\.(0|[1-9][0-9]{0,17})(?![\\s\\S])", "type": "string" }, "PolicyMatch": { diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 3396e13..a8d0445 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -104,14 +104,14 @@ pub const CURRENT_POLICY_FORMAT_VERSION: &str = "1.0.0"; /// Software-managed policy document format version. /// -/// Readers accept supported SemVer 2.0.0 values in the compatible 1.x line. Applications -/// must stamp the current value, `1.0.0`, for new documents and must not expose -/// this value as publisher-authored editable metadata. +/// Readers accept canonical numeric versions in the compatible 1.x line. +/// Applications must stamp the current value, `1.0.0`, for new documents and +/// must not expose this value as publisher-authored editable metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] pub struct PolicyFormatVersion( #[schemars( length(max = 128), - regex(pattern = r"^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$") + regex(pattern = r"^1\.(0|[1-9][0-9]{0,17})\.(0|[1-9][0-9]{0,17})(?![\s\S])") )] String, ); @@ -126,17 +126,43 @@ impl PolicyFormatVersion { }); } - let version = semver::Version::parse(s).map_err(|error| ModelValidationError::Invalid { - type_name: "PolicyFormatVersion", - reason: error.to_string(), - })?; - if version.major != 1 { + let mut components = s.split('.'); + let (Some(major), Some(minor), Some(patch), None) = ( + components.next(), + components.next(), + components.next(), + components.next(), + ) else { + return Err(ModelValidationError::Invalid { + type_name: "PolicyFormatVersion", + reason: "must contain exactly three numeric components".to_owned(), + }); + }; + + let parse_component = |component: &str| { + if component.is_empty() + || component.len() > 18 + || (component.len() > 1 && component.starts_with('0')) + || !component.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + component.parse::().ok() + }; + + let (Some(major), Some(_minor), Some(_patch)) = + (parse_component(major), parse_component(minor), parse_component(patch)) + else { + return Err(ModelValidationError::Invalid { + type_name: "PolicyFormatVersion", + reason: "components must be canonical unsigned integers of at most 18 digits".to_owned(), + }); + }; + + if major != 1 { return Err(ModelValidationError::Invalid { type_name: "PolicyFormatVersion", - reason: format!( - "unsupported major version {}; supported major version is 1", - version.major - ), + reason: format!("unsupported major version {}; supported major version is 1", major), }); } diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 9b89da7..20c5a97 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -158,7 +158,7 @@ fn malformed_policy_format_versions_are_rejected() { let path = samples_dir().join("corporate-allowlist.policy.json"); let valid: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - for version in ["not-semver", "1.02.3", "1.2.3-١a", "1.0.0\n"] { + for version in ["not-semver", "1.02.3", "1.2.3-beta", "1.0.0\n"] { let mut value = valid.clone(); value["PolicyFormatVersion"] = serde_json::json!(version); assert!( From ac14caa6d881fc08776b4b64e428957599e681ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 21:36:59 +0900 Subject: [PATCH 11/12] refactor(policy): bound format version components Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Model.Tests/PolicyTests.cs | 4 ++-- .../Devolutions.Now.Policy.Model/PolicyModels.cs | 11 ++++++----- .../rust/now-policy-api/openapi/now-policy-api.yaml | 4 ++-- .../schema/devolutions.now-policy-draft.schema.json | 2 +- .../schema/devolutions.now-policy.schema.json | 2 +- policies/rust/now-policy/src/newtypes.rs | 7 +++---- policies/rust/now-policy/tests/policy_samples.rs | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 88b88d1..1f62078 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -263,7 +263,7 @@ public void Draft_conversion_omits_and_restores_server_metadata_without_aliasing [Theory] [InlineData("not-semver")] [InlineData("2.0.0")] - [InlineData("1.18446744073709551616.0")] + [InlineData("1.101.0")] [InlineData("1.2.3-beta")] [InlineData("1.0.0\n")] public void Unsupported_policy_format_versions_are_rejected(string value) @@ -276,7 +276,7 @@ public void Unsupported_policy_format_versions_are_rejected(string value) } [Theory] - [InlineData("1.18446744073709551616.0")] + [InlineData("1.101.0")] [InlineData("1.0.0-01")] [InlineData("1.0.0-.")] [InlineData("1.0.0\n")] diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index 7e9730c..2fcdca1 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -36,7 +36,7 @@ public static PolicyFormatVersion Parse(string value) || !TryParseComponent(components[2], out _)) { throw new FormatException( - "PolicyFormatVersion must contain three canonical unsigned integer components of at most 18 digits."); + "PolicyFormatVersion must contain three canonical unsigned integer components between 0 and 100."); } if (major != PolicyFormatVersions.SupportedMajor) { @@ -47,16 +47,17 @@ public static PolicyFormatVersion Parse(string value) return new PolicyFormatVersion(value); } - private static bool TryParseComponent(string component, out ulong value) + private static bool TryParseComponent(string component, out byte value) { value = 0; - return component.Length is >= 1 and <= 18 + return component.Length is >= 1 and <= 3 && (component.Length == 1 || component[0] != '0') - && ulong.TryParse( + && byte.TryParse( component, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, - out value); + out value) + && value <= 100; } public bool Equals(PolicyFormatVersion? other) => 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 80cb7a6..14a2a4a 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1267,7 +1267,7 @@ components: must not expose this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(0|[1-9][0-9]{0,17})\.(0|[1-9][0-9]{0,17})(?![\s\S]) + pattern: ^1\.(0|[1-9][0-9]?|100)\.(0|[1-9][0-9]?|100)(?![\s\S]) PolicyManagementResponse: description: Response body for `GET /v1/policy/management`. type: object @@ -2258,7 +2258,7 @@ components: must not expose this value as publisher-authored editable metadata. type: string maxLength: 128 - pattern: ^1\.(0|[1-9][0-9]{0,17})\.(0|[1-9][0-9]{0,17})(?![\s\S]) + pattern: ^1\.(0|[1-9][0-9]?|100)\.(0|[1-9][0-9]?|100)(?![\s\S]) PolicyModelPolicyMatch: description: |- Match criteria for a policy rule. All specified fields must match. diff --git a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index 0d3d22c..3c3da31 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -250,7 +250,7 @@ "PolicyFormatVersion": { "description": "Software-managed policy document format version.\n\n Readers accept canonical numeric versions in the compatible 1.x line.\n Applications must stamp the current value, `1.0.0`, for new documents and\n must not expose this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(0|[1-9][0-9]{0,17})\\.(0|[1-9][0-9]{0,17})(?![\\s\\S])", + "pattern": "^1\\.(0|[1-9][0-9]?|100)\\.(0|[1-9][0-9]?|100)(?![\\s\\S])", "type": "string" }, "PolicyMatch": { 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 49fa1b8..b169a3d 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -190,7 +190,7 @@ "PolicyFormatVersion": { "description": "Software-managed policy document format version.\n\n Readers accept canonical numeric versions in the compatible 1.x line.\n Applications must stamp the current value, `1.0.0`, for new documents and\n must not expose this value as publisher-authored editable metadata.", "maxLength": 128, - "pattern": "^1\\.(0|[1-9][0-9]{0,17})\\.(0|[1-9][0-9]{0,17})(?![\\s\\S])", + "pattern": "^1\\.(0|[1-9][0-9]?|100)\\.(0|[1-9][0-9]?|100)(?![\\s\\S])", "type": "string" }, "PolicyMatch": { diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index a8d0445..9becae6 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -111,7 +111,7 @@ pub const CURRENT_POLICY_FORMAT_VERSION: &str = "1.0.0"; pub struct PolicyFormatVersion( #[schemars( length(max = 128), - regex(pattern = r"^1\.(0|[1-9][0-9]{0,17})\.(0|[1-9][0-9]{0,17})(?![\s\S])") + regex(pattern = r"^1\.(0|[1-9][0-9]?|100)\.(0|[1-9][0-9]?|100)(?![\s\S])") )] String, ); @@ -141,13 +141,12 @@ impl PolicyFormatVersion { let parse_component = |component: &str| { if component.is_empty() - || component.len() > 18 || (component.len() > 1 && component.starts_with('0')) || !component.bytes().all(|byte| byte.is_ascii_digit()) { return None; } - component.parse::().ok() + component.parse::().ok().filter(|value| *value <= 100) }; let (Some(major), Some(_minor), Some(_patch)) = @@ -155,7 +154,7 @@ impl PolicyFormatVersion { else { return Err(ModelValidationError::Invalid { type_name: "PolicyFormatVersion", - reason: "components must be canonical unsigned integers of at most 18 digits".to_owned(), + reason: "components must be canonical unsigned integers between 0 and 100".to_owned(), }); }; diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 20c5a97..677283b 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -184,10 +184,10 @@ fn compatible_policy_format_version_is_preserved_by_conversions() { } #[test] -fn policy_format_version_rejects_numeric_identifiers_outside_supported_range() { +fn policy_format_version_rejects_components_above_100() { 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["PolicyFormatVersion"] = serde_json::json!("1.18446744073709551616.0"); + value["PolicyFormatVersion"] = serde_json::json!("1.101.0"); assert!(serde_json::from_value::(value).is_err()); } From 1f47a8393c4ae0fa22565304b4fa12a1449292fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 15 Sep 2026 21:40:50 +0900 Subject: [PATCH 12/12] fix(policy): remove artificial version cap Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Now.Policy.Model.Tests/PolicyTests.cs | 3 +-- .../Devolutions.Now.Policy.Model/PolicyModels.cs | 11 +++++------ .../rust/now-policy-api/openapi/now-policy-api.yaml | 8 ++++++-- .../schema/devolutions.now-policy-draft.schema.json | 4 ++-- .../schema/devolutions.now-policy.schema.json | 4 ++-- policies/rust/now-policy/src/newtypes.rs | 8 +++++--- policies/rust/now-policy/tests/policy_samples.rs | 4 ++-- 7 files changed, 23 insertions(+), 19 deletions(-) diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs index 1f62078..ce0fa99 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs @@ -263,7 +263,7 @@ public void Draft_conversion_omits_and_restores_server_metadata_without_aliasing [Theory] [InlineData("not-semver")] [InlineData("2.0.0")] - [InlineData("1.101.0")] + [InlineData("1.18446744073709551616.0")] [InlineData("1.2.3-beta")] [InlineData("1.0.0\n")] public void Unsupported_policy_format_versions_are_rejected(string value) @@ -276,7 +276,6 @@ public void Unsupported_policy_format_versions_are_rejected(string value) } [Theory] - [InlineData("1.101.0")] [InlineData("1.0.0-01")] [InlineData("1.0.0-.")] [InlineData("1.0.0\n")] diff --git a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs index 2fcdca1..09004fb 100644 --- a/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs +++ b/policies/dotnet/Devolutions.Now.Policy.Model/PolicyModels.cs @@ -36,7 +36,7 @@ public static PolicyFormatVersion Parse(string value) || !TryParseComponent(components[2], out _)) { throw new FormatException( - "PolicyFormatVersion must contain three canonical unsigned integer components between 0 and 100."); + "PolicyFormatVersion must contain three canonical unsigned 64-bit integer components."); } if (major != PolicyFormatVersions.SupportedMajor) { @@ -47,17 +47,16 @@ public static PolicyFormatVersion Parse(string value) return new PolicyFormatVersion(value); } - private static bool TryParseComponent(string component, out byte value) + private static bool TryParseComponent(string component, out ulong value) { value = 0; - return component.Length is >= 1 and <= 3 + return component.Length >= 1 && (component.Length == 1 || component[0] != '0') - && byte.TryParse( + && ulong.TryParse( component, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, - out value) - && value <= 100; + out value); } public bool Equals(PolicyFormatVersion? other) => 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 14a2a4a..cb6f555 100644 --- a/policies/rust/now-policy-api/openapi/now-policy-api.yaml +++ b/policies/rust/now-policy-api/openapi/now-policy-api.yaml @@ -1265,9 +1265,11 @@ components: Readers accept canonical numeric versions in the compatible 1.x line. Applications must stamp the current value, `1.0.0`, for new documents and must not expose this value as publisher-authored editable metadata. + Schemas describe the canonical shape; runtime readers additionally bound + each numeric component to an unsigned 64-bit integer. type: string maxLength: 128 - pattern: ^1\.(0|[1-9][0-9]?|100)\.(0|[1-9][0-9]?|100)(?![\s\S]) + pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?![\s\S]) PolicyManagementResponse: description: Response body for `GET /v1/policy/management`. type: object @@ -2256,9 +2258,11 @@ components: Readers accept canonical numeric versions in the compatible 1.x line. Applications must stamp the current value, `1.0.0`, for new documents and must not expose this value as publisher-authored editable metadata. + Schemas describe the canonical shape; runtime readers additionally bound + each numeric component to an unsigned 64-bit integer. type: string maxLength: 128 - pattern: ^1\.(0|[1-9][0-9]?|100)\.(0|[1-9][0-9]?|100)(?![\s\S]) + pattern: ^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?![\s\S]) PolicyModelPolicyMatch: description: |- Match criteria for a policy rule. All specified fields must match. diff --git a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json index 3c3da31..c011677 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy-draft.schema.json @@ -248,9 +248,9 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept canonical numeric versions in the compatible 1.x line.\n Applications must stamp the current value, `1.0.0`, for new documents and\n must not expose this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept canonical numeric versions in the compatible 1.x line.\n Applications must stamp the current value, `1.0.0`, for new documents and\n must not expose this value as publisher-authored editable metadata.\n Schemas describe the canonical shape; runtime readers additionally bound\n each numeric component to an unsigned 64-bit integer.", "maxLength": 128, - "pattern": "^1\\.(0|[1-9][0-9]?|100)\\.(0|[1-9][0-9]?|100)(?![\\s\\S])", + "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?![\\s\\S])", "type": "string" }, "PolicyMatch": { 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 b169a3d..fc1a12c 100644 --- a/policies/rust/now-policy/schema/devolutions.now-policy.schema.json +++ b/policies/rust/now-policy/schema/devolutions.now-policy.schema.json @@ -188,9 +188,9 @@ "type": "object" }, "PolicyFormatVersion": { - "description": "Software-managed policy document format version.\n\n Readers accept canonical numeric versions in the compatible 1.x line.\n Applications must stamp the current value, `1.0.0`, for new documents and\n must not expose this value as publisher-authored editable metadata.", + "description": "Software-managed policy document format version.\n\n Readers accept canonical numeric versions in the compatible 1.x line.\n Applications must stamp the current value, `1.0.0`, for new documents and\n must not expose this value as publisher-authored editable metadata.\n Schemas describe the canonical shape; runtime readers additionally bound\n each numeric component to an unsigned 64-bit integer.", "maxLength": 128, - "pattern": "^1\\.(0|[1-9][0-9]?|100)\\.(0|[1-9][0-9]?|100)(?![\\s\\S])", + "pattern": "^1\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?![\\s\\S])", "type": "string" }, "PolicyMatch": { diff --git a/policies/rust/now-policy/src/newtypes.rs b/policies/rust/now-policy/src/newtypes.rs index 9becae6..22292da 100644 --- a/policies/rust/now-policy/src/newtypes.rs +++ b/policies/rust/now-policy/src/newtypes.rs @@ -107,11 +107,13 @@ pub const CURRENT_POLICY_FORMAT_VERSION: &str = "1.0.0"; /// Readers accept canonical numeric versions in the compatible 1.x line. /// Applications must stamp the current value, `1.0.0`, for new documents and /// must not expose this value as publisher-authored editable metadata. +/// Schemas describe the canonical shape; runtime readers additionally bound +/// each numeric component to an unsigned 64-bit integer. #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] pub struct PolicyFormatVersion( #[schemars( length(max = 128), - regex(pattern = r"^1\.(0|[1-9][0-9]?|100)\.(0|[1-9][0-9]?|100)(?![\s\S])") + regex(pattern = r"^1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?![\s\S])") )] String, ); @@ -146,7 +148,7 @@ impl PolicyFormatVersion { { return None; } - component.parse::().ok().filter(|value| *value <= 100) + component.parse::().ok() }; let (Some(major), Some(_minor), Some(_patch)) = @@ -154,7 +156,7 @@ impl PolicyFormatVersion { else { return Err(ModelValidationError::Invalid { type_name: "PolicyFormatVersion", - reason: "components must be canonical unsigned integers between 0 and 100".to_owned(), + reason: "components must be canonical unsigned 64-bit integers".to_owned(), }); }; diff --git a/policies/rust/now-policy/tests/policy_samples.rs b/policies/rust/now-policy/tests/policy_samples.rs index 677283b..549e8a3 100644 --- a/policies/rust/now-policy/tests/policy_samples.rs +++ b/policies/rust/now-policy/tests/policy_samples.rs @@ -184,10 +184,10 @@ fn compatible_policy_format_version_is_preserved_by_conversions() { } #[test] -fn policy_format_version_rejects_components_above_100() { +fn policy_format_version_rejects_components_outside_u64_range() { 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["PolicyFormatVersion"] = serde_json::json!("1.101.0"); + value["PolicyFormatVersion"] = serde_json::json!("1.18446744073709551616.0"); assert!(serde_json::from_value::(value).is_err()); }