154 lines
6.1 KiB
C#
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'");
|
|
}
|