Refactor code structure for improved readability and maintainability

This commit is contained in:
StellaOps Bot
2025-12-06 10:23:40 +02:00
parent 6beb9d7c4e
commit 37304cf819
78 changed files with 5471 additions and 104 deletions

View File

@@ -46,6 +46,10 @@ public static class AirGapServiceCollectionExtensions
timeProvider: timeProvider);
});
// Register timeline emitter (CONCELIER-WEB-AIRGAP-58-001)
services.TryAddSingleton<IBundleTimelineEmitter, BundleTimelineEmitter>();
services.AddSingleton<IBundleTimelineEventSink, LoggingBundleTimelineEventSink>();
return services;
}
}

View File

@@ -0,0 +1,183 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Core.AirGap.Models;
namespace StellaOps.Concelier.Core.AirGap;
/// <summary>
/// Default implementation of bundle timeline event emission.
/// Per CONCELIER-WEB-AIRGAP-58-001.
/// </summary>
public sealed class BundleTimelineEmitter : IBundleTimelineEmitter
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<BundleTimelineEmitter> _logger;
private readonly List<IBundleTimelineEventSink> _sinks;
public BundleTimelineEmitter(
TimeProvider timeProvider,
IEnumerable<IBundleTimelineEventSink> sinks,
ILogger<BundleTimelineEmitter> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_sinks = sinks?.ToList() ?? [];
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task EmitImportAsync(
BundleImportTimelineEvent timelineEvent,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(timelineEvent);
_logger.LogInformation(
"Emitting bundle import timeline event: TenantId={TenantId}, BundleId={BundleId}, SourceId={SourceId}, Type={BundleType}, Scope={Scope}, ItemsAdded={ItemsAdded}, ItemsUpdated={ItemsUpdated}",
timelineEvent.TenantId,
timelineEvent.BundleId,
timelineEvent.SourceId,
timelineEvent.BundleType,
timelineEvent.Scope,
timelineEvent.Stats.ItemsAdded,
timelineEvent.Stats.ItemsUpdated);
// Emit to all registered sinks
var tasks = _sinks.Select(sink => EmitToSinkAsync(sink, timelineEvent, cancellationToken));
await Task.WhenAll(tasks).ConfigureAwait(false);
}
public async Task<BundleImportTimelineEvent> EmitImportAsync(
BundleImportRequest request,
BundleImportResult result,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(result);
var timelineEvent = new BundleImportTimelineEvent
{
EventId = Guid.NewGuid(),
TenantId = request.TenantId,
BundleId = request.Bundle.BundleId,
SourceId = request.Bundle.SourceId,
BundleType = request.Bundle.Type,
Scope = request.Scope,
Actor = request.Actor,
Stats = result.Stats,
EvidenceBundleRef = result.EvidenceBundleRef,
ContentHash = request.Bundle.ContentHash,
OccurredAt = _timeProvider.GetUtcNow(),
TraceId = request.TraceId ?? Activity.Current?.TraceId.ToString()
};
await EmitImportAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
return timelineEvent;
}
private async Task EmitToSinkAsync(
IBundleTimelineEventSink sink,
BundleImportTimelineEvent timelineEvent,
CancellationToken cancellationToken)
{
try
{
await sink.WriteAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to emit timeline event to sink {SinkType}: EventId={EventId}, BundleId={BundleId}",
sink.GetType().Name,
timelineEvent.EventId,
timelineEvent.BundleId);
// Swallow exception to allow other sinks to process
}
}
}
/// <summary>
/// Sink for writing bundle timeline events to a destination.
/// </summary>
public interface IBundleTimelineEventSink
{
/// <summary>
/// Writes a timeline event.
/// </summary>
Task WriteAsync(BundleImportTimelineEvent timelineEvent, CancellationToken cancellationToken);
}
/// <summary>
/// In-memory sink for testing or local buffering.
/// </summary>
public sealed class InMemoryBundleTimelineEventSink : IBundleTimelineEventSink
{
private readonly List<BundleImportTimelineEvent> _events = [];
private readonly object _lock = new();
public IReadOnlyList<BundleImportTimelineEvent> Events
{
get
{
lock (_lock)
{
return _events.ToList();
}
}
}
public Task WriteAsync(BundleImportTimelineEvent timelineEvent, CancellationToken cancellationToken)
{
lock (_lock)
{
_events.Add(timelineEvent);
}
return Task.CompletedTask;
}
public void Clear()
{
lock (_lock)
{
_events.Clear();
}
}
}
/// <summary>
/// Logging sink that writes timeline events to structured logs.
/// </summary>
public sealed class LoggingBundleTimelineEventSink : IBundleTimelineEventSink
{
private readonly ILogger<LoggingBundleTimelineEventSink> _logger;
public LoggingBundleTimelineEventSink(ILogger<LoggingBundleTimelineEventSink> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task WriteAsync(BundleImportTimelineEvent timelineEvent, CancellationToken cancellationToken)
{
_logger.LogInformation(
"TIMELINE_EVENT: Type={Type}, EventId={EventId}, TenantId={TenantId}, BundleId={BundleId}, " +
"SourceId={SourceId}, BundleType={BundleType}, Scope={Scope}, ActorId={ActorId}, " +
"ItemsAdded={ItemsAdded}, ItemsUpdated={ItemsUpdated}, ItemsRemoved={ItemsRemoved}, " +
"DurationMs={DurationMs}, ContentHash={ContentHash}, TraceId={TraceId}, OccurredAt={OccurredAt}",
timelineEvent.Type,
timelineEvent.EventId,
timelineEvent.TenantId,
timelineEvent.BundleId,
timelineEvent.SourceId,
timelineEvent.BundleType,
timelineEvent.Scope,
timelineEvent.Actor.Id,
timelineEvent.Stats.ItemsAdded,
timelineEvent.Stats.ItemsUpdated,
timelineEvent.Stats.ItemsRemoved,
timelineEvent.Stats.DurationMs,
timelineEvent.ContentHash,
timelineEvent.TraceId,
timelineEvent.OccurredAt.ToString("O"));
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,82 @@
using StellaOps.Concelier.Core.AirGap.Models;
namespace StellaOps.Concelier.Core.AirGap;
/// <summary>
/// Service for emitting timeline events for bundle operations.
/// Per CONCELIER-WEB-AIRGAP-58-001.
/// </summary>
public interface IBundleTimelineEmitter
{
/// <summary>
/// Emits a timeline event for a bundle import.
/// </summary>
Task EmitImportAsync(
BundleImportTimelineEvent timelineEvent,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates and emits a timeline event for a bundle import.
/// </summary>
Task<BundleImportTimelineEvent> EmitImportAsync(
BundleImportRequest request,
BundleImportResult result,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for a bundle import operation.
/// </summary>
public sealed record BundleImportRequest
{
/// <summary>
/// Tenant performing the import.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Bundle to import.
/// </summary>
public required BundleCatalogEntry Bundle { get; init; }
/// <summary>
/// Scope of the import.
/// </summary>
public BundleImportScope Scope { get; init; } = BundleImportScope.Delta;
/// <summary>
/// Actor performing the import.
/// </summary>
public required BundleImportActor Actor { get; init; }
/// <summary>
/// Optional trace ID for correlation.
/// </summary>
public string? TraceId { get; init; }
}
/// <summary>
/// Result of a bundle import operation.
/// </summary>
public sealed record BundleImportResult
{
/// <summary>
/// Whether the import succeeded.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Import statistics.
/// </summary>
public required BundleImportStats Stats { get; init; }
/// <summary>
/// Evidence bundle reference if generated.
/// </summary>
public string? EvidenceBundleRef { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? ErrorMessage { get; init; }
}

View File

@@ -2,6 +2,8 @@ using StellaOps.Concelier.Core.AirGap.Models;
namespace StellaOps.Concelier.Core.AirGap;
// Per CONCELIER-WEB-AIRGAP-57-001: Egress blocking with remediation guidance
/// <summary>
/// Enforces sealed mode by blocking direct internet feeds.
/// Per CONCELIER-WEB-AIRGAP-56-001.
@@ -37,16 +39,41 @@ public interface ISealedModeEnforcer
/// <summary>
/// Exception thrown when a sealed mode violation occurs.
/// Per CONCELIER-WEB-AIRGAP-57-001.
/// </summary>
public sealed class SealedModeViolationException : Exception
{
public SealedModeViolationException(string sourceName, Uri destination)
: this(sourceName, destination, DateTimeOffset.UtcNow)
{
}
public SealedModeViolationException(string sourceName, Uri destination, DateTimeOffset occurredAt)
: base($"Sealed mode violation: source '{sourceName}' attempted to access '{destination}'")
{
SourceName = sourceName;
Destination = destination;
OccurredAt = occurredAt;
Payload = AirGapEgressBlockedPayload.FromViolation(sourceName, destination, occurredAt, wasBlocked: true);
}
/// <summary>
/// Source name that attempted the egress.
/// </summary>
public string SourceName { get; }
/// <summary>
/// Destination URI that was blocked.
/// </summary>
public Uri Destination { get; }
/// <summary>
/// When the violation occurred.
/// </summary>
public DateTimeOffset OccurredAt { get; }
/// <summary>
/// Structured payload with remediation guidance.
/// </summary>
public AirGapEgressBlockedPayload Payload { get; }
}

View File

@@ -0,0 +1,164 @@
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.AirGap.Models;
/// <summary>
/// Structured payload for AIRGAP_EGRESS_BLOCKED events.
/// Per CONCELIER-WEB-AIRGAP-57-001.
/// </summary>
public sealed record AirGapEgressBlockedPayload
{
/// <summary>
/// Error code for this violation type.
/// </summary>
public const string ErrorCode = "AIRGAP_EGRESS_BLOCKED";
/// <summary>
/// Source name that attempted the egress.
/// </summary>
public required string SourceName { get; init; }
/// <summary>
/// Destination URI that was blocked.
/// </summary>
public required string Destination { get; init; }
/// <summary>
/// Host portion of the destination.
/// </summary>
public required string DestinationHost { get; init; }
/// <summary>
/// Reason for blocking.
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// Timestamp when the violation occurred.
/// </summary>
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Whether this was actually blocked (vs. warn-only mode).
/// </summary>
public required bool WasBlocked { get; init; }
/// <summary>
/// Remediation guidance for the operator.
/// </summary>
public required AirGapRemediationGuidance Remediation { get; init; }
/// <summary>
/// Creates a payload from a violation exception.
/// </summary>
public static AirGapEgressBlockedPayload FromViolation(
string sourceName,
Uri destination,
DateTimeOffset occurredAt,
bool wasBlocked)
{
return new AirGapEgressBlockedPayload
{
SourceName = sourceName,
Destination = destination.ToString(),
DestinationHost = destination.Host,
Reason = $"Source '{sourceName}' is not in the allowed sources list and host '{destination.Host}' is not in the allowed hosts list.",
OccurredAt = occurredAt,
WasBlocked = wasBlocked,
Remediation = AirGapRemediationGuidance.ForEgressBlocked(sourceName, destination.Host)
};
}
}
/// <summary>
/// Remediation guidance for air-gap violations.
/// Per CONCELIER-WEB-AIRGAP-57-001.
/// </summary>
public sealed record AirGapRemediationGuidance
{
/// <summary>
/// Short summary of what to do.
/// </summary>
public required string Summary { get; init; }
/// <summary>
/// Detailed steps to remediate the issue.
/// </summary>
public required ImmutableArray<RemediationStep> Steps { get; init; }
/// <summary>
/// Configuration keys that can be modified to allow this access.
/// </summary>
public required ImmutableArray<ConfigurationHint> ConfigurationHints { get; init; }
/// <summary>
/// Links to relevant documentation.
/// </summary>
public required ImmutableArray<DocumentationLink> DocumentationLinks { get; init; }
/// <summary>
/// Creates remediation guidance for an egress blocked violation.
/// </summary>
public static AirGapRemediationGuidance ForEgressBlocked(string sourceName, string host)
{
return new AirGapRemediationGuidance
{
Summary = $"Add '{sourceName}' to allowed sources or '{host}' to allowed hosts to permit this access.",
Steps = ImmutableArray.Create(
new RemediationStep(
Order: 1,
Action: "Review the blocked access",
Description: $"Verify that '{sourceName}' should be allowed to access '{host}' based on your security policy."),
new RemediationStep(
Order: 2,
Action: "Update configuration",
Description: "Add the source or host to the appropriate allowlist in your configuration."),
new RemediationStep(
Order: 3,
Action: "Restart or reload",
Description: "Restart the service or trigger a configuration reload for changes to take effect.")
),
ConfigurationHints = ImmutableArray.Create(
new ConfigurationHint(
Key: "Concelier:AirGap:SealedMode:AllowedSources",
Description: $"Add '{sourceName}' to this list to allow the source.",
Example: $"[\"{sourceName}\"]"),
new ConfigurationHint(
Key: "Concelier:AirGap:SealedMode:AllowedHosts",
Description: $"Add '{host}' to this list to allow the destination host.",
Example: $"[\"{host}\"]")
),
DocumentationLinks = ImmutableArray.Create(
new DocumentationLink(
Title: "Air-Gap Configuration Guide",
Url: "https://docs.stellaops.org/concelier/airgap/configuration"),
new DocumentationLink(
Title: "Sealed Mode Reference",
Url: "https://docs.stellaops.org/concelier/airgap/sealed-mode")
)
};
}
}
/// <summary>
/// A remediation step.
/// </summary>
public sealed record RemediationStep(
int Order,
string Action,
string Description);
/// <summary>
/// A configuration hint for remediation.
/// </summary>
public sealed record ConfigurationHint(
string Key,
string Description,
string Example);
/// <summary>
/// A link to documentation.
/// </summary>
public sealed record DocumentationLink(
string Title,
string Url);

View File

@@ -0,0 +1,161 @@
namespace StellaOps.Concelier.Core.AirGap.Models;
/// <summary>
/// Timeline event emitted when a bundle is imported.
/// Per CONCELIER-WEB-AIRGAP-58-001.
/// </summary>
public sealed record BundleImportTimelineEvent
{
/// <summary>
/// Event type identifier.
/// </summary>
public const string EventType = "airgap.bundle.imported";
/// <summary>
/// Unique event identifier.
/// </summary>
public required Guid EventId { get; init; }
/// <summary>
/// Type of the event (always "airgap.bundle.imported").
/// </summary>
public string Type => EventType;
/// <summary>
/// Tenant that owns this import.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Bundle identifier.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// Source that provided the bundle.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Bundle type (advisory, vex, sbom, etc.).
/// </summary>
public required string BundleType { get; init; }
/// <summary>
/// Scope of the import (full, delta, patch).
/// </summary>
public required BundleImportScope Scope { get; init; }
/// <summary>
/// Actor who performed the import.
/// </summary>
public required BundleImportActor Actor { get; init; }
/// <summary>
/// Import statistics.
/// </summary>
public required BundleImportStats Stats { get; init; }
/// <summary>
/// Evidence bundle reference if applicable.
/// </summary>
public string? EvidenceBundleRef { get; init; }
/// <summary>
/// Content hash of the imported bundle.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// When the import occurred.
/// </summary>
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Correlation trace ID for distributed tracing.
/// </summary>
public string? TraceId { get; init; }
}
/// <summary>
/// Scope of the bundle import.
/// </summary>
public enum BundleImportScope
{
/// <summary>
/// Full import replacing all existing data.
/// </summary>
Full,
/// <summary>
/// Delta import with only changes.
/// </summary>
Delta,
/// <summary>
/// Patch import for specific corrections.
/// </summary>
Patch
}
/// <summary>
/// Actor information for the import.
/// </summary>
public sealed record BundleImportActor
{
/// <summary>
/// Actor identifier.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Actor type (user, service, system).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Actor display name.
/// </summary>
public string? DisplayName { get; init; }
}
/// <summary>
/// Statistics for the bundle import.
/// </summary>
public sealed record BundleImportStats
{
/// <summary>
/// Total items in the bundle.
/// </summary>
public int TotalItems { get; init; }
/// <summary>
/// Items added during import.
/// </summary>
public int ItemsAdded { get; init; }
/// <summary>
/// Items updated during import.
/// </summary>
public int ItemsUpdated { get; init; }
/// <summary>
/// Items removed during import.
/// </summary>
public int ItemsRemoved { get; init; }
/// <summary>
/// Items skipped (unchanged).
/// </summary>
public int ItemsSkipped { get; init; }
/// <summary>
/// Duration of the import in milliseconds.
/// </summary>
public long DurationMs { get; init; }
/// <summary>
/// Bundle size in bytes.
/// </summary>
public long SizeBytes { get; init; }
}

View File

@@ -72,7 +72,7 @@ public sealed class SealedModeEnforcer : ISealedModeEnforcer
"Sealed mode violation blocked: source '{SourceName}' attempted to access '{Destination}'",
sourceName, destination);
throw new SealedModeViolationException(sourceName, destination);
throw new SealedModeViolationException(sourceName, destination, _timeProvider.GetUtcNow());
}
/// <inheritdoc />