feat: Implement Scheduler Worker Options and Planner Loop
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:
2025-10-27 09:46:31 +02:00
parent 96d52884e8
commit 730354a1af
135 changed files with 10721 additions and 946 deletions

View File

@@ -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);