Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Engine/AirGap/AirGapNotifications.cs
StellaOps Bot 5e514532df Implement VEX document verification system with issuer management and signature verification
- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation.
- Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments.
- Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats.
- Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats.
- Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction.
- Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
2025-12-06 13:41:22 +02:00

422 lines
14 KiB
C#

using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Notification types for air-gap events.
/// </summary>
public enum AirGapNotificationType
{
/// <summary>Staleness warning threshold crossed.</summary>
StalenessWarning,
/// <summary>Staleness breach occurred.</summary>
StalenessBreach,
/// <summary>Staleness recovered.</summary>
StalenessRecovered,
/// <summary>Bundle import started.</summary>
BundleImportStarted,
/// <summary>Bundle import completed.</summary>
BundleImportCompleted,
/// <summary>Bundle import failed.</summary>
BundleImportFailed,
/// <summary>Environment sealed.</summary>
EnvironmentSealed,
/// <summary>Environment unsealed.</summary>
EnvironmentUnsealed,
/// <summary>Time anchor missing.</summary>
TimeAnchorMissing,
/// <summary>Policy pack updated.</summary>
PolicyPackUpdated
}
/// <summary>
/// Notification severity levels.
/// </summary>
public enum NotificationSeverity
{
Info,
Warning,
Error,
Critical
}
/// <summary>
/// Represents a notification to be delivered.
/// </summary>
public sealed record AirGapNotification(
string NotificationId,
string TenantId,
AirGapNotificationType Type,
NotificationSeverity Severity,
string Title,
string Message,
DateTimeOffset OccurredAt,
IDictionary<string, object?>? Metadata = null);
/// <summary>
/// Interface for notification delivery channels.
/// </summary>
public interface IAirGapNotificationChannel
{
/// <summary>
/// Gets the name of this notification channel.
/// </summary>
string ChannelName { get; }
/// <summary>
/// Delivers a notification through this channel.
/// </summary>
Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for managing air-gap notifications.
/// </summary>
public interface IAirGapNotificationService
{
/// <summary>
/// Sends a notification through all configured channels.
/// </summary>
Task SendAsync(AirGapNotification notification, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a staleness-related notification.
/// </summary>
Task NotifyStalenessEventAsync(
string tenantId,
StalenessEventType eventType,
int ageSeconds,
int thresholdSeconds,
CancellationToken cancellationToken = default);
/// <summary>
/// Sends a bundle import notification.
/// </summary>
Task NotifyBundleImportAsync(
string tenantId,
string bundleId,
bool success,
string? error = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Sends a sealed-mode state change notification.
/// </summary>
Task NotifySealedStateChangeAsync(
string tenantId,
bool isSealed,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of air-gap notification service.
/// </summary>
internal sealed class AirGapNotificationService : IAirGapNotificationService, IStalenessEventSink
{
private readonly IEnumerable<IAirGapNotificationChannel> _channels;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AirGapNotificationService> _logger;
public AirGapNotificationService(
IEnumerable<IAirGapNotificationChannel> channels,
TimeProvider timeProvider,
ILogger<AirGapNotificationService> logger)
{
_channels = channels ?? [];
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SendAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
_logger.LogInformation(
"Sending air-gap notification {NotificationId}: {Type} for tenant {TenantId}",
notification.NotificationId, notification.Type, notification.TenantId);
var deliveryTasks = _channels.Select(channel =>
DeliverToChannelAsync(channel, notification, cancellationToken));
await Task.WhenAll(deliveryTasks).ConfigureAwait(false);
}
private async Task DeliverToChannelAsync(
IAirGapNotificationChannel channel,
AirGapNotification notification,
CancellationToken cancellationToken)
{
try
{
var delivered = await channel.DeliverAsync(notification, cancellationToken).ConfigureAwait(false);
if (delivered)
{
_logger.LogDebug(
"Notification {NotificationId} delivered via {Channel}",
notification.NotificationId, channel.ChannelName);
}
else
{
_logger.LogWarning(
"Notification {NotificationId} delivery to {Channel} returned false",
notification.NotificationId, channel.ChannelName);
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to deliver notification {NotificationId} via {Channel}",
notification.NotificationId, channel.ChannelName);
}
}
public async Task NotifyStalenessEventAsync(
string tenantId,
StalenessEventType eventType,
int ageSeconds,
int thresholdSeconds,
CancellationToken cancellationToken = default)
{
var (notificationType, severity, title, message) = eventType switch
{
StalenessEventType.Warning => (
AirGapNotificationType.StalenessWarning,
NotificationSeverity.Warning,
"Staleness Warning",
$"Time anchor age ({ageSeconds}s) approaching breach threshold ({thresholdSeconds}s)"),
StalenessEventType.Breach => (
AirGapNotificationType.StalenessBreach,
NotificationSeverity.Critical,
"Staleness Breach",
$"Time anchor staleness breached: age {ageSeconds}s exceeds threshold {thresholdSeconds}s"),
StalenessEventType.Recovered => (
AirGapNotificationType.StalenessRecovered,
NotificationSeverity.Info,
"Staleness Recovered",
"Time anchor has been refreshed, staleness recovered"),
StalenessEventType.AnchorMissing => (
AirGapNotificationType.TimeAnchorMissing,
NotificationSeverity.Error,
"Time Anchor Missing",
"Time anchor not configured in sealed mode"),
_ => (
AirGapNotificationType.StalenessWarning,
NotificationSeverity.Info,
"Staleness Event",
$"Staleness event: {eventType}")
};
var notification = new AirGapNotification(
NotificationId: GenerateNotificationId(),
TenantId: tenantId,
Type: notificationType,
Severity: severity,
Title: title,
Message: message,
OccurredAt: _timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["age_seconds"] = ageSeconds,
["threshold_seconds"] = thresholdSeconds,
["event_type"] = eventType.ToString()
});
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
}
public async Task NotifyBundleImportAsync(
string tenantId,
string bundleId,
bool success,
string? error = null,
CancellationToken cancellationToken = default)
{
var (notificationType, severity, title, message) = success
? (
AirGapNotificationType.BundleImportCompleted,
NotificationSeverity.Info,
"Bundle Import Completed",
$"Policy pack bundle '{bundleId}' imported successfully")
: (
AirGapNotificationType.BundleImportFailed,
NotificationSeverity.Error,
"Bundle Import Failed",
$"Policy pack bundle '{bundleId}' import failed: {error ?? "unknown error"}");
var notification = new AirGapNotification(
NotificationId: GenerateNotificationId(),
TenantId: tenantId,
Type: notificationType,
Severity: severity,
Title: title,
Message: message,
OccurredAt: _timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["bundle_id"] = bundleId,
["success"] = success,
["error"] = error
});
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
}
public async Task NotifySealedStateChangeAsync(
string tenantId,
bool isSealed,
CancellationToken cancellationToken = default)
{
var (notificationType, title, message) = isSealed
? (
AirGapNotificationType.EnvironmentSealed,
"Environment Sealed",
"Policy engine environment has been sealed for air-gap operation")
: (
AirGapNotificationType.EnvironmentUnsealed,
"Environment Unsealed",
"Policy engine environment has been unsealed");
var notification = new AirGapNotification(
NotificationId: GenerateNotificationId(),
TenantId: tenantId,
Type: notificationType,
Severity: NotificationSeverity.Info,
Title: title,
Message: message,
OccurredAt: _timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["sealed"] = isSealed
});
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
}
// Implement IStalenessEventSink to auto-notify on staleness events
public Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default)
{
return NotifyStalenessEventAsync(
evt.TenantId,
evt.Type,
evt.AgeSeconds,
evt.ThresholdSeconds,
cancellationToken);
}
private static string GenerateNotificationId()
{
return $"notify-{Guid.NewGuid():N}"[..24];
}
}
/// <summary>
/// Logging-based notification channel for observability.
/// </summary>
internal sealed class LoggingNotificationChannel : IAirGapNotificationChannel
{
private readonly ILogger<LoggingNotificationChannel> _logger;
public LoggingNotificationChannel(ILogger<LoggingNotificationChannel> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string ChannelName => "Logging";
public Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
{
var logLevel = notification.Severity switch
{
NotificationSeverity.Critical => LogLevel.Critical,
NotificationSeverity.Error => LogLevel.Error,
NotificationSeverity.Warning => LogLevel.Warning,
_ => LogLevel.Information
};
_logger.Log(
logLevel,
"[{NotificationType}] {Title}: {Message} (tenant={TenantId}, id={NotificationId})",
notification.Type,
notification.Title,
notification.Message,
notification.TenantId,
notification.NotificationId);
return Task.FromResult(true);
}
}
/// <summary>
/// Webhook-based notification channel for external integrations.
/// </summary>
internal sealed class WebhookNotificationChannel : IAirGapNotificationChannel
{
private readonly HttpClient _httpClient;
private readonly string _webhookUrl;
private readonly ILogger<WebhookNotificationChannel> _logger;
public WebhookNotificationChannel(
HttpClient httpClient,
string webhookUrl,
ILogger<WebhookNotificationChannel> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string ChannelName => $"Webhook({_webhookUrl})";
public async Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
{
try
{
var payload = new
{
notification_id = notification.NotificationId,
tenant_id = notification.TenantId,
type = notification.Type.ToString(),
severity = notification.Severity.ToString(),
title = notification.Title,
message = notification.Message,
occurred_at = notification.OccurredAt.ToString("O"),
metadata = notification.Metadata
};
var response = await _httpClient.PostAsJsonAsync(_webhookUrl, payload, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return true;
}
_logger.LogWarning(
"Webhook delivery returned {StatusCode} for notification {NotificationId}",
response.StatusCode, notification.NotificationId);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Webhook delivery failed for notification {NotificationId} to {WebhookUrl}",
notification.NotificationId, _webhookUrl);
return false;
}
}
}