Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/Services/EvidenceSnapshotService.cs
2026-01-04 22:49:53 +02:00

488 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Configuration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Repositories;
using StellaOps.EvidenceLocker.Core.Signing;
using StellaOps.EvidenceLocker.Core.Incident;
using StellaOps.EvidenceLocker.Core.Timeline;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.Determinism;
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
public sealed class EvidenceSnapshotService
{
private static readonly string EmptyRoot = new('0', 64);
private static readonly JsonSerializerOptions IncidentSerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly IEvidenceBundleRepository _repository;
private readonly IEvidenceBundleBuilder _bundleBuilder;
private readonly IEvidenceSignatureService _signatureService;
private readonly IEvidenceTimelinePublisher _timelinePublisher;
private readonly IIncidentModeState _incidentMode;
private readonly IEvidenceObjectStore _objectStore;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ILogger<EvidenceSnapshotService> _logger;
private readonly QuotaOptions _quotas;
public EvidenceSnapshotService(
IEvidenceBundleRepository repository,
IEvidenceBundleBuilder bundleBuilder,
IEvidenceSignatureService signatureService,
IEvidenceTimelinePublisher timelinePublisher,
IIncidentModeState incidentMode,
IEvidenceObjectStore objectStore,
TimeProvider timeProvider,
IOptions<EvidenceLockerOptions> options,
ILogger<EvidenceSnapshotService> logger,
IGuidProvider? guidProvider = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_bundleBuilder = bundleBuilder ?? throw new ArgumentNullException(nameof(bundleBuilder));
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
_incidentMode = incidentMode ?? throw new ArgumentNullException(nameof(incidentMode));
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
_timeProvider = timeProvider ?? TimeProvider.System;
ArgumentNullException.ThrowIfNull(options);
_quotas = options.Value.Quotas ?? throw new InvalidOperationException("Quota options are required.");
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
public async Task<EvidenceSnapshotResult> CreateSnapshotAsync(
TenantId tenantId,
EvidenceSnapshotRequest request,
CancellationToken cancellationToken)
{
if (tenantId == default)
{
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
}
ArgumentNullException.ThrowIfNull(request);
ValidateRequest(request);
var bundleId = EvidenceBundleId.FromGuid(_guidProvider.NewGuid());
var createdAt = _timeProvider.GetUtcNow();
var storageKey = $"tenants/{tenantId.Value:N}/bundles/{bundleId.Value:N}/bundle.tgz";
var incidentSnapshot = _incidentMode.Current;
DateTimeOffset? expiresAt = null;
if (incidentSnapshot.IsActive && incidentSnapshot.RetentionExtensionDays > 0)
{
expiresAt = createdAt.AddDays(incidentSnapshot.RetentionExtensionDays);
}
var metadataBuffer = new Dictionary<string, string>(
request.Metadata ?? new Dictionary<string, string>(),
StringComparer.Ordinal);
if (incidentSnapshot.IsActive)
{
metadataBuffer["incident.mode"] = "enabled";
metadataBuffer["incident.changedAt"] = incidentSnapshot.ChangedAt.ToString("O", CultureInfo.InvariantCulture);
metadataBuffer["incident.retentionExtensionDays"] = incidentSnapshot.RetentionExtensionDays.ToString(CultureInfo.InvariantCulture);
}
var normalizedMetadata = NormalizeMetadata(metadataBuffer);
var bundle = new EvidenceBundle(
bundleId,
tenantId,
request.Kind,
EvidenceBundleStatus.Pending,
EmptyRoot,
storageKey,
createdAt,
createdAt,
request.Description,
null,
expiresAt);
await _repository.CreateBundleAsync(bundle, cancellationToken).ConfigureAwait(false);
var normalizedMaterials = request.Materials
.Select(material => new EvidenceBundleMaterial(
material.Section ?? string.Empty,
material.Path ?? string.Empty,
material.Sha256,
material.SizeBytes,
material.MediaType ?? "application/octet-stream",
NormalizeAttributes(material.Attributes)))
.ToList();
if (incidentSnapshot.IsActive &&
incidentSnapshot.CaptureRequestSnapshot &&
normalizedMaterials.Count < _quotas.MaxMaterialCount)
{
var incidentMaterial = await TryCaptureIncidentSnapshotAsync(
tenantId,
bundleId,
incidentSnapshot,
request,
normalizedMetadata,
createdAt,
cancellationToken).ConfigureAwait(false);
if (incidentMaterial is not null)
{
normalizedMaterials.Add(incidentMaterial);
}
}
var buildRequest = new EvidenceBundleBuildRequest(
bundleId,
tenantId,
request.Kind,
createdAt,
normalizedMetadata,
normalizedMaterials);
var buildResult = await _bundleBuilder.BuildAsync(buildRequest, cancellationToken).ConfigureAwait(false);
await _repository.SetBundleAssemblyAsync(
bundleId,
tenantId,
EvidenceBundleStatus.Assembling,
buildResult.RootHash,
createdAt,
cancellationToken).ConfigureAwait(false);
var signature = await _signatureService.SignManifestAsync(
bundleId,
tenantId,
buildResult.Manifest,
cancellationToken).ConfigureAwait(false);
if (signature is not null)
{
await _repository.UpsertSignatureAsync(signature, cancellationToken).ConfigureAwait(false);
await _timelinePublisher.PublishBundleSealedAsync(signature, buildResult.Manifest, buildResult.RootHash, cancellationToken)
.ConfigureAwait(false);
}
var sealedAt = signature?.TimestampedAt ?? signature?.SignedAt ?? _timeProvider.GetUtcNow();
await _repository.MarkBundleSealedAsync(
bundleId,
tenantId,
EvidenceBundleStatus.Sealed,
sealedAt,
cancellationToken).ConfigureAwait(false);
return new EvidenceSnapshotResult(bundleId.Value, buildResult.RootHash, buildResult.Manifest, signature);
}
public Task<EvidenceBundleDetails?> GetBundleAsync(
TenantId tenantId,
EvidenceBundleId bundleId,
CancellationToken cancellationToken)
{
if (tenantId == default)
{
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
}
if (bundleId == default)
{
throw new ArgumentException("Bundle identifier is required.", nameof(bundleId));
}
return _repository.GetBundleAsync(bundleId, tenantId, cancellationToken);
}
public async Task<bool> VerifyAsync(
TenantId tenantId,
EvidenceBundleId bundleId,
string expectedRootHash,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(expectedRootHash))
{
throw new ArgumentException("Expected root hash must be provided.", nameof(expectedRootHash));
}
var details = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken).ConfigureAwait(false);
return details is not null &&
string.Equals(details.Bundle.RootHash, expectedRootHash, StringComparison.OrdinalIgnoreCase);
}
public async Task<EvidenceHold> CreateHoldAsync(
TenantId tenantId,
string caseId,
EvidenceHoldRequest request,
CancellationToken cancellationToken)
{
if (tenantId == default)
{
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
}
ArgumentException.ThrowIfNullOrWhiteSpace(caseId);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason);
EvidenceBundleId? bundleId = null;
if (request.BundleId.HasValue)
{
bundleId = EvidenceBundleId.FromGuid(request.BundleId.Value);
var exists = await _repository.ExistsAsync(bundleId.Value, tenantId, cancellationToken).ConfigureAwait(false);
if (!exists)
{
throw new InvalidOperationException($"Referenced bundle '{bundleId.Value.Value:D}' does not exist for tenant '{tenantId.Value:D}'.");
}
}
var holdId = EvidenceHoldId.FromGuid(_guidProvider.NewGuid());
var createdAt = _timeProvider.GetUtcNow();
var hold = new EvidenceHold(
holdId,
tenantId,
bundleId,
caseId,
request.Reason,
createdAt,
request.ExpiresAt,
null,
request.Notes);
EvidenceHold persisted;
try
{
persisted = await _repository.CreateHoldAsync(hold, cancellationToken).ConfigureAwait(false);
}
catch (PostgresException ex) when (string.Equals(ex.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal))
{
throw new InvalidOperationException($"A hold already exists for case '{caseId}' in tenant '{tenantId.Value:D}'.", ex);
}
if (bundleId.HasValue)
{
await _repository.ExtendBundleRetentionAsync(
bundleId.Value,
tenantId,
request.ExpiresAt,
createdAt,
cancellationToken).ConfigureAwait(false);
}
await _timelinePublisher.PublishHoldCreatedAsync(persisted, cancellationToken).ConfigureAwait(false);
return persisted;
}
private void ValidateRequest(EvidenceSnapshotRequest request)
{
if (!Enum.IsDefined(typeof(EvidenceBundleKind), request.Kind))
{
throw new InvalidOperationException($"Unsupported evidence bundle kind '{request.Kind}'.");
}
var metadataCount = request.Metadata?.Count ?? 0;
if (metadataCount > _quotas.MaxMetadataEntries)
{
throw new InvalidOperationException($"Metadata entry count {metadataCount} exceeds limit of {_quotas.MaxMetadataEntries}.");
}
if (request.Materials is null || request.Materials.Count == 0)
{
throw new InvalidOperationException("At least one material must be supplied for an evidence snapshot.");
}
if (request.Materials.Count > _quotas.MaxMaterialCount)
{
throw new InvalidOperationException($"Material count {request.Materials.Count} exceeds limit of {_quotas.MaxMaterialCount}.");
}
long totalSizeBytes = 0;
foreach (var entry in request.Metadata ?? new Dictionary<string, string>())
{
ValidateMetadata(entry.Key, entry.Value);
}
foreach (var material in request.Materials)
{
ValidateMaterial(material);
totalSizeBytes = checked(totalSizeBytes + material.SizeBytes);
if (totalSizeBytes > _quotas.MaxTotalMaterialSizeBytes)
{
throw new InvalidOperationException($"Material size total {totalSizeBytes} exceeds limit of {_quotas.MaxTotalMaterialSizeBytes} bytes.");
}
}
}
private void ValidateMetadata(string key, string value)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new InvalidOperationException("Metadata keys must be non-empty.");
}
if (key.Length > _quotas.MaxMetadataKeyLength)
{
throw new InvalidOperationException($"Metadata key '{key}' exceeds length limit of {_quotas.MaxMetadataKeyLength} characters.");
}
if (value is null)
{
throw new InvalidOperationException($"Metadata value for key '{key}' must not be null.");
}
if (value.Length > _quotas.MaxMetadataValueLength)
{
throw new InvalidOperationException($"Metadata value for key '{key}' exceeds length limit of {_quotas.MaxMetadataValueLength} characters.");
}
}
private void ValidateMaterial(EvidenceSnapshotMaterial material)
{
if (string.IsNullOrWhiteSpace(material.Sha256))
{
throw new InvalidOperationException("Material SHA-256 digest must be provided.");
}
if (material.Sha256.Length != 64 || !IsHex(material.Sha256))
{
throw new InvalidOperationException($"Material SHA-256 digest '{material.Sha256}' must be 64 hex characters.");
}
if (material.SizeBytes < 0)
{
throw new InvalidOperationException("Material size bytes cannot be negative.");
}
foreach (var attribute in material.Attributes ?? new Dictionary<string, string>())
{
ValidateMetadata(attribute.Key, attribute.Value);
}
}
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
}
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(metadata, StringComparer.Ordinal));
}
private static IReadOnlyDictionary<string, string> NormalizeAttributes(IDictionary<string, string>? attributes)
{
if (attributes is null || attributes.Count == 0)
{
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal));
}
return new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
private static bool IsHex(string value)
{
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
if (!isHex)
{
return false;
}
}
return true;
}
private async Task<EvidenceBundleMaterial?> TryCaptureIncidentSnapshotAsync(
TenantId tenantId,
EvidenceBundleId bundleId,
IncidentModeSnapshot incidentSnapshot,
EvidenceSnapshotRequest request,
IReadOnlyDictionary<string, string> normalizedMetadata,
DateTimeOffset capturedAt,
CancellationToken cancellationToken)
{
try
{
var payload = new
{
capturedAt = capturedAt,
incident = new
{
state = incidentSnapshot.IsActive ? "enabled" : "disabled",
retentionExtensionDays = incidentSnapshot.RetentionExtensionDays
},
request = new
{
kind = request.Kind,
metadata = normalizedMetadata,
materials = request.Materials.Select(material => new
{
section = material.Section,
path = material.Path,
sha256 = material.Sha256,
sizeBytes = material.SizeBytes,
mediaType = material.MediaType,
attributes = material.Attributes
})
}
};
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, IncidentSerializerOptions);
var artifactFileName = $"request-{capturedAt:yyyyMMddHHmmssfff}.json";
var artifactName = $"incident/{artifactFileName}";
await using var stream = new MemoryStream(bytes);
var metadata = await _objectStore.StoreAsync(
stream,
new EvidenceObjectWriteOptions(
tenantId,
bundleId,
artifactName,
"application/json"),
cancellationToken)
.ConfigureAwait(false);
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
{
["storageKey"] = metadata.StorageKey
};
return new EvidenceBundleMaterial(
"incident",
artifactFileName,
metadata.Sha256,
metadata.SizeBytes,
metadata.ContentType,
attributes);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to capture incident snapshot for bundle {BundleId}: {Message}",
bundleId.Value,
ex.Message);
return null;
}
}
}