Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Builders;
|
||||
|
||||
public sealed record EvidenceBundleBuildRequest(
|
||||
EvidenceBundleId BundleId,
|
||||
TenantId TenantId,
|
||||
EvidenceBundleKind Kind,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
IReadOnlyList<EvidenceBundleMaterial> Materials);
|
||||
|
||||
public sealed record EvidenceBundleMaterial(
|
||||
string Section,
|
||||
string Path,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string MediaType,
|
||||
IReadOnlyDictionary<string, string>? Attributes = null);
|
||||
|
||||
public sealed record EvidenceManifestEntry(
|
||||
string Section,
|
||||
string CanonicalPath,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string MediaType,
|
||||
IReadOnlyDictionary<string, string> Attributes);
|
||||
|
||||
public sealed record EvidenceBundleManifest(
|
||||
EvidenceBundleId BundleId,
|
||||
TenantId TenantId,
|
||||
EvidenceBundleKind Kind,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
IReadOnlyList<EvidenceManifestEntry> Entries);
|
||||
|
||||
public sealed record EvidenceBundleBuildResult(
|
||||
string RootHash,
|
||||
EvidenceBundleManifest Manifest);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.EvidenceLocker.Core.Builders;
|
||||
|
||||
public interface IEvidenceBundleBuilder
|
||||
{
|
||||
Task<EvidenceBundleBuildResult> BuildAsync(
|
||||
EvidenceBundleBuildRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Builders;
|
||||
|
||||
public interface IMerkleTreeCalculator
|
||||
{
|
||||
string CalculateRootHash(IEnumerable<string> canonicalLeafValues);
|
||||
}
|
||||
|
||||
public sealed class MerkleTreeCalculator : IMerkleTreeCalculator
|
||||
{
|
||||
public string CalculateRootHash(IEnumerable<string> canonicalLeafValues)
|
||||
{
|
||||
var leaves = canonicalLeafValues
|
||||
.Select(value => HashString(value))
|
||||
.ToArray();
|
||||
|
||||
if (leaves.Length == 0)
|
||||
{
|
||||
return HashString("stellaops:evidence:empty");
|
||||
}
|
||||
|
||||
return BuildTree(leaves);
|
||||
}
|
||||
|
||||
private static string BuildTree(IReadOnlyList<string> currentLevel)
|
||||
{
|
||||
if (currentLevel.Count == 1)
|
||||
{
|
||||
return currentLevel[0];
|
||||
}
|
||||
|
||||
var nextLevel = new List<string>((currentLevel.Count + 1) / 2);
|
||||
for (var i = 0; i < currentLevel.Count; i += 2)
|
||||
{
|
||||
var left = currentLevel[i];
|
||||
var right = i + 1 < currentLevel.Count ? currentLevel[i + 1] : left;
|
||||
var combined = string.CompareOrdinal(left, right) <= 0
|
||||
? $"{left}|{right}"
|
||||
: $"{right}|{left}";
|
||||
nextLevel.Add(HashString(combined));
|
||||
}
|
||||
|
||||
return BuildTree(nextLevel);
|
||||
}
|
||||
|
||||
private static string HashString(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.EvidenceLocker.Core;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Configuration;
|
||||
|
||||
public sealed class EvidenceLockerOptions
|
||||
{
|
||||
public const string SectionName = "EvidenceLocker";
|
||||
|
||||
[Required]
|
||||
public required DatabaseOptions Database { get; init; }
|
||||
|
||||
[Required]
|
||||
public required ObjectStoreOptions ObjectStore { get; init; }
|
||||
|
||||
[Required]
|
||||
public required QuotaOptions Quotas { get; init; }
|
||||
|
||||
[Required]
|
||||
public required SigningOptions Signing { get; init; }
|
||||
|
||||
public TimelineOptions? Timeline { get; init; }
|
||||
|
||||
public PortableOptions Portable { get; init; } = new();
|
||||
|
||||
public IncidentModeOptions Incident { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class DatabaseOptions
|
||||
{
|
||||
[Required]
|
||||
public required string ConnectionString { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables automatic execution of SQL migrations at startup.
|
||||
/// </summary>
|
||||
public bool ApplyMigrationsAtStartup { get; init; } = true;
|
||||
}
|
||||
|
||||
public enum ObjectStoreKind
|
||||
{
|
||||
FileSystem = 1,
|
||||
AmazonS3 = 2
|
||||
}
|
||||
|
||||
public sealed class ObjectStoreOptions
|
||||
{
|
||||
[Required]
|
||||
public required ObjectStoreKind Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, drivers must prevent object overwrite (WORM mode).
|
||||
/// </summary>
|
||||
public bool EnforceWriteOnce { get; init; } = true;
|
||||
|
||||
public FileSystemStoreOptions? FileSystem { get; init; }
|
||||
|
||||
public AmazonS3StoreOptions? AmazonS3 { get; init; }
|
||||
}
|
||||
|
||||
public sealed class FileSystemStoreOptions
|
||||
{
|
||||
[Required]
|
||||
public required string RootPath { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AmazonS3StoreOptions
|
||||
{
|
||||
[Required]
|
||||
public required string BucketName { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Region { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional prefix to namespace evidence objects.
|
||||
/// </summary>
|
||||
public string? Prefix { get; init; }
|
||||
|
||||
public bool UseIntelligentTiering { get; init; }
|
||||
}
|
||||
|
||||
public sealed class QuotaOptions
|
||||
{
|
||||
[Range(1, 10_000)]
|
||||
public int MaxMaterialCount { get; init; } = 128;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxTotalMaterialSizeBytes { get; init; } = 512L * 1024 * 1024;
|
||||
|
||||
[Range(0, 10_000)]
|
||||
public int MaxMetadataEntries { get; init; } = 64;
|
||||
|
||||
[Range(0, 2048)]
|
||||
public int MaxMetadataKeyLength { get; init; } = 128;
|
||||
|
||||
[Range(0, 8192)]
|
||||
public int MaxMetadataValueLength { get; init; } = 512;
|
||||
}
|
||||
|
||||
public sealed class SigningOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
[Required]
|
||||
public string Algorithm { get; init; } = SignatureAlgorithms.Es256;
|
||||
|
||||
[Required]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
public string? Provider { get; init; }
|
||||
|
||||
public string PayloadType { get; init; } = "application/vnd.stella.evidence.manifest+json";
|
||||
|
||||
public SigningKeyMaterialOptions? KeyMaterial { get; init; }
|
||||
|
||||
public TimestampingOptions? Timestamping { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SigningKeyMaterialOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional PEM-encoded EC private key used to seed the default provider.
|
||||
/// </summary>
|
||||
public string? EcPrivateKeyPem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional PEM-encoded EC public key to accompany the private key when seeding providers that require explicit public material.
|
||||
/// </summary>
|
||||
public string? EcPublicKeyPem { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TimestampingOptions
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[Url]
|
||||
public string? Endpoint { get; init; }
|
||||
|
||||
public string HashAlgorithm { get; init; } = "SHA256";
|
||||
|
||||
public bool RequireTimestamp { get; init; }
|
||||
|
||||
[Range(1, 300)]
|
||||
public int RequestTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
public TimestampAuthorityAuthenticationOptions? Authentication { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TimestampAuthorityAuthenticationOptions
|
||||
{
|
||||
public string? Username { get; init; }
|
||||
|
||||
public string? Password { get; init; }
|
||||
}
|
||||
|
||||
public sealed class IncidentModeOptions
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[Range(0, 3650)]
|
||||
public int RetentionExtensionDays { get; init; } = 30;
|
||||
|
||||
public bool CaptureRequestSnapshot { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed class TimelineOptions
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[Url]
|
||||
public string? Endpoint { get; init; }
|
||||
|
||||
[Range(1, 300)]
|
||||
public int RequestTimeoutSeconds { get; init; } = 15;
|
||||
|
||||
public string Source { get; init; } = "stellaops.evidence-locker";
|
||||
|
||||
public TimelineAuthenticationOptions? Authentication { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TimelineAuthenticationOptions
|
||||
{
|
||||
public string HeaderName { get; init; } = "Authorization";
|
||||
|
||||
public string Scheme { get; init; } = "Bearer";
|
||||
|
||||
public string? Token { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PortableOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public string ArtifactName { get; init; } = "portable-bundle-v1.tgz";
|
||||
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public string InstructionsFileName { get; init; } = "instructions-portable.txt";
|
||||
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public string OfflineScriptFileName { get; init; } = "verify-offline.sh";
|
||||
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public string MetadataFileName { get; init; } = "bundle.json";
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
public enum EvidenceBundleKind
|
||||
{
|
||||
Evaluation = 1,
|
||||
Job = 2,
|
||||
Export = 3
|
||||
}
|
||||
|
||||
public enum EvidenceBundleStatus
|
||||
{
|
||||
Pending = 1,
|
||||
Assembling = 2,
|
||||
Sealed = 3,
|
||||
Failed = 4,
|
||||
Archived = 5
|
||||
}
|
||||
|
||||
public sealed record EvidenceBundle(
|
||||
EvidenceBundleId Id,
|
||||
TenantId TenantId,
|
||||
EvidenceBundleKind Kind,
|
||||
EvidenceBundleStatus Status,
|
||||
string RootHash,
|
||||
string StorageKey,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? Description = null,
|
||||
DateTimeOffset? SealedAt = null,
|
||||
DateTimeOffset? ExpiresAt = null,
|
||||
string? PortableStorageKey = null,
|
||||
DateTimeOffset? PortableGeneratedAt = null);
|
||||
|
||||
public sealed record EvidenceArtifact(
|
||||
EvidenceArtifactId Id,
|
||||
EvidenceBundleId BundleId,
|
||||
TenantId TenantId,
|
||||
string Name,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string StorageKey,
|
||||
string Sha256,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record EvidenceHold(
|
||||
EvidenceHoldId Id,
|
||||
TenantId TenantId,
|
||||
EvidenceBundleId? BundleId,
|
||||
string CaseId,
|
||||
string Reason,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset? ReleasedAt,
|
||||
string? Notes = null);
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
public sealed record EvidenceBundleSignature(
|
||||
EvidenceBundleId BundleId,
|
||||
TenantId TenantId,
|
||||
string PayloadType,
|
||||
string Payload,
|
||||
string Signature,
|
||||
string? KeyId,
|
||||
string Algorithm,
|
||||
string Provider,
|
||||
DateTimeOffset SignedAt,
|
||||
DateTimeOffset? TimestampedAt = null,
|
||||
string? TimestampAuthority = null,
|
||||
byte[]? TimestampToken = null);
|
||||
|
||||
public sealed record EvidenceBundleDetails(
|
||||
EvidenceBundle Bundle,
|
||||
EvidenceBundleSignature? Signature);
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
public readonly record struct TenantId(Guid Value)
|
||||
{
|
||||
public static TenantId FromGuid(Guid value)
|
||||
=> value == Guid.Empty
|
||||
? throw new ArgumentException("Tenant identifier cannot be empty.", nameof(value))
|
||||
: new TenantId(value);
|
||||
|
||||
public override string ToString() => Value.ToString("N");
|
||||
}
|
||||
|
||||
public readonly record struct EvidenceBundleId(Guid Value)
|
||||
{
|
||||
public static EvidenceBundleId FromGuid(Guid value)
|
||||
=> value == Guid.Empty
|
||||
? throw new ArgumentException("Bundle identifier cannot be empty.", nameof(value))
|
||||
: new EvidenceBundleId(value);
|
||||
|
||||
public override string ToString() => Value.ToString("N");
|
||||
}
|
||||
|
||||
public readonly record struct EvidenceArtifactId(Guid Value)
|
||||
{
|
||||
public static EvidenceArtifactId FromGuid(Guid value)
|
||||
=> value == Guid.Empty
|
||||
? throw new ArgumentException("Artifact identifier cannot be empty.", nameof(value))
|
||||
: new EvidenceArtifactId(value);
|
||||
|
||||
public override string ToString() => Value.ToString("N");
|
||||
}
|
||||
|
||||
public readonly record struct EvidenceHoldId(Guid Value)
|
||||
{
|
||||
public static EvidenceHoldId FromGuid(Guid value)
|
||||
=> value == Guid.Empty
|
||||
? throw new ArgumentException("Hold identifier cannot be empty.", nameof(value))
|
||||
: new EvidenceHoldId(value);
|
||||
|
||||
public override string ToString() => Value.ToString("N");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
public sealed record EvidenceSnapshotRequest
|
||||
{
|
||||
public EvidenceBundleKind Kind { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
public IList<EvidenceSnapshotMaterial> Materials { get; init; } = new List<EvidenceSnapshotMaterial>();
|
||||
}
|
||||
|
||||
public sealed record EvidenceSnapshotMaterial
|
||||
{
|
||||
public string? Section { get; init; }
|
||||
|
||||
public string? Path { get; init; }
|
||||
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
public IDictionary<string, string> Attributes { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public sealed record EvidenceSnapshotResult(
|
||||
Guid BundleId,
|
||||
string RootHash,
|
||||
EvidenceBundleManifest Manifest,
|
||||
EvidenceBundleSignature? Signature);
|
||||
|
||||
public sealed record EvidenceHoldRequest
|
||||
{
|
||||
public Guid? BundleId { get; init; }
|
||||
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Incident;
|
||||
|
||||
public interface IIncidentModeState
|
||||
{
|
||||
IncidentModeSnapshot Current { get; }
|
||||
|
||||
bool IsActive { get; }
|
||||
}
|
||||
|
||||
public sealed record IncidentModeSnapshot(
|
||||
bool IsActive,
|
||||
DateTimeOffset ChangedAt,
|
||||
int RetentionExtensionDays,
|
||||
bool CaptureRequestSnapshot);
|
||||
|
||||
public sealed record IncidentModeChange(
|
||||
bool IsActive,
|
||||
DateTimeOffset ChangedAt,
|
||||
int RetentionExtensionDays);
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.EvidenceLocker.Core.Incident;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Notifications;
|
||||
|
||||
public interface IEvidenceIncidentNotifier
|
||||
{
|
||||
Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NullEvidenceIncidentNotifier : IEvidenceIncidentNotifier
|
||||
{
|
||||
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Repositories;
|
||||
|
||||
public interface IEvidenceBundleRepository
|
||||
{
|
||||
Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken);
|
||||
|
||||
Task SetBundleAssemblyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleStatus status,
|
||||
string rootHash,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task MarkBundleSealedAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleStatus status,
|
||||
DateTimeOffset sealedAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken);
|
||||
|
||||
Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken);
|
||||
|
||||
Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken);
|
||||
|
||||
Task ExtendBundleRetentionAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? holdExpiresAt,
|
||||
DateTimeOffset processedAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateStorageKeyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
string storageKey,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdatePortableStorageKeyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
string storageKey,
|
||||
DateTimeOffset generatedAt,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Signing;
|
||||
|
||||
public interface IEvidenceSignatureService
|
||||
{
|
||||
Task<EvidenceBundleSignature?> SignManifestAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleManifest manifest,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Signing;
|
||||
|
||||
public interface ITimestampAuthorityClient
|
||||
{
|
||||
Task<TimestampResult?> RequestTimestampAsync(
|
||||
ReadOnlyMemory<byte> signature,
|
||||
string hashAlgorithm,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record TimestampResult(
|
||||
DateTimeOffset Timestamp,
|
||||
string Authority,
|
||||
byte[] Token);
|
||||
@@ -1,18 +1,14 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
<?xml version="1.0"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Storage;
|
||||
|
||||
public sealed record EvidenceObjectMetadata(
|
||||
string StorageKey,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string Sha256,
|
||||
string? ETag,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record EvidenceObjectWriteOptions(
|
||||
TenantId TenantId,
|
||||
EvidenceBundleId BundleId,
|
||||
string ArtifactName,
|
||||
string ContentType,
|
||||
bool EnforceWriteOnce = true,
|
||||
IDictionary<string, string>? Tags = null);
|
||||
|
||||
public interface IEvidenceObjectStore
|
||||
{
|
||||
Task<EvidenceObjectMetadata> StoreAsync(
|
||||
Stream content,
|
||||
EvidenceObjectWriteOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Stream> OpenReadAsync(
|
||||
string storageKey,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> ExistsAsync(
|
||||
string storageKey,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Incident;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Timeline;
|
||||
|
||||
public interface IEvidenceTimelinePublisher
|
||||
{
|
||||
Task PublishBundleSealedAsync(
|
||||
EvidenceBundleSignature signature,
|
||||
EvidenceBundleManifest manifest,
|
||||
string rootHash,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task PublishHoldCreatedAsync(
|
||||
EvidenceHold hold,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task PublishIncidentModeChangedAsync(
|
||||
IncidentModeChange change,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.EvidenceLocker.Tests")]
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Builders;
|
||||
|
||||
internal sealed class EvidenceBundleBuilder(
|
||||
IEvidenceBundleRepository repository,
|
||||
IMerkleTreeCalculator merkleTreeCalculator) : IEvidenceBundleBuilder
|
||||
{
|
||||
public async Task<EvidenceBundleBuildResult> BuildAsync(
|
||||
EvidenceBundleBuildRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var manifest = CreateManifest(request);
|
||||
var rootHash = merkleTreeCalculator.CalculateRootHash(
|
||||
manifest.Entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256}"));
|
||||
|
||||
await repository.SetBundleAssemblyAsync(
|
||||
request.BundleId,
|
||||
request.TenantId,
|
||||
EvidenceBundleStatus.Sealed,
|
||||
rootHash,
|
||||
request.CreatedAt,
|
||||
cancellationToken);
|
||||
|
||||
return new EvidenceBundleBuildResult(rootHash, manifest);
|
||||
}
|
||||
|
||||
private static EvidenceBundleManifest CreateManifest(EvidenceBundleBuildRequest request)
|
||||
{
|
||||
var normalizedMetadata = request.Metadata?.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary(pair => pair.Key, pair => pair.Value)
|
||||
?? ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var entries = request.Materials?
|
||||
.Select(material => CreateEntry(material))
|
||||
.OrderBy(entry => entry.CanonicalPath, StringComparer.Ordinal)
|
||||
.ToImmutableArray() ?? ImmutableArray<EvidenceManifestEntry>.Empty;
|
||||
|
||||
return new EvidenceBundleManifest(
|
||||
request.BundleId,
|
||||
request.TenantId,
|
||||
request.Kind,
|
||||
request.CreatedAt,
|
||||
normalizedMetadata,
|
||||
entries);
|
||||
}
|
||||
|
||||
private static EvidenceManifestEntry CreateEntry(EvidenceBundleMaterial material)
|
||||
{
|
||||
var canonicalSection = NormalizeSection(material.Section);
|
||||
var canonicalPath = $"{canonicalSection}/{NormalizePath(material.Path)}";
|
||||
|
||||
var attributes = material.Attributes?
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary(pair => pair.Key, pair => pair.Value)
|
||||
?? ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
return new EvidenceManifestEntry(
|
||||
canonicalSection,
|
||||
canonicalPath,
|
||||
material.Sha256.ToLowerInvariant(),
|
||||
material.SizeBytes,
|
||||
material.MediaType,
|
||||
attributes);
|
||||
}
|
||||
|
||||
private static string NormalizeSection(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "root";
|
||||
}
|
||||
|
||||
return NormalizeSegment(value);
|
||||
}
|
||||
|
||||
private static string NormalizePath(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "item";
|
||||
}
|
||||
|
||||
var segments = value.Split(['/','\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||
var normalized = segments
|
||||
.Where(segment => segment is not "." and not "..")
|
||||
.Select(NormalizeSegment);
|
||||
return string.Join('/', normalized);
|
||||
}
|
||||
|
||||
private static string NormalizeSegment(string value)
|
||||
{
|
||||
Span<char> buffer = stackalloc char[value.Length];
|
||||
var index = 0;
|
||||
|
||||
foreach (var ch in value.Trim())
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
buffer[index++] = char.ToLowerInvariant(ch);
|
||||
}
|
||||
else if (ch is '-' or '_' || ch == '.')
|
||||
{
|
||||
buffer[index++] = ch;
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer[index++] = '-';
|
||||
}
|
||||
}
|
||||
|
||||
return index > 0 ? new string(buffer[..index]) : "item";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
|
||||
public sealed class EvidenceLockerDataSource : IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<EvidenceLockerDataSource> _logger;
|
||||
|
||||
public EvidenceLockerDataSource(
|
||||
DatabaseOptions databaseOptions,
|
||||
ILogger<EvidenceLockerDataSource> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(databaseOptions);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(databaseOptions.ConnectionString);
|
||||
|
||||
_logger = logger;
|
||||
_dataSource = CreateDataSource(databaseOptions.ConnectionString);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
public Task<NpgsqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||
=> OpenConnectionAsync(null, cancellationToken);
|
||||
|
||||
public async Task<NpgsqlConnection> OpenConnectionAsync(TenantId? tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await ConfigureSessionAsync(connection, tenantId, cancellationToken);
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static NpgsqlDataSource CreateDataSource(string connectionString)
|
||||
{
|
||||
var builder = new NpgsqlDataSourceBuilder(connectionString);
|
||||
builder.EnableDynamicJson();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private async Task ConfigureSessionAsync(NpgsqlConnection connection, TenantId? tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var command = new NpgsqlCommand("SET TIME ZONE 'UTC';", connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
await using var tenantCommand = new NpgsqlCommand("SELECT set_config('app.current_tenant', @tenant, false);", connection);
|
||||
tenantCommand.Parameters.AddWithValue("tenant", tenantId.Value.Value.ToString("D"));
|
||||
await tenantCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error))
|
||||
{
|
||||
_logger.LogError(ex, "Failed to configure Evidence Locker session state.");
|
||||
}
|
||||
|
||||
await connection.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
|
||||
public interface IEvidenceLockerMigrationRunner
|
||||
{
|
||||
Task ApplyAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class EvidenceLockerMigrationRunner(
|
||||
EvidenceLockerDataSource dataSource,
|
||||
ILogger<EvidenceLockerMigrationRunner> logger) : IEvidenceLockerMigrationRunner
|
||||
{
|
||||
private const string VersionTableSql = """
|
||||
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_schema_version
|
||||
(
|
||||
version integer PRIMARY KEY,
|
||||
script_name text NOT NULL,
|
||||
script_checksum text NOT NULL,
|
||||
applied_at_utc timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')
|
||||
);
|
||||
""";
|
||||
|
||||
public async Task ApplyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var scripts = MigrationLoader.LoadAll();
|
||||
|
||||
if (scripts.Count == 0)
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
logger.LogDebug("No migrations discovered for Evidence Locker.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
await EnsureVersionTableAsync(connection, transaction, cancellationToken);
|
||||
var appliedScripts = await LoadAppliedScriptsAsync(connection, transaction, cancellationToken);
|
||||
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
if (appliedScripts.TryGetValue(script.Version, out var existingChecksum))
|
||||
{
|
||||
if (!string.Equals(existingChecksum, script.Sha256, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Checksum mismatch for migration {script.Name}. Expected {existingChecksum}, computed {script.Sha256}.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation("Applying Evidence Locker migration {Version}: {Name}", script.Version, script.Name);
|
||||
}
|
||||
|
||||
await ExecuteScriptAsync(connection, transaction, script.Sql, cancellationToken);
|
||||
await RecordAppliedScriptAsync(connection, transaction, script, cancellationToken);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task EnsureVersionTableAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(VersionTableSql, connection, transaction);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<int, string>> LoadAppliedScriptsAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT version, script_checksum
|
||||
FROM evidence_locker.evidence_schema_version
|
||||
ORDER BY version;
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
var dictionary = new Dictionary<int, string>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var version = reader.GetInt32(0);
|
||||
var checksum = reader.GetString(1);
|
||||
dictionary[version] = checksum;
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private static async Task ExecuteScriptAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction)
|
||||
{
|
||||
CommandTimeout = 0
|
||||
};
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task RecordAppliedScriptAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, MigrationScript script, CancellationToken cancellationToken)
|
||||
{
|
||||
const string insertSql = """
|
||||
INSERT INTO evidence_locker.evidence_schema_version(version, script_name, script_checksum)
|
||||
VALUES (@version, @name, @checksum);
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(insertSql, connection, transaction);
|
||||
command.Parameters.AddWithValue("version", script.Version);
|
||||
command.Parameters.AddWithValue("name", script.Name);
|
||||
command.Parameters.AddWithValue("checksum", script.Sha256);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
|
||||
internal static class MigrationLoader
|
||||
{
|
||||
private static readonly Assembly Assembly = typeof(MigrationLoader).Assembly;
|
||||
|
||||
public static IReadOnlyList<MigrationScript> LoadAll()
|
||||
{
|
||||
var scripts = new List<MigrationScript>();
|
||||
|
||||
foreach (var resourceName in Assembly.GetManifestResourceNames())
|
||||
{
|
||||
if (!resourceName.Contains(".Db.Migrations.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var stream = Assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = reader.ReadToEnd();
|
||||
|
||||
if (MigrationScript.TryCreate(resourceName, sql, out var script))
|
||||
{
|
||||
scripts.Add(script);
|
||||
}
|
||||
}
|
||||
|
||||
return scripts
|
||||
.OrderBy(script => script.Version)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
|
||||
internal sealed class MigrationScript
|
||||
{
|
||||
private static readonly Regex VersionRegex = new(@"^(?<version>\d{3,})[_-]", RegexOptions.Compiled);
|
||||
|
||||
private MigrationScript(int version, string name, string sql)
|
||||
{
|
||||
Version = version;
|
||||
Name = name;
|
||||
Sql = sql;
|
||||
Sha256 = ComputeSha256(sql);
|
||||
}
|
||||
|
||||
public int Version { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Sql { get; }
|
||||
|
||||
public string Sha256 { get; }
|
||||
|
||||
public static bool TryCreate(string resourceName, string sql, [NotNullWhen(true)] out MigrationScript? script)
|
||||
{
|
||||
var fileName = resourceName.Split('.').Last();
|
||||
var match = VersionRegex.Match(fileName);
|
||||
|
||||
if (!match.Success || !int.TryParse(match.Groups["version"].Value, out var version))
|
||||
{
|
||||
script = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
script = new MigrationScript(version, fileName, sql);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string sql)
|
||||
{
|
||||
var normalized = NormalizeLineEndings(sql);
|
||||
var bytes = Encoding.UTF8.GetBytes(normalized);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndings(string value)
|
||||
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
-- 001_initial_schema.sql
|
||||
-- Establishes core schema, RLS policies, and supporting functions for the Evidence Locker.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS evidence_locker;
|
||||
CREATE SCHEMA IF NOT EXISTS evidence_locker_app;
|
||||
|
||||
CREATE OR REPLACE FUNCTION evidence_locker_app.require_current_tenant()
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
tenant_text text;
|
||||
BEGIN
|
||||
tenant_text := current_setting('app.current_tenant', true);
|
||||
IF tenant_text IS NULL OR length(tenant_text) = 0 THEN
|
||||
RAISE EXCEPTION 'app.current_tenant is not set for the current session';
|
||||
END IF;
|
||||
RETURN tenant_text::uuid;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_bundles
|
||||
(
|
||||
bundle_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
kind smallint NOT NULL CHECK (kind BETWEEN 1 AND 3),
|
||||
status smallint NOT NULL CHECK (status BETWEEN 1 AND 5),
|
||||
root_hash text NOT NULL CHECK (root_hash ~ '^[0-9a-f]{64}$'),
|
||||
storage_key text NOT NULL,
|
||||
description text,
|
||||
sealed_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
updated_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_bundles_storage_key
|
||||
ON evidence_locker.evidence_bundles (tenant_id, storage_key);
|
||||
|
||||
ALTER TABLE evidence_locker.evidence_bundles
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY IF NOT EXISTS evidence_bundles_isolation
|
||||
ON evidence_locker.evidence_bundles
|
||||
USING (tenant_id = evidence_locker_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_artifacts
|
||||
(
|
||||
artifact_id uuid PRIMARY KEY,
|
||||
bundle_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
content_type text NOT NULL,
|
||||
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
|
||||
storage_key text NOT NULL,
|
||||
sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
||||
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT fk_artifacts_bundle FOREIGN KEY (bundle_id) REFERENCES evidence_locker.evidence_bundles (bundle_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_evidence_artifacts_bundle_id
|
||||
ON evidence_locker.evidence_artifacts (bundle_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_artifacts_storage_key
|
||||
ON evidence_locker.evidence_artifacts (tenant_id, storage_key);
|
||||
|
||||
ALTER TABLE evidence_locker.evidence_artifacts
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY IF NOT EXISTS evidence_artifacts_isolation
|
||||
ON evidence_locker.evidence_artifacts
|
||||
USING (tenant_id = evidence_locker_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_holds
|
||||
(
|
||||
hold_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
bundle_id uuid,
|
||||
case_id text NOT NULL,
|
||||
reason text NOT NULL,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
expires_at timestamptz,
|
||||
released_at timestamptz,
|
||||
CONSTRAINT fk_evidence_holds_bundle
|
||||
FOREIGN KEY (bundle_id) REFERENCES evidence_locker.evidence_bundles (bundle_id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_holds_case
|
||||
ON evidence_locker.evidence_holds (tenant_id, case_id);
|
||||
|
||||
ALTER TABLE evidence_locker.evidence_holds
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY IF NOT EXISTS evidence_holds_isolation
|
||||
ON evidence_locker.evidence_holds
|
||||
USING (tenant_id = evidence_locker_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_bundle_signatures
|
||||
(
|
||||
bundle_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
payload_type text NOT NULL,
|
||||
payload text NOT NULL,
|
||||
signature text NOT NULL,
|
||||
key_id text,
|
||||
algorithm text NOT NULL,
|
||||
provider text NOT NULL,
|
||||
signed_at timestamptz NOT NULL,
|
||||
timestamped_at timestamptz,
|
||||
timestamp_authority text,
|
||||
timestamp_token bytea,
|
||||
PRIMARY KEY (bundle_id, tenant_id),
|
||||
CONSTRAINT fk_evidence_bundle_signatures_bundle
|
||||
FOREIGN KEY (bundle_id) REFERENCES evidence_locker.evidence_bundles (bundle_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_evidence_bundle_signatures_signed_at
|
||||
ON evidence_locker.evidence_bundle_signatures (tenant_id, signed_at DESC);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 003_portable_bundles.sql
|
||||
-- Adds portable evidence bundle storage metadata for sealed-mode packaging.
|
||||
|
||||
ALTER TABLE evidence_locker.evidence_bundles
|
||||
ADD COLUMN IF NOT EXISTS portable_storage_key text,
|
||||
ADD COLUMN IF NOT EXISTS portable_generated_at timestamptz;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_bundles_portable_storage_key
|
||||
ON evidence_locker.evidence_bundles (tenant_id, portable_storage_key)
|
||||
WHERE portable_storage_key IS NOT NULL;
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using Amazon;
|
||||
using Amazon.S3;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography.Plugin.BouncyCastle;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Incident;
|
||||
using StellaOps.EvidenceLocker.Core.Notifications;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Signing;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
using StellaOps.EvidenceLocker.Core.Timeline;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Builders;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Repositories;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Signing;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Storage;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Timeline;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;
|
||||
|
||||
public static class EvidenceLockerInfrastructureServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddEvidenceLockerInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services
|
||||
.AddOptions<EvidenceLockerOptions>()
|
||||
.Bind(configuration.GetSection(EvidenceLockerOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(static options => options.Signing is not null, "Signing options must be provided.")
|
||||
.Validate(static options => ValidateObjectStore(options.ObjectStore), "Invalid object-store configuration.")
|
||||
.Validate(static options => ValidateTimeline(options.Timeline), "Invalid timeline configuration.")
|
||||
.Validate(static options => ValidateIncident(options.Incident), "Invalid incident configuration.")
|
||||
.Validate(static options => ValidatePortable(options.Portable), "Invalid portable configuration.");
|
||||
|
||||
services.AddStellaOpsCrypto();
|
||||
services.AddBouncyCastleEd25519Provider();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
services.AddSingleton(provider =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value;
|
||||
var logger = provider.GetRequiredService<ILogger<EvidenceLockerDataSource>>();
|
||||
return new EvidenceLockerDataSource(options.Database, logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<IEvidenceLockerMigrationRunner, EvidenceLockerMigrationRunner>();
|
||||
services.AddHostedService<EvidenceLockerMigrationHostedService>();
|
||||
|
||||
services.AddSingleton<IMerkleTreeCalculator, MerkleTreeCalculator>();
|
||||
services.AddScoped<IEvidenceBundleBuilder, EvidenceBundleBuilder>();
|
||||
services.AddScoped<IEvidenceBundleRepository, EvidenceBundleRepository>();
|
||||
|
||||
services.AddSingleton<NullEvidenceTimelinePublisher>();
|
||||
services.AddHttpClient<TimelineIndexerEvidenceTimelinePublisher>((provider, client) =>
|
||||
{
|
||||
var timeline = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value.Timeline!;
|
||||
client.BaseAddress = new Uri(timeline.Endpoint!, UriKind.Absolute);
|
||||
client.Timeout = TimeSpan.FromSeconds(timeline.RequestTimeoutSeconds);
|
||||
|
||||
var auth = timeline.Authentication;
|
||||
if (auth?.Token is { Length: > 0 })
|
||||
{
|
||||
if (string.Equals(auth.HeaderName, "Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(auth.Scheme, auth.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
var value = string.IsNullOrWhiteSpace(auth.Scheme)
|
||||
? auth.Token
|
||||
: $"{auth.Scheme} {auth.Token}";
|
||||
client.DefaultRequestHeaders.Remove(auth.HeaderName);
|
||||
client.DefaultRequestHeaders.Add(auth.HeaderName, value);
|
||||
}
|
||||
}
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(static () => new SocketsHttpHandler
|
||||
{
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.All
|
||||
});
|
||||
|
||||
services.AddSingleton<IEvidenceTimelinePublisher>(provider =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value;
|
||||
if (options.Timeline?.Enabled is true)
|
||||
{
|
||||
return provider.GetRequiredService<TimelineIndexerEvidenceTimelinePublisher>();
|
||||
}
|
||||
|
||||
return provider.GetRequiredService<NullEvidenceTimelinePublisher>();
|
||||
});
|
||||
|
||||
services.TryAddSingleton<IEvidenceIncidentNotifier, NullEvidenceIncidentNotifier>();
|
||||
services.AddSingleton<IncidentModeManager>();
|
||||
services.AddSingleton<IIncidentModeState>(provider => provider.GetRequiredService<IncidentModeManager>());
|
||||
|
||||
services.TryAddSingleton<ITimestampAuthorityClient, NullTimestampAuthorityClient>();
|
||||
services.AddScoped<IEvidenceSignatureService, EvidenceSignatureService>();
|
||||
services.AddScoped<EvidenceSnapshotService>();
|
||||
services.AddScoped<EvidenceBundlePackagingService>();
|
||||
services.AddScoped<EvidencePortableBundleService>();
|
||||
|
||||
services.AddSingleton<IEvidenceObjectStore>(provider =>
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value;
|
||||
var enforceWriteOnce = options.ObjectStore.EnforceWriteOnce;
|
||||
|
||||
return options.ObjectStore.Kind switch
|
||||
{
|
||||
ObjectStoreKind.FileSystem => CreateFileSystemStore(
|
||||
options.ObjectStore.FileSystem!,
|
||||
enforceWriteOnce,
|
||||
provider.GetRequiredService<ILogger<FileSystemEvidenceObjectStore>>()),
|
||||
ObjectStoreKind.AmazonS3 => CreateS3Store(
|
||||
options.ObjectStore.AmazonS3!,
|
||||
enforceWriteOnce,
|
||||
provider.GetRequiredService<ILogger<S3EvidenceObjectStore>>()),
|
||||
_ => throw new InvalidOperationException($"Unsupported object-store kind '{options.ObjectStore.Kind}'.")
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static bool ValidateObjectStore(ObjectStoreOptions options)
|
||||
{
|
||||
return options.Kind switch
|
||||
{
|
||||
ObjectStoreKind.FileSystem => options.FileSystem is not null &&
|
||||
!string.IsNullOrWhiteSpace(options.FileSystem.RootPath),
|
||||
ObjectStoreKind.AmazonS3 => options.AmazonS3 is not null &&
|
||||
!string.IsNullOrWhiteSpace(options.AmazonS3.BucketName) &&
|
||||
!string.IsNullOrWhiteSpace(options.AmazonS3.Region),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ValidateTimeline(TimelineOptions? options)
|
||||
{
|
||||
if (options is null || !options.Enabled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(options.Endpoint);
|
||||
}
|
||||
|
||||
private static bool ValidateIncident(IncidentModeOptions? options)
|
||||
{
|
||||
if (options is null || !options.Enabled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return options.RetentionExtensionDays >= 1;
|
||||
}
|
||||
|
||||
private static bool ValidatePortable(PortableOptions? options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(options.ArtifactName)
|
||||
&& !string.IsNullOrWhiteSpace(options.MetadataFileName)
|
||||
&& !string.IsNullOrWhiteSpace(options.InstructionsFileName)
|
||||
&& !string.IsNullOrWhiteSpace(options.OfflineScriptFileName);
|
||||
}
|
||||
|
||||
private static IEvidenceObjectStore CreateFileSystemStore(
|
||||
FileSystemStoreOptions options,
|
||||
bool enforceWriteOnce,
|
||||
ILogger<FileSystemEvidenceObjectStore> logger)
|
||||
=> new FileSystemEvidenceObjectStore(options, enforceWriteOnce, logger);
|
||||
|
||||
private static IEvidenceObjectStore CreateS3Store(
|
||||
AmazonS3StoreOptions options,
|
||||
bool enforceWriteOnce,
|
||||
ILogger<S3EvidenceObjectStore> logger)
|
||||
{
|
||||
var region = RegionEndpoint.GetBySystemName(options.Region);
|
||||
var client = new AmazonS3Client(region);
|
||||
return new S3EvidenceObjectStore(client, options, enforceWriteOnce, logger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;
|
||||
|
||||
internal sealed class EvidenceLockerMigrationHostedService(
|
||||
IEvidenceLockerMigrationRunner migrationRunner,
|
||||
IOptions<EvidenceLockerOptions> options,
|
||||
ILogger<EvidenceLockerMigrationHostedService> logger) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Value.Database.ApplyMigrationsAtStartup)
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation("Evidence Locker migrations skipped (ApplyMigrationsAtStartup = false).");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await migrationRunner.ApplyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Repositories;
|
||||
|
||||
internal sealed class EvidenceBundleRepository(EvidenceLockerDataSource dataSource) : IEvidenceBundleRepository
|
||||
{
|
||||
private const string InsertBundleSql = """
|
||||
INSERT INTO evidence_locker.evidence_bundles
|
||||
(bundle_id, tenant_id, kind, status, root_hash, storage_key, description, created_at, updated_at)
|
||||
VALUES
|
||||
(@bundle_id, @tenant_id, @kind, @status, @root_hash, @storage_key, @description, @created_at, @updated_at);
|
||||
""";
|
||||
|
||||
private const string UpdateBundleSql = """
|
||||
UPDATE evidence_locker.evidence_bundles
|
||||
SET status = @status,
|
||||
root_hash = @root_hash,
|
||||
updated_at = @updated_at
|
||||
WHERE bundle_id = @bundle_id
|
||||
AND tenant_id = @tenant_id;
|
||||
""";
|
||||
|
||||
private const string MarkBundleSealedSql = """
|
||||
UPDATE evidence_locker.evidence_bundles
|
||||
SET status = @status,
|
||||
sealed_at = @sealed_at,
|
||||
updated_at = @sealed_at
|
||||
WHERE bundle_id = @bundle_id
|
||||
AND tenant_id = @tenant_id;
|
||||
""";
|
||||
|
||||
private const string UpsertSignatureSql = """
|
||||
INSERT INTO evidence_locker.evidence_bundle_signatures
|
||||
(bundle_id, tenant_id, payload_type, payload, signature, key_id, algorithm, provider, signed_at, timestamped_at, timestamp_authority, timestamp_token)
|
||||
VALUES
|
||||
(@bundle_id, @tenant_id, @payload_type, @payload, @signature, @key_id, @algorithm, @provider, @signed_at, @timestamped_at, @timestamp_authority, @timestamp_token)
|
||||
ON CONFLICT (bundle_id, tenant_id)
|
||||
DO UPDATE SET
|
||||
payload_type = EXCLUDED.payload_type,
|
||||
payload = EXCLUDED.payload,
|
||||
signature = EXCLUDED.signature,
|
||||
key_id = EXCLUDED.key_id,
|
||||
algorithm = EXCLUDED.algorithm,
|
||||
provider = EXCLUDED.provider,
|
||||
signed_at = EXCLUDED.signed_at,
|
||||
timestamped_at = EXCLUDED.timestamped_at,
|
||||
timestamp_authority = EXCLUDED.timestamp_authority,
|
||||
timestamp_token = EXCLUDED.timestamp_token;
|
||||
""";
|
||||
|
||||
private const string SelectBundleSql = """
|
||||
SELECT b.bundle_id, b.tenant_id, b.kind, b.status, b.root_hash, b.storage_key, b.description, b.sealed_at, b.created_at, b.updated_at, b.expires_at,
|
||||
b.portable_storage_key, b.portable_generated_at,
|
||||
s.payload_type, s.payload, s.signature, s.key_id, s.algorithm, s.provider, s.signed_at, s.timestamped_at, s.timestamp_authority, s.timestamp_token
|
||||
FROM evidence_locker.evidence_bundles b
|
||||
LEFT JOIN evidence_locker.evidence_bundle_signatures s
|
||||
ON s.bundle_id = b.bundle_id AND s.tenant_id = b.tenant_id
|
||||
WHERE b.bundle_id = @bundle_id AND b.tenant_id = @tenant_id;
|
||||
""";
|
||||
|
||||
private const string ExistsSql = """
|
||||
SELECT 1
|
||||
FROM evidence_locker.evidence_bundles
|
||||
WHERE bundle_id = @bundle_id AND tenant_id = @tenant_id;
|
||||
""";
|
||||
|
||||
private const string InsertHoldSql = """
|
||||
INSERT INTO evidence_locker.evidence_holds
|
||||
(hold_id, tenant_id, bundle_id, case_id, reason, notes, created_at, expires_at)
|
||||
VALUES
|
||||
(@hold_id, @tenant_id, @bundle_id, @case_id, @reason, @notes, @created_at, @expires_at)
|
||||
RETURNING hold_id, tenant_id, bundle_id, case_id, reason, notes, created_at, expires_at, released_at;
|
||||
""";
|
||||
|
||||
private const string ExtendRetentionSql = """
|
||||
UPDATE evidence_locker.evidence_bundles
|
||||
SET expires_at = CASE
|
||||
WHEN @hold_expires_at IS NULL THEN NULL
|
||||
WHEN expires_at IS NULL THEN @hold_expires_at
|
||||
WHEN expires_at < @hold_expires_at THEN @hold_expires_at
|
||||
ELSE expires_at
|
||||
END,
|
||||
updated_at = GREATEST(updated_at, @processed_at)
|
||||
WHERE bundle_id = @bundle_id
|
||||
AND tenant_id = @tenant_id;
|
||||
""";
|
||||
|
||||
private const string UpdateStorageKeySql = """
|
||||
UPDATE evidence_locker.evidence_bundles
|
||||
SET storage_key = @storage_key,
|
||||
updated_at = NOW() AT TIME ZONE 'UTC'
|
||||
WHERE bundle_id = @bundle_id
|
||||
AND tenant_id = @tenant_id;
|
||||
""";
|
||||
|
||||
private const string UpdatePortableStorageKeySql = """
|
||||
UPDATE evidence_locker.evidence_bundles
|
||||
SET portable_storage_key = @storage_key,
|
||||
portable_generated_at = @generated_at,
|
||||
updated_at = GREATEST(updated_at, @generated_at)
|
||||
WHERE bundle_id = @bundle_id
|
||||
AND tenant_id = @tenant_id;
|
||||
""";
|
||||
|
||||
public async Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(bundle.TenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(InsertBundleSql, connection);
|
||||
command.Parameters.AddWithValue("bundle_id", bundle.Id.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", bundle.TenantId.Value);
|
||||
command.Parameters.AddWithValue("kind", (int)bundle.Kind);
|
||||
command.Parameters.AddWithValue("status", (int)bundle.Status);
|
||||
command.Parameters.AddWithValue("root_hash", bundle.RootHash);
|
||||
command.Parameters.AddWithValue("storage_key", bundle.StorageKey);
|
||||
command.Parameters.AddWithValue("description", (object?)bundle.Description ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("created_at", bundle.CreatedAt.UtcDateTime);
|
||||
command.Parameters.AddWithValue("updated_at", bundle.UpdatedAt.UtcDateTime);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task SetBundleAssemblyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleStatus status,
|
||||
string rootHash,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(UpdateBundleSql, connection);
|
||||
command.Parameters.AddWithValue("status", (int)status);
|
||||
command.Parameters.AddWithValue("root_hash", rootHash);
|
||||
command.Parameters.AddWithValue("updated_at", updatedAt.UtcDateTime);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
if (affected == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle record not found for update.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkBundleSealedAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleStatus status,
|
||||
DateTimeOffset sealedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(MarkBundleSealedSql, connection);
|
||||
command.Parameters.AddWithValue("status", (int)status);
|
||||
command.Parameters.AddWithValue("sealed_at", sealedAt.UtcDateTime);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
|
||||
|
||||
var affected = await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
if (affected == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle record not found for sealing.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(signature.TenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(UpsertSignatureSql, connection);
|
||||
command.Parameters.AddWithValue("bundle_id", signature.BundleId.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", signature.TenantId.Value);
|
||||
command.Parameters.AddWithValue("payload_type", signature.PayloadType);
|
||||
command.Parameters.AddWithValue("payload", signature.Payload);
|
||||
command.Parameters.AddWithValue("signature", signature.Signature);
|
||||
command.Parameters.AddWithValue("key_id", (object?)signature.KeyId ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("algorithm", signature.Algorithm);
|
||||
command.Parameters.AddWithValue("provider", signature.Provider);
|
||||
command.Parameters.AddWithValue("signed_at", signature.SignedAt.UtcDateTime);
|
||||
command.Parameters.AddWithValue("timestamped_at", signature.TimestampedAt?.UtcDateTime ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("timestamp_authority", (object?)signature.TimestampAuthority ?? DBNull.Value);
|
||||
var timestampTokenParameter = command.Parameters.Add("timestamp_token", NpgsqlDbType.Bytea);
|
||||
timestampTokenParameter.Value = signature.TimestampToken ?? (object)DBNull.Value;
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(SelectBundleSql, connection);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var createdAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(8), DateTimeKind.Utc));
|
||||
var updatedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(9), DateTimeKind.Utc));
|
||||
|
||||
DateTimeOffset? sealedAt = null;
|
||||
if (!reader.IsDBNull(7))
|
||||
{
|
||||
sealedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
DateTimeOffset? expiresAt = null;
|
||||
if (!reader.IsDBNull(10))
|
||||
{
|
||||
expiresAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(10), DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
var portableStorageKey = reader.IsDBNull(11) ? null : reader.GetString(11);
|
||||
|
||||
DateTimeOffset? portableGeneratedAt = null;
|
||||
if (!reader.IsDBNull(12))
|
||||
{
|
||||
portableGeneratedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(12), DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
EvidenceBundleSignature? signature = null;
|
||||
if (!reader.IsDBNull(13))
|
||||
{
|
||||
var signedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(19), DateTimeKind.Utc));
|
||||
DateTimeOffset? timestampedAt = null;
|
||||
if (!reader.IsDBNull(20))
|
||||
{
|
||||
timestampedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(20), DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
byte[]? timestampToken = null;
|
||||
if (!reader.IsDBNull(22))
|
||||
{
|
||||
timestampToken = (byte[])reader[22];
|
||||
}
|
||||
|
||||
signature = new EvidenceBundleSignature(
|
||||
EvidenceBundleId.FromGuid(reader.GetGuid(0)),
|
||||
TenantId.FromGuid(reader.GetGuid(1)),
|
||||
reader.GetString(13),
|
||||
reader.GetString(14),
|
||||
reader.GetString(15),
|
||||
reader.IsDBNull(16) ? null : reader.GetString(16),
|
||||
reader.GetString(17),
|
||||
reader.GetString(18),
|
||||
signedAt,
|
||||
timestampedAt,
|
||||
reader.IsDBNull(21) ? null : reader.GetString(21),
|
||||
timestampToken);
|
||||
}
|
||||
|
||||
var bundle = new EvidenceBundle(
|
||||
bundleId,
|
||||
tenantId,
|
||||
(EvidenceBundleKind)reader.GetInt16(2),
|
||||
(EvidenceBundleStatus)reader.GetInt16(3),
|
||||
reader.GetString(4),
|
||||
reader.GetString(5),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
sealedAt,
|
||||
expiresAt,
|
||||
portableStorageKey,
|
||||
portableGeneratedAt);
|
||||
|
||||
return new EvidenceBundleDetails(bundle, signature);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(ExistsSql, connection);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(hold.TenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(InsertHoldSql, connection);
|
||||
command.Parameters.AddWithValue("hold_id", hold.Id.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", hold.TenantId.Value);
|
||||
command.Parameters.AddWithValue("bundle_id", hold.BundleId?.Value ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("case_id", hold.CaseId);
|
||||
command.Parameters.AddWithValue("reason", hold.Reason);
|
||||
command.Parameters.AddWithValue("notes", hold.Notes ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("created_at", hold.CreatedAt.UtcDateTime);
|
||||
command.Parameters.AddWithValue("expires_at", hold.ExpiresAt?.UtcDateTime ?? (object)DBNull.Value);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
await reader.ReadAsync(cancellationToken);
|
||||
|
||||
var holdId = EvidenceHoldId.FromGuid(reader.GetGuid(0));
|
||||
var tenantId = TenantId.FromGuid(reader.GetGuid(1));
|
||||
EvidenceBundleId? bundleId = null;
|
||||
if (!reader.IsDBNull(2))
|
||||
{
|
||||
bundleId = EvidenceBundleId.FromGuid(reader.GetGuid(2));
|
||||
}
|
||||
|
||||
var createdAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(6), DateTimeKind.Utc));
|
||||
DateTimeOffset? expiresAt = null;
|
||||
if (!reader.IsDBNull(7))
|
||||
{
|
||||
expiresAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
DateTimeOffset? releasedAt = null;
|
||||
if (!reader.IsDBNull(8))
|
||||
{
|
||||
releasedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(8), DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
return new EvidenceHold(
|
||||
holdId,
|
||||
tenantId,
|
||||
bundleId,
|
||||
reader.GetString(3),
|
||||
reader.GetString(4),
|
||||
createdAt,
|
||||
expiresAt,
|
||||
releasedAt,
|
||||
reader.IsDBNull(5) ? null : reader.GetString(5));
|
||||
}
|
||||
|
||||
public async Task ExtendBundleRetentionAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? holdExpiresAt,
|
||||
DateTimeOffset processedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(ExtendRetentionSql, connection);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
|
||||
command.Parameters.AddWithValue("processed_at", processedAt.UtcDateTime);
|
||||
command.Parameters.AddWithValue("hold_expires_at", holdExpiresAt?.UtcDateTime ?? (object)DBNull.Value);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateStorageKeyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
string storageKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(UpdateStorageKeySql, connection);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
|
||||
command.Parameters.AddWithValue("storage_key", storageKey);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdatePortableStorageKeyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
string storageKey,
|
||||
DateTimeOffset generatedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(UpdatePortableStorageKeySql, connection);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId.Value);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
|
||||
command.Parameters.AddWithValue("storage_key", storageKey);
|
||||
command.Parameters.AddWithValue("generated_at", generatedAt.UtcDateTime);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
public sealed class EvidenceBundlePackagingService
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly IEvidenceBundleRepository _repository;
|
||||
private readonly IEvidenceObjectStore _objectStore;
|
||||
private readonly ILogger<EvidenceBundlePackagingService> _logger;
|
||||
|
||||
public EvidenceBundlePackagingService(
|
||||
IEvidenceBundleRepository repository,
|
||||
IEvidenceObjectStore objectStore,
|
||||
ILogger<EvidenceBundlePackagingService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<EvidenceBundlePackageResult> EnsurePackageAsync(
|
||||
TenantId tenantId,
|
||||
EvidenceBundleId bundleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId.Value == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier cannot be empty.", nameof(tenantId));
|
||||
}
|
||||
|
||||
if (bundleId.Value == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Bundle identifier cannot be empty.", nameof(bundleId));
|
||||
}
|
||||
|
||||
var details = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Evidence bundle '{bundleId.Value:D}' not found for tenant '{tenantId.Value:D}'.");
|
||||
|
||||
if (details.Bundle.Status != EvidenceBundleStatus.Sealed)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle must be sealed before packaging.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(details.Bundle.StorageKey))
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle storage key is not set.");
|
||||
}
|
||||
|
||||
if (await _objectStore.ExistsAsync(details.Bundle.StorageKey, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return new EvidenceBundlePackageResult(details.Bundle.StorageKey, details.Bundle.RootHash, Created: false);
|
||||
}
|
||||
|
||||
if (details.Signature is null)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle signature is required for packaging.");
|
||||
}
|
||||
|
||||
var manifestDocument = DecodeManifest(details.Signature);
|
||||
var packageStream = BuildPackageStream(details, manifestDocument);
|
||||
|
||||
var metadata = await _objectStore.StoreAsync(
|
||||
packageStream,
|
||||
new EvidenceObjectWriteOptions(
|
||||
tenantId,
|
||||
bundleId,
|
||||
"bundle.tgz",
|
||||
"application/gzip",
|
||||
EnforceWriteOnce: true),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!string.Equals(metadata.StorageKey, details.Bundle.StorageKey, StringComparison.Ordinal))
|
||||
{
|
||||
await _repository.UpdateStorageKeyAsync(bundleId, tenantId, metadata.StorageKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Packaged evidence bundle {BundleId} for tenant {TenantId} at storage key {StorageKey}.",
|
||||
bundleId.Value,
|
||||
tenantId.Value,
|
||||
metadata.StorageKey);
|
||||
|
||||
return new EvidenceBundlePackageResult(metadata.StorageKey, details.Bundle.RootHash, Created: true);
|
||||
}
|
||||
|
||||
private ManifestDocument DecodeManifest(EvidenceBundleSignature signature)
|
||||
{
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(signature.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Evidence bundle manifest payload for bundle {BundleId} (tenant {TenantId}) is not valid base64.",
|
||||
signature.BundleId.Value,
|
||||
signature.TenantId.Value);
|
||||
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var document = JsonSerializer.Deserialize<ManifestDocument>(payload, SerializerOptions)
|
||||
?? throw new InvalidOperationException();
|
||||
return document;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Evidence bundle manifest payload for bundle {BundleId} (tenant {TenantId}) could not be parsed.",
|
||||
signature.BundleId.Value,
|
||||
signature.TenantId.Value);
|
||||
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Stream BuildPackageStream(EvidenceBundleDetails details, ManifestDocument manifest)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
WriteTextEntry(tarWriter, "manifest.json", GetManifestJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, "signature.json", GetSignatureJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, "bundle.json", GetBundleMetadataJson(details));
|
||||
WriteTextEntry(tarWriter, "checksums.txt", BuildChecksums(manifest, details.Bundle.RootHash));
|
||||
WriteTextEntry(tarWriter, "instructions.txt", BuildInstructions(details, manifest));
|
||||
}
|
||||
|
||||
ApplyDeterministicGZipHeader(stream);
|
||||
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(TarWriter writer, string path, string content)
|
||||
{
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
|
||||
ModificationTime = FixedTimestamp
|
||||
};
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entry.DataStream = new MemoryStream(bytes);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static string GetManifestJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(Convert.FromBase64String(signature.Payload));
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(document.RootElement, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string GetSignatureJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var model = new SignatureDocument(
|
||||
signature.PayloadType,
|
||||
signature.Payload,
|
||||
signature.Signature,
|
||||
signature.KeyId,
|
||||
signature.Algorithm,
|
||||
signature.Provider,
|
||||
signature.SignedAt,
|
||||
signature.TimestampedAt,
|
||||
signature.TimestampAuthority,
|
||||
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
|
||||
|
||||
return JsonSerializer.Serialize(model, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string GetBundleMetadataJson(EvidenceBundleDetails details)
|
||||
{
|
||||
var document = new BundleMetadataDocument(
|
||||
details.Bundle.Id.Value,
|
||||
details.Bundle.TenantId.Value,
|
||||
details.Bundle.Kind,
|
||||
details.Bundle.Status,
|
||||
details.Bundle.RootHash,
|
||||
details.Bundle.StorageKey,
|
||||
details.Bundle.CreatedAt,
|
||||
details.Bundle.SealedAt);
|
||||
|
||||
return JsonSerializer.Serialize(document, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string BuildChecksums(ManifestDocument manifest, string rootHash)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Evidence bundle checksums (sha256)");
|
||||
builder.Append("root ").AppendLine(rootHash);
|
||||
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
foreach (var entry in entries.OrderBy(e => e.CanonicalPath, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(entry.Sha256)
|
||||
.Append(" ")
|
||||
.AppendLine(entry.CanonicalPath);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildInstructions(EvidenceBundleDetails details, ManifestDocument manifest)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("Evidence Bundle Instructions");
|
||||
builder.AppendLine("============================");
|
||||
builder.Append("Bundle ID: ").AppendLine(details.Bundle.Id.Value.ToString("D"));
|
||||
builder.Append("Root Hash: ").AppendLine(details.Bundle.RootHash);
|
||||
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O"));
|
||||
if (details.Signature?.TimestampedAt is { } timestampedAt)
|
||||
{
|
||||
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O"));
|
||||
}
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Verification steps:");
|
||||
builder.AppendLine("1. Inspect `manifest.json` and ensure the bundle contents match expectations.");
|
||||
builder.AppendLine("2. Compute the Merkle root using the manifest entries and compare with the Root Hash above.");
|
||||
builder.AppendLine("3. Validate `signature.json` using the StellaOps provenance verifier (`stella evidence verify <bundle.tgz>`).");
|
||||
if (details.Signature?.TimestampToken is not null)
|
||||
{
|
||||
builder.AppendLine("4. Validate the RFC3161 timestamp token with your configured TSA before trusting the bundle.");
|
||||
builder.AppendLine("5. Review `checksums.txt` when transferring the bundle between systems.");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AppendLine("4. Review `checksums.txt` when transferring the bundle between systems.");
|
||||
}
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("For offline verification guidance, consult docs/forensics/evidence-locker.md (portable evidence section).");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGZipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for evidence bundle package.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
|
||||
private sealed record ManifestDocument(
|
||||
Guid BundleId,
|
||||
Guid TenantId,
|
||||
int Kind,
|
||||
DateTimeOffset CreatedAt,
|
||||
IDictionary<string, string>? Metadata,
|
||||
ManifestEntryDocument[]? Entries);
|
||||
|
||||
private sealed record ManifestEntryDocument(
|
||||
string Section,
|
||||
string CanonicalPath,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string? MediaType,
|
||||
IDictionary<string, string>? Attributes);
|
||||
|
||||
private sealed record SignatureDocument(
|
||||
string PayloadType,
|
||||
string Payload,
|
||||
string Signature,
|
||||
string? KeyId,
|
||||
string Algorithm,
|
||||
string Provider,
|
||||
DateTimeOffset SignedAt,
|
||||
DateTimeOffset? TimestampedAt,
|
||||
string? TimestampAuthority,
|
||||
string? TimestampToken);
|
||||
|
||||
private sealed record BundleMetadataDocument(
|
||||
Guid BundleId,
|
||||
Guid TenantId,
|
||||
EvidenceBundleKind Kind,
|
||||
EvidenceBundleStatus Status,
|
||||
string RootHash,
|
||||
string StorageKey,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? SealedAt);
|
||||
}
|
||||
|
||||
public sealed record EvidenceBundlePackageResult(string StorageKey, string RootHash, bool Created);
|
||||
@@ -0,0 +1,414 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
public sealed class EvidencePortableBundleService
|
||||
{
|
||||
private const string PortableManifestFileName = "manifest.json";
|
||||
private const string PortableSignatureFileName = "signature.json";
|
||||
private const string PortableChecksumsFileName = "checksums.txt";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly UnixFileMode DefaultFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
private static readonly UnixFileMode ExecutableFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
|
||||
|
||||
private readonly IEvidenceBundleRepository _repository;
|
||||
private readonly IEvidenceObjectStore _objectStore;
|
||||
private readonly ILogger<EvidencePortableBundleService> _logger;
|
||||
private readonly PortableOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EvidencePortableBundleService(
|
||||
IEvidenceBundleRepository repository,
|
||||
IEvidenceObjectStore objectStore,
|
||||
IOptions<EvidenceLockerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EvidencePortableBundleService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value.Portable ?? new PortableOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<EvidenceBundlePackageResult> EnsurePortablePackageAsync(
|
||||
TenantId tenantId,
|
||||
EvidenceBundleId bundleId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId.Value == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier cannot be empty.", nameof(tenantId));
|
||||
}
|
||||
|
||||
if (bundleId.Value == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Bundle identifier cannot be empty.", nameof(bundleId));
|
||||
}
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Portable bundle packaging is disabled for this deployment.");
|
||||
}
|
||||
|
||||
var details = await _repository
|
||||
.GetBundleAsync(bundleId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException($"Evidence bundle '{bundleId.Value:D}' not found for tenant '{tenantId.Value:D}'.");
|
||||
|
||||
if (details.Bundle.Status != EvidenceBundleStatus.Sealed)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle must be sealed before creating a portable package.");
|
||||
}
|
||||
|
||||
if (details.Signature is null)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle signature is required for portable packaging.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(details.Bundle.PortableStorageKey)
|
||||
&& await _objectStore.ExistsAsync(details.Bundle.PortableStorageKey, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return new EvidenceBundlePackageResult(details.Bundle.PortableStorageKey!, details.Bundle.RootHash, Created: false);
|
||||
}
|
||||
|
||||
var manifestDocument = DecodeManifest(details.Signature);
|
||||
var generatedAt = _timeProvider.GetUtcNow();
|
||||
var packageStream = BuildPackageStream(details, manifestDocument, generatedAt);
|
||||
|
||||
var metadata = await _objectStore
|
||||
.StoreAsync(
|
||||
packageStream,
|
||||
new EvidenceObjectWriteOptions(
|
||||
tenantId,
|
||||
bundleId,
|
||||
_options.ArtifactName,
|
||||
"application/gzip",
|
||||
EnforceWriteOnce: true),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await _repository
|
||||
.UpdatePortableStorageKeyAsync(bundleId, tenantId, metadata.StorageKey, generatedAt, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Portable evidence bundle {BundleId} for tenant {TenantId} stored at {StorageKey}.",
|
||||
bundleId.Value,
|
||||
tenantId.Value,
|
||||
metadata.StorageKey);
|
||||
|
||||
return new EvidenceBundlePackageResult(metadata.StorageKey, details.Bundle.RootHash, Created: true);
|
||||
}
|
||||
|
||||
private static Stream BuildPackageStream(
|
||||
EvidenceBundleDetails details,
|
||||
ManifestDocument manifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
WriteTextEntry(tarWriter, PortableManifestFileName, GetManifestJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, PortableSignatureFileName, GetSignatureJson(details.Signature!));
|
||||
WriteTextEntry(tarWriter, PortableChecksumsFileName, BuildChecksums(manifest, details.Bundle.RootHash));
|
||||
|
||||
var metadataDocument = BuildPortableMetadata(details, manifest, generatedAt);
|
||||
WriteTextEntry(
|
||||
tarWriter,
|
||||
_options.MetadataFileName,
|
||||
JsonSerializer.Serialize(metadataDocument, SerializerOptions));
|
||||
|
||||
WriteTextEntry(
|
||||
tarWriter,
|
||||
_options.InstructionsFileName,
|
||||
BuildInstructions(details, manifest, generatedAt));
|
||||
|
||||
WriteTextEntry(
|
||||
tarWriter,
|
||||
_options.OfflineScriptFileName,
|
||||
BuildOfflineScript(_options.ArtifactName, _options.MetadataFileName),
|
||||
ExecutableFileMode);
|
||||
}
|
||||
|
||||
ApplyDeterministicGZipHeader(stream);
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static ManifestDocument DecodeManifest(EvidenceBundleSignature signature)
|
||||
{
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(signature.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ManifestDocument>(payload, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Evidence bundle manifest payload is empty.");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
throw new InvalidOperationException("Evidence bundle manifest payload is invalid.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static PortableBundleMetadataDocument BuildPortableMetadata(
|
||||
EvidenceBundleDetails details,
|
||||
ManifestDocument manifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
var entryCount = entries.Length;
|
||||
var totalSize = entries.Sum(e => e.SizeBytes);
|
||||
|
||||
IReadOnlyDictionary<string, string>? incidentMetadata = null;
|
||||
if (manifest.Metadata is { Count: > 0 })
|
||||
{
|
||||
var incidentPairs = manifest.Metadata
|
||||
.Where(kvp => kvp.Key.StartsWith("incident.", StringComparison.Ordinal))
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
|
||||
|
||||
if (incidentPairs.Count > 0)
|
||||
{
|
||||
incidentMetadata = new ReadOnlyDictionary<string, string>(incidentPairs);
|
||||
}
|
||||
}
|
||||
|
||||
return new PortableBundleMetadataDocument(
|
||||
details.Bundle.Id.Value,
|
||||
details.Bundle.Kind,
|
||||
details.Bundle.RootHash,
|
||||
manifest.CreatedAt,
|
||||
details.Bundle.SealedAt,
|
||||
details.Bundle.ExpiresAt,
|
||||
details.Signature?.TimestampedAt is not null,
|
||||
details.Signature?.TimestampedAt,
|
||||
generatedAt,
|
||||
entryCount,
|
||||
totalSize,
|
||||
incidentMetadata);
|
||||
}
|
||||
|
||||
private static string BuildInstructions(
|
||||
EvidenceBundleDetails details,
|
||||
ManifestDocument manifest,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("Portable Evidence Bundle Instructions");
|
||||
builder.AppendLine("===================================");
|
||||
builder.Append("Bundle ID: ").AppendLine(details.Bundle.Id.Value.ToString("D"));
|
||||
builder.Append("Root Hash: ").AppendLine(details.Bundle.RootHash);
|
||||
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O"));
|
||||
if (details.Bundle.SealedAt is { } sealedAt)
|
||||
{
|
||||
builder.Append("Sealed At: ").AppendLine(sealedAt.ToString("O"));
|
||||
}
|
||||
|
||||
if (details.Signature?.TimestampedAt is { } timestampedAt)
|
||||
{
|
||||
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O"));
|
||||
}
|
||||
|
||||
builder.Append("Portable Generated At: ").AppendLine(generatedAt.ToString("O"));
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Verification steps:");
|
||||
builder.Append("1. Copy '").Append(_options.ArtifactName).AppendLine("' into the sealed environment.");
|
||||
builder.Append("2. Execute './").Append(_options.OfflineScriptFileName).Append(' ');
|
||||
builder.Append(_options.ArtifactName).AppendLine("' to extract contents and verify checksums.");
|
||||
builder.AppendLine("3. Review 'bundle.json' for sanitized metadata and incident context.");
|
||||
builder.AppendLine("4. Run 'stella evidence verify --bundle <path>' or use an offline verifier with 'manifest.json' + 'signature.json'.");
|
||||
builder.AppendLine("5. Store the bundle and verification output with the receiving enclave's evidence locker.");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Notes:");
|
||||
builder.AppendLine("- Metadata is redacted to remove tenant identifiers, storage coordinates, and free-form descriptions.");
|
||||
builder.AppendLine("- Incident metadata (if present) is exposed under 'incidentMetadata'.");
|
||||
builder.AppendLine("- Checksums cover every canonical entry and the Merkle root hash for tamper detection.");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildOfflineScript(string defaultArchiveName, string metadataFileName)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("#!/usr/bin/env sh");
|
||||
builder.AppendLine("set -euo pipefail");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"ARCHIVE=\"${{1:-{defaultArchiveName}}}\"");
|
||||
builder.AppendLine("if [ ! -f \"$ARCHIVE\" ]; then");
|
||||
builder.AppendLine(" echo \"Usage: $0 <portable-bundle.tgz>\" >&2");
|
||||
builder.AppendLine(" exit 1");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("WORKDIR=\"$(mktemp -d)\"");
|
||||
builder.AppendLine("cleanup() { rm -rf \"$WORKDIR\"; }");
|
||||
builder.AppendLine("trap cleanup EXIT INT TERM");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("tar -xzf \"$ARCHIVE\" -C \"$WORKDIR\"");
|
||||
builder.AppendLine("echo \"Portable evidence extracted to $WORKDIR\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" (cd \"$WORKDIR\" && sha256sum --check checksums.txt)");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" (cd \"$WORKDIR\" && shasum -a 256 --check checksums.txt)");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("ROOT_HASH=$(sed -n 's/.*\"rootHash\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$WORKDIR\"/" + metadataFileName + " | head -n 1)");
|
||||
builder.AppendLine("echo \"Root hash: ${ROOT_HASH:-unknown}\"");
|
||||
builder.AppendLine("echo \"Verify DSSE signature with: stella evidence verify --bundle $ARCHIVE\"");
|
||||
builder.AppendLine("echo \"or provide manifest.json and signature.json to an offline verifier.\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"Leaving extracted contents in $WORKDIR for manual inspection.\"");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string GetManifestJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(Convert.FromBase64String(signature.Payload));
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(document.RootElement, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string GetSignatureJson(EvidenceBundleSignature signature)
|
||||
{
|
||||
var model = new SignatureDocument(
|
||||
signature.PayloadType,
|
||||
signature.Payload,
|
||||
signature.Signature,
|
||||
signature.KeyId,
|
||||
signature.Algorithm,
|
||||
signature.Provider,
|
||||
signature.SignedAt,
|
||||
signature.TimestampedAt,
|
||||
signature.TimestampAuthority,
|
||||
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
|
||||
|
||||
return JsonSerializer.Serialize(model, SerializerOptions);
|
||||
}
|
||||
|
||||
private static string BuildChecksums(ManifestDocument manifest, string rootHash)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Evidence bundle checksums (sha256)");
|
||||
builder.Append("root ").AppendLine(rootHash);
|
||||
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
foreach (var entry in entries.OrderBy(e => e.CanonicalPath, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(entry.Sha256)
|
||||
.Append(" ")
|
||||
.AppendLine(entry.CanonicalPath);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(
|
||||
TarWriter writer,
|
||||
string path,
|
||||
string content,
|
||||
UnixFileMode mode = default)
|
||||
{
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = mode == default ? DefaultFileMode : mode,
|
||||
ModificationTime = FixedTimestamp
|
||||
};
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entry.DataStream = new MemoryStream(bytes);
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGZipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for portable evidence package.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
|
||||
private sealed record ManifestDocument(
|
||||
Guid BundleId,
|
||||
Guid TenantId,
|
||||
int Kind,
|
||||
DateTimeOffset CreatedAt,
|
||||
IDictionary<string, string>? Metadata,
|
||||
ManifestEntryDocument[]? Entries);
|
||||
|
||||
private sealed record ManifestEntryDocument(
|
||||
string Section,
|
||||
string CanonicalPath,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string? MediaType,
|
||||
IDictionary<string, string>? Attributes);
|
||||
|
||||
private sealed record SignatureDocument(
|
||||
string PayloadType,
|
||||
string Payload,
|
||||
string Signature,
|
||||
string? KeyId,
|
||||
string Algorithm,
|
||||
string Provider,
|
||||
DateTimeOffset SignedAt,
|
||||
DateTimeOffset? TimestampedAt,
|
||||
string? TimestampAuthority,
|
||||
string? TimestampToken);
|
||||
|
||||
private sealed record PortableBundleMetadataDocument(
|
||||
Guid BundleId,
|
||||
EvidenceBundleKind Kind,
|
||||
string RootHash,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? SealedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
bool Timestamped,
|
||||
DateTimeOffset? TimestampedAt,
|
||||
DateTimeOffset PortableGeneratedAt,
|
||||
int EntryCount,
|
||||
long TotalSizeBytes,
|
||||
IReadOnlyDictionary<string, string>? IncidentMetadata);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
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;
|
||||
|
||||
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 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)
|
||||
{
|
||||
_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));
|
||||
}
|
||||
|
||||
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(Guid.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(Guid.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Incident;
|
||||
using StellaOps.EvidenceLocker.Core.Notifications;
|
||||
using StellaOps.EvidenceLocker.Core.Timeline;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
internal sealed class IncidentModeManager : IIncidentModeState, IDisposable
|
||||
{
|
||||
private readonly IEvidenceTimelinePublisher _timelinePublisher;
|
||||
private readonly IEvidenceIncidentNotifier _incidentNotifier;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<IncidentModeManager> _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly IDisposable _subscription = null!;
|
||||
|
||||
private IncidentModeSnapshot _current;
|
||||
|
||||
public IncidentModeManager(
|
||||
IOptionsMonitor<EvidenceLockerOptions> optionsMonitor,
|
||||
IEvidenceTimelinePublisher timelinePublisher,
|
||||
IEvidenceIncidentNotifier incidentNotifier,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<IncidentModeManager> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(optionsMonitor);
|
||||
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
|
||||
_incidentNotifier = incidentNotifier ?? throw new ArgumentNullException(nameof(incidentNotifier));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_current = CreateSnapshot(optionsMonitor.CurrentValue?.Incident, _timeProvider.GetUtcNow());
|
||||
if (_current.IsActive)
|
||||
{
|
||||
var initialChange = new IncidentModeChange(_current.IsActive, _current.ChangedAt, _current.RetentionExtensionDays);
|
||||
_ = Task.Run(() => PublishChangeAsync(initialChange, _cts.Token), _cts.Token);
|
||||
}
|
||||
_subscription = optionsMonitor.OnChange((options, _) => HandleOptionsChanged(options?.Incident))!;
|
||||
}
|
||||
|
||||
public IncidentModeSnapshot Current => _current;
|
||||
|
||||
public bool IsActive => _current.IsActive;
|
||||
|
||||
private void HandleOptionsChanged(IncidentModeOptions? options)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var next = CreateSnapshot(options, now);
|
||||
|
||||
var previous = Interlocked.Exchange(ref _current, next);
|
||||
|
||||
if (previous.IsActive == next.IsActive &&
|
||||
previous.RetentionExtensionDays == next.RetentionExtensionDays &&
|
||||
previous.CaptureRequestSnapshot == next.CaptureRequestSnapshot)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (previous.IsActive != next.IsActive)
|
||||
{
|
||||
var change = new IncidentModeChange(next.IsActive, next.ChangedAt, next.RetentionExtensionDays);
|
||||
_logger.LogInformation(
|
||||
"Incident mode changed to {State} at {ChangedAt} (retention extension: {RetentionDays} days).",
|
||||
next.IsActive ? "enabled" : "disabled",
|
||||
next.ChangedAt,
|
||||
next.RetentionExtensionDays);
|
||||
|
||||
_ = Task.Run(() => PublishChangeAsync(change, _cts.Token), _cts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Incident mode configuration updated (retention extension: {RetentionDays} days, capture request snapshot: {CaptureRequestSnapshot}).",
|
||||
next.RetentionExtensionDays,
|
||||
next.CaptureRequestSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PublishChangeAsync(IncidentModeChange change, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _timelinePublisher.PublishIncidentModeChangedAsync(change, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to publish incident mode change to timeline: {Message}", ex.Message);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _incidentNotifier.PublishIncidentModeChangedAsync(change, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to publish incident mode change notification: {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private IncidentModeSnapshot CreateSnapshot(IncidentModeOptions? options, DateTimeOffset timestamp)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
return new IncidentModeSnapshot(false, timestamp, 0, false);
|
||||
}
|
||||
|
||||
var retentionExtensionDays = Math.Max(0, options.RetentionExtensionDays);
|
||||
return new IncidentModeSnapshot(
|
||||
options.Enabled,
|
||||
timestamp,
|
||||
retentionExtensionDays,
|
||||
options.CaptureRequestSnapshot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_cts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed by another path.
|
||||
}
|
||||
|
||||
_subscription.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Signing;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;
|
||||
|
||||
public sealed class EvidenceSignatureService : IEvidenceSignatureService
|
||||
{
|
||||
private readonly ICryptoProviderRegistry _cryptoRegistry;
|
||||
private readonly ITimestampAuthorityClient _timestampAuthorityClient;
|
||||
private readonly ILogger<EvidenceSignatureService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SigningOptions _options;
|
||||
private readonly TimestampingOptions? _timestampingOptions;
|
||||
private bool _signerPrepared;
|
||||
private readonly SemaphoreSlim _initializationGate = new(1, 1);
|
||||
|
||||
public EvidenceSignatureService(
|
||||
ICryptoProviderRegistry cryptoRegistry,
|
||||
ITimestampAuthorityClient timestampAuthorityClient,
|
||||
IOptions<EvidenceLockerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EvidenceSignatureService> logger)
|
||||
{
|
||||
_cryptoRegistry = cryptoRegistry ?? throw new ArgumentNullException(nameof(cryptoRegistry));
|
||||
_timestampAuthorityClient = timestampAuthorityClient ?? throw new ArgumentNullException(nameof(timestampAuthorityClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value.Signing ?? throw new InvalidOperationException("Signing options are required.");
|
||||
_timestampingOptions = _options.Timestamping;
|
||||
|
||||
if (_timestampingOptions?.Enabled is true && string.IsNullOrWhiteSpace(_timestampingOptions.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("Evidence Locker timestamping endpoint must be configured when timestamping is enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<EvidenceBundleSignature?> SignManifestAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleManifest manifest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
await EnsureSigningKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payloadBytes = SerializeManifest(manifest);
|
||||
var signerResolution = _cryptoRegistry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
_options.Algorithm,
|
||||
new CryptoKeyReference(_options.KeyId, _options.Provider),
|
||||
_options.Provider);
|
||||
|
||||
var signatureBytes = await signerResolution.Signer.SignAsync(payloadBytes, cancellationToken).ConfigureAwait(false);
|
||||
var signedAt = _timeProvider.GetUtcNow();
|
||||
TimestampResult? timestampResult = null;
|
||||
|
||||
if (_timestampingOptions?.Enabled is true)
|
||||
{
|
||||
try
|
||||
{
|
||||
timestampResult = await _timestampAuthorityClient.RequestTimestampAsync(
|
||||
signatureBytes,
|
||||
_timestampingOptions.HashAlgorithm,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_timestampingOptions.RequireTimestamp)
|
||||
{
|
||||
throw new InvalidOperationException("Timestamp authority request failed.", ex);
|
||||
}
|
||||
|
||||
_logger.LogWarning(ex, "Failed to obtain timestamp for evidence bundle {BundleId}.", bundleId);
|
||||
}
|
||||
}
|
||||
|
||||
return new EvidenceBundleSignature(
|
||||
bundleId,
|
||||
tenantId,
|
||||
_options.PayloadType,
|
||||
Convert.ToBase64String(payloadBytes),
|
||||
Convert.ToBase64String(signatureBytes),
|
||||
signerResolution.Signer.KeyId,
|
||||
signerResolution.Signer.AlgorithmId,
|
||||
signerResolution.ProviderName,
|
||||
signedAt,
|
||||
timestampResult?.Timestamp,
|
||||
timestampResult?.Authority,
|
||||
timestampResult?.Token);
|
||||
}
|
||||
|
||||
private async Task EnsureSigningKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_signerPrepared)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _initializationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_signerPrepared)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = _cryptoRegistry.ResolveSigner(
|
||||
CryptoCapability.Signing,
|
||||
_options.Algorithm,
|
||||
new CryptoKeyReference(_options.KeyId, _options.Provider),
|
||||
_options.Provider);
|
||||
_signerPrepared = true;
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
ex,
|
||||
"Provisioning signing key {KeyId} for provider {Provider} using configured material.",
|
||||
_options.KeyId,
|
||||
_options.Provider ?? "default");
|
||||
|
||||
var provider = ResolveProvider();
|
||||
var signingKey = LoadSigningKeyMaterial();
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
_signerPrepared = true;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initializationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private ICryptoProvider ResolveProvider()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.Provider) &&
|
||||
_cryptoRegistry.TryResolve(_options.Provider, out var hinted))
|
||||
{
|
||||
return hinted;
|
||||
}
|
||||
|
||||
return _cryptoRegistry.ResolveOrThrow(CryptoCapability.Signing, _options.Algorithm);
|
||||
}
|
||||
|
||||
private CryptoSigningKey LoadSigningKeyMaterial()
|
||||
{
|
||||
if (_options.KeyMaterial?.EcPrivateKeyPem is { Length: > 0 })
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportFromPem(_options.KeyMaterial.EcPrivateKeyPem);
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
return new CryptoSigningKey(
|
||||
new CryptoKeyReference(_options.KeyId, _options.Provider),
|
||||
_options.Algorithm,
|
||||
parameters,
|
||||
_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Evidence Locker signing key material not configured; generating transient key for provider {Provider}.",
|
||||
_options.Provider ?? "default");
|
||||
|
||||
using var fallback = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var ephemeralParams = fallback.ExportParameters(includePrivateParameters: true);
|
||||
return new CryptoSigningKey(
|
||||
new CryptoKeyReference(_options.KeyId, _options.Provider),
|
||||
_options.Algorithm,
|
||||
ephemeralParams,
|
||||
_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private byte[] SerializeManifest(EvidenceBundleManifest manifest)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false });
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("bundleId", manifest.BundleId.Value.ToString("D"));
|
||||
writer.WriteString("tenantId", manifest.TenantId.Value.ToString("D"));
|
||||
writer.WriteNumber("kind", (int)manifest.Kind);
|
||||
writer.WriteString("createdAt", manifest.CreatedAt.UtcDateTime.ToString("O"));
|
||||
|
||||
writer.WriteStartObject("metadata");
|
||||
foreach (var kvp in manifest.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteString(kvp.Key, kvp.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
|
||||
writer.WriteStartArray("entries");
|
||||
foreach (var entry in manifest.Entries)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("section", entry.Section);
|
||||
writer.WriteString("canonicalPath", entry.CanonicalPath);
|
||||
writer.WriteString("sha256", entry.Sha256);
|
||||
writer.WriteNumber("sizeBytes", entry.SizeBytes);
|
||||
if (!string.IsNullOrWhiteSpace(entry.MediaType))
|
||||
{
|
||||
writer.WriteString("mediaType", entry.MediaType);
|
||||
}
|
||||
|
||||
writer.WriteStartObject("attributes");
|
||||
foreach (var attribute in entry.Attributes.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteString(attribute.Key, attribute.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Core.Signing;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;
|
||||
|
||||
public sealed class NullTimestampAuthorityClient : ITimestampAuthorityClient
|
||||
{
|
||||
private readonly ILogger<NullTimestampAuthorityClient> _logger;
|
||||
|
||||
public NullTimestampAuthorityClient(ILogger<NullTimestampAuthorityClient> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<TimestampResult?> RequestTimestampAsync(
|
||||
ReadOnlyMemory<byte> signature,
|
||||
string hashAlgorithm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Timestamp authority disabled; skipping timestamp generation.");
|
||||
return Task.FromResult<TimestampResult?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Org.BouncyCastle.Asn1;
|
||||
using Org.BouncyCastle.Asn1.Nist;
|
||||
using Org.BouncyCastle.Asn1.Oiw;
|
||||
using Org.BouncyCastle.Math;
|
||||
using Org.BouncyCastle.Tsp;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Signing;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Signing;
|
||||
|
||||
public sealed class Rfc3161TimestampAuthorityClient : ITimestampAuthorityClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<EvidenceLockerOptions> _options;
|
||||
private readonly ILogger<Rfc3161TimestampAuthorityClient> _logger;
|
||||
|
||||
public Rfc3161TimestampAuthorityClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<EvidenceLockerOptions> options,
|
||||
ILogger<Rfc3161TimestampAuthorityClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TimestampResult?> RequestTimestampAsync(
|
||||
ReadOnlyMemory<byte> signature,
|
||||
string hashAlgorithm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timestamping = _options.Value.Signing?.Timestamping;
|
||||
if (timestamping is null || !timestamping.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(timestamping.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("Timestamping endpoint must be configured when enabled.");
|
||||
}
|
||||
|
||||
var digest = ComputeDigest(signature.Span, hashAlgorithm);
|
||||
var hashOid = ResolveHashAlgorithmOid(hashAlgorithm);
|
||||
|
||||
var requestGenerator = new TimeStampRequestGenerator();
|
||||
requestGenerator.SetCertReq(true);
|
||||
|
||||
Span<byte> nonceBuffer = stackalloc byte[16];
|
||||
RandomNumberGenerator.Fill(nonceBuffer);
|
||||
var nonce = new BigInteger(1, nonceBuffer);
|
||||
|
||||
var timeStampRequest = requestGenerator.Generate(new DerObjectIdentifier(hashOid), digest, nonce);
|
||||
var requestBytes = timeStampRequest.GetEncoded();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, timestamping.Endpoint);
|
||||
httpRequest.Content = new ByteArrayContent(requestBytes);
|
||||
httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/timestamp-query");
|
||||
httpRequest.Headers.Accept.Clear();
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/timestamp-reply"));
|
||||
httpRequest.Headers.UserAgent.ParseAdd("StellaOpsEvidenceLocker/1.0");
|
||||
|
||||
if (timestamping.RequestTimeoutSeconds > 0)
|
||||
{
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(timestamping.RequestTimeoutSeconds);
|
||||
}
|
||||
|
||||
if (timestamping.Authentication is { Username: { Length: > 0 } } auth)
|
||||
{
|
||||
var credentials = $"{auth.Username}:{auth.Password ?? string.Empty}";
|
||||
var credentialBytes = Encoding.UTF8.GetBytes(credentials);
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(credentialBytes));
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (timestamping.RequireTimestamp)
|
||||
{
|
||||
throw new InvalidOperationException($"Timestamp authority responded with status code {(int)response.StatusCode} ({response.StatusCode}).");
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Warning))
|
||||
{
|
||||
_logger.LogWarning("Timestamp authority request failed with status {StatusCode}.", response.StatusCode);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var responseBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TimeStampResponse tspResponse;
|
||||
try
|
||||
{
|
||||
tspResponse = new TimeStampResponse(responseBytes);
|
||||
tspResponse.Validate(timeStampRequest);
|
||||
}
|
||||
catch (Exception ex) when (!timestamping.RequireTimestamp)
|
||||
{
|
||||
_logger.LogWarning(ex, "Timestamp authority returned an invalid response.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tspResponse.Status is not 0 and not 1)
|
||||
{
|
||||
if (timestamping.RequireTimestamp)
|
||||
{
|
||||
throw new InvalidOperationException($"Timestamp authority declined request with status code {tspResponse.Status}.");
|
||||
}
|
||||
|
||||
_logger.LogWarning("Timestamp authority declined request with status code {Status}.", tspResponse.Status);
|
||||
return null;
|
||||
}
|
||||
|
||||
var token = tspResponse.TimeStampToken;
|
||||
if (token is null)
|
||||
{
|
||||
if (timestamping.RequireTimestamp)
|
||||
{
|
||||
throw new InvalidOperationException("Timestamp authority response did not include a token.");
|
||||
}
|
||||
|
||||
_logger.LogWarning("Timestamp authority response missing token for bundle timestamp request.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var info = token.TimeStampInfo;
|
||||
var authority = info.Tsa?.Name?.ToString() ?? timestamping.Endpoint;
|
||||
var tokenBytes = token.GetEncoded();
|
||||
|
||||
return new TimestampResult(info.GenTime, authority, tokenBytes);
|
||||
}
|
||||
|
||||
private static byte[] ComputeDigest(ReadOnlySpan<byte> data, string algorithm)
|
||||
{
|
||||
var hashAlgorithm = GetHashAlgorithmName(algorithm);
|
||||
using var hasher = IncrementalHash.CreateHash(hashAlgorithm);
|
||||
hasher.AppendData(data);
|
||||
return hasher.GetHashAndReset();
|
||||
}
|
||||
|
||||
private static HashAlgorithmName GetHashAlgorithmName(string algorithm)
|
||||
=> (algorithm ?? string.Empty).ToUpperInvariant() switch
|
||||
{
|
||||
"SHA256" => HashAlgorithmName.SHA256,
|
||||
"SHA384" => HashAlgorithmName.SHA384,
|
||||
"SHA512" => HashAlgorithmName.SHA512,
|
||||
"SHA1" => HashAlgorithmName.SHA1,
|
||||
_ => throw new InvalidOperationException($"Unsupported timestamp hash algorithm '{algorithm}'.")
|
||||
};
|
||||
|
||||
private static string ResolveHashAlgorithmOid(string algorithm)
|
||||
=> (algorithm ?? string.Empty).ToUpperInvariant() switch
|
||||
{
|
||||
"SHA256" => NistObjectIdentifiers.IdSha256.Id,
|
||||
"SHA384" => NistObjectIdentifiers.IdSha384.Id,
|
||||
"SHA512" => NistObjectIdentifiers.IdSha512.Id,
|
||||
"SHA1" => OiwObjectIdentifiers.IdSha1.Id,
|
||||
_ => throw new InvalidOperationException($"Unsupported timestamp hash algorithm '{algorithm}'.")
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,33 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
<?xml version="1.0"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.303.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Npgsql" Version="8.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Db\Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Storage;
|
||||
|
||||
internal sealed class FileSystemEvidenceObjectStore : IEvidenceObjectStore
|
||||
{
|
||||
private readonly string _rootPath;
|
||||
private readonly bool _enforceWriteOnce;
|
||||
private readonly ILogger<FileSystemEvidenceObjectStore> _logger;
|
||||
|
||||
public FileSystemEvidenceObjectStore(
|
||||
FileSystemStoreOptions options,
|
||||
bool enforceWriteOnce,
|
||||
ILogger<FileSystemEvidenceObjectStore> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.RootPath);
|
||||
|
||||
_rootPath = Path.GetFullPath(options.RootPath);
|
||||
_enforceWriteOnce = enforceWriteOnce;
|
||||
_logger = logger;
|
||||
|
||||
Directory.CreateDirectory(_rootPath);
|
||||
}
|
||||
|
||||
public async Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var writeOnce = _enforceWriteOnce || options.EnforceWriteOnce;
|
||||
var utcNow = DateTimeOffset.UtcNow;
|
||||
var tempFilePath = Path.Combine(_rootPath, ".tmp", Guid.NewGuid().ToString("N"));
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(tempFilePath)!);
|
||||
|
||||
await using var tempStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan | FileOptions.WriteThrough);
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
long totalBytes = 0;
|
||||
var buffer = new byte[81920];
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
|
||||
{
|
||||
await tempStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||||
sha.TransformBlock(buffer, 0, bytesRead, null, 0);
|
||||
totalBytes += bytesRead;
|
||||
}
|
||||
|
||||
await tempStream.FlushAsync(cancellationToken);
|
||||
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
var sha256 = Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
var storageKey = StorageKeyGenerator.BuildObjectKey(options.TenantId, options.BundleId, options.ArtifactName, sha256);
|
||||
var destinationPath = Path.Combine(_rootPath, storageKey.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
|
||||
if (writeOnce && File.Exists(destinationPath))
|
||||
{
|
||||
await DeleteTempFileAsync(tempFilePath);
|
||||
throw new InvalidOperationException($"Evidence object already exists for key '{storageKey}'.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!writeOnce && File.Exists(destinationPath))
|
||||
{
|
||||
File.Delete(destinationPath);
|
||||
}
|
||||
|
||||
File.Move(tempFilePath, destinationPath, overwrite: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await DeleteTempFileAsync(tempFilePath);
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Error))
|
||||
{
|
||||
_logger.LogError(ex, "Failed to persist evidence object to filesystem store.");
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
return new EvidenceObjectMetadata(
|
||||
StorageKey: storageKey,
|
||||
ContentType: options.ContentType,
|
||||
SizeBytes: totalBytes,
|
||||
Sha256: sha256,
|
||||
ETag: null,
|
||||
CreatedAt: utcNow);
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
|
||||
var path = Path.Combine(_rootPath, storageKey.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("Evidence object not found.", path);
|
||||
}
|
||||
|
||||
Stream stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
return Task.FromResult(stream);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
|
||||
var path = Path.Combine(_rootPath, storageKey.Replace('/', Path.DirectorySeparatorChar));
|
||||
var exists = File.Exists(path);
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
|
||||
private static async Task DeleteTempFileAsync(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
await Task.Run(() => File.Delete(path));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow cleanup errors – best effort only.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Storage;
|
||||
|
||||
internal sealed class S3EvidenceObjectStore : IEvidenceObjectStore, IDisposable
|
||||
{
|
||||
private readonly IAmazonS3 _s3;
|
||||
private readonly AmazonS3StoreOptions _options;
|
||||
private readonly bool _enforceWriteOnce;
|
||||
private readonly ILogger<S3EvidenceObjectStore> _logger;
|
||||
|
||||
public S3EvidenceObjectStore(
|
||||
IAmazonS3 s3,
|
||||
AmazonS3StoreOptions options,
|
||||
bool enforceWriteOnce,
|
||||
ILogger<S3EvidenceObjectStore> logger)
|
||||
{
|
||||
_s3 = s3 ?? throw new ArgumentNullException(nameof(s3));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_enforceWriteOnce = enforceWriteOnce;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<EvidenceObjectMetadata> StoreAsync(
|
||||
Stream content,
|
||||
EvidenceObjectWriteOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var writeOnce = _enforceWriteOnce || options.EnforceWriteOnce;
|
||||
var tempFilePath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.tmp");
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
long totalBytes = 0;
|
||||
|
||||
await using (var fileStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
|
||||
{
|
||||
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||||
sha.TransformBlock(buffer, 0, bytesRead, null, 0);
|
||||
totalBytes += bytesRead;
|
||||
}
|
||||
|
||||
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
}
|
||||
|
||||
var sha256 = Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
var storageKey = StorageKeyGenerator.BuildObjectKey(options.TenantId, options.BundleId, options.ArtifactName, sha256, _options.Prefix);
|
||||
|
||||
string? eTag;
|
||||
|
||||
try
|
||||
{
|
||||
eTag = await UploadAsync(storageKey, tempFilePath, options, totalBytes, sha256, writeOnce, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryCleanupTempFile(tempFilePath);
|
||||
}
|
||||
|
||||
return new EvidenceObjectMetadata(
|
||||
StorageKey: storageKey,
|
||||
ContentType: options.ContentType,
|
||||
SizeBytes: totalBytes,
|
||||
Sha256: sha256,
|
||||
ETag: eTag,
|
||||
CreatedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _s3.GetObjectAsync(new GetObjectRequest
|
||||
{
|
||||
BucketName = _options.BucketName,
|
||||
Key = storageKey
|
||||
}, cancellationToken);
|
||||
|
||||
return new S3ObjectReadStream(response);
|
||||
}
|
||||
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
throw new FileNotFoundException($"Evidence object '{storageKey}' not found in bucket '{_options.BucketName}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
|
||||
try
|
||||
{
|
||||
var metadata = await _s3.GetObjectMetadataAsync(
|
||||
new GetObjectMetadataRequest
|
||||
{
|
||||
BucketName = _options.BucketName,
|
||||
Key = storageKey
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return metadata.HttpStatusCode == System.Net.HttpStatusCode.OK;
|
||||
}
|
||||
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> UploadAsync(
|
||||
string storageKey,
|
||||
string tempFilePath,
|
||||
EvidenceObjectWriteOptions options,
|
||||
long contentLength,
|
||||
string sha256,
|
||||
bool writeOnce,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var inputStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 81920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
var request = new PutObjectRequest
|
||||
{
|
||||
BucketName = _options.BucketName,
|
||||
Key = storageKey,
|
||||
InputStream = inputStream,
|
||||
AutoCloseStream = false,
|
||||
ContentType = options.ContentType
|
||||
};
|
||||
|
||||
request.Headers.ContentLength = contentLength;
|
||||
request.Metadata["sha256"] = sha256;
|
||||
request.Metadata["tenant-id"] = options.TenantId.Value.ToString("D");
|
||||
request.Metadata["bundle-id"] = options.BundleId.Value.ToString("D");
|
||||
|
||||
if (options.Tags is not null)
|
||||
{
|
||||
request.TagSet = options.Tags
|
||||
.Select(tag => new Tag { Key = tag.Key, Value = tag.Value })
|
||||
.ToList();
|
||||
|
||||
foreach (var tag in options.Tags)
|
||||
{
|
||||
request.Metadata[$"tag-{tag.Key}"] = tag.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.UseIntelligentTiering)
|
||||
{
|
||||
request.StorageClass = S3StorageClass.IntelligentTiering;
|
||||
}
|
||||
|
||||
if (writeOnce)
|
||||
{
|
||||
request.Headers["If-None-Match"] = "*";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _s3.PutObjectAsync(request, cancellationToken);
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Uploaded evidence object {Key} to bucket {Bucket} (ETag: {ETag}).", storageKey, _options.BucketName, response.ETag);
|
||||
}
|
||||
return response.ETag;
|
||||
}
|
||||
catch (AmazonS3Exception ex) when (writeOnce && ex.StatusCode == System.Net.HttpStatusCode.PreconditionFailed)
|
||||
{
|
||||
throw new InvalidOperationException($"Evidence object already exists for key '{storageKey}'.", ex);
|
||||
}
|
||||
catch (AmazonS3Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error))
|
||||
{
|
||||
_logger.LogError(ex, "Failed to upload evidence object {Key} to bucket {Bucket}.", storageKey, _options.BucketName);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryCleanupTempFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
(_s3 as IDisposable)?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private sealed class S3ObjectReadStream(GetObjectResponse response) : Stream
|
||||
{
|
||||
private readonly GetObjectResponse _response = response ?? throw new ArgumentNullException(nameof(response));
|
||||
private readonly Stream _inner = response.ResponseStream;
|
||||
|
||||
public override bool CanRead => _inner.CanRead;
|
||||
public override bool CanSeek => _inner.CanSeek;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _inner.Length;
|
||||
public override long Position { get => _inner.Position; set => _inner.Position = value; }
|
||||
|
||||
public override void Flush() => _inner.Flush();
|
||||
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
|
||||
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
|
||||
public override void SetLength(long value) => _inner.SetLength(value);
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
=> await _inner.ReadAsync(buffer, cancellationToken);
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_inner.Dispose();
|
||||
_response.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Storage;
|
||||
|
||||
internal static partial class StorageKeyGenerator
|
||||
{
|
||||
public static string BuildObjectKey(TenantId tenantId, EvidenceBundleId bundleId, string artifactName, string sha256, string? prefix = null)
|
||||
{
|
||||
var sanitizedName = SanitizeComponent(artifactName);
|
||||
var tenantSegment = tenantId.Value.ToString("N");
|
||||
var bundleSegment = bundleId.Value.ToString("N");
|
||||
var builder = new StringBuilder();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
builder.Append(prefix.Trim().Trim('/'));
|
||||
builder.Append('/');
|
||||
}
|
||||
|
||||
builder.Append("tenants/");
|
||||
builder.Append(tenantSegment);
|
||||
builder.Append("/bundles/");
|
||||
builder.Append(bundleSegment);
|
||||
builder.Append('/');
|
||||
builder.Append(sha256);
|
||||
|
||||
if (!string.IsNullOrEmpty(sanitizedName))
|
||||
{
|
||||
builder.Append('-');
|
||||
builder.Append(sanitizedName);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string SanitizeComponent(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var normalized = InvalidCharacters().Replace(trimmed, "-").ToLowerInvariant();
|
||||
return normalized.Length switch
|
||||
{
|
||||
> 80 => normalized[..80],
|
||||
_ => normalized
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex("[^a-zA-Z0-9._-]+")]
|
||||
private static partial Regex InvalidCharacters();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Incident;
|
||||
using StellaOps.EvidenceLocker.Core.Timeline;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Timeline;
|
||||
|
||||
public sealed class NullEvidenceTimelinePublisher : IEvidenceTimelinePublisher
|
||||
{
|
||||
private readonly ILogger<NullEvidenceTimelinePublisher> _logger;
|
||||
|
||||
public NullEvidenceTimelinePublisher(ILogger<NullEvidenceTimelinePublisher> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task PublishBundleSealedAsync(
|
||||
EvidenceBundleSignature signature,
|
||||
EvidenceBundleManifest manifest,
|
||||
string rootHash,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Timeline publisher not configured; skipping bundle sealed event for {BundleId}.",
|
||||
signature.BundleId.Value);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Timeline publisher not configured; skipping hold event for case {CaseId}.",
|
||||
hold.CaseId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Timeline publisher not configured; skipping incident mode event (state: {State}).",
|
||||
change.IsActive ? "enabled" : "disabled");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Incident;
|
||||
using StellaOps.EvidenceLocker.Core.Timeline;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Timeline;
|
||||
|
||||
internal sealed class TimelineIndexerEvidenceTimelinePublisher : IEvidenceTimelinePublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TimelineOptions _options;
|
||||
private readonly ILogger<TimelineIndexerEvidenceTimelinePublisher> _logger;
|
||||
private readonly Uri _endpoint;
|
||||
|
||||
public TimelineIndexerEvidenceTimelinePublisher(
|
||||
HttpClient httpClient,
|
||||
IOptions<EvidenceLockerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<TimelineIndexerEvidenceTimelinePublisher> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value.Timeline ?? throw new InvalidOperationException("Timeline options must be configured when the publisher is enabled.");
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("Timeline publisher cannot be constructed when disabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("Timeline endpoint must be provided when publishing is enabled.");
|
||||
}
|
||||
|
||||
_endpoint = new Uri(_options.Endpoint, UriKind.Absolute);
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task PublishBundleSealedAsync(
|
||||
EvidenceBundleSignature signature,
|
||||
EvidenceBundleManifest manifest,
|
||||
string rootHash,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootHash);
|
||||
|
||||
var envelope = BuildBundleEvent(signature, manifest, rootHash);
|
||||
await SendAsync(envelope, signature.BundleId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hold);
|
||||
|
||||
var envelope = BuildHoldEvent(hold);
|
||||
await SendAsync(envelope, hold.Id.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
|
||||
{
|
||||
var envelope = BuildIncidentEvent(change);
|
||||
await SendAsync(envelope, Guid.Empty, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private TimelineEventEnvelope BuildBundleEvent(EvidenceBundleSignature signature, EvidenceBundleManifest manifest, string rootHash)
|
||||
{
|
||||
var eventId = Guid.NewGuid();
|
||||
var occurredAt = signature.TimestampedAt ?? signature.SignedAt;
|
||||
var attributes = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["bundleId"] = signature.BundleId.Value.ToString("D"),
|
||||
["bundleKind"] = ((int)manifest.Kind).ToString(),
|
||||
["rootHash"] = rootHash,
|
||||
["payloadType"] = signature.PayloadType
|
||||
};
|
||||
|
||||
var metadata = ToSortedDictionary(manifest.Metadata);
|
||||
var manifestEntries = manifest.Entries
|
||||
.Select(entry => new TimelineManifestEntryRecord(
|
||||
entry.Section,
|
||||
entry.CanonicalPath,
|
||||
entry.Sha256,
|
||||
entry.SizeBytes,
|
||||
entry.MediaType,
|
||||
ToSortedDictionary(entry.Attributes)))
|
||||
.ToArray();
|
||||
|
||||
var signatureRecord = new TimelineSignatureRecord(
|
||||
signature.Payload,
|
||||
signature.Signature,
|
||||
signature.KeyId,
|
||||
signature.Algorithm,
|
||||
signature.Provider,
|
||||
signature.SignedAt,
|
||||
signature.TimestampedAt,
|
||||
signature.TimestampAuthority,
|
||||
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
|
||||
|
||||
var bundleRecord = new TimelineBundleRecord(
|
||||
signature.BundleId.Value,
|
||||
manifest.Kind,
|
||||
rootHash,
|
||||
metadata,
|
||||
manifestEntries,
|
||||
signatureRecord);
|
||||
|
||||
return new TimelineEventEnvelope(
|
||||
eventId,
|
||||
signature.TenantId.Value,
|
||||
_options.Source,
|
||||
"evidence.bundle.sealed",
|
||||
occurredAt,
|
||||
attributes,
|
||||
Bundle: bundleRecord,
|
||||
Hold: null,
|
||||
Incident: null);
|
||||
}
|
||||
|
||||
private TimelineEventEnvelope BuildHoldEvent(EvidenceHold hold)
|
||||
{
|
||||
var eventId = Guid.NewGuid();
|
||||
var occurredAt = hold.CreatedAt;
|
||||
var attributes = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["caseId"] = hold.CaseId
|
||||
};
|
||||
|
||||
if (hold.BundleId is not null)
|
||||
{
|
||||
attributes["bundleId"] = hold.BundleId.Value.Value.ToString("D");
|
||||
}
|
||||
|
||||
var holdRecord = new TimelineHoldRecord(
|
||||
hold.Id.Value,
|
||||
hold.CaseId,
|
||||
hold.BundleId?.Value,
|
||||
hold.Reason,
|
||||
hold.CreatedAt,
|
||||
hold.ExpiresAt,
|
||||
hold.ReleasedAt,
|
||||
hold.Notes);
|
||||
|
||||
return new TimelineEventEnvelope(
|
||||
eventId,
|
||||
hold.TenantId.Value,
|
||||
_options.Source,
|
||||
"evidence.hold.created",
|
||||
occurredAt,
|
||||
attributes,
|
||||
Bundle: null,
|
||||
Hold: holdRecord,
|
||||
Incident: null);
|
||||
}
|
||||
|
||||
private TimelineEventEnvelope BuildIncidentEvent(IncidentModeChange change)
|
||||
{
|
||||
var eventId = Guid.NewGuid();
|
||||
var attributes = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["state"] = change.IsActive ? "enabled" : "disabled",
|
||||
["retentionExtensionDays"] = change.RetentionExtensionDays.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
var incidentRecord = new TimelineIncidentRecord(
|
||||
change.IsActive,
|
||||
change.ChangedAt,
|
||||
change.RetentionExtensionDays);
|
||||
|
||||
return new TimelineEventEnvelope(
|
||||
eventId,
|
||||
Guid.Empty,
|
||||
_options.Source,
|
||||
"evidence.incident.mode",
|
||||
change.ChangedAt,
|
||||
attributes,
|
||||
Bundle: null,
|
||||
Hold: null,
|
||||
Incident: incidentRecord);
|
||||
}
|
||||
|
||||
private async Task SendAsync(TimelineEventEnvelope envelope, Guid referenceId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.PostAsJsonAsync(_endpoint, envelope, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Published timeline event {EventId} for reference {ReferenceId}.", envelope.EventId, referenceId);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = await SafeReadBodyAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning(
|
||||
"Timeline publish for {ReferenceId} failed with status {StatusCode}. Body: {Body}",
|
||||
referenceId,
|
||||
response.StatusCode,
|
||||
body);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or InvalidOperationException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Timeline publish for {ReferenceId} failed: {Message}",
|
||||
referenceId,
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> SafeReadBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, string> ToSortedDictionary(IReadOnlyDictionary<string, string> source)
|
||||
{
|
||||
var result = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var kvp in source)
|
||||
{
|
||||
result[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed record TimelineEventEnvelope(
|
||||
Guid EventId,
|
||||
Guid TenantId,
|
||||
string Source,
|
||||
string Kind,
|
||||
DateTimeOffset OccurredAt,
|
||||
IReadOnlyDictionary<string, string> Attributes,
|
||||
TimelineBundleRecord? Bundle,
|
||||
TimelineHoldRecord? Hold,
|
||||
TimelineIncidentRecord? Incident);
|
||||
|
||||
private sealed record TimelineBundleRecord(
|
||||
Guid BundleId,
|
||||
EvidenceBundleKind Kind,
|
||||
string RootHash,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
IReadOnlyList<TimelineManifestEntryRecord> Entries,
|
||||
TimelineSignatureRecord Signature);
|
||||
|
||||
private sealed record TimelineManifestEntryRecord(
|
||||
string Section,
|
||||
string CanonicalPath,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string? MediaType,
|
||||
IReadOnlyDictionary<string, string> Attributes);
|
||||
|
||||
private sealed record TimelineSignatureRecord(
|
||||
string Payload,
|
||||
string Signature,
|
||||
string? KeyId,
|
||||
string Algorithm,
|
||||
string Provider,
|
||||
DateTimeOffset SignedAt,
|
||||
DateTimeOffset? TimestampedAt,
|
||||
string? TimestampAuthority,
|
||||
string? TimestampToken);
|
||||
|
||||
private sealed record TimelineHoldRecord(
|
||||
Guid HoldId,
|
||||
string CaseId,
|
||||
Guid? BundleId,
|
||||
string Reason,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset? ReleasedAt,
|
||||
string? Notes);
|
||||
|
||||
private sealed record TimelineIncidentRecord(
|
||||
bool Enabled,
|
||||
DateTimeOffset ChangedAt,
|
||||
int RetentionExtensionDays);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Docker.DotNet;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Configurations;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class DatabaseMigrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlTestcontainer _postgres;
|
||||
private EvidenceLockerDataSource? _dataSource;
|
||||
private IEvidenceLockerMigrationRunner? _migrationRunner;
|
||||
private string? _skipReason;
|
||||
|
||||
public DatabaseMigrationTests()
|
||||
{
|
||||
_postgres = new TestcontainersBuilder<PostgreSqlTestcontainer>()
|
||||
.WithDatabase(new PostgreSqlTestcontainerConfiguration
|
||||
{
|
||||
Database = "evidence_locker_tests",
|
||||
Username = "postgres",
|
||||
Password = "postgres"
|
||||
})
|
||||
.WithCleanUp(true)
|
||||
.Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_CreatesExpectedSchemaAndPolicies()
|
||||
{
|
||||
if (_skipReason is not null)
|
||||
{
|
||||
Assert.Skip(_skipReason);
|
||||
}
|
||||
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
|
||||
await _migrationRunner!.ApplyAsync(cancellationToken);
|
||||
|
||||
await using var connection = await _dataSource!.OpenConnectionAsync(cancellationToken);
|
||||
await using var tablesCommand = new NpgsqlCommand(
|
||||
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'evidence_locker' ORDER BY table_name;",
|
||||
connection);
|
||||
var tables = new List<string>();
|
||||
await using (var reader = await tablesCommand.ExecuteReaderAsync(cancellationToken))
|
||||
{
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Contains("evidence_artifacts", tables);
|
||||
Assert.Contains("evidence_bundles", tables);
|
||||
Assert.Contains("evidence_holds", tables);
|
||||
Assert.Contains("evidence_schema_version", tables);
|
||||
|
||||
await using var versionCommand = new NpgsqlCommand(
|
||||
"SELECT COUNT(*) FROM evidence_locker.evidence_schema_version WHERE version = 1;",
|
||||
connection);
|
||||
var applied = Convert.ToInt64(await versionCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
|
||||
Assert.Equal(1, applied);
|
||||
|
||||
var tenant = TenantId.FromGuid(Guid.NewGuid());
|
||||
await using var tenantConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
|
||||
await using var insertCommand = new NpgsqlCommand(@"
|
||||
INSERT INTO evidence_locker.evidence_bundles
|
||||
(bundle_id, tenant_id, kind, status, root_hash, storage_key)
|
||||
VALUES
|
||||
(@bundle, @tenant, 1, 3, @hash, @key);",
|
||||
tenantConnection);
|
||||
insertCommand.Parameters.AddWithValue("bundle", Guid.NewGuid());
|
||||
insertCommand.Parameters.AddWithValue("tenant", tenant.Value);
|
||||
insertCommand.Parameters.AddWithValue("hash", new string('a', 64));
|
||||
insertCommand.Parameters.AddWithValue("key", $"tenants/{tenant.Value:N}/bundles/test/resource");
|
||||
await insertCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
await using var isolationConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
|
||||
await using var selectCommand = new NpgsqlCommand(
|
||||
"SELECT COUNT(*) FROM evidence_locker.evidence_bundles;",
|
||||
isolationConnection);
|
||||
var visibleCount = Convert.ToInt64(await selectCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
|
||||
Assert.Equal(1, visibleCount);
|
||||
|
||||
await using var otherTenantConnection = await _dataSource.OpenConnectionAsync(TenantId.FromGuid(Guid.NewGuid()), cancellationToken);
|
||||
await using var otherSelectCommand = new NpgsqlCommand(
|
||||
"SELECT COUNT(*) FROM evidence_locker.evidence_bundles;",
|
||||
otherTenantConnection);
|
||||
var otherVisible = Convert.ToInt64(await otherSelectCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
|
||||
Assert.Equal(0, otherVisible);
|
||||
|
||||
await using var violationConnection = await _dataSource.OpenConnectionAsync(tenant, cancellationToken);
|
||||
await using var violationCommand = new NpgsqlCommand(@"
|
||||
INSERT INTO evidence_locker.evidence_bundles
|
||||
(bundle_id, tenant_id, kind, status, root_hash, storage_key)
|
||||
VALUES
|
||||
(@bundle, @tenant, 1, 3, @hash, @key);",
|
||||
violationConnection);
|
||||
violationCommand.Parameters.AddWithValue("bundle", Guid.NewGuid());
|
||||
violationCommand.Parameters.AddWithValue("tenant", Guid.NewGuid());
|
||||
violationCommand.Parameters.AddWithValue("hash", new string('b', 64));
|
||||
violationCommand.Parameters.AddWithValue("key", "tenants/other/bundles/resource");
|
||||
|
||||
await Assert.ThrowsAsync<PostgresException>(() => violationCommand.ExecuteNonQueryAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _postgres.StartAsync();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_skipReason = $"Docker endpoint unavailable: {ex.Message}";
|
||||
return;
|
||||
}
|
||||
catch (Docker.DotNet.DockerApiException ex)
|
||||
{
|
||||
_skipReason = $"Docker API error: {ex.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
var databaseOptions = new DatabaseOptions
|
||||
{
|
||||
ConnectionString = _postgres.ConnectionString,
|
||||
ApplyMigrationsAtStartup = false
|
||||
};
|
||||
|
||||
_dataSource = new EvidenceLockerDataSource(databaseOptions, NullLogger<EvidenceLockerDataSource>.Instance);
|
||||
_migrationRunner = new EvidenceLockerMigrationRunner(_dataSource, NullLogger<EvidenceLockerMigrationRunner>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_skipReason is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_dataSource is not null)
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
await _postgres.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Builders;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class EvidenceBundleBuilderTests
|
||||
{
|
||||
private readonly FakeRepository _repository = new();
|
||||
private readonly IEvidenceBundleBuilder _builder;
|
||||
|
||||
public EvidenceBundleBuilderTests()
|
||||
{
|
||||
_builder = new EvidenceBundleBuilder(_repository, new MerkleTreeCalculator());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ComputesDeterministicRootAndPersists()
|
||||
{
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var request = new EvidenceBundleBuildRequest(
|
||||
bundleId,
|
||||
tenantId,
|
||||
EvidenceBundleKind.Job,
|
||||
DateTimeOffset.Parse("2025-11-03T15:04:05Z"),
|
||||
new Dictionary<string, string> { ["run-id"] = "job-42" },
|
||||
new List<EvidenceBundleMaterial>
|
||||
{
|
||||
new("inputs", "config/env.json", "5a6b7c", 1024, "application/json"),
|
||||
new("outputs", "reports/result.txt", "7f8e9d", 2048, "text/plain")
|
||||
});
|
||||
|
||||
var result = await _builder.BuildAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(EvidenceBundleStatus.Sealed, _repository.LastStatus);
|
||||
Assert.Equal(bundleId, _repository.LastBundleId);
|
||||
Assert.Equal(tenantId, _repository.LastTenantId);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-11-03T15:04:05Z"), _repository.LastUpdatedAt);
|
||||
|
||||
Assert.Equal(result.RootHash, _repository.LastRootHash);
|
||||
Assert.Equal(2, result.Manifest.Entries.Count);
|
||||
Assert.True(result.Manifest.Entries.SequenceEqual(
|
||||
result.Manifest.Entries.OrderBy(entry => entry.CanonicalPath, StringComparer.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_NormalizesSectionAndPath()
|
||||
{
|
||||
var request = new EvidenceBundleBuildRequest(
|
||||
EvidenceBundleId.FromGuid(Guid.NewGuid()),
|
||||
TenantId.FromGuid(Guid.NewGuid()),
|
||||
EvidenceBundleKind.Evaluation,
|
||||
DateTimeOffset.UtcNow,
|
||||
new Dictionary<string, string>(),
|
||||
new List<EvidenceBundleMaterial>
|
||||
{
|
||||
new(" Inputs ", "./Config/Env.JSON ", "abc123", 10, "application/json"),
|
||||
new("OUTPUTS", "\\Logs\\app.log", "def456", 20, "text/plain")
|
||||
});
|
||||
|
||||
var result = await _builder.BuildAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Collection(result.Manifest.Entries,
|
||||
entry => Assert.Equal("inputs/config/env.json", entry.CanonicalPath),
|
||||
entry => Assert.Equal("outputs/logs/app.log", entry.CanonicalPath));
|
||||
}
|
||||
|
||||
private sealed class FakeRepository : IEvidenceBundleRepository
|
||||
{
|
||||
public EvidenceBundleId LastBundleId { get; private set; }
|
||||
public TenantId LastTenantId { get; private set; }
|
||||
public EvidenceBundleStatus LastStatus { get; private set; }
|
||||
public string? LastRootHash { get; private set; }
|
||||
public DateTimeOffset LastUpdatedAt { get; private set; }
|
||||
|
||||
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task SetBundleAssemblyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleStatus status,
|
||||
string rootHash,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastBundleId = bundleId;
|
||||
LastTenantId = tenantId;
|
||||
LastStatus = status;
|
||||
LastRootHash = rootHash;
|
||||
LastUpdatedAt = updatedAt;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<EvidenceBundleDetails?>(null);
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(hold);
|
||||
|
||||
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class EvidenceBundlePackagingServiceTests
|
||||
{
|
||||
private static readonly TenantId TenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
private static readonly EvidenceBundleId BundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
private static readonly DateTimeOffset CreatedAt = new(2025, 11, 3, 12, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_ReturnsCached_WhenPackageExists()
|
||||
{
|
||||
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature());
|
||||
var objectStore = new FakeObjectStore(exists: true);
|
||||
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
|
||||
|
||||
var result = await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Created);
|
||||
Assert.Equal(repository.Bundle.StorageKey, result.StorageKey);
|
||||
Assert.Equal(repository.Bundle.RootHash, result.RootHash);
|
||||
Assert.False(objectStore.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_Throws_WhenSignatureMissing()
|
||||
{
|
||||
var repository = new FakeRepository(CreateSealedBundle(), signature: null);
|
||||
var objectStore = new FakeObjectStore(exists: false);
|
||||
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_CreatesPackageWithExpectedEntries()
|
||||
{
|
||||
var repository = new FakeRepository(
|
||||
CreateSealedBundle(storageKey: $"tenants/{TenantId.Value:N}/bundles/{BundleId.Value:N}/bundle-old.tgz"),
|
||||
CreateSignature(includeTimestamp: true));
|
||||
var objectStore = new FakeObjectStore(exists: false);
|
||||
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
|
||||
|
||||
var result = await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Created);
|
||||
Assert.True(objectStore.Stored);
|
||||
|
||||
var entries = ReadArchiveEntries(objectStore.StoredBytes!);
|
||||
Assert.Contains(entries.Keys, key => key == "manifest.json");
|
||||
Assert.Contains(entries.Keys, key => key == "signature.json");
|
||||
Assert.Contains(entries.Keys, key => key == "bundle.json");
|
||||
Assert.Contains(entries.Keys, key => key == "checksums.txt");
|
||||
Assert.Contains(entries.Keys, key => key == "instructions.txt");
|
||||
|
||||
var manifestJson = entries["manifest.json"];
|
||||
using var manifestDoc = JsonDocument.Parse(manifestJson);
|
||||
Assert.Equal(BundleId.Value.ToString("D"), manifestDoc.RootElement.GetProperty("bundleId").GetString());
|
||||
|
||||
var signatureJson = entries["signature.json"];
|
||||
using var signatureDoc = JsonDocument.Parse(signatureJson);
|
||||
Assert.Equal("application/vnd.stella.evidence.manifest+json", signatureDoc.RootElement.GetProperty("payloadType").GetString());
|
||||
Assert.Equal("tsa.default", signatureDoc.RootElement.GetProperty("timestampAuthority").GetString());
|
||||
Assert.False(string.IsNullOrEmpty(signatureDoc.RootElement.GetProperty("timestampToken").GetString()));
|
||||
|
||||
var checksums = entries["checksums.txt"].Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
Assert.Contains(checksums, line => line.Contains(repository.Bundle.RootHash, StringComparison.Ordinal));
|
||||
|
||||
var instructions = entries["instructions.txt"];
|
||||
Assert.Contains("Timestamped At:", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Validate the RFC3161 timestamp token", instructions, StringComparison.Ordinal);
|
||||
|
||||
Assert.True(repository.StorageKeyUpdated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_ProducesDeterministicGzipHeader()
|
||||
{
|
||||
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature());
|
||||
var objectStore = new FakeObjectStore(exists: false);
|
||||
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
|
||||
|
||||
await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None);
|
||||
|
||||
Assert.True(objectStore.Stored);
|
||||
var archiveBytes = objectStore.StoredBytes!;
|
||||
|
||||
Assert.True(archiveBytes.Length > 10);
|
||||
Assert.Equal(0x1f, archiveBytes[0]);
|
||||
Assert.Equal(0x8b, archiveBytes[1]);
|
||||
|
||||
var mtime = BinaryPrimitives.ReadInt32LittleEndian(archiveBytes.AsSpan(4, 4));
|
||||
var expectedSeconds = (int)(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) - DateTimeOffset.UnixEpoch).TotalSeconds;
|
||||
Assert.Equal(expectedSeconds, mtime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_Throws_WhenManifestPayloadInvalid()
|
||||
{
|
||||
var signature = CreateSignature() with { Payload = "not-base64" };
|
||||
var repository = new FakeRepository(CreateSealedBundle(), signature);
|
||||
var objectStore = new FakeObjectStore(exists: false);
|
||||
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None));
|
||||
Assert.Contains("manifest payload", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePackageAsync_Throws_WhenManifestPayloadNotJson()
|
||||
{
|
||||
var rawPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("not-json"));
|
||||
var signature = CreateSignature() with { Payload = rawPayload };
|
||||
var repository = new FakeRepository(CreateSealedBundle(), signature);
|
||||
var objectStore = new FakeObjectStore(exists: false);
|
||||
var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger<EvidenceBundlePackagingService>.Instance);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None));
|
||||
Assert.Contains("manifest payload", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static EvidenceBundle CreateSealedBundle(string? storageKey = null)
|
||||
=> new(
|
||||
BundleId,
|
||||
TenantId,
|
||||
EvidenceBundleKind.Job,
|
||||
EvidenceBundleStatus.Sealed,
|
||||
new string('a', 64),
|
||||
storageKey ?? $"tenants/{TenantId.Value:N}/bundles/{BundleId.Value:N}/bundle.tgz",
|
||||
CreatedAt,
|
||||
CreatedAt,
|
||||
Description: "test bundle",
|
||||
SealedAt: CreatedAt.AddMinutes(1),
|
||||
ExpiresAt: null);
|
||||
|
||||
private static EvidenceBundleSignature CreateSignature(bool includeTimestamp = false)
|
||||
{
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = BundleId.Value.ToString("D"),
|
||||
tenantId = TenantId.Value.ToString("D"),
|
||||
kind = (int)EvidenceBundleKind.Job,
|
||||
createdAt = CreatedAt.ToString("O"),
|
||||
metadata = new Dictionary<string, string> { ["run"] = "nightly" },
|
||||
entries = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
section = "inputs",
|
||||
canonicalPath = "inputs/config.json",
|
||||
sha256 = new string('b', 64),
|
||||
sizeBytes = 128,
|
||||
mediaType = "application/json",
|
||||
attributes = new Dictionary<string, string>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
|
||||
|
||||
return new EvidenceBundleSignature(
|
||||
BundleId,
|
||||
TenantId,
|
||||
"application/vnd.stella.evidence.manifest+json",
|
||||
payload,
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes("signature")),
|
||||
"key-1",
|
||||
"ES256",
|
||||
"default",
|
||||
CreatedAt.AddMinutes(1),
|
||||
TimestampedAt: includeTimestamp ? CreatedAt.AddMinutes(2) : null,
|
||||
TimestampAuthority: includeTimestamp ? "tsa.default" : null,
|
||||
TimestampToken: includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadArchiveEntries(byte[] archiveBytes)
|
||||
{
|
||||
using var memory = new MemoryStream(archiveBytes);
|
||||
using var gzip = new GZipStream(memory, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var reader = new TarReader(gzip);
|
||||
|
||||
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
TarEntry? entry;
|
||||
while ((entry = reader.GetNextEntry()) is not null)
|
||||
{
|
||||
if (entry.EntryType != TarEntryType.RegularFile)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var entryStream = new MemoryStream();
|
||||
entry.DataStream!.CopyTo(entryStream);
|
||||
var content = Encoding.UTF8.GetString(entryStream.ToArray());
|
||||
entries[entry.Name] = content;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private sealed class FakeRepository : IEvidenceBundleRepository
|
||||
{
|
||||
private EvidenceBundle _bundle;
|
||||
|
||||
public FakeRepository(EvidenceBundle bundle, EvidenceBundleSignature? signature)
|
||||
{
|
||||
_bundle = bundle;
|
||||
Signature = signature;
|
||||
}
|
||||
|
||||
public EvidenceBundle Bundle => _bundle;
|
||||
public EvidenceBundleSignature? Signature { get; }
|
||||
public bool StorageKeyUpdated { get; private set; }
|
||||
public bool PortableStorageKeyUpdated { get; private set; }
|
||||
|
||||
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(hold);
|
||||
|
||||
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
StorageKeyUpdated = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
PortableStorageKeyUpdated = true;
|
||||
_bundle = _bundle with
|
||||
{
|
||||
PortableStorageKey = storageKey,
|
||||
PortableGeneratedAt = generatedAt
|
||||
};
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeObjectStore : IEvidenceObjectStore
|
||||
{
|
||||
private readonly bool _exists;
|
||||
|
||||
private readonly string? _fixedStorageKey;
|
||||
|
||||
public FakeObjectStore(bool exists, string? fixedStorageKey = null)
|
||||
{
|
||||
_exists = exists;
|
||||
_fixedStorageKey = fixedStorageKey;
|
||||
}
|
||||
|
||||
public bool Stored { get; private set; }
|
||||
public byte[]? StoredBytes { get; private set; }
|
||||
|
||||
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
Stored = true;
|
||||
using var memory = new MemoryStream();
|
||||
content.CopyTo(memory);
|
||||
StoredBytes = memory.ToArray();
|
||||
|
||||
var storageKey = _fixedStorageKey ?? $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/bundle.tgz";
|
||||
|
||||
return Task.FromResult(new EvidenceObjectMetadata(
|
||||
storageKey,
|
||||
options.ContentType,
|
||||
StoredBytes.Length,
|
||||
Convert.ToHexString(SHA256.HashData(StoredBytes)).ToLowerInvariant(),
|
||||
ETag: null,
|
||||
CreatedAt: DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (StoredBytes is null)
|
||||
{
|
||||
throw new FileNotFoundException("Package not created.");
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream>(new MemoryStream(StoredBytes, writable: false));
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_exists);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
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;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
internal sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _contentRoot;
|
||||
|
||||
public EvidenceLockerWebApplicationFactory()
|
||||
{
|
||||
_contentRoot = Path.Combine(Path.GetTempPath(), "evidence-locker-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_contentRoot);
|
||||
File.WriteAllText(Path.Combine(_contentRoot, "appsettings.json"), "{}");
|
||||
}
|
||||
|
||||
public TestEvidenceBundleRepository Repository => Services.GetRequiredService<TestEvidenceBundleRepository>();
|
||||
public TestEvidenceObjectStore ObjectStore => Services.GetRequiredService<TestEvidenceObjectStore>();
|
||||
|
||||
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
|
||||
|
||||
private static SigningKeyMaterialOptions GenerateKeyMaterial()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
return new SigningKeyMaterialOptions
|
||||
{
|
||||
EcPrivateKeyPem = ecdsa.ExportECPrivateKeyPem(),
|
||||
EcPublicKeyPem = ecdsa.ExportSubjectPublicKeyInfoPem()
|
||||
};
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseSetting(WebHostDefaults.ContentRootKey, _contentRoot);
|
||||
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
|
||||
{
|
||||
configurationBuilder.Sources.Clear();
|
||||
var keyMaterial = GenerateKeyMaterial();
|
||||
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EvidenceLocker:Database:ConnectionString"] = "Host=localhost",
|
||||
["EvidenceLocker:Database:ApplyMigrationsAtStartup"] = "false",
|
||||
["EvidenceLocker:ObjectStore:Kind"] = "FileSystem",
|
||||
["EvidenceLocker:ObjectStore:FileSystem:RootPath"] = ".",
|
||||
["EvidenceLocker:Quotas:MaxMaterialCount"] = "4",
|
||||
["EvidenceLocker:Quotas:MaxTotalMaterialSizeBytes"] = "1024",
|
||||
["EvidenceLocker:Quotas:MaxMetadataEntries"] = "4",
|
||||
["EvidenceLocker:Quotas:MaxMetadataKeyLength"] = "32",
|
||||
["EvidenceLocker:Quotas:MaxMetadataValueLength"] = "64",
|
||||
["EvidenceLocker:Signing:Enabled"] = "true",
|
||||
["EvidenceLocker:Signing:Algorithm"] = "ES256",
|
||||
["EvidenceLocker:Signing:KeyId"] = "test-key",
|
||||
["EvidenceLocker:Signing:PayloadType"] = "application/vnd.stella.test-manifest+json",
|
||||
["EvidenceLocker:Signing:KeyMaterial:EcPrivateKeyPem"] = keyMaterial.EcPrivateKeyPem,
|
||||
["EvidenceLocker:Signing:KeyMaterial:EcPublicKeyPem"] = keyMaterial.EcPublicKeyPem,
|
||||
["EvidenceLocker:Signing:Timestamping:Enabled"] = "true",
|
||||
["EvidenceLocker:Signing:Timestamping:Endpoint"] = "https://tsa.example",
|
||||
["EvidenceLocker:Signing:Timestamping:HashAlgorithm"] = "SHA256",
|
||||
["EvidenceLocker:Incident:Enabled"] = "false",
|
||||
["EvidenceLocker:Incident:RetentionExtensionDays"] = "30",
|
||||
["EvidenceLocker:Incident:CaptureRequestSnapshot"] = "true",
|
||||
["Authority:ResourceServer:Authority"] = "https://authority.localtest.me",
|
||||
["Authority:ResourceServer:Audiences:0"] = "api://evidence-locker",
|
||||
["Authority:ResourceServer:RequiredTenants:0"] = "tenant-default"
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IEvidenceBundleRepository>();
|
||||
services.RemoveAll<IEvidenceTimelinePublisher>();
|
||||
services.RemoveAll<ITimestampAuthorityClient>();
|
||||
services.RemoveAll<IEvidenceObjectStore>();
|
||||
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
|
||||
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
|
||||
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
|
||||
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
|
||||
|
||||
services.AddSingleton<TestEvidenceBundleRepository>();
|
||||
services.AddSingleton<IEvidenceBundleRepository>(sp => sp.GetRequiredService<TestEvidenceBundleRepository>());
|
||||
services.AddSingleton<TestTimelinePublisher>();
|
||||
services.AddSingleton<IEvidenceTimelinePublisher>(sp => sp.GetRequiredService<TestTimelinePublisher>());
|
||||
services.AddSingleton<ITimestampAuthorityClient, TestTimestampAuthorityClient>();
|
||||
services.AddSingleton<TestEvidenceObjectStore>();
|
||||
services.AddSingleton<IEvidenceObjectStore>(sp => sp.GetRequiredService<TestEvidenceObjectStore>());
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = EvidenceLockerTestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = EvidenceLockerTestAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, EvidenceLockerTestAuthHandler>(EvidenceLockerTestAuthHandler.SchemeName, _ => { })
|
||||
.AddScheme<AuthenticationSchemeOptions, EvidenceLockerTestAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
services.PostConfigure<AuthorizationOptions>(options =>
|
||||
{
|
||||
var allowAllPolicy = new AuthorizationPolicyBuilder()
|
||||
.AddAuthenticationSchemes(EvidenceLockerTestAuthHandler.SchemeName)
|
||||
.RequireAssertion(_ => true)
|
||||
.Build();
|
||||
|
||||
options.DefaultPolicy = allowAllPolicy;
|
||||
options.FallbackPolicy = allowAllPolicy;
|
||||
options.AddPolicy(StellaOpsResourceServerPolicies.EvidenceCreate, allowAllPolicy);
|
||||
options.AddPolicy(StellaOpsResourceServerPolicies.EvidenceRead, allowAllPolicy);
|
||||
options.AddPolicy(StellaOpsResourceServerPolicies.EvidenceHold, allowAllPolicy);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing && Directory.Exists(_contentRoot))
|
||||
{
|
||||
Directory.Delete(_contentRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestTimestampAuthorityClient : ITimestampAuthorityClient
|
||||
{
|
||||
public Task<TimestampResult?> RequestTimestampAsync(ReadOnlyMemory<byte> signature, string hashAlgorithm, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = signature.ToArray();
|
||||
var result = new TimestampResult(DateTimeOffset.UtcNow, "test-tsa", token);
|
||||
return Task.FromResult<TimestampResult?>(result);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
|
||||
{
|
||||
public List<string> PublishedEvents { get; } = new();
|
||||
public List<string> IncidentEvents { get; } = new();
|
||||
|
||||
public Task PublishBundleSealedAsync(
|
||||
EvidenceBundleSignature signature,
|
||||
EvidenceBundleManifest manifest,
|
||||
string rootHash,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PublishedEvents.Add($"bundle:{signature.BundleId.Value:D}:{rootHash}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
{
|
||||
PublishedEvents.Add($"hold:{hold.CaseId}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
|
||||
{
|
||||
IncidentEvents.Add(change.IsActive ? "enabled" : "disabled");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestEvidenceObjectStore : IEvidenceObjectStore
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _preExisting = new(StringComparer.Ordinal);
|
||||
|
||||
public IReadOnlyDictionary<string, byte[]> StoredObjects => _objects;
|
||||
|
||||
public void SeedExisting(string storageKey) => _preExisting.Add(storageKey);
|
||||
|
||||
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
using var memory = new MemoryStream();
|
||||
content.CopyTo(memory);
|
||||
var bytes = memory.ToArray();
|
||||
var storageKey = $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/{options.ArtifactName}";
|
||||
_objects[storageKey] = bytes;
|
||||
_preExisting.Add(storageKey);
|
||||
|
||||
return Task.FromResult(new EvidenceObjectMetadata(
|
||||
storageKey,
|
||||
options.ContentType,
|
||||
bytes.Length,
|
||||
Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(),
|
||||
null,
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_objects.TryGetValue(storageKey, out var bytes))
|
||||
{
|
||||
throw new FileNotFoundException(storageKey);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream>(new MemoryStream(bytes, writable: false));
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_preExisting.Contains(storageKey));
|
||||
}
|
||||
|
||||
internal sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
|
||||
{
|
||||
private readonly List<EvidenceBundleSignature> _signatures = new();
|
||||
private readonly Dictionary<(Guid BundleId, Guid TenantId), EvidenceBundle> _bundles = new();
|
||||
|
||||
public bool HoldConflict { get; set; }
|
||||
|
||||
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||
{
|
||||
_bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
UpdateBundle(bundleId, tenantId, bundle => bundle with
|
||||
{
|
||||
Status = status,
|
||||
RootHash = rootHash,
|
||||
UpdatedAt = updatedAt
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
UpdateBundle(bundleId, tenantId, bundle => bundle with
|
||||
{
|
||||
Status = status,
|
||||
SealedAt = sealedAt,
|
||||
UpdatedAt = sealedAt
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
|
||||
{
|
||||
_signatures.RemoveAll(sig => sig.BundleId == signature.BundleId && sig.TenantId == signature.TenantId);
|
||||
_signatures.Add(signature);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
_bundles.TryGetValue((bundleId.Value, tenantId.Value), out var bundle);
|
||||
var signature = _signatures.FirstOrDefault(sig => sig.BundleId == bundleId && sig.TenantId == tenantId);
|
||||
return Task.FromResult<EvidenceBundleDetails?>(bundle is null ? null : new EvidenceBundleDetails(bundle, signature));
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_bundles.ContainsKey((bundleId.Value, tenantId.Value)));
|
||||
|
||||
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
{
|
||||
if (HoldConflict)
|
||||
{
|
||||
throw CreateUniqueViolationException();
|
||||
}
|
||||
|
||||
return Task.FromResult(hold);
|
||||
}
|
||||
|
||||
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
UpdateBundle(bundleId, tenantId, bundle => bundle with
|
||||
{
|
||||
ExpiresAt = holdExpiresAt,
|
||||
UpdatedAt = processedAt > bundle.UpdatedAt ? processedAt : bundle.UpdatedAt
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
UpdateBundle(bundleId, tenantId, bundle => bundle with
|
||||
{
|
||||
StorageKey = storageKey,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdatePortableStorageKeyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
string storageKey,
|
||||
DateTimeOffset generatedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
UpdateBundle(bundleId, tenantId, bundle => bundle with
|
||||
{
|
||||
PortableStorageKey = storageKey,
|
||||
PortableGeneratedAt = generatedAt,
|
||||
UpdatedAt = generatedAt > bundle.UpdatedAt ? generatedAt : bundle.UpdatedAt
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void UpdateBundle(EvidenceBundleId bundleId, TenantId tenantId, Func<EvidenceBundle, EvidenceBundle> updater)
|
||||
{
|
||||
var key = (bundleId.Value, tenantId.Value);
|
||||
if (_bundles.TryGetValue(key, out var existing))
|
||||
{
|
||||
_bundles[key] = updater(existing);
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable SYSLIB0050
|
||||
private static PostgresException CreateUniqueViolationException()
|
||||
{
|
||||
var exception = (PostgresException)FormatterServices.GetUninitializedObject(typeof(PostgresException));
|
||||
SetStringField(exception, "<SqlState>k__BackingField", PostgresErrorCodes.UniqueViolation);
|
||||
SetStringField(exception, "_sqlState", PostgresErrorCodes.UniqueViolation);
|
||||
return exception;
|
||||
}
|
||||
#pragma warning restore SYSLIB0050
|
||||
|
||||
private static void SetStringField(object target, string fieldName, string value)
|
||||
{
|
||||
var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
field?.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EvidenceLockerTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
internal const string SchemeName = "EvidenceLockerTest";
|
||||
|
||||
public EvidenceLockerTestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var rawHeader) ||
|
||||
!AuthenticationHeaderValue.TryParse(rawHeader, out var header) ||
|
||||
!string.Equals(header.Scheme, SchemeName, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var claims = new List<Claim>();
|
||||
|
||||
var subject = Request.Headers.TryGetValue("X-Test-Subject", out var subjectValue)
|
||||
? subjectValue.ToString()
|
||||
: "subject-test";
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Subject, subject));
|
||||
|
||||
if (Request.Headers.TryGetValue("X-Test-Client", out var clientValue) &&
|
||||
!string.IsNullOrWhiteSpace(clientValue))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.ClientId, clientValue.ToString()!));
|
||||
}
|
||||
|
||||
if (Request.Headers.TryGetValue("X-Test-Tenant", out var tenantValue) &&
|
||||
Guid.TryParse(tenantValue, out var tenantId))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantId.ToString("D")));
|
||||
}
|
||||
|
||||
if (Request.Headers.TryGetValue("X-Test-Scopes", out var scopesValue))
|
||||
{
|
||||
var scopes = scopesValue
|
||||
.ToString()
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Scope, scope));
|
||||
}
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Linq;
|
||||
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class EvidenceLockerWebServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Snapshot_ReturnsSignatureAndEmitsTimeline()
|
||||
{
|
||||
using var factory = new EvidenceLockerWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var payload = new
|
||||
{
|
||||
kind = (int)EvidenceBundleKind.Evaluation,
|
||||
metadata = new Dictionary<string, string>
|
||||
{
|
||||
["run"] = "daily",
|
||||
["orchestratorJobId"] = "job-123"
|
||||
},
|
||||
materials = new[]
|
||||
{
|
||||
new { section = "inputs", path = "config.json", sha256 = new string('a', 64), sizeBytes = 256L, mediaType = "application/json" }
|
||||
}
|
||||
};
|
||||
|
||||
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
|
||||
snapshotResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.NotEqual(Guid.Empty, snapshot!.BundleId);
|
||||
Assert.False(string.IsNullOrEmpty(snapshot.RootHash));
|
||||
Assert.NotNull(snapshot.Signature);
|
||||
Assert.False(string.IsNullOrEmpty(snapshot.Signature!.Signature));
|
||||
Assert.NotNull(snapshot.Signature.TimestampToken);
|
||||
|
||||
var timelineEvent = Assert.Single(factory.TimelinePublisher.PublishedEvents);
|
||||
Assert.Contains(snapshot.BundleId.ToString("D"), timelineEvent);
|
||||
Assert.Contains(snapshot.RootHash, timelineEvent);
|
||||
|
||||
var bundle = await client.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot.BundleId}", TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Equal(snapshot.RootHash, bundle!.RootHash);
|
||||
Assert.NotNull(bundle.Signature);
|
||||
Assert.Equal(snapshot.Signature.Signature, bundle.Signature!.Signature);
|
||||
Assert.Equal(snapshot.Signature.TimestampToken, bundle.Signature.TimestampToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_WithIncidentModeActive_ExtendsRetentionAndCapturesDebugArtifact()
|
||||
{
|
||||
using var baseFactory = new EvidenceLockerWebApplicationFactory();
|
||||
using var factory = baseFactory.WithWebHostBuilder(
|
||||
builder => builder.ConfigureAppConfiguration((_, configurationBuilder) =>
|
||||
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EvidenceLocker:Incident:Enabled"] = "true",
|
||||
["EvidenceLocker:Incident:RetentionExtensionDays"] = "60",
|
||||
["EvidenceLocker:Incident:CaptureRequestSnapshot"] = "true"
|
||||
})));
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var optionsMonitor = factory.Services.GetRequiredService<IOptionsMonitor<EvidenceLockerOptions>>();
|
||||
Assert.True(optionsMonitor.CurrentValue.Incident.Enabled);
|
||||
Assert.Equal(60, optionsMonitor.CurrentValue.Incident.RetentionExtensionDays);
|
||||
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var payload = new
|
||||
{
|
||||
kind = (int)EvidenceBundleKind.Job,
|
||||
metadata = new Dictionary<string, string> { ["run"] = "incident" },
|
||||
materials = new[]
|
||||
{
|
||||
new { section = "inputs", path = "config.json", sha256 = new string('b', 64), sizeBytes = 64L, mediaType = "application/json" }
|
||||
}
|
||||
};
|
||||
|
||||
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
|
||||
snapshotResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(snapshot);
|
||||
|
||||
var bundle = await client.GetFromJsonAsync<EvidenceBundleResponseDto>($"/evidence/{snapshot!.BundleId}", TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
Assert.NotNull(bundle!.ExpiresAt);
|
||||
Assert.True(bundle.ExpiresAt > bundle.CreatedAt);
|
||||
var objectStore = factory.Services.GetRequiredService<TestEvidenceObjectStore>();
|
||||
var timeline = factory.Services.GetRequiredService<TestTimelinePublisher>();
|
||||
Assert.Contains(objectStore.StoredObjects.Keys, key => key.Contains("/incident/request-", StringComparison.Ordinal));
|
||||
Assert.Contains("enabled", timeline.IncidentEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Download_ReturnsPackageStream()
|
||||
{
|
||||
using var factory = new EvidenceLockerWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var payload = new
|
||||
{
|
||||
kind = (int)EvidenceBundleKind.Evaluation,
|
||||
metadata = new Dictionary<string, string> { ["run"] = "nightly" },
|
||||
materials = new[]
|
||||
{
|
||||
new { section = "inputs", path = "config.json", sha256 = new string('a', 64), sizeBytes = 128L, mediaType = "application/json" }
|
||||
}
|
||||
};
|
||||
|
||||
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
|
||||
snapshotResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(snapshot);
|
||||
|
||||
var downloadResponse = await client.GetAsync($"/evidence/{snapshot!.BundleId}/download", TestContext.Current.CancellationToken);
|
||||
downloadResponse.EnsureSuccessStatusCode();
|
||||
Assert.Equal("application/gzip", downloadResponse.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
var archiveBytes = await downloadResponse.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||
var mtime = BinaryPrimitives.ReadInt32LittleEndian(archiveBytes.AsSpan(4, 4));
|
||||
var expectedSeconds = (int)(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) - DateTimeOffset.UnixEpoch).TotalSeconds;
|
||||
Assert.Equal(expectedSeconds, mtime);
|
||||
|
||||
var entries = ReadArchiveEntries(archiveBytes);
|
||||
Assert.Contains("manifest.json", entries.Keys);
|
||||
Assert.Contains("signature.json", entries.Keys);
|
||||
Assert.Contains("instructions.txt", entries.Keys);
|
||||
|
||||
using var manifestDoc = JsonDocument.Parse(entries["manifest.json"]);
|
||||
Assert.Equal(snapshot.BundleId.ToString(), manifestDoc.RootElement.GetProperty("bundleId").GetString());
|
||||
|
||||
var instructions = entries["instructions.txt"];
|
||||
Assert.Contains("Evidence Bundle Instructions", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Validate `signature.json`", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Review `checksums.txt`", instructions, StringComparison.Ordinal);
|
||||
if (instructions.Contains("Timestamped At:", StringComparison.Ordinal))
|
||||
{
|
||||
Assert.Contains("Validate the RFC3161 timestamp token", instructions, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.NotEmpty(factory.ObjectStore.StoredObjects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PortableDownload_ReturnsSanitizedBundle()
|
||||
{
|
||||
using var factory = new EvidenceLockerWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var payload = new
|
||||
{
|
||||
kind = (int)EvidenceBundleKind.Export,
|
||||
metadata = new Dictionary<string, string> { ["pipeline"] = "sealed" },
|
||||
materials = new[]
|
||||
{
|
||||
new { section = "inputs", path = "artifact.txt", sha256 = new string('d', 64), sizeBytes = 256L, mediaType = "text/plain" }
|
||||
}
|
||||
};
|
||||
|
||||
var snapshotResponse = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
|
||||
snapshotResponse.EnsureSuccessStatusCode();
|
||||
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<EvidenceSnapshotResponseDto>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(snapshot);
|
||||
|
||||
var portableResponse = await client.GetAsync($"/evidence/{snapshot!.BundleId}/portable", TestContext.Current.CancellationToken);
|
||||
portableResponse.EnsureSuccessStatusCode();
|
||||
Assert.Equal("application/gzip", portableResponse.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
var archiveBytes = await portableResponse.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||
var entries = ReadArchiveEntries(archiveBytes);
|
||||
Assert.Contains("bundle.json", entries.Keys);
|
||||
Assert.Contains("instructions-portable.txt", entries.Keys);
|
||||
Assert.Contains("verify-offline.sh", entries.Keys);
|
||||
|
||||
using var bundleDoc = JsonDocument.Parse(entries["bundle.json"]);
|
||||
var bundleRoot = bundleDoc.RootElement;
|
||||
Assert.False(bundleRoot.TryGetProperty("tenantId", out _));
|
||||
Assert.False(bundleRoot.TryGetProperty("storageKey", out _));
|
||||
Assert.True(bundleRoot.TryGetProperty("portableGeneratedAt", out _));
|
||||
|
||||
var script = entries["verify-offline.sh"];
|
||||
Assert.StartsWith("#!/usr/bin/env sh", script, StringComparison.Ordinal);
|
||||
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
|
||||
Assert.Contains("stella evidence verify", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_ReturnsValidationError_WhenQuotaExceeded()
|
||||
{
|
||||
using var factory = new EvidenceLockerWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var payload = new
|
||||
{
|
||||
kind = (int)EvidenceBundleKind.Job,
|
||||
materials = new[]
|
||||
{
|
||||
new { section = "inputs", path = "layer0.tar", sha256 = new string('a', 64), sizeBytes = 900L },
|
||||
new { section = "inputs", path = "layer1.tar", sha256 = new string('b', 64), sizeBytes = 300L }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but received {(int)response.StatusCode}: {responseContent}");
|
||||
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(problem);
|
||||
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
|
||||
Assert.Contains(messages, m => m.Contains("exceeds", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_ReturnsForbidden_WhenTenantMissing()
|
||||
{
|
||||
using var factory = new EvidenceLockerWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(EvidenceLockerTestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var payload = new
|
||||
{
|
||||
kind = (int)EvidenceBundleKind.Evaluation,
|
||||
materials = new[]
|
||||
{
|
||||
new { section = "inputs", path = "input.txt", sha256 = "abc123", sizeBytes = 1L }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/evidence/snapshot", payload, TestContext.Current.CancellationToken);
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.Forbidden, $"Expected 403 but received {(int)response.StatusCode}: {responseContent}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hold_ReturnsConflict_WhenCaseAlreadyExists()
|
||||
{
|
||||
using var factory = new EvidenceLockerWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var repository = factory.Repository;
|
||||
repository.HoldConflict = true;
|
||||
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceHold} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/evidence/hold/case-123",
|
||||
new
|
||||
{
|
||||
reason = "legal-hold"
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but received {(int)response.StatusCode}: {responseContent}");
|
||||
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(problem);
|
||||
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
|
||||
Assert.Contains(messages, m => m.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hold_CreatesTimelineEvent()
|
||||
{
|
||||
using var factory = new EvidenceLockerWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(client, tenantId, scopes: $"{StellaOpsScopes.EvidenceHold} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/evidence/hold/case-789",
|
||||
new
|
||||
{
|
||||
reason = "retention",
|
||||
notes = "retain for investigation"
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var hold = await response.Content.ReadFromJsonAsync<EvidenceHoldResponseDto>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(hold);
|
||||
Assert.Contains($"hold:{hold!.CaseId}", factory.TimelinePublisher.PublishedEvents);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadArchiveEntries(byte[] archiveBytes)
|
||||
{
|
||||
using var memory = new MemoryStream(archiveBytes);
|
||||
using var gzip = new GZipStream(memory, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var reader = new TarReader(gzip);
|
||||
|
||||
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
TarEntry? entry;
|
||||
while ((entry = reader.GetNextEntry()) is not null)
|
||||
{
|
||||
if (entry.EntryType != TarEntryType.RegularFile)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var entryStream = new MemoryStream();
|
||||
entry.DataStream!.CopyTo(entryStream);
|
||||
var content = Encoding.UTF8.GetString(entryStream.ToArray());
|
||||
entries[entry.Name] = content;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void ConfigureAuthHeaders(HttpClient client, string tenantId, string scopes)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(EvidenceLockerTestAuthHandler.SchemeName);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", scopes);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class EvidencePortableBundleServiceTests
|
||||
{
|
||||
private static readonly TenantId TenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
private static readonly EvidenceBundleId BundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
private static readonly DateTimeOffset CreatedAt = new(2025, 11, 4, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePortablePackageAsync_ReturnsCached_WhenObjectExists()
|
||||
{
|
||||
var bundle = CreateSealedBundle(
|
||||
portableStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz",
|
||||
portableGeneratedAt: CreatedAt);
|
||||
var repository = new FakeRepository(bundle, CreateSignature());
|
||||
var objectStore = new FakeObjectStore(exists: true);
|
||||
var service = CreateService(repository, objectStore);
|
||||
|
||||
var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Created);
|
||||
Assert.Equal(bundle.RootHash, result.RootHash);
|
||||
Assert.Equal(bundle.PortableStorageKey, result.StorageKey);
|
||||
Assert.False(objectStore.Stored);
|
||||
Assert.False(repository.PortableStorageKeyUpdated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePortablePackageAsync_CreatesPortableArchiveWithRedactedMetadata()
|
||||
{
|
||||
var repository = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true));
|
||||
var objectStore = new FakeObjectStore(exists: false, fixedStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz");
|
||||
var service = CreateService(repository, objectStore);
|
||||
|
||||
var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Created);
|
||||
Assert.True(objectStore.Stored);
|
||||
Assert.NotNull(objectStore.StoredBytes);
|
||||
Assert.True(repository.PortableStorageKeyUpdated);
|
||||
Assert.NotNull(repository.Bundle.PortableStorageKey);
|
||||
Assert.NotNull(repository.Bundle.PortableGeneratedAt);
|
||||
|
||||
var entries = ReadArchiveEntries(objectStore.StoredBytes!);
|
||||
Assert.Contains("manifest.json", entries.Keys);
|
||||
Assert.Contains("signature.json", entries.Keys);
|
||||
Assert.Contains("bundle.json", entries.Keys);
|
||||
Assert.Contains("instructions-portable.txt", entries.Keys);
|
||||
Assert.Contains("verify-offline.sh", entries.Keys);
|
||||
|
||||
using var bundleJson = JsonDocument.Parse(entries["bundle.json"]);
|
||||
var root = bundleJson.RootElement;
|
||||
Assert.False(root.TryGetProperty("tenantId", out _));
|
||||
Assert.False(root.TryGetProperty("storageKey", out _));
|
||||
Assert.False(root.TryGetProperty("description", out _));
|
||||
Assert.Equal(repository.Bundle.Id.Value.ToString("D"), root.GetProperty("bundleId").GetString());
|
||||
Assert.Equal(repository.Bundle.RootHash, root.GetProperty("rootHash").GetString());
|
||||
Assert.True(root.TryGetProperty("portableGeneratedAt", out var generatedAtProperty));
|
||||
Assert.True(DateTimeOffset.TryParse(generatedAtProperty.GetString(), out _));
|
||||
|
||||
var incidentMetadata = root.GetProperty("incidentMetadata");
|
||||
Assert.Equal(JsonValueKind.Object, incidentMetadata.ValueKind);
|
||||
Assert.True(incidentMetadata.EnumerateObject().Any(p => p.Name.StartsWith("incident.", StringComparison.Ordinal)));
|
||||
|
||||
var instructions = entries["instructions-portable.txt"];
|
||||
Assert.Contains("Portable Evidence Bundle Instructions", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("verify-offline.sh", instructions, StringComparison.Ordinal);
|
||||
|
||||
var script = entries["verify-offline.sh"];
|
||||
Assert.StartsWith("#!/usr/bin/env sh", script, StringComparison.Ordinal);
|
||||
Assert.Contains("sha256sum", script, StringComparison.Ordinal);
|
||||
Assert.Contains("stella evidence verify", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsurePortablePackageAsync_Throws_WhenSignatureMissing()
|
||||
{
|
||||
var repository = new FakeRepository(CreateSealedBundle(), signature: null);
|
||||
var objectStore = new FakeObjectStore(exists: false);
|
||||
var service = CreateService(repository, objectStore);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None));
|
||||
}
|
||||
|
||||
private static EvidencePortableBundleService CreateService(FakeRepository repository, IEvidenceObjectStore objectStore)
|
||||
{
|
||||
var options = Options.Create(new EvidenceLockerOptions
|
||||
{
|
||||
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
|
||||
ObjectStore = new ObjectStoreOptions { Kind = ObjectStoreKind.FileSystem, FileSystem = new FileSystemStoreOptions { RootPath = "." } },
|
||||
Quotas = new QuotaOptions(),
|
||||
Signing = new SigningOptions(),
|
||||
Portable = new PortableOptions()
|
||||
});
|
||||
|
||||
return new EvidencePortableBundleService(
|
||||
repository,
|
||||
objectStore,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<EvidencePortableBundleService>.Instance);
|
||||
}
|
||||
|
||||
private static EvidenceBundle CreateSealedBundle(
|
||||
string? portableStorageKey = null,
|
||||
DateTimeOffset? portableGeneratedAt = null)
|
||||
=> new EvidenceBundle(
|
||||
BundleId,
|
||||
TenantId,
|
||||
EvidenceBundleKind.Evaluation,
|
||||
EvidenceBundleStatus.Sealed,
|
||||
new string('f', 64),
|
||||
"tenants/foo/bundles/bar/bundle.tgz",
|
||||
CreatedAt,
|
||||
CreatedAt,
|
||||
Description: "sensitive",
|
||||
SealedAt: CreatedAt.AddMinutes(5),
|
||||
ExpiresAt: CreatedAt.AddDays(30),
|
||||
PortableStorageKey: portableStorageKey,
|
||||
PortableGeneratedAt: portableGeneratedAt);
|
||||
|
||||
private static EvidenceBundleSignature CreateSignature(bool includeTimestamp = false)
|
||||
{
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = BundleId.Value,
|
||||
tenantId = TenantId.Value,
|
||||
kind = (int)EvidenceBundleKind.Evaluation,
|
||||
createdAt = CreatedAt,
|
||||
metadata = new Dictionary<string, string>
|
||||
{
|
||||
["pipeline"] = "ops",
|
||||
["incident.mode"] = "enabled",
|
||||
["incident.changedAt"] = CreatedAt.ToString("O"),
|
||||
["incident.retentionExtensionDays"] = "60"
|
||||
},
|
||||
entries = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
section = "inputs",
|
||||
canonicalPath = "inputs/config.json",
|
||||
sha256 = new string('a', 64),
|
||||
sizeBytes = 128L,
|
||||
mediaType = "application/json",
|
||||
attributes = new Dictionary<string, string>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var payload = JsonSerializer.Serialize(manifest);
|
||||
|
||||
return new EvidenceBundleSignature(
|
||||
BundleId,
|
||||
TenantId,
|
||||
"application/vnd.stella.evidence.manifest+json",
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
|
||||
"sig-payload",
|
||||
"key-id",
|
||||
"ES256",
|
||||
"provider",
|
||||
CreatedAt,
|
||||
includeTimestamp ? CreatedAt.AddMinutes(1) : null,
|
||||
includeTimestamp ? "tsa.default" : null,
|
||||
includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ReadArchiveEntries(byte[] archive)
|
||||
{
|
||||
using var memory = new MemoryStream(archive);
|
||||
using var gzip = new GZipStream(memory, CompressionMode.Decompress);
|
||||
using var tarReader = new TarReader(gzip);
|
||||
|
||||
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
TarEntry? entry;
|
||||
while ((entry = tarReader.GetNextEntry()) is not null)
|
||||
{
|
||||
if (entry.EntryType != TarEntryType.RegularFile)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var entryStream = new MemoryStream();
|
||||
entry.DataStream!.CopyTo(entryStream);
|
||||
entries[entry.Name] = Encoding.UTF8.GetString(entryStream.ToArray());
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private sealed class FakeRepository : IEvidenceBundleRepository
|
||||
{
|
||||
private EvidenceBundle _bundle;
|
||||
|
||||
public FakeRepository(EvidenceBundle bundle, EvidenceBundleSignature? signature)
|
||||
{
|
||||
_bundle = bundle;
|
||||
Signature = signature;
|
||||
}
|
||||
|
||||
public EvidenceBundle Bundle => _bundle;
|
||||
public EvidenceBundleSignature? Signature { get; }
|
||||
public bool PortableStorageKeyUpdated { get; private set; }
|
||||
|
||||
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(hold);
|
||||
|
||||
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
PortableStorageKeyUpdated = true;
|
||||
_bundle = _bundle with
|
||||
{
|
||||
PortableStorageKey = storageKey,
|
||||
PortableGeneratedAt = generatedAt
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeObjectStore : IEvidenceObjectStore
|
||||
{
|
||||
private readonly bool _exists;
|
||||
private readonly string? _fixedStorageKey;
|
||||
|
||||
public FakeObjectStore(bool exists, string? fixedStorageKey = null)
|
||||
{
|
||||
_exists = exists;
|
||||
_fixedStorageKey = fixedStorageKey;
|
||||
}
|
||||
|
||||
public bool Stored { get; private set; }
|
||||
public byte[]? StoredBytes { get; private set; }
|
||||
|
||||
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
Stored = true;
|
||||
using var memory = new MemoryStream();
|
||||
content.CopyTo(memory);
|
||||
StoredBytes = memory.ToArray();
|
||||
|
||||
var storageKey = _fixedStorageKey ?? $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/portable-bundle-v1.tgz";
|
||||
|
||||
return Task.FromResult(new EvidenceObjectMetadata(
|
||||
storageKey,
|
||||
options.ContentType,
|
||||
StoredBytes.Length,
|
||||
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(StoredBytes)).ToLowerInvariant(),
|
||||
ETag: null,
|
||||
CreatedAt: DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<Stream>(new MemoryStream(StoredBytes ?? Array.Empty<byte>()));
|
||||
|
||||
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_exists);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Signing;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class EvidenceSignatureServiceTests
|
||||
{
|
||||
private static readonly SigningKeyMaterialOptions TestKeyMaterial = CreateKeyMaterial();
|
||||
|
||||
[Fact]
|
||||
public async Task SignManifestAsync_SignsManifestWithoutTimestamp_WhenTimestampingDisabled()
|
||||
{
|
||||
var timestampClient = new FakeTimestampAuthorityClient();
|
||||
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
|
||||
var service = CreateService(timestampClient, timeProvider);
|
||||
|
||||
var manifest = CreateManifest();
|
||||
var signature = await service.SignManifestAsync(
|
||||
manifest.BundleId,
|
||||
manifest.TenantId,
|
||||
manifest,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(signature);
|
||||
Assert.Equal("application/vnd.stella.test+json", signature.PayloadType);
|
||||
Assert.NotNull(signature.Payload);
|
||||
Assert.NotEmpty(signature.Signature);
|
||||
Assert.Null(signature.TimestampedAt);
|
||||
Assert.Null(signature.TimestampAuthority);
|
||||
Assert.Null(signature.TimestampToken);
|
||||
Assert.Equal(0, timestampClient.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignManifestAsync_AttachesTimestamp_WhenAuthorityClientSucceeds()
|
||||
{
|
||||
var timestampClient = new FakeTimestampAuthorityClient
|
||||
{
|
||||
Result = new TimestampResult(
|
||||
new DateTimeOffset(2025, 11, 3, 10, 0, 5, TimeSpan.Zero),
|
||||
"CN=Test TSA",
|
||||
new byte[] { 1, 2, 3 })
|
||||
};
|
||||
|
||||
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 3, 10, 0, 0, TimeSpan.Zero));
|
||||
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = "https://tsa.example",
|
||||
HashAlgorithm = "SHA256"
|
||||
});
|
||||
|
||||
var service = CreateService(timestampClient, timeProvider, signingOptions);
|
||||
var manifest = CreateManifest();
|
||||
|
||||
var signature = await service.SignManifestAsync(
|
||||
manifest.BundleId,
|
||||
manifest.TenantId,
|
||||
manifest,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(signature);
|
||||
Assert.Equal(timestampClient.Result!.Timestamp, signature.TimestampedAt);
|
||||
Assert.Equal(timestampClient.Result.Authority, signature.TimestampAuthority);
|
||||
Assert.Equal(timestampClient.Result.Token, signature.TimestampToken);
|
||||
Assert.Equal(1, timestampClient.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignManifestAsync_Throws_WhenTimestampRequiredAndClientFails()
|
||||
{
|
||||
var timestampClient = new FakeTimestampAuthorityClient
|
||||
{
|
||||
Exception = new InvalidOperationException("TSA offline")
|
||||
};
|
||||
|
||||
var signingOptions = CreateSigningOptions(timestamping: new TimestampingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = "https://tsa.example",
|
||||
HashAlgorithm = "SHA256",
|
||||
RequireTimestamp = true
|
||||
});
|
||||
|
||||
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow), signingOptions);
|
||||
var manifest = CreateManifest();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.SignManifestAsync(
|
||||
manifest.BundleId,
|
||||
manifest.TenantId,
|
||||
manifest,
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignManifestAsync_ProducesDeterministicPayload()
|
||||
{
|
||||
var timestampClient = new FakeTimestampAuthorityClient();
|
||||
var service = CreateService(timestampClient, new TestTimeProvider(DateTimeOffset.UtcNow));
|
||||
|
||||
var sharedBundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var sharedTenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
|
||||
var manifestA = CreateManifest(
|
||||
metadataOrder: new[] { ("zeta", "1"), ("alpha", "2") },
|
||||
bundleId: sharedBundleId,
|
||||
tenantId: sharedTenantId);
|
||||
var manifestB = CreateManifest(
|
||||
metadataOrder: new[] { ("alpha", "2"), ("zeta", "1") },
|
||||
bundleId: sharedBundleId,
|
||||
tenantId: sharedTenantId);
|
||||
|
||||
var signatureA = await service.SignManifestAsync(
|
||||
manifestA.BundleId,
|
||||
manifestA.TenantId,
|
||||
manifestA,
|
||||
CancellationToken.None);
|
||||
|
||||
var signatureB = await service.SignManifestAsync(
|
||||
manifestB.BundleId,
|
||||
manifestB.TenantId,
|
||||
manifestB,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(signatureA);
|
||||
Assert.NotNull(signatureB);
|
||||
|
||||
var payloadA = Encoding.UTF8.GetString(Convert.FromBase64String(signatureA!.Payload));
|
||||
var payloadB = Encoding.UTF8.GetString(Convert.FromBase64String(signatureB!.Payload));
|
||||
Assert.Equal(payloadA, payloadB);
|
||||
|
||||
using var document = JsonDocument.Parse(payloadA);
|
||||
var metadataElement = document.RootElement.GetProperty("metadata");
|
||||
using var enumerator = metadataElement.EnumerateObject();
|
||||
Assert.True(enumerator.MoveNext());
|
||||
Assert.Equal("alpha", enumerator.Current.Name);
|
||||
Assert.True(enumerator.MoveNext());
|
||||
Assert.Equal("zeta", enumerator.Current.Name);
|
||||
}
|
||||
|
||||
private static EvidenceSignatureService CreateService(
|
||||
ITimestampAuthorityClient timestampAuthorityClient,
|
||||
TimeProvider timeProvider,
|
||||
SigningOptions? signingOptions = null)
|
||||
{
|
||||
var registry = new CryptoProviderRegistry(new ICryptoProvider[] { new DefaultCryptoProvider() });
|
||||
|
||||
var options = Options.Create(new EvidenceLockerOptions
|
||||
{
|
||||
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
|
||||
ObjectStore = new ObjectStoreOptions
|
||||
{
|
||||
Kind = ObjectStoreKind.FileSystem,
|
||||
FileSystem = new FileSystemStoreOptions { RootPath = "." }
|
||||
},
|
||||
Quotas = new QuotaOptions(),
|
||||
Signing = signingOptions ?? CreateSigningOptions()
|
||||
});
|
||||
|
||||
return new EvidenceSignatureService(
|
||||
registry,
|
||||
timestampAuthorityClient,
|
||||
options,
|
||||
timeProvider,
|
||||
NullLogger<EvidenceSignatureService>.Instance);
|
||||
}
|
||||
|
||||
private static SigningOptions CreateSigningOptions(TimestampingOptions? timestamping = null)
|
||||
=> new()
|
||||
{
|
||||
Enabled = true,
|
||||
Algorithm = SignatureAlgorithms.Es256,
|
||||
KeyId = "test-key",
|
||||
PayloadType = "application/vnd.stella.test+json",
|
||||
KeyMaterial = TestKeyMaterial,
|
||||
Timestamping = timestamping
|
||||
};
|
||||
|
||||
private static SigningKeyMaterialOptions CreateKeyMaterial()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var privatePem = ecdsa.ExportECPrivateKeyPem();
|
||||
var publicPem = ecdsa.ExportSubjectPublicKeyInfoPem();
|
||||
return new SigningKeyMaterialOptions
|
||||
{
|
||||
EcPrivateKeyPem = privatePem,
|
||||
EcPublicKeyPem = publicPem
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceBundleManifest CreateManifest(
|
||||
(string key, string value)[]? metadataOrder = null,
|
||||
EvidenceBundleId? bundleId = null,
|
||||
TenantId? tenantId = null)
|
||||
{
|
||||
metadataOrder ??= new[] { ("alpha", "1"), ("beta", "2") };
|
||||
var metadataDictionary = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in metadataOrder)
|
||||
{
|
||||
metadataDictionary[key] = value;
|
||||
}
|
||||
|
||||
var metadata = new ReadOnlyDictionary<string, string>(metadataDictionary);
|
||||
|
||||
var attributesDictionary = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["scope"] = "inputs",
|
||||
["priority"] = "high"
|
||||
};
|
||||
var attributes = new ReadOnlyDictionary<string, string>(attributesDictionary);
|
||||
|
||||
var manifestEntry = new EvidenceManifestEntry(
|
||||
"inputs",
|
||||
"inputs/config.json",
|
||||
new string('a', 64),
|
||||
128,
|
||||
"application/json",
|
||||
attributes);
|
||||
|
||||
return new EvidenceBundleManifest(
|
||||
bundleId ?? EvidenceBundleId.FromGuid(Guid.NewGuid()),
|
||||
tenantId ?? TenantId.FromGuid(Guid.NewGuid()),
|
||||
EvidenceBundleKind.Evaluation,
|
||||
new DateTimeOffset(2025, 11, 3, 9, 30, 0, TimeSpan.Zero),
|
||||
metadata,
|
||||
new List<EvidenceManifestEntry> { manifestEntry });
|
||||
}
|
||||
|
||||
private sealed class FakeTimestampAuthorityClient : ITimestampAuthorityClient
|
||||
{
|
||||
public TimestampResult? Result { get; set; }
|
||||
public Exception? Exception { get; set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<TimestampResult?> RequestTimestampAsync(
|
||||
ReadOnlyMemory<byte> signature,
|
||||
string hashAlgorithm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
if (Exception is not null)
|
||||
{
|
||||
throw Exception;
|
||||
}
|
||||
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider(DateTimeOffset fixedUtcNow) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => fixedUtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Cryptography;
|
||||
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.Storage;
|
||||
using StellaOps.EvidenceLocker.Core.Timeline;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class EvidenceSnapshotServiceTests
|
||||
{
|
||||
private static readonly string ValidSha256 = Sha('a');
|
||||
private static readonly string DefaultRootHash = Sha('f');
|
||||
|
||||
private readonly FakeRepository _repository = new();
|
||||
private readonly FakeBuilder _builder = new();
|
||||
private readonly FakeSignatureService _signatureService = new();
|
||||
private readonly FakeTimelinePublisher _timelinePublisher = new();
|
||||
private readonly TestIncidentState _incidentState = new();
|
||||
private readonly TestObjectStore _objectStore = new();
|
||||
private readonly EvidenceSnapshotService _service;
|
||||
|
||||
public EvidenceSnapshotServiceTests()
|
||||
{
|
||||
var options = Options.Create(new EvidenceLockerOptions
|
||||
{
|
||||
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
|
||||
ObjectStore = new ObjectStoreOptions
|
||||
{
|
||||
Kind = ObjectStoreKind.FileSystem,
|
||||
FileSystem = new FileSystemStoreOptions { RootPath = "." }
|
||||
},
|
||||
Quotas = new QuotaOptions
|
||||
{
|
||||
MaxMaterialCount = 4,
|
||||
MaxTotalMaterialSizeBytes = 1_024,
|
||||
MaxMetadataEntries = 4,
|
||||
MaxMetadataKeyLength = 32,
|
||||
MaxMetadataValueLength = 64
|
||||
},
|
||||
Signing = new SigningOptions
|
||||
{
|
||||
Enabled = false,
|
||||
Algorithm = SignatureAlgorithms.Es256,
|
||||
KeyId = "test-key"
|
||||
},
|
||||
Incident = new IncidentModeOptions
|
||||
{
|
||||
Enabled = false,
|
||||
RetentionExtensionDays = 30,
|
||||
CaptureRequestSnapshot = true
|
||||
}
|
||||
});
|
||||
|
||||
_service = new EvidenceSnapshotService(
|
||||
_repository,
|
||||
_builder,
|
||||
_signatureService,
|
||||
_timelinePublisher,
|
||||
_incidentState,
|
||||
_objectStore,
|
||||
TimeProvider.System,
|
||||
options,
|
||||
NullLogger<EvidenceSnapshotService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshotAsync_PersistsBundleAndBuildsManifest()
|
||||
{
|
||||
var request = new EvidenceSnapshotRequest
|
||||
{
|
||||
Kind = EvidenceBundleKind.Evaluation,
|
||||
Metadata = new Dictionary<string, string> { ["run"] = "alpha" },
|
||||
Materials = new List<EvidenceSnapshotMaterial>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Section = "inputs",
|
||||
Path = "config.json",
|
||||
Sha256 = ValidSha256,
|
||||
SizeBytes = 128,
|
||||
MediaType = "application/json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None);
|
||||
|
||||
Assert.NotEqual(Guid.Empty, result.BundleId);
|
||||
Assert.Equal(_builder.RootHash, result.RootHash);
|
||||
Assert.Equal(tenantId, _repository.LastCreateTenant);
|
||||
Assert.Equal(EvidenceBundleStatus.Pending, _repository.LastCreatedStatus);
|
||||
Assert.Equal(EvidenceBundleStatus.Assembling, _repository.AssemblyStatus);
|
||||
Assert.Equal(_builder.RootHash, _repository.AssemblyRootHash);
|
||||
Assert.True(_builder.Invoked);
|
||||
Assert.Null(result.Signature);
|
||||
Assert.False(_timelinePublisher.BundleSealedPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshotAsync_StoresSignature_WhenSignerReturnsEnvelope()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
|
||||
_signatureService.NextSignature = new EvidenceBundleSignature(
|
||||
bundleId,
|
||||
tenantId,
|
||||
"application/vnd.test",
|
||||
"payload",
|
||||
Convert.ToBase64String(new byte[] { 1, 2, 3 }),
|
||||
"key-1",
|
||||
SignatureAlgorithms.Es256,
|
||||
"default",
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
_builder.OverrideBundleId = bundleId;
|
||||
|
||||
var request = new EvidenceSnapshotRequest
|
||||
{
|
||||
Kind = EvidenceBundleKind.Evaluation,
|
||||
Materials = new List<EvidenceSnapshotMaterial>
|
||||
{
|
||||
new() { Section = "inputs", Path = "config.json", Sha256 = ValidSha256, SizeBytes = 128 }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result.Signature);
|
||||
Assert.True(_repository.SignatureUpserted);
|
||||
Assert.True(_timelinePublisher.BundleSealedPublished);
|
||||
Assert.Equal(_repository.LastCreatedBundleId?.Value ?? Guid.Empty, result.BundleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshotAsync_ThrowsWhenMaterialQuotaExceeded()
|
||||
{
|
||||
var request = new EvidenceSnapshotRequest
|
||||
{
|
||||
Kind = EvidenceBundleKind.Job,
|
||||
Materials = new List<EvidenceSnapshotMaterial>
|
||||
{
|
||||
new() { Section = "a", Path = "1", Sha256 = Sha('a'), SizeBytes = 900 },
|
||||
new() { Section = "b", Path = "2", Sha256 = Sha('b'), SizeBytes = 300 }
|
||||
}
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.CreateSnapshotAsync(TenantId.FromGuid(Guid.NewGuid()), request, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateHoldAsync_ReturnsHoldWhenValid()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
_repository.NextExistsResult = true;
|
||||
|
||||
var holdRequest = new EvidenceHoldRequest
|
||||
{
|
||||
BundleId = Guid.NewGuid(),
|
||||
Reason = "legal",
|
||||
Notes = "note"
|
||||
};
|
||||
|
||||
var hold = await _service.CreateHoldAsync(tenantId, "case-123", holdRequest, CancellationToken.None);
|
||||
Assert.Equal("case-123", hold.CaseId);
|
||||
Assert.True(_repository.RetentionExtended);
|
||||
Assert.Equal(holdRequest.BundleId, _repository.RetentionBundleId?.Value);
|
||||
Assert.Null(_repository.RetentionExpiresAt);
|
||||
Assert.NotNull(_repository.RetentionProcessedAt);
|
||||
Assert.Equal(tenantId, _repository.RetentionTenant);
|
||||
Assert.True(_timelinePublisher.HoldPublished);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateHoldAsyncThrowsWhenBundleMissing()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
_repository.NextExistsResult = false;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.CreateHoldAsync(tenantId, "case-999", new EvidenceHoldRequest
|
||||
{
|
||||
BundleId = Guid.NewGuid(),
|
||||
Reason = "legal"
|
||||
}, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateHoldAsyncThrowsWhenCaseAlreadyExists()
|
||||
{
|
||||
_repository.ThrowUniqueViolationForHolds = true;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.CreateHoldAsync(
|
||||
TenantId.FromGuid(Guid.NewGuid()),
|
||||
"case-dup",
|
||||
new EvidenceHoldRequest { Reason = "legal" },
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshotAsync_ExtendsRetentionAndCapturesIncidentArtifacts_WhenIncidentModeActive()
|
||||
{
|
||||
_incidentState.SetState(true, retentionExtensionDays: 45, captureSnapshot: true);
|
||||
Assert.True(_incidentState.Current.IsActive);
|
||||
Assert.Equal(45, _incidentState.Current.RetentionExtensionDays);
|
||||
|
||||
var request = new EvidenceSnapshotRequest
|
||||
{
|
||||
Kind = EvidenceBundleKind.Job,
|
||||
Metadata = new Dictionary<string, string> { ["run"] = "diagnostic" },
|
||||
Materials = new List<EvidenceSnapshotMaterial>
|
||||
{
|
||||
new() { Section = "inputs", Path = "input.txt", Sha256 = ValidSha256, SizeBytes = 32 }
|
||||
}
|
||||
};
|
||||
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(_repository.CreatedBundle);
|
||||
Assert.True(_repository.CreatedBundle!.ExpiresAt.HasValue);
|
||||
Assert.NotNull(_repository.LastCreatedExpiresAt);
|
||||
Assert.NotNull(_repository.LastCreatedAt);
|
||||
Assert.Equal(
|
||||
_repository.LastCreatedAt!.Value.AddDays(45),
|
||||
_repository.LastCreatedExpiresAt!.Value,
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.NotEmpty(_objectStore.StoredArtifacts);
|
||||
var incidentEntry = result.Manifest.Entries.Single(e => e.Section == "incident");
|
||||
Assert.True(result.Manifest.Metadata.ContainsKey("incident.mode"));
|
||||
Assert.Equal("enabled", result.Manifest.Metadata["incident.mode"]);
|
||||
Assert.StartsWith("incident/request-", incidentEntry.CanonicalPath, StringComparison.Ordinal);
|
||||
Assert.Equal("application/json", incidentEntry.MediaType);
|
||||
}
|
||||
|
||||
private static string Sha(char fill) => new string(fill, 64);
|
||||
private sealed class FakeRepository : IEvidenceBundleRepository
|
||||
{
|
||||
public TenantId? LastCreateTenant { get; private set; }
|
||||
public EvidenceBundleStatus? LastCreatedStatus { get; private set; }
|
||||
public EvidenceBundleId? LastCreatedBundleId { get; private set; }
|
||||
public bool NextExistsResult { get; set; } = true;
|
||||
public bool ThrowUniqueViolationForHolds { get; set; }
|
||||
public bool SignatureUpserted { get; private set; }
|
||||
public bool RetentionExtended { get; private set; }
|
||||
public EvidenceBundleId? RetentionBundleId { get; private set; }
|
||||
public TenantId? RetentionTenant { get; private set; }
|
||||
public DateTimeOffset? RetentionExpiresAt { get; private set; }
|
||||
public DateTimeOffset? RetentionProcessedAt { get; private set; }
|
||||
public string? AssemblyRootHash { get; private set; }
|
||||
public EvidenceBundleStatus? AssemblyStatus { get; private set; }
|
||||
public DateTimeOffset? LastCreatedExpiresAt { get; private set; }
|
||||
public DateTimeOffset? LastCreatedAt { get; private set; }
|
||||
public EvidenceBundle? CreatedBundle { get; private set; }
|
||||
|
||||
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||
{
|
||||
LastCreateTenant = bundle.TenantId;
|
||||
LastCreatedStatus = bundle.Status;
|
||||
LastCreatedBundleId = bundle.Id;
|
||||
LastCreatedExpiresAt = bundle.ExpiresAt;
|
||||
LastCreatedAt = bundle.CreatedAt;
|
||||
CreatedBundle = bundle;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
AssemblyStatus = status;
|
||||
AssemblyRootHash = rootHash;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
|
||||
{
|
||||
SignatureUpserted = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<EvidenceBundleDetails?>(null);
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(NextExistsResult);
|
||||
|
||||
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowUniqueViolationForHolds)
|
||||
{
|
||||
throw CreateUniqueViolationException();
|
||||
}
|
||||
|
||||
return Task.FromResult(hold);
|
||||
}
|
||||
|
||||
public Task ExtendBundleRetentionAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? holdExpiresAt,
|
||||
DateTimeOffset processedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RetentionExtended = true;
|
||||
RetentionBundleId = bundleId;
|
||||
RetentionTenant = tenantId;
|
||||
RetentionExpiresAt = holdExpiresAt;
|
||||
RetentionProcessedAt = processedAt;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateStorageKeyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
string storageKey,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpdatePortableStorageKeyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
string storageKey,
|
||||
DateTimeOffset generatedAt,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
#pragma warning disable SYSLIB0050
|
||||
private static PostgresException CreateUniqueViolationException()
|
||||
{
|
||||
var exception = (PostgresException)FormatterServices.GetUninitializedObject(typeof(PostgresException));
|
||||
SetStringField(exception, "<SqlState>k__BackingField", PostgresErrorCodes.UniqueViolation);
|
||||
SetStringField(exception, "_sqlState", PostgresErrorCodes.UniqueViolation);
|
||||
return exception;
|
||||
}
|
||||
#pragma warning restore SYSLIB0050
|
||||
|
||||
private static void SetStringField(object target, string fieldName, string value)
|
||||
{
|
||||
var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
field?.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeBuilder : IEvidenceBundleBuilder
|
||||
{
|
||||
public string RootHash { get; } = DefaultRootHash;
|
||||
public bool Invoked { get; private set; }
|
||||
public EvidenceBundleId? OverrideBundleId { get; set; }
|
||||
|
||||
public Task<EvidenceBundleBuildResult> BuildAsync(EvidenceBundleBuildRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Invoked = true;
|
||||
|
||||
var effectiveBundleId = OverrideBundleId ?? request.BundleId;
|
||||
|
||||
var manifest = new EvidenceBundleManifest(
|
||||
effectiveBundleId,
|
||||
request.TenantId,
|
||||
request.Kind,
|
||||
request.CreatedAt,
|
||||
request.Metadata,
|
||||
request.Materials.Select(m => new EvidenceManifestEntry(
|
||||
m.Section,
|
||||
$"{m.Section}/{m.Path}",
|
||||
m.Sha256,
|
||||
m.SizeBytes,
|
||||
m.MediaType,
|
||||
m.Attributes ?? new Dictionary<string, string>())).ToList());
|
||||
|
||||
return Task.FromResult(new EvidenceBundleBuildResult(RootHash, manifest));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSignatureService : IEvidenceSignatureService
|
||||
{
|
||||
public EvidenceBundleSignature? NextSignature { get; set; }
|
||||
|
||||
public Task<EvidenceBundleSignature?> SignManifestAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleManifest manifest, CancellationToken cancellationToken)
|
||||
{
|
||||
if (NextSignature is null)
|
||||
{
|
||||
return Task.FromResult<EvidenceBundleSignature?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<EvidenceBundleSignature?>(NextSignature with { BundleId = bundleId, TenantId = tenantId });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTimelinePublisher : IEvidenceTimelinePublisher
|
||||
{
|
||||
public bool BundleSealedPublished { get; private set; }
|
||||
public bool HoldPublished { get; private set; }
|
||||
|
||||
public string? LastBundleRoot { get; private set; }
|
||||
public List<string> IncidentEvents { get; } = new();
|
||||
|
||||
public Task PublishBundleSealedAsync(
|
||||
EvidenceBundleSignature signature,
|
||||
EvidenceBundleManifest manifest,
|
||||
string rootHash,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
BundleSealedPublished = true;
|
||||
LastBundleRoot = rootHash;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
{
|
||||
HoldPublished = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken)
|
||||
{
|
||||
IncidentEvents.Add(change.IsActive ? "enabled" : "disabled");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestIncidentState : IIncidentModeState
|
||||
{
|
||||
private IncidentModeSnapshot _snapshot = new(false, DateTimeOffset.UtcNow, 0, false);
|
||||
|
||||
public IncidentModeSnapshot Current => _snapshot;
|
||||
|
||||
public bool IsActive => _snapshot.IsActive;
|
||||
|
||||
public void SetState(bool isActive, int retentionExtensionDays, bool captureSnapshot)
|
||||
{
|
||||
_snapshot = new IncidentModeSnapshot(
|
||||
isActive,
|
||||
DateTimeOffset.UtcNow,
|
||||
retentionExtensionDays,
|
||||
captureSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestObjectStore : IEvidenceObjectStore
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
public List<EvidenceObjectMetadata> StoredArtifacts { get; } = new();
|
||||
|
||||
public Task<EvidenceObjectMetadata> StoreAsync(
|
||||
Stream content,
|
||||
EvidenceObjectWriteOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var memory = new MemoryStream();
|
||||
content.CopyTo(memory);
|
||||
var bytes = memory.ToArray();
|
||||
|
||||
var storageKey = $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/{options.ArtifactName}";
|
||||
var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
var metadata = new EvidenceObjectMetadata(
|
||||
storageKey,
|
||||
options.ContentType,
|
||||
bytes.LongLength,
|
||||
sha,
|
||||
ETag: null,
|
||||
CreatedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
_objects[storageKey] = bytes;
|
||||
StoredArtifacts.Add(metadata);
|
||||
return Task.FromResult(metadata);
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_objects.TryGetValue(storageKey, out var bytes))
|
||||
{
|
||||
throw new FileNotFoundException(storageKey);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream>(new MemoryStream(bytes, writable: false));
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_objects.ContainsKey(storageKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Storage;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class FileSystemEvidenceObjectStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _rootPath;
|
||||
|
||||
public FileSystemEvidenceObjectStoreTests()
|
||||
{
|
||||
_rootPath = Path.Combine(Path.GetTempPath(), $"evidence-locker-tests-{Guid.NewGuid():N}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_EnforcesWriteOnceWhenConfigured()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var store = CreateStore(enforceWriteOnce: true);
|
||||
var options = CreateWriteOptions();
|
||||
|
||||
using var first = CreateStream("payload-1");
|
||||
await store.StoreAsync(first, options, cancellationToken);
|
||||
|
||||
using var second = CreateStream("payload-1");
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => store.StoreAsync(second, options, cancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_AllowsOverwriteWhenWriteOnceDisabled()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var store = CreateStore(enforceWriteOnce: false);
|
||||
var options = CreateWriteOptions() with { EnforceWriteOnce = false };
|
||||
|
||||
using var first = CreateStream("payload-1");
|
||||
var firstMetadata = await store.StoreAsync(first, options, cancellationToken);
|
||||
|
||||
using var second = CreateStream("payload-1");
|
||||
var secondMetadata = await store.StoreAsync(second, options, cancellationToken);
|
||||
|
||||
Assert.Equal(firstMetadata.Sha256, secondMetadata.Sha256);
|
||||
Assert.True(File.Exists(Path.Combine(_rootPath, secondMetadata.StorageKey.Replace('/', Path.DirectorySeparatorChar))));
|
||||
}
|
||||
|
||||
private FileSystemEvidenceObjectStore CreateStore(bool enforceWriteOnce)
|
||||
{
|
||||
var fileSystemOptions = new FileSystemStoreOptions
|
||||
{
|
||||
RootPath = _rootPath
|
||||
};
|
||||
|
||||
return new FileSystemEvidenceObjectStore(
|
||||
fileSystemOptions,
|
||||
enforceWriteOnce,
|
||||
NullLogger<FileSystemEvidenceObjectStore>.Instance);
|
||||
}
|
||||
|
||||
private static EvidenceObjectWriteOptions CreateWriteOptions()
|
||||
{
|
||||
var tenant = TenantId.FromGuid(Guid.NewGuid());
|
||||
var bundle = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
return new EvidenceObjectWriteOptions(
|
||||
tenant,
|
||||
bundle,
|
||||
"artifact.txt",
|
||||
"text/plain");
|
||||
}
|
||||
|
||||
private static MemoryStream CreateStream(string content)
|
||||
=> new(Encoding.UTF8.GetBytes(content));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_rootPath))
|
||||
{
|
||||
Directory.Delete(_rootPath, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Signing;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Signing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class Rfc3161TimestampAuthorityClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RequestTimestampAsync_ReturnsNull_WhenAuthorityFailsAndTimestampOptional()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
||||
var client = CreateClient(handler, new TimestampingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = "https://tsa.example",
|
||||
HashAlgorithm = "SHA256",
|
||||
RequireTimestamp = false
|
||||
});
|
||||
|
||||
var result = await client.RequestTimestampAsync(new byte[] { 4, 5, 6 }, "SHA256", CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestTimestampAsync_Throws_WhenAuthorityFailsAndTimestampRequired()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
||||
var client = CreateClient(handler, new TimestampingOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = "https://tsa.example",
|
||||
HashAlgorithm = "SHA256",
|
||||
RequireTimestamp = true
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.RequestTimestampAsync(new byte[] { 7, 8 }, "SHA256", CancellationToken.None));
|
||||
}
|
||||
|
||||
private static Rfc3161TimestampAuthorityClient CreateClient(HttpMessageHandler handler, TimestampingOptions timestampingOptions)
|
||||
{
|
||||
var httpClient = new HttpClient(handler, disposeHandler: false);
|
||||
var options = Options.Create(new EvidenceLockerOptions
|
||||
{
|
||||
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
|
||||
ObjectStore = new ObjectStoreOptions
|
||||
{
|
||||
Kind = ObjectStoreKind.FileSystem,
|
||||
FileSystem = new FileSystemStoreOptions { RootPath = "." }
|
||||
},
|
||||
Quotas = new QuotaOptions(),
|
||||
Signing = new SigningOptions
|
||||
{
|
||||
Algorithm = SignatureAlgorithms.Es256,
|
||||
KeyId = "test-key",
|
||||
Timestamping = timestampingOptions
|
||||
}
|
||||
});
|
||||
|
||||
return new Rfc3161TimestampAuthorityClient(httpClient, options, NullLogger<Rfc3161TimestampAuthorityClient>.Instance);
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder = responder;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_responder(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Amazon;
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Storage;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class S3EvidenceObjectStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreAsync_SetsIfNoneMatchAndMetadataWhenEnforcingWriteOnce()
|
||||
{
|
||||
var fakeClient = new FakeAmazonS3Client();
|
||||
using var store = new S3EvidenceObjectStore(
|
||||
fakeClient,
|
||||
new AmazonS3StoreOptions
|
||||
{
|
||||
BucketName = "evidence",
|
||||
Region = RegionEndpoint.USEast1.SystemName,
|
||||
Prefix = "locker"
|
||||
},
|
||||
enforceWriteOnce: true,
|
||||
NullLogger<S3EvidenceObjectStore>.Instance);
|
||||
|
||||
var options = new EvidenceObjectWriteOptions(
|
||||
TenantId.FromGuid(Guid.NewGuid()),
|
||||
EvidenceBundleId.FromGuid(Guid.NewGuid()),
|
||||
"bundle-manifest.json",
|
||||
"application/json",
|
||||
EnforceWriteOnce: true,
|
||||
Tags: new Dictionary<string, string>
|
||||
{
|
||||
{ "case", "incident-123" }
|
||||
});
|
||||
|
||||
var metadata = await store.StoreAsync(new MemoryStream(Encoding.UTF8.GetBytes("{\"value\":1}")), options, CancellationToken.None);
|
||||
|
||||
var request = fakeClient.PutRequests.Single();
|
||||
|
||||
Assert.Equal("evidence", request.Bucket);
|
||||
Assert.StartsWith("locker/tenants/", request.Key, StringComparison.Ordinal);
|
||||
Assert.Equal("*", request.IfNoneMatch);
|
||||
var shaEntry = request.Metadata.FirstOrDefault(kvp => kvp.Key.EndsWith("sha256", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.NotEqual(default(KeyValuePair<string, string>), shaEntry);
|
||||
Assert.Equal(metadata.Sha256, shaEntry.Value);
|
||||
var tenantEntry = request.Metadata.FirstOrDefault(kvp => kvp.Key.EndsWith("tenant-id", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.NotEqual(default(KeyValuePair<string, string>), tenantEntry);
|
||||
Assert.Equal(options.TenantId.Value.ToString("D"), tenantEntry.Value);
|
||||
Assert.Contains(request.Tags, tag => tag.Key == "case" && tag.Value == "incident-123");
|
||||
Assert.Equal("\"etag\"", metadata.ETag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_DoesNotSetIfNoneMatchWhenWriteOnceDisabled()
|
||||
{
|
||||
var fakeClient = new FakeAmazonS3Client();
|
||||
using var store = new S3EvidenceObjectStore(
|
||||
fakeClient,
|
||||
new AmazonS3StoreOptions
|
||||
{
|
||||
BucketName = "evidence",
|
||||
Region = RegionEndpoint.USEast1.SystemName
|
||||
},
|
||||
enforceWriteOnce: false,
|
||||
NullLogger<S3EvidenceObjectStore>.Instance);
|
||||
|
||||
var options = new EvidenceObjectWriteOptions(
|
||||
TenantId.FromGuid(Guid.NewGuid()),
|
||||
EvidenceBundleId.FromGuid(Guid.NewGuid()),
|
||||
"artifact.bin",
|
||||
"application/octet-stream",
|
||||
EnforceWriteOnce: false);
|
||||
|
||||
await store.StoreAsync(new MemoryStream(Encoding.UTF8.GetBytes("payload")), options, CancellationToken.None);
|
||||
|
||||
var request = fakeClient.PutRequests.Single();
|
||||
Assert.Null(request.IfNoneMatch);
|
||||
}
|
||||
|
||||
private sealed class FakeAmazonS3Client : AmazonS3Client
|
||||
{
|
||||
public FakeAmazonS3Client()
|
||||
: base(new AnonymousAWSCredentials(), new AmazonS3Config
|
||||
{
|
||||
RegionEndpoint = RegionEndpoint.USEast1,
|
||||
ForcePathStyle = true,
|
||||
UseHttp = true,
|
||||
ServiceURL = "http://localhost"
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
public List<CapturedPutObjectRequest> PutRequests { get; } = new();
|
||||
|
||||
public override Task<PutObjectResponse> PutObjectAsync(PutObjectRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var key in request.Metadata.Keys)
|
||||
{
|
||||
metadata[key] = request.Metadata[key];
|
||||
}
|
||||
var tags = request.TagSet?.Select(tag => new KeyValuePair<string, string>(tag.Key, tag.Value)).ToList()
|
||||
?? new List<KeyValuePair<string, string>>();
|
||||
var ifNoneMatch = request.Headers?["If-None-Match"];
|
||||
|
||||
using var memory = new MemoryStream();
|
||||
request.InputStream.CopyTo(memory);
|
||||
|
||||
PutRequests.Add(new CapturedPutObjectRequest(
|
||||
request.BucketName,
|
||||
request.Key,
|
||||
metadata,
|
||||
tags,
|
||||
ifNoneMatch,
|
||||
memory.ToArray()));
|
||||
|
||||
return Task.FromResult(new PutObjectResponse
|
||||
{
|
||||
ETag = "\"etag\""
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record CapturedPutObjectRequest(
|
||||
string Bucket,
|
||||
string Key,
|
||||
IDictionary<string, string> Metadata,
|
||||
IList<KeyValuePair<string, string>> Tags,
|
||||
string? IfNoneMatch,
|
||||
byte[] Payload);
|
||||
}
|
||||
}
|
||||
@@ -1,135 +1,35 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<OutputType>Exe</OutputType>
|
||||
|
||||
|
||||
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
|
||||
|
||||
<LangVersion>preview</LangVersion>
|
||||
|
||||
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<Using Include="Xunit"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.EvidenceLocker.Infrastructure\StellaOps.EvidenceLocker.Infrastructure.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNet.Testcontainers" Version="1.6.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Npgsql" Version="8.0.3" />
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.EvidenceLocker.Core\StellaOps.EvidenceLocker.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.EvidenceLocker.Infrastructure\StellaOps.EvidenceLocker.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.EvidenceLocker.WebService\StellaOps.EvidenceLocker.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Timeline;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class TimelineIndexerEvidenceTimelinePublisherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishBundleSealedAsync_SendsExpectedPayload()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var manifest = new EvidenceBundleManifest(
|
||||
bundleId,
|
||||
tenantId,
|
||||
EvidenceBundleKind.Job,
|
||||
DateTimeOffset.Parse("2025-11-03T12:00:00Z"),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["project"] = "atlas",
|
||||
["environment"] = "prod"
|
||||
},
|
||||
new List<EvidenceManifestEntry>
|
||||
{
|
||||
new(
|
||||
"inputs",
|
||||
"inputs/config.json",
|
||||
new string('a', 64),
|
||||
128,
|
||||
"application/json",
|
||||
new Dictionary<string, string> { ["role"] = "primary" })
|
||||
});
|
||||
|
||||
var signature = new EvidenceBundleSignature(
|
||||
bundleId,
|
||||
tenantId,
|
||||
"application/vnd.stella.manifest+json",
|
||||
Convert.ToBase64String(new byte[] { 1, 2, 3 }),
|
||||
Convert.ToBase64String(new byte[] { 4, 5, 6 }),
|
||||
"key-1",
|
||||
SignatureAlgorithms.Es256,
|
||||
"default",
|
||||
DateTimeOffset.Parse("2025-11-03T12:05:00Z"),
|
||||
DateTimeOffset.Parse("2025-11-03T12:06:00Z"),
|
||||
"tsa://test",
|
||||
new byte[] { 9, 8, 7 });
|
||||
|
||||
var handler = new RecordingHttpMessageHandler(HttpStatusCode.Accepted);
|
||||
var client = new HttpClient(handler);
|
||||
var publisher = new TimelineIndexerEvidenceTimelinePublisher(
|
||||
client,
|
||||
CreateOptions(),
|
||||
TimeProvider.System,
|
||||
NullLogger<TimelineIndexerEvidenceTimelinePublisher>.Instance);
|
||||
|
||||
await publisher.PublishBundleSealedAsync(signature, manifest, new string('f', 64), CancellationToken.None);
|
||||
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal("https://timeline.test/events", request.Uri?.ToString());
|
||||
|
||||
Assert.NotNull(request.Content);
|
||||
using var json = JsonDocument.Parse(request.Content!);
|
||||
var root = json.RootElement;
|
||||
Assert.Equal("evidence.bundle.sealed", root.GetProperty("kind").GetString());
|
||||
Assert.Equal(signature.BundleId.Value.ToString("D"), root.GetProperty("attributes").GetProperty("bundleId").GetString());
|
||||
|
||||
var bundle = root.GetProperty("bundle");
|
||||
Assert.Equal(signature.BundleId.Value.ToString("D"), bundle.GetProperty("bundleId").GetString());
|
||||
Assert.Equal(new string('f', 64), bundle.GetProperty("rootHash").GetString());
|
||||
|
||||
var signatureJson = bundle.GetProperty("signature");
|
||||
Assert.Equal(Convert.ToBase64String(new byte[] { 4, 5, 6 }), signatureJson.GetProperty("signature").GetString());
|
||||
Assert.Equal(Convert.ToBase64String(new byte[] { 9, 8, 7 }), signatureJson.GetProperty("timestampToken").GetString());
|
||||
|
||||
var metadata = bundle.GetProperty("metadata");
|
||||
Assert.Equal("atlas", metadata.GetProperty("project").GetString());
|
||||
Assert.Equal("prod", metadata.GetProperty("environment").GetString());
|
||||
|
||||
var entry = Assert.Single(bundle.GetProperty("entries").EnumerateArray());
|
||||
Assert.Equal("inputs", entry.GetProperty("section").GetString());
|
||||
Assert.Equal("inputs/config.json", entry.GetProperty("canonicalPath").GetString());
|
||||
Assert.Equal("primary", entry.GetProperty("attributes").GetProperty("role").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishHoldCreatedAsync_ProducesHoldPayload()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var hold = new EvidenceHold(
|
||||
EvidenceHoldId.FromGuid(Guid.NewGuid()),
|
||||
tenantId,
|
||||
EvidenceBundleId.FromGuid(Guid.NewGuid()),
|
||||
"case-001",
|
||||
"legal",
|
||||
DateTimeOffset.Parse("2025-10-31T09:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-12-31T00:00:00Z"),
|
||||
ReleasedAt: null,
|
||||
Notes: "retain until close");
|
||||
|
||||
var handler = new RecordingHttpMessageHandler(HttpStatusCode.BadGateway);
|
||||
var client = new HttpClient(handler);
|
||||
var publisher = new TimelineIndexerEvidenceTimelinePublisher(
|
||||
client,
|
||||
CreateOptions(),
|
||||
TimeProvider.System,
|
||||
NullLogger<TimelineIndexerEvidenceTimelinePublisher>.Instance);
|
||||
|
||||
await publisher.PublishHoldCreatedAsync(hold, CancellationToken.None);
|
||||
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
|
||||
using var json = JsonDocument.Parse(request.Content!);
|
||||
var root = json.RootElement;
|
||||
Assert.Equal("evidence.hold.created", root.GetProperty("kind").GetString());
|
||||
Assert.Equal(hold.CaseId, root.GetProperty("attributes").GetProperty("caseId").GetString());
|
||||
|
||||
var holdJson = root.GetProperty("hold");
|
||||
Assert.Equal(hold.Id.Value.ToString("D"), holdJson.GetProperty("holdId").GetString());
|
||||
Assert.Equal(hold.BundleId?.Value.ToString("D"), holdJson.GetProperty("bundleId").GetString());
|
||||
}
|
||||
|
||||
private static IOptions<EvidenceLockerOptions> CreateOptions()
|
||||
=> Options.Create(new EvidenceLockerOptions
|
||||
{
|
||||
Database = new DatabaseOptions { ConnectionString = "Host=localhost" },
|
||||
ObjectStore = new ObjectStoreOptions
|
||||
{
|
||||
Kind = ObjectStoreKind.FileSystem,
|
||||
FileSystem = new FileSystemStoreOptions { RootPath = "." }
|
||||
},
|
||||
Quotas = new QuotaOptions(),
|
||||
Signing = new SigningOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Algorithm = SignatureAlgorithms.Es256,
|
||||
KeyId = "test-key"
|
||||
},
|
||||
Timeline = new TimelineOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = "https://timeline.test/events",
|
||||
Source = "test-source"
|
||||
}
|
||||
});
|
||||
|
||||
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
|
||||
public RecordingHttpMessageHandler(HttpStatusCode statusCode)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
}
|
||||
|
||||
public List<RecordedRequest> Requests { get; } = new();
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var content = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Requests.Add(new RecordedRequest(request.Method, request.RequestUri, content));
|
||||
return new HttpResponseMessage(_statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RecordedRequest(HttpMethod Method, Uri? Uri, string? Content);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.WebService.Audit;
|
||||
|
||||
internal static class EvidenceAuditLogger
|
||||
{
|
||||
internal const string LoggerName = "EvidenceLocker.Audit";
|
||||
|
||||
private const string OperationSnapshotCreate = "snapshot.create";
|
||||
private const string OperationBundleRead = "bundle.read";
|
||||
private const string OperationBundleVerify = "bundle.verify";
|
||||
private const string OperationHoldCreate = "hold.create";
|
||||
private const string OperationBundleDownload = "bundle.download";
|
||||
private const string OperationBundlePortable = "bundle.portable";
|
||||
|
||||
public static void LogTenantMissing(ILogger logger, ClaimsPrincipal user, string path)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=tenant_missing path={Path} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
"request",
|
||||
path,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogSnapshotCreated(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleKind kind,
|
||||
Guid bundleId,
|
||||
int materialCount,
|
||||
long totalBytes)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogInformation(
|
||||
"Evidence audit operation={Operation} outcome=success tenant={TenantId} bundle={BundleId} kind={Kind} materials={MaterialCount} totalBytes={TotalBytes} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationSnapshotCreate,
|
||||
tenantId.Value,
|
||||
bundleId,
|
||||
kind,
|
||||
materialCount,
|
||||
totalBytes,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogSnapshotRejected(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleKind kind,
|
||||
string reason)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=validation_failed tenant={TenantId} kind={Kind} reason=\"{Reason}\" subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationSnapshotCreate,
|
||||
tenantId.Value,
|
||||
kind,
|
||||
reason,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogBundleNotFound(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
Guid bundleId)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=not_found tenant={TenantId} bundle={BundleId} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationBundleRead,
|
||||
tenantId.Value,
|
||||
bundleId,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogBundleRetrieved(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
EvidenceBundle bundle)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogInformation(
|
||||
"Evidence audit operation={Operation} outcome=success tenant={TenantId} bundle={BundleId} status={Status} kind={Kind} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationBundleRead,
|
||||
tenantId.Value,
|
||||
bundle.Id.Value,
|
||||
bundle.Status,
|
||||
bundle.Kind,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogVerificationResult(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
Guid bundleId,
|
||||
string expectedRoot,
|
||||
bool trusted)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogInformation(
|
||||
"Evidence audit operation={Operation} outcome={Outcome} tenant={TenantId} bundle={BundleId} expectedRoot={ExpectedRoot} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationBundleVerify,
|
||||
trusted ? "trusted" : "mismatch",
|
||||
tenantId.Value,
|
||||
bundleId,
|
||||
expectedRoot,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogHoldCreated(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
EvidenceHold hold)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogInformation(
|
||||
"Evidence audit operation={Operation} outcome=success tenant={TenantId} hold={HoldId} caseId={CaseId} bundle={BundleId} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationHoldCreate,
|
||||
tenantId.Value,
|
||||
hold.Id.Value,
|
||||
hold.CaseId,
|
||||
hold.BundleId?.Value,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogHoldBundleMissing(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
string caseId,
|
||||
Guid bundleId)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=bundle_missing tenant={TenantId} caseId={CaseId} bundle={BundleId} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationHoldCreate,
|
||||
tenantId.Value,
|
||||
caseId,
|
||||
bundleId,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogHoldConflict(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
string caseId)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=conflict tenant={TenantId} caseId={CaseId} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationHoldCreate,
|
||||
tenantId.Value,
|
||||
caseId,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogHoldValidationFailure(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
string caseId,
|
||||
string reason)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=validation_failed tenant={TenantId} caseId={CaseId} reason=\"{Reason}\" subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationHoldCreate,
|
||||
tenantId.Value,
|
||||
caseId,
|
||||
reason,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogBundleDownload(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
Guid bundleId,
|
||||
string storageKey,
|
||||
bool created)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogInformation(
|
||||
"Evidence audit operation={Operation} outcome=success tenant={TenantId} bundle={BundleId} storageKey={StorageKey} cached={Cached} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationBundleDownload,
|
||||
tenantId.Value,
|
||||
bundleId,
|
||||
storageKey,
|
||||
created ? "false" : "true",
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogBundleDownloadFailure(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
Guid bundleId,
|
||||
string reason)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=failure tenant={TenantId} bundle={BundleId} reason=\"{Reason}\" subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationBundleDownload,
|
||||
tenantId.Value,
|
||||
bundleId,
|
||||
reason,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogPortableDownload(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
Guid bundleId,
|
||||
string storageKey,
|
||||
bool created)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogInformation(
|
||||
"Evidence audit operation={Operation} outcome=success tenant={TenantId} bundle={BundleId} storageKey={StorageKey} cached={Cached} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationBundlePortable,
|
||||
tenantId.Value,
|
||||
bundleId,
|
||||
storageKey,
|
||||
created ? "false" : "true",
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogPortableDownloadFailure(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
Guid bundleId,
|
||||
string reason)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=failure tenant={TenantId} bundle={BundleId} reason=\"{Reason}\" subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationBundlePortable,
|
||||
tenantId.Value,
|
||||
bundleId,
|
||||
reason,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
private static AuditIdentity ExtractIdentity(ClaimsPrincipal? user)
|
||||
{
|
||||
if (user is null)
|
||||
{
|
||||
return new AuditIdentity("(anonymous)", "(none)", "(none)");
|
||||
}
|
||||
|
||||
var subject = user.FindFirst(StellaOpsClaimTypes.Subject)?.Value;
|
||||
var clientId = user.FindFirst(StellaOpsClaimTypes.ClientId)?.Value;
|
||||
var scopes = ExtractScopes(user);
|
||||
|
||||
return new AuditIdentity(
|
||||
string.IsNullOrWhiteSpace(subject) ? "(anonymous)" : subject,
|
||||
string.IsNullOrWhiteSpace(clientId) ? "(none)" : clientId,
|
||||
scopes.Length == 0 ? "(none)" : string.Join(',', scopes));
|
||||
}
|
||||
|
||||
private static string[] ExtractScopes(ClaimsPrincipal principal)
|
||||
{
|
||||
var values = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
values.Add(claim.Value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(part);
|
||||
if (!string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
values.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values.Count == 0 ? Array.Empty<string>() : values.ToArray();
|
||||
}
|
||||
|
||||
private readonly record struct AuditIdentity(string Subject, string ClientId, string Scopes);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.WebService.Contracts;
|
||||
|
||||
public sealed record EvidenceSnapshotRequestDto
|
||||
{
|
||||
[Required]
|
||||
public EvidenceBundleKind Kind { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[Required]
|
||||
public List<EvidenceSnapshotMaterialDto> Materials { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record EvidenceSnapshotMaterialDto
|
||||
{
|
||||
public string? Section { get; init; }
|
||||
|
||||
public string? Path { get; init; }
|
||||
|
||||
[Required]
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
[Range(0, long.MaxValue)]
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
public Dictionary<string, string>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record EvidenceSnapshotResponseDto(Guid BundleId, string RootHash, EvidenceBundleSignatureDto? Signature);
|
||||
|
||||
public sealed record EvidenceBundleResponseDto(
|
||||
Guid BundleId,
|
||||
EvidenceBundleKind Kind,
|
||||
EvidenceBundleStatus Status,
|
||||
string RootHash,
|
||||
string StorageKey,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? Description,
|
||||
DateTimeOffset? SealedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
EvidenceBundleSignatureDto? Signature);
|
||||
|
||||
public sealed record EvidenceBundleSignatureDto(
|
||||
string PayloadType,
|
||||
string Payload,
|
||||
string Signature,
|
||||
string? KeyId,
|
||||
string Algorithm,
|
||||
string Provider,
|
||||
DateTimeOffset SignedAt,
|
||||
DateTimeOffset? TimestampedAt,
|
||||
string? TimestampAuthority,
|
||||
string? TimestampToken);
|
||||
|
||||
public sealed record EvidenceVerifyRequestDto
|
||||
{
|
||||
[Required]
|
||||
public Guid BundleId { get; init; }
|
||||
|
||||
[Required]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record EvidenceVerifyResponseDto(bool Trusted);
|
||||
|
||||
public sealed record EvidenceHoldRequestDto
|
||||
{
|
||||
public Guid? BundleId { get; init; }
|
||||
|
||||
[Required]
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record EvidenceHoldResponseDto(
|
||||
Guid HoldId,
|
||||
Guid? BundleId,
|
||||
string CaseId,
|
||||
string Reason,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset? ReleasedAt,
|
||||
string? Notes);
|
||||
|
||||
public sealed record ErrorResponse(string Code, string Message);
|
||||
|
||||
public static class EvidenceContractMapper
|
||||
{
|
||||
public static EvidenceSnapshotRequest ToDomain(this EvidenceSnapshotRequestDto dto)
|
||||
=> new()
|
||||
{
|
||||
Kind = dto.Kind,
|
||||
Description = dto.Description,
|
||||
Metadata = dto.Metadata ?? new Dictionary<string, string>(),
|
||||
Materials = dto.Materials
|
||||
.Select(m => new EvidenceSnapshotMaterial
|
||||
{
|
||||
Section = m.Section,
|
||||
Path = m.Path,
|
||||
Sha256 = m.Sha256,
|
||||
SizeBytes = m.SizeBytes,
|
||||
MediaType = m.MediaType,
|
||||
Attributes = m.Attributes ?? new Dictionary<string, string>()
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
public static EvidenceHoldRequest ToDomain(this EvidenceHoldRequestDto dto)
|
||||
=> new()
|
||||
{
|
||||
BundleId = dto.BundleId,
|
||||
Reason = dto.Reason,
|
||||
ExpiresAt = dto.ExpiresAt,
|
||||
Notes = dto.Notes
|
||||
};
|
||||
|
||||
public static EvidenceBundleSignatureDto? ToDto(this EvidenceBundleSignature? signature)
|
||||
{
|
||||
if (signature is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new EvidenceBundleSignatureDto(
|
||||
signature.PayloadType,
|
||||
signature.Payload,
|
||||
signature.Signature,
|
||||
signature.KeyId,
|
||||
signature.Algorithm,
|
||||
signature.Provider,
|
||||
signature.SignedAt,
|
||||
signature.TimestampedAt,
|
||||
signature.TimestampAuthority,
|
||||
signature.TimestampToken is null ? null : Convert.ToBase64String(signature.TimestampToken));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
using StellaOps.EvidenceLocker.WebService.Audit;
|
||||
using StellaOps.EvidenceLocker.WebService.Contracts;
|
||||
using StellaOps.EvidenceLocker.WebService.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddEvidenceLockerInfrastructure(builder.Configuration);
|
||||
builder.Services.AddScoped<EvidenceSnapshotService>();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configure: options =>
|
||||
{
|
||||
options.RequiredScopes.Clear();
|
||||
});
|
||||
configure: options => options.RequiredScopes.Clear());
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
@@ -34,13 +52,282 @@ app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGet("/evidence/{id}", (string id) => Results.Ok(new { id, status = "available" }))
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead);
|
||||
app.MapHealthChecks("/health/ready");
|
||||
|
||||
app.MapPost("/evidence", () => Results.Accepted("/evidence", new { status = "queued" }))
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceCreate);
|
||||
app.MapPost("/evidence/snapshot",
|
||||
async (HttpContext context, ClaimsPrincipal user, EvidenceSnapshotRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
|
||||
|
||||
app.MapPost("/evidence/{id}/hold", (string id) => Results.Accepted($"/evidence/{id}/hold", new { id, status = "on-hold" }))
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceHold);
|
||||
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
|
||||
{
|
||||
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/snapshot");
|
||||
return ForbidTenant();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.CreateSnapshotAsync(tenantId, request.ToDomain(), cancellationToken);
|
||||
var materialCount = request.Materials.Count;
|
||||
var totalSize = request.Materials.Sum(material => material.SizeBytes);
|
||||
EvidenceAuditLogger.LogSnapshotCreated(logger, user, tenantId, request.Kind, result.BundleId, materialCount, totalSize);
|
||||
|
||||
var dto = new EvidenceSnapshotResponseDto(
|
||||
result.BundleId,
|
||||
result.RootHash,
|
||||
result.Signature.ToDto());
|
||||
|
||||
return Results.Created($"/evidence/{result.BundleId}", dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
EvidenceAuditLogger.LogSnapshotRejected(logger, user, tenantId, request.Kind, ex.Message);
|
||||
return ValidationProblem(ex.Message);
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceHold)
|
||||
.Produces<EvidenceSnapshotResponseDto>(StatusCodes.Status201Created)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
|
||||
.WithName("CreateEvidenceSnapshot")
|
||||
.WithTags("Evidence")
|
||||
.WithSummary("Create a new evidence snapshot for the tenant.");
|
||||
|
||||
app.MapGet("/evidence/{bundleId:guid}",
|
||||
async (HttpContext context, ClaimsPrincipal user, Guid bundleId, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
|
||||
|
||||
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
|
||||
{
|
||||
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}");
|
||||
return ForbidTenant();
|
||||
}
|
||||
|
||||
var details = await service.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
|
||||
if (details is null)
|
||||
{
|
||||
EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId);
|
||||
return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found."));
|
||||
}
|
||||
|
||||
EvidenceAuditLogger.LogBundleRetrieved(logger, user, tenantId, details.Bundle);
|
||||
|
||||
var dto = new EvidenceBundleResponseDto(
|
||||
details.Bundle.Id.Value,
|
||||
details.Bundle.Kind,
|
||||
details.Bundle.Status,
|
||||
details.Bundle.RootHash,
|
||||
details.Bundle.StorageKey,
|
||||
details.Bundle.CreatedAt,
|
||||
details.Bundle.UpdatedAt,
|
||||
details.Bundle.Description,
|
||||
details.Bundle.SealedAt,
|
||||
details.Bundle.ExpiresAt,
|
||||
details.Signature.ToDto());
|
||||
|
||||
return Results.Ok(dto);
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
|
||||
.Produces<EvidenceBundleResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
|
||||
.WithName("GetEvidenceBundle")
|
||||
.WithTags("Evidence");
|
||||
|
||||
app.MapGet("/evidence/{bundleId:guid}/download",
|
||||
async (HttpContext context,
|
||||
ClaimsPrincipal user,
|
||||
Guid bundleId,
|
||||
EvidenceSnapshotService snapshotService,
|
||||
EvidenceBundlePackagingService packagingService,
|
||||
IEvidenceObjectStore objectStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
|
||||
|
||||
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
|
||||
{
|
||||
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}/download");
|
||||
return ForbidTenant();
|
||||
}
|
||||
|
||||
var bundle = await snapshotService.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
|
||||
if (bundle is null)
|
||||
{
|
||||
EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId);
|
||||
return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var package = await packagingService.EnsurePackageAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
|
||||
EvidenceAuditLogger.LogBundleDownload(logger, user, tenantId, bundleId, package.StorageKey, package.Created);
|
||||
|
||||
var packageStream = await objectStore.OpenReadAsync(package.StorageKey, cancellationToken).ConfigureAwait(false);
|
||||
return Results.File(
|
||||
packageStream,
|
||||
contentType: "application/gzip",
|
||||
fileDownloadName: $"evidence-bundle-{bundleId:D}.tgz");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
EvidenceAuditLogger.LogBundleDownloadFailure(logger, user, tenantId, bundleId, ex.Message);
|
||||
return ValidationProblem(ex.Message);
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
|
||||
.WithName("DownloadEvidenceBundle")
|
||||
.WithTags("Evidence");
|
||||
|
||||
app.MapGet("/evidence/{bundleId:guid}/portable",
|
||||
async (HttpContext context,
|
||||
ClaimsPrincipal user,
|
||||
Guid bundleId,
|
||||
EvidenceSnapshotService snapshotService,
|
||||
EvidencePortableBundleService portableService,
|
||||
IEvidenceObjectStore objectStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
|
||||
|
||||
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
|
||||
{
|
||||
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}/portable");
|
||||
return ForbidTenant();
|
||||
}
|
||||
|
||||
var bundle = await snapshotService.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
|
||||
if (bundle is null)
|
||||
{
|
||||
EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId);
|
||||
return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var package = await portableService.EnsurePortablePackageAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
|
||||
EvidenceAuditLogger.LogPortableDownload(logger, user, tenantId, bundleId, package.StorageKey, package.Created);
|
||||
|
||||
var packageStream = await objectStore.OpenReadAsync(package.StorageKey, cancellationToken).ConfigureAwait(false);
|
||||
return Results.File(
|
||||
packageStream,
|
||||
contentType: "application/gzip",
|
||||
fileDownloadName: $"portable-evidence-bundle-{bundleId:D}.tgz");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
EvidenceAuditLogger.LogPortableDownloadFailure(logger, user, tenantId, bundleId, ex.Message);
|
||||
return ValidationProblem(ex.Message);
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
|
||||
.WithName("DownloadPortableEvidenceBundle")
|
||||
.WithTags("Evidence")
|
||||
.WithSummary("Download a sealed, portable evidence bundle for sealed or air-gapped distribution.");
|
||||
|
||||
app.MapPost("/evidence/verify",
|
||||
async (HttpContext context, ClaimsPrincipal user, EvidenceVerifyRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
|
||||
|
||||
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
|
||||
{
|
||||
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/verify");
|
||||
return ForbidTenant();
|
||||
}
|
||||
|
||||
var trusted = await service.VerifyAsync(tenantId, EvidenceBundleId.FromGuid(request.BundleId), request.RootHash, cancellationToken);
|
||||
EvidenceAuditLogger.LogVerificationResult(logger, user, tenantId, request.BundleId, request.RootHash, trusted);
|
||||
|
||||
return Results.Ok(new EvidenceVerifyResponseDto(trusted));
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
|
||||
.Produces<EvidenceVerifyResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
|
||||
.WithName("VerifyEvidenceBundle")
|
||||
.WithTags("Evidence");
|
||||
|
||||
app.MapPost("/evidence/hold/{caseId}",
|
||||
async (HttpContext context, ClaimsPrincipal user, string caseId, EvidenceHoldRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(caseId))
|
||||
{
|
||||
return ValidationProblem("Case identifier is required.");
|
||||
}
|
||||
|
||||
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
|
||||
{
|
||||
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/hold/{caseId}");
|
||||
return ForbidTenant();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var hold = await service.CreateHoldAsync(tenantId, caseId, request.ToDomain(), cancellationToken);
|
||||
EvidenceAuditLogger.LogHoldCreated(logger, user, tenantId, hold);
|
||||
|
||||
var dto = new EvidenceHoldResponseDto(
|
||||
hold.Id.Value,
|
||||
hold.BundleId?.Value,
|
||||
hold.CaseId,
|
||||
hold.Reason,
|
||||
hold.CreatedAt,
|
||||
hold.ExpiresAt,
|
||||
hold.ReleasedAt,
|
||||
hold.Notes);
|
||||
|
||||
return Results.Created($"/evidence/hold/{hold.Id.Value}", dto);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase) && request.BundleId is Guid referencedBundle)
|
||||
{
|
||||
EvidenceAuditLogger.LogHoldBundleMissing(logger, user, tenantId, caseId, referencedBundle);
|
||||
}
|
||||
else if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
EvidenceAuditLogger.LogHoldConflict(logger, user, tenantId, caseId);
|
||||
}
|
||||
else
|
||||
{
|
||||
EvidenceAuditLogger.LogHoldValidationFailure(logger, user, tenantId, caseId, ex.Message);
|
||||
}
|
||||
|
||||
return ValidationProblem(ex.Message);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
EvidenceAuditLogger.LogHoldValidationFailure(logger, user, tenantId, caseId, ex.Message);
|
||||
return ValidationProblem(ex.Message);
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceCreate)
|
||||
.Produces<EvidenceHoldResponseDto>(StatusCodes.Status201Created)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
|
||||
.WithName("CreateEvidenceHold")
|
||||
.WithTags("Evidence")
|
||||
.WithSummary("Create a legal hold for the specified case identifier.");
|
||||
|
||||
app.Run();
|
||||
|
||||
static IResult ForbidTenant() => Results.Forbid();
|
||||
|
||||
static IResult ValidationProblem(string message)
|
||||
=> Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["message"] = new[] { message }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.WebService.Security;
|
||||
|
||||
internal static class TenantResolution
|
||||
{
|
||||
public static bool TryResolveTenant(ClaimsPrincipal user, out TenantId tenantId)
|
||||
{
|
||||
tenantId = default;
|
||||
var tenantValue = user?.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
if (string.IsNullOrWhiteSpace(tenantValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantValue, out var guid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
tenantId = TenantId.FromGuid(guid);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
Logging: {
|
||||
LogLevel: {
|
||||
Default: Information,
|
||||
Microsoft.AspNetCore: Warning
|
||||
}
|
||||
},
|
||||
EvidenceLocker: {
|
||||
Database: {
|
||||
ConnectionString: Host=localhost
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
AllowedHosts: *
|
||||
}
|
||||
EvidenceLocker: {
|
||||
Database: {
|
||||
ConnectionString: Host=localhost
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using StellaOps.EvidenceLocker.Worker;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services.AddHostedService<Worker>();
|
||||
|
||||
var host = builder.Build();
|
||||
host.Run();
|
||||
using StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;
|
||||
using StellaOps.EvidenceLocker.Worker;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddEvidenceLockerInfrastructure(builder.Configuration);
|
||||
builder.Services.AddHostedService<Worker>();
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
namespace StellaOps.EvidenceLocker.Worker;
|
||||
|
||||
public class Worker(ILogger<Worker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
|
||||
}
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Worker;
|
||||
|
||||
public sealed class Worker(ILogger<Worker> logger, EvidenceLockerDataSource dataSource) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(stoppingToken);
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation("Evidence Locker worker verified connectivity to database '{Database}'", connection.Database);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Evidence Locker worker failed to verify database connectivity.");
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
Logging: {
|
||||
LogLevel: {
|
||||
Default: Information,
|
||||
Microsoft.Hosting.Lifetime: Information
|
||||
}
|
||||
},
|
||||
EvidenceLocker: {
|
||||
Database: {
|
||||
ConnectionString: Host=localhost
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
Logging: {
|
||||
LogLevel: {
|
||||
Default: Information,
|
||||
Microsoft.Hosting.Lifetime: Information
|
||||
}
|
||||
},
|
||||
EvidenceLocker: {
|
||||
Database: {
|
||||
ConnectionString: Host=localhost
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
# Evidence Locker Task Board — Epic 15: Observability & Forensics
|
||||
|
||||
## Sprint 53 – Evidence Bundle Foundations
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EVID-OBS-53-001 | TODO | Evidence Locker Guild | TELEMETRY-OBS-50-001, DEVOPS-OBS-50-003 | Bootstrap `StellaOps.Evidence.Locker` service with Postgres schema for `evidence_bundles`, `evidence_artifacts`, `evidence_holds`, tenant RLS, and object-store abstraction (WORM optional). | Service builds/tests; migrations deterministic; storage abstraction has local filesystem + S3 drivers; compliance checklist recorded. |
|
||||
| EVID-OBS-53-002 | TODO | Evidence Locker Guild, Orchestrator Guild | EVID-OBS-53-001, ORCH-OBS-53-001 | Implement bundle builders for evaluation/job/export snapshots collecting inputs, outputs, env digests, run metadata. Generate Merkle tree + manifest skeletons and persist root hash. | Builders cover three bundle types; integration tests verify deterministic manifests; root hash stored; docs stubbed. |
|
||||
| EVID-OBS-53-003 | TODO | Evidence Locker Guild, Security Guild | EVID-OBS-53-002 | Expose REST APIs (`POST /evidence/snapshot`, `GET /evidence/:id`, `POST /evidence/verify`, `POST /evidence/hold/:case_id`) with audit logging, tenant enforcement, and size quotas. | APIs documented via OpenAPI; tests cover RBAC/legal hold; size quota rejection returns structured error; audit logs validated. |
|
||||
|
||||
## Sprint 54 – Provenance Integration
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EVID-OBS-54-001 | TODO | Evidence Locker Guild, Provenance Guild | EVID-OBS-53-003, PROV-OBS-53-002 | Attach DSSE signing and RFC3161 timestamping to bundle manifests; validate against Provenance verification library. Wire legal hold retention extension and chain-of-custody events for Timeline Indexer. | Bundles signed; verification tests pass; timeline events emitted; timestamp optional but documented; retention updates recorded. |
|
||||
| EVID-OBS-54-002 | TODO | Evidence Locker Guild, DevEx/CLI Guild | EVID-OBS-54-001, CLI-FORENSICS-54-001 | Provide bundle download/export packaging (tgz) with checksum manifest, offline verification instructions, and sample fixture for CLI tests. | Packaging script deterministic; CLI verifies sample; offline instructions documented; checksum cross-check done. |
|
||||
|
||||
## Sprint 55 – Incident Mode & Retention
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EVID-OBS-55-001 | TODO | Evidence Locker Guild, DevOps Guild | EVID-OBS-54-001, DEVOPS-OBS-55-001 | Implement incident mode hooks increasing retention window, capturing additional debug artefacts, and emitting activation/deactivation events to Timeline Indexer + Notifier. | Incident mode extends retention per config; activation events emitted; tests cover revert to baseline; runbook updated. |
|
||||
|
||||
## Sprint 60 – Sealed Mode Portability
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EVID-OBS-60-001 | TODO | Evidence Locker Guild | EVID-OBS-55-001, AIRGAP-CTL-56-002 | Deliver portable evidence export flow for sealed environments: generate sealed bundles with checksum manifest, redacted metadata, and offline verification script. Document air-gapped import/verify procedures. | Portable bundle tooling implemented; checksum/verify script passes; sealed-mode docs updated; tests cover tamper + re-import scenarios. |
|
||||
# Evidence Locker Task Board — Epic 15: Observability & Forensics
|
||||
|
||||
## Sprint 53 – Evidence Bundle Foundations
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EVID-OBS-53-001 | DONE (2025-11-03) | Evidence Locker Guild | TELEMETRY-OBS-50-001, DEVOPS-OBS-50-003 | Bootstrap `StellaOps.Evidence.Locker` service with Postgres schema for `evidence_bundles`, `evidence_artifacts`, `evidence_holds`, tenant RLS, and object-store abstraction (WORM optional). | Service builds/tests; migrations deterministic; storage abstraction has local filesystem + S3 drivers; compliance checklist recorded. |
|
||||
| EVID-OBS-53-002 | DONE (2025-11-03) | Evidence Locker Guild, Orchestrator Guild | EVID-OBS-53-001, ORCH-OBS-53-001 | Implement bundle builders for evaluation/job/export snapshots collecting inputs, outputs, env digests, run metadata. Generate Merkle tree + manifest skeletons and persist root hash. | Builders cover three bundle types; integration tests verify deterministic manifests; root hash stored; docs stubbed. |
|
||||
| EVID-OBS-53-003 | DONE (2025-11-03) | Evidence Locker Guild, Security Guild | EVID-OBS-53-002 | Expose REST APIs (`POST /evidence/snapshot`, `GET /evidence/:id`, `POST /evidence/verify`, `POST /evidence/hold/:case_id`) with audit logging, tenant enforcement, and size quotas. | APIs documented via OpenAPI; tests cover RBAC/legal hold; size quota rejection returns structured error; audit logs validated. |
|
||||
|
||||
## Sprint 54 – Provenance Integration
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EVID-OBS-54-001 | DONE (2025-11-04) | Evidence Locker Guild, Provenance Guild | EVID-OBS-53-003, PROV-OBS-53-002 | Attach DSSE signing and RFC3161 timestamping to bundle manifests; validate against Provenance verification library. Wire legal hold retention extension and chain-of-custody events for Timeline Indexer. | Bundles signed; verification tests pass; timeline events emitted; timestamp optional but documented; retention updates recorded. |
|
||||
| EVID-OBS-54-002 | DONE (2025-11-04) | Evidence Locker Guild, DevEx/CLI Guild | EVID-OBS-54-001, CLI-FORENSICS-54-001 | Provide bundle download/export packaging (tgz) with checksum manifest, offline verification instructions, and sample fixture for CLI tests. | Packaging script deterministic; CLI verifies sample; offline instructions documented; checksum cross-check done. |
|
||||
|
||||
## Sprint 55 – Incident Mode & Retention
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EVID-OBS-55-001 | DONE (2025-11-04) | Evidence Locker Guild, DevOps Guild | EVID-OBS-54-001, DEVOPS-OBS-55-001 | Implement incident mode hooks increasing retention window, capturing additional debug artefacts, and emitting activation/deactivation events to Timeline Indexer + Notifier. | Incident mode extends retention per config; activation events emitted; tests cover revert to baseline; runbook updated. |
|
||||
|
||||
## Sprint 187 – Replay Enablement
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EVID-REPLAY-187-001 | TODO | Evidence Locker Guild, Ops Guild | REPLAY-CORE-185-001, SCAN-REPLAY-186-001 | Implement replay bundle ingestion/retention APIs, enforce CAS-backed storage, and update `docs/modules/evidence-locker/architecture.md` referencing `docs/replay/DETERMINISTIC_REPLAY.md` Sections 2 & 8. | Replay bundles stored with retention policies; verification tests pass; documentation merged. |
|
||||
|
||||
## Sprint 60 – Sealed Mode Portability
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| EVID-OBS-60-001 | DONE (2025-11-04) | Evidence Locker Guild | EVID-OBS-55-001, AIRGAP-CTL-56-002 | Deliver portable evidence export flow for sealed environments: generate sealed bundles with checksum manifest, redacted metadata, and offline verification script. Document air-gapped import/verify procedures. | Portable bundle tooling implemented; checksum/verify script passes; sealed-mode docs updated; tests cover tamper + re-import scenarios. |
|
||||
|
||||
Reference in New Issue
Block a user