feat: Implement Scheduler Worker Options and Planner Loop
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			- Added `SchedulerWorkerOptions` class to encapsulate configuration for the scheduler worker. - Introduced `PlannerBackgroundService` to manage the planner loop, fetching and processing planning runs. - Created `PlannerExecutionService` to handle the execution logic for planning runs, including impact targeting and run persistence. - Developed `PlannerExecutionResult` and `PlannerExecutionStatus` to standardize execution outcomes. - Implemented validation logic within `SchedulerWorkerOptions` to ensure proper configuration. - Added documentation for the planner loop and impact targeting features. - Established health check endpoints and authentication mechanisms for the Signals service. - Created unit tests for the Signals API to ensure proper functionality and response handling. - Configured options for authority integration and fallback authentication methods.
This commit is contained in:
		| @@ -180,16 +180,19 @@ public static class PolicyBinder | ||||
|         [JsonPropertyName("description")] | ||||
|         public string? Description { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("metadata")] | ||||
|         public Dictionary<string, JsonNode?>? Metadata { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("rules")] | ||||
|         public List<PolicyRuleModel>? Rules { get; init; } | ||||
|  | ||||
|         [JsonExtensionData] | ||||
|         public Dictionary<string, JsonElement>? Extensions { get; init; } | ||||
|     } | ||||
|  | ||||
|         [JsonPropertyName("metadata")] | ||||
|         public Dictionary<string, JsonNode?>? Metadata { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("rules")] | ||||
|         public List<PolicyRuleModel>? Rules { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("exceptions")] | ||||
|         public PolicyExceptionsModel? Exceptions { get; init; } | ||||
|  | ||||
|         [JsonExtensionData] | ||||
|         public Dictionary<string, JsonElement>? Extensions { get; init; } | ||||
|     } | ||||
|  | ||||
|     private sealed record PolicyRuleModel | ||||
|     { | ||||
|         [JsonPropertyName("id")] | ||||
| @@ -258,18 +261,78 @@ public static class PolicyBinder | ||||
|         [JsonPropertyName("quiet")] | ||||
|         public bool? Quiet { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("metadata")] | ||||
|         public Dictionary<string, JsonNode?>? Metadata { get; init; } | ||||
|  | ||||
|         [JsonExtensionData] | ||||
|         public Dictionary<string, JsonElement>? Extensions { get; init; } | ||||
|     } | ||||
|  | ||||
|     private sealed class PolicyNormalizer | ||||
|     { | ||||
|         private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap = | ||||
|             new Dictionary<string, PolicySeverity>(StringComparer.OrdinalIgnoreCase) | ||||
|             { | ||||
|         [JsonPropertyName("metadata")] | ||||
|         public Dictionary<string, JsonNode?>? Metadata { get; init; } | ||||
|  | ||||
|         [JsonExtensionData] | ||||
|         public Dictionary<string, JsonElement>? Extensions { get; init; } | ||||
|     } | ||||
|  | ||||
|     private sealed record PolicyExceptionsModel | ||||
|     { | ||||
|         [JsonPropertyName("effects")] | ||||
|         public List<PolicyExceptionEffectModel>? Effects { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("routingTemplates")] | ||||
|         public List<PolicyExceptionRoutingTemplateModel>? RoutingTemplates { get; init; } | ||||
|  | ||||
|         [JsonExtensionData] | ||||
|         public Dictionary<string, JsonElement>? Extensions { get; init; } | ||||
|     } | ||||
|  | ||||
|     private sealed record PolicyExceptionEffectModel | ||||
|     { | ||||
|         [JsonPropertyName("id")] | ||||
|         public string? Id { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("name")] | ||||
|         public string? Name { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("description")] | ||||
|         public string? Description { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("effect")] | ||||
|         public string? Effect { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("downgradeSeverity")] | ||||
|         public string? DowngradeSeverity { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("requiredControlId")] | ||||
|         public string? RequiredControlId { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("routingTemplate")] | ||||
|         public string? RoutingTemplate { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("maxDurationDays")] | ||||
|         public int? MaxDurationDays { get; init; } | ||||
|  | ||||
|         [JsonExtensionData] | ||||
|         public Dictionary<string, JsonElement>? Extensions { get; init; } | ||||
|     } | ||||
|  | ||||
|     private sealed record PolicyExceptionRoutingTemplateModel | ||||
|     { | ||||
|         [JsonPropertyName("id")] | ||||
|         public string? Id { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("description")] | ||||
|         public string? Description { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("authorityRouteId")] | ||||
|         public string? AuthorityRouteId { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("requireMfa")] | ||||
|         public bool? RequireMfa { get; init; } | ||||
|  | ||||
|         [JsonExtensionData] | ||||
|         public Dictionary<string, JsonElement>? Extensions { get; init; } | ||||
|     } | ||||
|  | ||||
|     private sealed class PolicyNormalizer | ||||
|     { | ||||
|         private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap = | ||||
|             new Dictionary<string, PolicySeverity>(StringComparer.OrdinalIgnoreCase) | ||||
|             { | ||||
|                 ["critical"] = PolicySeverity.Critical, | ||||
|                 ["high"] = PolicySeverity.High, | ||||
|                 ["medium"] = PolicySeverity.Medium, | ||||
| @@ -282,33 +345,35 @@ public static class PolicyBinder | ||||
|             }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         public static (PolicyDocument Document, ImmutableArray<PolicyIssue> Issues) Normalize(PolicyDocumentModel model) | ||||
|         { | ||||
|             var issues = ImmutableArray.CreateBuilder<PolicyIssue>(); | ||||
|  | ||||
|             var version = NormalizeVersion(model.Version, issues); | ||||
|             var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues); | ||||
|             var rules = NormalizeRules(model.Rules, issues); | ||||
|  | ||||
|             if (model.Extensions is { Count: > 0 }) | ||||
|             { | ||||
|                 foreach (var pair in model.Extensions) | ||||
|                 { | ||||
|         { | ||||
|             var issues = ImmutableArray.CreateBuilder<PolicyIssue>(); | ||||
|  | ||||
|             var version = NormalizeVersion(model.Version, issues); | ||||
|             var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues); | ||||
|             var rules = NormalizeRules(model.Rules, issues); | ||||
|             var exceptions = NormalizeExceptions(model.Exceptions, issues); | ||||
|  | ||||
|             if (model.Extensions is { Count: > 0 }) | ||||
|             { | ||||
|                 foreach (var pair in model.Extensions) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Warning( | ||||
|                         "policy.document.extension", | ||||
|                         $"Unrecognized document property '{pair.Key}' has been ignored.", | ||||
|                         $"$.{pair.Key}")); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             var document = new PolicyDocument( | ||||
|                 version ?? PolicySchema.CurrentVersion, | ||||
|                 rules, | ||||
|                 metadata); | ||||
|  | ||||
|             var orderedIssues = SortIssues(issues); | ||||
|             return (document, orderedIssues); | ||||
|         } | ||||
|  | ||||
|             } | ||||
|  | ||||
|             var document = new PolicyDocument( | ||||
|                 version ?? PolicySchema.CurrentVersion, | ||||
|                 rules, | ||||
|                 metadata, | ||||
|                 exceptions); | ||||
|  | ||||
|             var orderedIssues = SortIssues(issues); | ||||
|             return (document, orderedIssues); | ||||
|         } | ||||
|  | ||||
|         private static string? NormalizeVersion(JsonNode? versionNode, ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (versionNode is null) | ||||
| @@ -392,11 +457,11 @@ public static class PolicyBinder | ||||
|             return builder.ToImmutable(); | ||||
|         } | ||||
|  | ||||
|         private static ImmutableArray<PolicyRule> NormalizeRules( | ||||
|             List<PolicyRuleModel>? rules, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (rules is null || rules.Count == 0) | ||||
|         private static ImmutableArray<PolicyRule> NormalizeRules( | ||||
|             List<PolicyRuleModel>? rules, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (rules is null || rules.Count == 0) | ||||
|             { | ||||
|                 issues.Add(PolicyIssue.Error("policy.rules.empty", "At least one rule must be defined.", "$.rules")); | ||||
|                 return ImmutableArray<PolicyRule>.Empty; | ||||
| @@ -425,19 +490,273 @@ public static class PolicyBinder | ||||
|                 normalized.Add((normalizedRule, index)); | ||||
|             } | ||||
|  | ||||
|             return normalized | ||||
|                 .OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase) | ||||
|                 .ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase) | ||||
|                 .ThenBy(static tuple => tuple.Index) | ||||
|                 .Select(static tuple => tuple.Rule) | ||||
|                 .ToImmutableArray(); | ||||
|         } | ||||
|  | ||||
|         private static PolicyRule? NormalizeRule( | ||||
|             PolicyRuleModel model, | ||||
|             int index, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             return normalized | ||||
|                 .OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase) | ||||
|                 .ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase) | ||||
|                 .ThenBy(static tuple => tuple.Index) | ||||
|                 .Select(static tuple => tuple.Rule) | ||||
|                 .ToImmutableArray(); | ||||
|         } | ||||
|  | ||||
|         private static PolicyExceptionConfiguration NormalizeExceptions( | ||||
|             PolicyExceptionsModel? model, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (model is null) | ||||
|             { | ||||
|                 return PolicyExceptionConfiguration.Empty; | ||||
|             } | ||||
|  | ||||
|             var effects = NormalizeExceptionEffects(model.Effects, "$.exceptions.effects", issues); | ||||
|             var routingTemplates = NormalizeExceptionRoutingTemplates(model.RoutingTemplates, "$.exceptions.routingTemplates", issues); | ||||
|  | ||||
|             if (model.Extensions is { Count: > 0 }) | ||||
|             { | ||||
|                 foreach (var pair in model.Extensions) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Warning( | ||||
|                         "policy.exceptions.extension", | ||||
|                         $"Unrecognized exceptions property '{pair.Key}' has been ignored.", | ||||
|                         $"$.exceptions.{pair.Key}")); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return new PolicyExceptionConfiguration(effects, routingTemplates); | ||||
|         } | ||||
|  | ||||
|         private static ImmutableArray<PolicyExceptionEffect> NormalizeExceptionEffects( | ||||
|             List<PolicyExceptionEffectModel>? models, | ||||
|             string path, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (models is null || models.Count == 0) | ||||
|             { | ||||
|                 return ImmutableArray<PolicyExceptionEffect>.Empty; | ||||
|             } | ||||
|  | ||||
|             var builder = ImmutableArray.CreateBuilder<PolicyExceptionEffect>(); | ||||
|             var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|             for (var index = 0; index < models.Count; index++) | ||||
|             { | ||||
|                 var model = models[index]; | ||||
|                 var basePath = $"{path}[{index}]"; | ||||
|  | ||||
|                 var id = NormalizeOptionalString(model.Id); | ||||
|                 if (string.IsNullOrEmpty(id)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Error( | ||||
|                         "policy.exceptions.effect.id.missing", | ||||
|                         "Exception effect id is required.", | ||||
|                         $"{basePath}.id")); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!seenIds.Add(id)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Error( | ||||
|                         "policy.exceptions.effect.id.duplicate", | ||||
|                         $"Duplicate exception effect id '{id}'.", | ||||
|                         $"{basePath}.id")); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var effectType = NormalizeExceptionEffectType(model.Effect, $"{basePath}.effect", issues); | ||||
|                 if (effectType is null) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 PolicySeverity? downgradeSeverity = null; | ||||
|                 if (!string.IsNullOrWhiteSpace(model.DowngradeSeverity)) | ||||
|                 { | ||||
|                     var severityText = NormalizeOptionalString(model.DowngradeSeverity); | ||||
|                     if (!string.IsNullOrEmpty(severityText) && SeverityMap.TryGetValue(severityText, out var mapped)) | ||||
|                     { | ||||
|                         downgradeSeverity = mapped; | ||||
|                     } | ||||
|                     else if (!string.IsNullOrEmpty(severityText)) | ||||
|                     { | ||||
|                         issues.Add(PolicyIssue.Error( | ||||
|                             "policy.exceptions.effect.downgrade.invalidSeverity", | ||||
|                             $"Unknown downgradeSeverity '{severityText}'.", | ||||
|                             $"{basePath}.downgradeSeverity")); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 var requiredControlId = NormalizeOptionalString(model.RequiredControlId); | ||||
|                 if (effectType == PolicyExceptionEffectType.RequireControl && string.IsNullOrEmpty(requiredControlId)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Error( | ||||
|                         "policy.exceptions.effect.control.missing", | ||||
|                         "requireControl effects must specify requiredControlId.", | ||||
|                         $"{basePath}.requiredControlId")); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (effectType == PolicyExceptionEffectType.Downgrade && downgradeSeverity is null) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Error( | ||||
|                         "policy.exceptions.effect.downgrade.missingSeverity", | ||||
|                         "downgrade effects must specify downgradeSeverity.", | ||||
|                         $"{basePath}.downgradeSeverity")); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var name = NormalizeOptionalString(model.Name); | ||||
|                 var routingTemplate = NormalizeOptionalString(model.RoutingTemplate); | ||||
|                 var description = NormalizeOptionalString(model.Description); | ||||
|                 int? maxDurationDays = null; | ||||
|                 if (model.MaxDurationDays is { } durationValue) | ||||
|                 { | ||||
|                     if (durationValue <= 0) | ||||
|                     { | ||||
|                         issues.Add(PolicyIssue.Error( | ||||
|                             "policy.exceptions.effect.duration.invalid", | ||||
|                             "maxDurationDays must be greater than zero.", | ||||
|                             $"{basePath}.maxDurationDays")); | ||||
|                     } | ||||
|                     else | ||||
|                     { | ||||
|                         maxDurationDays = durationValue; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if (model.Extensions is { Count: > 0 }) | ||||
|                 { | ||||
|                     foreach (var pair in model.Extensions) | ||||
|                     { | ||||
|                         issues.Add(PolicyIssue.Warning( | ||||
|                             "policy.exceptions.effect.extension", | ||||
|                             $"Unrecognized exception effect property '{pair.Key}' has been ignored.", | ||||
|                             $"{basePath}.{pair.Key}")); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 builder.Add(new PolicyExceptionEffect( | ||||
|                     id, | ||||
|                     name, | ||||
|                     effectType.Value, | ||||
|                     downgradeSeverity, | ||||
|                     requiredControlId, | ||||
|                     routingTemplate, | ||||
|                     maxDurationDays, | ||||
|                     description)); | ||||
|             } | ||||
|  | ||||
|             return builder.ToImmutable(); | ||||
|         } | ||||
|  | ||||
|         private static ImmutableArray<PolicyExceptionRoutingTemplate> NormalizeExceptionRoutingTemplates( | ||||
|             List<PolicyExceptionRoutingTemplateModel>? models, | ||||
|             string path, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             if (models is null || models.Count == 0) | ||||
|             { | ||||
|                 return ImmutableArray<PolicyExceptionRoutingTemplate>.Empty; | ||||
|             } | ||||
|  | ||||
|             var builder = ImmutableArray.CreateBuilder<PolicyExceptionRoutingTemplate>(); | ||||
|             var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|             for (var index = 0; index < models.Count; index++) | ||||
|             { | ||||
|                 var model = models[index]; | ||||
|                 var basePath = $"{path}[{index}]"; | ||||
|  | ||||
|                 var id = NormalizeOptionalString(model.Id); | ||||
|                 if (string.IsNullOrEmpty(id)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Error( | ||||
|                         "policy.exceptions.routing.id.missing", | ||||
|                         "Routing template id is required.", | ||||
|                         $"{basePath}.id")); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (!seenIds.Add(id)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Error( | ||||
|                         "policy.exceptions.routing.id.duplicate", | ||||
|                         $"Duplicate routing template id '{id}'.", | ||||
|                         $"{basePath}.id")); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var authorityRouteId = NormalizeOptionalString(model.AuthorityRouteId); | ||||
|                 if (string.IsNullOrEmpty(authorityRouteId)) | ||||
|                 { | ||||
|                     issues.Add(PolicyIssue.Error( | ||||
|                         "policy.exceptions.routing.authority.missing", | ||||
|                         "Routing template must specify authorityRouteId.", | ||||
|                         $"{basePath}.authorityRouteId")); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var description = NormalizeOptionalString(model.Description); | ||||
|                 var requireMfa = model.RequireMfa ?? false; | ||||
|  | ||||
|                 if (model.Extensions is { Count: > 0 }) | ||||
|                 { | ||||
|                     foreach (var pair in model.Extensions) | ||||
|                     { | ||||
|                         issues.Add(PolicyIssue.Warning( | ||||
|                             "policy.exceptions.routing.extension", | ||||
|                             $"Unrecognized routing template property '{pair.Key}' has been ignored.", | ||||
|                             $"{basePath}.{pair.Key}")); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 builder.Add(new PolicyExceptionRoutingTemplate( | ||||
|                     id, | ||||
|                     authorityRouteId, | ||||
|                     requireMfa, | ||||
|                     description)); | ||||
|             } | ||||
|  | ||||
|             return builder.ToImmutable(); | ||||
|         } | ||||
|  | ||||
|         private static PolicyExceptionEffectType? NormalizeExceptionEffectType( | ||||
|             string? value, | ||||
|             string path, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             var normalized = NormalizeOptionalString(value); | ||||
|             if (string.IsNullOrEmpty(normalized)) | ||||
|             { | ||||
|                 issues.Add(PolicyIssue.Error( | ||||
|                     "policy.exceptions.effect.type.missing", | ||||
|                     "Exception effect type is required.", | ||||
|                     path)); | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             switch (normalized.ToLowerInvariant()) | ||||
|             { | ||||
|                 case "suppress": | ||||
|                     return PolicyExceptionEffectType.Suppress; | ||||
|                 case "defer": | ||||
|                     return PolicyExceptionEffectType.Defer; | ||||
|                 case "downgrade": | ||||
|                     return PolicyExceptionEffectType.Downgrade; | ||||
|                 case "requirecontrol": | ||||
|                     return PolicyExceptionEffectType.RequireControl; | ||||
|                 default: | ||||
|                     issues.Add(PolicyIssue.Error( | ||||
|                         "policy.exceptions.effect.type.invalid", | ||||
|                         $"Unsupported exception effect type '{normalized}'.", | ||||
|                         path)); | ||||
|                     return null; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private static PolicyRule? NormalizeRule( | ||||
|             PolicyRuleModel model, | ||||
|             int index, | ||||
|             ImmutableArray<PolicyIssue>.Builder issues) | ||||
|         { | ||||
|             var basePath = $"$.rules[{index}]"; | ||||
|  | ||||
|             var name = NormalizeRequiredString(model.Name, $"{basePath}.name", "Rule name", issues); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user