- 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.
422 lines
14 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|