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();
}
}