Add post-quantum cryptography support with PqSoftCryptoProvider
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
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
wine-csp-build / Build Wine CSP Image (push) Has been cancelled

- Implemented PqSoftCryptoProvider for software-only post-quantum algorithms (Dilithium3, Falcon512) using BouncyCastle.
- Added PqSoftProviderOptions and PqSoftKeyOptions for configuration.
- Created unit tests for Dilithium3 and Falcon512 signing and verification.
- Introduced EcdsaPolicyCryptoProvider for compliance profiles (FIPS/eIDAS) with explicit allow-lists.
- Added KcmvpHashOnlyProvider for KCMVP baseline compliance.
- Updated project files and dependencies for new libraries and testing frameworks.
This commit is contained in:
StellaOps Bot
2025-12-07 15:04:19 +02:00
parent 862bb6ed80
commit 98e6b76584
119 changed files with 11436 additions and 1732 deletions

View File

@@ -0,0 +1,300 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Event types for policy profile notifications per docs/modules/policy/notifications.md.
/// </summary>
public static class PolicyProfileNotificationEventTypes
{
public const string ProfileCreated = "policy.profile.created";
public const string ProfileActivated = "policy.profile.activated";
public const string ProfileDeactivated = "policy.profile.deactivated";
public const string ThresholdChanged = "policy.profile.threshold_changed";
public const string OverrideAdded = "policy.profile.override_added";
public const string OverrideRemoved = "policy.profile.override_removed";
public const string SimulationReady = "policy.profile.simulation_ready";
}
/// <summary>
/// Notification event for policy profile lifecycle changes.
/// Follows the contract at docs/modules/policy/notifications.md.
/// </summary>
public sealed record PolicyProfileNotificationEvent
{
/// <summary>
/// Unique event identifier (UUIDv7 for time-ordered deduplication).
/// </summary>
[JsonPropertyName("event_id")]
public required string EventId { get; init; }
/// <summary>
/// Event type from PolicyProfileNotificationEventTypes.
/// </summary>
[JsonPropertyName("event_type")]
public required string EventType { get; init; }
/// <summary>
/// UTC timestamp when the event was emitted.
/// </summary>
[JsonPropertyName("emitted_at")]
public required DateTimeOffset EmittedAt { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Profile identifier.
/// </summary>
[JsonPropertyName("profile_id")]
public required string ProfileId { get; init; }
/// <summary>
/// Profile version affected by this event.
/// </summary>
[JsonPropertyName("profile_version")]
public required string ProfileVersion { get; init; }
/// <summary>
/// Human-readable reason for the change.
/// </summary>
[JsonPropertyName("change_reason")]
public string? ChangeReason { get; init; }
/// <summary>
/// Actor who triggered the event.
/// </summary>
[JsonPropertyName("actor")]
public NotificationActor? Actor { get; init; }
/// <summary>
/// Risk thresholds (populated for threshold_changed events).
/// </summary>
[JsonPropertyName("thresholds")]
public NotificationThresholds? Thresholds { get; init; }
/// <summary>
/// Effective scope for the profile.
/// </summary>
[JsonPropertyName("effective_scope")]
public NotificationEffectiveScope? EffectiveScope { get; init; }
/// <summary>
/// Hash of the profile bundle.
/// </summary>
[JsonPropertyName("hash")]
public NotificationHash? Hash { get; init; }
/// <summary>
/// Related URLs for profile, diff, and simulation.
/// </summary>
[JsonPropertyName("links")]
public NotificationLinks? Links { get; init; }
/// <summary>
/// Trace context for observability.
/// </summary>
[JsonPropertyName("trace")]
public NotificationTraceContext? Trace { get; init; }
/// <summary>
/// Override details (populated for override_added/removed events).
/// </summary>
[JsonPropertyName("override_details")]
public NotificationOverrideDetails? OverrideDetails { get; init; }
/// <summary>
/// Simulation details (populated for simulation_ready events).
/// </summary>
[JsonPropertyName("simulation_details")]
public NotificationSimulationDetails? SimulationDetails { get; init; }
}
/// <summary>
/// Actor information for notifications.
/// </summary>
public sealed record NotificationActor
{
/// <summary>
/// Actor type: "user" or "system".
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Actor identifier (email, service name, etc.).
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
}
/// <summary>
/// Risk thresholds for notifications.
/// </summary>
public sealed record NotificationThresholds
{
[JsonPropertyName("info")]
public double? Info { get; init; }
[JsonPropertyName("low")]
public double? Low { get; init; }
[JsonPropertyName("medium")]
public double? Medium { get; init; }
[JsonPropertyName("high")]
public double? High { get; init; }
[JsonPropertyName("critical")]
public double? Critical { get; init; }
}
/// <summary>
/// Effective scope for profile application.
/// </summary>
public sealed record NotificationEffectiveScope
{
[JsonPropertyName("tenants")]
public IReadOnlyList<string>? Tenants { get; init; }
[JsonPropertyName("projects")]
public IReadOnlyList<string>? Projects { get; init; }
[JsonPropertyName("purl_patterns")]
public IReadOnlyList<string>? PurlPatterns { get; init; }
[JsonPropertyName("cpe_patterns")]
public IReadOnlyList<string>? CpePatterns { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
/// <summary>
/// Hash information for profile content.
/// </summary>
public sealed record NotificationHash
{
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
[JsonPropertyName("value")]
public required string Value { get; init; }
}
/// <summary>
/// Related URLs for the notification.
/// </summary>
public sealed record NotificationLinks
{
[JsonPropertyName("profile_url")]
public string? ProfileUrl { get; init; }
[JsonPropertyName("diff_url")]
public string? DiffUrl { get; init; }
[JsonPropertyName("simulation_url")]
public string? SimulationUrl { get; init; }
}
/// <summary>
/// Trace context for distributed tracing.
/// </summary>
public sealed record NotificationTraceContext
{
[JsonPropertyName("trace_id")]
public string? TraceId { get; init; }
[JsonPropertyName("span_id")]
public string? SpanId { get; init; }
}
/// <summary>
/// Override details for override_added/removed events.
/// </summary>
public sealed record NotificationOverrideDetails
{
[JsonPropertyName("override_id")]
public string? OverrideId { get; init; }
[JsonPropertyName("override_type")]
public string? OverrideType { get; init; }
[JsonPropertyName("target")]
public string? Target { get; init; }
[JsonPropertyName("action")]
public string? Action { get; init; }
[JsonPropertyName("justification")]
public string? Justification { get; init; }
}
/// <summary>
/// Simulation details for simulation_ready events.
/// </summary>
public sealed record NotificationSimulationDetails
{
[JsonPropertyName("simulation_id")]
public string? SimulationId { get; init; }
[JsonPropertyName("findings_count")]
public int? FindingsCount { get; init; }
[JsonPropertyName("high_impact_count")]
public int? HighImpactCount { get; init; }
[JsonPropertyName("completed_at")]
public DateTimeOffset? CompletedAt { get; init; }
}
/// <summary>
/// Request to publish a notification via webhook.
/// </summary>
public sealed record WebhookDeliveryRequest
{
/// <summary>
/// Target webhook URL.
/// </summary>
public required string Url { get; init; }
/// <summary>
/// The notification event to deliver.
/// </summary>
public required PolicyProfileNotificationEvent Event { get; init; }
/// <summary>
/// Shared secret for HMAC signature (X-Stella-Signature header).
/// </summary>
public string? SharedSecret { get; init; }
}
/// <summary>
/// Configuration options for policy profile notifications.
/// </summary>
public sealed class PolicyProfileNotificationOptions
{
/// <summary>
/// Topic name for notifications service delivery.
/// Default: notifications.policy.profiles
/// </summary>
public string TopicName { get; set; } = "notifications.policy.profiles";
/// <summary>
/// Base URL for generating profile links.
/// </summary>
public string? BaseUrl { get; set; }
/// <summary>
/// Whether to include trace context in notifications.
/// </summary>
public bool IncludeTraceContext { get; set; } = true;
/// <summary>
/// Whether notifications are enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,396 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Interface for publishing policy profile notification events.
/// </summary>
public interface IPolicyProfileNotificationPublisher
{
/// <summary>
/// Publishes a notification event to the configured transport.
/// </summary>
Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default);
/// <summary>
/// Delivers a notification via webhook with HMAC signature.
/// </summary>
Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Logging-based notification publisher for policy profile events.
/// Logs notifications as structured events for downstream consumption.
/// </summary>
internal sealed class LoggingPolicyProfileNotificationPublisher : IPolicyProfileNotificationPublisher
{
private readonly ILogger<LoggingPolicyProfileNotificationPublisher> _logger;
private readonly PolicyProfileNotificationOptions _options;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
public LoggingPolicyProfileNotificationPublisher(
ILogger<LoggingPolicyProfileNotificationPublisher> logger,
IOptions<PolicyProfileNotificationOptions> options,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new PolicyProfileNotificationOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
if (!_options.Enabled)
{
_logger.LogDebug(
"Policy profile notifications disabled; skipping event {EventId} type {EventType}",
notification.EventId,
notification.EventType);
return Task.CompletedTask;
}
var payload = JsonSerializer.Serialize(notification, JsonOptions);
_logger.LogInformation(
"PolicyProfileNotification topic={Topic} event_id={EventId} event_type={EventType} tenant={TenantId} profile={ProfileId}@{ProfileVersion} payload={Payload}",
_options.TopicName,
notification.EventId,
notification.EventType,
notification.TenantId,
notification.ProfileId,
notification.ProfileVersion,
payload);
return Task.CompletedTask;
}
public Task<bool> DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var payload = JsonSerializer.Serialize(request.Event, JsonOptions);
var signature = ComputeHmacSignature(payload, request.SharedSecret);
_logger.LogInformation(
"PolicyProfileWebhook url={Url} event_id={EventId} event_type={EventType} signature={Signature}",
request.Url,
request.Event.EventId,
request.Event.EventType,
signature ?? "(no secret)");
return Task.FromResult(true);
}
private static string? ComputeHmacSignature(string payload, string? sharedSecret)
{
if (string.IsNullOrEmpty(sharedSecret))
{
return null;
}
var keyBytes = Encoding.UTF8.GetBytes(sharedSecret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(payloadBytes);
return Convert.ToHexStringLower(hashBytes);
}
}
/// <summary>
/// Factory for creating policy profile notification events.
/// </summary>
public sealed class PolicyProfileNotificationFactory
{
private readonly TimeProvider _timeProvider;
private readonly PolicyProfileNotificationOptions _options;
public PolicyProfileNotificationFactory(
TimeProvider? timeProvider = null,
PolicyProfileNotificationOptions? options = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_options = options ?? new PolicyProfileNotificationOptions();
}
/// <summary>
/// Creates a profile created notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateProfileCreatedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? hash,
NotificationEffectiveScope? scope = null)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ProfileCreated,
tenantId,
profileId,
profileVersion,
"New profile draft created",
actorId,
hash,
scope: scope);
}
/// <summary>
/// Creates a profile activated notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateProfileActivatedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? hash,
NotificationEffectiveScope? scope = null)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ProfileActivated,
tenantId,
profileId,
profileVersion,
"Profile version activated",
actorId,
hash,
scope: scope);
}
/// <summary>
/// Creates a profile deactivated notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateProfileDeactivatedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? reason,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ProfileDeactivated,
tenantId,
profileId,
profileVersion,
reason ?? "Profile version deactivated",
actorId,
hash);
}
/// <summary>
/// Creates a threshold changed notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateThresholdChangedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? reason,
NotificationThresholds thresholds,
string? hash,
NotificationEffectiveScope? scope = null)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.ThresholdChanged,
tenantId,
profileId,
profileVersion,
reason ?? "Risk thresholds updated",
actorId,
hash,
thresholds: thresholds,
scope: scope);
}
/// <summary>
/// Creates an override added notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateOverrideAddedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
NotificationOverrideDetails overrideDetails,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.OverrideAdded,
tenantId,
profileId,
profileVersion,
$"Override added: {overrideDetails.OverrideType}",
actorId,
hash,
overrideDetails: overrideDetails);
}
/// <summary>
/// Creates an override removed notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateOverrideRemovedEvent(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
NotificationOverrideDetails overrideDetails,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.OverrideRemoved,
tenantId,
profileId,
profileVersion,
$"Override removed: {overrideDetails.OverrideId}",
actorId,
hash,
overrideDetails: overrideDetails);
}
/// <summary>
/// Creates a simulation ready notification event.
/// </summary>
public PolicyProfileNotificationEvent CreateSimulationReadyEvent(
string tenantId,
string profileId,
string profileVersion,
NotificationSimulationDetails simulationDetails,
string? hash)
{
return CreateEvent(
PolicyProfileNotificationEventTypes.SimulationReady,
tenantId,
profileId,
profileVersion,
"Simulation results available",
actorId: null,
hash,
simulationDetails: simulationDetails);
}
private PolicyProfileNotificationEvent CreateEvent(
string eventType,
string tenantId,
string profileId,
string profileVersion,
string changeReason,
string? actorId,
string? hash,
NotificationThresholds? thresholds = null,
NotificationEffectiveScope? scope = null,
NotificationOverrideDetails? overrideDetails = null,
NotificationSimulationDetails? simulationDetails = null)
{
var eventId = GenerateUuidV7();
var now = _timeProvider.GetUtcNow();
NotificationActor? actor = null;
if (!string.IsNullOrWhiteSpace(actorId))
{
actor = new NotificationActor
{
Type = actorId.Contains('@') ? "user" : "system",
Id = actorId
};
}
NotificationHash? hashInfo = null;
if (!string.IsNullOrWhiteSpace(hash))
{
hashInfo = new NotificationHash
{
Algorithm = "sha256",
Value = hash
};
}
NotificationLinks? links = null;
if (!string.IsNullOrWhiteSpace(_options.BaseUrl))
{
links = new NotificationLinks
{
ProfileUrl = $"{_options.BaseUrl}/api/risk/profiles/{profileId}",
DiffUrl = $"{_options.BaseUrl}/api/risk/profiles/{profileId}/diff",
SimulationUrl = simulationDetails?.SimulationId is not null
? $"{_options.BaseUrl}/api/risk/simulations/results/{simulationDetails.SimulationId}"
: null
};
}
NotificationTraceContext? trace = null;
if (_options.IncludeTraceContext)
{
var activity = Activity.Current;
if (activity is not null)
{
trace = new NotificationTraceContext
{
TraceId = activity.TraceId.ToString(),
SpanId = activity.SpanId.ToString()
};
}
}
return new PolicyProfileNotificationEvent
{
EventId = eventId,
EventType = eventType,
EmittedAt = now,
TenantId = tenantId,
ProfileId = profileId,
ProfileVersion = profileVersion,
ChangeReason = changeReason,
Actor = actor,
Thresholds = thresholds,
EffectiveScope = scope,
Hash = hashInfo,
Links = links,
Trace = trace,
OverrideDetails = overrideDetails,
SimulationDetails = simulationDetails
};
}
/// <summary>
/// Generates a UUIDv7 (time-ordered UUID) for event identification.
/// </summary>
private string GenerateUuidV7()
{
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
var randomBytes = new byte[10];
RandomNumberGenerator.Fill(randomBytes);
var bytes = new byte[16];
// First 6 bytes: timestamp (48 bits)
bytes[0] = (byte)((timestamp >> 40) & 0xFF);
bytes[1] = (byte)((timestamp >> 32) & 0xFF);
bytes[2] = (byte)((timestamp >> 24) & 0xFF);
bytes[3] = (byte)((timestamp >> 16) & 0xFF);
bytes[4] = (byte)((timestamp >> 8) & 0xFF);
bytes[5] = (byte)(timestamp & 0xFF);
// Version 7 (4 bits) + random (12 bits)
bytes[6] = (byte)(0x70 | (randomBytes[0] & 0x0F));
bytes[7] = randomBytes[1];
// Variant (2 bits) + random (62 bits)
bytes[8] = (byte)(0x80 | (randomBytes[2] & 0x3F));
Array.Copy(randomBytes, 3, bytes, 9, 7);
return new Guid(bytes).ToString();
}
}

View File

@@ -0,0 +1,467 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.RiskProfile.Lifecycle;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Service for publishing policy profile lifecycle notifications.
/// Integrates with the RiskProfileLifecycleService to emit events.
/// </summary>
public sealed class PolicyProfileNotificationService
{
private readonly IPolicyProfileNotificationPublisher _publisher;
private readonly PolicyProfileNotificationFactory _factory;
private readonly PolicyProfileNotificationOptions _options;
private readonly ILogger<PolicyProfileNotificationService> _logger;
public PolicyProfileNotificationService(
IPolicyProfileNotificationPublisher publisher,
PolicyProfileNotificationFactory factory,
IOptions<PolicyProfileNotificationOptions> options,
ILogger<PolicyProfileNotificationService> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_options = options?.Value ?? new PolicyProfileNotificationOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Notifies that a new profile version was created.
/// </summary>
public async Task NotifyProfileCreatedAsync(
string tenantId,
RiskProfileModel profile,
string? actorId,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_options.Enabled)
{
return;
}
try
{
var scope = ExtractEffectiveScope(profile);
var notification = _factory.CreateProfileCreatedEvent(
tenantId,
profile.Id,
profile.Version,
actorId,
hash,
scope);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish profile created notification for {ProfileId}@{Version}",
profile.Id, profile.Version);
}
}
/// <summary>
/// Notifies that a profile version was activated.
/// </summary>
public async Task NotifyProfileActivatedAsync(
string tenantId,
RiskProfileModel profile,
string? actorId,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_options.Enabled)
{
return;
}
try
{
var scope = ExtractEffectiveScope(profile);
var notification = _factory.CreateProfileActivatedEvent(
tenantId,
profile.Id,
profile.Version,
actorId,
hash,
scope);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish profile activated notification for {ProfileId}@{Version}",
profile.Id, profile.Version);
}
}
/// <summary>
/// Notifies that a profile version was deactivated (deprecated or archived).
/// </summary>
public async Task NotifyProfileDeactivatedAsync(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string? reason,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var notification = _factory.CreateProfileDeactivatedEvent(
tenantId,
profileId,
profileVersion,
actorId,
reason,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish profile deactivated notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies that risk thresholds were changed.
/// </summary>
public async Task NotifyThresholdChangedAsync(
string tenantId,
RiskProfileModel profile,
string? actorId,
string? reason,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!_options.Enabled)
{
return;
}
try
{
var thresholds = ExtractThresholds(profile);
var scope = ExtractEffectiveScope(profile);
var notification = _factory.CreateThresholdChangedEvent(
tenantId,
profile.Id,
profile.Version,
actorId,
reason,
thresholds,
hash,
scope);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish threshold changed notification for {ProfileId}@{Version}",
profile.Id, profile.Version);
}
}
/// <summary>
/// Notifies that an override was added to a profile.
/// </summary>
public async Task NotifyOverrideAddedAsync(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string overrideId,
string overrideType,
string? target,
string? action,
string? justification,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var overrideDetails = new NotificationOverrideDetails
{
OverrideId = overrideId,
OverrideType = overrideType,
Target = target,
Action = action,
Justification = justification
};
var notification = _factory.CreateOverrideAddedEvent(
tenantId,
profileId,
profileVersion,
actorId,
overrideDetails,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish override added notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies that an override was removed from a profile.
/// </summary>
public async Task NotifyOverrideRemovedAsync(
string tenantId,
string profileId,
string profileVersion,
string? actorId,
string overrideId,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var overrideDetails = new NotificationOverrideDetails
{
OverrideId = overrideId
};
var notification = _factory.CreateOverrideRemovedEvent(
tenantId,
profileId,
profileVersion,
actorId,
overrideDetails,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish override removed notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies that simulation results are ready for consumption.
/// </summary>
public async Task NotifySimulationReadyAsync(
string tenantId,
string profileId,
string profileVersion,
string simulationId,
int findingsCount,
int highImpactCount,
DateTimeOffset completedAt,
string? hash,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return;
}
try
{
var simulationDetails = new NotificationSimulationDetails
{
SimulationId = simulationId,
FindingsCount = findingsCount,
HighImpactCount = highImpactCount,
CompletedAt = completedAt
};
var notification = _factory.CreateSimulationReadyEvent(
tenantId,
profileId,
profileVersion,
simulationDetails,
hash);
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish simulation ready notification for {ProfileId}@{Version}",
profileId, profileVersion);
}
}
/// <summary>
/// Notifies based on a lifecycle event from the RiskProfileLifecycleService.
/// </summary>
public async Task NotifyFromLifecycleEventAsync(
string tenantId,
RiskProfileLifecycleEvent lifecycleEvent,
RiskProfileModel? profile,
string? hash,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(lifecycleEvent);
if (!_options.Enabled)
{
return;
}
switch (lifecycleEvent.EventType)
{
case RiskProfileLifecycleEventType.Created:
if (profile is not null)
{
await NotifyProfileCreatedAsync(tenantId, profile, lifecycleEvent.Actor, hash, cancellationToken)
.ConfigureAwait(false);
}
break;
case RiskProfileLifecycleEventType.Activated:
if (profile is not null)
{
await NotifyProfileActivatedAsync(tenantId, profile, lifecycleEvent.Actor, hash, cancellationToken)
.ConfigureAwait(false);
}
break;
case RiskProfileLifecycleEventType.Deprecated:
case RiskProfileLifecycleEventType.Archived:
await NotifyProfileDeactivatedAsync(
tenantId,
lifecycleEvent.ProfileId,
lifecycleEvent.Version,
lifecycleEvent.Actor,
lifecycleEvent.Reason,
hash,
cancellationToken).ConfigureAwait(false);
break;
case RiskProfileLifecycleEventType.Restored:
// Restored profiles go back to deprecated status; no dedicated notification
_logger.LogDebug("Profile {ProfileId}@{Version} restored; no notification emitted",
lifecycleEvent.ProfileId, lifecycleEvent.Version);
break;
default:
_logger.LogDebug("Unhandled lifecycle event type {EventType} for {ProfileId}@{Version}",
lifecycleEvent.EventType, lifecycleEvent.ProfileId, lifecycleEvent.Version);
break;
}
}
private static NotificationEffectiveScope? ExtractEffectiveScope(RiskProfileModel profile)
{
// Extract scope information from profile metadata if available
var metadata = profile.Metadata;
if (metadata is null || metadata.Count == 0)
{
return null;
}
var scope = new NotificationEffectiveScope();
var hasAny = false;
if (metadata.TryGetValue("tenants", out var tenantsObj) && tenantsObj is IEnumerable<object> tenants)
{
scope = scope with { Tenants = tenants.Select(t => t.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("projects", out var projectsObj) && projectsObj is IEnumerable<object> projects)
{
scope = scope with { Projects = projects.Select(p => p.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("purl_patterns", out var purlObj) && purlObj is IEnumerable<object> purls)
{
scope = scope with { PurlPatterns = purls.Select(p => p.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("cpe_patterns", out var cpeObj) && cpeObj is IEnumerable<object> cpes)
{
scope = scope with { CpePatterns = cpes.Select(c => c.ToString()!).ToList() };
hasAny = true;
}
if (metadata.TryGetValue("tags", out var tagsObj) && tagsObj is IEnumerable<object> tags)
{
scope = scope with { Tags = tags.Select(t => t.ToString()!).ToList() };
hasAny = true;
}
return hasAny ? scope : null;
}
private static NotificationThresholds ExtractThresholds(RiskProfileModel profile)
{
// Extract thresholds from profile overrides
var thresholds = new NotificationThresholds();
// Map severity overrides to threshold values
foreach (var severityOverride in profile.Overrides.Severity)
{
var targetSeverity = severityOverride.Set.ToString().ToLowerInvariant();
var threshold = ExtractThresholdValue(severityOverride.When);
thresholds = targetSeverity switch
{
"info" or "informational" => thresholds with { Info = threshold },
"low" => thresholds with { Low = threshold },
"medium" => thresholds with { Medium = threshold },
"high" => thresholds with { High = threshold },
"critical" => thresholds with { Critical = threshold },
_ => thresholds
};
}
return thresholds;
}
private static double? ExtractThresholdValue(Dictionary<string, object> conditions)
{
// Try to extract a numeric threshold from conditions
if (conditions.TryGetValue("score_gte", out var scoreGte) && scoreGte is double d1)
{
return d1;
}
if (conditions.TryGetValue("score_gt", out var scoreGt) && scoreGt is double d2)
{
return d2;
}
if (conditions.TryGetValue("threshold", out var threshold) && threshold is double d3)
{
return d3;
}
return null;
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Policy.Engine.Notifications;
/// <summary>
/// Extension methods for registering policy profile notification services.
/// </summary>
public static class PolicyProfileNotificationServiceCollectionExtensions
{
/// <summary>
/// Adds policy profile notification services to the service collection.
/// </summary>
public static IServiceCollection AddPolicyProfileNotifications(this IServiceCollection services)
{
services.TryAddSingleton<PolicyProfileNotificationFactory>();
services.TryAddSingleton<IPolicyProfileNotificationPublisher, LoggingPolicyProfileNotificationPublisher>();
services.TryAddSingleton<PolicyProfileNotificationService>();
return services;
}
/// <summary>
/// Adds policy profile notification services with configuration.
/// </summary>
public static IServiceCollection AddPolicyProfileNotifications(
this IServiceCollection services,
Action<PolicyProfileNotificationOptions> configure)
{
services.Configure(configure);
return services.AddPolicyProfileNotifications();
}
}