up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 09:40:40 +02:00
parent 1c6730a1d2
commit 05da719048
206 changed files with 34741 additions and 1751 deletions

View File

@@ -0,0 +1,184 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Events;
/// <summary>
/// Type of policy effective event.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PolicyEffectiveEventType>))]
public enum PolicyEffectiveEventType
{
/// <summary>Policy decision changed for a subject.</summary>
[JsonPropertyName("policy.effective.updated")]
EffectiveUpdated,
/// <summary>Policy decision added for new subject.</summary>
[JsonPropertyName("policy.effective.added")]
EffectiveAdded,
/// <summary>Policy decision removed (subject no longer affected).</summary>
[JsonPropertyName("policy.effective.removed")]
EffectiveRemoved,
/// <summary>Batch re-evaluation completed.</summary>
[JsonPropertyName("policy.effective.batch_completed")]
BatchCompleted
}
/// <summary>
/// Base class for policy effective events.
/// </summary>
public abstract record PolicyEffectiveEvent(
[property: JsonPropertyName("event_id")] string EventId,
[property: JsonPropertyName("event_type")] PolicyEffectiveEventType EventType,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("correlation_id")] string? CorrelationId);
/// <summary>
/// Event emitted when a policy decision is updated for a subject.
/// </summary>
public sealed record PolicyEffectiveUpdatedEvent(
string EventId,
string TenantId,
DateTimeOffset Timestamp,
string? CorrelationId,
[property: JsonPropertyName("pack_id")] string PackId,
[property: JsonPropertyName("pack_version")] int PackVersion,
[property: JsonPropertyName("subject_purl")] string SubjectPurl,
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
[property: JsonPropertyName("trigger_type")] string TriggerType,
[property: JsonPropertyName("diff")] PolicyDecisionDiff Diff)
: PolicyEffectiveEvent(EventId, PolicyEffectiveEventType.EffectiveUpdated, TenantId, Timestamp, CorrelationId);
/// <summary>
/// Diff metadata for policy decision changes.
/// </summary>
public sealed record PolicyDecisionDiff(
[property: JsonPropertyName("old_status")] string? OldStatus,
[property: JsonPropertyName("new_status")] string NewStatus,
[property: JsonPropertyName("old_severity")] string? OldSeverity,
[property: JsonPropertyName("new_severity")] string? NewSeverity,
[property: JsonPropertyName("old_rule")] string? OldRule,
[property: JsonPropertyName("new_rule")] string? NewRule,
[property: JsonPropertyName("old_priority")] int? OldPriority,
[property: JsonPropertyName("new_priority")] int? NewPriority,
[property: JsonPropertyName("status_changed")] bool StatusChanged,
[property: JsonPropertyName("severity_changed")] bool SeverityChanged,
[property: JsonPropertyName("rule_changed")] bool RuleChanged,
[property: JsonPropertyName("annotations_added")] ImmutableArray<string> AnnotationsAdded,
[property: JsonPropertyName("annotations_removed")] ImmutableArray<string> AnnotationsRemoved)
{
/// <summary>
/// Creates a diff between two policy decisions.
/// </summary>
public static PolicyDecisionDiff Create(
string? oldStatus, string newStatus,
string? oldSeverity, string? newSeverity,
string? oldRule, string? newRule,
int? oldPriority, int? newPriority,
ImmutableDictionary<string, string>? oldAnnotations,
ImmutableDictionary<string, string>? newAnnotations)
{
var oldKeys = oldAnnotations?.Keys ?? Enumerable.Empty<string>();
var newKeys = newAnnotations?.Keys ?? Enumerable.Empty<string>();
var annotationsAdded = newKeys
.Where(k => oldAnnotations?.ContainsKey(k) != true)
.OrderBy(k => k)
.ToImmutableArray();
var annotationsRemoved = oldKeys
.Where(k => newAnnotations?.ContainsKey(k) != true)
.OrderBy(k => k)
.ToImmutableArray();
return new PolicyDecisionDiff(
OldStatus: oldStatus,
NewStatus: newStatus,
OldSeverity: oldSeverity,
NewSeverity: newSeverity,
OldRule: oldRule,
NewRule: newRule,
OldPriority: oldPriority,
NewPriority: newPriority,
StatusChanged: !string.Equals(oldStatus, newStatus, StringComparison.Ordinal),
SeverityChanged: !string.Equals(oldSeverity, newSeverity, StringComparison.Ordinal),
RuleChanged: !string.Equals(oldRule, newRule, StringComparison.Ordinal),
AnnotationsAdded: annotationsAdded,
AnnotationsRemoved: annotationsRemoved);
}
}
/// <summary>
/// Event emitted when batch re-evaluation completes.
/// </summary>
public sealed record PolicyBatchCompletedEvent(
string EventId,
string TenantId,
DateTimeOffset Timestamp,
string? CorrelationId,
[property: JsonPropertyName("batch_id")] string BatchId,
[property: JsonPropertyName("trigger_type")] string TriggerType,
[property: JsonPropertyName("subjects_evaluated")] int SubjectsEvaluated,
[property: JsonPropertyName("decisions_changed")] int DecisionsChanged,
[property: JsonPropertyName("duration_ms")] long DurationMs,
[property: JsonPropertyName("summary")] PolicyBatchSummary Summary)
: PolicyEffectiveEvent(EventId, PolicyEffectiveEventType.BatchCompleted, TenantId, Timestamp, CorrelationId);
/// <summary>
/// Summary of changes in a batch re-evaluation.
/// </summary>
public sealed record PolicyBatchSummary(
[property: JsonPropertyName("status_upgrades")] int StatusUpgrades,
[property: JsonPropertyName("status_downgrades")] int StatusDowngrades,
[property: JsonPropertyName("new_blocks")] int NewBlocks,
[property: JsonPropertyName("blocks_removed")] int BlocksRemoved,
[property: JsonPropertyName("affected_advisories")] ImmutableArray<string> AffectedAdvisories,
[property: JsonPropertyName("affected_purls")] ImmutableArray<string> AffectedPurls);
/// <summary>
/// Request to schedule a re-evaluation job.
/// </summary>
public sealed record ReEvaluationJobRequest(
string JobId,
string TenantId,
string PackId,
int PackVersion,
string TriggerType,
string? CorrelationId,
DateTimeOffset CreatedAt,
PolicyChangePriority Priority,
ImmutableArray<string> AdvisoryIds,
ImmutableArray<string> SubjectPurls,
ImmutableArray<string> SbomIds,
ImmutableDictionary<string, string> Metadata)
{
/// <summary>
/// Creates a deterministic job ID.
/// </summary>
public static string CreateJobId(
string tenantId,
string packId,
int packVersion,
string triggerType,
DateTimeOffset createdAt)
{
var seed = $"{tenantId}|{packId}|{packVersion}|{triggerType}|{createdAt:O}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"rej-{Convert.ToHexStringLower(bytes)[..16]}";
}
}
/// <summary>
/// Policy change priority from IncrementalOrchestrator namespace.
/// </summary>
public enum PolicyChangePriority
{
Normal = 0,
High = 1,
Emergency = 2
}

View File

@@ -0,0 +1,454 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.IncrementalOrchestrator;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Events;
/// <summary>
/// Interface for publishing policy effective events.
/// </summary>
public interface IPolicyEffectiveEventPublisher
{
/// <summary>
/// Publishes a policy effective updated event.
/// </summary>
Task PublishEffectiveUpdatedAsync(PolicyEffectiveUpdatedEvent evt, CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a batch completed event.
/// </summary>
Task PublishBatchCompletedAsync(PolicyBatchCompletedEvent evt, CancellationToken cancellationToken = default);
/// <summary>
/// Registers a handler for effective events.
/// </summary>
void RegisterHandler(Func<PolicyEffectiveEvent, Task> handler);
}
/// <summary>
/// Interface for scheduling re-evaluation jobs.
/// </summary>
public interface IReEvaluationJobScheduler
{
/// <summary>
/// Schedules a re-evaluation job.
/// </summary>
Task<string> ScheduleAsync(ReEvaluationJobRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Gets pending job count.
/// </summary>
int GetPendingJobCount();
/// <summary>
/// Gets job by ID.
/// </summary>
ReEvaluationJobRequest? GetJob(string jobId);
}
/// <summary>
/// Processes policy change events, schedules re-evaluations, and emits effective events.
/// </summary>
public sealed class PolicyEventProcessor : IPolicyEffectiveEventPublisher, IReEvaluationJobScheduler
{
private readonly ILogger<PolicyEventProcessor> _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentQueue<ReEvaluationJobRequest> _jobQueue;
private readonly ConcurrentDictionary<string, ReEvaluationJobRequest> _jobIndex;
private readonly ConcurrentQueue<PolicyEffectiveEvent> _eventStream;
private readonly List<Func<PolicyEffectiveEvent, Task>> _eventHandlers;
private readonly object _handlersLock = new();
private const int MaxQueueSize = 10000;
private const int MaxEventStreamSize = 50000;
public PolicyEventProcessor(
ILogger<PolicyEventProcessor> logger,
TimeProvider timeProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_jobQueue = new ConcurrentQueue<ReEvaluationJobRequest>();
_jobIndex = new ConcurrentDictionary<string, ReEvaluationJobRequest>(StringComparer.OrdinalIgnoreCase);
_eventStream = new ConcurrentQueue<PolicyEffectiveEvent>();
_eventHandlers = new List<Func<PolicyEffectiveEvent, Task>>();
}
/// <summary>
/// Processes a policy change event and schedules re-evaluation if needed.
/// </summary>
public async Task<string?> ProcessChangeEventAsync(
PolicyChangeEvent changeEvent,
string packId,
int packVersion,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(changeEvent);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("policy_event.process", ActivityKind.Internal);
activity?.SetTag("event.id", changeEvent.EventId);
activity?.SetTag("event.type", changeEvent.ChangeType.ToString());
activity?.SetTag("tenant.id", changeEvent.TenantId);
_logger.LogDebug(
"Processing policy change event {EventId}: {ChangeType} for tenant {TenantId}",
changeEvent.EventId, changeEvent.ChangeType, changeEvent.TenantId);
// Skip if event targets no subjects
if (changeEvent.AffectedPurls.IsDefaultOrEmpty &&
changeEvent.AffectedSbomIds.IsDefaultOrEmpty &&
changeEvent.AffectedProductKeys.IsDefaultOrEmpty)
{
_logger.LogDebug("Skipping event {EventId}: no affected subjects", changeEvent.EventId);
return null;
}
// Create re-evaluation job request
var jobId = ReEvaluationJobRequest.CreateJobId(
changeEvent.TenantId,
packId,
packVersion,
changeEvent.ChangeType.ToString(),
_timeProvider.GetUtcNow());
var jobRequest = new ReEvaluationJobRequest(
JobId: jobId,
TenantId: changeEvent.TenantId,
PackId: packId,
PackVersion: packVersion,
TriggerType: changeEvent.ChangeType.ToString(),
CorrelationId: changeEvent.CorrelationId,
CreatedAt: _timeProvider.GetUtcNow(),
Priority: MapPriority(changeEvent.Priority),
AdvisoryIds: changeEvent.AdvisoryId is not null
? ImmutableArray.Create(changeEvent.AdvisoryId)
: ImmutableArray<string>.Empty,
SubjectPurls: changeEvent.AffectedPurls,
SbomIds: changeEvent.AffectedSbomIds,
Metadata: changeEvent.Metadata);
// Schedule the job
var scheduledId = await ScheduleAsync(jobRequest, cancellationToken).ConfigureAwait(false);
activity?.SetTag("job.id", scheduledId);
PolicyEngineTelemetry.PolicyEventsProcessed.Add(1);
return scheduledId;
}
/// <summary>
/// Processes results from a re-evaluation and emits effective events.
/// </summary>
public async Task ProcessReEvaluationResultsAsync(
string jobId,
string tenantId,
string packId,
int packVersion,
string triggerType,
string? correlationId,
IReadOnlyList<PolicyDecisionChange> changes,
long durationMs,
CancellationToken cancellationToken = default)
{
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("policy_event.emit_results", ActivityKind.Internal);
activity?.SetTag("job.id", jobId);
activity?.SetTag("changes.count", changes.Count);
var now = _timeProvider.GetUtcNow();
var changedCount = 0;
// Emit individual effective events for each changed decision
foreach (var change in changes)
{
if (!change.HasChanged)
{
continue;
}
changedCount++;
var diff = PolicyDecisionDiff.Create(
change.OldStatus, change.NewStatus,
change.OldSeverity, change.NewSeverity,
change.OldRule, change.NewRule,
change.OldPriority, change.NewPriority,
change.OldAnnotations, change.NewAnnotations);
var evt = new PolicyEffectiveUpdatedEvent(
EventId: GenerateEventId(),
TenantId: tenantId,
Timestamp: now,
CorrelationId: correlationId,
PackId: packId,
PackVersion: packVersion,
SubjectPurl: change.SubjectPurl,
AdvisoryId: change.AdvisoryId,
TriggerType: triggerType,
Diff: diff);
await PublishEffectiveUpdatedAsync(evt, cancellationToken).ConfigureAwait(false);
}
// Emit batch completed event
var summary = ComputeBatchSummary(changes);
var batchEvent = new PolicyBatchCompletedEvent(
EventId: GenerateEventId(),
TenantId: tenantId,
Timestamp: now,
CorrelationId: correlationId,
BatchId: jobId,
TriggerType: triggerType,
SubjectsEvaluated: changes.Count,
DecisionsChanged: changedCount,
DurationMs: durationMs,
Summary: summary);
await PublishBatchCompletedAsync(batchEvent, cancellationToken).ConfigureAwait(false);
activity?.SetTag("decisions.changed", changedCount);
_logger.LogInformation(
"Re-evaluation {JobId} completed: {Evaluated} subjects, {Changed} decisions changed in {Duration}ms",
jobId, changes.Count, changedCount, durationMs);
}
/// <inheritdoc/>
public async Task PublishEffectiveUpdatedAsync(
PolicyEffectiveUpdatedEvent evt,
CancellationToken cancellationToken = default)
{
await PublishEventAsync(evt).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task PublishBatchCompletedAsync(
PolicyBatchCompletedEvent evt,
CancellationToken cancellationToken = default)
{
await PublishEventAsync(evt).ConfigureAwait(false);
}
/// <inheritdoc/>
public void RegisterHandler(Func<PolicyEffectiveEvent, Task> handler)
{
ArgumentNullException.ThrowIfNull(handler);
lock (_handlersLock)
{
_eventHandlers.Add(handler);
}
}
/// <inheritdoc/>
public Task<string> ScheduleAsync(ReEvaluationJobRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Check for duplicate
if (_jobIndex.ContainsKey(request.JobId))
{
_logger.LogDebug("Duplicate job {JobId} ignored", request.JobId);
return Task.FromResult(request.JobId);
}
// Enforce queue limit
if (_jobQueue.Count >= MaxQueueSize)
{
_logger.LogWarning("Job queue full, rejecting job {JobId}", request.JobId);
throw new InvalidOperationException("Re-evaluation job queue is full");
}
_jobIndex[request.JobId] = request;
_jobQueue.Enqueue(request);
PolicyEngineTelemetry.ReEvaluationJobsScheduled.Add(1);
_logger.LogDebug(
"Scheduled re-evaluation job {JobId}: {TriggerType} for {TenantId}/{PackId}@{Version}",
request.JobId, request.TriggerType, request.TenantId, request.PackId, request.PackVersion);
return Task.FromResult(request.JobId);
}
/// <inheritdoc/>
public int GetPendingJobCount() => _jobQueue.Count;
/// <inheritdoc/>
public ReEvaluationJobRequest? GetJob(string jobId)
{
_jobIndex.TryGetValue(jobId, out var job);
return job;
}
/// <summary>
/// Dequeues the next job for processing.
/// </summary>
public ReEvaluationJobRequest? DequeueJob()
{
if (_jobQueue.TryDequeue(out var job))
{
_jobIndex.TryRemove(job.JobId, out _);
return job;
}
return null;
}
/// <summary>
/// Gets recent effective events.
/// </summary>
public IReadOnlyList<PolicyEffectiveEvent> GetRecentEvents(int limit = 100)
{
return _eventStream
.ToArray()
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToList()
.AsReadOnly();
}
private async Task PublishEventAsync(PolicyEffectiveEvent evt)
{
// Add to stream
_eventStream.Enqueue(evt);
// Trim if too large
while (_eventStream.Count > MaxEventStreamSize)
{
_eventStream.TryDequeue(out _);
}
// Invoke handlers
List<Func<PolicyEffectiveEvent, Task>> handlers;
lock (_handlersLock)
{
handlers = _eventHandlers.ToList();
}
foreach (var handler in handlers)
{
try
{
await handler(evt).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking event handler for {EventType}", evt.EventType);
}
}
PolicyEngineTelemetry.PolicyEffectiveEventsPublished.Add(1);
}
private static PolicyBatchSummary ComputeBatchSummary(IReadOnlyList<PolicyDecisionChange> changes)
{
var statusUpgrades = 0;
var statusDowngrades = 0;
var newBlocks = 0;
var blocksRemoved = 0;
var advisories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var purls = new HashSet<string>(StringComparer.Ordinal);
foreach (var change in changes)
{
advisories.Add(change.AdvisoryId);
purls.Add(change.SubjectPurl);
if (!change.HasChanged)
{
continue;
}
var severityChange = CompareSeverity(change.OldStatus, change.NewStatus);
if (severityChange > 0)
{
statusUpgrades++;
}
else if (severityChange < 0)
{
statusDowngrades++;
}
if (IsBlockStatus(change.NewStatus) && !IsBlockStatus(change.OldStatus))
{
newBlocks++;
}
else if (IsBlockStatus(change.OldStatus) && !IsBlockStatus(change.NewStatus))
{
blocksRemoved++;
}
}
return new PolicyBatchSummary(
StatusUpgrades: statusUpgrades,
StatusDowngrades: statusDowngrades,
NewBlocks: newBlocks,
BlocksRemoved: blocksRemoved,
AffectedAdvisories: advisories.OrderBy(a => a).ToImmutableArray(),
AffectedPurls: purls.OrderBy(p => p).Take(100).ToImmutableArray());
}
private static int CompareSeverity(string? oldStatus, string? newStatus)
{
var oldSeverity = GetStatusSeverityLevel(oldStatus);
var newSeverity = GetStatusSeverityLevel(newStatus);
return newSeverity.CompareTo(oldSeverity);
}
private static int GetStatusSeverityLevel(string? status) => status?.ToLowerInvariant() switch
{
"blocked" => 4,
"deny" => 4,
"warn" => 3,
"affected" => 2,
"allow" => 1,
"ignored" => 0,
_ => 1
};
private static bool IsBlockStatus(string? status) =>
string.Equals(status, "blocked", StringComparison.OrdinalIgnoreCase) ||
string.Equals(status, "deny", StringComparison.OrdinalIgnoreCase);
private static Events.PolicyChangePriority MapPriority(IncrementalOrchestrator.PolicyChangePriority priority) =>
priority switch
{
IncrementalOrchestrator.PolicyChangePriority.Emergency => Events.PolicyChangePriority.Emergency,
IncrementalOrchestrator.PolicyChangePriority.High => Events.PolicyChangePriority.High,
_ => Events.PolicyChangePriority.Normal
};
private static string GenerateEventId()
{
var guid = Guid.NewGuid().ToByteArray();
return $"pee-{Convert.ToHexStringLower(guid)[..16]}";
}
}
/// <summary>
/// Represents a change in policy decision for a subject.
/// </summary>
public sealed record PolicyDecisionChange(
string SubjectPurl,
string AdvisoryId,
string? OldStatus,
string NewStatus,
string? OldSeverity,
string? NewSeverity,
string? OldRule,
string? NewRule,
int? OldPriority,
int? NewPriority,
ImmutableDictionary<string, string>? OldAnnotations,
ImmutableDictionary<string, string>? NewAnnotations)
{
/// <summary>
/// Whether the decision has changed.
/// </summary>
public bool HasChanged =>
!string.Equals(OldStatus, NewStatus, StringComparison.Ordinal) ||
!string.Equals(OldSeverity, NewSeverity, StringComparison.Ordinal) ||
!string.Equals(OldRule, NewRule, StringComparison.Ordinal);
}