Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,6 @@

namespace Devolutions.Now.Policy.Api;

/// <summary>Canonical schema URI used in the <c>$schema</c> field of policy documents.</summary>
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";
}

/// <summary>Shared <see cref="JsonSerializerOptions"/> for broker documents.</summary>
public static class BrokerSerializer
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,8 @@ public enum PolicyFindingCode
InvalidWildcardPattern,
ContradictoryConstraints,
InvalidValidityInterval,
UnsupportedSchema,
UnsupportedPolicyType,
UnsupportedPolicyVersion,
UnsupportedPolicyFormatVersion,
AuditModeEnabled,
DefaultAllow,
SensitiveOptionAllowed,
Expand Down
2 changes: 2 additions & 0 deletions policies/dotnet/Devolutions.Now.Policy.Api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`; supported numeric versions in the 1.x line are accepted and preserved.

Architecture
------------

Expand Down
5 changes: 3 additions & 2 deletions policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<default>","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":"<default>","Reason":"allowed"},"WouldExecute":true,"Policy":{"Id":"mock.policy","Revision":1,"PolicyFormatVersion":"1.0.0"}}
""");
var client = new BrokerClient(new BrokerClientOptions
{
Expand Down Expand Up @@ -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":"<default>","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":"<default>","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");
Expand All @@ -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);

Expand Down Expand Up @@ -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":"<default>","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":"<default>","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"}
Expand All @@ -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":"<default>","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":"<default>","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"}
Expand Down Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonException>(
() => BrokerSerializer.DeserializeStrict<PolicyResponse>(document.ToJsonString()));

document["Policy"]!.AsObject().Remove("$schema");
document["Policy"]!["PolicyFormatVersion"] = "2.0.0";
Assert.Throws<JsonException>(
() => BrokerSerializer.DeserializeStrict<PolicyResponse>(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<JsonException>(
() => BrokerSerializer.DeserializeStrict<EvaluationResponse>(document.ToJsonString()));
}
else
{
Assert.Throws<JsonException>(
() => BrokerSerializer.DeserializeStrict<ExecutionResponse>(document.ToJsonString()));
}
}

[Fact]
public async Task ErrorResponse_serializes_to_schema_valid_output()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -537,10 +537,6 @@ public async Task Public_serializer_options_enforce_policy_root_semantic_invaria
}

var committedDto = JsonSerializer.Deserialize<PolicyDocument>(committed.ToJsonString(), options)!;
committedDto.Schema = Devolutions.Now.Policy.Model.SchemaUris.PolicyDraft;
Assert.Throws<JsonException>(() => JsonSerializer.Serialize(committedDto, options));

committedDto = JsonSerializer.Deserialize<PolicyDocument>(committed.ToJsonString(), options)!;
committedDto.Metadata.Revision = 0;
Assert.Throws<JsonException>(() => JsonSerializer.Serialize(committedDto, options));

Expand All @@ -553,10 +549,6 @@ public async Task Public_serializer_options_enforce_policy_root_semantic_invaria
Assert.Throws<JsonException>(() => JsonSerializer.Serialize(committedDto, options));

var draftDto = JsonSerializer.Deserialize<PolicyDraftDocument>(draft.ToJsonString(), options)!;
draftDto.Schema = Devolutions.Now.Policy.Model.SchemaUris.Policy;
Assert.Throws<JsonException>(() => JsonSerializer.Serialize(draftDto, options));

draftDto = JsonSerializer.Deserialize<PolicyDraftDocument>(draft.ToJsonString(), options)!;
draftDto.Rules[0].Match.SkipHashCheck = [false, true];
Assert.Throws<JsonException>(() => JsonSerializer.Serialize(draftDto, options));
}
Expand Down Expand Up @@ -646,10 +638,6 @@ private static JsonNode MismatchedReplacementCanonicalDraft(JsonNode response)

private static IEnumerable<JsonNode> 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;
Expand All @@ -665,10 +653,6 @@ private static IEnumerable<JsonNode> InvalidCommittedPolicies(JsonNode committed

private static IEnumerable<JsonNode> 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;
Expand Down
72 changes: 61 additions & 11 deletions policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonException>(() => 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<JsonException>(
() => PolicySerializer.DeserializePolicyDraftDocumentStrict(draftJson.ToJsonString()));
}
Expand Down Expand Up @@ -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")]
Expand All @@ -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")]
Expand Down Expand Up @@ -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"]);

Expand All @@ -246,11 +248,60 @@ 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-beta")]
[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<JsonException>(() => PolicyDocument.ParseJson(document.ToJsonString()));
}

[Theory]
[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()
{
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()
{
Expand Down Expand Up @@ -420,8 +471,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",
Expand Down
Loading