Files
git.stella-ops.org/src/Findings/StellaOps.Findings.Ledger/Services/AirgapImportService.cs

154 lines
6.1 KiB
C#

using System.Globalization;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure;
using StellaOps.Findings.Ledger.Infrastructure.AirGap;
using StellaOps.Findings.Ledger.Observability;
namespace StellaOps.Findings.Ledger.Services;
public sealed record AirgapImportInput(
string TenantId,
string BundleId,
string? MirrorGeneration,
string MerkleRoot,
DateTimeOffset TimeAnchor,
string? Publisher,
string? HashAlgorithm,
IReadOnlyList<string> Contents,
string? ImportOperator);
public sealed record AirgapImportResult(
bool Success,
Guid ChainId,
long? SequenceNumber,
Guid? LedgerEventId,
string? Error);
public sealed class AirgapImportService
{
private readonly ILedgerEventRepository _ledgerEventRepository;
private readonly ILedgerEventWriteService _writeService;
private readonly IAirgapImportRepository _repository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AirgapImportService> _logger;
public AirgapImportService(
ILedgerEventRepository ledgerEventRepository,
ILedgerEventWriteService writeService,
IAirgapImportRepository repository,
TimeProvider timeProvider,
ILogger<AirgapImportService> logger)
{
_ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository));
_writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AirgapImportResult> RecordAsync(AirgapImportInput input, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(input);
var chainId = LedgerChainIdGenerator.FromTenantSubject(input.TenantId, $"airgap::{input.BundleId}");
var chainHead = await _ledgerEventRepository.GetChainHeadAsync(input.TenantId, chainId, cancellationToken).ConfigureAwait(false);
var sequence = (chainHead?.SequenceNumber ?? 0) + 1;
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
var eventId = Guid.NewGuid();
var recordedAt = _timeProvider.GetUtcNow();
var payload = new JsonObject
{
["airgap"] = new JsonObject
{
["bundleId"] = input.BundleId,
["mirrorGeneration"] = input.MirrorGeneration,
["merkleRoot"] = input.MerkleRoot,
["timeAnchor"] = input.TimeAnchor.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
["publisher"] = input.Publisher,
["hashAlgorithm"] = input.HashAlgorithm,
["contents"] = new JsonArray(input.Contents.Select(c => (JsonNode)c).ToArray())
}
};
var envelope = new JsonObject
{
["event"] = new JsonObject
{
["id"] = eventId.ToString(),
["type"] = LedgerEventConstants.EventAirgapBundleImported,
["tenant"] = input.TenantId,
["chainId"] = chainId.ToString(),
["sequence"] = sequence,
["policyVersion"] = input.MirrorGeneration ?? "airgap-bundle",
["artifactId"] = input.BundleId,
["finding"] = new JsonObject
{
["id"] = input.BundleId,
["artifactId"] = input.BundleId,
["vulnId"] = "airgap-import"
},
["actor"] = new JsonObject
{
["id"] = input.ImportOperator ?? "airgap-operator",
["type"] = "operator"
},
["occurredAt"] = FormatTimestamp(input.TimeAnchor),
["recordedAt"] = FormatTimestamp(recordedAt),
["payload"] = payload.DeepClone()
}
};
var draft = new LedgerEventDraft(
input.TenantId,
chainId,
sequence,
eventId,
LedgerEventConstants.EventAirgapBundleImported,
input.MirrorGeneration ?? "airgap-bundle",
input.BundleId,
input.BundleId,
SourceRunId: null,
ActorId: input.ImportOperator ?? "airgap-operator",
ActorType: "operator",
OccurredAt: input.TimeAnchor.ToUniversalTime(),
RecordedAt: recordedAt,
Payload: payload,
CanonicalEnvelope: envelope,
ProvidedPreviousHash: previousHash);
var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent))
{
var error = string.Join(";", writeResult.Errors);
return new AirgapImportResult(false, chainId, sequence, writeResult.Record?.EventId, error);
}
var ledgerEventId = writeResult.Record?.EventId;
var record = new AirgapImportRecord(
input.TenantId,
input.BundleId,
input.MirrorGeneration,
input.MerkleRoot,
input.TimeAnchor.ToUniversalTime(),
input.Publisher,
input.HashAlgorithm,
new JsonArray(input.Contents.Select(c => (JsonNode)c).ToArray()),
recordedAt,
input.ImportOperator,
ledgerEventId);
await _repository.InsertAsync(record, cancellationToken).ConfigureAwait(false);
LedgerTimeline.EmitAirgapImport(_logger, input.TenantId, input.BundleId, input.MerkleRoot, ledgerEventId);
return new AirgapImportResult(true, chainId, sequence, ledgerEventId, null);
}
private static string FormatTimestamp(DateTimeOffset value)
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
}