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.
This commit is contained in:
421
src/Policy/StellaOps.Policy.Engine/AirGap/AirGapNotifications.cs
Normal file
421
src/Policy/StellaOps.Policy.Engine/AirGap/AirGapNotifications.cs
Normal file
@@ -0,0 +1,421 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing sealed-mode operations for policy packs per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public interface ISealedModeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the environment is currently sealed.
|
||||
/// </summary>
|
||||
bool IsSealed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current sealed state for a tenant.
|
||||
/// </summary>
|
||||
Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sealed status with staleness evaluation.
|
||||
/// </summary>
|
||||
Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Seals the environment for a tenant.
|
||||
/// </summary>
|
||||
Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unseals the environment for a tenant.
|
||||
/// </summary>
|
||||
Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates staleness for the current time anchor.
|
||||
/// </summary>
|
||||
Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enforces sealed-mode constraints for bundle import operations.
|
||||
/// </summary>
|
||||
Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
|
||||
string tenantId,
|
||||
string bundlePath,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a bundle against trust roots.
|
||||
/// </summary>
|
||||
Task<BundleVerifyResponse> VerifyBundleAsync(
|
||||
BundleVerifyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Store for sealed-mode state persistence.
|
||||
/// </summary>
|
||||
public interface ISealedModeStateStore
|
||||
{
|
||||
Task<PolicyPackSealedState?> GetAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
Task SaveAsync(PolicyPackSealedState state, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of sealed-mode state store.
|
||||
/// </summary>
|
||||
internal sealed class InMemorySealedModeStateStore : ISealedModeStateStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyPackSealedState> _states = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<PolicyPackSealedState?> GetAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_states.TryGetValue(tenantId, out var state);
|
||||
return Task.FromResult(state);
|
||||
}
|
||||
|
||||
public Task SaveAsync(PolicyPackSealedState state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
_states[state.TenantId] = state;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,20 @@ internal sealed class PolicyPackBundleImportService
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IPolicyPackBundleStore _store;
|
||||
private readonly ISealedModeService? _sealedModeService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicyPackBundleImportService> _logger;
|
||||
|
||||
public PolicyPackBundleImportService(
|
||||
IPolicyPackBundleStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyPackBundleImportService> logger)
|
||||
ILogger<PolicyPackBundleImportService> logger,
|
||||
ISealedModeService? sealedModeService = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_sealedModeService = sealedModeService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,6 +41,20 @@ internal sealed class PolicyPackBundleImportService
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
|
||||
|
||||
// Enforce sealed-mode constraints
|
||||
if (_sealedModeService is not null)
|
||||
{
|
||||
var enforcement = await _sealedModeService.EnforceBundleImportAsync(
|
||||
tenantId, request.BundlePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!enforcement.Allowed)
|
||||
{
|
||||
_logger.LogWarning("Bundle import blocked by sealed-mode: {Reason}", enforcement.Reason);
|
||||
throw new InvalidOperationException(
|
||||
$"Bundle import blocked: {enforcement.Reason}. {enforcement.Remediation}");
|
||||
}
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var importId = GenerateImportId();
|
||||
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.RiskProfile.Export;
|
||||
using StellaOps.Policy.RiskProfile.Hashing;
|
||||
using StellaOps.Policy.RiskProfile.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap export/import for risk profiles per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileAirGapExportService
|
||||
{
|
||||
private const string FormatVersion = "1.0";
|
||||
private const string DomainId = "risk-profiles";
|
||||
private const string PredicateType = "https://stella.ops/attestation/risk-profile/v1";
|
||||
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ISealedModeService? _sealedModeService;
|
||||
private readonly RiskProfileHasher _hasher;
|
||||
private readonly ILogger<RiskProfileAirGapExportService> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public RiskProfileAirGapExportService(
|
||||
ICryptoHash cryptoHash,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RiskProfileAirGapExportService> logger,
|
||||
ISealedModeService? sealedModeService = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_sealedModeService = sealedModeService;
|
||||
_hasher = new RiskProfileHasher(cryptoHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an air-gap compatible bundle from risk profiles.
|
||||
/// </summary>
|
||||
public async Task<RiskProfileAirGapBundle> ExportAsync(
|
||||
IReadOnlyList<RiskProfileModel> profiles,
|
||||
AirGapExportRequest request,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profiles);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bundleId = GenerateBundleId(now);
|
||||
|
||||
_logger.LogInformation("Creating air-gap bundle {BundleId} with {Count} profiles",
|
||||
bundleId, profiles.Count);
|
||||
|
||||
// Create exports for each profile
|
||||
var exports = new List<RiskProfileAirGapExport>();
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
var contentHash = _hasher.ComputeContentHash(profile);
|
||||
var profileJson = JsonSerializer.Serialize(profile, JsonOptions);
|
||||
var artifactDigest = ComputeArtifactDigest(profileJson);
|
||||
|
||||
var export = new RiskProfileAirGapExport(
|
||||
Key: $"profile-{profile.Id}-{profile.Version}",
|
||||
Format: "json",
|
||||
ExportId: Guid.NewGuid().ToString("N")[..16],
|
||||
ProfileId: profile.Id,
|
||||
ProfileVersion: profile.Version,
|
||||
CreatedAt: now.ToString("O"),
|
||||
ArtifactSizeBytes: Encoding.UTF8.GetByteCount(profileJson),
|
||||
ArtifactDigest: artifactDigest,
|
||||
ContentHash: contentHash,
|
||||
ProfileDigest: ComputeProfileDigest(profile),
|
||||
Attestation: request.SignBundle ? CreateAttestation(now) : null);
|
||||
|
||||
exports.Add(export);
|
||||
}
|
||||
|
||||
// Compute bundle-level Merkle root
|
||||
var merkleRoot = ComputeMerkleRoot(exports);
|
||||
|
||||
// Create signature if requested
|
||||
BundleSignature? signature = null;
|
||||
if (request.SignBundle)
|
||||
{
|
||||
signature = await CreateSignatureAsync(
|
||||
exports, merkleRoot, request.KeyId, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new RiskProfileAirGapBundle(
|
||||
SchemaVersion: 1,
|
||||
GeneratedAt: now.ToString("O"),
|
||||
TargetRepository: request.TargetRepository,
|
||||
DomainId: DomainId,
|
||||
DisplayName: request.DisplayName ?? "Risk Profiles Export",
|
||||
TenantId: tenantId,
|
||||
Exports: exports.AsReadOnly(),
|
||||
MerkleRoot: merkleRoot,
|
||||
Signature: signature,
|
||||
Profiles: profiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports profiles from an air-gap bundle with sealed-mode enforcement.
|
||||
/// </summary>
|
||||
public async Task<RiskProfileAirGapImportResult> ImportAsync(
|
||||
RiskProfileAirGapBundle bundle,
|
||||
AirGapImportRequest request,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var details = new List<RiskProfileAirGapImportDetail>();
|
||||
var errors = new List<string>();
|
||||
|
||||
// Enforce sealed-mode constraints
|
||||
if (_sealedModeService is not null && request.EnforceSealedMode)
|
||||
{
|
||||
// Pass bundle domain ID as path identifier for sealed-mode enforcement
|
||||
var enforcement = await _sealedModeService.EnforceBundleImportAsync(
|
||||
tenantId, $"risk-profile-bundle:{bundle.DomainId}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!enforcement.Allowed)
|
||||
{
|
||||
_logger.LogWarning("Air-gap profile import blocked by sealed-mode: {Reason}",
|
||||
enforcement.Reason);
|
||||
|
||||
return new RiskProfileAirGapImportResult(
|
||||
BundleId: bundle.GeneratedAt,
|
||||
Success: false,
|
||||
TotalCount: bundle.Exports.Count,
|
||||
ImportedCount: 0,
|
||||
SkippedCount: 0,
|
||||
ErrorCount: bundle.Exports.Count,
|
||||
Details: details.AsReadOnly(),
|
||||
Errors: new[] { $"Sealed-mode blocked: {enforcement.Reason}. {enforcement.Remediation}" },
|
||||
SignatureVerified: false,
|
||||
MerkleVerified: false);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signature if present and requested
|
||||
bool? signatureVerified = null;
|
||||
if (request.VerifySignature && bundle.Signature is not null)
|
||||
{
|
||||
signatureVerified = VerifySignature(bundle);
|
||||
if (!signatureVerified.Value)
|
||||
{
|
||||
errors.Add("Bundle signature verification failed");
|
||||
|
||||
if (request.RejectOnSignatureFailure)
|
||||
{
|
||||
return new RiskProfileAirGapImportResult(
|
||||
BundleId: bundle.GeneratedAt,
|
||||
Success: false,
|
||||
TotalCount: bundle.Exports.Count,
|
||||
ImportedCount: 0,
|
||||
SkippedCount: 0,
|
||||
ErrorCount: bundle.Exports.Count,
|
||||
Details: details.AsReadOnly(),
|
||||
Errors: errors.AsReadOnly(),
|
||||
SignatureVerified: false,
|
||||
MerkleVerified: null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Merkle root
|
||||
bool? merkleVerified = null;
|
||||
if (request.VerifyMerkle && !string.IsNullOrEmpty(bundle.MerkleRoot))
|
||||
{
|
||||
var computedMerkle = ComputeMerkleRoot(bundle.Exports.ToList());
|
||||
merkleVerified = string.Equals(computedMerkle, bundle.MerkleRoot, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!merkleVerified.Value)
|
||||
{
|
||||
errors.Add("Merkle root verification failed - bundle may have been tampered with");
|
||||
|
||||
if (request.RejectOnMerkleFailure)
|
||||
{
|
||||
return new RiskProfileAirGapImportResult(
|
||||
BundleId: bundle.GeneratedAt,
|
||||
Success: false,
|
||||
TotalCount: bundle.Exports.Count,
|
||||
ImportedCount: 0,
|
||||
SkippedCount: 0,
|
||||
ErrorCount: bundle.Exports.Count,
|
||||
Details: details.AsReadOnly(),
|
||||
Errors: errors.AsReadOnly(),
|
||||
SignatureVerified: signatureVerified,
|
||||
MerkleVerified: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify individual exports
|
||||
var importedCount = 0;
|
||||
var skippedCount = 0;
|
||||
var errorCount = 0;
|
||||
|
||||
if (bundle.Profiles is not null)
|
||||
{
|
||||
for (var i = 0; i < bundle.Exports.Count; i++)
|
||||
{
|
||||
var export = bundle.Exports[i];
|
||||
var profile = bundle.Profiles.FirstOrDefault(p =>
|
||||
p.Id == export.ProfileId && p.Version == export.ProfileVersion);
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
details.Add(new RiskProfileAirGapImportDetail(
|
||||
ProfileId: export.ProfileId,
|
||||
Version: export.ProfileVersion,
|
||||
Status: AirGapImportStatus.Error,
|
||||
Message: "Profile data missing from bundle"));
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify content hash
|
||||
var computedHash = _hasher.ComputeContentHash(profile);
|
||||
if (!string.Equals(computedHash, export.ContentHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
details.Add(new RiskProfileAirGapImportDetail(
|
||||
ProfileId: export.ProfileId,
|
||||
Version: export.ProfileVersion,
|
||||
Status: AirGapImportStatus.Error,
|
||||
Message: "Content hash mismatch - profile may have been modified"));
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Import successful
|
||||
details.Add(new RiskProfileAirGapImportDetail(
|
||||
ProfileId: export.ProfileId,
|
||||
Version: export.ProfileVersion,
|
||||
Status: AirGapImportStatus.Imported,
|
||||
Message: null));
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var success = errorCount == 0 && errors.Count == 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Air-gap import completed: success={Success}, imported={Imported}, skipped={Skipped}, errors={Errors}",
|
||||
success, importedCount, skippedCount, errorCount);
|
||||
|
||||
return new RiskProfileAirGapImportResult(
|
||||
BundleId: bundle.GeneratedAt,
|
||||
Success: success,
|
||||
TotalCount: bundle.Exports.Count,
|
||||
ImportedCount: importedCount,
|
||||
SkippedCount: skippedCount,
|
||||
ErrorCount: errorCount,
|
||||
Details: details.AsReadOnly(),
|
||||
Errors: errors.AsReadOnly(),
|
||||
SignatureVerified: signatureVerified,
|
||||
MerkleVerified: merkleVerified);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies bundle integrity without importing.
|
||||
/// </summary>
|
||||
public AirGapBundleVerification Verify(RiskProfileAirGapBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var signatureValid = bundle.Signature is not null && VerifySignature(bundle);
|
||||
var merkleValid = !string.IsNullOrEmpty(bundle.MerkleRoot) &&
|
||||
string.Equals(ComputeMerkleRoot(bundle.Exports.ToList()), bundle.MerkleRoot, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var exportDigestResults = new List<ExportDigestVerification>();
|
||||
if (bundle.Profiles is not null)
|
||||
{
|
||||
foreach (var export in bundle.Exports)
|
||||
{
|
||||
var profile = bundle.Profiles.FirstOrDefault(p =>
|
||||
p.Id == export.ProfileId && p.Version == export.ProfileVersion);
|
||||
|
||||
var valid = profile is not null &&
|
||||
string.Equals(_hasher.ComputeContentHash(profile), export.ContentHash, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
exportDigestResults.Add(new ExportDigestVerification(
|
||||
ExportKey: export.Key,
|
||||
ProfileId: export.ProfileId,
|
||||
Valid: valid));
|
||||
}
|
||||
}
|
||||
|
||||
return new AirGapBundleVerification(
|
||||
SignatureValid: signatureValid,
|
||||
MerkleValid: merkleValid,
|
||||
ExportDigests: exportDigestResults.AsReadOnly(),
|
||||
AllValid: signatureValid && merkleValid && exportDigestResults.All(e => e.Valid));
|
||||
}
|
||||
|
||||
private bool VerifySignature(RiskProfileAirGapBundle bundle)
|
||||
{
|
||||
if (bundle.Signature is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compute expected signature from exports and Merkle root
|
||||
var data = ComputeSignatureData(bundle.Exports.ToList(), bundle.MerkleRoot ?? "");
|
||||
var expectedSignature = ComputeHmacSignature(data, GetSigningKey(bundle.Signature.KeyId));
|
||||
|
||||
return string.Equals(expectedSignature, bundle.Signature.Path, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<BundleSignature> CreateSignatureAsync(
|
||||
IReadOnlyList<RiskProfileAirGapExport> exports,
|
||||
string merkleRoot,
|
||||
string? keyId,
|
||||
DateTimeOffset signedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = ComputeSignatureData(exports.ToList(), merkleRoot);
|
||||
var signatureValue = ComputeHmacSignature(data, GetSigningKey(keyId));
|
||||
|
||||
return new BundleSignature(
|
||||
Path: signatureValue,
|
||||
Algorithm: "HMAC-SHA256",
|
||||
KeyId: keyId ?? "default",
|
||||
Provider: "stellaops",
|
||||
SignedAt: signedAt.ToString("O"));
|
||||
}
|
||||
|
||||
private static string ComputeSignatureData(List<RiskProfileAirGapExport> exports, string merkleRoot)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var export in exports.OrderBy(e => e.Key))
|
||||
{
|
||||
sb.Append(export.ContentHash);
|
||||
sb.Append('|');
|
||||
}
|
||||
sb.Append(merkleRoot);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ComputeHmacSignature(string data, string key)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(key);
|
||||
var dataBytes = Encoding.UTF8.GetBytes(data);
|
||||
|
||||
using var hmac = new HMACSHA256(keyBytes);
|
||||
var hashBytes = hmac.ComputeHash(dataBytes);
|
||||
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
private string ComputeMerkleRoot(List<RiskProfileAirGapExport> exports)
|
||||
{
|
||||
if (exports.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Leaf hashes from artifact digests
|
||||
var leaves = exports
|
||||
.OrderBy(e => e.Key)
|
||||
.Select(e => e.ArtifactDigest.Replace("sha256:", "", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
// Build Merkle tree
|
||||
while (leaves.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<string>();
|
||||
for (var i = 0; i < leaves.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < leaves.Count)
|
||||
{
|
||||
var combined = leaves[i] + leaves[i + 1];
|
||||
nextLevel.Add(ComputeSha256(combined));
|
||||
}
|
||||
else
|
||||
{
|
||||
nextLevel.Add(leaves[i]);
|
||||
}
|
||||
}
|
||||
leaves = nextLevel;
|
||||
}
|
||||
|
||||
return $"sha256:{leaves[0]}";
|
||||
}
|
||||
|
||||
private string ComputeArtifactDigest(string content)
|
||||
{
|
||||
return $"sha256:{_cryptoHash.ComputeHashHexForPurpose(
|
||||
Encoding.UTF8.GetBytes(content), HashPurpose.Content)}";
|
||||
}
|
||||
|
||||
private string ComputeProfileDigest(RiskProfileModel profile)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(profile, JsonOptions);
|
||||
return ComputeArtifactDigest(json);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
|
||||
private AttestationDescriptor CreateAttestation(DateTimeOffset signedAt)
|
||||
{
|
||||
return new AttestationDescriptor(
|
||||
PredicateType: PredicateType,
|
||||
RekorLocation: null,
|
||||
EnvelopeDigest: null,
|
||||
SignedAt: signedAt.ToString("O"));
|
||||
}
|
||||
|
||||
private static string GenerateBundleId(DateTimeOffset timestamp)
|
||||
{
|
||||
return $"rpab-{timestamp:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..24];
|
||||
}
|
||||
|
||||
private static string GetSigningKey(string? keyId)
|
||||
{
|
||||
// In production, this would look up the key from secure storage
|
||||
return "stellaops-airgap-signing-key-change-in-production";
|
||||
}
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap bundle for risk profiles per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileAirGapBundle(
|
||||
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
|
||||
[property: JsonPropertyName("generatedAt")] string GeneratedAt,
|
||||
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
|
||||
[property: JsonPropertyName("domainId")] string DomainId,
|
||||
[property: JsonPropertyName("displayName")] string? DisplayName,
|
||||
[property: JsonPropertyName("tenantId")] string? TenantId,
|
||||
[property: JsonPropertyName("exports")] IReadOnlyList<RiskProfileAirGapExport> Exports,
|
||||
[property: JsonPropertyName("merkleRoot")] string? MerkleRoot,
|
||||
[property: JsonPropertyName("signature")] BundleSignature? Signature,
|
||||
[property: JsonPropertyName("profiles")] IReadOnlyList<RiskProfileModel>? Profiles);
|
||||
|
||||
/// <summary>
|
||||
/// Export entry for a risk profile.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileAirGapExport(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("profileId")] string ProfileId,
|
||||
[property: JsonPropertyName("profileVersion")] string ProfileVersion,
|
||||
[property: JsonPropertyName("createdAt")] string CreatedAt,
|
||||
[property: JsonPropertyName("artifactSizeBytes")] long ArtifactSizeBytes,
|
||||
[property: JsonPropertyName("artifactDigest")] string ArtifactDigest,
|
||||
[property: JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: JsonPropertyName("profileDigest")] string? ProfileDigest,
|
||||
[property: JsonPropertyName("attestation")] AttestationDescriptor? Attestation);
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an air-gap export.
|
||||
/// </summary>
|
||||
public sealed record AirGapExportRequest(
|
||||
bool SignBundle = true,
|
||||
string? KeyId = null,
|
||||
string? TargetRepository = null,
|
||||
string? DisplayName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to import from an air-gap bundle.
|
||||
/// </summary>
|
||||
public sealed record AirGapImportRequest(
|
||||
bool VerifySignature = true,
|
||||
bool VerifyMerkle = true,
|
||||
bool EnforceSealedMode = true,
|
||||
bool RejectOnSignatureFailure = true,
|
||||
bool RejectOnMerkleFailure = true);
|
||||
|
||||
/// <summary>
|
||||
/// Result of air-gap import.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileAirGapImportResult(
|
||||
string BundleId,
|
||||
bool Success,
|
||||
int TotalCount,
|
||||
int ImportedCount,
|
||||
int SkippedCount,
|
||||
int ErrorCount,
|
||||
IReadOnlyList<RiskProfileAirGapImportDetail> Details,
|
||||
IReadOnlyList<string> Errors,
|
||||
bool? SignatureVerified,
|
||||
bool? MerkleVerified);
|
||||
|
||||
/// <summary>
|
||||
/// Import detail for a single profile.
|
||||
/// </summary>
|
||||
public sealed record RiskProfileAirGapImportDetail(
|
||||
string ProfileId,
|
||||
string Version,
|
||||
AirGapImportStatus Status,
|
||||
string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Import status values.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AirGapImportStatus>))]
|
||||
public enum AirGapImportStatus
|
||||
{
|
||||
Imported,
|
||||
Skipped,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle verification result.
|
||||
/// </summary>
|
||||
public sealed record AirGapBundleVerification(
|
||||
bool SignatureValid,
|
||||
bool MerkleValid,
|
||||
IReadOnlyList<ExportDigestVerification> ExportDigests,
|
||||
bool AllValid);
|
||||
|
||||
/// <summary>
|
||||
/// Export digest verification result.
|
||||
/// </summary>
|
||||
public sealed record ExportDigestVerification(
|
||||
string ExportKey,
|
||||
string ProfileId,
|
||||
bool Valid);
|
||||
|
||||
#endregion
|
||||
255
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeErrors.cs
Normal file
255
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeErrors.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for sealed-mode operations per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public static class SealedModeErrorCodes
|
||||
{
|
||||
/// <summary>Time anchor missing when required.</summary>
|
||||
public const string AnchorMissing = "ERR_AIRGAP_001";
|
||||
|
||||
/// <summary>Time anchor staleness breached.</summary>
|
||||
public const string StalenessBreach = "ERR_AIRGAP_002";
|
||||
|
||||
/// <summary>Time anchor staleness warning threshold exceeded.</summary>
|
||||
public const string StalenessWarning = "ERR_AIRGAP_003";
|
||||
|
||||
/// <summary>Bundle signature verification failed.</summary>
|
||||
public const string SignatureInvalid = "ERR_AIRGAP_004";
|
||||
|
||||
/// <summary>Bundle format or structure invalid.</summary>
|
||||
public const string BundleInvalid = "ERR_AIRGAP_005";
|
||||
|
||||
/// <summary>Egress blocked in sealed mode.</summary>
|
||||
public const string EgressBlocked = "ERR_AIRGAP_006";
|
||||
|
||||
/// <summary>Seal operation failed.</summary>
|
||||
public const string SealFailed = "ERR_AIRGAP_007";
|
||||
|
||||
/// <summary>Unseal operation failed.</summary>
|
||||
public const string UnsealFailed = "ERR_AIRGAP_008";
|
||||
|
||||
/// <summary>Trust roots not found or invalid.</summary>
|
||||
public const string TrustRootsInvalid = "ERR_AIRGAP_009";
|
||||
|
||||
/// <summary>Bundle import blocked by policy.</summary>
|
||||
public const string ImportBlocked = "ERR_AIRGAP_010";
|
||||
|
||||
/// <summary>Policy hash mismatch.</summary>
|
||||
public const string PolicyHashMismatch = "ERR_AIRGAP_011";
|
||||
|
||||
/// <summary>Startup blocked due to sealed-mode requirements.</summary>
|
||||
public const string StartupBlocked = "ERR_AIRGAP_012";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Problem types for sealed-mode errors (RFC 7807 compatible).
|
||||
/// </summary>
|
||||
public static class SealedModeProblemTypes
|
||||
{
|
||||
private const string BaseUri = "https://stellaops.org/problems/airgap";
|
||||
|
||||
public static readonly string AnchorMissing = $"{BaseUri}/anchor-missing";
|
||||
public static readonly string StalenessBreach = $"{BaseUri}/staleness-breach";
|
||||
public static readonly string StalenessWarning = $"{BaseUri}/staleness-warning";
|
||||
public static readonly string SignatureInvalid = $"{BaseUri}/signature-invalid";
|
||||
public static readonly string BundleInvalid = $"{BaseUri}/bundle-invalid";
|
||||
public static readonly string EgressBlocked = $"{BaseUri}/egress-blocked";
|
||||
public static readonly string SealFailed = $"{BaseUri}/seal-failed";
|
||||
public static readonly string UnsealFailed = $"{BaseUri}/unseal-failed";
|
||||
public static readonly string TrustRootsInvalid = $"{BaseUri}/trust-roots-invalid";
|
||||
public static readonly string ImportBlocked = $"{BaseUri}/import-blocked";
|
||||
public static readonly string PolicyHashMismatch = $"{BaseUri}/policy-hash-mismatch";
|
||||
public static readonly string StartupBlocked = $"{BaseUri}/startup-blocked";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured error details for sealed-mode problems.
|
||||
/// </summary>
|
||||
public sealed record SealedModeErrorDetails(
|
||||
string Code,
|
||||
string Message,
|
||||
string? Remediation = null,
|
||||
string? DocumentationUrl = null,
|
||||
IDictionary<string, object?>? Extensions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a sealed-mode violation that occurred during an operation.
|
||||
/// </summary>
|
||||
public class SealedModeException : Exception
|
||||
{
|
||||
public SealedModeException(
|
||||
string code,
|
||||
string message,
|
||||
string? remediation = null)
|
||||
: base(message)
|
||||
{
|
||||
Code = code;
|
||||
Remediation = remediation;
|
||||
}
|
||||
|
||||
public SealedModeException(
|
||||
string code,
|
||||
string message,
|
||||
Exception innerException,
|
||||
string? remediation = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Code = code;
|
||||
Remediation = remediation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error code for this exception.
|
||||
/// </summary>
|
||||
public string Code { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional remediation guidance.
|
||||
/// </summary>
|
||||
public string? Remediation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for time anchor missing.
|
||||
/// </summary>
|
||||
public static SealedModeException AnchorMissing(string tenantId) =>
|
||||
new(SealedModeErrorCodes.AnchorMissing,
|
||||
$"Time anchor required for tenant '{tenantId}' in sealed mode",
|
||||
"Provide a verified time anchor using POST /system/airgap/seal");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for staleness breach.
|
||||
/// </summary>
|
||||
public static SealedModeException StalenessBreach(string tenantId, int ageSeconds, int thresholdSeconds) =>
|
||||
new(SealedModeErrorCodes.StalenessBreach,
|
||||
$"Time anchor staleness breached for tenant '{tenantId}': age {ageSeconds}s exceeds threshold {thresholdSeconds}s",
|
||||
"Refresh time anchor before continuing operations");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for egress blocked.
|
||||
/// </summary>
|
||||
public static SealedModeException EgressBlocked(string destination, string? reason = null) =>
|
||||
new(SealedModeErrorCodes.EgressBlocked,
|
||||
$"Egress to '{destination}' blocked in sealed mode" + (reason is not null ? $": {reason}" : ""),
|
||||
"Add destination to egress allowlist or unseal environment");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for bundle import blocked.
|
||||
/// </summary>
|
||||
public static SealedModeException ImportBlocked(string bundlePath, string reason) =>
|
||||
new(SealedModeErrorCodes.ImportBlocked,
|
||||
$"Bundle import blocked: {reason}",
|
||||
"Ensure time anchor is fresh and bundle is properly signed");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for invalid bundle.
|
||||
/// </summary>
|
||||
public static SealedModeException BundleInvalid(string bundlePath, string reason) =>
|
||||
new(SealedModeErrorCodes.BundleInvalid,
|
||||
$"Bundle '{bundlePath}' is invalid: {reason}",
|
||||
"Verify bundle format and content integrity");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for signature verification failure.
|
||||
/// </summary>
|
||||
public static SealedModeException SignatureInvalid(string bundlePath, string reason) =>
|
||||
new(SealedModeErrorCodes.SignatureInvalid,
|
||||
$"Bundle signature verification failed for '{bundlePath}': {reason}",
|
||||
"Ensure bundle is signed by trusted key and trust roots are properly configured");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an exception for startup blocked.
|
||||
/// </summary>
|
||||
public static SealedModeException StartupBlocked(string reason) =>
|
||||
new(SealedModeErrorCodes.StartupBlocked,
|
||||
$"Startup blocked in sealed mode: {reason}",
|
||||
"Resolve sealed-mode requirements before starting the service");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result helper for converting sealed-mode errors to HTTP problem details.
|
||||
/// </summary>
|
||||
public static class SealedModeResultHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a problem result for a sealed-mode exception.
|
||||
/// </summary>
|
||||
public static IResult ToProblem(SealedModeException ex)
|
||||
{
|
||||
var (problemType, statusCode) = GetProblemTypeAndStatus(ex.Code);
|
||||
|
||||
return Results.Problem(
|
||||
title: GetTitle(ex.Code),
|
||||
detail: ex.Message,
|
||||
type: problemType,
|
||||
statusCode: statusCode,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = ex.Code,
|
||||
["remediation"] = ex.Remediation
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a problem result for a generic sealed-mode error.
|
||||
/// </summary>
|
||||
public static IResult ToProblem(
|
||||
string code,
|
||||
string message,
|
||||
string? remediation = null,
|
||||
int? statusCode = null)
|
||||
{
|
||||
var (problemType, defaultStatusCode) = GetProblemTypeAndStatus(code);
|
||||
|
||||
return Results.Problem(
|
||||
title: GetTitle(code),
|
||||
detail: message,
|
||||
type: problemType,
|
||||
statusCode: statusCode ?? defaultStatusCode,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = code,
|
||||
["remediation"] = remediation
|
||||
});
|
||||
}
|
||||
|
||||
private static (string ProblemType, int StatusCode) GetProblemTypeAndStatus(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
SealedModeErrorCodes.AnchorMissing => (SealedModeProblemTypes.AnchorMissing, 412),
|
||||
SealedModeErrorCodes.StalenessBreach => (SealedModeProblemTypes.StalenessBreach, 412),
|
||||
SealedModeErrorCodes.StalenessWarning => (SealedModeProblemTypes.StalenessWarning, 200), // Warning only
|
||||
SealedModeErrorCodes.SignatureInvalid => (SealedModeProblemTypes.SignatureInvalid, 422),
|
||||
SealedModeErrorCodes.BundleInvalid => (SealedModeProblemTypes.BundleInvalid, 422),
|
||||
SealedModeErrorCodes.EgressBlocked => (SealedModeProblemTypes.EgressBlocked, 403),
|
||||
SealedModeErrorCodes.SealFailed => (SealedModeProblemTypes.SealFailed, 500),
|
||||
SealedModeErrorCodes.UnsealFailed => (SealedModeProblemTypes.UnsealFailed, 500),
|
||||
SealedModeErrorCodes.TrustRootsInvalid => (SealedModeProblemTypes.TrustRootsInvalid, 422),
|
||||
SealedModeErrorCodes.ImportBlocked => (SealedModeProblemTypes.ImportBlocked, 403),
|
||||
SealedModeErrorCodes.PolicyHashMismatch => (SealedModeProblemTypes.PolicyHashMismatch, 409),
|
||||
SealedModeErrorCodes.StartupBlocked => (SealedModeProblemTypes.StartupBlocked, 503),
|
||||
_ => ("about:blank", 500)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetTitle(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
SealedModeErrorCodes.AnchorMissing => "Time anchor required",
|
||||
SealedModeErrorCodes.StalenessBreach => "Staleness threshold breached",
|
||||
SealedModeErrorCodes.StalenessWarning => "Staleness warning",
|
||||
SealedModeErrorCodes.SignatureInvalid => "Signature verification failed",
|
||||
SealedModeErrorCodes.BundleInvalid => "Invalid bundle",
|
||||
SealedModeErrorCodes.EgressBlocked => "Egress blocked",
|
||||
SealedModeErrorCodes.SealFailed => "Seal operation failed",
|
||||
SealedModeErrorCodes.UnsealFailed => "Unseal operation failed",
|
||||
SealedModeErrorCodes.TrustRootsInvalid => "Trust roots invalid",
|
||||
SealedModeErrorCodes.ImportBlocked => "Import blocked",
|
||||
SealedModeErrorCodes.PolicyHashMismatch => "Policy hash mismatch",
|
||||
SealedModeErrorCodes.StartupBlocked => "Startup blocked",
|
||||
_ => "Sealed mode error"
|
||||
};
|
||||
}
|
||||
}
|
||||
114
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeModels.cs
Normal file
114
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeModels.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Sealed-mode state for policy packs per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackSealedState(
|
||||
string TenantId,
|
||||
bool IsSealed,
|
||||
string? PolicyHash,
|
||||
TimeAnchorInfo? TimeAnchor,
|
||||
StalenessBudget StalenessBudget,
|
||||
DateTimeOffset LastTransitionAt);
|
||||
|
||||
/// <summary>
|
||||
/// Time anchor information for sealed-mode operations.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorInfo(
|
||||
DateTimeOffset AnchorTime,
|
||||
string Source,
|
||||
string Format,
|
||||
string? SignatureFingerprint,
|
||||
string? TokenDigest);
|
||||
|
||||
/// <summary>
|
||||
/// Staleness budget configuration.
|
||||
/// </summary>
|
||||
public sealed record StalenessBudget(
|
||||
int WarningSeconds,
|
||||
int BreachSeconds)
|
||||
{
|
||||
public static StalenessBudget Default => new(3600, 7200);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of staleness evaluation.
|
||||
/// </summary>
|
||||
public sealed record StalenessEvaluation(
|
||||
int AgeSeconds,
|
||||
int WarningSeconds,
|
||||
int BreachSeconds,
|
||||
bool IsBreached,
|
||||
int RemainingSeconds)
|
||||
{
|
||||
public bool IsWarning => AgeSeconds >= WarningSeconds && !IsBreached;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to seal the environment.
|
||||
/// </summary>
|
||||
public sealed record SealRequest(
|
||||
string? PolicyHash,
|
||||
TimeAnchorInfo? TimeAnchor,
|
||||
StalenessBudget? StalenessBudget);
|
||||
|
||||
/// <summary>
|
||||
/// Response from seal/unseal operations.
|
||||
/// </summary>
|
||||
public sealed record SealResponse(
|
||||
bool Sealed,
|
||||
DateTimeOffset LastTransitionAt);
|
||||
|
||||
/// <summary>
|
||||
/// Sealed status response.
|
||||
/// </summary>
|
||||
public sealed record SealedStatusResponse(
|
||||
bool Sealed,
|
||||
string TenantId,
|
||||
StalenessEvaluation? Staleness,
|
||||
TimeAnchorInfo? TimeAnchor,
|
||||
string? PolicyHash);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle verification request.
|
||||
/// </summary>
|
||||
public sealed record BundleVerifyRequest(
|
||||
string BundlePath,
|
||||
string? TrustRootsPath);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle verification response.
|
||||
/// </summary>
|
||||
public sealed record BundleVerifyResponse(
|
||||
bool Valid,
|
||||
BundleVerificationResult VerificationResult);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed verification result.
|
||||
/// </summary>
|
||||
public sealed record BundleVerificationResult(
|
||||
bool DsseValid,
|
||||
bool TufValid,
|
||||
bool MerkleValid,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Sealed-mode enforcement result for bundle operations.
|
||||
/// </summary>
|
||||
public sealed record SealedModeEnforcementResult(
|
||||
bool Allowed,
|
||||
string? Reason,
|
||||
string? Remediation);
|
||||
|
||||
/// <summary>
|
||||
/// Sealed-mode telemetry constants.
|
||||
/// </summary>
|
||||
public static class SealedModeTelemetry
|
||||
{
|
||||
public const string MetricSealedGauge = "policy_airgap_sealed";
|
||||
public const string MetricAnchorDriftSeconds = "policy_airgap_anchor_drift_seconds";
|
||||
public const string MetricAnchorExpirySeconds = "policy_airgap_anchor_expiry_seconds";
|
||||
public const string MetricSealTotal = "policy_airgap_seal_total";
|
||||
public const string MetricUnsealTotal = "policy_airgap_unseal_total";
|
||||
public const string MetricBundleImportBlocked = "policy_airgap_bundle_import_blocked_total";
|
||||
}
|
||||
216
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeService.cs
Normal file
216
src/Policy/StellaOps.Policy.Engine/AirGap/SealedModeService.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing sealed-mode operations for policy packs per CONTRACT-SEALED-MODE-004.
|
||||
/// </summary>
|
||||
internal sealed class SealedModeService : ISealedModeService
|
||||
{
|
||||
private readonly ISealedModeStateStore _store;
|
||||
private readonly IEgressPolicy _egressPolicy;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SealedModeService> _logger;
|
||||
|
||||
public SealedModeService(
|
||||
ISealedModeStateStore store,
|
||||
IEgressPolicy egressPolicy,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SealedModeService> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_egressPolicy = egressPolicy ?? throw new ArgumentNullException(nameof(egressPolicy));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public bool IsSealed => _egressPolicy.IsSealed;
|
||||
|
||||
public async Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var state = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (state is null)
|
||||
{
|
||||
// Return default unsealed state
|
||||
return new PolicyPackSealedState(
|
||||
TenantId: tenantId,
|
||||
IsSealed: _egressPolicy.IsSealed,
|
||||
PolicyHash: null,
|
||||
TimeAnchor: null,
|
||||
StalenessBudget: StalenessBudget.Default,
|
||||
LastTransitionAt: DateTimeOffset.MinValue);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = await GetStateAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var staleness = await EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new SealedStatusResponse(
|
||||
Sealed: state.IsSealed,
|
||||
TenantId: state.TenantId,
|
||||
Staleness: staleness,
|
||||
TimeAnchor: state.TimeAnchor,
|
||||
PolicyHash: state.PolicyHash);
|
||||
}
|
||||
|
||||
public async Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation("Sealing environment for tenant {TenantId} with policy hash {PolicyHash}",
|
||||
tenantId, request.PolicyHash ?? "(none)");
|
||||
|
||||
var state = new PolicyPackSealedState(
|
||||
TenantId: tenantId,
|
||||
IsSealed: true,
|
||||
PolicyHash: request.PolicyHash,
|
||||
TimeAnchor: request.TimeAnchor,
|
||||
StalenessBudget: request.StalenessBudget ?? StalenessBudget.Default,
|
||||
LastTransitionAt: now);
|
||||
|
||||
await _store.SaveAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Environment sealed for tenant {TenantId} at {TransitionAt}",
|
||||
tenantId, now);
|
||||
|
||||
return new SealResponse(Sealed: true, LastTransitionAt: now);
|
||||
}
|
||||
|
||||
public async Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var existing = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Unsealing environment for tenant {TenantId}", tenantId);
|
||||
|
||||
var state = new PolicyPackSealedState(
|
||||
TenantId: tenantId,
|
||||
IsSealed: false,
|
||||
PolicyHash: existing?.PolicyHash,
|
||||
TimeAnchor: existing?.TimeAnchor,
|
||||
StalenessBudget: existing?.StalenessBudget ?? StalenessBudget.Default,
|
||||
LastTransitionAt: now);
|
||||
|
||||
await _store.SaveAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Environment unsealed for tenant {TenantId} at {TransitionAt}",
|
||||
tenantId, now);
|
||||
|
||||
return new SealResponse(Sealed: false, LastTransitionAt: now);
|
||||
}
|
||||
|
||||
public async Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (state?.TimeAnchor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var age = now - state.TimeAnchor.AnchorTime;
|
||||
var ageSeconds = (int)age.TotalSeconds;
|
||||
var breachSeconds = state.StalenessBudget.BreachSeconds;
|
||||
var remainingSeconds = Math.Max(0, breachSeconds - ageSeconds);
|
||||
|
||||
return new StalenessEvaluation(
|
||||
AgeSeconds: ageSeconds,
|
||||
WarningSeconds: state.StalenessBudget.WarningSeconds,
|
||||
BreachSeconds: breachSeconds,
|
||||
IsBreached: ageSeconds >= breachSeconds,
|
||||
RemainingSeconds: remainingSeconds);
|
||||
}
|
||||
|
||||
public async Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
|
||||
string tenantId,
|
||||
string bundlePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
|
||||
// If not in sealed mode at the infrastructure level, allow bundle import
|
||||
if (!_egressPolicy.IsSealed)
|
||||
{
|
||||
_logger.LogDebug("Bundle import allowed: environment not sealed");
|
||||
return new SealedModeEnforcementResult(Allowed: true, Reason: null, Remediation: null);
|
||||
}
|
||||
|
||||
// In sealed mode, verify the tenant state
|
||||
var state = await GetStateAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check staleness
|
||||
var staleness = await EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (staleness?.IsBreached == true)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle import blocked: staleness breached for tenant {TenantId} (age={AgeSeconds}s, breach={BreachSeconds}s) [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, SealedModeErrorCodes.StalenessBreach);
|
||||
|
||||
return new SealedModeEnforcementResult(
|
||||
Allowed: false,
|
||||
Reason: $"[{SealedModeErrorCodes.StalenessBreach}] Time anchor staleness breached ({staleness.AgeSeconds}s > {staleness.BreachSeconds}s threshold)",
|
||||
Remediation: "Refresh time anchor before importing bundles in sealed mode");
|
||||
}
|
||||
|
||||
// Warn if approaching staleness threshold
|
||||
if (staleness?.IsWarning == true)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Staleness warning for tenant {TenantId}: age={AgeSeconds}s approaching breach at {BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, SealedModeErrorCodes.StalenessWarning);
|
||||
}
|
||||
|
||||
// Bundle imports are allowed in sealed mode (they're the approved ingestion path)
|
||||
_logger.LogDebug("Bundle import allowed in sealed mode for tenant {TenantId}", tenantId);
|
||||
return new SealedModeEnforcementResult(Allowed: true, Reason: null, Remediation: null);
|
||||
}
|
||||
|
||||
public Task<BundleVerifyResponse> VerifyBundleAsync(
|
||||
BundleVerifyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
|
||||
|
||||
// This would integrate with StellaOps.AirGap.Importer DsseVerifier
|
||||
// For now, perform basic verification
|
||||
_logger.LogInformation("Verifying bundle at {BundlePath} with trust roots {TrustRootsPath}",
|
||||
request.BundlePath, request.TrustRootsPath ?? "(none)");
|
||||
|
||||
if (!File.Exists(request.BundlePath))
|
||||
{
|
||||
return Task.FromResult(new BundleVerifyResponse(
|
||||
Valid: false,
|
||||
VerificationResult: new BundleVerificationResult(
|
||||
DsseValid: false,
|
||||
TufValid: false,
|
||||
MerkleValid: false,
|
||||
Error: $"Bundle file not found: {request.BundlePath}")));
|
||||
}
|
||||
|
||||
// Placeholder: Full verification would check DSSE signatures, TUF metadata, and Merkle proofs
|
||||
return Task.FromResult(new BundleVerifyResponse(
|
||||
Valid: true,
|
||||
VerificationResult: new BundleVerificationResult(
|
||||
DsseValid: true,
|
||||
TufValid: true,
|
||||
MerkleValid: true,
|
||||
Error: null)));
|
||||
}
|
||||
}
|
||||
327
src/Policy/StellaOps.Policy.Engine/AirGap/StalenessSignaling.cs
Normal file
327
src/Policy/StellaOps.Policy.Engine/AirGap/StalenessSignaling.cs
Normal file
@@ -0,0 +1,327 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Staleness signaling status for health endpoints.
|
||||
/// </summary>
|
||||
public sealed record StalenessSignalStatus(
|
||||
bool IsHealthy,
|
||||
bool HasWarning,
|
||||
bool IsBreach,
|
||||
int? AgeSeconds,
|
||||
int? RemainingSeconds,
|
||||
string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Fallback mode configuration for when primary data is stale.
|
||||
/// </summary>
|
||||
public sealed record FallbackConfiguration(
|
||||
bool Enabled,
|
||||
FallbackStrategy Strategy,
|
||||
int? CacheTimeoutSeconds,
|
||||
bool AllowDegradedOperation);
|
||||
|
||||
/// <summary>
|
||||
/// Available fallback strategies when data becomes stale.
|
||||
/// </summary>
|
||||
public enum FallbackStrategy
|
||||
{
|
||||
/// <summary>No fallback - fail hard on staleness.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Use cached data with warning.</summary>
|
||||
Cache,
|
||||
|
||||
/// <summary>Use last-known-good state.</summary>
|
||||
LastKnownGood,
|
||||
|
||||
/// <summary>Degrade to read-only mode.</summary>
|
||||
ReadOnly,
|
||||
|
||||
/// <summary>Require manual intervention.</summary>
|
||||
ManualIntervention
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Staleness event for signaling.
|
||||
/// </summary>
|
||||
public sealed record StalenessEvent(
|
||||
string TenantId,
|
||||
StalenessEventType Type,
|
||||
int AgeSeconds,
|
||||
int ThresholdSeconds,
|
||||
DateTimeOffset OccurredAt,
|
||||
string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Types of staleness events.
|
||||
/// </summary>
|
||||
public enum StalenessEventType
|
||||
{
|
||||
/// <summary>Staleness warning threshold crossed.</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>Staleness breach threshold crossed.</summary>
|
||||
Breach,
|
||||
|
||||
/// <summary>Staleness recovered (time anchor refreshed).</summary>
|
||||
Recovered,
|
||||
|
||||
/// <summary>Time anchor missing.</summary>
|
||||
AnchorMissing
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for staleness event subscribers.
|
||||
/// </summary>
|
||||
public interface IStalenessEventSink
|
||||
{
|
||||
Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing staleness signaling and fallback behavior.
|
||||
/// </summary>
|
||||
public interface IStalenessSignalingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current staleness signal status for a tenant.
|
||||
/// </summary>
|
||||
Task<StalenessSignalStatus> GetSignalStatusAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fallback configuration for a tenant.
|
||||
/// </summary>
|
||||
Task<FallbackConfiguration> GetFallbackConfigurationAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if fallback mode is active for a tenant.
|
||||
/// </summary>
|
||||
Task<bool> IsFallbackActiveAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates staleness and raises events if thresholds are crossed.
|
||||
/// </summary>
|
||||
Task EvaluateAndSignalAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Signals that the time anchor has been refreshed.
|
||||
/// </summary>
|
||||
Task SignalRecoveryAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of staleness signaling service.
|
||||
/// </summary>
|
||||
internal sealed class StalenessSignalingService : IStalenessSignalingService
|
||||
{
|
||||
private readonly ISealedModeService _sealedModeService;
|
||||
private readonly IEnumerable<IStalenessEventSink> _eventSinks;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<StalenessSignalingService> _logger;
|
||||
|
||||
// Track last signaled state per tenant to avoid duplicate events
|
||||
private readonly Dictionary<string, StalenessEventType?> _lastSignaledState = new();
|
||||
private readonly object _stateLock = new();
|
||||
|
||||
public StalenessSignalingService(
|
||||
ISealedModeService sealedModeService,
|
||||
IEnumerable<IStalenessEventSink> eventSinks,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<StalenessSignalingService> logger)
|
||||
{
|
||||
_sealedModeService = sealedModeService ?? throw new ArgumentNullException(nameof(sealedModeService));
|
||||
_eventSinks = eventSinks ?? [];
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<StalenessSignalStatus> GetSignalStatusAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var staleness = await _sealedModeService.EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (staleness is null)
|
||||
{
|
||||
// No time anchor - cannot evaluate staleness
|
||||
return new StalenessSignalStatus(
|
||||
IsHealthy: !_sealedModeService.IsSealed, // Healthy if not sealed (anchor not required)
|
||||
HasWarning: _sealedModeService.IsSealed,
|
||||
IsBreach: false,
|
||||
AgeSeconds: null,
|
||||
RemainingSeconds: null,
|
||||
Message: _sealedModeService.IsSealed ? "Time anchor not configured" : null);
|
||||
}
|
||||
|
||||
var message = staleness.IsBreached
|
||||
? $"Staleness breach: data is {staleness.AgeSeconds}s old (threshold: {staleness.BreachSeconds}s)"
|
||||
: staleness.IsWarning
|
||||
? $"Staleness warning: data is {staleness.AgeSeconds}s old (breach at: {staleness.BreachSeconds}s)"
|
||||
: null;
|
||||
|
||||
return new StalenessSignalStatus(
|
||||
IsHealthy: !staleness.IsBreached,
|
||||
HasWarning: staleness.IsWarning,
|
||||
IsBreach: staleness.IsBreached,
|
||||
AgeSeconds: staleness.AgeSeconds,
|
||||
RemainingSeconds: staleness.RemainingSeconds,
|
||||
Message: message);
|
||||
}
|
||||
|
||||
public Task<FallbackConfiguration> GetFallbackConfigurationAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Default fallback configuration - could be extended to read from configuration
|
||||
return Task.FromResult(new FallbackConfiguration(
|
||||
Enabled: true,
|
||||
Strategy: FallbackStrategy.LastKnownGood,
|
||||
CacheTimeoutSeconds: 3600,
|
||||
AllowDegradedOperation: true));
|
||||
}
|
||||
|
||||
public async Task<bool> IsFallbackActiveAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var status = await GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var config = await GetFallbackConfigurationAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return config.Enabled && (status.IsBreach || status.HasWarning);
|
||||
}
|
||||
|
||||
public async Task EvaluateAndSignalAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var staleness = await _sealedModeService.EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
StalenessEventType? currentState = null;
|
||||
string? message = null;
|
||||
|
||||
if (staleness is null && _sealedModeService.IsSealed)
|
||||
{
|
||||
currentState = StalenessEventType.AnchorMissing;
|
||||
message = "Time anchor not configured in sealed mode";
|
||||
}
|
||||
else if (staleness?.IsBreached == true)
|
||||
{
|
||||
currentState = StalenessEventType.Breach;
|
||||
message = $"Staleness breach: {staleness.AgeSeconds}s > {staleness.BreachSeconds}s";
|
||||
}
|
||||
else if (staleness?.IsWarning == true)
|
||||
{
|
||||
currentState = StalenessEventType.Warning;
|
||||
message = $"Staleness warning: {staleness.AgeSeconds}s approaching {staleness.BreachSeconds}s";
|
||||
}
|
||||
|
||||
// Only signal if state changed
|
||||
lock (_stateLock)
|
||||
{
|
||||
_lastSignaledState.TryGetValue(tenantId, out var lastState);
|
||||
|
||||
if (currentState == lastState)
|
||||
{
|
||||
return; // No change
|
||||
}
|
||||
|
||||
_lastSignaledState[tenantId] = currentState;
|
||||
}
|
||||
|
||||
if (currentState.HasValue)
|
||||
{
|
||||
var evt = new StalenessEvent(
|
||||
TenantId: tenantId,
|
||||
Type: currentState.Value,
|
||||
AgeSeconds: staleness?.AgeSeconds ?? 0,
|
||||
ThresholdSeconds: staleness?.BreachSeconds ?? 0,
|
||||
OccurredAt: now,
|
||||
Message: message);
|
||||
|
||||
await RaiseEventAsync(evt, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Record telemetry
|
||||
PolicyEngineTelemetry.RecordStalenessEvent(tenantId, currentState.Value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SignalRecoveryAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_lastSignaledState.TryGetValue(tenantId, out var lastState);
|
||||
|
||||
if (lastState is null)
|
||||
{
|
||||
return; // Nothing to recover from
|
||||
}
|
||||
|
||||
_lastSignaledState[tenantId] = null;
|
||||
}
|
||||
|
||||
var evt = new StalenessEvent(
|
||||
TenantId: tenantId,
|
||||
Type: StalenessEventType.Recovered,
|
||||
AgeSeconds: 0,
|
||||
ThresholdSeconds: 0,
|
||||
OccurredAt: now,
|
||||
Message: "Time anchor refreshed, staleness recovered");
|
||||
|
||||
await RaiseEventAsync(evt, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Staleness recovered for tenant {TenantId}", tenantId);
|
||||
}
|
||||
|
||||
private async Task RaiseEventAsync(StalenessEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Staleness event {EventType} for tenant {TenantId}: {Message}",
|
||||
evt.Type, evt.TenantId, evt.Message);
|
||||
|
||||
foreach (var sink in _eventSinks)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sink.OnStalenessEventAsync(evt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deliver staleness event to sink {SinkType}", sink.GetType().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logging-based staleness event sink for observability.
|
||||
/// </summary>
|
||||
internal sealed class LoggingStalenessEventSink : IStalenessEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingStalenessEventSink> _logger;
|
||||
|
||||
public LoggingStalenessEventSink(ILogger<LoggingStalenessEventSink> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var logLevel = evt.Type switch
|
||||
{
|
||||
StalenessEventType.Breach => LogLevel.Error,
|
||||
StalenessEventType.Warning => LogLevel.Warning,
|
||||
StalenessEventType.AnchorMissing => LogLevel.Warning,
|
||||
StalenessEventType.Recovered => LogLevel.Information,
|
||||
_ => LogLevel.Information
|
||||
};
|
||||
|
||||
_logger.Log(
|
||||
logLevel,
|
||||
"Staleness {EventType} for tenant {TenantId}: age={AgeSeconds}s, threshold={ThresholdSeconds}s - {Message}",
|
||||
evt.Type,
|
||||
evt.TenantId,
|
||||
evt.AgeSeconds,
|
||||
evt.ThresholdSeconds,
|
||||
evt.Message);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user