Add unit tests for PhpFrameworkSurface and PhpPharScanner
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
- Implement comprehensive tests for PhpFrameworkSurface, covering scenarios such as empty surfaces, presence of routes, controllers, middlewares, CLI commands, cron jobs, and event listeners. - Validate metadata creation for route counts, HTTP methods, protected and public routes, and route patterns. - Introduce tests for PhpPharScanner, including handling of non-existent files, null or empty paths, invalid PHAR files, and minimal PHAR structures. - Ensure correct computation of SHA256 for valid PHAR files and validate the properties of PhpPharArchive, PhpPharEntry, and PhpPharScanResult.
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Attestation;
|
||||
|
||||
public class AttestationPointerServiceTests
|
||||
{
|
||||
private readonly Mock<ILedgerEventRepository> _ledgerEventRepository;
|
||||
private readonly Mock<ILedgerEventWriteService> _writeService;
|
||||
private readonly InMemoryAttestationPointerRepository _repository;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly AttestationPointerService _service;
|
||||
|
||||
public AttestationPointerServiceTests()
|
||||
{
|
||||
_ledgerEventRepository = new Mock<ILedgerEventRepository>();
|
||||
_writeService = new Mock<ILedgerEventWriteService>();
|
||||
_repository = new InMemoryAttestationPointerRepository();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_writeService.Setup(w => w.AppendAsync(It.IsAny<LedgerEventDraft>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((LedgerEventDraft draft, CancellationToken _) =>
|
||||
{
|
||||
var record = new LedgerEventRecord(
|
||||
draft.TenantId,
|
||||
draft.ChainId,
|
||||
draft.SequenceNumber,
|
||||
draft.EventId,
|
||||
draft.EventType,
|
||||
draft.PolicyVersion,
|
||||
draft.FindingId,
|
||||
draft.ArtifactId,
|
||||
draft.SourceRunId,
|
||||
draft.ActorId,
|
||||
draft.ActorType,
|
||||
draft.OccurredAt,
|
||||
draft.RecordedAt,
|
||||
draft.Payload,
|
||||
"event-hash",
|
||||
draft.ProvidedPreviousHash ?? LedgerEventConstants.EmptyHash,
|
||||
"merkle-leaf-hash",
|
||||
draft.CanonicalEnvelope.ToJsonString());
|
||||
return LedgerWriteResult.Success(record);
|
||||
});
|
||||
|
||||
_service = new AttestationPointerService(
|
||||
_ledgerEventRepository.Object,
|
||||
_writeService.Object,
|
||||
_repository,
|
||||
_timeProvider,
|
||||
NullLogger<AttestationPointerService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePointer_CreatesNewPointer()
|
||||
{
|
||||
var input = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-123",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:abc123def456789012345678901234567890123456789012345678901234abcd",
|
||||
AttestationId: Guid.NewGuid(),
|
||||
StorageUri: "s3://attestations/test.json",
|
||||
PayloadType: "application/vnd.in-toto+json",
|
||||
PredicateType: "https://slsa.dev/provenance/v1"),
|
||||
VerificationResult: new VerificationResult(
|
||||
Verified: true,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Verifier: "stellaops-attestor",
|
||||
VerifierVersion: "2025.01.0"),
|
||||
CreatedBy: "test-system");
|
||||
|
||||
var result = await _service.CreatePointerAsync(input);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.PointerId);
|
||||
Assert.NotNull(result.LedgerEventId);
|
||||
Assert.Null(result.Error);
|
||||
|
||||
var saved = await _repository.GetByIdAsync("tenant-1", result.PointerId!.Value, CancellationToken.None);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(input.FindingId, saved!.FindingId);
|
||||
Assert.Equal(input.AttestationType, saved.AttestationType);
|
||||
Assert.Equal(input.AttestationRef.Digest, saved.AttestationRef.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePointer_IsIdempotent()
|
||||
{
|
||||
var input = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-456",
|
||||
AttestationType: AttestationType.SlsaProvenance,
|
||||
Relationship: AttestationRelationship.AttestedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:def456789012345678901234567890123456789012345678901234567890abcd"),
|
||||
CreatedBy: "test-system");
|
||||
|
||||
var result1 = await _service.CreatePointerAsync(input);
|
||||
var result2 = await _service.CreatePointerAsync(input);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.PointerId, result2.PointerId);
|
||||
|
||||
var pointers = await _repository.GetByFindingIdAsync("tenant-1", "finding-456", CancellationToken.None);
|
||||
Assert.Single(pointers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPointers_ReturnsAllPointersForFinding()
|
||||
{
|
||||
var input1 = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-multi",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:aaa111222333444555666777888999000111222333444555666777888999000a"));
|
||||
|
||||
var input2 = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-multi",
|
||||
AttestationType: AttestationType.VexAttestation,
|
||||
Relationship: AttestationRelationship.DerivedFrom,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:bbb111222333444555666777888999000111222333444555666777888999000b"));
|
||||
|
||||
await _service.CreatePointerAsync(input1);
|
||||
await _service.CreatePointerAsync(input2);
|
||||
|
||||
var pointers = await _service.GetPointersAsync("tenant-1", "finding-multi");
|
||||
|
||||
Assert.Equal(2, pointers.Count);
|
||||
Assert.Contains(pointers, p => p.AttestationType == AttestationType.DsseEnvelope);
|
||||
Assert.Contains(pointers, p => p.AttestationType == AttestationType.VexAttestation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSummary_CalculatesCorrectCounts()
|
||||
{
|
||||
var verified = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-summary",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:ver111222333444555666777888999000111222333444555666777888999000a"),
|
||||
VerificationResult: new VerificationResult(Verified: true, VerifiedAt: _timeProvider.GetUtcNow()));
|
||||
|
||||
var unverified = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-summary",
|
||||
AttestationType: AttestationType.SbomAttestation,
|
||||
Relationship: AttestationRelationship.DerivedFrom,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:unv111222333444555666777888999000111222333444555666777888999000b"));
|
||||
|
||||
await _service.CreatePointerAsync(verified);
|
||||
await _service.CreatePointerAsync(unverified);
|
||||
|
||||
var summary = await _service.GetSummaryAsync("tenant-1", "finding-summary");
|
||||
|
||||
Assert.Equal("finding-summary", summary.FindingId);
|
||||
Assert.Equal(2, summary.AttestationCount);
|
||||
Assert.Equal(1, summary.VerifiedCount);
|
||||
Assert.Equal(OverallVerificationStatus.PartiallyVerified, summary.OverallVerificationStatus);
|
||||
Assert.Contains(AttestationType.DsseEnvelope, summary.AttestationTypes);
|
||||
Assert.Contains(AttestationType.SbomAttestation, summary.AttestationTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_FiltersByAttestationType()
|
||||
{
|
||||
var input1 = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-search-1",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:sea111222333444555666777888999000111222333444555666777888999000a"));
|
||||
|
||||
var input2 = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-search-2",
|
||||
AttestationType: AttestationType.SlsaProvenance,
|
||||
Relationship: AttestationRelationship.AttestedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:sea222333444555666777888999000111222333444555666777888999000111b"));
|
||||
|
||||
await _service.CreatePointerAsync(input1);
|
||||
await _service.CreatePointerAsync(input2);
|
||||
|
||||
var query = new AttestationPointerQuery(
|
||||
TenantId: "tenant-1",
|
||||
AttestationTypes: new[] { AttestationType.DsseEnvelope });
|
||||
|
||||
var results = await _service.SearchAsync(query);
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal(AttestationType.DsseEnvelope, results[0].AttestationType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateVerificationResult_UpdatesExistingPointer()
|
||||
{
|
||||
var input = new AttestationPointerInput(
|
||||
TenantId: "tenant-1",
|
||||
FindingId: "finding-update",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:upd111222333444555666777888999000111222333444555666777888999000a"));
|
||||
|
||||
var createResult = await _service.CreatePointerAsync(input);
|
||||
Assert.True(createResult.Success);
|
||||
|
||||
var verificationResult = new VerificationResult(
|
||||
Verified: true,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Verifier: "external-verifier",
|
||||
VerifierVersion: "1.0.0",
|
||||
Checks: new[]
|
||||
{
|
||||
new VerificationCheck(VerificationCheckType.SignatureValid, true, "ECDSA verified"),
|
||||
new VerificationCheck(VerificationCheckType.CertificateValid, true, "Chain verified")
|
||||
});
|
||||
|
||||
var success = await _service.UpdateVerificationResultAsync(
|
||||
"tenant-1", createResult.PointerId!.Value, verificationResult);
|
||||
|
||||
Assert.True(success);
|
||||
|
||||
var updated = await _repository.GetByIdAsync("tenant-1", createResult.PointerId!.Value, CancellationToken.None);
|
||||
Assert.NotNull(updated?.VerificationResult);
|
||||
Assert.True(updated.VerificationResult!.Verified);
|
||||
Assert.Equal("external-verifier", updated.VerificationResult.Verifier);
|
||||
Assert.Equal(2, updated.VerificationResult.Checks!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantIsolation_PreventsAccessAcrossTenants()
|
||||
{
|
||||
var input = new AttestationPointerInput(
|
||||
TenantId: "tenant-a",
|
||||
FindingId: "finding-isolated",
|
||||
AttestationType: AttestationType.DsseEnvelope,
|
||||
Relationship: AttestationRelationship.VerifiedBy,
|
||||
AttestationRef: new AttestationRef(
|
||||
Digest: "sha256:iso111222333444555666777888999000111222333444555666777888999000a"));
|
||||
|
||||
var result = await _service.CreatePointerAsync(input);
|
||||
Assert.True(result.Success);
|
||||
|
||||
var fromTenantA = await _service.GetPointersAsync("tenant-a", "finding-isolated");
|
||||
var fromTenantB = await _service.GetPointersAsync("tenant-b", "finding-isolated");
|
||||
|
||||
Assert.Single(fromTenantA);
|
||||
Assert.Empty(fromTenantB);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryAttestationPointerRepository : IAttestationPointerRepository
|
||||
{
|
||||
private readonly List<AttestationPointerRecord> _records = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_records.Add(record);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AttestationPointerRecord?> GetByIdAsync(string tenantId, Guid pointerId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var result = _records.FirstOrDefault(r => r.TenantId == tenantId && r.PointerId == pointerId);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestationPointerRecord>> GetByFindingIdAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _records
|
||||
.Where(r => r.TenantId == tenantId && r.FindingId == findingId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<AttestationPointerRecord>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestationPointerRecord>> GetByDigestAsync(string tenantId, string digest, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _records
|
||||
.Where(r => r.TenantId == tenantId && r.AttestationRef.Digest == digest)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<AttestationPointerRecord>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(AttestationPointerQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _records.Where(r => r.TenantId == query.TenantId);
|
||||
|
||||
if (query.FindingIds is { Count: > 0 })
|
||||
{
|
||||
results = results.Where(r => query.FindingIds.Contains(r.FindingId));
|
||||
}
|
||||
|
||||
if (query.AttestationTypes is { Count: > 0 })
|
||||
{
|
||||
results = results.Where(r => query.AttestationTypes.Contains(r.AttestationType));
|
||||
}
|
||||
|
||||
if (query.VerificationStatus.HasValue)
|
||||
{
|
||||
results = query.VerificationStatus.Value switch
|
||||
{
|
||||
AttestationVerificationFilter.Verified =>
|
||||
results.Where(r => r.VerificationResult?.Verified == true),
|
||||
AttestationVerificationFilter.Unverified =>
|
||||
results.Where(r => r.VerificationResult is null),
|
||||
AttestationVerificationFilter.Failed =>
|
||||
results.Where(r => r.VerificationResult?.Verified == false),
|
||||
_ => results
|
||||
};
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.CreatedAt >= query.CreatedAfter.Value);
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.CreatedAt <= query.CreatedBefore.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.SignerIdentity))
|
||||
{
|
||||
results = results.Where(r => r.AttestationRef.SignerInfo?.Subject == query.SignerIdentity);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PredicateType))
|
||||
{
|
||||
results = results.Where(r => r.AttestationRef.PredicateType == query.PredicateType);
|
||||
}
|
||||
|
||||
var list = results
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AttestationPointerRecord>>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<FindingAttestationSummary> GetSummaryAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var pointers = _records.Where(r => r.TenantId == tenantId && r.FindingId == findingId).ToList();
|
||||
|
||||
if (pointers.Count == 0)
|
||||
{
|
||||
return Task.FromResult(new FindingAttestationSummary(
|
||||
findingId, 0, 0, null, Array.Empty<AttestationType>(), OverallVerificationStatus.NoAttestations));
|
||||
}
|
||||
|
||||
var verifiedCount = pointers.Count(p => p.VerificationResult?.Verified == true);
|
||||
var latest = pointers.Max(p => p.CreatedAt);
|
||||
var types = pointers.Select(p => p.AttestationType).Distinct().ToList();
|
||||
|
||||
var status = pointers.Count switch
|
||||
{
|
||||
0 => OverallVerificationStatus.NoAttestations,
|
||||
_ when verifiedCount == pointers.Count => OverallVerificationStatus.AllVerified,
|
||||
_ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
|
||||
_ => OverallVerificationStatus.NoneVerified
|
||||
};
|
||||
|
||||
return Task.FromResult(new FindingAttestationSummary(
|
||||
findingId, pointers.Count, verifiedCount, latest, types, status));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(string tenantId, IReadOnlyList<string> findingIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = findingIds.Select(fid => GetSummaryAsync(tenantId, fid, cancellationToken));
|
||||
return Task.WhenAll(tasks).ContinueWith(t => (IReadOnlyList<FindingAttestationSummary>)t.Result.ToList());
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string tenantId, string findingId, string digest, AttestationType attestationType, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var exists = _records.Any(r =>
|
||||
r.TenantId == tenantId &&
|
||||
r.FindingId == findingId &&
|
||||
r.AttestationRef.Digest == digest &&
|
||||
r.AttestationType == attestationType);
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
}
|
||||
|
||||
public Task UpdateVerificationResultAsync(string tenantId, Guid pointerId, VerificationResult verificationResult, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var idx = _records.FindIndex(r => r.TenantId == tenantId && r.PointerId == pointerId);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var old = _records[idx];
|
||||
_records[idx] = old with { VerificationResult = verificationResult };
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> GetCountAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var count = _records.Count(r => r.TenantId == tenantId && r.FindingId == findingId);
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(string tenantId, AttestationVerificationFilter? verificationFilter, IReadOnlyList<AttestationType>? attestationTypes, int limit, int offset, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var results = _records.Where(r => r.TenantId == tenantId);
|
||||
|
||||
if (attestationTypes is { Count: > 0 })
|
||||
{
|
||||
results = results.Where(r => attestationTypes.Contains(r.AttestationType));
|
||||
}
|
||||
|
||||
if (verificationFilter.HasValue)
|
||||
{
|
||||
results = verificationFilter.Value switch
|
||||
{
|
||||
AttestationVerificationFilter.Verified =>
|
||||
results.Where(r => r.VerificationResult?.Verified == true),
|
||||
AttestationVerificationFilter.Unverified =>
|
||||
results.Where(r => r.VerificationResult is null),
|
||||
AttestationVerificationFilter.Failed =>
|
||||
results.Where(r => r.VerificationResult?.Verified == false),
|
||||
_ => results
|
||||
};
|
||||
}
|
||||
|
||||
var list = results
|
||||
.Select(r => r.FindingId)
|
||||
.Distinct()
|
||||
.OrderBy(f => f)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
namespace StellaOps.Findings.Ledger.Tests.Snapshot;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using Xunit;
|
||||
|
||||
public class SnapshotServiceTests
|
||||
{
|
||||
private readonly InMemorySnapshotRepository _snapshotRepository;
|
||||
private readonly InMemoryTimeTravelRepository _timeTravelRepository;
|
||||
private readonly SnapshotService _service;
|
||||
|
||||
public SnapshotServiceTests()
|
||||
{
|
||||
_snapshotRepository = new InMemorySnapshotRepository();
|
||||
_timeTravelRepository = new InMemoryTimeTravelRepository();
|
||||
_service = new SnapshotService(
|
||||
_snapshotRepository,
|
||||
_timeTravelRepository,
|
||||
NullLogger<SnapshotService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshotAsync_CreatesSnapshotSuccessfully()
|
||||
{
|
||||
var input = new CreateSnapshotInput(
|
||||
TenantId: "tenant-1",
|
||||
Label: "test-snapshot",
|
||||
Description: "Test description");
|
||||
|
||||
var result = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Snapshot);
|
||||
Assert.Equal("test-snapshot", result.Snapshot.Label);
|
||||
Assert.Equal(SnapshotStatus.Available, result.Snapshot.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshotAsync_WithExpiry_SetsExpiresAt()
|
||||
{
|
||||
var input = new CreateSnapshotInput(
|
||||
TenantId: "tenant-1",
|
||||
Label: "expiring-snapshot",
|
||||
ExpiresIn: TimeSpan.FromHours(24));
|
||||
|
||||
var result = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Snapshot?.ExpiresAt);
|
||||
Assert.True(result.Snapshot.ExpiresAt > DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshotAsync_ReturnsExistingSnapshot()
|
||||
{
|
||||
var input = new CreateSnapshotInput(TenantId: "tenant-1", Label: "get-test");
|
||||
var createResult = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
var snapshot = await _service.GetSnapshotAsync("tenant-1", createResult.Snapshot!.SnapshotId);
|
||||
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("get-test", snapshot.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshotAsync_ReturnsNullForNonExistent()
|
||||
{
|
||||
var snapshot = await _service.GetSnapshotAsync("tenant-1", Guid.NewGuid());
|
||||
|
||||
Assert.Null(snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListSnapshotsAsync_ReturnsAllSnapshots()
|
||||
{
|
||||
await _service.CreateSnapshotAsync(new CreateSnapshotInput("tenant-1", Label: "snap-1"));
|
||||
await _service.CreateSnapshotAsync(new CreateSnapshotInput("tenant-1", Label: "snap-2"));
|
||||
await _service.CreateSnapshotAsync(new CreateSnapshotInput("tenant-2", Label: "snap-3"));
|
||||
|
||||
var (snapshots, _) = await _service.ListSnapshotsAsync(new SnapshotListQuery("tenant-1"));
|
||||
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSnapshotAsync_MarksAsDeleted()
|
||||
{
|
||||
var input = new CreateSnapshotInput(TenantId: "tenant-1", Label: "to-delete");
|
||||
var createResult = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
var deleted = await _service.DeleteSnapshotAsync("tenant-1", createResult.Snapshot!.SnapshotId);
|
||||
|
||||
Assert.True(deleted);
|
||||
|
||||
var snapshot = await _service.GetSnapshotAsync("tenant-1", createResult.Snapshot.SnapshotId);
|
||||
Assert.Equal(SnapshotStatus.Deleted, snapshot?.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentPointAsync_ReturnsLatestPoint()
|
||||
{
|
||||
var point = await _service.GetCurrentPointAsync("tenant-1");
|
||||
|
||||
Assert.True(point.SequenceNumber >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryHistoricalFindingsAsync_ReturnsItems()
|
||||
{
|
||||
_timeTravelRepository.AddFinding("tenant-1", new FindingHistoryItem(
|
||||
"finding-1", "artifact-1", "CVE-2024-001", "open", 7.5m, "v1",
|
||||
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow, null));
|
||||
|
||||
var request = new HistoricalQueryRequest(
|
||||
"tenant-1", null, null, null, EntityType.Finding, null);
|
||||
|
||||
var result = await _service.QueryHistoricalFindingsAsync(request);
|
||||
|
||||
Assert.Single(result.Items);
|
||||
Assert.Equal("finding-1", result.Items[0].FindingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckStalenessAsync_ReturnsResult()
|
||||
{
|
||||
var result = await _service.CheckStalenessAsync("tenant-1", TimeSpan.FromHours(1));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.CheckedAt <= DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantIsolation_CannotAccessOtherTenantSnapshots()
|
||||
{
|
||||
var input = new CreateSnapshotInput(TenantId: "tenant-1", Label: "isolated");
|
||||
var createResult = await _service.CreateSnapshotAsync(input);
|
||||
|
||||
var snapshot = await _service.GetSnapshotAsync("tenant-2", createResult.Snapshot!.SnapshotId);
|
||||
|
||||
Assert.Null(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation for testing.
|
||||
/// </summary>
|
||||
internal class InMemorySnapshotRepository : ISnapshotRepository
|
||||
{
|
||||
private readonly List<LedgerSnapshot> _snapshots = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task<LedgerSnapshot> CreateAsync(
|
||||
string tenantId,
|
||||
CreateSnapshotInput input,
|
||||
long currentSequence,
|
||||
DateTimeOffset currentTimestamp,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var snapshot = new LedgerSnapshot(
|
||||
tenantId,
|
||||
Guid.NewGuid(),
|
||||
input.Label,
|
||||
input.Description,
|
||||
SnapshotStatus.Creating,
|
||||
DateTimeOffset.UtcNow,
|
||||
input.ExpiresIn.HasValue ? DateTimeOffset.UtcNow.Add(input.ExpiresIn.Value) : null,
|
||||
input.AtSequence ?? currentSequence,
|
||||
input.AtTimestamp ?? currentTimestamp,
|
||||
new SnapshotStatistics(0, 0, 0, 0, 0, 0),
|
||||
null,
|
||||
null,
|
||||
input.Metadata);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_snapshots.Add(snapshot);
|
||||
}
|
||||
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
|
||||
public Task<LedgerSnapshot?> GetByIdAsync(string tenantId, Guid snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var snapshot = _snapshots.FirstOrDefault(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<(IReadOnlyList<LedgerSnapshot> Snapshots, string? NextPageToken)> ListAsync(
|
||||
SnapshotListQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var filtered = _snapshots
|
||||
.Where(s => s.TenantId == query.TenantId)
|
||||
.Where(s => !query.Status.HasValue || s.Status == query.Status.Value)
|
||||
.Take(query.PageSize)
|
||||
.ToList();
|
||||
return Task.FromResult<(IReadOnlyList<LedgerSnapshot>, string?)>((filtered, null));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> UpdateStatusAsync(string tenantId, Guid snapshotId, SnapshotStatus newStatus, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = _snapshots.FindIndex(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
|
||||
if (index < 0) return Task.FromResult(false);
|
||||
|
||||
_snapshots[index] = _snapshots[index] with { Status = newStatus };
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> UpdateStatisticsAsync(string tenantId, Guid snapshotId, SnapshotStatistics statistics, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = _snapshots.FindIndex(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
|
||||
if (index < 0) return Task.FromResult(false);
|
||||
|
||||
_snapshots[index] = _snapshots[index] with { Statistics = statistics };
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> SetMerkleRootAsync(string tenantId, Guid snapshotId, string merkleRoot, string? dsseDigest, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var index = _snapshots.FindIndex(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
|
||||
if (index < 0) return Task.FromResult(false);
|
||||
|
||||
_snapshots[index] = _snapshots[index] with { MerkleRoot = merkleRoot, DsseDigest = dsseDigest };
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> ExpireSnapshotsAsync(DateTimeOffset cutoff, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var count = 0;
|
||||
for (int i = 0; i < _snapshots.Count; i++)
|
||||
{
|
||||
if (_snapshots[i].ExpiresAt.HasValue &&
|
||||
_snapshots[i].ExpiresAt < cutoff &&
|
||||
_snapshots[i].Status == SnapshotStatus.Available)
|
||||
{
|
||||
_snapshots[i] = _snapshots[i] with { Status = SnapshotStatus.Expired };
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
return UpdateStatusAsync(tenantId, snapshotId, SnapshotStatus.Deleted, ct);
|
||||
}
|
||||
|
||||
public Task<LedgerSnapshot?> GetLatestAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var snapshot = _snapshots
|
||||
.Where(s => s.TenantId == tenantId && s.Status == SnapshotStatus.Available)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string tenantId, Guid snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_snapshots.Any(s => s.TenantId == tenantId && s.SnapshotId == snapshotId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory time-travel repository for testing.
|
||||
/// </summary>
|
||||
internal class InMemoryTimeTravelRepository : ITimeTravelRepository
|
||||
{
|
||||
private readonly Dictionary<string, List<FindingHistoryItem>> _findings = new();
|
||||
private readonly Dictionary<string, List<VexHistoryItem>> _vex = new();
|
||||
private readonly Dictionary<string, List<AdvisoryHistoryItem>> _advisories = new();
|
||||
private readonly Dictionary<string, List<ReplayEvent>> _events = new();
|
||||
private long _currentSequence = 100;
|
||||
|
||||
public void AddFinding(string tenantId, FindingHistoryItem finding)
|
||||
{
|
||||
if (!_findings.ContainsKey(tenantId))
|
||||
_findings[tenantId] = new List<FindingHistoryItem>();
|
||||
_findings[tenantId].Add(finding);
|
||||
}
|
||||
|
||||
public Task<QueryPoint> GetCurrentPointAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new QueryPoint(DateTimeOffset.UtcNow, _currentSequence));
|
||||
}
|
||||
|
||||
public Task<QueryPoint?> ResolveQueryPointAsync(string tenantId, DateTimeOffset? timestamp, long? sequence, Guid? snapshotId, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<QueryPoint?>(new QueryPoint(timestamp ?? DateTimeOffset.UtcNow, sequence ?? _currentSequence, snapshotId));
|
||||
}
|
||||
|
||||
public Task<HistoricalQueryResponse<FindingHistoryItem>> QueryFindingsAsync(HistoricalQueryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var items = _findings.TryGetValue(request.TenantId, out var list) ? list : new List<FindingHistoryItem>();
|
||||
var queryPoint = new QueryPoint(DateTimeOffset.UtcNow, _currentSequence);
|
||||
return Task.FromResult(new HistoricalQueryResponse<FindingHistoryItem>(queryPoint, EntityType.Finding, items, null, items.Count));
|
||||
}
|
||||
|
||||
public Task<HistoricalQueryResponse<VexHistoryItem>> QueryVexAsync(HistoricalQueryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var items = _vex.TryGetValue(request.TenantId, out var list) ? list : new List<VexHistoryItem>();
|
||||
var queryPoint = new QueryPoint(DateTimeOffset.UtcNow, _currentSequence);
|
||||
return Task.FromResult(new HistoricalQueryResponse<VexHistoryItem>(queryPoint, EntityType.Vex, items, null, items.Count));
|
||||
}
|
||||
|
||||
public Task<HistoricalQueryResponse<AdvisoryHistoryItem>> QueryAdvisoriesAsync(HistoricalQueryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var items = _advisories.TryGetValue(request.TenantId, out var list) ? list : new List<AdvisoryHistoryItem>();
|
||||
var queryPoint = new QueryPoint(DateTimeOffset.UtcNow, _currentSequence);
|
||||
return Task.FromResult(new HistoricalQueryResponse<AdvisoryHistoryItem>(queryPoint, EntityType.Advisory, items, null, items.Count));
|
||||
}
|
||||
|
||||
public Task<(IReadOnlyList<ReplayEvent> Events, ReplayMetadata Metadata)> ReplayEventsAsync(ReplayRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var items = _events.TryGetValue(request.TenantId, out var list) ? list : new List<ReplayEvent>();
|
||||
var metadata = new ReplayMetadata(0, _currentSequence, items.Count, false, 10);
|
||||
return Task.FromResult<(IReadOnlyList<ReplayEvent>, ReplayMetadata)>((items, metadata));
|
||||
}
|
||||
|
||||
public Task<DiffResponse> ComputeDiffAsync(DiffRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var fromPoint = new QueryPoint(request.From.Timestamp ?? DateTimeOffset.UtcNow.AddHours(-1), request.From.SequenceNumber ?? 0);
|
||||
var toPoint = new QueryPoint(request.To.Timestamp ?? DateTimeOffset.UtcNow, request.To.SequenceNumber ?? _currentSequence);
|
||||
var summary = new DiffSummary(0, 0, 0, 0);
|
||||
return Task.FromResult(new DiffResponse(fromPoint, toPoint, summary, null, null));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ChangeLogEntry>> GetChangelogAsync(string tenantId, EntityType entityType, string entityId, int limit = 100, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ChangeLogEntry>>(new List<ChangeLogEntry>());
|
||||
}
|
||||
|
||||
public Task<StalenessResult> CheckStalenessAsync(string tenantId, TimeSpan threshold, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new StalenessResult(
|
||||
false,
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
threshold,
|
||||
TimeSpan.FromMinutes(5)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record CreateAttestationPointerRequest(
|
||||
string FindingId,
|
||||
string AttestationType,
|
||||
string Relationship,
|
||||
AttestationRefDto AttestationRef,
|
||||
VerificationResultDto? VerificationResult = null,
|
||||
string? CreatedBy = null,
|
||||
Dictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an attestation artifact.
|
||||
/// </summary>
|
||||
public sealed record AttestationRefDto(
|
||||
string Digest,
|
||||
string? AttestationId = null,
|
||||
string? StorageUri = null,
|
||||
string? PayloadType = null,
|
||||
string? PredicateType = null,
|
||||
IReadOnlyList<string>? SubjectDigests = null,
|
||||
SignerInfoDto? SignerInfo = null,
|
||||
RekorEntryRefDto? RekorEntry = null);
|
||||
|
||||
/// <summary>
|
||||
/// Information about the attestation signer.
|
||||
/// </summary>
|
||||
public sealed record SignerInfoDto(
|
||||
string? KeyId = null,
|
||||
string? Issuer = null,
|
||||
string? Subject = null,
|
||||
IReadOnlyList<string>? CertificateChain = null,
|
||||
DateTimeOffset? SignedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to Rekor transparency log entry.
|
||||
/// </summary>
|
||||
public sealed record RekorEntryRefDto(
|
||||
long? LogIndex = null,
|
||||
string? LogId = null,
|
||||
string? Uuid = null,
|
||||
long? IntegratedTime = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record VerificationResultDto(
|
||||
bool Verified,
|
||||
DateTimeOffset VerifiedAt,
|
||||
string? Verifier = null,
|
||||
string? VerifierVersion = null,
|
||||
string? PolicyRef = null,
|
||||
IReadOnlyList<VerificationCheckDto>? Checks = null,
|
||||
IReadOnlyList<string>? Warnings = null,
|
||||
IReadOnlyList<string>? Errors = null);
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification check result.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheckDto(
|
||||
string CheckType,
|
||||
bool Passed,
|
||||
string? Details = null,
|
||||
Dictionary<string, object>? Evidence = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response for creating an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record CreateAttestationPointerResponse(
|
||||
bool Success,
|
||||
string? PointerId,
|
||||
string? LedgerEventId,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Response for getting attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerResponse(
|
||||
string PointerId,
|
||||
string FindingId,
|
||||
string AttestationType,
|
||||
string Relationship,
|
||||
AttestationRefDto AttestationRef,
|
||||
VerificationResultDto? VerificationResult,
|
||||
DateTimeOffset CreatedAt,
|
||||
string CreatedBy,
|
||||
Dictionary<string, object>? Metadata,
|
||||
string? LedgerEventId);
|
||||
|
||||
/// <summary>
|
||||
/// Response for attestation summary.
|
||||
/// </summary>
|
||||
public sealed record AttestationSummaryResponse(
|
||||
string FindingId,
|
||||
int AttestationCount,
|
||||
int VerifiedCount,
|
||||
DateTimeOffset? LatestAttestation,
|
||||
IReadOnlyList<string> AttestationTypes,
|
||||
string OverallVerificationStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for searching attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerSearchRequest(
|
||||
IReadOnlyList<string>? FindingIds = null,
|
||||
IReadOnlyList<string>? AttestationTypes = null,
|
||||
string? VerificationStatus = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
string? SignerIdentity = null,
|
||||
string? PredicateType = null,
|
||||
int Limit = 100,
|
||||
int Offset = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Response for searching attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerSearchResponse(
|
||||
IReadOnlyList<AttestationPointerResponse> Pointers,
|
||||
int TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Request to update verification result.
|
||||
/// </summary>
|
||||
public sealed record UpdateVerificationResultRequest(
|
||||
VerificationResultDto VerificationResult);
|
||||
|
||||
/// <summary>
|
||||
/// Mapping extensions for attestation pointer DTOs.
|
||||
/// </summary>
|
||||
public static class AttestationPointerMappings
|
||||
{
|
||||
public static AttestationPointerInput ToInput(this CreateAttestationPointerRequest request, string tenantId)
|
||||
{
|
||||
if (!Enum.TryParse<AttestationType>(request.AttestationType, ignoreCase: true, out var attestationType))
|
||||
{
|
||||
throw new ArgumentException($"Invalid attestation type: {request.AttestationType}");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<AttestationRelationship>(request.Relationship, ignoreCase: true, out var relationship))
|
||||
{
|
||||
throw new ArgumentException($"Invalid relationship: {request.Relationship}");
|
||||
}
|
||||
|
||||
return new AttestationPointerInput(
|
||||
tenantId,
|
||||
request.FindingId,
|
||||
attestationType,
|
||||
relationship,
|
||||
request.AttestationRef.ToModel(),
|
||||
request.VerificationResult?.ToModel(),
|
||||
request.CreatedBy,
|
||||
request.Metadata);
|
||||
}
|
||||
|
||||
public static AttestationRef ToModel(this AttestationRefDto dto)
|
||||
{
|
||||
return new AttestationRef(
|
||||
dto.Digest,
|
||||
dto.AttestationId is not null ? Guid.Parse(dto.AttestationId) : null,
|
||||
dto.StorageUri,
|
||||
dto.PayloadType,
|
||||
dto.PredicateType,
|
||||
dto.SubjectDigests,
|
||||
dto.SignerInfo?.ToModel(),
|
||||
dto.RekorEntry?.ToModel());
|
||||
}
|
||||
|
||||
public static SignerInfo ToModel(this SignerInfoDto dto)
|
||||
{
|
||||
return new SignerInfo(
|
||||
dto.KeyId,
|
||||
dto.Issuer,
|
||||
dto.Subject,
|
||||
dto.CertificateChain,
|
||||
dto.SignedAt);
|
||||
}
|
||||
|
||||
public static RekorEntryRef ToModel(this RekorEntryRefDto dto)
|
||||
{
|
||||
return new RekorEntryRef(
|
||||
dto.LogIndex,
|
||||
dto.LogId,
|
||||
dto.Uuid,
|
||||
dto.IntegratedTime);
|
||||
}
|
||||
|
||||
public static VerificationResult ToModel(this VerificationResultDto dto)
|
||||
{
|
||||
return new VerificationResult(
|
||||
dto.Verified,
|
||||
dto.VerifiedAt,
|
||||
dto.Verifier,
|
||||
dto.VerifierVersion,
|
||||
dto.PolicyRef,
|
||||
dto.Checks?.Select(c => c.ToModel()).ToList(),
|
||||
dto.Warnings,
|
||||
dto.Errors);
|
||||
}
|
||||
|
||||
public static VerificationCheck ToModel(this VerificationCheckDto dto)
|
||||
{
|
||||
if (!Enum.TryParse<VerificationCheckType>(dto.CheckType, ignoreCase: true, out var checkType))
|
||||
{
|
||||
throw new ArgumentException($"Invalid check type: {dto.CheckType}");
|
||||
}
|
||||
|
||||
return new VerificationCheck(checkType, dto.Passed, dto.Details, dto.Evidence);
|
||||
}
|
||||
|
||||
public static AttestationPointerResponse ToResponse(this AttestationPointerRecord record)
|
||||
{
|
||||
return new AttestationPointerResponse(
|
||||
record.PointerId.ToString(),
|
||||
record.FindingId,
|
||||
record.AttestationType.ToString(),
|
||||
record.Relationship.ToString(),
|
||||
record.AttestationRef.ToDto(),
|
||||
record.VerificationResult?.ToDto(),
|
||||
record.CreatedAt,
|
||||
record.CreatedBy,
|
||||
record.Metadata,
|
||||
record.LedgerEventId?.ToString());
|
||||
}
|
||||
|
||||
public static AttestationRefDto ToDto(this AttestationRef model)
|
||||
{
|
||||
return new AttestationRefDto(
|
||||
model.Digest,
|
||||
model.AttestationId?.ToString(),
|
||||
model.StorageUri,
|
||||
model.PayloadType,
|
||||
model.PredicateType,
|
||||
model.SubjectDigests,
|
||||
model.SignerInfo?.ToDto(),
|
||||
model.RekorEntry?.ToDto());
|
||||
}
|
||||
|
||||
public static SignerInfoDto ToDto(this SignerInfo model)
|
||||
{
|
||||
return new SignerInfoDto(
|
||||
model.KeyId,
|
||||
model.Issuer,
|
||||
model.Subject,
|
||||
model.CertificateChain,
|
||||
model.SignedAt);
|
||||
}
|
||||
|
||||
public static RekorEntryRefDto ToDto(this RekorEntryRef model)
|
||||
{
|
||||
return new RekorEntryRefDto(
|
||||
model.LogIndex,
|
||||
model.LogId,
|
||||
model.Uuid,
|
||||
model.IntegratedTime);
|
||||
}
|
||||
|
||||
public static VerificationResultDto ToDto(this VerificationResult model)
|
||||
{
|
||||
return new VerificationResultDto(
|
||||
model.Verified,
|
||||
model.VerifiedAt,
|
||||
model.Verifier,
|
||||
model.VerifierVersion,
|
||||
model.PolicyRef,
|
||||
model.Checks?.Select(c => c.ToDto()).ToList(),
|
||||
model.Warnings,
|
||||
model.Errors);
|
||||
}
|
||||
|
||||
public static VerificationCheckDto ToDto(this VerificationCheck model)
|
||||
{
|
||||
return new VerificationCheckDto(
|
||||
model.CheckType.ToString(),
|
||||
model.Passed,
|
||||
model.Details,
|
||||
model.Evidence);
|
||||
}
|
||||
|
||||
public static AttestationSummaryResponse ToResponse(this FindingAttestationSummary summary)
|
||||
{
|
||||
return new AttestationSummaryResponse(
|
||||
summary.FindingId,
|
||||
summary.AttestationCount,
|
||||
summary.VerifiedCount,
|
||||
summary.LatestAttestation,
|
||||
summary.AttestationTypes.Select(t => t.ToString()).ToList(),
|
||||
summary.OverallVerificationStatus.ToString());
|
||||
}
|
||||
|
||||
public static AttestationPointerQuery ToQuery(this AttestationPointerSearchRequest request, string tenantId)
|
||||
{
|
||||
IReadOnlyList<AttestationType>? attestationTypes = null;
|
||||
if (request.AttestationTypes is { Count: > 0 })
|
||||
{
|
||||
attestationTypes = request.AttestationTypes
|
||||
.Where(t => Enum.TryParse<AttestationType>(t, ignoreCase: true, out _))
|
||||
.Select(t => Enum.Parse<AttestationType>(t, ignoreCase: true))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
AttestationVerificationFilter? verificationFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.VerificationStatus))
|
||||
{
|
||||
if (Enum.TryParse<AttestationVerificationFilter>(request.VerificationStatus, ignoreCase: true, out var filter))
|
||||
{
|
||||
verificationFilter = filter;
|
||||
}
|
||||
}
|
||||
|
||||
return new AttestationPointerQuery(
|
||||
tenantId,
|
||||
request.FindingIds,
|
||||
attestationTypes,
|
||||
verificationFilter,
|
||||
request.CreatedAfter,
|
||||
request.CreatedBefore,
|
||||
request.SignerIdentity,
|
||||
request.PredicateType,
|
||||
request.Limit,
|
||||
request.Offset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
namespace StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
|
||||
// === Snapshot Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotRequest(
|
||||
string? Label = null,
|
||||
string? Description = null,
|
||||
DateTimeOffset? AtTimestamp = null,
|
||||
long? AtSequence = null,
|
||||
int? ExpiresInHours = null,
|
||||
IReadOnlyList<string>? IncludeEntityTypes = null,
|
||||
bool Sign = false,
|
||||
Dictionary<string, object>? Metadata = null)
|
||||
{
|
||||
public CreateSnapshotInput ToInput(string tenantId) => new(
|
||||
TenantId: tenantId,
|
||||
Label: Label,
|
||||
Description: Description,
|
||||
AtTimestamp: AtTimestamp,
|
||||
AtSequence: AtSequence,
|
||||
ExpiresIn: ExpiresInHours.HasValue ? TimeSpan.FromHours(ExpiresInHours.Value) : null,
|
||||
IncludeEntityTypes: IncludeEntityTypes?.Select(ParseEntityType).ToList(),
|
||||
Sign: Sign,
|
||||
Metadata: Metadata);
|
||||
|
||||
private static EntityType ParseEntityType(string s) =>
|
||||
Enum.TryParse<EntityType>(s, true, out var et) ? et : EntityType.Finding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a snapshot.
|
||||
/// </summary>
|
||||
public sealed record SnapshotResponse(
|
||||
Guid SnapshotId,
|
||||
string? Label,
|
||||
string? Description,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
SnapshotStatisticsResponse Statistics,
|
||||
string? MerkleRoot,
|
||||
string? DsseDigest,
|
||||
Dictionary<string, object>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Response for snapshot statistics.
|
||||
/// </summary>
|
||||
public sealed record SnapshotStatisticsResponse(
|
||||
long FindingsCount,
|
||||
long VexStatementsCount,
|
||||
long AdvisoriesCount,
|
||||
long SbomsCount,
|
||||
long EventsCount,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotResponse(
|
||||
bool Success,
|
||||
SnapshotResponse? Snapshot,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing snapshots.
|
||||
/// </summary>
|
||||
public sealed record SnapshotListResponse(
|
||||
IReadOnlyList<SnapshotResponse> Snapshots,
|
||||
string? NextPageToken);
|
||||
|
||||
// === Time-Travel Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryApiRequest(
|
||||
DateTimeOffset? AtTimestamp = null,
|
||||
long? AtSequence = null,
|
||||
Guid? SnapshotId = null,
|
||||
string? Status = null,
|
||||
decimal? SeverityMin = null,
|
||||
decimal? SeverityMax = null,
|
||||
string? PolicyVersion = null,
|
||||
string? ArtifactId = null,
|
||||
string? VulnId = null,
|
||||
int PageSize = 500,
|
||||
string? PageToken = null)
|
||||
{
|
||||
public HistoricalQueryRequest ToRequest(string tenantId, EntityType entityType) => new(
|
||||
TenantId: tenantId,
|
||||
AtTimestamp: AtTimestamp,
|
||||
AtSequence: AtSequence,
|
||||
SnapshotId: SnapshotId,
|
||||
EntityType: entityType,
|
||||
Filters: new TimeQueryFilters(
|
||||
Status: Status,
|
||||
SeverityMin: SeverityMin,
|
||||
SeverityMax: SeverityMax,
|
||||
PolicyVersion: PolicyVersion,
|
||||
ArtifactId: ArtifactId,
|
||||
VulnId: VulnId),
|
||||
PageSize: PageSize,
|
||||
PageToken: PageToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryApiResponse<T>(
|
||||
QueryPointResponse QueryPoint,
|
||||
string EntityType,
|
||||
IReadOnlyList<T> Items,
|
||||
string? NextPageToken,
|
||||
long TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Query point response.
|
||||
/// </summary>
|
||||
public sealed record QueryPointResponse(
|
||||
DateTimeOffset Timestamp,
|
||||
long SequenceNumber,
|
||||
Guid? SnapshotId);
|
||||
|
||||
/// <summary>
|
||||
/// Finding history item response.
|
||||
/// </summary>
|
||||
public sealed record FindingHistoryResponse(
|
||||
string FindingId,
|
||||
string ArtifactId,
|
||||
string VulnId,
|
||||
string Status,
|
||||
decimal? Severity,
|
||||
string? PolicyVersion,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastUpdated,
|
||||
Dictionary<string, string>? Labels);
|
||||
|
||||
/// <summary>
|
||||
/// VEX history item response.
|
||||
/// </summary>
|
||||
public sealed record VexHistoryResponse(
|
||||
string StatementId,
|
||||
string VulnId,
|
||||
string ProductId,
|
||||
string Status,
|
||||
string? Justification,
|
||||
DateTimeOffset IssuedAt,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Advisory history item response.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryHistoryResponse(
|
||||
string AdvisoryId,
|
||||
string Source,
|
||||
string Title,
|
||||
decimal? CvssScore,
|
||||
DateTimeOffset PublishedAt,
|
||||
DateTimeOffset? ModifiedAt);
|
||||
|
||||
// === Replay Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request for replaying events.
|
||||
/// </summary>
|
||||
public sealed record ReplayApiRequest(
|
||||
long? FromSequence = null,
|
||||
long? ToSequence = null,
|
||||
DateTimeOffset? FromTimestamp = null,
|
||||
DateTimeOffset? ToTimestamp = null,
|
||||
IReadOnlyList<Guid>? ChainIds = null,
|
||||
IReadOnlyList<string>? EventTypes = null,
|
||||
bool IncludePayload = true,
|
||||
int PageSize = 1000)
|
||||
{
|
||||
public ReplayRequest ToRequest(string tenantId) => new(
|
||||
TenantId: tenantId,
|
||||
FromSequence: FromSequence,
|
||||
ToSequence: ToSequence,
|
||||
FromTimestamp: FromTimestamp,
|
||||
ToTimestamp: ToTimestamp,
|
||||
ChainIds: ChainIds,
|
||||
EventTypes: EventTypes,
|
||||
IncludePayload: IncludePayload,
|
||||
PageSize: PageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for replay.
|
||||
/// </summary>
|
||||
public sealed record ReplayApiResponse(
|
||||
IReadOnlyList<ReplayEventResponse> Events,
|
||||
ReplayMetadataResponse Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Replay event response.
|
||||
/// </summary>
|
||||
public sealed record ReplayEventResponse(
|
||||
Guid EventId,
|
||||
long SequenceNumber,
|
||||
Guid ChainId,
|
||||
int ChainSequence,
|
||||
string EventType,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? ActorId,
|
||||
string? ActorType,
|
||||
string? ArtifactId,
|
||||
string? FindingId,
|
||||
string? PolicyVersion,
|
||||
string EventHash,
|
||||
string PreviousHash,
|
||||
object? Payload);
|
||||
|
||||
/// <summary>
|
||||
/// Replay metadata response.
|
||||
/// </summary>
|
||||
public sealed record ReplayMetadataResponse(
|
||||
long FromSequence,
|
||||
long ToSequence,
|
||||
long EventsCount,
|
||||
bool HasMore,
|
||||
long ReplayDurationMs);
|
||||
|
||||
// === Diff Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Request for computing diff.
|
||||
/// </summary>
|
||||
public sealed record DiffApiRequest(
|
||||
DiffPointRequest From,
|
||||
DiffPointRequest To,
|
||||
IReadOnlyList<string>? EntityTypes = null,
|
||||
bool IncludeUnchanged = false,
|
||||
string OutputFormat = "Summary")
|
||||
{
|
||||
public DiffRequest ToRequest(string tenantId) => new(
|
||||
TenantId: tenantId,
|
||||
From: From.ToDiffPoint(),
|
||||
To: To.ToDiffPoint(),
|
||||
EntityTypes: EntityTypes?.Select(ParseEntityType).ToList(),
|
||||
IncludeUnchanged: IncludeUnchanged,
|
||||
OutputFormat: Enum.TryParse<DiffOutputFormat>(OutputFormat, true, out var fmt)
|
||||
? fmt : DiffOutputFormat.Summary);
|
||||
|
||||
private static EntityType ParseEntityType(string s) =>
|
||||
Enum.TryParse<EntityType>(s, true, out var et) ? et : EntityType.Finding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff point request.
|
||||
/// </summary>
|
||||
public sealed record DiffPointRequest(
|
||||
DateTimeOffset? Timestamp = null,
|
||||
long? SequenceNumber = null,
|
||||
Guid? SnapshotId = null)
|
||||
{
|
||||
public DiffPoint ToDiffPoint() => new(Timestamp, SequenceNumber, SnapshotId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for diff.
|
||||
/// </summary>
|
||||
public sealed record DiffApiResponse(
|
||||
QueryPointResponse FromPoint,
|
||||
QueryPointResponse ToPoint,
|
||||
DiffSummaryResponse Summary,
|
||||
IReadOnlyList<DiffEntryResponse>? Changes,
|
||||
string? NextPageToken);
|
||||
|
||||
/// <summary>
|
||||
/// Diff summary response.
|
||||
/// </summary>
|
||||
public sealed record DiffSummaryResponse(
|
||||
int Added,
|
||||
int Modified,
|
||||
int Removed,
|
||||
int Unchanged,
|
||||
Dictionary<string, DiffCountsResponse>? ByEntityType);
|
||||
|
||||
/// <summary>
|
||||
/// Diff counts response.
|
||||
/// </summary>
|
||||
public sealed record DiffCountsResponse(int Added, int Modified, int Removed);
|
||||
|
||||
/// <summary>
|
||||
/// Diff entry response.
|
||||
/// </summary>
|
||||
public sealed record DiffEntryResponse(
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
string ChangeType,
|
||||
object? FromState,
|
||||
object? ToState,
|
||||
IReadOnlyList<string>? ChangedFields);
|
||||
|
||||
// === Changelog Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Changelog entry response.
|
||||
/// </summary>
|
||||
public sealed record ChangeLogEntryResponse(
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
string EventType,
|
||||
string? EventHash,
|
||||
string? ActorId,
|
||||
string? Summary);
|
||||
|
||||
// === Staleness Contracts ===
|
||||
|
||||
/// <summary>
|
||||
/// Staleness check response.
|
||||
/// </summary>
|
||||
public sealed record StalenessResponse(
|
||||
bool IsStale,
|
||||
DateTimeOffset CheckedAt,
|
||||
DateTimeOffset? LastEventAt,
|
||||
string StalenessThreshold,
|
||||
string? StalenessDuration,
|
||||
Dictionary<string, EntityStalenessResponse>? ByEntityType);
|
||||
|
||||
/// <summary>
|
||||
/// Entity staleness response.
|
||||
/// </summary>
|
||||
public sealed record EntityStalenessResponse(
|
||||
bool IsStale,
|
||||
DateTimeOffset? LastEventAt,
|
||||
long EventsBehind);
|
||||
|
||||
// === Extension Methods ===
|
||||
|
||||
public static class SnapshotExtensions
|
||||
{
|
||||
public static SnapshotResponse ToResponse(this LedgerSnapshot snapshot) => new(
|
||||
SnapshotId: snapshot.SnapshotId,
|
||||
Label: snapshot.Label,
|
||||
Description: snapshot.Description,
|
||||
Status: snapshot.Status.ToString(),
|
||||
CreatedAt: snapshot.CreatedAt,
|
||||
ExpiresAt: snapshot.ExpiresAt,
|
||||
SequenceNumber: snapshot.SequenceNumber,
|
||||
Timestamp: snapshot.Timestamp,
|
||||
Statistics: snapshot.Statistics.ToResponse(),
|
||||
MerkleRoot: snapshot.MerkleRoot,
|
||||
DsseDigest: snapshot.DsseDigest,
|
||||
Metadata: snapshot.Metadata);
|
||||
|
||||
public static SnapshotStatisticsResponse ToResponse(this SnapshotStatistics stats) => new(
|
||||
FindingsCount: stats.FindingsCount,
|
||||
VexStatementsCount: stats.VexStatementsCount,
|
||||
AdvisoriesCount: stats.AdvisoriesCount,
|
||||
SbomsCount: stats.SbomsCount,
|
||||
EventsCount: stats.EventsCount,
|
||||
SizeBytes: stats.SizeBytes);
|
||||
|
||||
public static QueryPointResponse ToResponse(this QueryPoint point) => new(
|
||||
Timestamp: point.Timestamp,
|
||||
SequenceNumber: point.SequenceNumber,
|
||||
SnapshotId: point.SnapshotId);
|
||||
|
||||
public static FindingHistoryResponse ToResponse(this FindingHistoryItem item) => new(
|
||||
FindingId: item.FindingId,
|
||||
ArtifactId: item.ArtifactId,
|
||||
VulnId: item.VulnId,
|
||||
Status: item.Status,
|
||||
Severity: item.Severity,
|
||||
PolicyVersion: item.PolicyVersion,
|
||||
FirstSeen: item.FirstSeen,
|
||||
LastUpdated: item.LastUpdated,
|
||||
Labels: item.Labels);
|
||||
|
||||
public static VexHistoryResponse ToResponse(this VexHistoryItem item) => new(
|
||||
StatementId: item.StatementId,
|
||||
VulnId: item.VulnId,
|
||||
ProductId: item.ProductId,
|
||||
Status: item.Status,
|
||||
Justification: item.Justification,
|
||||
IssuedAt: item.IssuedAt,
|
||||
ExpiresAt: item.ExpiresAt);
|
||||
|
||||
public static AdvisoryHistoryResponse ToResponse(this AdvisoryHistoryItem item) => new(
|
||||
AdvisoryId: item.AdvisoryId,
|
||||
Source: item.Source,
|
||||
Title: item.Title,
|
||||
CvssScore: item.CvssScore,
|
||||
PublishedAt: item.PublishedAt,
|
||||
ModifiedAt: item.ModifiedAt);
|
||||
|
||||
public static ReplayEventResponse ToResponse(this ReplayEvent e) => new(
|
||||
EventId: e.EventId,
|
||||
SequenceNumber: e.SequenceNumber,
|
||||
ChainId: e.ChainId,
|
||||
ChainSequence: e.ChainSequence,
|
||||
EventType: e.EventType,
|
||||
OccurredAt: e.OccurredAt,
|
||||
RecordedAt: e.RecordedAt,
|
||||
ActorId: e.ActorId,
|
||||
ActorType: e.ActorType,
|
||||
ArtifactId: e.ArtifactId,
|
||||
FindingId: e.FindingId,
|
||||
PolicyVersion: e.PolicyVersion,
|
||||
EventHash: e.EventHash,
|
||||
PreviousHash: e.PreviousHash,
|
||||
Payload: e.Payload);
|
||||
|
||||
public static ReplayMetadataResponse ToResponse(this ReplayMetadata m) => new(
|
||||
FromSequence: m.FromSequence,
|
||||
ToSequence: m.ToSequence,
|
||||
EventsCount: m.EventsCount,
|
||||
HasMore: m.HasMore,
|
||||
ReplayDurationMs: m.ReplayDurationMs);
|
||||
|
||||
public static DiffSummaryResponse ToResponse(this DiffSummary summary) => new(
|
||||
Added: summary.Added,
|
||||
Modified: summary.Modified,
|
||||
Removed: summary.Removed,
|
||||
Unchanged: summary.Unchanged,
|
||||
ByEntityType: summary.ByEntityType?.ToDictionary(
|
||||
kv => kv.Key.ToString(),
|
||||
kv => new DiffCountsResponse(kv.Value.Added, kv.Value.Modified, kv.Value.Removed)));
|
||||
|
||||
public static DiffEntryResponse ToResponse(this DiffEntry entry) => new(
|
||||
EntityType: entry.EntityType.ToString(),
|
||||
EntityId: entry.EntityId,
|
||||
ChangeType: entry.ChangeType.ToString(),
|
||||
FromState: entry.FromState,
|
||||
ToState: entry.ToState,
|
||||
ChangedFields: entry.ChangedFields);
|
||||
|
||||
public static ChangeLogEntryResponse ToResponse(this ChangeLogEntry entry) => new(
|
||||
SequenceNumber: entry.SequenceNumber,
|
||||
Timestamp: entry.Timestamp,
|
||||
EntityType: entry.EntityType.ToString(),
|
||||
EntityId: entry.EntityId,
|
||||
EventType: entry.EventType,
|
||||
EventHash: entry.EventHash,
|
||||
ActorId: entry.ActorId,
|
||||
Summary: entry.Summary);
|
||||
|
||||
public static StalenessResponse ToResponse(this StalenessResult result) => new(
|
||||
IsStale: result.IsStale,
|
||||
CheckedAt: result.CheckedAt,
|
||||
LastEventAt: result.LastEventAt,
|
||||
StalenessThreshold: result.StalenessThreshold.ToString(),
|
||||
StalenessDuration: result.StalenessDuration?.ToString(),
|
||||
ByEntityType: result.ByEntityType?.ToDictionary(
|
||||
kv => kv.Key.ToString(),
|
||||
kv => new EntityStalenessResponse(kv.Value.IsStale, kv.Value.LastEventAt, kv.Value.EventsBehind)));
|
||||
}
|
||||
@@ -155,6 +155,14 @@ builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
|
||||
builder.Services.AddHostedService<LedgerProjectionWorker>();
|
||||
builder.Services.AddSingleton<ExportQueryService>();
|
||||
builder.Services.AddSingleton<AttestationQueryService>();
|
||||
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Attestation.IAttestationPointerRepository,
|
||||
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresAttestationPointerRepository>();
|
||||
builder.Services.AddSingleton<AttestationPointerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ISnapshotRepository,
|
||||
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresSnapshotRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ITimeTravelRepository,
|
||||
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresTimeTravelRepository>();
|
||||
builder.Services.AddSingleton<SnapshotService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -633,6 +641,206 @@ app.MapPost("/internal/ledger/airgap-import", async Task<Results<Accepted<Airgap
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
// Attestation Pointer Endpoints (LEDGER-ATTEST-73-001)
|
||||
app.MapPost("/v1/ledger/attestation-pointers", async Task<Results<Created<CreateAttestationPointerResponse>, Ok<CreateAttestationPointerResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
CreateAttestationPointerRequest request,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var input = request.ToInput(tenantId);
|
||||
var result = await service.CreatePointerAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new CreateAttestationPointerResponse(
|
||||
result.Success,
|
||||
result.PointerId?.ToString(),
|
||||
result.LedgerEventId?.ToString(),
|
||||
result.Error);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "attestation_pointer_failed",
|
||||
detail: result.Error);
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/v1/ledger/attestation-pointers/{result.PointerId}", response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_request",
|
||||
detail: ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("CreateAttestationPointer")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/attestation-pointers/{pointerId}", async Task<Results<JsonHttpResult<AttestationPointerResponse>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string pointerId,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(pointerId, out var pointerGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_pointer_id",
|
||||
detail: "Pointer ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
var pointer = await service.GetPointerAsync(tenantId, pointerGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (pointer is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Json(pointer.ToResponse());
|
||||
})
|
||||
.WithName("GetAttestationPointer")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/findings/{findingId}/attestation-pointers", async Task<Results<JsonHttpResult<IReadOnlyList<AttestationPointerResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string findingId,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var pointers = await service.GetPointersAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
|
||||
IReadOnlyList<AttestationPointerResponse> responseList = pointers.Select(p => p.ToResponse()).ToList();
|
||||
return TypedResults.Json(responseList);
|
||||
})
|
||||
.WithName("GetFindingAttestationPointers")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/findings/{findingId}/attestation-summary", async Task<Results<JsonHttpResult<AttestationSummaryResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string findingId,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var summary = await service.GetSummaryAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
|
||||
return TypedResults.Json(summary.ToResponse());
|
||||
})
|
||||
.WithName("GetFindingAttestationSummary")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapPost("/v1/ledger/attestation-pointers/search", async Task<Results<JsonHttpResult<AttestationPointerSearchResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
AttestationPointerSearchRequest request,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var query = request.ToQuery(tenantId);
|
||||
var pointers = await service.SearchAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new AttestationPointerSearchResponse(
|
||||
pointers.Select(p => p.ToResponse()).ToList(),
|
||||
pointers.Count);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_request",
|
||||
detail: ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("SearchAttestationPointers")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapPut("/v1/ledger/attestation-pointers/{pointerId}/verification", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string pointerId,
|
||||
UpdateVerificationResultRequest request,
|
||||
AttestationPointerService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(pointerId, out var pointerGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_pointer_id",
|
||||
detail: "Pointer ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var verificationResult = request.VerificationResult.ToModel();
|
||||
var success = await service.UpdateVerificationResultAsync(tenantId, pointerGuid, verificationResult, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_request",
|
||||
detail: ex.Message);
|
||||
}
|
||||
})
|
||||
.WithName("UpdateAttestationPointerVerification")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/.well-known/openapi", () =>
|
||||
{
|
||||
var contentRoot = AppContext.BaseDirectory;
|
||||
@@ -649,6 +857,383 @@ app.MapGet("/.well-known/openapi", () =>
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// Snapshot Endpoints (LEDGER-PACKS-42-001-DEV)
|
||||
app.MapPost("/v1/ledger/snapshots", async Task<Results<Created<CreateSnapshotResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
CreateSnapshotRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var input = request.ToInput(tenantId);
|
||||
var result = await service.CreateSnapshotAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new CreateSnapshotResponse(
|
||||
result.Success,
|
||||
result.Snapshot?.ToResponse(),
|
||||
result.Error);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "snapshot_creation_failed",
|
||||
detail: result.Error);
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/v1/ledger/snapshots/{result.Snapshot!.SnapshotId}", response);
|
||||
})
|
||||
.WithName("CreateSnapshot")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/snapshots", async Task<Results<JsonHttpResult<SnapshotListResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var statusStr = httpContext.Request.Query["status"].ToString();
|
||||
Domain.SnapshotStatus? status = null;
|
||||
if (!string.IsNullOrEmpty(statusStr) && Enum.TryParse<Domain.SnapshotStatus>(statusStr, true, out var parsedStatus))
|
||||
{
|
||||
status = parsedStatus;
|
||||
}
|
||||
|
||||
var query = new Domain.SnapshotListQuery(
|
||||
tenantId,
|
||||
status,
|
||||
ParseDate(httpContext.Request.Query["created_after"]),
|
||||
ParseDate(httpContext.Request.Query["created_before"]),
|
||||
ParseInt(httpContext.Request.Query["page_size"]) ?? 100,
|
||||
httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var (snapshots, nextPageToken) = await service.ListSnapshotsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new SnapshotListResponse(
|
||||
snapshots.Select(s => s.ToResponse()).ToList(),
|
||||
nextPageToken);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ListSnapshots")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/snapshots/{snapshotId}", async Task<Results<JsonHttpResult<SnapshotResponse>, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string snapshotId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_snapshot_id",
|
||||
detail: "Snapshot ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
var snapshot = await service.GetSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Json(snapshot.ToResponse());
|
||||
})
|
||||
.WithName("GetSnapshot")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapDelete("/v1/ledger/snapshots/{snapshotId}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string snapshotId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_snapshot_id",
|
||||
detail: "Snapshot ID must be a valid GUID.");
|
||||
}
|
||||
|
||||
var deleted = await service.DeleteSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
})
|
||||
.WithName("DeleteSnapshot")
|
||||
.RequireAuthorization(LedgerWritePolicy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Time-Travel Query Endpoints
|
||||
app.MapGet("/v1/ledger/time-travel/findings", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<FindingHistoryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var request = new HistoricalQueryApiRequest(
|
||||
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
||||
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
||||
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
||||
Status: httpContext.Request.Query["status"].ToString(),
|
||||
SeverityMin: ParseDecimal(httpContext.Request.Query["severity_min"]),
|
||||
SeverityMax: ParseDecimal(httpContext.Request.Query["severity_max"]),
|
||||
PolicyVersion: httpContext.Request.Query["policy_version"].ToString(),
|
||||
ArtifactId: httpContext.Request.Query["artifact_id"].ToString(),
|
||||
VulnId: httpContext.Request.Query["vuln_id"].ToString(),
|
||||
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
||||
PageToken: httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Finding);
|
||||
var result = await service.QueryHistoricalFindingsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new HistoricalQueryApiResponse<FindingHistoryResponse>(
|
||||
result.QueryPoint.ToResponse(),
|
||||
"Finding",
|
||||
result.Items.Select(i => i.ToResponse()).ToList(),
|
||||
result.NextPageToken,
|
||||
result.TotalCount);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("TimeTravelQueryFindings")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/time-travel/vex", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<VexHistoryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var request = new HistoricalQueryApiRequest(
|
||||
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
||||
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
||||
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
||||
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
||||
PageToken: httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Vex);
|
||||
var result = await service.QueryHistoricalVexAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new HistoricalQueryApiResponse<VexHistoryResponse>(
|
||||
result.QueryPoint.ToResponse(),
|
||||
"Vex",
|
||||
result.Items.Select(i => i.ToResponse()).ToList(),
|
||||
result.NextPageToken,
|
||||
result.TotalCount);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("TimeTravelQueryVex")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.MapGet("/v1/ledger/time-travel/advisories", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<AdvisoryHistoryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var request = new HistoricalQueryApiRequest(
|
||||
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
||||
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
||||
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
||||
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
||||
PageToken: httpContext.Request.Query["page_token"].ToString());
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Advisory);
|
||||
var result = await service.QueryHistoricalAdvisoriesAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new HistoricalQueryApiResponse<AdvisoryHistoryResponse>(
|
||||
result.QueryPoint.ToResponse(),
|
||||
"Advisory",
|
||||
result.Items.Select(i => i.ToResponse()).ToList(),
|
||||
result.NextPageToken,
|
||||
result.TotalCount);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("TimeTravelQueryAdvisories")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Replay Endpoint
|
||||
app.MapPost("/v1/ledger/replay", async Task<Results<JsonHttpResult<ReplayApiResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
ReplayApiRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId);
|
||||
var (events, metadata) = await service.ReplayEventsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new ReplayApiResponse(
|
||||
events.Select(e => e.ToResponse()).ToList(),
|
||||
metadata.ToResponse());
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ReplayEvents")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Diff Endpoint
|
||||
app.MapPost("/v1/ledger/diff", async Task<Results<JsonHttpResult<DiffApiResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
DiffApiRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var domainRequest = request.ToRequest(tenantId);
|
||||
var result = await service.ComputeDiffAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new DiffApiResponse(
|
||||
result.FromPoint.ToResponse(),
|
||||
result.ToPoint.ToResponse(),
|
||||
result.Summary.ToResponse(),
|
||||
result.Changes?.Select(c => c.ToResponse()).ToList(),
|
||||
result.NextPageToken);
|
||||
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("ComputeDiff")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Changelog Endpoint
|
||||
app.MapGet("/v1/ledger/changelog/{entityType}/{entityId}", async Task<Results<JsonHttpResult<IReadOnlyList<ChangeLogEntryResponse>>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string entityType,
|
||||
string entityId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<Domain.EntityType>(entityType, true, out var parsedEntityType))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "invalid_entity_type",
|
||||
detail: "Entity type must be one of: Finding, Vex, Advisory, Sbom, Evidence.");
|
||||
}
|
||||
|
||||
var limit = ParseInt(httpContext.Request.Query["limit"]) ?? 100;
|
||||
var changelog = await service.GetChangelogAsync(tenantId, parsedEntityType, entityId, limit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<ChangeLogEntryResponse> response = changelog.Select(e => e.ToResponse()).ToList();
|
||||
return TypedResults.Json(response);
|
||||
})
|
||||
.WithName("GetChangelog")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Staleness Check Endpoint
|
||||
app.MapGet("/v1/ledger/staleness", async Task<Results<JsonHttpResult<StalenessResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var thresholdMinutes = ParseInt(httpContext.Request.Query["threshold_minutes"]) ?? 60;
|
||||
var threshold = TimeSpan.FromMinutes(thresholdMinutes);
|
||||
|
||||
var result = await service.CheckStalenessAsync(tenantId, threshold, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Json(result.ToResponse());
|
||||
})
|
||||
.WithName("CheckStaleness")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Current Point Endpoint
|
||||
app.MapGet("/v1/ledger/current-point", async Task<Results<JsonHttpResult<QueryPointResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var point = await service.GetCurrentPointAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return TypedResults.Json(point.ToResponse());
|
||||
})
|
||||
.WithName("GetCurrentPoint")
|
||||
.RequireAuthorization(LedgerExportPolicy)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.Run();
|
||||
|
||||
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)
|
||||
@@ -738,3 +1323,8 @@ static bool? ParseBool(string value)
|
||||
{
|
||||
return bool.TryParse(value, out var result) ? result : null;
|
||||
}
|
||||
|
||||
static Guid? ParseGuid(string value)
|
||||
{
|
||||
return Guid.TryParse(value, out var result) ? result : null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public static class LedgerEventConstants
|
||||
public const string EventEvidenceSnapshotLinked = "airgap.evidence_snapshot_linked";
|
||||
public const string EventAirgapTimelineImpact = "airgap.timeline_impact";
|
||||
public const string EventOrchestratorExportRecorded = "orchestrator.export_recorded";
|
||||
public const string EventAttestationPointerLinked = "attestation.pointer_linked";
|
||||
|
||||
public static readonly ImmutableHashSet<string> SupportedEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,
|
||||
EventFindingCreated,
|
||||
@@ -33,7 +34,8 @@ public static class LedgerEventConstants
|
||||
EventAirgapBundleImported,
|
||||
EventEvidenceSnapshotLinked,
|
||||
EventAirgapTimelineImpact,
|
||||
EventOrchestratorExportRecorded);
|
||||
EventOrchestratorExportRecorded,
|
||||
EventAttestationPointerLinked);
|
||||
|
||||
public static readonly ImmutableHashSet<string> FindingEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,
|
||||
EventFindingCreated,
|
||||
|
||||
281
src/Findings/StellaOps.Findings.Ledger/Domain/SnapshotModels.cs
Normal file
281
src/Findings/StellaOps.Findings.Ledger/Domain/SnapshotModels.cs
Normal file
@@ -0,0 +1,281 @@
|
||||
namespace StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a point-in-time snapshot of ledger state.
|
||||
/// </summary>
|
||||
public sealed record LedgerSnapshot(
|
||||
string TenantId,
|
||||
Guid SnapshotId,
|
||||
string? Label,
|
||||
string? Description,
|
||||
SnapshotStatus Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
SnapshotStatistics Statistics,
|
||||
string? MerkleRoot,
|
||||
string? DsseDigest,
|
||||
Dictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot lifecycle status.
|
||||
/// </summary>
|
||||
public enum SnapshotStatus
|
||||
{
|
||||
Creating,
|
||||
Available,
|
||||
Exporting,
|
||||
Expired,
|
||||
Deleted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for a snapshot.
|
||||
/// </summary>
|
||||
public sealed record SnapshotStatistics(
|
||||
long FindingsCount,
|
||||
long VexStatementsCount,
|
||||
long AdvisoriesCount,
|
||||
long SbomsCount,
|
||||
long EventsCount,
|
||||
long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Input for creating a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotInput(
|
||||
string TenantId,
|
||||
string? Label = null,
|
||||
string? Description = null,
|
||||
DateTimeOffset? AtTimestamp = null,
|
||||
long? AtSequence = null,
|
||||
TimeSpan? ExpiresIn = null,
|
||||
IReadOnlyList<EntityType>? IncludeEntityTypes = null,
|
||||
bool Sign = false,
|
||||
Dictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a snapshot.
|
||||
/// </summary>
|
||||
public sealed record CreateSnapshotResult(
|
||||
bool Success,
|
||||
LedgerSnapshot? Snapshot,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Entity types tracked in the ledger.
|
||||
/// </summary>
|
||||
public enum EntityType
|
||||
{
|
||||
Finding,
|
||||
Vex,
|
||||
Advisory,
|
||||
Sbom,
|
||||
Evidence
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query point specification (timestamp or sequence).
|
||||
/// </summary>
|
||||
public sealed record QueryPoint(
|
||||
DateTimeOffset Timestamp,
|
||||
long SequenceNumber,
|
||||
Guid? SnapshotId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Filters for time-travel queries.
|
||||
/// </summary>
|
||||
public sealed record TimeQueryFilters(
|
||||
string? Status = null,
|
||||
decimal? SeverityMin = null,
|
||||
decimal? SeverityMax = null,
|
||||
string? PolicyVersion = null,
|
||||
string? ArtifactId = null,
|
||||
string? VulnId = null,
|
||||
Dictionary<string, string>? Labels = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryRequest(
|
||||
string TenantId,
|
||||
DateTimeOffset? AtTimestamp,
|
||||
long? AtSequence,
|
||||
Guid? SnapshotId,
|
||||
EntityType EntityType,
|
||||
TimeQueryFilters? Filters,
|
||||
int PageSize = 500,
|
||||
string? PageToken = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response for historical query.
|
||||
/// </summary>
|
||||
public sealed record HistoricalQueryResponse<T>(
|
||||
QueryPoint QueryPoint,
|
||||
EntityType EntityType,
|
||||
IReadOnlyList<T> Items,
|
||||
string? NextPageToken,
|
||||
long TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Request for replaying events.
|
||||
/// </summary>
|
||||
public sealed record ReplayRequest(
|
||||
string TenantId,
|
||||
long? FromSequence = null,
|
||||
long? ToSequence = null,
|
||||
DateTimeOffset? FromTimestamp = null,
|
||||
DateTimeOffset? ToTimestamp = null,
|
||||
IReadOnlyList<Guid>? ChainIds = null,
|
||||
IReadOnlyList<string>? EventTypes = null,
|
||||
bool IncludePayload = true,
|
||||
int PageSize = 1000);
|
||||
|
||||
/// <summary>
|
||||
/// Replayed event record.
|
||||
/// </summary>
|
||||
public sealed record ReplayEvent(
|
||||
Guid EventId,
|
||||
long SequenceNumber,
|
||||
Guid ChainId,
|
||||
int ChainSequence,
|
||||
string EventType,
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset RecordedAt,
|
||||
string? ActorId,
|
||||
string? ActorType,
|
||||
string? ArtifactId,
|
||||
string? FindingId,
|
||||
string? PolicyVersion,
|
||||
string EventHash,
|
||||
string PreviousHash,
|
||||
object? Payload);
|
||||
|
||||
/// <summary>
|
||||
/// Replay metadata.
|
||||
/// </summary>
|
||||
public sealed record ReplayMetadata(
|
||||
long FromSequence,
|
||||
long ToSequence,
|
||||
long EventsCount,
|
||||
bool HasMore,
|
||||
long ReplayDurationMs);
|
||||
|
||||
/// <summary>
|
||||
/// Request for computing diff between two points.
|
||||
/// </summary>
|
||||
public sealed record DiffRequest(
|
||||
string TenantId,
|
||||
DiffPoint From,
|
||||
DiffPoint To,
|
||||
IReadOnlyList<EntityType>? EntityTypes = null,
|
||||
bool IncludeUnchanged = false,
|
||||
DiffOutputFormat OutputFormat = DiffOutputFormat.Summary);
|
||||
|
||||
/// <summary>
|
||||
/// Diff point specification.
|
||||
/// </summary>
|
||||
public sealed record DiffPoint(
|
||||
DateTimeOffset? Timestamp = null,
|
||||
long? SequenceNumber = null,
|
||||
Guid? SnapshotId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Diff output format.
|
||||
/// </summary>
|
||||
public enum DiffOutputFormat
|
||||
{
|
||||
Summary,
|
||||
Detailed,
|
||||
Full
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff summary counts.
|
||||
/// </summary>
|
||||
public sealed record DiffSummary(
|
||||
int Added,
|
||||
int Modified,
|
||||
int Removed,
|
||||
int Unchanged,
|
||||
Dictionary<EntityType, DiffCounts>? ByEntityType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Diff counts per entity type.
|
||||
/// </summary>
|
||||
public sealed record DiffCounts(int Added, int Modified, int Removed);
|
||||
|
||||
/// <summary>
|
||||
/// Individual diff entry.
|
||||
/// </summary>
|
||||
public sealed record DiffEntry(
|
||||
EntityType EntityType,
|
||||
string EntityId,
|
||||
DiffChangeType ChangeType,
|
||||
object? FromState,
|
||||
object? ToState,
|
||||
IReadOnlyList<string>? ChangedFields);
|
||||
|
||||
/// <summary>
|
||||
/// Type of change in a diff.
|
||||
/// </summary>
|
||||
public enum DiffChangeType
|
||||
{
|
||||
Added,
|
||||
Modified,
|
||||
Removed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff response.
|
||||
/// </summary>
|
||||
public sealed record DiffResponse(
|
||||
QueryPoint FromPoint,
|
||||
QueryPoint ToPoint,
|
||||
DiffSummary Summary,
|
||||
IReadOnlyList<DiffEntry>? Changes,
|
||||
string? NextPageToken);
|
||||
|
||||
/// <summary>
|
||||
/// Changelog entry.
|
||||
/// </summary>
|
||||
public sealed record ChangeLogEntry(
|
||||
long SequenceNumber,
|
||||
DateTimeOffset Timestamp,
|
||||
EntityType EntityType,
|
||||
string EntityId,
|
||||
string EventType,
|
||||
string? EventHash,
|
||||
string? ActorId,
|
||||
string? Summary);
|
||||
|
||||
/// <summary>
|
||||
/// Staleness check result.
|
||||
/// </summary>
|
||||
public sealed record StalenessResult(
|
||||
bool IsStale,
|
||||
DateTimeOffset CheckedAt,
|
||||
DateTimeOffset? LastEventAt,
|
||||
TimeSpan StalenessThreshold,
|
||||
TimeSpan? StalenessDuration,
|
||||
Dictionary<EntityType, EntityStaleness>? ByEntityType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Staleness per entity type.
|
||||
/// </summary>
|
||||
public sealed record EntityStaleness(
|
||||
bool IsStale,
|
||||
DateTimeOffset? LastEventAt,
|
||||
long EventsBehind);
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing snapshots.
|
||||
/// </summary>
|
||||
public sealed record SnapshotListQuery(
|
||||
string TenantId,
|
||||
SnapshotStatus? Status = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
int PageSize = 100,
|
||||
string? PageToken = null);
|
||||
@@ -0,0 +1,184 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Record representing an attestation pointer linking a finding to a verification report or attestation envelope.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerRecord(
|
||||
string TenantId,
|
||||
Guid PointerId,
|
||||
string FindingId,
|
||||
AttestationType AttestationType,
|
||||
AttestationRelationship Relationship,
|
||||
AttestationRef AttestationRef,
|
||||
VerificationResult? VerificationResult,
|
||||
DateTimeOffset CreatedAt,
|
||||
string CreatedBy,
|
||||
Dictionary<string, object>? Metadata = null,
|
||||
Guid? LedgerEventId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of attestation being pointed to.
|
||||
/// </summary>
|
||||
public enum AttestationType
|
||||
{
|
||||
VerificationReport,
|
||||
DsseEnvelope,
|
||||
SlsaProvenance,
|
||||
VexAttestation,
|
||||
SbomAttestation,
|
||||
ScanAttestation,
|
||||
PolicyAttestation,
|
||||
ApprovalAttestation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Semantic relationship between finding and attestation.
|
||||
/// </summary>
|
||||
public enum AttestationRelationship
|
||||
{
|
||||
VerifiedBy,
|
||||
AttestedBy,
|
||||
SignedBy,
|
||||
ApprovedBy,
|
||||
DerivedFrom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an attestation artifact.
|
||||
/// </summary>
|
||||
public sealed record AttestationRef(
|
||||
string Digest,
|
||||
Guid? AttestationId = null,
|
||||
string? StorageUri = null,
|
||||
string? PayloadType = null,
|
||||
string? PredicateType = null,
|
||||
IReadOnlyList<string>? SubjectDigests = null,
|
||||
SignerInfo? SignerInfo = null,
|
||||
RekorEntryRef? RekorEntry = null);
|
||||
|
||||
/// <summary>
|
||||
/// Information about the attestation signer.
|
||||
/// </summary>
|
||||
public sealed record SignerInfo(
|
||||
string? KeyId = null,
|
||||
string? Issuer = null,
|
||||
string? Subject = null,
|
||||
IReadOnlyList<string>? CertificateChain = null,
|
||||
DateTimeOffset? SignedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to Rekor transparency log entry.
|
||||
/// </summary>
|
||||
public sealed record RekorEntryRef(
|
||||
long? LogIndex = null,
|
||||
string? LogId = null,
|
||||
string? Uuid = null,
|
||||
long? IntegratedTime = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation verification.
|
||||
/// </summary>
|
||||
public sealed record VerificationResult(
|
||||
bool Verified,
|
||||
DateTimeOffset VerifiedAt,
|
||||
string? Verifier = null,
|
||||
string? VerifierVersion = null,
|
||||
string? PolicyRef = null,
|
||||
IReadOnlyList<VerificationCheck>? Checks = null,
|
||||
IReadOnlyList<string>? Warnings = null,
|
||||
IReadOnlyList<string>? Errors = null);
|
||||
|
||||
/// <summary>
|
||||
/// Individual verification check result.
|
||||
/// </summary>
|
||||
public sealed record VerificationCheck(
|
||||
VerificationCheckType CheckType,
|
||||
bool Passed,
|
||||
string? Details = null,
|
||||
Dictionary<string, object>? Evidence = null);
|
||||
|
||||
/// <summary>
|
||||
/// Type of verification check performed.
|
||||
/// </summary>
|
||||
public enum VerificationCheckType
|
||||
{
|
||||
SignatureValid,
|
||||
CertificateValid,
|
||||
CertificateNotExpired,
|
||||
CertificateNotRevoked,
|
||||
RekorEntryValid,
|
||||
TimestampValid,
|
||||
PolicyMet,
|
||||
IdentityVerified,
|
||||
IssuerTrusted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for creating an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerInput(
|
||||
string TenantId,
|
||||
string FindingId,
|
||||
AttestationType AttestationType,
|
||||
AttestationRelationship Relationship,
|
||||
AttestationRef AttestationRef,
|
||||
VerificationResult? VerificationResult = null,
|
||||
string? CreatedBy = null,
|
||||
Dictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating an attestation pointer.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerResult(
|
||||
bool Success,
|
||||
Guid? PointerId,
|
||||
Guid? LedgerEventId,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of attestations for a finding.
|
||||
/// </summary>
|
||||
public sealed record FindingAttestationSummary(
|
||||
string FindingId,
|
||||
int AttestationCount,
|
||||
int VerifiedCount,
|
||||
DateTimeOffset? LatestAttestation,
|
||||
IReadOnlyList<AttestationType> AttestationTypes,
|
||||
OverallVerificationStatus OverallVerificationStatus);
|
||||
|
||||
/// <summary>
|
||||
/// Overall verification status for a finding's attestations.
|
||||
/// </summary>
|
||||
public enum OverallVerificationStatus
|
||||
{
|
||||
AllVerified,
|
||||
PartiallyVerified,
|
||||
NoneVerified,
|
||||
NoAttestations
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for searching attestation pointers.
|
||||
/// </summary>
|
||||
public sealed record AttestationPointerQuery(
|
||||
string TenantId,
|
||||
IReadOnlyList<string>? FindingIds = null,
|
||||
IReadOnlyList<AttestationType>? AttestationTypes = null,
|
||||
AttestationVerificationFilter? VerificationStatus = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
string? SignerIdentity = null,
|
||||
string? PredicateType = null,
|
||||
int Limit = 100,
|
||||
int Offset = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Filter for verification status.
|
||||
/// </summary>
|
||||
public enum AttestationVerificationFilter
|
||||
{
|
||||
Any,
|
||||
Verified,
|
||||
Unverified,
|
||||
Failed
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for managing attestation pointers linking findings to verification reports and attestation envelopes.
|
||||
/// </summary>
|
||||
public interface IAttestationPointerRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts a new attestation pointer.
|
||||
/// </summary>
|
||||
Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attestation pointer by ID.
|
||||
/// </summary>
|
||||
Task<AttestationPointerRecord?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all attestation pointers for a finding.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AttestationPointerRecord>> GetByFindingIdAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation pointers by attestation digest.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AttestationPointerRecord>> GetByDigestAsync(
|
||||
string tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Searches attestation pointers based on query parameters.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(
|
||||
AttestationPointerQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation summary for a finding.
|
||||
/// </summary>
|
||||
Task<FindingAttestationSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation summaries for multiple findings.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an attestation pointer already exists (for idempotency).
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
string digest,
|
||||
AttestationType attestationType,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the verification result for an attestation pointer.
|
||||
/// </summary>
|
||||
Task UpdateVerificationResultAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
VerificationResult verificationResult,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of attestation pointers for a finding.
|
||||
/// </summary>
|
||||
Task<int> GetCountAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets findings that have attestations matching the criteria.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(
|
||||
string tenantId,
|
||||
AttestationVerificationFilter? verificationFilter,
|
||||
IReadOnlyList<AttestationType>? attestationTypes,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Postgres-backed repository for attestation pointers.
|
||||
/// </summary>
|
||||
public sealed class PostgresAttestationPointerRepository : IAttestationPointerRepository
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresAttestationPointerRepository> _logger;
|
||||
|
||||
public PostgresAttestationPointerRepository(
|
||||
LedgerDataSource dataSource,
|
||||
ILogger<PostgresAttestationPointerRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO ledger_attestation_pointers (
|
||||
tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
) VALUES (
|
||||
@tenant_id, @pointer_id, @finding_id, @attestation_type, @relationship,
|
||||
@attestation_ref::jsonb, @verification_result::jsonb, @created_at, @created_by,
|
||||
@metadata::jsonb, @ledger_event_id
|
||||
)
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
record.TenantId, "attestation_pointer_write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", record.TenantId);
|
||||
command.Parameters.AddWithValue("pointer_id", record.PointerId);
|
||||
command.Parameters.AddWithValue("finding_id", record.FindingId);
|
||||
command.Parameters.AddWithValue("attestation_type", record.AttestationType.ToString());
|
||||
command.Parameters.AddWithValue("relationship", record.Relationship.ToString());
|
||||
command.Parameters.AddWithValue("attestation_ref", JsonSerializer.Serialize(record.AttestationRef, JsonOptions));
|
||||
command.Parameters.AddWithValue("verification_result",
|
||||
record.VerificationResult is not null
|
||||
? JsonSerializer.Serialize(record.VerificationResult, JsonOptions)
|
||||
: DBNull.Value);
|
||||
command.Parameters.AddWithValue("created_at", record.CreatedAt);
|
||||
command.Parameters.AddWithValue("created_by", record.CreatedBy);
|
||||
command.Parameters.AddWithValue("metadata",
|
||||
record.Metadata is not null
|
||||
? JsonSerializer.Serialize(record.Metadata, JsonOptions)
|
||||
: DBNull.Value);
|
||||
command.Parameters.AddWithValue("ledger_event_id",
|
||||
record.LedgerEventId.HasValue ? record.LedgerEventId.Value : DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Inserted attestation pointer {PointerId} for finding {FindingId} with type {AttestationType}",
|
||||
record.PointerId, record.FindingId, record.AttestationType);
|
||||
}
|
||||
|
||||
public async Task<AttestationPointerRecord?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
const string sql = """
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND pointer_id = @pointer_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("pointer_id", pointerId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return ReadRecord(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> GetByFindingIdAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
const string sql = """
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND finding_id = @finding_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
|
||||
return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> GetByDigestAsync(
|
||||
string tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
const string sql = """
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND attestation_ref->>'digest' = @digest
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("digest", digest);
|
||||
|
||||
return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(
|
||||
AttestationPointerQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.TenantId);
|
||||
|
||||
var sqlBuilder = new StringBuilder("""
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
metadata, ledger_event_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenant_id", query.TenantId) { NpgsqlDbType = NpgsqlDbType.Text }
|
||||
};
|
||||
|
||||
if (query.FindingIds is { Count: > 0 })
|
||||
{
|
||||
sqlBuilder.Append(" AND finding_id = ANY(@finding_ids)");
|
||||
parameters.Add(new NpgsqlParameter<string[]>("finding_ids", query.FindingIds.ToArray()));
|
||||
}
|
||||
|
||||
if (query.AttestationTypes is { Count: > 0 })
|
||||
{
|
||||
sqlBuilder.Append(" AND attestation_type = ANY(@attestation_types)");
|
||||
parameters.Add(new NpgsqlParameter<string[]>("attestation_types",
|
||||
query.AttestationTypes.Select(t => t.ToString()).ToArray()));
|
||||
}
|
||||
|
||||
if (query.VerificationStatus.HasValue && query.VerificationStatus.Value != AttestationVerificationFilter.Any)
|
||||
{
|
||||
sqlBuilder.Append(query.VerificationStatus.Value switch
|
||||
{
|
||||
AttestationVerificationFilter.Verified =>
|
||||
" AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = true",
|
||||
AttestationVerificationFilter.Unverified =>
|
||||
" AND verification_result IS NULL",
|
||||
AttestationVerificationFilter.Failed =>
|
||||
" AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = false",
|
||||
_ => ""
|
||||
});
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND created_at >= @created_after");
|
||||
parameters.Add(new NpgsqlParameter<DateTimeOffset>("created_after", query.CreatedAfter.Value)
|
||||
{
|
||||
NpgsqlDbType = NpgsqlDbType.TimestampTz
|
||||
});
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND created_at <= @created_before");
|
||||
parameters.Add(new NpgsqlParameter<DateTimeOffset>("created_before", query.CreatedBefore.Value)
|
||||
{
|
||||
NpgsqlDbType = NpgsqlDbType.TimestampTz
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.SignerIdentity))
|
||||
{
|
||||
sqlBuilder.Append(" AND attestation_ref->'signer_info'->>'subject' = @signer_identity");
|
||||
parameters.Add(new NpgsqlParameter<string>("signer_identity", query.SignerIdentity));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PredicateType))
|
||||
{
|
||||
sqlBuilder.Append(" AND attestation_ref->>'predicate_type' = @predicate_type");
|
||||
parameters.Add(new NpgsqlParameter<string>("predicate_type", query.PredicateType));
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY created_at DESC");
|
||||
sqlBuilder.Append(" LIMIT @limit OFFSET @offset");
|
||||
parameters.Add(new NpgsqlParameter<int>("limit", query.Limit));
|
||||
parameters.Add(new NpgsqlParameter<int>("offset", query.Offset));
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
query.TenantId, "attestation_pointer_search", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sqlBuilder.ToString(), connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
command.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<FindingAttestationSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
|
||||
AND (verification_result->>'verified')::boolean = true) as verified_count,
|
||||
MAX(created_at) as latest_attestation,
|
||||
array_agg(DISTINCT attestation_type) as attestation_types
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND finding_id = @finding_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_summary", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var totalCount = reader.GetInt32(0);
|
||||
var verifiedCount = reader.GetInt32(1);
|
||||
var latestAttestation = reader.IsDBNull(2)
|
||||
? (DateTimeOffset?)null
|
||||
: reader.GetFieldValue<DateTimeOffset>(2);
|
||||
var attestationTypesRaw = reader.IsDBNull(3)
|
||||
? Array.Empty<string>()
|
||||
: reader.GetFieldValue<string[]>(3);
|
||||
|
||||
var attestationTypes = attestationTypesRaw
|
||||
.Where(t => Enum.TryParse<AttestationType>(t, out _))
|
||||
.Select(t => Enum.Parse<AttestationType>(t))
|
||||
.ToList();
|
||||
|
||||
var overallStatus = totalCount switch
|
||||
{
|
||||
0 => OverallVerificationStatus.NoAttestations,
|
||||
_ when verifiedCount == totalCount => OverallVerificationStatus.AllVerified,
|
||||
_ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
|
||||
_ => OverallVerificationStatus.NoneVerified
|
||||
};
|
||||
|
||||
return new FindingAttestationSummary(
|
||||
findingId,
|
||||
totalCount,
|
||||
verifiedCount,
|
||||
latestAttestation,
|
||||
attestationTypes,
|
||||
overallStatus);
|
||||
}
|
||||
|
||||
return new FindingAttestationSummary(
|
||||
findingId,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
Array.Empty<AttestationType>(),
|
||||
OverallVerificationStatus.NoAttestations);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(findingIds);
|
||||
|
||||
if (findingIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<FindingAttestationSummary>();
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
finding_id,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(*) FILTER (WHERE verification_result IS NOT NULL
|
||||
AND (verification_result->>'verified')::boolean = true) as verified_count,
|
||||
MAX(created_at) as latest_attestation,
|
||||
array_agg(DISTINCT attestation_type) as attestation_types
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND finding_id = ANY(@finding_ids)
|
||||
GROUP BY finding_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_summaries", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_ids", findingIds.ToArray());
|
||||
|
||||
var results = new List<FindingAttestationSummary>();
|
||||
var foundIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var fid = reader.GetString(0);
|
||||
foundIds.Add(fid);
|
||||
|
||||
var totalCount = reader.GetInt32(1);
|
||||
var verifiedCount = reader.GetInt32(2);
|
||||
var latestAttestation = reader.IsDBNull(3)
|
||||
? (DateTimeOffset?)null
|
||||
: reader.GetFieldValue<DateTimeOffset>(3);
|
||||
var attestationTypesRaw = reader.IsDBNull(4)
|
||||
? Array.Empty<string>()
|
||||
: reader.GetFieldValue<string[]>(4);
|
||||
|
||||
var attestationTypes = attestationTypesRaw
|
||||
.Where(t => Enum.TryParse<AttestationType>(t, out _))
|
||||
.Select(t => Enum.Parse<AttestationType>(t))
|
||||
.ToList();
|
||||
|
||||
var overallStatus = totalCount switch
|
||||
{
|
||||
0 => OverallVerificationStatus.NoAttestations,
|
||||
_ when verifiedCount == totalCount => OverallVerificationStatus.AllVerified,
|
||||
_ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
|
||||
_ => OverallVerificationStatus.NoneVerified
|
||||
};
|
||||
|
||||
results.Add(new FindingAttestationSummary(
|
||||
fid,
|
||||
totalCount,
|
||||
verifiedCount,
|
||||
latestAttestation,
|
||||
attestationTypes,
|
||||
overallStatus));
|
||||
}
|
||||
|
||||
// Add empty summaries for findings without attestations
|
||||
foreach (var fid in findingIds.Where(f => !foundIds.Contains(f)))
|
||||
{
|
||||
results.Add(new FindingAttestationSummary(
|
||||
fid,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
Array.Empty<AttestationType>(),
|
||||
OverallVerificationStatus.NoAttestations));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
string digest,
|
||||
AttestationType attestationType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
const string sql = """
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND finding_id = @finding_id
|
||||
AND attestation_ref->>'digest' = @digest
|
||||
AND attestation_type = @attestation_type
|
||||
)
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_exists", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
command.Parameters.AddWithValue("digest", digest);
|
||||
command.Parameters.AddWithValue("attestation_type", attestationType.ToString());
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is true;
|
||||
}
|
||||
|
||||
public async Task UpdateVerificationResultAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
VerificationResult verificationResult,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(verificationResult);
|
||||
|
||||
const string sql = """
|
||||
UPDATE ledger_attestation_pointers
|
||||
SET verification_result = @verification_result::jsonb
|
||||
WHERE tenant_id = @tenant_id AND pointer_id = @pointer_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_update", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("pointer_id", pointerId);
|
||||
command.Parameters.AddWithValue("verification_result",
|
||||
JsonSerializer.Serialize(verificationResult, JsonOptions));
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Updated verification result for attestation pointer {PointerId}, verified={Verified}",
|
||||
pointerId, verificationResult.Verified);
|
||||
}
|
||||
|
||||
public async Task<int> GetCountAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id AND finding_id = @finding_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_count", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(
|
||||
string tenantId,
|
||||
AttestationVerificationFilter? verificationFilter,
|
||||
IReadOnlyList<AttestationType>? attestationTypes,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var sqlBuilder = new StringBuilder("""
|
||||
SELECT DISTINCT finding_id
|
||||
FROM ledger_attestation_pointers
|
||||
WHERE tenant_id = @tenant_id
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text }
|
||||
};
|
||||
|
||||
if (attestationTypes is { Count: > 0 })
|
||||
{
|
||||
sqlBuilder.Append(" AND attestation_type = ANY(@attestation_types)");
|
||||
parameters.Add(new NpgsqlParameter<string[]>("attestation_types",
|
||||
attestationTypes.Select(t => t.ToString()).ToArray()));
|
||||
}
|
||||
|
||||
if (verificationFilter.HasValue && verificationFilter.Value != AttestationVerificationFilter.Any)
|
||||
{
|
||||
sqlBuilder.Append(verificationFilter.Value switch
|
||||
{
|
||||
AttestationVerificationFilter.Verified =>
|
||||
" AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = true",
|
||||
AttestationVerificationFilter.Unverified =>
|
||||
" AND verification_result IS NULL",
|
||||
AttestationVerificationFilter.Failed =>
|
||||
" AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = false",
|
||||
_ => ""
|
||||
});
|
||||
}
|
||||
|
||||
sqlBuilder.Append(" ORDER BY finding_id LIMIT @limit OFFSET @offset");
|
||||
parameters.Add(new NpgsqlParameter<int>("limit", limit));
|
||||
parameters.Add(new NpgsqlParameter<int>("offset", offset));
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(
|
||||
tenantId, "attestation_pointer_findings", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(sqlBuilder.ToString(), connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
command.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var results = new List<string>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<AttestationPointerRecord>> ReadRecordsAsync(
|
||||
NpgsqlCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<AttestationPointerRecord>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(ReadRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static AttestationPointerRecord ReadRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
var tenantId = reader.GetString(0);
|
||||
var pointerId = reader.GetGuid(1);
|
||||
var findingId = reader.GetString(2);
|
||||
var attestationType = Enum.Parse<AttestationType>(reader.GetString(3));
|
||||
var relationship = Enum.Parse<AttestationRelationship>(reader.GetString(4));
|
||||
|
||||
var attestationRefJson = reader.GetString(5);
|
||||
var attestationRef = JsonSerializer.Deserialize<AttestationRef>(attestationRefJson, JsonOptions)!;
|
||||
|
||||
VerificationResult? verificationResult = null;
|
||||
if (!reader.IsDBNull(6))
|
||||
{
|
||||
var verificationResultJson = reader.GetString(6);
|
||||
verificationResult = JsonSerializer.Deserialize<VerificationResult>(verificationResultJson, JsonOptions);
|
||||
}
|
||||
|
||||
var createdAt = reader.GetFieldValue<DateTimeOffset>(7);
|
||||
var createdBy = reader.GetString(8);
|
||||
|
||||
Dictionary<string, object>? metadata = null;
|
||||
if (!reader.IsDBNull(9))
|
||||
{
|
||||
var metadataJson = reader.GetString(9);
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metadataJson, JsonOptions);
|
||||
}
|
||||
|
||||
Guid? ledgerEventId = reader.IsDBNull(10) ? null : reader.GetGuid(10);
|
||||
|
||||
return new AttestationPointerRecord(
|
||||
tenantId,
|
||||
pointerId,
|
||||
findingId,
|
||||
attestationType,
|
||||
relationship,
|
||||
attestationRef,
|
||||
verificationResult,
|
||||
createdAt,
|
||||
createdBy,
|
||||
metadata,
|
||||
ledgerEventId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of snapshot repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresSnapshotRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LedgerSnapshot> CreateAsync(
|
||||
string tenantId,
|
||||
CreateSnapshotInput input,
|
||||
long currentSequence,
|
||||
DateTimeOffset currentTimestamp,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var snapshotId = Guid.NewGuid();
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
var expiresAt = input.ExpiresIn.HasValue
|
||||
? createdAt.Add(input.ExpiresIn.Value)
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
var sequenceNumber = input.AtSequence ?? currentSequence;
|
||||
var timestamp = input.AtTimestamp ?? currentTimestamp;
|
||||
|
||||
var initialStats = new SnapshotStatistics(0, 0, 0, 0, 0, 0);
|
||||
var metadataJson = input.Metadata != null
|
||||
? JsonSerializer.Serialize(input.Metadata, _jsonOptions)
|
||||
: null;
|
||||
var entityTypesJson = input.IncludeEntityTypes != null
|
||||
? JsonSerializer.Serialize(input.IncludeEntityTypes.Select(e => e.ToString()).ToList(), _jsonOptions)
|
||||
: null;
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO ledger_snapshots (
|
||||
tenant_id, snapshot_id, label, description, status,
|
||||
created_at, expires_at, sequence_number, snapshot_timestamp,
|
||||
findings_count, vex_statements_count, advisories_count,
|
||||
sboms_count, events_count, size_bytes,
|
||||
merkle_root, dsse_digest, metadata, include_entity_types, sign_requested
|
||||
) VALUES (
|
||||
@tenantId, @snapshotId, @label, @description, @status,
|
||||
@createdAt, @expiresAt, @sequenceNumber, @timestamp,
|
||||
@findingsCount, @vexCount, @advisoriesCount,
|
||||
@sbomsCount, @eventsCount, @sizeBytes,
|
||||
@merkleRoot, @dsseDigest, @metadata::jsonb, @entityTypes::jsonb, @sign
|
||||
)
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("label", (object?)input.Label ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("description", (object?)input.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status", SnapshotStatus.Creating.ToString());
|
||||
cmd.Parameters.AddWithValue("createdAt", createdAt);
|
||||
cmd.Parameters.AddWithValue("expiresAt", (object?)expiresAt ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("sequenceNumber", sequenceNumber);
|
||||
cmd.Parameters.AddWithValue("timestamp", timestamp);
|
||||
cmd.Parameters.AddWithValue("findingsCount", initialStats.FindingsCount);
|
||||
cmd.Parameters.AddWithValue("vexCount", initialStats.VexStatementsCount);
|
||||
cmd.Parameters.AddWithValue("advisoriesCount", initialStats.AdvisoriesCount);
|
||||
cmd.Parameters.AddWithValue("sbomsCount", initialStats.SbomsCount);
|
||||
cmd.Parameters.AddWithValue("eventsCount", initialStats.EventsCount);
|
||||
cmd.Parameters.AddWithValue("sizeBytes", initialStats.SizeBytes);
|
||||
cmd.Parameters.AddWithValue("merkleRoot", DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("dsseDigest", DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("metadata", (object?)metadataJson ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("entityTypes", (object?)entityTypesJson ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("sign", input.Sign);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
return new LedgerSnapshot(
|
||||
tenantId,
|
||||
snapshotId,
|
||||
input.Label,
|
||||
input.Description,
|
||||
SnapshotStatus.Creating,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
sequenceNumber,
|
||||
timestamp,
|
||||
initialStats,
|
||||
null,
|
||||
null,
|
||||
input.Metadata);
|
||||
}
|
||||
|
||||
public async Task<LedgerSnapshot?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT tenant_id, snapshot_id, label, description, status,
|
||||
created_at, expires_at, sequence_number, snapshot_timestamp,
|
||||
findings_count, vex_statements_count, advisories_count,
|
||||
sboms_count, events_count, size_bytes,
|
||||
merkle_root, dsse_digest, metadata
|
||||
FROM ledger_snapshots
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return null;
|
||||
|
||||
return MapSnapshot(reader);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<LedgerSnapshot> Snapshots, string? NextPageToken)> ListAsync(
|
||||
SnapshotListQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = new StringBuilder("""
|
||||
SELECT tenant_id, snapshot_id, label, description, status,
|
||||
created_at, expires_at, sequence_number, snapshot_timestamp,
|
||||
findings_count, vex_statements_count, advisories_count,
|
||||
sboms_count, events_count, size_bytes,
|
||||
merkle_root, dsse_digest, metadata
|
||||
FROM ledger_snapshots
|
||||
WHERE tenant_id = @tenantId
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenantId", query.TenantId)
|
||||
};
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
sql.Append(" AND status = @status");
|
||||
parameters.Add(new NpgsqlParameter("status", query.Status.Value.ToString()));
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
sql.Append(" AND created_at >= @createdAfter");
|
||||
parameters.Add(new NpgsqlParameter("createdAfter", query.CreatedAfter.Value));
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
sql.Append(" AND created_at < @createdBefore");
|
||||
parameters.Add(new NpgsqlParameter("createdBefore", query.CreatedBefore.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.PageToken))
|
||||
{
|
||||
if (Guid.TryParse(query.PageToken, out var lastId))
|
||||
{
|
||||
sql.Append(" AND snapshot_id > @lastId");
|
||||
parameters.Add(new NpgsqlParameter("lastId", lastId));
|
||||
}
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY created_at DESC, snapshot_id");
|
||||
sql.Append(" LIMIT @limit");
|
||||
parameters.Add(new NpgsqlParameter("limit", query.PageSize + 1));
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql.ToString());
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var snapshots = new List<LedgerSnapshot>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct) && snapshots.Count < query.PageSize)
|
||||
{
|
||||
snapshots.Add(MapSnapshot(reader));
|
||||
}
|
||||
|
||||
string? nextPageToken = null;
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
nextPageToken = snapshots.Last().SnapshotId.ToString();
|
||||
}
|
||||
|
||||
return (snapshots, nextPageToken);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
SnapshotStatus newStatus,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET status = @status, updated_at = @updatedAt
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("status", newStatus.ToString());
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatisticsAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
SnapshotStatistics statistics,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET findings_count = @findingsCount,
|
||||
vex_statements_count = @vexCount,
|
||||
advisories_count = @advisoriesCount,
|
||||
sboms_count = @sbomsCount,
|
||||
events_count = @eventsCount,
|
||||
size_bytes = @sizeBytes,
|
||||
updated_at = @updatedAt
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("findingsCount", statistics.FindingsCount);
|
||||
cmd.Parameters.AddWithValue("vexCount", statistics.VexStatementsCount);
|
||||
cmd.Parameters.AddWithValue("advisoriesCount", statistics.AdvisoriesCount);
|
||||
cmd.Parameters.AddWithValue("sbomsCount", statistics.SbomsCount);
|
||||
cmd.Parameters.AddWithValue("eventsCount", statistics.EventsCount);
|
||||
cmd.Parameters.AddWithValue("sizeBytes", statistics.SizeBytes);
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> SetMerkleRootAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
string merkleRoot,
|
||||
string? dsseDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET merkle_root = @merkleRoot,
|
||||
dsse_digest = @dsseDigest,
|
||||
updated_at = @updatedAt
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("merkleRoot", merkleRoot);
|
||||
cmd.Parameters.AddWithValue("dsseDigest", (object?)dsseDigest ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<int> ExpireSnapshotsAsync(
|
||||
DateTimeOffset cutoff,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET status = @expiredStatus, updated_at = @updatedAt
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at < @cutoff
|
||||
AND status = @availableStatus
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("expiredStatus", SnapshotStatus.Expired.ToString());
|
||||
cmd.Parameters.AddWithValue("availableStatus", SnapshotStatus.Available.ToString());
|
||||
cmd.Parameters.AddWithValue("cutoff", cutoff);
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE ledger_snapshots
|
||||
SET status = @deletedStatus, updated_at = @updatedAt
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
cmd.Parameters.AddWithValue("deletedStatus", SnapshotStatus.Deleted.ToString());
|
||||
cmd.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<LedgerSnapshot?> GetLatestAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT tenant_id, snapshot_id, label, description, status,
|
||||
created_at, expires_at, sequence_number, snapshot_timestamp,
|
||||
findings_count, vex_statements_count, advisories_count,
|
||||
sboms_count, events_count, size_bytes,
|
||||
merkle_root, dsse_digest, metadata
|
||||
FROM ledger_snapshots
|
||||
WHERE tenant_id = @tenantId AND status = @status
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("status", SnapshotStatus.Available.ToString());
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return null;
|
||||
|
||||
return MapSnapshot(reader);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT 1 FROM ledger_snapshots
|
||||
WHERE tenant_id = @tenantId AND snapshot_id = @snapshotId
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct);
|
||||
}
|
||||
|
||||
private LedgerSnapshot MapSnapshot(NpgsqlDataReader reader)
|
||||
{
|
||||
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("metadata"));
|
||||
|
||||
Dictionary<string, object>? metadata = null;
|
||||
if (!string.IsNullOrEmpty(metadataJson))
|
||||
{
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metadataJson, _jsonOptions);
|
||||
}
|
||||
|
||||
return new LedgerSnapshot(
|
||||
TenantId: reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
SnapshotId: reader.GetGuid(reader.GetOrdinal("snapshot_id")),
|
||||
Label: reader.IsDBNull(reader.GetOrdinal("label")) ? null : reader.GetString(reader.GetOrdinal("label")),
|
||||
Description: reader.IsDBNull(reader.GetOrdinal("description")) ? null : reader.GetString(reader.GetOrdinal("description")),
|
||||
Status: Enum.Parse<SnapshotStatus>(reader.GetString(reader.GetOrdinal("status"))),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
ExpiresAt: reader.IsDBNull(reader.GetOrdinal("expires_at")) ? null : reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("expires_at")),
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("sequence_number")),
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("snapshot_timestamp")),
|
||||
Statistics: new SnapshotStatistics(
|
||||
FindingsCount: reader.GetInt64(reader.GetOrdinal("findings_count")),
|
||||
VexStatementsCount: reader.GetInt64(reader.GetOrdinal("vex_statements_count")),
|
||||
AdvisoriesCount: reader.GetInt64(reader.GetOrdinal("advisories_count")),
|
||||
SbomsCount: reader.GetInt64(reader.GetOrdinal("sboms_count")),
|
||||
EventsCount: reader.GetInt64(reader.GetOrdinal("events_count")),
|
||||
SizeBytes: reader.GetInt64(reader.GetOrdinal("size_bytes"))),
|
||||
MerkleRoot: reader.IsDBNull(reader.GetOrdinal("merkle_root")) ? null : reader.GetString(reader.GetOrdinal("merkle_root")),
|
||||
DsseDigest: reader.IsDBNull(reader.GetOrdinal("dsse_digest")) ? null : reader.GetString(reader.GetOrdinal("dsse_digest")),
|
||||
Metadata: metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of time-travel repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresTimeTravelRepository : ITimeTravelRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ISnapshotRepository _snapshotRepository;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public PostgresTimeTravelRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ISnapshotRepository snapshotRepository)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_snapshotRepository = snapshotRepository;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<QueryPoint> GetCurrentPointAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COALESCE(MAX(sequence_number), 0) as seq,
|
||||
COALESCE(MAX(recorded_at), NOW()) as ts
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
|
||||
return new QueryPoint(
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("ts")),
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("seq")));
|
||||
}
|
||||
|
||||
public async Task<QueryPoint?> ResolveQueryPointAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? timestamp,
|
||||
long? sequence,
|
||||
Guid? snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// If snapshot ID is provided, get point from snapshot
|
||||
if (snapshotId.HasValue)
|
||||
{
|
||||
var snapshot = await _snapshotRepository.GetByIdAsync(tenantId, snapshotId.Value, ct);
|
||||
if (snapshot == null)
|
||||
return null;
|
||||
|
||||
return new QueryPoint(
|
||||
Timestamp: snapshot.Timestamp,
|
||||
SequenceNumber: snapshot.SequenceNumber,
|
||||
SnapshotId: snapshotId);
|
||||
}
|
||||
|
||||
// If sequence is provided, get timestamp for that sequence
|
||||
if (sequence.HasValue)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT recorded_at FROM ledger_events
|
||||
WHERE tenant_id = @tenantId AND sequence_number = @seq
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("seq", sequence.Value);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return null;
|
||||
|
||||
return new QueryPoint(
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(0),
|
||||
SequenceNumber: sequence.Value);
|
||||
}
|
||||
|
||||
// If timestamp is provided, find the sequence at that point
|
||||
if (timestamp.HasValue)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT sequence_number, recorded_at FROM ledger_events
|
||||
WHERE tenant_id = @tenantId AND recorded_at <= @ts
|
||||
ORDER BY sequence_number DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("ts", timestamp.Value);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
// No events before timestamp, return point at 0
|
||||
return new QueryPoint(timestamp.Value, 0);
|
||||
}
|
||||
|
||||
return new QueryPoint(
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(1),
|
||||
SequenceNumber: reader.GetInt64(0));
|
||||
}
|
||||
|
||||
// No constraints - return current point
|
||||
return await GetCurrentPointAsync(tenantId, ct);
|
||||
}
|
||||
|
||||
public async Task<HistoricalQueryResponse<FindingHistoryItem>> QueryFindingsAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var queryPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.AtTimestamp,
|
||||
request.AtSequence,
|
||||
request.SnapshotId,
|
||||
ct);
|
||||
|
||||
if (queryPoint == null)
|
||||
{
|
||||
return new HistoricalQueryResponse<FindingHistoryItem>(
|
||||
new QueryPoint(DateTimeOffset.UtcNow, 0),
|
||||
EntityType.Finding,
|
||||
Array.Empty<FindingHistoryItem>(),
|
||||
null,
|
||||
0);
|
||||
}
|
||||
|
||||
// Query findings state at the sequence point using event sourcing
|
||||
var sql = new StringBuilder("""
|
||||
WITH finding_state AS (
|
||||
SELECT
|
||||
e.finding_id,
|
||||
e.artifact_id,
|
||||
e.payload->>'vulnId' as vuln_id,
|
||||
e.payload->>'status' as status,
|
||||
(e.payload->>'severity')::decimal as severity,
|
||||
e.policy_version,
|
||||
MIN(e.recorded_at) OVER (PARTITION BY e.finding_id) as first_seen,
|
||||
e.recorded_at as last_updated,
|
||||
e.payload->'labels' as labels,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.finding_id ORDER BY e.sequence_number DESC) as rn
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number <= @seq
|
||||
AND e.finding_id IS NOT NULL
|
||||
)
|
||||
SELECT finding_id, artifact_id, vuln_id, status, severity,
|
||||
policy_version, first_seen, last_updated, labels
|
||||
FROM finding_state
|
||||
WHERE rn = 1
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenantId", request.TenantId),
|
||||
new("seq", queryPoint.SequenceNumber)
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
if (request.Filters != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.Filters.Status))
|
||||
{
|
||||
sql.Append(" AND status = @status");
|
||||
parameters.Add(new NpgsqlParameter("status", request.Filters.Status));
|
||||
}
|
||||
|
||||
if (request.Filters.SeverityMin.HasValue)
|
||||
{
|
||||
sql.Append(" AND severity >= @sevMin");
|
||||
parameters.Add(new NpgsqlParameter("sevMin", request.Filters.SeverityMin.Value));
|
||||
}
|
||||
|
||||
if (request.Filters.SeverityMax.HasValue)
|
||||
{
|
||||
sql.Append(" AND severity <= @sevMax");
|
||||
parameters.Add(new NpgsqlParameter("sevMax", request.Filters.SeverityMax.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Filters.ArtifactId))
|
||||
{
|
||||
sql.Append(" AND artifact_id = @artifactId");
|
||||
parameters.Add(new NpgsqlParameter("artifactId", request.Filters.ArtifactId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Filters.VulnId))
|
||||
{
|
||||
sql.Append(" AND vuln_id = @vulnId");
|
||||
parameters.Add(new NpgsqlParameter("vulnId", request.Filters.VulnId));
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if (!string.IsNullOrEmpty(request.PageToken))
|
||||
{
|
||||
sql.Append(" AND finding_id > @lastId");
|
||||
parameters.Add(new NpgsqlParameter("lastId", request.PageToken));
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY finding_id LIMIT @limit");
|
||||
parameters.Add(new NpgsqlParameter("limit", request.PageSize + 1));
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql.ToString());
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var items = new List<FindingHistoryItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct) && items.Count < request.PageSize)
|
||||
{
|
||||
var labelsJson = reader.IsDBNull(reader.GetOrdinal("labels"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("labels"));
|
||||
|
||||
items.Add(new FindingHistoryItem(
|
||||
FindingId: reader.GetString(reader.GetOrdinal("finding_id")),
|
||||
ArtifactId: reader.GetString(reader.GetOrdinal("artifact_id")),
|
||||
VulnId: reader.GetString(reader.GetOrdinal("vuln_id")),
|
||||
Status: reader.GetString(reader.GetOrdinal("status")),
|
||||
Severity: reader.IsDBNull(reader.GetOrdinal("severity")) ? null : reader.GetDecimal(reader.GetOrdinal("severity")),
|
||||
PolicyVersion: reader.IsDBNull(reader.GetOrdinal("policy_version")) ? null : reader.GetString(reader.GetOrdinal("policy_version")),
|
||||
FirstSeen: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("first_seen")),
|
||||
LastUpdated: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("last_updated")),
|
||||
Labels: string.IsNullOrEmpty(labelsJson)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<Dictionary<string, string>>(labelsJson, _jsonOptions)));
|
||||
}
|
||||
|
||||
string? nextPageToken = null;
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
nextPageToken = items.Last().FindingId;
|
||||
}
|
||||
|
||||
return new HistoricalQueryResponse<FindingHistoryItem>(
|
||||
queryPoint,
|
||||
EntityType.Finding,
|
||||
items,
|
||||
nextPageToken,
|
||||
items.Count);
|
||||
}
|
||||
|
||||
public async Task<HistoricalQueryResponse<VexHistoryItem>> QueryVexAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var queryPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.AtTimestamp,
|
||||
request.AtSequence,
|
||||
request.SnapshotId,
|
||||
ct);
|
||||
|
||||
if (queryPoint == null)
|
||||
{
|
||||
return new HistoricalQueryResponse<VexHistoryItem>(
|
||||
new QueryPoint(DateTimeOffset.UtcNow, 0),
|
||||
EntityType.Vex,
|
||||
Array.Empty<VexHistoryItem>(),
|
||||
null,
|
||||
0);
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
WITH vex_state AS (
|
||||
SELECT
|
||||
e.payload->>'statementId' as statement_id,
|
||||
e.payload->>'vulnId' as vuln_id,
|
||||
e.payload->>'productId' as product_id,
|
||||
e.payload->>'status' as status,
|
||||
e.payload->>'justification' as justification,
|
||||
(e.payload->>'issuedAt')::timestamptz as issued_at,
|
||||
(e.payload->>'expiresAt')::timestamptz as expires_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.payload->>'statementId' ORDER BY e.sequence_number DESC) as rn
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number <= @seq
|
||||
AND e.event_type LIKE 'vex.%'
|
||||
)
|
||||
SELECT statement_id, vuln_id, product_id, status, justification, issued_at, expires_at
|
||||
FROM vex_state
|
||||
WHERE rn = 1
|
||||
ORDER BY statement_id
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", request.TenantId);
|
||||
cmd.Parameters.AddWithValue("seq", queryPoint.SequenceNumber);
|
||||
cmd.Parameters.AddWithValue("limit", request.PageSize);
|
||||
|
||||
var items = new List<VexHistoryItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
items.Add(new VexHistoryItem(
|
||||
StatementId: reader.GetString(reader.GetOrdinal("statement_id")),
|
||||
VulnId: reader.GetString(reader.GetOrdinal("vuln_id")),
|
||||
ProductId: reader.GetString(reader.GetOrdinal("product_id")),
|
||||
Status: reader.GetString(reader.GetOrdinal("status")),
|
||||
Justification: reader.IsDBNull(reader.GetOrdinal("justification")) ? null : reader.GetString(reader.GetOrdinal("justification")),
|
||||
IssuedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("issued_at")),
|
||||
ExpiresAt: reader.IsDBNull(reader.GetOrdinal("expires_at")) ? null : reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("expires_at"))));
|
||||
}
|
||||
|
||||
return new HistoricalQueryResponse<VexHistoryItem>(
|
||||
queryPoint,
|
||||
EntityType.Vex,
|
||||
items,
|
||||
null,
|
||||
items.Count);
|
||||
}
|
||||
|
||||
public async Task<HistoricalQueryResponse<AdvisoryHistoryItem>> QueryAdvisoriesAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var queryPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.AtTimestamp,
|
||||
request.AtSequence,
|
||||
request.SnapshotId,
|
||||
ct);
|
||||
|
||||
if (queryPoint == null)
|
||||
{
|
||||
return new HistoricalQueryResponse<AdvisoryHistoryItem>(
|
||||
new QueryPoint(DateTimeOffset.UtcNow, 0),
|
||||
EntityType.Advisory,
|
||||
Array.Empty<AdvisoryHistoryItem>(),
|
||||
null,
|
||||
0);
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
WITH advisory_state AS (
|
||||
SELECT
|
||||
e.payload->>'advisoryId' as advisory_id,
|
||||
e.payload->>'source' as source,
|
||||
e.payload->>'title' as title,
|
||||
(e.payload->>'cvssScore')::decimal as cvss_score,
|
||||
(e.payload->>'publishedAt')::timestamptz as published_at,
|
||||
(e.payload->>'modifiedAt')::timestamptz as modified_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY e.payload->>'advisoryId' ORDER BY e.sequence_number DESC) as rn
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number <= @seq
|
||||
AND e.event_type LIKE 'advisory.%'
|
||||
)
|
||||
SELECT advisory_id, source, title, cvss_score, published_at, modified_at
|
||||
FROM advisory_state
|
||||
WHERE rn = 1
|
||||
ORDER BY advisory_id
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", request.TenantId);
|
||||
cmd.Parameters.AddWithValue("seq", queryPoint.SequenceNumber);
|
||||
cmd.Parameters.AddWithValue("limit", request.PageSize);
|
||||
|
||||
var items = new List<AdvisoryHistoryItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
items.Add(new AdvisoryHistoryItem(
|
||||
AdvisoryId: reader.GetString(reader.GetOrdinal("advisory_id")),
|
||||
Source: reader.GetString(reader.GetOrdinal("source")),
|
||||
Title: reader.GetString(reader.GetOrdinal("title")),
|
||||
CvssScore: reader.IsDBNull(reader.GetOrdinal("cvss_score")) ? null : reader.GetDecimal(reader.GetOrdinal("cvss_score")),
|
||||
PublishedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("published_at")),
|
||||
ModifiedAt: reader.IsDBNull(reader.GetOrdinal("modified_at")) ? null : reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("modified_at"))));
|
||||
}
|
||||
|
||||
return new HistoricalQueryResponse<AdvisoryHistoryItem>(
|
||||
queryPoint,
|
||||
EntityType.Advisory,
|
||||
items,
|
||||
null,
|
||||
items.Count);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<ReplayEvent> Events, ReplayMetadata Metadata)> ReplayEventsAsync(
|
||||
ReplayRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var sql = new StringBuilder("""
|
||||
SELECT event_id, sequence_number, chain_id, chain_sequence,
|
||||
event_type, occurred_at, recorded_at,
|
||||
actor_id, actor_type, artifact_id, finding_id,
|
||||
policy_version, event_hash, previous_hash, payload
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenantId
|
||||
""");
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenantId", request.TenantId)
|
||||
};
|
||||
|
||||
if (request.FromSequence.HasValue)
|
||||
{
|
||||
sql.Append(" AND sequence_number >= @fromSeq");
|
||||
parameters.Add(new NpgsqlParameter("fromSeq", request.FromSequence.Value));
|
||||
}
|
||||
|
||||
if (request.ToSequence.HasValue)
|
||||
{
|
||||
sql.Append(" AND sequence_number <= @toSeq");
|
||||
parameters.Add(new NpgsqlParameter("toSeq", request.ToSequence.Value));
|
||||
}
|
||||
|
||||
if (request.FromTimestamp.HasValue)
|
||||
{
|
||||
sql.Append(" AND recorded_at >= @fromTs");
|
||||
parameters.Add(new NpgsqlParameter("fromTs", request.FromTimestamp.Value));
|
||||
}
|
||||
|
||||
if (request.ToTimestamp.HasValue)
|
||||
{
|
||||
sql.Append(" AND recorded_at <= @toTs");
|
||||
parameters.Add(new NpgsqlParameter("toTs", request.ToTimestamp.Value));
|
||||
}
|
||||
|
||||
if (request.ChainIds?.Count > 0)
|
||||
{
|
||||
sql.Append(" AND chain_id = ANY(@chainIds)");
|
||||
parameters.Add(new NpgsqlParameter("chainIds", request.ChainIds.ToArray()));
|
||||
}
|
||||
|
||||
if (request.EventTypes?.Count > 0)
|
||||
{
|
||||
sql.Append(" AND event_type = ANY(@eventTypes)");
|
||||
parameters.Add(new NpgsqlParameter("eventTypes", request.EventTypes.ToArray()));
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY sequence_number LIMIT @limit");
|
||||
parameters.Add(new NpgsqlParameter("limit", request.PageSize + 1));
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql.ToString());
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
var events = new List<ReplayEvent>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct) && events.Count < request.PageSize)
|
||||
{
|
||||
object? payload = null;
|
||||
if (request.IncludePayload && !reader.IsDBNull(reader.GetOrdinal("payload")))
|
||||
{
|
||||
var payloadJson = reader.GetString(reader.GetOrdinal("payload"));
|
||||
payload = JsonSerializer.Deserialize<object>(payloadJson, _jsonOptions);
|
||||
}
|
||||
|
||||
events.Add(new ReplayEvent(
|
||||
EventId: reader.GetGuid(reader.GetOrdinal("event_id")),
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("sequence_number")),
|
||||
ChainId: reader.GetGuid(reader.GetOrdinal("chain_id")),
|
||||
ChainSequence: reader.GetInt32(reader.GetOrdinal("chain_sequence")),
|
||||
EventType: reader.GetString(reader.GetOrdinal("event_type")),
|
||||
OccurredAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
|
||||
RecordedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("recorded_at")),
|
||||
ActorId: reader.IsDBNull(reader.GetOrdinal("actor_id")) ? null : reader.GetString(reader.GetOrdinal("actor_id")),
|
||||
ActorType: reader.IsDBNull(reader.GetOrdinal("actor_type")) ? null : reader.GetString(reader.GetOrdinal("actor_type")),
|
||||
ArtifactId: reader.IsDBNull(reader.GetOrdinal("artifact_id")) ? null : reader.GetString(reader.GetOrdinal("artifact_id")),
|
||||
FindingId: reader.IsDBNull(reader.GetOrdinal("finding_id")) ? null : reader.GetString(reader.GetOrdinal("finding_id")),
|
||||
PolicyVersion: reader.IsDBNull(reader.GetOrdinal("policy_version")) ? null : reader.GetString(reader.GetOrdinal("policy_version")),
|
||||
EventHash: reader.GetString(reader.GetOrdinal("event_hash")),
|
||||
PreviousHash: reader.GetString(reader.GetOrdinal("previous_hash")),
|
||||
Payload: payload));
|
||||
}
|
||||
|
||||
var hasMore = await reader.ReadAsync(ct);
|
||||
sw.Stop();
|
||||
|
||||
var fromSeq = events.Count > 0 ? events.First().SequenceNumber : 0;
|
||||
var toSeq = events.Count > 0 ? events.Last().SequenceNumber : 0;
|
||||
|
||||
var metadata = new ReplayMetadata(
|
||||
FromSequence: fromSeq,
|
||||
ToSequence: toSeq,
|
||||
EventsCount: events.Count,
|
||||
HasMore: hasMore,
|
||||
ReplayDurationMs: sw.ElapsedMilliseconds);
|
||||
|
||||
return (events, metadata);
|
||||
}
|
||||
|
||||
public async Task<DiffResponse> ComputeDiffAsync(
|
||||
DiffRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var fromPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.From.Timestamp,
|
||||
request.From.SequenceNumber,
|
||||
request.From.SnapshotId,
|
||||
ct) ?? new QueryPoint(DateTimeOffset.MinValue, 0);
|
||||
|
||||
var toPoint = await ResolveQueryPointAsync(
|
||||
request.TenantId,
|
||||
request.To.Timestamp,
|
||||
request.To.SequenceNumber,
|
||||
request.To.SnapshotId,
|
||||
ct) ?? await GetCurrentPointAsync(request.TenantId, ct);
|
||||
|
||||
// Count changes between the two points
|
||||
const string countSql = """
|
||||
WITH changes AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN e.event_type LIKE 'finding.%' THEN 'Finding'
|
||||
WHEN e.event_type LIKE 'vex.%' THEN 'Vex'
|
||||
WHEN e.event_type LIKE 'advisory.%' THEN 'Advisory'
|
||||
WHEN e.event_type LIKE 'sbom.%' THEN 'Sbom'
|
||||
ELSE 'Evidence'
|
||||
END as entity_type,
|
||||
CASE
|
||||
WHEN e.event_type LIKE '%.created' THEN 'Added'
|
||||
WHEN e.event_type LIKE '%.deleted' THEN 'Removed'
|
||||
ELSE 'Modified'
|
||||
END as change_type
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number > @fromSeq
|
||||
AND e.sequence_number <= @toSeq
|
||||
)
|
||||
SELECT entity_type, change_type, COUNT(*) as cnt
|
||||
FROM changes
|
||||
GROUP BY entity_type, change_type
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(countSql);
|
||||
cmd.Parameters.AddWithValue("tenantId", request.TenantId);
|
||||
cmd.Parameters.AddWithValue("fromSeq", fromPoint.SequenceNumber);
|
||||
cmd.Parameters.AddWithValue("toSeq", toPoint.SequenceNumber);
|
||||
|
||||
var byEntityType = new Dictionary<EntityType, DiffCounts>();
|
||||
int totalAdded = 0, totalModified = 0, totalRemoved = 0;
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var entityTypeStr = reader.GetString(0);
|
||||
var changeType = reader.GetString(1);
|
||||
var count = (int)reader.GetInt64(2);
|
||||
|
||||
if (Enum.TryParse<EntityType>(entityTypeStr, out var entityType))
|
||||
{
|
||||
if (!byEntityType.TryGetValue(entityType, out var counts))
|
||||
{
|
||||
counts = new DiffCounts(0, 0, 0);
|
||||
}
|
||||
|
||||
byEntityType[entityType] = changeType switch
|
||||
{
|
||||
"Added" => counts with { Added = counts.Added + count },
|
||||
"Removed" => counts with { Removed = counts.Removed + count },
|
||||
_ => counts with { Modified = counts.Modified + count }
|
||||
};
|
||||
|
||||
switch (changeType)
|
||||
{
|
||||
case "Added": totalAdded += count; break;
|
||||
case "Removed": totalRemoved += count; break;
|
||||
default: totalModified += count; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var summary = new DiffSummary(
|
||||
Added: totalAdded,
|
||||
Modified: totalModified,
|
||||
Removed: totalRemoved,
|
||||
Unchanged: 0,
|
||||
ByEntityType: byEntityType.Count > 0 ? byEntityType : null);
|
||||
|
||||
// For detailed output, include individual changes
|
||||
IReadOnlyList<DiffEntry>? changes = null;
|
||||
if (request.OutputFormat != DiffOutputFormat.Summary)
|
||||
{
|
||||
changes = await GetDetailedChangesAsync(
|
||||
request.TenantId,
|
||||
fromPoint.SequenceNumber,
|
||||
toPoint.SequenceNumber,
|
||||
request.EntityTypes,
|
||||
ct);
|
||||
}
|
||||
|
||||
return new DiffResponse(
|
||||
FromPoint: fromPoint,
|
||||
ToPoint: toPoint,
|
||||
Summary: summary,
|
||||
Changes: changes,
|
||||
NextPageToken: null);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DiffEntry>> GetDetailedChangesAsync(
|
||||
string tenantId,
|
||||
long fromSeq,
|
||||
long toSeq,
|
||||
IReadOnlyList<EntityType>? entityTypes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sql = new StringBuilder("""
|
||||
SELECT
|
||||
e.event_type,
|
||||
COALESCE(e.finding_id, e.artifact_id, e.payload->>'entityId') as entity_id,
|
||||
e.payload as to_state
|
||||
FROM ledger_events e
|
||||
WHERE e.tenant_id = @tenantId
|
||||
AND e.sequence_number > @fromSeq
|
||||
AND e.sequence_number <= @toSeq
|
||||
""");
|
||||
|
||||
if (entityTypes?.Count > 0)
|
||||
{
|
||||
var patterns = entityTypes.Select(et => et switch
|
||||
{
|
||||
EntityType.Finding => "finding.%",
|
||||
EntityType.Vex => "vex.%",
|
||||
EntityType.Advisory => "advisory.%",
|
||||
EntityType.Sbom => "sbom.%",
|
||||
_ => "evidence.%"
|
||||
}).ToList();
|
||||
|
||||
sql.Append(" AND (");
|
||||
for (int i = 0; i < patterns.Count; i++)
|
||||
{
|
||||
if (i > 0) sql.Append(" OR ");
|
||||
sql.Append($"e.event_type LIKE @pattern{i}");
|
||||
}
|
||||
sql.Append(")");
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY e.sequence_number LIMIT 1000");
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql.ToString());
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("fromSeq", fromSeq);
|
||||
cmd.Parameters.AddWithValue("toSeq", toSeq);
|
||||
|
||||
if (entityTypes?.Count > 0)
|
||||
{
|
||||
var patterns = entityTypes.Select(et => et switch
|
||||
{
|
||||
EntityType.Finding => "finding.%",
|
||||
EntityType.Vex => "vex.%",
|
||||
EntityType.Advisory => "advisory.%",
|
||||
EntityType.Sbom => "sbom.%",
|
||||
_ => "evidence.%"
|
||||
}).ToList();
|
||||
|
||||
for (int i = 0; i < patterns.Count; i++)
|
||||
{
|
||||
cmd.Parameters.AddWithValue($"pattern{i}", patterns[i]);
|
||||
}
|
||||
}
|
||||
|
||||
var entries = new List<DiffEntry>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var eventType = reader.GetString(0);
|
||||
var entityId = reader.IsDBNull(1) ? "unknown" : reader.GetString(1);
|
||||
var toStateJson = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||
|
||||
var entityType = eventType switch
|
||||
{
|
||||
var et when et.StartsWith("finding.") => EntityType.Finding,
|
||||
var et when et.StartsWith("vex.") => EntityType.Vex,
|
||||
var et when et.StartsWith("advisory.") => EntityType.Advisory,
|
||||
var et when et.StartsWith("sbom.") => EntityType.Sbom,
|
||||
_ => EntityType.Evidence
|
||||
};
|
||||
|
||||
var changeType = eventType switch
|
||||
{
|
||||
var et when et.EndsWith(".created") => DiffChangeType.Added,
|
||||
var et when et.EndsWith(".deleted") => DiffChangeType.Removed,
|
||||
_ => DiffChangeType.Modified
|
||||
};
|
||||
|
||||
object? toState = null;
|
||||
if (!string.IsNullOrEmpty(toStateJson))
|
||||
{
|
||||
toState = JsonSerializer.Deserialize<object>(toStateJson, _jsonOptions);
|
||||
}
|
||||
|
||||
entries.Add(new DiffEntry(
|
||||
EntityType: entityType,
|
||||
EntityId: entityId,
|
||||
ChangeType: changeType,
|
||||
FromState: null,
|
||||
ToState: toState,
|
||||
ChangedFields: null));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChangeLogEntry>> GetChangelogAsync(
|
||||
string tenantId,
|
||||
EntityType entityType,
|
||||
string entityId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var eventTypePrefix = entityType switch
|
||||
{
|
||||
EntityType.Finding => "finding.",
|
||||
EntityType.Vex => "vex.",
|
||||
EntityType.Advisory => "advisory.",
|
||||
EntityType.Sbom => "sbom.",
|
||||
_ => "evidence."
|
||||
};
|
||||
|
||||
const string sql = """
|
||||
SELECT sequence_number, recorded_at, event_type, event_hash, actor_id,
|
||||
COALESCE(payload->>'summary', event_type) as summary
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenantId
|
||||
AND event_type LIKE @eventTypePrefix
|
||||
AND (finding_id = @entityId OR artifact_id = @entityId OR payload->>'entityId' = @entityId)
|
||||
ORDER BY sequence_number DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("eventTypePrefix", eventTypePrefix + "%");
|
||||
cmd.Parameters.AddWithValue("entityId", entityId);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
var entries = new List<ChangeLogEntry>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
entries.Add(new ChangeLogEntry(
|
||||
SequenceNumber: reader.GetInt64(reader.GetOrdinal("sequence_number")),
|
||||
Timestamp: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("recorded_at")),
|
||||
EntityType: entityType,
|
||||
EntityId: entityId,
|
||||
EventType: reader.GetString(reader.GetOrdinal("event_type")),
|
||||
EventHash: reader.IsDBNull(reader.GetOrdinal("event_hash")) ? null : reader.GetString(reader.GetOrdinal("event_hash")),
|
||||
ActorId: reader.IsDBNull(reader.GetOrdinal("actor_id")) ? null : reader.GetString(reader.GetOrdinal("actor_id")),
|
||||
Summary: reader.IsDBNull(reader.GetOrdinal("summary")) ? null : reader.GetString(reader.GetOrdinal("summary"))));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public async Task<StalenessResult> CheckStalenessAsync(
|
||||
string tenantId,
|
||||
TimeSpan threshold,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var checkedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
MAX(recorded_at) as last_event,
|
||||
MAX(CASE WHEN event_type LIKE 'finding.%' THEN recorded_at END) as finding_last,
|
||||
MAX(CASE WHEN event_type LIKE 'vex.%' THEN recorded_at END) as vex_last,
|
||||
MAX(CASE WHEN event_type LIKE 'advisory.%' THEN recorded_at END) as advisory_last
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
|
||||
var lastEventAt = reader.IsDBNull(0) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(0);
|
||||
var findingLast = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(1);
|
||||
var vexLast = reader.IsDBNull(2) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(2);
|
||||
var advisoryLast = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3);
|
||||
|
||||
var isStale = lastEventAt.HasValue && (checkedAt - lastEventAt.Value) > threshold;
|
||||
var stalenessDuration = lastEventAt.HasValue ? checkedAt - lastEventAt.Value : (TimeSpan?)null;
|
||||
|
||||
var byEntityType = new Dictionary<EntityType, EntityStaleness>
|
||||
{
|
||||
[EntityType.Finding] = new EntityStaleness(
|
||||
findingLast.HasValue && (checkedAt - findingLast.Value) > threshold,
|
||||
findingLast,
|
||||
0),
|
||||
[EntityType.Vex] = new EntityStaleness(
|
||||
vexLast.HasValue && (checkedAt - vexLast.Value) > threshold,
|
||||
vexLast,
|
||||
0),
|
||||
[EntityType.Advisory] = new EntityStaleness(
|
||||
advisoryLast.HasValue && (checkedAt - advisoryLast.Value) > threshold,
|
||||
advisoryLast,
|
||||
0)
|
||||
};
|
||||
|
||||
return new StalenessResult(
|
||||
IsStale: isStale,
|
||||
CheckedAt: checkedAt,
|
||||
LastEventAt: lastEventAt,
|
||||
StalenessThreshold: threshold,
|
||||
StalenessDuration: stalenessDuration,
|
||||
ByEntityType: byEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for ledger snapshot persistence.
|
||||
/// </summary>
|
||||
public interface ISnapshotRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new snapshot record.
|
||||
/// </summary>
|
||||
Task<LedgerSnapshot> CreateAsync(
|
||||
string tenantId,
|
||||
CreateSnapshotInput input,
|
||||
long currentSequence,
|
||||
DateTimeOffset currentTimestamp,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot by ID.
|
||||
/// </summary>
|
||||
Task<LedgerSnapshot?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists snapshots with filtering and pagination.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<LedgerSnapshot> Snapshots, string? NextPageToken)> ListAsync(
|
||||
SnapshotListQuery query,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates snapshot status.
|
||||
/// </summary>
|
||||
Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
SnapshotStatus newStatus,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates snapshot statistics.
|
||||
/// </summary>
|
||||
Task<bool> UpdateStatisticsAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
SnapshotStatistics statistics,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Merkle root and optional DSSE digest for a snapshot.
|
||||
/// </summary>
|
||||
Task<bool> SetMerkleRootAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
string merkleRoot,
|
||||
string? dsseDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks expired snapshots as expired.
|
||||
/// </summary>
|
||||
Task<int> ExpireSnapshotsAsync(
|
||||
DateTimeOffset cutoff,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a snapshot (soft delete - marks as Deleted).
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest snapshot for a tenant.
|
||||
/// </summary>
|
||||
Task<LedgerSnapshot?> GetLatestAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a snapshot exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for time-travel queries.
|
||||
/// </summary>
|
||||
public interface ITimeTravelRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current sequence number and timestamp.
|
||||
/// </summary>
|
||||
Task<QueryPoint> GetCurrentPointAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a query point from timestamp, sequence, or snapshot ID.
|
||||
/// </summary>
|
||||
Task<QueryPoint?> ResolveQueryPointAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? timestamp,
|
||||
long? sequence,
|
||||
Guid? snapshotId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical findings at a specific point.
|
||||
/// </summary>
|
||||
Task<HistoricalQueryResponse<FindingHistoryItem>> QueryFindingsAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical VEX statements at a specific point.
|
||||
/// </summary>
|
||||
Task<HistoricalQueryResponse<VexHistoryItem>> QueryVexAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical advisories at a specific point.
|
||||
/// </summary>
|
||||
Task<HistoricalQueryResponse<AdvisoryHistoryItem>> QueryAdvisoriesAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Replays events within a range.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<ReplayEvent> Events, ReplayMetadata Metadata)> ReplayEventsAsync(
|
||||
ReplayRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes diff between two points.
|
||||
/// </summary>
|
||||
Task<DiffResponse> ComputeDiffAsync(
|
||||
DiffRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets changelog entries for an entity.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ChangeLogEntry>> GetChangelogAsync(
|
||||
string tenantId,
|
||||
EntityType entityType,
|
||||
string entityId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks staleness of ledger data.
|
||||
/// </summary>
|
||||
Task<StalenessResult> CheckStalenessAsync(
|
||||
string tenantId,
|
||||
TimeSpan threshold,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historical finding item.
|
||||
/// </summary>
|
||||
public sealed record FindingHistoryItem(
|
||||
string FindingId,
|
||||
string ArtifactId,
|
||||
string VulnId,
|
||||
string Status,
|
||||
decimal? Severity,
|
||||
string? PolicyVersion,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastUpdated,
|
||||
Dictionary<string, string>? Labels);
|
||||
|
||||
/// <summary>
|
||||
/// Historical VEX item.
|
||||
/// </summary>
|
||||
public sealed record VexHistoryItem(
|
||||
string StatementId,
|
||||
string VulnId,
|
||||
string ProductId,
|
||||
string Status,
|
||||
string? Justification,
|
||||
DateTimeOffset IssuedAt,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Historical advisory item.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryHistoryItem(
|
||||
string AdvisoryId,
|
||||
string Source,
|
||||
string Title,
|
||||
decimal? CvssScore,
|
||||
DateTimeOffset PublishedAt,
|
||||
DateTimeOffset? ModifiedAt);
|
||||
@@ -17,6 +17,12 @@ internal static class LedgerTimeline
|
||||
private static readonly EventId AirgapImport = new(6401, "ledger.airgap.imported");
|
||||
private static readonly EventId EvidenceSnapshotLinkedEvent = new(6501, "ledger.evidence.snapshot_linked");
|
||||
private static readonly EventId AirgapTimelineImpactEvent = new(6601, "ledger.airgap.timeline_impact");
|
||||
private static readonly EventId AttestationPointerLinkedEvent = new(6701, "ledger.attestation.pointer_linked");
|
||||
private static readonly EventId SnapshotCreatedEvent = new(6801, "ledger.snapshot.created");
|
||||
private static readonly EventId SnapshotDeletedEvent = new(6802, "ledger.snapshot.deleted");
|
||||
private static readonly EventId TimeTravelQueryEvent = new(6803, "ledger.timetravel.query");
|
||||
private static readonly EventId ReplayCompletedEvent = new(6804, "ledger.replay.completed");
|
||||
private static readonly EventId DiffComputedEvent = new(6805, "ledger.diff.computed");
|
||||
|
||||
public static void EmitLedgerAppended(ILogger logger, LedgerEventRecord record, string? evidenceBundleRef = null)
|
||||
{
|
||||
@@ -144,4 +150,134 @@ internal static class LedgerTimeline
|
||||
timeAnchor.ToString("O"),
|
||||
sealedMode);
|
||||
}
|
||||
|
||||
public static void EmitAttestationPointerLinked(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
string findingId,
|
||||
Guid pointerId,
|
||||
string attestationType,
|
||||
string digest)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
AttestationPointerLinkedEvent,
|
||||
"timeline ledger.attestation.pointer_linked tenant={Tenant} finding={FindingId} pointer={PointerId} attestation_type={AttestationType} digest={Digest}",
|
||||
tenantId,
|
||||
findingId,
|
||||
pointerId,
|
||||
attestationType,
|
||||
digest);
|
||||
}
|
||||
|
||||
public static void EmitSnapshotCreated(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
long sequenceNumber,
|
||||
long findingsCount)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
SnapshotCreatedEvent,
|
||||
"timeline ledger.snapshot.created tenant={Tenant} snapshot={SnapshotId} sequence={SequenceNumber} findings_count={FindingsCount}",
|
||||
tenantId,
|
||||
snapshotId,
|
||||
sequenceNumber,
|
||||
findingsCount);
|
||||
}
|
||||
|
||||
public static void EmitSnapshotDeleted(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
Guid snapshotId)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
SnapshotDeletedEvent,
|
||||
"timeline ledger.snapshot.deleted tenant={Tenant} snapshot={SnapshotId}",
|
||||
tenantId,
|
||||
snapshotId);
|
||||
}
|
||||
|
||||
public static void EmitTimeTravelQuery(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
string entityType,
|
||||
long atSequence,
|
||||
int resultCount)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
TimeTravelQueryEvent,
|
||||
"timeline ledger.timetravel.query tenant={Tenant} entity_type={EntityType} at_sequence={AtSequence} result_count={ResultCount}",
|
||||
tenantId,
|
||||
entityType,
|
||||
atSequence,
|
||||
resultCount);
|
||||
}
|
||||
|
||||
public static void EmitReplayCompleted(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
long fromSequence,
|
||||
long toSequence,
|
||||
int eventsCount,
|
||||
long durationMs)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
ReplayCompletedEvent,
|
||||
"timeline ledger.replay.completed tenant={Tenant} from_sequence={FromSequence} to_sequence={ToSequence} events_count={EventsCount} duration_ms={DurationMs}",
|
||||
tenantId,
|
||||
fromSequence,
|
||||
toSequence,
|
||||
eventsCount,
|
||||
durationMs);
|
||||
}
|
||||
|
||||
public static void EmitDiffComputed(
|
||||
ILogger logger,
|
||||
string tenantId,
|
||||
long fromSequence,
|
||||
long toSequence,
|
||||
int added,
|
||||
int modified,
|
||||
int removed)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
DiffComputedEvent,
|
||||
"timeline ledger.diff.computed tenant={Tenant} from_sequence={FromSequence} to_sequence={ToSequence} added={Added} modified={Modified} removed={Removed}",
|
||||
tenantId,
|
||||
fromSequence,
|
||||
toSequence,
|
||||
added,
|
||||
modified,
|
||||
removed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing attestation pointers linking findings to verification reports and attestation envelopes.
|
||||
/// </summary>
|
||||
public sealed class AttestationPointerService
|
||||
{
|
||||
private readonly ILedgerEventRepository _ledgerEventRepository;
|
||||
private readonly ILedgerEventWriteService _writeService;
|
||||
private readonly IAttestationPointerRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AttestationPointerService> _logger;
|
||||
|
||||
public AttestationPointerService(
|
||||
ILedgerEventRepository ledgerEventRepository,
|
||||
ILedgerEventWriteService writeService,
|
||||
IAttestationPointerRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AttestationPointerService> logger)
|
||||
{
|
||||
_ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository));
|
||||
_writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation pointer linking a finding to a verification report or attestation envelope.
|
||||
/// </summary>
|
||||
public async Task<AttestationPointerResult> CreatePointerAsync(
|
||||
AttestationPointerInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.TenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.FindingId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(input.AttestationRef.Digest);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var createdBy = input.CreatedBy ?? "attestation-linker";
|
||||
|
||||
// Check for idempotency
|
||||
var exists = await _repository.ExistsAsync(
|
||||
input.TenantId,
|
||||
input.FindingId,
|
||||
input.AttestationRef.Digest,
|
||||
input.AttestationType,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Attestation pointer already exists for finding {FindingId} with digest {Digest}",
|
||||
input.FindingId, input.AttestationRef.Digest);
|
||||
|
||||
// Find and return the existing pointer
|
||||
var existing = await _repository.GetByDigestAsync(
|
||||
input.TenantId,
|
||||
input.AttestationRef.Digest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var match = existing.FirstOrDefault(p =>
|
||||
p.FindingId == input.FindingId && p.AttestationType == input.AttestationType);
|
||||
|
||||
return new AttestationPointerResult(true, match?.PointerId, match?.LedgerEventId, null);
|
||||
}
|
||||
|
||||
var pointerId = Guid.NewGuid();
|
||||
|
||||
// Create ledger event for the attestation pointer
|
||||
var chainId = LedgerChainIdGenerator.FromTenantSubject(
|
||||
input.TenantId, $"attestation::{input.FindingId}");
|
||||
|
||||
var chainHead = await _ledgerEventRepository.GetChainHeadAsync(
|
||||
input.TenantId, chainId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sequence = (chainHead?.SequenceNumber ?? 0) + 1;
|
||||
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
var attestationPayload = BuildAttestationPayload(input, pointerId);
|
||||
var envelope = BuildEnvelope(eventId, input, chainId, sequence, now, attestationPayload);
|
||||
|
||||
var draft = new LedgerEventDraft(
|
||||
input.TenantId,
|
||||
chainId,
|
||||
sequence,
|
||||
eventId,
|
||||
LedgerEventConstants.EventAttestationPointerLinked,
|
||||
"attestation-pointer",
|
||||
input.FindingId,
|
||||
input.FindingId,
|
||||
SourceRunId: null,
|
||||
ActorId: createdBy,
|
||||
ActorType: "system",
|
||||
OccurredAt: now,
|
||||
RecordedAt: now,
|
||||
Payload: attestationPayload,
|
||||
CanonicalEnvelope: envelope,
|
||||
ProvidedPreviousHash: previousHash);
|
||||
|
||||
var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent))
|
||||
{
|
||||
var error = string.Join(";", writeResult.Errors);
|
||||
_logger.LogWarning(
|
||||
"Failed to write ledger event for attestation pointer {PointerId}: {Error}",
|
||||
pointerId, error);
|
||||
return new AttestationPointerResult(false, null, null, error);
|
||||
}
|
||||
|
||||
var ledgerEventId = writeResult.Record?.EventId;
|
||||
|
||||
var record = new AttestationPointerRecord(
|
||||
input.TenantId,
|
||||
pointerId,
|
||||
input.FindingId,
|
||||
input.AttestationType,
|
||||
input.Relationship,
|
||||
input.AttestationRef,
|
||||
input.VerificationResult,
|
||||
now,
|
||||
createdBy,
|
||||
input.Metadata,
|
||||
ledgerEventId);
|
||||
|
||||
await _repository.InsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
LedgerTimeline.EmitAttestationPointerLinked(
|
||||
_logger,
|
||||
input.TenantId,
|
||||
input.FindingId,
|
||||
pointerId,
|
||||
input.AttestationType.ToString(),
|
||||
input.AttestationRef.Digest);
|
||||
|
||||
return new AttestationPointerResult(true, pointerId, ledgerEventId, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation pointers for a finding.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> GetPointersAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
return await _repository.GetByFindingIdAsync(tenantId, findingId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attestation pointer by ID.
|
||||
/// </summary>
|
||||
public async Task<AttestationPointerRecord?> GetPointerAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
return await _repository.GetByIdAsync(tenantId, pointerId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches attestation pointers.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> SearchAsync(
|
||||
AttestationPointerQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
return await _repository.SearchAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation summary for a finding.
|
||||
/// </summary>
|
||||
public async Task<FindingAttestationSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
return await _repository.GetSummaryAsync(tenantId, findingId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestation summaries for multiple findings.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<FindingAttestationSummary>> GetSummariesAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> findingIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(findingIds);
|
||||
|
||||
return await _repository.GetSummariesAsync(tenantId, findingIds, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the verification result for an attestation pointer.
|
||||
/// </summary>
|
||||
public async Task<bool> UpdateVerificationResultAsync(
|
||||
string tenantId,
|
||||
Guid pointerId,
|
||||
VerificationResult verificationResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(verificationResult);
|
||||
|
||||
var existing = await _repository.GetByIdAsync(tenantId, pointerId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation pointer {PointerId} not found for tenant {TenantId}",
|
||||
pointerId, tenantId);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _repository.UpdateVerificationResultAsync(
|
||||
tenantId, pointerId, verificationResult, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated verification result for attestation pointer {PointerId}, verified={Verified}",
|
||||
pointerId, verificationResult.Verified);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets findings that have attestations matching the criteria.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(
|
||||
string tenantId,
|
||||
AttestationVerificationFilter? verificationFilter = null,
|
||||
IReadOnlyList<AttestationType>? attestationTypes = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
return await _repository.GetFindingIdsWithAttestationsAsync(
|
||||
tenantId, verificationFilter, attestationTypes, limit, offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static JsonObject BuildAttestationPayload(AttestationPointerInput input, Guid pointerId)
|
||||
{
|
||||
var attestationRefNode = new JsonObject
|
||||
{
|
||||
["digest"] = input.AttestationRef.Digest
|
||||
};
|
||||
|
||||
if (input.AttestationRef.AttestationId.HasValue)
|
||||
{
|
||||
attestationRefNode["attestation_id"] = input.AttestationRef.AttestationId.Value.ToString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.StorageUri))
|
||||
{
|
||||
attestationRefNode["storage_uri"] = input.AttestationRef.StorageUri;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.PayloadType))
|
||||
{
|
||||
attestationRefNode["payload_type"] = input.AttestationRef.PayloadType;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.PredicateType))
|
||||
{
|
||||
attestationRefNode["predicate_type"] = input.AttestationRef.PredicateType;
|
||||
}
|
||||
|
||||
if (input.AttestationRef.SubjectDigests is { Count: > 0 })
|
||||
{
|
||||
var subjectsArray = new JsonArray();
|
||||
foreach (var subject in input.AttestationRef.SubjectDigests)
|
||||
{
|
||||
subjectsArray.Add(subject);
|
||||
}
|
||||
attestationRefNode["subject_digests"] = subjectsArray;
|
||||
}
|
||||
|
||||
if (input.AttestationRef.SignerInfo is not null)
|
||||
{
|
||||
var signerNode = new JsonObject();
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.KeyId))
|
||||
{
|
||||
signerNode["key_id"] = input.AttestationRef.SignerInfo.KeyId;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.Issuer))
|
||||
{
|
||||
signerNode["issuer"] = input.AttestationRef.SignerInfo.Issuer;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.Subject))
|
||||
{
|
||||
signerNode["subject"] = input.AttestationRef.SignerInfo.Subject;
|
||||
}
|
||||
if (input.AttestationRef.SignerInfo.SignedAt.HasValue)
|
||||
{
|
||||
signerNode["signed_at"] = FormatTimestamp(input.AttestationRef.SignerInfo.SignedAt.Value);
|
||||
}
|
||||
attestationRefNode["signer_info"] = signerNode;
|
||||
}
|
||||
|
||||
if (input.AttestationRef.RekorEntry is not null)
|
||||
{
|
||||
var rekorNode = new JsonObject();
|
||||
if (input.AttestationRef.RekorEntry.LogIndex.HasValue)
|
||||
{
|
||||
rekorNode["log_index"] = input.AttestationRef.RekorEntry.LogIndex.Value;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.RekorEntry.LogId))
|
||||
{
|
||||
rekorNode["log_id"] = input.AttestationRef.RekorEntry.LogId;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(input.AttestationRef.RekorEntry.Uuid))
|
||||
{
|
||||
rekorNode["uuid"] = input.AttestationRef.RekorEntry.Uuid;
|
||||
}
|
||||
if (input.AttestationRef.RekorEntry.IntegratedTime.HasValue)
|
||||
{
|
||||
rekorNode["integrated_time"] = input.AttestationRef.RekorEntry.IntegratedTime.Value;
|
||||
}
|
||||
attestationRefNode["rekor_entry"] = rekorNode;
|
||||
}
|
||||
|
||||
var pointerNode = new JsonObject
|
||||
{
|
||||
["pointer_id"] = pointerId.ToString(),
|
||||
["attestation_type"] = input.AttestationType.ToString(),
|
||||
["relationship"] = input.Relationship.ToString(),
|
||||
["attestation_ref"] = attestationRefNode
|
||||
};
|
||||
|
||||
if (input.VerificationResult is not null)
|
||||
{
|
||||
var verificationNode = new JsonObject
|
||||
{
|
||||
["verified"] = input.VerificationResult.Verified,
|
||||
["verified_at"] = FormatTimestamp(input.VerificationResult.VerifiedAt)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(input.VerificationResult.Verifier))
|
||||
{
|
||||
verificationNode["verifier"] = input.VerificationResult.Verifier;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.VerificationResult.VerifierVersion))
|
||||
{
|
||||
verificationNode["verifier_version"] = input.VerificationResult.VerifierVersion;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(input.VerificationResult.PolicyRef))
|
||||
{
|
||||
verificationNode["policy_ref"] = input.VerificationResult.PolicyRef;
|
||||
}
|
||||
|
||||
if (input.VerificationResult.Checks is { Count: > 0 })
|
||||
{
|
||||
var checksArray = new JsonArray();
|
||||
foreach (var check in input.VerificationResult.Checks)
|
||||
{
|
||||
var checkNode = new JsonObject
|
||||
{
|
||||
["check_type"] = check.CheckType.ToString(),
|
||||
["passed"] = check.Passed
|
||||
};
|
||||
if (!string.IsNullOrEmpty(check.Details))
|
||||
{
|
||||
checkNode["details"] = check.Details;
|
||||
}
|
||||
checksArray.Add(checkNode);
|
||||
}
|
||||
verificationNode["checks"] = checksArray;
|
||||
}
|
||||
|
||||
if (input.VerificationResult.Warnings is { Count: > 0 })
|
||||
{
|
||||
var warningsArray = new JsonArray();
|
||||
foreach (var warning in input.VerificationResult.Warnings)
|
||||
{
|
||||
warningsArray.Add(warning);
|
||||
}
|
||||
verificationNode["warnings"] = warningsArray;
|
||||
}
|
||||
|
||||
if (input.VerificationResult.Errors is { Count: > 0 })
|
||||
{
|
||||
var errorsArray = new JsonArray();
|
||||
foreach (var error in input.VerificationResult.Errors)
|
||||
{
|
||||
errorsArray.Add(error);
|
||||
}
|
||||
verificationNode["errors"] = errorsArray;
|
||||
}
|
||||
|
||||
pointerNode["verification_result"] = verificationNode;
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["attestation"] = new JsonObject
|
||||
{
|
||||
["pointer"] = pointerNode
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject BuildEnvelope(
|
||||
Guid eventId,
|
||||
AttestationPointerInput input,
|
||||
Guid chainId,
|
||||
long sequence,
|
||||
DateTimeOffset now,
|
||||
JsonObject payload)
|
||||
{
|
||||
return new JsonObject
|
||||
{
|
||||
["event"] = new JsonObject
|
||||
{
|
||||
["id"] = eventId.ToString(),
|
||||
["type"] = LedgerEventConstants.EventAttestationPointerLinked,
|
||||
["tenant"] = input.TenantId,
|
||||
["chainId"] = chainId.ToString(),
|
||||
["sequence"] = sequence,
|
||||
["policyVersion"] = "attestation-pointer",
|
||||
["artifactId"] = input.FindingId,
|
||||
["finding"] = new JsonObject
|
||||
{
|
||||
["id"] = input.FindingId,
|
||||
["artifactId"] = input.FindingId,
|
||||
["vulnId"] = "attestation-pointer"
|
||||
},
|
||||
["actor"] = new JsonObject
|
||||
{
|
||||
["id"] = input.CreatedBy ?? "attestation-linker",
|
||||
["type"] = "system"
|
||||
},
|
||||
["occurredAt"] = FormatTimestamp(now),
|
||||
["recordedAt"] = FormatTimestamp(now),
|
||||
["payload"] = payload.DeepClone()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(DateTimeOffset value)
|
||||
=> value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing ledger snapshots and time-travel queries.
|
||||
/// </summary>
|
||||
public sealed class SnapshotService
|
||||
{
|
||||
private readonly ISnapshotRepository _snapshotRepository;
|
||||
private readonly ITimeTravelRepository _timeTravelRepository;
|
||||
private readonly ILogger<SnapshotService> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public SnapshotService(
|
||||
ISnapshotRepository snapshotRepository,
|
||||
ITimeTravelRepository timeTravelRepository,
|
||||
ILogger<SnapshotService> logger)
|
||||
{
|
||||
_snapshotRepository = snapshotRepository;
|
||||
_timeTravelRepository = timeTravelRepository;
|
||||
_logger = logger;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new snapshot of the ledger at the specified point.
|
||||
/// </summary>
|
||||
public async Task<CreateSnapshotResult> CreateSnapshotAsync(
|
||||
CreateSnapshotInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating snapshot for tenant {TenantId} at sequence {Sequence} / timestamp {Timestamp}",
|
||||
input.TenantId,
|
||||
input.AtSequence,
|
||||
input.AtTimestamp);
|
||||
|
||||
// Get current ledger state
|
||||
var currentPoint = await _timeTravelRepository.GetCurrentPointAsync(input.TenantId, ct);
|
||||
|
||||
// Create the snapshot record
|
||||
var snapshot = await _snapshotRepository.CreateAsync(
|
||||
input.TenantId,
|
||||
input,
|
||||
currentPoint.SequenceNumber,
|
||||
currentPoint.Timestamp,
|
||||
ct);
|
||||
|
||||
// Compute statistics asynchronously
|
||||
var statistics = await ComputeStatisticsAsync(
|
||||
input.TenantId,
|
||||
snapshot.SequenceNumber,
|
||||
input.IncludeEntityTypes,
|
||||
ct);
|
||||
|
||||
await _snapshotRepository.UpdateStatisticsAsync(
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
statistics,
|
||||
ct);
|
||||
|
||||
// Compute Merkle root if signing is requested
|
||||
string? merkleRoot = null;
|
||||
string? dsseDigest = null;
|
||||
|
||||
if (input.Sign)
|
||||
{
|
||||
merkleRoot = await ComputeMerkleRootAsync(
|
||||
input.TenantId,
|
||||
snapshot.SequenceNumber,
|
||||
ct);
|
||||
|
||||
await _snapshotRepository.SetMerkleRootAsync(
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
merkleRoot,
|
||||
dsseDigest,
|
||||
ct);
|
||||
}
|
||||
|
||||
// Mark as available
|
||||
await _snapshotRepository.UpdateStatusAsync(
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
SnapshotStatus.Available,
|
||||
ct);
|
||||
|
||||
// Retrieve updated snapshot
|
||||
var finalSnapshot = await _snapshotRepository.GetByIdAsync(
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
ct);
|
||||
|
||||
LedgerTimeline.EmitSnapshotCreated(
|
||||
_logger,
|
||||
input.TenantId,
|
||||
snapshot.SnapshotId,
|
||||
snapshot.SequenceNumber,
|
||||
statistics.FindingsCount);
|
||||
|
||||
return new CreateSnapshotResult(true, finalSnapshot, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create snapshot for tenant {TenantId}", input.TenantId);
|
||||
return new CreateSnapshotResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot by ID.
|
||||
/// </summary>
|
||||
public async Task<LedgerSnapshot?> GetSnapshotAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _snapshotRepository.GetByIdAsync(tenantId, snapshotId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists snapshots for a tenant.
|
||||
/// </summary>
|
||||
public async Task<(IReadOnlyList<LedgerSnapshot> Snapshots, string? NextPageToken)> ListSnapshotsAsync(
|
||||
SnapshotListQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _snapshotRepository.ListAsync(query, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a snapshot.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteSnapshotAsync(
|
||||
string tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var deleted = await _snapshotRepository.DeleteAsync(tenantId, snapshotId, ct);
|
||||
|
||||
if (deleted)
|
||||
{
|
||||
LedgerTimeline.EmitSnapshotDeleted(_logger, tenantId, snapshotId);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical findings at a specific point in time.
|
||||
/// </summary>
|
||||
public async Task<HistoricalQueryResponse<FindingHistoryItem>> QueryHistoricalFindingsAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.QueryFindingsAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical VEX statements at a specific point in time.
|
||||
/// </summary>
|
||||
public async Task<HistoricalQueryResponse<VexHistoryItem>> QueryHistoricalVexAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.QueryVexAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries historical advisories at a specific point in time.
|
||||
/// </summary>
|
||||
public async Task<HistoricalQueryResponse<AdvisoryHistoryItem>> QueryHistoricalAdvisoriesAsync(
|
||||
HistoricalQueryRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.QueryAdvisoriesAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays events within a specified range.
|
||||
/// </summary>
|
||||
public async Task<(IReadOnlyList<ReplayEvent> Events, ReplayMetadata Metadata)> ReplayEventsAsync(
|
||||
ReplayRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.ReplayEventsAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes diff between two points in time.
|
||||
/// </summary>
|
||||
public async Task<DiffResponse> ComputeDiffAsync(
|
||||
DiffRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.ComputeDiffAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets changelog for an entity.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ChangeLogEntry>> GetChangelogAsync(
|
||||
string tenantId,
|
||||
EntityType entityType,
|
||||
string entityId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.GetChangelogAsync(tenantId, entityType, entityId, limit, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks staleness of ledger data.
|
||||
/// </summary>
|
||||
public async Task<StalenessResult> CheckStalenessAsync(
|
||||
string tenantId,
|
||||
TimeSpan threshold,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.CheckStalenessAsync(tenantId, threshold, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current query point (latest sequence and timestamp).
|
||||
/// </summary>
|
||||
public async Task<QueryPoint> GetCurrentPointAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _timeTravelRepository.GetCurrentPointAsync(tenantId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expires old snapshots.
|
||||
/// </summary>
|
||||
public async Task<int> ExpireOldSnapshotsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow;
|
||||
var count = await _snapshotRepository.ExpireSnapshotsAsync(cutoff, ct);
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
_logger.LogInformation("Expired {Count} snapshots", count);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private async Task<SnapshotStatistics> ComputeStatisticsAsync(
|
||||
string tenantId,
|
||||
long atSequence,
|
||||
IReadOnlyList<EntityType>? entityTypes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Query counts from time-travel repository
|
||||
var findingsResult = await _timeTravelRepository.QueryFindingsAsync(
|
||||
new HistoricalQueryRequest(
|
||||
tenantId,
|
||||
null,
|
||||
atSequence,
|
||||
null,
|
||||
EntityType.Finding,
|
||||
null,
|
||||
1),
|
||||
ct);
|
||||
|
||||
var vexResult = await _timeTravelRepository.QueryVexAsync(
|
||||
new HistoricalQueryRequest(
|
||||
tenantId,
|
||||
null,
|
||||
atSequence,
|
||||
null,
|
||||
EntityType.Vex,
|
||||
null,
|
||||
1),
|
||||
ct);
|
||||
|
||||
var advisoryResult = await _timeTravelRepository.QueryAdvisoriesAsync(
|
||||
new HistoricalQueryRequest(
|
||||
tenantId,
|
||||
null,
|
||||
atSequence,
|
||||
null,
|
||||
EntityType.Advisory,
|
||||
null,
|
||||
1),
|
||||
ct);
|
||||
|
||||
// Get event count
|
||||
var (events, _) = await _timeTravelRepository.ReplayEventsAsync(
|
||||
new ReplayRequest(tenantId, ToSequence: atSequence, IncludePayload: false, PageSize: 1),
|
||||
ct);
|
||||
|
||||
// Note: These are approximations; actual counting would need dedicated queries
|
||||
return new SnapshotStatistics(
|
||||
FindingsCount: findingsResult.TotalCount,
|
||||
VexStatementsCount: vexResult.TotalCount,
|
||||
AdvisoriesCount: advisoryResult.TotalCount,
|
||||
SbomsCount: 0, // Would need separate SBOM tracking
|
||||
EventsCount: atSequence,
|
||||
SizeBytes: 0); // Would need to compute actual storage size
|
||||
}
|
||||
|
||||
private async Task<string> ComputeMerkleRootAsync(
|
||||
string tenantId,
|
||||
long atSequence,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Get all event hashes up to the sequence
|
||||
var (events, _) = await _timeTravelRepository.ReplayEventsAsync(
|
||||
new ReplayRequest(
|
||||
tenantId,
|
||||
ToSequence: atSequence,
|
||||
IncludePayload: false,
|
||||
PageSize: 10000),
|
||||
ct);
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return ComputeHash("empty");
|
||||
}
|
||||
|
||||
// Build Merkle tree from event hashes
|
||||
var hashes = events.Select(e => e.EventHash).ToList();
|
||||
return ComputeMerkleRoot(hashes);
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(List<string> hashes)
|
||||
{
|
||||
if (hashes.Count == 0)
|
||||
return ComputeHash("empty");
|
||||
|
||||
if (hashes.Count == 1)
|
||||
return hashes[0];
|
||||
|
||||
var nextLevel = new List<string>();
|
||||
for (int i = 0; i < hashes.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < hashes.Count)
|
||||
{
|
||||
nextLevel.Add(ComputeHash(hashes[i] + hashes[i + 1]));
|
||||
}
|
||||
else
|
||||
{
|
||||
nextLevel.Add(hashes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return ComputeMerkleRoot(nextLevel);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
-- 008_attestation_pointers.sql
|
||||
-- LEDGER-ATTEST-73-001: Persist pointers from findings to verification reports and attestation envelopes
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================
|
||||
-- 1. Create attestation pointers table
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_attestation_pointers (
|
||||
tenant_id text NOT NULL,
|
||||
pointer_id uuid NOT NULL,
|
||||
finding_id text NOT NULL,
|
||||
attestation_type text NOT NULL,
|
||||
relationship text NOT NULL,
|
||||
attestation_ref jsonb NOT NULL,
|
||||
verification_result jsonb NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
created_by text NOT NULL,
|
||||
metadata jsonb NULL,
|
||||
ledger_event_id uuid NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ledger_attestation_pointers
|
||||
ADD CONSTRAINT pk_ledger_attestation_pointers PRIMARY KEY (tenant_id, pointer_id);
|
||||
|
||||
-- ============================================
|
||||
-- 2. Create indexes for efficient queries
|
||||
-- ============================================
|
||||
|
||||
-- Index for finding lookups (most common query pattern)
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_finding
|
||||
ON ledger_attestation_pointers (tenant_id, finding_id, created_at DESC);
|
||||
|
||||
-- Index for digest-based lookups (idempotency checks)
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_digest
|
||||
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'digest'));
|
||||
|
||||
-- Index for attestation type filtering
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_type
|
||||
ON ledger_attestation_pointers (tenant_id, attestation_type, created_at DESC);
|
||||
|
||||
-- Index for verification status filtering (verified/unverified/failed)
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_verified
|
||||
ON ledger_attestation_pointers (tenant_id, ((verification_result->>'verified')::boolean))
|
||||
WHERE verification_result IS NOT NULL;
|
||||
|
||||
-- Index for signer identity searches
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_signer
|
||||
ON ledger_attestation_pointers (tenant_id, (attestation_ref->'signer_info'->>'subject'))
|
||||
WHERE attestation_ref->'signer_info' IS NOT NULL;
|
||||
|
||||
-- Index for predicate type searches
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_predicate
|
||||
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'predicate_type'))
|
||||
WHERE attestation_ref->>'predicate_type' IS NOT NULL;
|
||||
|
||||
-- ============================================
|
||||
-- 3. Enable Row-Level Security
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE ledger_attestation_pointers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ledger_attestation_pointers FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS ledger_attestation_pointers_tenant_isolation ON ledger_attestation_pointers;
|
||||
CREATE POLICY ledger_attestation_pointers_tenant_isolation
|
||||
ON ledger_attestation_pointers
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
|
||||
-- ============================================
|
||||
-- 4. Add comments for documentation
|
||||
-- ============================================
|
||||
|
||||
COMMENT ON TABLE ledger_attestation_pointers IS
|
||||
'Links findings to verification reports and attestation envelopes for explainability (LEDGER-ATTEST-73-001)';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.pointer_id IS
|
||||
'Unique identifier for this attestation pointer';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.finding_id IS
|
||||
'Finding that this pointer references';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.attestation_type IS
|
||||
'Type of attestation: verification_report, dsse_envelope, slsa_provenance, vex_attestation, sbom_attestation, scan_attestation, policy_attestation, approval_attestation';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.relationship IS
|
||||
'Semantic relationship: verified_by, attested_by, signed_by, approved_by, derived_from';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.attestation_ref IS
|
||||
'JSON object containing digest, storage_uri, payload_type, predicate_type, subject_digests, signer_info, rekor_entry';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.verification_result IS
|
||||
'JSON object containing verified (bool), verified_at, verifier, verifier_version, policy_ref, checks, warnings, errors';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.ledger_event_id IS
|
||||
'Reference to the ledger event that recorded this pointer creation';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Migration: 009_snapshots
|
||||
-- Description: Creates ledger_snapshots table for time-travel/snapshot functionality
|
||||
-- Date: 2025-12-07
|
||||
|
||||
-- Create ledger_snapshots table
|
||||
CREATE TABLE IF NOT EXISTS ledger_snapshots (
|
||||
tenant_id TEXT NOT NULL,
|
||||
snapshot_id UUID NOT NULL,
|
||||
label TEXT,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'Creating',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
sequence_number BIGINT NOT NULL,
|
||||
snapshot_timestamp TIMESTAMPTZ NOT NULL,
|
||||
findings_count BIGINT NOT NULL DEFAULT 0,
|
||||
vex_statements_count BIGINT NOT NULL DEFAULT 0,
|
||||
advisories_count BIGINT NOT NULL DEFAULT 0,
|
||||
sboms_count BIGINT NOT NULL DEFAULT 0,
|
||||
events_count BIGINT NOT NULL DEFAULT 0,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
merkle_root TEXT,
|
||||
dsse_digest TEXT,
|
||||
metadata JSONB,
|
||||
include_entity_types JSONB,
|
||||
sign_requested BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (tenant_id, snapshot_id)
|
||||
);
|
||||
|
||||
-- Index for listing snapshots by status
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_status
|
||||
ON ledger_snapshots (tenant_id, status, created_at DESC);
|
||||
|
||||
-- Index for finding expired snapshots
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_expires
|
||||
ON ledger_snapshots (expires_at)
|
||||
WHERE expires_at IS NOT NULL AND status = 'Available';
|
||||
|
||||
-- Index for sequence lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_sequence
|
||||
ON ledger_snapshots (tenant_id, sequence_number);
|
||||
|
||||
-- Index for label search
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_label
|
||||
ON ledger_snapshots (tenant_id, label)
|
||||
WHERE label IS NOT NULL;
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE ledger_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policy for tenant isolation
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE tablename = 'ledger_snapshots'
|
||||
AND policyname = 'ledger_snapshots_tenant_isolation'
|
||||
) THEN
|
||||
CREATE POLICY ledger_snapshots_tenant_isolation ON ledger_snapshots
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON TABLE ledger_snapshots IS 'Point-in-time snapshots of ledger state for time-travel queries';
|
||||
COMMENT ON COLUMN ledger_snapshots.sequence_number IS 'Ledger sequence number at snapshot time';
|
||||
COMMENT ON COLUMN ledger_snapshots.snapshot_timestamp IS 'Timestamp of ledger state captured';
|
||||
COMMENT ON COLUMN ledger_snapshots.merkle_root IS 'Merkle root hash of all events up to sequence_number';
|
||||
COMMENT ON COLUMN ledger_snapshots.dsse_digest IS 'DSSE envelope digest if signed';
|
||||
Reference in New Issue
Block a user