488 lines
18 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|