using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Policy.RiskProfile.Lifecycle; using StellaOps.Policy.RiskProfile.Models; namespace StellaOps.Policy.Engine.Notifications; /// /// Service for publishing policy profile lifecycle notifications. /// Integrates with the RiskProfileLifecycleService to emit events. /// public sealed class PolicyProfileNotificationService { private readonly IPolicyProfileNotificationPublisher _publisher; private readonly PolicyProfileNotificationFactory _factory; private readonly PolicyProfileNotificationOptions _options; private readonly ILogger _logger; public PolicyProfileNotificationService( IPolicyProfileNotificationPublisher publisher, PolicyProfileNotificationFactory factory, IOptions options, ILogger 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)); } /// /// Notifies that a new profile version was created. /// 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); } } /// /// Notifies that a profile version was activated. /// 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); } } /// /// Notifies that a profile version was deactivated (deprecated or archived). /// 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); } } /// /// Notifies that risk thresholds were changed. /// 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); } } /// /// Notifies that an override was added to a profile. /// 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); } } /// /// Notifies that an override was removed from a profile. /// 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); } } /// /// Notifies that simulation results are ready for consumption. /// 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); } } /// /// Notifies based on a lifecycle event from the RiskProfileLifecycleService. /// 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 tenants) { scope = scope with { Tenants = tenants.Select(t => t.ToString()!).ToList() }; hasAny = true; } if (metadata.TryGetValue("projects", out var projectsObj) && projectsObj is IEnumerable projects) { scope = scope with { Projects = projects.Select(p => p.ToString()!).ToList() }; hasAny = true; } if (metadata.TryGetValue("purl_patterns", out var purlObj) && purlObj is IEnumerable purls) { scope = scope with { PurlPatterns = purls.Select(p => p.ToString()!).ToList() }; hasAny = true; } if (metadata.TryGetValue("cpe_patterns", out var cpeObj) && cpeObj is IEnumerable cpes) { scope = scope with { CpePatterns = cpes.Select(c => c.ToString()!).ToList() }; hasAny = true; } if (metadata.TryGetValue("tags", out var tagsObj) && tagsObj is IEnumerable 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 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; } }