diff --git a/Cargo.lock b/Cargo.lock
index 7177c60..06e8e1e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -619,7 +619,6 @@ dependencies = [
"semver",
"serde",
"serde_json",
- "serde_yaml",
"thiserror",
"url",
]
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs
index 1c2e5f3..27b8933 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs
@@ -9,9 +9,14 @@ public static class BrokerApi
public const string DefaultPipeName = "Devolutions.Now.PackageBroker.v1";
+ /// Maximum complete HTTP request-body size for policy validation and replacement.
+ public const int MaxPolicyManagementBodyBytes = 16 * 1024 * 1024;
+
public const string PackageRequestKind = "PackageRequest";
public const string StatusRequestKind = "StatusRequest";
public const string CancelRequestKind = "CancelRequest";
+ public const string PolicyValidationRequestKind = "PolicyValidationRequest";
+ public const string PolicyReplacementRequestKind = "PolicyReplacementRequest";
public const string HealthResponseKind = "HealthResponse";
public const string CapabilitiesResponseKind = "CapabilitiesResponse";
@@ -20,6 +25,9 @@ public static class BrokerApi
public const string StatusResponseKind = "StatusResponse";
public const string CancelResponseKind = "CancelResponse";
public const string PolicyResponseKind = "PolicyResponse";
+ public const string PolicyManagementResponseKind = "PolicyManagementResponse";
+ public const string PolicyValidationResponseKind = "PolicyValidationResponse";
+ public const string PolicyReplacementResponseKind = "PolicyReplacementResponse";
public const string ErrorResponseKind = "ErrorResponse";
internal static string ValidateMessageKind(string? value, string expected, string propertyName)
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs
index 7afcb4d..54b73d4 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs
@@ -25,60 +25,187 @@ public static class BrokerJson
public static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
- public static string Serialize(T value) =>
- JsonSerializer.Serialize(value, TypeInfo());
+ public static string Serialize(T value)
+ {
+ ValidateSemanticValue(value);
+ return JsonSerializer.Serialize(value, TypeInfo());
+ }
- public static T? Deserialize(string json) =>
- JsonSerializer.Deserialize(json, TypeInfo());
+ public static T? Deserialize(string json)
+ {
+ var value = JsonSerializer.Deserialize(json, TypeInfo());
+ ValidateSemanticValue(value);
+ return value;
+ }
public static T? DeserializeStrict(string json)
{
var value = JsonSerializer.Deserialize(json, StrictTypeInfo());
- if (value is PolicyResponse response)
+ ValidateSemanticValue(value);
+ return value;
+ }
+
+ private static void ValidateSemanticValue(T value)
+ {
+ switch (value)
{
- PolicyJson.ValidateRequiredCollectionElements(response.Policy);
+ case PolicyResponse response:
+ PolicyJson.ValidateRequiredCollectionElements(response.Policy);
+ break;
+ case PolicyManagementResponse response:
+ ValidateManagement(response.Management);
+ break;
+ case PolicyValidationResponse response:
+ ValidateValidation(response.Validation);
+ break;
+ case PolicyReplacementResponse response:
+ PolicyJson.ValidateRequiredCollectionElements(response.Policy);
+ ValidateValidation(response.Validation);
+ ValidateManagement(response.Management);
+ break;
+ case ErrorResponse error:
+ ValidateError(error);
+ break;
}
-
- return value;
}
private static JsonTypeInfo TypeInfo() =>
typeof(T) == typeof(PackageRequest) ? Cast(BrokerJsonSerializerContext.Default.PackageRequest) :
typeof(T) == typeof(StatusRequest) ? Cast(BrokerJsonSerializerContext.Default.StatusRequest) :
typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonSerializerContext.Default.CancelRequest) :
+ typeof(T) == typeof(PolicyValidationRequest) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyValidationRequest) :
+ typeof(T) == typeof(PolicyReplacementRequest) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyReplacementRequest) :
typeof(T) == typeof(HealthResponse) ? Cast(BrokerJsonSerializerContext.Default.HealthResponse) :
typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerJsonSerializerContext.Default.CapabilitiesResponse) :
typeof(T) == typeof(PolicyResponse) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyResponse) :
+ typeof(T) == typeof(PolicyManagementResponse) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyManagementResponse) :
+ typeof(T) == typeof(PolicyValidationResponse) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyValidationResponse) :
+ typeof(T) == typeof(PolicyReplacementResponse) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyReplacementResponse) :
typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonSerializerContext.Default.EvaluationResponse) :
typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonSerializerContext.Default.ExecutionResponse) :
typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonSerializerContext.Default.StatusResponse) :
typeof(T) == typeof(CancelResponse) ? Cast(BrokerJsonSerializerContext.Default.CancelResponse) :
- typeof(T) == typeof(ErrorResponse) ? Cast(BrokerJsonSerializerContext.Default.ErrorResponse) :
+ typeof(T) == typeof(ErrorResponse) ? Cast(BrokerErrorJsonSerializerContext.Default.ErrorResponse) :
throw new NotSupportedException($"Broker JSON serialization for {typeof(T).FullName} is not source-generated.");
private static JsonTypeInfo StrictTypeInfo() =>
typeof(T) == typeof(PackageRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.PackageRequest) :
typeof(T) == typeof(StatusRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.StatusRequest) :
typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.CancelRequest) :
+ typeof(T) == typeof(PolicyValidationRequest) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyValidationRequest) :
+ typeof(T) == typeof(PolicyReplacementRequest) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyReplacementRequest) :
typeof(T) == typeof(HealthResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.HealthResponse) :
typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.CapabilitiesResponse) :
typeof(T) == typeof(PolicyResponse) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyResponse) :
+ typeof(T) == typeof(PolicyManagementResponse) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyManagementResponse) :
+ typeof(T) == typeof(PolicyValidationResponse) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyValidationResponse) :
+ typeof(T) == typeof(PolicyReplacementResponse) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyReplacementResponse) :
typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.EvaluationResponse) :
typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.ExecutionResponse) :
typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.StatusResponse) :
typeof(T) == typeof(CancelResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.CancelResponse) :
- typeof(T) == typeof(ErrorResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.ErrorResponse) :
+ typeof(T) == typeof(ErrorResponse) ? Cast(BrokerErrorJsonStrictSerializerContext.Default.ErrorResponse) :
throw new NotSupportedException($"Strict broker JSON deserialization for {typeof(T).FullName} is not source-generated.");
private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) =>
(JsonTypeInfo)jsonTypeInfo;
+ private static void ValidateManagement(PolicyManagementSnapshot management)
+ {
+ switch (management.State)
+ {
+ case PolicyManagementState.Active when management.Policy is null || management.InvalidDiagnostics is not null:
+ throw new JsonException("Active management snapshots require Policy and forbid InvalidDiagnostics.");
+ case PolicyManagementState.Missing when management.Policy is not null || management.InvalidDiagnostics is not null:
+ throw new JsonException("Missing management snapshots forbid Policy and InvalidDiagnostics.");
+ case PolicyManagementState.Invalid:
+ if (management.Policy is not null)
+ {
+ throw new JsonException("Invalid management snapshots forbid Policy.");
+ }
+ if (management.InvalidDiagnostics is null
+ || management.InvalidDiagnostics.Findings.Count == 0
+ || !management.InvalidDiagnostics.Findings.Any(
+ finding => finding.Severity == PolicyFindingSeverity.Error))
+ {
+ throw new JsonException(
+ "Invalid management snapshots require nonempty diagnostics with an Error finding.");
+ }
+ break;
+ }
+
+ switch (management.WriteCapability)
+ {
+ case PolicyWriteCapability.Writable when management.ReadOnlyReason is not null:
+ throw new JsonException("Writable management snapshots forbid ReadOnlyReason.");
+ case PolicyWriteCapability.ReadOnly or PolicyWriteCapability.Unsupported
+ when management.ReadOnlyReason is null:
+ throw new JsonException("ReadOnly and Unsupported management snapshots require ReadOnlyReason.");
+ }
+
+ if (management.Policy is { } policy)
+ {
+ PolicyJson.ValidateRequiredCollectionElements(policy);
+ }
+ }
+
+ private static void ValidateError(ErrorResponse error)
+ {
+ if (error.Code == ErrorCode.StalePolicyStoreToken && error.Management is null)
+ {
+ throw new JsonException("StalePolicyStoreToken errors require Management.");
+ }
+
+ if (error.Management is { } management)
+ {
+ ValidateManagement(management);
+ }
+
+ if (error.Validation is { } validation)
+ {
+ ValidateValidation(validation);
+ }
+ }
+
+ private static void ValidateValidation(PolicyValidationResult validation)
+ {
+ var hasError = validation.Findings.Any(finding => finding.Severity == PolicyFindingSeverity.Error);
+ if (validation.IsValid)
+ {
+ if (validation.CanonicalDraft is null || validation.ValidationReceipt is null)
+ {
+ throw new JsonException(
+ "Valid policy validation results require CanonicalDraft and ValidationReceipt.");
+ }
+ if (hasError)
+ {
+ throw new JsonException("Valid policy validation results must not contain Error findings.");
+ }
+
+ PolicyJson.ValidateRequiredCollectionElements(validation.CanonicalDraft);
+ }
+ else
+ {
+ if (validation.CanonicalDraft is not null || validation.ValidationReceipt is not null)
+ {
+ throw new JsonException(
+ "Invalid policy validation results must not contain CanonicalDraft or ValidationReceipt.");
+ }
+ if (!hasError)
+ {
+ throw new JsonException(
+ "Invalid policy validation results require at least one Error finding.");
+ }
+ }
+ }
+
private static JsonSerializerOptions CreateOptions(bool writeIndented) =>
new(BrokerJsonSerializerContext.Default.Options)
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(
BrokerJsonSerializerContext.Default,
- BrokerPolicyJsonSerializerContext.Default),
+ BrokerPolicyJsonSerializerContext.Default,
+ BrokerErrorJsonSerializerContext.Default),
WriteIndented = writeIndented,
};
}
@@ -96,7 +223,6 @@ private static JsonSerializerOptions CreateOptions(bool writeIndented) =>
[JsonSerializable(typeof(ExecutionResponse))]
[JsonSerializable(typeof(StatusResponse))]
[JsonSerializable(typeof(CancelResponse))]
-[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
[JsonSerializable(typeof(JsonArray))]
@@ -116,7 +242,6 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex
[JsonSerializable(typeof(ExecutionResponse))]
[JsonSerializable(typeof(StatusResponse))]
[JsonSerializable(typeof(CancelResponse))]
-[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
[JsonSerializable(typeof(JsonArray))]
@@ -128,6 +253,11 @@ internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializer
RespectNullableAnnotations = true,
WriteIndented = false)]
[JsonSerializable(typeof(PolicyResponse))]
+[JsonSerializable(typeof(PolicyManagementResponse))]
+[JsonSerializable(typeof(PolicyValidationRequest))]
+[JsonSerializable(typeof(PolicyValidationResponse))]
+[JsonSerializable(typeof(PolicyReplacementRequest))]
+[JsonSerializable(typeof(PolicyReplacementResponse))]
internal sealed partial class BrokerPolicyJsonSerializerContext : JsonSerializerContext;
[JsonSourceGenerationOptions(
@@ -137,4 +267,24 @@ internal sealed partial class BrokerPolicyJsonSerializerContext : JsonSerializer
WriteIndented = false,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
[JsonSerializable(typeof(PolicyResponse))]
-internal sealed partial class BrokerPolicyJsonStrictSerializerContext : JsonSerializerContext;
\ No newline at end of file
+[JsonSerializable(typeof(PolicyManagementResponse))]
+[JsonSerializable(typeof(PolicyValidationRequest))]
+[JsonSerializable(typeof(PolicyValidationResponse))]
+[JsonSerializable(typeof(PolicyReplacementRequest))]
+[JsonSerializable(typeof(PolicyReplacementResponse))]
+internal sealed partial class BrokerPolicyJsonStrictSerializerContext : JsonSerializerContext;
+
+[JsonSourceGenerationOptions(
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ RespectNullableAnnotations = true,
+ WriteIndented = false)]
+[JsonSerializable(typeof(ErrorResponse))]
+internal sealed partial class BrokerErrorJsonSerializerContext : JsonSerializerContext;
+
+[JsonSourceGenerationOptions(
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ RespectNullableAnnotations = true,
+ WriteIndented = false,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
+[JsonSerializable(typeof(ErrorResponse))]
+internal sealed partial class BrokerErrorJsonStrictSerializerContext : JsonSerializerContext;
\ No newline at end of file
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs
index 38fe46e..5e5525f 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs
@@ -36,6 +36,7 @@ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOpt
}
internal sealed class ExactCaseTransportConverter : ExactCaseStringEnumConverter;
+internal sealed class ExactCaseErrorCodeConverter : ExactCaseStringEnumConverter;
/// Package operation type.
[JsonConverter(typeof(JsonStringEnumConverter))]
@@ -132,7 +133,7 @@ public enum HealthStatus
}
/// Structured machine-readable error code.
-[JsonConverter(typeof(JsonStringEnumConverter))]
+[JsonConverter(typeof(ExactCaseErrorCodeConverter))]
public enum ErrorCode
{
BadRequest,
@@ -146,6 +147,18 @@ public enum ErrorCode
BrokerPaused,
InternalError,
Timeout,
+ UnsupportedEndpoint,
+ MalformedDraft,
+ InvalidPolicy,
+ WarningConfirmationRequired,
+ Unauthenticated,
+ AdministratorRequired,
+ UnsafePolicyPath,
+ UnsupportedPolicyFormat,
+ StalePolicyStoreToken,
+ UnsupportedPolicyFilesystem,
+ PolicyPersistenceFailed,
+ PolicyActivationFailed,
}
/// Transport kind of a per-operation event channel.
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs
index 871ea38..c4a44c4 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs
@@ -126,6 +126,14 @@ public string ResponseKind
[JsonPropertyName("Details")]
public List Details { get; set; } = [];
+ /// Current authoritative policy findings for management errors.
+ [JsonPropertyName("Validation")]
+ public PolicyValidationResult? Validation { get; set; }
+
+ /// Atomic current policy state, required for stale store-token errors.
+ [JsonPropertyName("Management")]
+ public PolicyManagementSnapshot? Management { get; set; }
+
}
public sealed class ErrorDetail
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs
new file mode 100644
index 0000000..578061e
--- /dev/null
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs
@@ -0,0 +1,415 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using Devolutions.Now.Policy.Model;
+
+namespace Devolutions.Now.Policy.Api;
+
+internal sealed class ExactCasePolicyManagementStateConverter : ExactCaseStringEnumConverter;
+internal sealed class ExactCasePolicyConfigurationSourceConverter : ExactCaseStringEnumConverter;
+internal sealed class ExactCasePolicyWriteCapabilityConverter : ExactCaseStringEnumConverter;
+internal sealed class ExactCasePolicyReadOnlyReasonConverter : ExactCaseStringEnumConverter;
+internal sealed class ExactCasePolicyReplacementOperationConverter : ExactCaseStringEnumConverter;
+internal sealed class ExactCasePolicyConflictHandlingConverter : ExactCaseStringEnumConverter;
+internal sealed class ExactCasePolicyFindingSeverityConverter : ExactCaseStringEnumConverter;
+internal sealed class ExactCasePolicyFindingCodeConverter : ExactCaseStringEnumConverter;
+
+internal abstract class OpaqueStringJsonConverter(int maxLength, string typeName) : JsonConverter
+{
+ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.String)
+ {
+ throw new JsonException($"{typeName} must be a string.");
+ }
+
+ return Validate(reader.GetString());
+ }
+
+ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
+ => writer.WriteStringValue(Validate(value));
+
+ private string Validate(string? value)
+ {
+ if (string.IsNullOrEmpty(value) || value.Length > maxLength)
+ {
+ throw new JsonException($"{typeName} must contain between 1 and {maxLength} characters.");
+ }
+ if (!IsAsciiAlphanumeric(value[0]) || value.Any(character =>
+ !IsAsciiAlphanumeric(character) && character is not ('.' or '_' or '~' or ':' or '-')))
+ {
+ throw new JsonException(
+ $"{typeName} must use safe printable ASCII characters and start with an ASCII alphanumeric character.");
+ }
+
+ return value;
+ }
+
+ private static bool IsAsciiAlphanumeric(char character) =>
+ character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9';
+}
+
+internal sealed class PolicyStoreTokenJsonConverter()
+ : OpaqueStringJsonConverter(512, "PolicyStoreToken");
+
+internal sealed class PolicyValidationReceiptJsonConverter()
+ : OpaqueStringJsonConverter(2048, "PolicyValidationReceipt");
+
+/// Current configured-policy state.
+[JsonConverter(typeof(ExactCasePolicyManagementStateConverter))]
+public enum PolicyManagementState
+{
+ Active,
+ Missing,
+ Invalid,
+}
+
+/// Origin of the resolved policy path.
+[JsonConverter(typeof(ExactCasePolicyConfigurationSourceConverter))]
+public enum PolicyConfigurationSource
+{
+ DefaultPath,
+ ConfiguredPath,
+}
+
+/// Advisory ability to write the configured policy through the management API.
+[JsonConverter(typeof(ExactCasePolicyWriteCapabilityConverter))]
+public enum PolicyWriteCapability
+{
+ Writable,
+ ReadOnly,
+ Unsupported,
+}
+
+/// Stable reason why the configured policy cannot be written.
+[JsonConverter(typeof(ExactCasePolicyReadOnlyReasonConverter))]
+public enum PolicyReadOnlyReason
+{
+ ManagementDisabled,
+ PathNotConfigured,
+ UnsupportedFormat,
+ UnsafePath,
+ InsufficientPermissions,
+ UnsupportedFileSystem,
+}
+
+/// Requested identity/revision behavior for a policy replacement.
+[JsonConverter(typeof(ExactCasePolicyReplacementOperationConverter))]
+public enum PolicyReplacementOperation
+{
+ Update,
+ ReplaceIdentity,
+ Create,
+ Repair,
+}
+
+/// Optimistic-conflict behavior for policy replacement.
+[JsonConverter(typeof(ExactCasePolicyConflictHandlingConverter))]
+public enum PolicyConflictHandling
+{
+ Reject,
+ ConfirmOverwrite,
+}
+
+/// Severity of a policy validation finding.
+[JsonConverter(typeof(ExactCasePolicyFindingSeverityConverter))]
+public enum PolicyFindingSeverity
+{
+ Error,
+ Warning,
+}
+
+/// Stable policy validation finding code.
+[JsonConverter(typeof(ExactCasePolicyFindingCodeConverter))]
+public enum PolicyFindingCode
+{
+ SchemaViolation,
+ UnknownField,
+ MissingRequiredField,
+ InvalidFieldType,
+ InvalidFieldValue,
+ DuplicateRuleId,
+ IneffectiveBooleanMatch,
+ InvalidVersionRange,
+ EmptyVersionRange,
+ InvalidWildcardPattern,
+ ContradictoryConstraints,
+ InvalidValidityInterval,
+ UnsupportedSchema,
+ UnsupportedPolicyType,
+ UnsupportedPolicyVersion,
+ AuditModeEnabled,
+ DefaultAllow,
+ SensitiveOptionAllowed,
+}
+
+/// Versioned, structured policy validation finding.
+public sealed class PolicyFinding
+{
+ [JsonPropertyName("FindingVersion")]
+ [JsonRequired]
+ public string FindingVersion { get; set; } = BrokerApi.Version;
+
+ [JsonPropertyName("Severity")]
+ [JsonRequired]
+ public PolicyFindingSeverity Severity { get; set; }
+
+ [JsonPropertyName("Code")]
+ [JsonRequired]
+ public PolicyFindingCode Code { get; set; }
+
+ /// RFC 6901 JSON Pointer into the submitted draft.
+ [JsonPropertyName("Path")]
+ [JsonRequired]
+ public string Path { get; set; } = "";
+
+ [JsonPropertyName("RuleId")]
+ public string? RuleId { get; set; }
+
+ /// Machine-readable message arguments for localization.
+ [JsonPropertyName("Arguments")]
+ public Dictionary Arguments { get; set; } = [];
+
+ /// Human-readable fallback for clients that do not recognize the code.
+ [JsonPropertyName("Message")]
+ [JsonRequired]
+ public string Message { get; set; } = "";
+}
+
+/// Authoritative policy validation output.
+public sealed class PolicyValidationResult
+{
+ [JsonPropertyName("ResultVersion")]
+ [JsonRequired]
+ public string ResultVersion { get; set; } = BrokerApi.Version;
+
+ [JsonPropertyName("ValidatorVersion")]
+ [JsonRequired]
+ public string ValidatorVersion { get; set; } = "";
+
+ [JsonPropertyName("IsValid")]
+ [JsonRequired]
+ public bool IsValid { get; set; }
+
+ [JsonPropertyName("CanonicalDraft")]
+ public PolicyDraftDocument? CanonicalDraft { get; set; }
+
+ [JsonPropertyName("ValidationReceipt")]
+ [JsonConverter(typeof(PolicyValidationReceiptJsonConverter))]
+ public string? ValidationReceipt { get; set; }
+
+ [JsonPropertyName("Findings")]
+ [JsonRequired]
+ public List Findings { get; set; } = [];
+}
+
+/// Sanitized diagnostics for an invalid configured policy.
+public sealed class InvalidPolicyDiagnostics
+{
+ [JsonPropertyName("DiagnosticsVersion")]
+ [JsonRequired]
+ public string DiagnosticsVersion { get; set; } = BrokerApi.Version;
+
+ [JsonPropertyName("Findings")]
+ [JsonRequired]
+ public List Findings { get; set; } = [];
+}
+
+/// Atomic view of configured policy state and management guidance.
+public sealed class PolicyManagementSnapshot
+{
+ [JsonPropertyName("State")]
+ [JsonRequired]
+ public PolicyManagementState State { get; set; }
+
+ [JsonPropertyName("ConfiguredPath")]
+ [JsonRequired]
+ public string ConfiguredPath { get; set; } = "";
+
+ [JsonPropertyName("StoreToken")]
+ [JsonRequired]
+ [JsonConverter(typeof(PolicyStoreTokenJsonConverter))]
+ public string StoreToken { get; set; } = "";
+
+ [JsonPropertyName("Source")]
+ [JsonRequired]
+ public PolicyConfigurationSource Source { get; set; }
+
+ [JsonPropertyName("WriteCapability")]
+ [JsonRequired]
+ public PolicyWriteCapability WriteCapability { get; set; }
+
+ [JsonPropertyName("ReadOnlyReason")]
+ public PolicyReadOnlyReason? ReadOnlyReason { get; set; }
+
+ [JsonPropertyName("ElevationRequired")]
+ [JsonRequired]
+ public bool ElevationRequired { get; set; }
+
+ [JsonPropertyName("Policy")]
+ public PolicyDocument? Policy { get; set; }
+
+ [JsonPropertyName("InvalidDiagnostics")]
+ public InvalidPolicyDiagnostics? InvalidDiagnostics { get; set; }
+}
+
+/// Response body for GET /v1/policy/management.
+public sealed class PolicyManagementResponse
+{
+ private const string Kind = BrokerApi.PolicyManagementResponseKind;
+ private string _responseKind = Kind;
+
+ [JsonPropertyName("ResponseKind")]
+ [JsonRequired]
+ public string ResponseKind
+ {
+ get => _responseKind;
+ set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
+ }
+
+ [JsonPropertyName("ResponseVersion")]
+ [JsonRequired]
+ public string ResponseVersion { get; set; } = BrokerApi.Version;
+
+ [JsonPropertyName("Server")]
+ [JsonRequired]
+ public ServerContext Server { get; set; } = new();
+
+ [JsonPropertyName("Management")]
+ [JsonRequired]
+ public PolicyManagementSnapshot Management { get; set; } = new();
+}
+
+/// Request body for POST /v1/policy/validate.
+public sealed class PolicyValidationRequest
+{
+ private const string Kind = BrokerApi.PolicyValidationRequestKind;
+ private string _requestKind = Kind;
+
+ [JsonPropertyName("RequestKind")]
+ [JsonRequired]
+ public string RequestKind
+ {
+ get => _requestKind;
+ set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind));
+ }
+
+ [JsonPropertyName("RequestVersion")]
+ [JsonRequired]
+ public string RequestVersion { get; set; } = BrokerApi.Version;
+
+ /// Raw draft JSON retained without dropping unknown members.
+ [JsonPropertyName("Draft")]
+ [JsonRequired]
+ public JsonElement Draft { get; set; }
+}
+
+/// Response body for POST /v1/policy/validate.
+public sealed class PolicyValidationResponse
+{
+ private const string Kind = BrokerApi.PolicyValidationResponseKind;
+ private string _responseKind = Kind;
+
+ [JsonPropertyName("ResponseKind")]
+ [JsonRequired]
+ public string ResponseKind
+ {
+ get => _responseKind;
+ set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
+ }
+
+ [JsonPropertyName("ResponseVersion")]
+ [JsonRequired]
+ public string ResponseVersion { get; set; } = BrokerApi.Version;
+
+ [JsonPropertyName("Server")]
+ [JsonRequired]
+ public ServerContext Server { get; set; } = new();
+
+ [JsonPropertyName("Validation")]
+ [JsonRequired]
+ public PolicyValidationResult Validation { get; set; } = new();
+}
+
+/// Request body for PUT /v1/policy.
+public sealed class PolicyReplacementRequest
+{
+ private const string Kind = BrokerApi.PolicyReplacementRequestKind;
+ private string _requestKind = Kind;
+
+ [JsonPropertyName("RequestKind")]
+ [JsonRequired]
+ public string RequestKind
+ {
+ get => _requestKind;
+ set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind));
+ }
+
+ [JsonPropertyName("RequestVersion")]
+ [JsonRequired]
+ public string RequestVersion { get; set; } = BrokerApi.Version;
+
+ [JsonPropertyName("ExpectedStoreToken")]
+ [JsonRequired]
+ [JsonConverter(typeof(PolicyStoreTokenJsonConverter))]
+ public string ExpectedStoreToken { get; set; } = "";
+
+ [JsonPropertyName("Operation")]
+ [JsonRequired]
+ public PolicyReplacementOperation Operation { get; set; }
+
+ [JsonPropertyName("ConflictHandling")]
+ [JsonRequired]
+ public PolicyConflictHandling ConflictHandling { get; set; }
+
+ [JsonPropertyName("WarningsAcknowledged")]
+ [JsonRequired]
+ public bool WarningsAcknowledged { get; set; }
+
+ /// Raw draft JSON retained for transaction-time reparsing and revalidation.
+ [JsonPropertyName("Draft")]
+ [JsonRequired]
+ public JsonElement Draft { get; set; }
+
+ [JsonPropertyName("ValidationReceipt")]
+ [JsonRequired]
+ [JsonConverter(typeof(PolicyValidationReceiptJsonConverter))]
+ public string ValidationReceipt { get; set; } = "";
+}
+
+/// Response body for PUT /v1/policy.
+public sealed class PolicyReplacementResponse
+{
+ private const string Kind = BrokerApi.PolicyReplacementResponseKind;
+ private string _responseKind = Kind;
+
+ [JsonPropertyName("ResponseKind")]
+ [JsonRequired]
+ public string ResponseKind
+ {
+ get => _responseKind;
+ set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
+ }
+
+ [JsonPropertyName("ResponseVersion")]
+ [JsonRequired]
+ public string ResponseVersion { get; set; } = BrokerApi.Version;
+
+ [JsonPropertyName("Server")]
+ [JsonRequired]
+ public ServerContext Server { get; set; } = new();
+
+ /// Exact committed active policy, including server-assigned metadata.
+ [JsonPropertyName("Policy")]
+ [JsonRequired]
+ public PolicyDocument Policy { get; set; } = new();
+
+ [JsonPropertyName("Validation")]
+ [JsonRequired]
+ public PolicyValidationResult Validation { get; set; } = new();
+
+ /// Newly observed management state and store token.
+ [JsonPropertyName("Management")]
+ [JsonRequired]
+ public PolicyManagementSnapshot Management { get; set; } = new();
+}
\ No newline at end of file
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md
index f8ff2cf..f494813 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md
@@ -6,7 +6,7 @@ Devolutions NOW package broker API for .NET
Purpose
-------
-This package contains request, response, status, health, capabilities, active-policy inspection, and error DTOs for package broker clients and implementations. It does not perform HTTP transport, named-pipe I/O, policy evaluation, or package-manager execution.
+This package contains request, response, status, health, capabilities, active-policy inspection and management, and error DTOs for package broker clients and implementations. It does not perform HTTP transport, named-pipe I/O, policy validation/evaluation, persistence, or package-manager execution.
Top-level request DTOs carry `RequestKind` and `RequestVersion`; top-level response DTOs carry
`ResponseKind` and `ResponseVersion`. Kind properties are fixed discriminators that serialize
@@ -26,12 +26,23 @@ Architecture
- `RequestModels.cs` defines `PackageRequest` and request context/options.
- `ResponseModels.cs` defines active-policy, evaluation, and execution responses plus shared response context, summaries, decisions, policy info, diagnostics, and operation submission. `PolicyResponse` embeds the canonical `Devolutions.Now.Policy.Model.PolicyDocument`.
+- `PolicyManagementModels.cs` defines atomic management snapshots, raw JSON draft requests, versioned validation findings/receipts, optimistic replacement intents, and management responses.
- `StatusModels.cs` defines status query request/response DTOs.
- `MetaModels.cs` defines health, capabilities, manager capability, and error DTOs.
- `Enums.cs` defines package broker API enums and JSON string enum converters.
- `BrokerJson.cs` defines source-generated serializer options for the broker wire format. Public `BrokerJson.Options` and `BrokerJson.PrettyOptions` support every broker DTO, including the embedded policy model, without reflection and reject JSON null for non-nullable contract members.
- `PolicyCompatibility.cs` maps compatible API enums to and from `Devolutions.Now.Policy.Model` enums.
+Opaque policy store tokens and validation receipts are restricted to safe printable ASCII (`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `:`, `-`) beginning with an ASCII alphanumeric character, so Rust and .NET enforce identical bounds.
+
+`BrokerApi.MaxPolicyManagementBodyBytes` exposes the fixed 16 MiB limit for the complete serialized
+HTTP body of policy validation and replacement requests. It is separate from the package-operation
+limit advertised by broker capabilities.
+
+Because policy documents are JSON-only, configured `.yaml`, `.yml`, extensionless, and other
+non-JSON paths use `PolicyReadOnlyReason.UnsupportedFormat` in management snapshots and
+`ErrorCode.UnsupportedPolicyFormat` for structured HTTP 422 errors.
+
OpenAPI relationship
--------------------
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs
index 6cfd91c..7ca1434 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs
@@ -62,6 +62,36 @@ public async Task CapabilitiesResponse_round_trips_and_validates(string path)
public async Task PolicyResponse_round_trips_and_validates(string path)
=> await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyResponse"));
+ [Theory]
+ [MemberData(nameof(TestData.PolicyManagementResponseSamples), MemberType = typeof(TestData))]
+ public async Task PolicyManagementResponse_round_trips_and_validates(string path)
+ => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyManagementResponse"));
+
+ [Theory]
+ [MemberData(nameof(TestData.PolicyValidationRequestSamples), MemberType = typeof(TestData))]
+ public async Task PolicyValidationRequest_round_trips_and_validates(string path)
+ => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyValidationRequest"));
+
+ [Theory]
+ [MemberData(nameof(TestData.PolicyValidationResponseSamples), MemberType = typeof(TestData))]
+ public async Task PolicyValidationResponse_round_trips_and_validates(string path)
+ => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyValidationResponse"));
+
+ [Theory]
+ [MemberData(nameof(TestData.PolicyReplacementRequestSamples), MemberType = typeof(TestData))]
+ public async Task PolicyReplacementRequest_round_trips_and_validates(string path)
+ => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyReplacementRequest"));
+
+ [Theory]
+ [MemberData(nameof(TestData.PolicyReplacementResponseSamples), MemberType = typeof(TestData))]
+ public async Task PolicyReplacementResponse_round_trips_and_validates(string path)
+ => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyReplacementResponse"));
+
+ [Theory]
+ [MemberData(nameof(TestData.PolicyManagementErrorSamples), MemberType = typeof(TestData))]
+ public async Task PolicyManagementError_round_trips_and_validates(string path)
+ => await AssertRoundTrip(path, await TestData.SchemaAsync("ErrorResponse"));
+
private static async Task AssertRoundTrip(string samplePath, JsonSchema schema)
{
var original = await File.ReadAllTextAsync(samplePath);
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs
index dfd4539..d14567b 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs
@@ -108,6 +108,15 @@ public void Public_json_options_source_generate_all_broker_dtos()
typeof(RequestOptions),
typeof(ClientContext),
typeof(PolicyResponse),
+ typeof(PolicyManagementResponse),
+ typeof(PolicyManagementSnapshot),
+ typeof(InvalidPolicyDiagnostics),
+ typeof(PolicyValidationRequest),
+ typeof(PolicyValidationResponse),
+ typeof(PolicyValidationResult),
+ typeof(PolicyFinding),
+ typeof(PolicyReplacementRequest),
+ typeof(PolicyReplacementResponse),
typeof(EvaluationResponse),
typeof(ExecutionResponse),
typeof(ServerContext),
@@ -127,7 +136,9 @@ public void Public_json_options_source_generate_all_broker_dtos()
typeof(ErrorDetail),
typeof(EventChannel),
typeof(PolicyDocument),
+ typeof(PolicyDraftDocument),
typeof(PolicyMetadata),
+ typeof(PolicyDraftMetadata),
typeof(PolicyEnforcement),
typeof(PolicyRule),
typeof(PolicyMatch),
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs
new file mode 100644
index 0000000..47b0158
--- /dev/null
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs
@@ -0,0 +1,537 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+using Devolutions.Now.Policy.Client;
+
+using Xunit;
+
+namespace Devolutions.Now.Policy.Client.Tests;
+
+public class PolicyManagementClientTests
+{
+ [Fact]
+ public async Task GetPolicyManagement_sends_json_get_and_strictly_parses_snapshot()
+ {
+ var body = await ReadFixture("responses", "policy-management.active.response.json");
+ var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 200, Body = body });
+ var response = await CreateClient(transport).GetPolicyManagement();
+
+ var request = Assert.Single(transport.Requests);
+ Assert.Equal("GET", request.Method);
+ Assert.Equal("/v1/policy/management", request.Path);
+ Assert.Equal(PolicyManagementState.Active, response.Management.State);
+ Assert.Equal("store:active:7", response.Management.StoreToken);
+ }
+
+ [Fact]
+ public async Task ValidatePolicy_preserves_raw_unknown_fields_and_returns_exact_warnings()
+ {
+ var requestJson = await ReadFixture("requests", "policy-validation.request.json");
+ using var requestDocument = JsonDocument.Parse(requestJson);
+ var body = await ReadFixture("responses", "policy-validation.valid.response.json");
+ var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 200, Body = body });
+
+ var response = await CreateClient(transport).ValidatePolicy(requestDocument.RootElement.GetProperty("Draft"));
+
+ var request = Assert.Single(transport.Requests);
+ Assert.Equal("POST", request.Method);
+ Assert.Equal("/v1/policy/validate", request.Path);
+ using var sent = JsonDocument.Parse(request.Body!);
+ Assert.True(sent.RootElement.GetProperty("Draft").GetProperty("EditorExtension").GetProperty("preserved").GetBoolean());
+ Assert.Equal("receipt:sha256:valid-warning-set", response.Validation.ValidationReceipt);
+ Assert.Equal(3, response.Validation.Findings.Count);
+ }
+
+ [Theory]
+ [InlineData("policy-replacement.update.request.json", PolicyReplacementOperation.Update, PolicyConflictHandling.Reject)]
+ [InlineData("policy-replacement.replace-identity.request.json", PolicyReplacementOperation.ReplaceIdentity, PolicyConflictHandling.Reject)]
+ [InlineData("policy-replacement.create.request.json", PolicyReplacementOperation.Create, PolicyConflictHandling.Reject)]
+ [InlineData("policy-replacement.repair.request.json", PolicyReplacementOperation.Repair, PolicyConflictHandling.Reject)]
+ [InlineData("policy-replacement.overwrite.request.json", PolicyReplacementOperation.Update, PolicyConflictHandling.ConfirmOverwrite)]
+ public async Task ReplacePolicy_sends_every_operation_intent(
+ string fixture,
+ PolicyReplacementOperation operation,
+ PolicyConflictHandling conflictHandling)
+ {
+ var request = BrokerJson.DeserializeStrict(await ReadFixture("requests", fixture))!;
+ var body = await ReadFixture("responses", "policy-replacement.response.json");
+ var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 200, Body = body });
+
+ var response = await CreateClient(transport).ReplacePolicy(request);
+
+ var sentRequest = Assert.Single(transport.Requests);
+ Assert.Equal("PUT", sentRequest.Method);
+ Assert.Equal("/v1/policy", sentRequest.Path);
+ using var sent = JsonDocument.Parse(sentRequest.Body!);
+ Assert.Equal(operation.ToString(), sent.RootElement.GetProperty("Operation").GetString());
+ Assert.Equal(conflictHandling.ToString(), sent.RootElement.GetProperty("ConflictHandling").GetString());
+ Assert.Equal(8U, response.Policy.Metadata.Revision);
+ Assert.Equal("store:active:8", response.Management.StoreToken);
+ }
+
+ [Fact]
+ public async Task ReplacePolicy_preserves_structured_stale_token_findings()
+ {
+ var request = BrokerJson.DeserializeStrict(
+ await ReadFixture("requests", "policy-replacement.update.request.json"))!;
+ var errorBody = await ReadFixture("responses", "policy-stale-token.error.json");
+ var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 409, Body = errorBody });
+
+ var exception = await Assert.ThrowsAsync(
+ () => CreateClient(transport).ReplacePolicy(request));
+
+ Assert.Equal(ErrorCode.StalePolicyStoreToken, exception.BrokerError?.Code);
+ Assert.Equal(PolicyFindingCode.InvalidFieldValue, exception.BrokerError?.Validation?.Findings[0].Code);
+ Assert.Equal("store:active:9", exception.BrokerError?.Management?.StoreToken);
+ }
+
+ [Fact]
+ public async Task ReplacePolicy_parses_unsupported_json_path_format()
+ {
+ var request = BrokerJson.DeserializeStrict(
+ await ReadFixture("requests", "policy-replacement.update.request.json"))!;
+ var errorBody = await ReadFixture("responses", "policy-unsupported-format.error.json");
+ var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 422, Body = errorBody });
+
+ var exception = await Assert.ThrowsAsync(
+ () => CreateClient(transport).ReplacePolicy(request));
+
+ Assert.Equal(422, exception.StatusCode);
+ Assert.Equal(ErrorCode.UnsupportedPolicyFormat, exception.BrokerError?.Code);
+ Assert.Equal(PolicyReadOnlyReason.UnsupportedFormat, exception.BrokerError?.Management?.ReadOnlyReason);
+ Assert.EndsWith("now-policy.yaml", exception.BrokerError?.Management?.ConfiguredPath, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Unsupported_policy_format_values_use_exact_case()
+ {
+ var management = JsonNode.Parse(
+ await ReadFixture("responses", "policy-management.unsupported-format.response.json"))!;
+ var parsedManagement = BrokerJson.DeserializeStrict(management.ToJsonString())!;
+ Assert.Equal(PolicyReadOnlyReason.UnsupportedFormat, parsedManagement.Management.ReadOnlyReason);
+ Assert.Contains("\"ReadOnlyReason\":\"UnsupportedFormat\"", BrokerJson.Serialize(parsedManagement));
+
+ management["Management"]!["ReadOnlyReason"] = JsonNode.Parse("\"unsupportedformat\"");
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(management.ToJsonString()));
+
+ var error = JsonNode.Parse(await ReadFixture("responses", "policy-unsupported-format.error.json"))!;
+ var parsedError = BrokerJson.DeserializeStrict(error.ToJsonString())!;
+ Assert.Equal(ErrorCode.UnsupportedPolicyFormat, parsedError.Code);
+ Assert.Contains("\"Code\":\"UnsupportedPolicyFormat\"", BrokerJson.Serialize(parsedError));
+
+ error["Code"] = JsonNode.Parse("\"unsupportedpolicyformat\"");
+ Assert.Throws(() => BrokerJson.DeserializeStrict(error.ToJsonString()));
+ }
+
+ [Fact]
+ public async Task Policy_management_requests_accept_the_exact_full_body_limit()
+ {
+ var validationBody = await ReadFixture("responses", "policy-validation.valid.response.json");
+ var validationTransport = new FakeBrokerTransport(
+ new BrokerTransportResponse { StatusCode = 200, Body = validationBody });
+ var validationRequest = new PolicyValidationRequest { RequestVersion = BrokerApi.Version };
+ var validationDraft = DraftForSerializedRequestSize(
+ validationRequest,
+ static (request, draft) => request.Draft = draft,
+ BrokerApi.MaxPolicyManagementBodyBytes);
+
+ await CreateClient(validationTransport).ValidatePolicy(validationDraft);
+
+ Assert.Equal(
+ BrokerApi.MaxPolicyManagementBodyBytes,
+ Encoding.UTF8.GetByteCount(Assert.Single(validationTransport.Requests).Body!));
+
+ var replacementRequest = BrokerJson.DeserializeStrict(
+ await ReadFixture("requests", "policy-replacement.update.request.json"))!;
+ replacementRequest.Draft = DraftForSerializedRequestSize(
+ replacementRequest,
+ static (request, draft) => request.Draft = draft,
+ BrokerApi.MaxPolicyManagementBodyBytes);
+ var replacementBody = await ReadFixture("responses", "policy-replacement.response.json");
+ var replacementTransport = new FakeBrokerTransport(
+ new BrokerTransportResponse { StatusCode = 200, Body = replacementBody });
+
+ await CreateClient(replacementTransport).ReplacePolicy(replacementRequest);
+
+ Assert.Equal(
+ BrokerApi.MaxPolicyManagementBodyBytes,
+ Encoding.UTF8.GetByteCount(Assert.Single(replacementTransport.Requests).Body!));
+ }
+
+ [Fact]
+ public async Task Policy_management_requests_reject_one_byte_over_before_transport()
+ {
+ var validationTransport = new FakeBrokerTransport();
+ var validationRequest = new PolicyValidationRequest { RequestVersion = BrokerApi.Version };
+ var validationDraft = DraftForSerializedRequestSize(
+ validationRequest,
+ static (request, draft) => request.Draft = draft,
+ BrokerApi.MaxPolicyManagementBodyBytes + 1);
+
+ var validationException = await Assert.ThrowsAsync(
+ () => CreateClient(validationTransport).ValidatePolicy(validationDraft));
+ Assert.Equal(BrokerClientErrorKind.RequestTooLarge, validationException.Kind);
+ Assert.Empty(validationTransport.Requests);
+
+ var replacementRequest = BrokerJson.DeserializeStrict(
+ await ReadFixture("requests", "policy-replacement.update.request.json"))!;
+ replacementRequest.Draft = DraftForSerializedRequestSize(
+ replacementRequest,
+ static (request, draft) => request.Draft = draft,
+ BrokerApi.MaxPolicyManagementBodyBytes + 1);
+ var replacementTransport = new FakeBrokerTransport();
+
+ var replacementException = await Assert.ThrowsAsync(
+ () => CreateClient(replacementTransport).ReplacePolicy(replacementRequest));
+ Assert.Equal(BrokerClientErrorKind.RequestTooLarge, replacementException.Kind);
+ Assert.Empty(replacementTransport.Requests);
+ }
+
+ [Theory]
+ [InlineData("management")]
+ [InlineData("validation")]
+ [InlineData("replacement")]
+ public async Task Management_success_responses_reject_unknown_members(string operation)
+ {
+ var (directory, fixture) = operation switch
+ {
+ "management" => ("responses", "policy-management.active.response.json"),
+ "validation" => ("responses", "policy-validation.valid.response.json"),
+ _ => ("responses", "policy-replacement.response.json"),
+ };
+ var document = JsonNode.Parse(await ReadFixture(directory, fixture))!;
+ document["Unexpected"] = true;
+ var transport = new FakeBrokerTransport(
+ new BrokerTransportResponse { StatusCode = 200, Body = document.ToJsonString() });
+ var client = CreateClient(transport);
+
+ var exception = operation switch
+ {
+ "management" => await Assert.ThrowsAsync(() => client.GetPolicyManagement()),
+ "validation" => await Assert.ThrowsAsync(
+ () => client.ValidatePolicy(JsonDocument.Parse("{}").RootElement)),
+ _ => await Assert.ThrowsAsync(
+ async () => await client.ReplacePolicy(BrokerJson.DeserializeStrict(
+ await ReadFixture("requests", "policy-replacement.update.request.json"))!)),
+ };
+
+ Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind);
+ }
+
+ [Fact]
+ public async Task Management_methods_propagate_cancellation()
+ {
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ var transport = new FakeBrokerTransport();
+ var client = CreateClient(transport);
+
+ await Assert.ThrowsAnyAsync(
+ () => client.GetPolicyManagement(cancellation.Token));
+ await Assert.ThrowsAnyAsync(
+ () => client.ValidatePolicy(JsonDocument.Parse("{}").RootElement, cancellation.Token));
+ Assert.Empty(transport.Requests);
+ }
+
+ [Theory]
+ [InlineData("State", "active")]
+ [InlineData("State", 0)]
+ public async Task Strict_management_response_rejects_noncanonical_enums(string property, object value)
+ {
+ var document = JsonNode.Parse(await ReadFixture("responses", "policy-management.active.response.json"))!;
+ document["Management"]![property] = value is int number
+ ? JsonValue.Create(number)
+ : JsonValue.Create((string)value);
+
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(document.ToJsonString()));
+ }
+
+ [Fact]
+ public async Task Strict_management_contract_rejects_empty_tokens_and_receipts()
+ {
+ var management = JsonNode.Parse(await ReadFixture("responses", "policy-management.active.response.json"))!;
+ management["Management"]!["StoreToken"] = "";
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(management.ToJsonString()));
+
+ var replacement = JsonNode.Parse(await ReadFixture("requests", "policy-replacement.update.request.json"))!;
+ replacement["ExpectedStoreToken"] = "";
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(replacement.ToJsonString()));
+ replacement["ExpectedStoreToken"] = "store:active:7";
+ replacement["ValidationReceipt"] = "";
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(replacement.ToJsonString()));
+ }
+
+ [Fact]
+ public async Task Opaque_tokens_and_receipts_reject_non_ascii_values()
+ {
+ var management = JsonNode.Parse(await ReadFixture("responses", "policy-management.active.response.json"))!;
+ management["Management"]!["StoreToken"] = "store:activé:7";
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(management.ToJsonString()));
+ Assert.NotEmpty(
+ (await TestData.SchemaAsync("PolicyManagementResponse")).Validate(management.ToJsonString()));
+
+ var replacement = JsonNode.Parse(await ReadFixture("requests", "policy-replacement.update.request.json"))!;
+ replacement["ValidationReceipt"] = "receipt:é";
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(replacement.ToJsonString()));
+ Assert.NotEmpty(
+ (await TestData.SchemaAsync("PolicyReplacementRequest")).Validate(replacement.ToJsonString()));
+
+ var managementDto = BrokerJson.DeserializeStrict(
+ await ReadFixture("responses", "policy-management.active.response.json"))!;
+ managementDto.Management.StoreToken = "store:activé:7";
+ Assert.Throws(() => BrokerJson.Serialize(managementDto));
+
+ var replacementDto = BrokerJson.DeserializeStrict(
+ await ReadFixture("requests", "policy-replacement.update.request.json"))!;
+ replacementDto.ValidationReceipt = "receipt:é";
+ Assert.Throws(() => BrokerJson.Serialize(replacementDto));
+ }
+
+ [Fact]
+ public async Task Strict_validation_response_enforces_success_artifact_invariant()
+ {
+ var valid = JsonNode.Parse(await ReadFixture("responses", "policy-validation.valid.response.json"))!;
+ valid["Validation"]!.AsObject().Remove("CanonicalDraft");
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(valid.ToJsonString()));
+
+ var invalid = JsonNode.Parse(await ReadFixture("responses", "policy-validation.invalid.response.json"))!;
+ invalid["Validation"]!["ValidationReceipt"] = "unexpected-receipt";
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(invalid.ToJsonString()));
+
+ var validWithNull = JsonNode.Parse(await ReadFixture("responses", "policy-validation.valid.response.json"))!;
+ validWithNull["Validation"]!["CanonicalDraft"] = null;
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(validWithNull.ToJsonString()));
+ Assert.NotEmpty(
+ (await TestData.SchemaAsync("PolicyValidationResponse")).Validate(validWithNull.ToJsonString()));
+ }
+
+ [Fact]
+ public async Task Active_management_snapshot_rejects_explicit_null_policy()
+ {
+ var active = JsonNode.Parse(await ReadFixture("responses", "policy-management.active.response.json"))!;
+ active["Management"]!["Policy"] = null;
+
+ Assert.Throws(
+ () => BrokerJson.DeserializeStrict(active.ToJsonString()));
+ Assert.NotEmpty(
+ (await TestData.SchemaAsync("PolicyManagementResponse")).Validate(active.ToJsonString()));
+ }
+
+ [Fact]
+ public async Task Optional_nulls_match_absent_values_in_none_states()
+ {
+ var invalid = JsonNode.Parse(await ReadFixture("responses", "policy-validation.invalid.response.json"))!;
+ invalid["Validation"]!["CanonicalDraft"] = null;
+ invalid["Validation"]!["ValidationReceipt"] = null;
+ Assert.NotNull(BrokerJson.DeserializeStrict(invalid.ToJsonString()));
+ Assert.Empty((await TestData.SchemaAsync("PolicyValidationResponse")).Validate(invalid.ToJsonString()));
+
+ var missing = JsonNode.Parse(await ReadFixture("responses", "policy-management.missing.response.json"))!;
+ missing["Management"]!["Policy"] = null;
+ missing["Management"]!["InvalidDiagnostics"] = null;
+ missing["Management"]!["ReadOnlyReason"] = null;
+ Assert.NotNull(BrokerJson.DeserializeStrict(missing.ToJsonString()));
+ Assert.Empty((await TestData.SchemaAsync("PolicyManagementResponse")).Validate(missing.ToJsonString()));
+ }
+
+ [Theory]
+ [InlineData("policy-validation.valid-with-error.response.json")]
+ [InlineData("policy-validation.invalid-with-warning.response.json")]
+ [InlineData("policy-validation.invalid-with-empty-findings.response.json")]
+ public async Task Strict_validation_response_rejects_contradictory_findings(string fixture)
+ {
+ var json = await ReadFixture(Path.Combine("invalid", "responses"), fixture);
+ Assert.Throws(() => BrokerJson.DeserializeStrict(json));
+ }
+
+ [Theory]
+ [InlineData("policy-management.active-without-policy.response.json")]
+ [InlineData("policy-management.invalid-with-warning.response.json")]
+ [InlineData("policy-management.readonly-without-reason.response.json")]
+ public async Task Strict_management_response_rejects_contradictory_snapshot(string fixture)
+ {
+ var json = await ReadFixture(Path.Combine("invalid", "responses"), fixture);
+ Assert.Throws(() => BrokerJson.DeserializeStrict(json));
+ }
+
+ [Theory]
+ [InlineData("policy-validation.valid-with-error.response.json", true)]
+ [InlineData("policy-validation.invalid-with-warning.response.json", true)]
+ [InlineData("policy-management.active-without-policy.response.json", false)]
+ [InlineData("policy-management.readonly-without-reason.response.json", false)]
+ public async Task Non_strict_deserialization_still_enforces_semantic_invariants(string fixture, bool validation)
+ {
+ var json = await ReadFixture(Path.Combine("invalid", "responses"), fixture);
+ if (validation)
+ {
+ Assert.Throws(() => BrokerJson.Deserialize(json));
+ }
+ else
+ {
+ Assert.Throws(() => BrokerJson.Deserialize(json));
+ }
+ }
+
+ [Fact]
+ public async Task Non_strict_replacement_deserialization_enforces_nested_invariants()
+ {
+ var response = JsonNode.Parse(await ReadFixture("responses", "policy-replacement.response.json"))!;
+ response["Validation"]!["IsValid"] = false;
+ response["Validation"]!.AsObject().Remove("CanonicalDraft");
+ response["Validation"]!.AsObject().Remove("ValidationReceipt");
+
+ Assert.Throws(
+ () => BrokerJson.Deserialize(response.ToJsonString()));
+ }
+
+ [Theory]
+ [InlineData("policy-validation.valid-with-error.response.json", "PolicyValidationResponse")]
+ [InlineData("policy-validation.invalid-with-warning.response.json", "PolicyValidationResponse")]
+ [InlineData("policy-validation.invalid-with-empty-findings.response.json", "PolicyValidationResponse")]
+ [InlineData("policy-management.active-without-policy.response.json", "PolicyManagementResponse")]
+ [InlineData("policy-management.invalid-with-warning.response.json", "PolicyManagementResponse")]
+ [InlineData("policy-management.readonly-without-reason.response.json", "PolicyManagementResponse")]
+ public async Task OpenApi_rejects_contradictory_policy_management_contracts(string fixture, string component)
+ {
+ var json = await ReadFixture(Path.Combine("invalid", "responses"), fixture);
+ var schema = await TestData.SchemaAsync(component);
+ Assert.NotEmpty(schema.Validate(json));
+ }
+
+ [Fact]
+ public async Task Serialization_rejects_contradictory_policy_management_contracts()
+ {
+ var validation = BrokerJson.DeserializeStrict(
+ await ReadFixture("responses", "policy-validation.invalid.response.json"))!;
+ validation.Validation.Findings.Clear();
+ Assert.Throws(() => BrokerJson.Serialize(validation));
+
+ var management = BrokerJson.DeserializeStrict(
+ await ReadFixture("responses", "policy-management.missing.response.json"))!;
+ management.Management.State = PolicyManagementState.Active;
+ Assert.Throws(() => BrokerJson.Serialize(management));
+ }
+
+ [Theory]
+ [InlineData("\"stalepolicystoretoken\"")]
+ [InlineData("16")]
+ public async Task Management_error_rejects_noncanonical_error_code(string value)
+ {
+ var error = JsonNode.Parse(await ReadFixture("responses", "policy-stale-token.error.json"))!;
+ error["Code"] = JsonNode.Parse(value);
+
+ Assert.Throws(() => BrokerJson.Deserialize(error.ToJsonString()));
+ }
+
+ [Fact]
+ public async Task Management_error_enforces_validation_result_invariant()
+ {
+ var error = JsonNode.Parse(await ReadFixture("responses", "policy-stale-token.error.json"))!;
+ error["Validation"]!["IsValid"] = true;
+
+ Assert.Throws(() => BrokerJson.Deserialize(error.ToJsonString()));
+ }
+
+ [Fact]
+ public async Task Stale_token_error_requires_atomic_management_snapshot()
+ {
+ var error = JsonNode.Parse(await ReadFixture("responses", "policy-stale-token.error.json"))!;
+ error.AsObject().Remove("Management");
+
+ Assert.Throws(() => BrokerJson.Deserialize(error.ToJsonString()));
+ Assert.NotEmpty((await TestData.SchemaAsync("ErrorResponse")).Validate(error.ToJsonString()));
+
+ var contradictory = JsonNode.Parse(await ReadFixture("responses", "policy-stale-token.error.json"))!;
+ contradictory["Management"]!.AsObject().Remove("Policy");
+ Assert.Throws(() => BrokerJson.Deserialize(contradictory.ToJsonString()));
+
+ var dto = new ErrorResponse
+ {
+ Code = ErrorCode.StalePolicyStoreToken,
+ Message = "stale",
+ };
+ Assert.Throws(() => BrokerJson.Serialize(dto));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("not found")]
+ public async Task GetPolicyManagement_preserves_legacy_route_not_found(string body)
+ {
+ var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 404, Body = body });
+
+ var exception = await Assert.ThrowsAsync(
+ () => CreateClient(transport).GetPolicyManagement());
+
+ Assert.True(
+ exception.Kind is BrokerClientErrorKind.EmptyResponse or BrokerClientErrorKind.BrokerError,
+ $"unexpected legacy 404 error kind: {exception.Kind}");
+ Assert.Equal(404, exception.StatusCode);
+ Assert.Null(exception.BrokerError);
+ }
+
+ private static async Task ReadFixture(string directory, string file) =>
+ await File.ReadAllTextAsync(Path.Combine(TestData.SamplesDir, directory, file));
+
+ private static JsonElement DraftForSerializedRequestSize(
+ TRequest request,
+ Action setDraft,
+ int targetSize)
+ {
+ using var emptyDraft = JsonDocument.Parse("""{"Padding":""}""");
+ setDraft(request, emptyDraft.RootElement.Clone());
+ var baseSize = Encoding.UTF8.GetByteCount(BrokerJson.Serialize(request));
+ var paddingLength = targetSize - baseSize;
+ Assert.True(paddingLength >= 0);
+
+ using var paddedDraft = JsonDocument.Parse($$"""{"Padding":"{{new string('x', paddingLength)}}"}""");
+ return paddedDraft.RootElement.Clone();
+ }
+
+ private static BrokerClient CreateClient(FakeBrokerTransport transport) => new(new BrokerClientOptions
+ {
+ Transport = transport,
+ EffectiveUser = "DEVOLUTIONS\\bob",
+ RequestedElevation = Elevation.Standard,
+ ClientExecutablePath = "C:\\Tools\\client.exe",
+ ClientVersion = "9.8.7",
+ });
+
+ private sealed class FakeBrokerTransport(params BrokerTransportResponse[] responses) : IBrokerTransport
+ {
+ private readonly Queue _responses = new(responses);
+
+ public Transport Kind => Transport.HttpNamedPipe;
+
+ public List Requests { get; } = [];
+
+ public Task Send(
+ BrokerTransportRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Requests.Add(request);
+ if (_responses.Count == 0)
+ {
+ throw new InvalidOperationException($"No fake broker response queued for {request.Path}.");
+ }
+
+ return Task.FromResult(_responses.Dequeue());
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs
index e01d110..9e5d7ec 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs
@@ -38,6 +38,15 @@ public async Task Broker_api_version_matches_openapi_and_message_versions()
Assert.Equal(BrokerApi.Version, new ErrorResponse().ResponseVersion);
}
+ [Fact]
+ public async Task Policy_management_body_limit_matches_generated_openapi()
+ {
+ var limits = await TestData.OpenApiPolicyManagementBodyLimitsAsync();
+
+ Assert.Equal(2, limits.Count);
+ Assert.All(limits, limit => Assert.Equal(BrokerApi.MaxPolicyManagementBodyBytes, limit));
+ }
+
[Theory]
[MemberData(nameof(TestData.RequestSamples), MemberType = typeof(TestData))]
public async Task Request_samples_are_schema_valid(string path)
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs
index fa16f1a..434822d 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs
@@ -51,6 +51,32 @@ public static async Task OpenApiVersionAsync()
return doc.Info.Version;
}
+ public static async Task> OpenApiPolicyManagementBodyLimitsAsync()
+ {
+ var yamlText = await File.ReadAllTextAsync(OpenApiSpec);
+ var stream = new YamlStream();
+ stream.Load(new StringReader(yamlText));
+
+ var root = (YamlMappingNode)stream.Documents[0].RootNode;
+ var paths = GetMapping(root, "paths");
+ var limits = new List();
+ foreach (var (path, method) in new[]
+ {
+ ("/v1/policy/validate", "post"),
+ ("/v1/policy", "put"),
+ })
+ {
+ var operation = GetMapping(GetMapping(paths, path), method);
+ var value = (YamlScalarNode)operation.Children[new YamlScalarNode("x-max-request-body-bytes")];
+ limits.Add(int.Parse(value.Value!, CultureInfo.InvariantCulture));
+ }
+
+ return limits;
+ }
+
+ private static YamlMappingNode GetMapping(YamlMappingNode parent, string key) =>
+ (YamlMappingNode)parent.Children[new YamlScalarNode(key)];
+
private static async Task LoadOpenApiAsync()
{
await s_docLock.WaitAsync();
@@ -142,6 +168,7 @@ public static IEnumerable