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; /// /// Interface for publishing policy profile notification events. /// public interface IPolicyProfileNotificationPublisher { /// /// Publishes a notification event to the configured transport. /// Task PublishAsync(PolicyProfileNotificationEvent notification, CancellationToken cancellationToken = default); /// /// Delivers a notification via webhook with HMAC signature. /// Task DeliverWebhookAsync(WebhookDeliveryRequest request, CancellationToken cancellationToken = default); } /// /// Logging-based notification publisher for policy profile events. /// Logs notifications as structured events for downstream consumption. /// internal sealed class LoggingPolicyProfileNotificationPublisher : IPolicyProfileNotificationPublisher { private readonly ILogger _logger; private readonly PolicyProfileNotificationOptions _options; private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = false }; public LoggingPolicyProfileNotificationPublisher( ILogger logger, IOptions 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 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); } } /// /// Factory for creating policy profile notification events. /// 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(); } /// /// Creates a profile created notification event. /// 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); } /// /// Creates a profile activated notification event. /// 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); } /// /// Creates a profile deactivated notification event. /// 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); } /// /// Creates a threshold changed notification event. /// 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); } /// /// Creates an override added notification event. /// 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); } /// /// Creates an override removed notification event. /// 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); } /// /// Creates a simulation ready notification event. /// 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 }; } /// /// Generates a UUIDv7 (time-ordered UUID) for event identification. /// 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(); } }