Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -0,0 +1,234 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.EvidenceLocker.Api;
/// <summary>
/// Pack-driven Evidence & Audit adapter routes.
/// </summary>
public static class EvidenceAuditEndpoints
{
private static readonly DateTimeOffset SnapshotAt = DateTimeOffset.Parse("2026-02-19T03:15:00Z");
private static readonly IReadOnlyList<EvidencePackSummaryDto> Packs =
[
new EvidencePackSummaryDto("pack-9001", "rel-003", "us-prod", "1.2.4", "sealed", "2026-02-18T08:33:00Z"),
new EvidencePackSummaryDto("pack-9002", "rel-002", "us-uat", "1.3.0-rc1", "sealed", "2026-02-18T07:30:00Z"),
new EvidencePackSummaryDto("pack-9003", "rel-001", "eu-prod", "1.2.3", "sealed", "2026-02-17T08:30:00Z"),
];
private static readonly IReadOnlyDictionary<string, EvidencePackDetailDto> PackDetails =
new Dictionary<string, EvidencePackDetailDto>(StringComparer.OrdinalIgnoreCase)
{
["pack-9001"] = new(
PackId: "pack-9001",
ReleaseId: "rel-003",
Environment: "us-prod",
BundleVersion: "1.2.4",
ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000003",
Decision: "pass_with_ack",
PromotionRunId: "run-7712",
Artifacts:
[
new EvidencePackArtifactDto("sbom", "spdx", "sha256:sbom-9001"),
new EvidencePackArtifactDto("findings", "json", "sha256:findings-9001"),
new EvidencePackArtifactDto("policy-decision", "dsse", "sha256:policy-9001"),
new EvidencePackArtifactDto("vex", "openvex", "sha256:vex-9001"),
],
ProofChainId: "chain-9912")
};
private static readonly IReadOnlyDictionary<string, ProofChainDetailDto> ProofsByDigest =
new Dictionary<string, ProofChainDetailDto>(StringComparer.OrdinalIgnoreCase)
{
["sha256:beef000000000000000000000000000000000000000000000000000000000003"] = new(
ChainId: "chain-9912",
SubjectDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000003",
Status: "valid",
DsseEnvelope: "dsse://pack-9001",
RekorEntry: "rekor://entry/9912",
VerifiedAt: "2026-02-19T03:10:00Z")
};
private static readonly IReadOnlyDictionary<string, CvssReceiptDto> CvssReceipts =
new Dictionary<string, CvssReceiptDto>(StringComparer.OrdinalIgnoreCase)
{
["CVE-2026-1234"] = new(
VulnerabilityId: "CVE-2026-1234",
CvssVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
BaseScore: 9.8m,
ScoredAt: "2026-02-18T08:21:00Z",
Source: "nvd")
};
public static void MapEvidenceAuditEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/evidence")
.WithTags("Evidence Audit");
group.MapGet(string.Empty, GetHome)
.WithName("GetEvidenceHome")
.WithSummary("Get evidence home summary and quick links.")
.RequireAuthorization();
group.MapGet("/packs", ListPacks)
.WithName("ListEvidencePacks")
.WithSummary("List evidence packs.")
.RequireAuthorization();
group.MapGet("/packs/{id}", GetPackDetail)
.WithName("GetEvidencePack")
.WithSummary("Get evidence pack detail.")
.RequireAuthorization();
group.MapGet("/proofs/{subjectDigest}", GetProofChain)
.WithName("GetEvidenceProofChain")
.WithSummary("Get proof chain by subject digest.")
.RequireAuthorization();
group.MapGet("/audit", ListAudit)
.WithName("ListEvidenceAuditLog")
.WithSummary("Get unified evidence audit log slice.")
.RequireAuthorization();
group.MapGet("/receipts/cvss/{id}", GetCvssReceipt)
.WithName("GetCvssReceipt")
.WithSummary("Get CVSS receipt by vulnerability id.")
.RequireAuthorization();
}
private static IResult GetHome()
{
var home = new EvidenceHomeDto(
GeneratedAt: SnapshotAt,
QuickStats: new EvidenceQuickStatsDto(
LatestPacks24h: 3,
SealedBundles7d: 5,
FailedVerifications7d: 1,
TrustAlerts30d: 1),
LatestPacks: Packs.OrderBy(item => item.PackId, StringComparer.Ordinal).Take(3).ToList(),
LatestBundles:
[
"bundle-2026-02-18-us-prod",
"bundle-2026-02-18-us-uat",
],
FailedVerifications:
[
"rr-002 (determinism mismatch)",
]);
return Results.Ok(home);
}
private static IResult ListPacks()
{
var items = Packs
.OrderBy(item => item.PackId, StringComparer.Ordinal)
.ToList();
return Results.Ok(new EvidencePackListResponseDto(items, items.Count, SnapshotAt));
}
private static IResult GetPackDetail(string id)
{
return PackDetails.TryGetValue(id, out var detail)
? Results.Ok(detail)
: Results.NotFound(new { error = "pack_not_found", id });
}
private static IResult GetProofChain(string subjectDigest)
{
return ProofsByDigest.TryGetValue(subjectDigest, out var proof)
? Results.Ok(proof)
: Results.NotFound(new { error = "proof_not_found", subjectDigest });
}
private static IResult ListAudit([FromQuery] int? limit = null)
{
var max = Math.Clamp(limit ?? 50, 1, 200);
var events = new[]
{
new EvidenceAuditEventDto("evt-3001", "export.created", "run-8811", "2026-02-18T08:40:00Z"),
new EvidenceAuditEventDto("evt-3002", "pack.sealed", "pack-9001", "2026-02-18T08:33:00Z"),
new EvidenceAuditEventDto("evt-3003", "trust.certificate-rotated", "issuer-registryca", "2026-02-18T07:10:00Z"),
}.OrderBy(eventRow => eventRow.EventId, StringComparer.Ordinal).Take(max).ToList();
return Results.Ok(new EvidenceAuditResponseDto(events, events.Count, SnapshotAt));
}
private static IResult GetCvssReceipt(string id)
{
return CvssReceipts.TryGetValue(id, out var receipt)
? Results.Ok(receipt)
: Results.NotFound(new { error = "cvss_receipt_not_found", id });
}
}
public sealed record EvidenceHomeDto(
DateTimeOffset GeneratedAt,
EvidenceQuickStatsDto QuickStats,
IReadOnlyList<EvidencePackSummaryDto> LatestPacks,
IReadOnlyList<string> LatestBundles,
IReadOnlyList<string> FailedVerifications);
public sealed record EvidenceQuickStatsDto(
int LatestPacks24h,
int SealedBundles7d,
int FailedVerifications7d,
int TrustAlerts30d);
public sealed record EvidencePackListResponseDto(
IReadOnlyList<EvidencePackSummaryDto> Items,
int Total,
DateTimeOffset GeneratedAt);
public sealed record EvidencePackSummaryDto(
string PackId,
string ReleaseId,
string Environment,
string BundleVersion,
string Status,
string CreatedAt);
public sealed record EvidencePackDetailDto(
string PackId,
string ReleaseId,
string Environment,
string BundleVersion,
string ManifestDigest,
string Decision,
string PromotionRunId,
IReadOnlyList<EvidencePackArtifactDto> Artifacts,
string ProofChainId);
public sealed record EvidencePackArtifactDto(
string Kind,
string Format,
string Digest);
public sealed record ProofChainDetailDto(
string ChainId,
string SubjectDigest,
string Status,
string DsseEnvelope,
string RekorEntry,
string VerifiedAt);
public sealed record EvidenceAuditResponseDto(
IReadOnlyList<EvidenceAuditEventDto> Items,
int Total,
DateTimeOffset GeneratedAt);
public sealed record EvidenceAuditEventDto(
string EventId,
string EventType,
string Subject,
string OccurredAt);
public sealed record CvssReceiptDto(
string VulnerabilityId,
string CvssVector,
decimal BaseScore,
string ScoredAt,
string Source);

View File

@@ -0,0 +1,109 @@
using System.Text.Json.Serialization;
namespace StellaOps.EvidenceLocker.Api;
/// <summary>
/// Response for GET /api/v1/evidence/thread/{canonicalId}.
/// Represents the Artifact Canonical Record per docs/contracts/artifact-canonical-record-v1.md.
/// Sprint: SPRINT_20260219_009 (CID-04)
/// </summary>
public sealed record GetEvidenceThreadResponse
{
[JsonPropertyName("canonical_id")]
public required string CanonicalId { get; init; }
[JsonPropertyName("format")]
public required string Format { get; init; }
[JsonPropertyName("artifact_digest")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ArtifactDigest { get; init; }
[JsonPropertyName("purl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Purl { get; init; }
[JsonPropertyName("attestations")]
public required IReadOnlyList<EvidenceThreadAttestation> Attestations { get; init; }
[JsonPropertyName("transparency_status")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public TransparencyStatus? TransparencyStatus { get; init; }
[JsonPropertyName("created_at")]
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Individual attestation record within an evidence thread.
/// </summary>
public sealed record EvidenceThreadAttestation
{
[JsonPropertyName("predicate_type")]
public required string PredicateType { get; init; }
[JsonPropertyName("dsse_digest")]
public required string DsseDigest { get; init; }
[JsonPropertyName("signer_keyid")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SignerKeyId { get; init; }
[JsonPropertyName("rekor_entry_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RekorEntryId { get; init; }
[JsonPropertyName("rekor_tile")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RekorTile { get; init; }
[JsonPropertyName("signed_at")]
public required DateTimeOffset SignedAt { get; init; }
}
/// <summary>
/// Transparency log status for offline/air-gapped deployments.
/// </summary>
public sealed record TransparencyStatus
{
[JsonPropertyName("mode")]
public required string Mode { get; init; } // "online" | "offline"
[JsonPropertyName("reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reason { get; init; }
}
/// <summary>
/// Response for GET /api/v1/evidence/thread?purl={purl} (PURL-based lookup).
/// </summary>
public sealed record ListEvidenceThreadsResponse
{
[JsonPropertyName("threads")]
public required IReadOnlyList<EvidenceThreadSummary> Threads { get; init; }
[JsonPropertyName("pagination")]
public required PaginationInfo Pagination { get; init; }
}
/// <summary>
/// Summary of an evidence thread (for list responses).
/// </summary>
public sealed record EvidenceThreadSummary
{
[JsonPropertyName("canonical_id")]
public required string CanonicalId { get; init; }
[JsonPropertyName("format")]
public required string Format { get; init; }
[JsonPropertyName("purl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Purl { get; init; }
[JsonPropertyName("attestation_count")]
public required int AttestationCount { get; init; }
[JsonPropertyName("created_at")]
public required DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,188 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Storage;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Api;
/// <summary>
/// Logging category for evidence thread endpoints.
/// </summary>
internal sealed class EvidenceThreadEndpointsLogger;
/// <summary>
/// Minimal API endpoints for the Evidence Thread API.
/// Returns Artifact Canonical Records per docs/contracts/artifact-canonical-record-v1.md.
/// Sprint: SPRINT_20260219_009 (CID-04)
/// </summary>
public static class EvidenceThreadEndpoints
{
public static void MapEvidenceThreadEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/evidence/thread")
.WithTags("Evidence Threads");
// GET /api/v1/evidence/thread/{canonicalId}
group.MapGet("/{canonicalId}", GetThreadByCanonicalIdAsync)
.WithName("GetEvidenceThread")
.WithSummary("Retrieve the evidence thread for an artifact by canonical_id")
.Produces<GetEvidenceThreadResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status500InternalServerError);
// GET /api/v1/evidence/thread?purl={purl}
group.MapGet("/", ListThreadsByPurlAsync)
.WithName("ListEvidenceThreads")
.WithSummary("List evidence threads matching a PURL")
.Produces<ListEvidenceThreadsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status500InternalServerError);
}
private static async Task<IResult> GetThreadByCanonicalIdAsync(
string canonicalId,
[FromServices] IEvidenceThreadRepository repository,
[FromServices] ILogger<EvidenceThreadEndpointsLogger> logger,
CancellationToken cancellationToken,
[FromQuery] bool include_attestations = true)
{
try
{
logger.LogInformation("Retrieving evidence thread for canonical_id {CanonicalId}", canonicalId);
var record = await repository.GetByCanonicalIdAsync(canonicalId, cancellationToken);
if (record is null)
{
logger.LogWarning("Evidence thread not found for canonical_id {CanonicalId}", canonicalId);
return Results.NotFound(new { error = "Evidence thread not found", canonical_id = canonicalId });
}
var attestations = ParseAttestations(record.Attestations);
var response = new GetEvidenceThreadResponse
{
CanonicalId = record.CanonicalId,
Format = record.Format,
ArtifactDigest = record.ArtifactDigest,
Purl = record.Purl,
Attestations = include_attestations ? attestations : [],
CreatedAt = record.CreatedAt
};
return Results.Ok(response);
}
catch (Exception ex)
{
logger.LogError(ex, "Error retrieving evidence thread for canonical_id {CanonicalId}", canonicalId);
return Results.Problem(
title: "Internal server error",
detail: "Failed to retrieve evidence thread",
statusCode: StatusCodes.Status500InternalServerError);
}
}
private static async Task<IResult> ListThreadsByPurlAsync(
[FromServices] IEvidenceThreadRepository repository,
[FromServices] ILogger<EvidenceThreadEndpointsLogger> logger,
CancellationToken cancellationToken,
[FromQuery] string? purl = null)
{
try
{
if (string.IsNullOrWhiteSpace(purl))
{
return Results.BadRequest(new { error = "purl query parameter is required" });
}
logger.LogInformation("Listing evidence threads for PURL {Purl}", purl);
var records = await repository.GetByPurlAsync(purl, cancellationToken);
var threads = records.Select(r =>
{
var attestations = ParseAttestations(r.Attestations);
return new EvidenceThreadSummary
{
CanonicalId = r.CanonicalId,
Format = r.Format,
Purl = r.Purl,
AttestationCount = attestations.Count,
CreatedAt = r.CreatedAt
};
}).ToList();
var response = new ListEvidenceThreadsResponse
{
Threads = threads,
Pagination = new PaginationInfo
{
Total = threads.Count,
Limit = 100,
Offset = 0
}
};
return Results.Ok(response);
}
catch (Exception ex)
{
logger.LogError(ex, "Error listing evidence threads for PURL {Purl}", purl);
return Results.Problem(
title: "Internal server error",
detail: "Failed to list evidence threads",
statusCode: StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Parses the JSONB attestations array from the materialized view into typed records.
/// </summary>
private static IReadOnlyList<EvidenceThreadAttestation> ParseAttestations(string attestationsJson)
{
if (string.IsNullOrWhiteSpace(attestationsJson) || attestationsJson == "[]")
{
return [];
}
try
{
using var doc = JsonDocument.Parse(attestationsJson);
var results = new List<EvidenceThreadAttestation>();
foreach (var element in doc.RootElement.EnumerateArray())
{
var predicateType = element.GetProperty("predicate_type").GetString();
var dsseDigest = element.GetProperty("dsse_digest").GetString();
var signedAtRaw = element.GetProperty("signed_at").GetString();
if (predicateType is null || dsseDigest is null || signedAtRaw is null)
{
continue;
}
results.Add(new EvidenceThreadAttestation
{
PredicateType = predicateType,
DsseDigest = dsseDigest,
SignerKeyId = element.TryGetProperty("signer_keyid", out var sk) ? sk.GetString() : null,
RekorEntryId = element.TryGetProperty("rekor_entry_id", out var re) ? re.GetString() : null,
RekorTile = element.TryGetProperty("rekor_tile", out var rt) ? rt.GetString() : null,
SignedAt = DateTimeOffset.Parse(signedAtRaw)
});
}
// Deterministic ordering: signed_at ascending, then predicate_type ascending
return results
.OrderBy(a => a.SignedAt)
.ThenBy(a => a.PredicateType, StringComparer.Ordinal)
.ToList();
}
catch
{
return [];
}
}
}

View File

@@ -90,6 +90,17 @@ public static class EvidenceLockerInfrastructureServiceCollectionExtensions
logger);
});
// Evidence Thread repository (Artifact Canonical Record API)
// Sprint: SPRINT_20260219_009 (CID-04)
services.AddScoped<StellaOps.EvidenceLocker.Storage.IEvidenceThreadRepository>(provider =>
{
var options = provider.GetRequiredService<IOptions<EvidenceLockerOptions>>().Value;
var logger = provider.GetRequiredService<ILogger<StellaOps.EvidenceLocker.Storage.PostgresEvidenceThreadRepository>>();
return new StellaOps.EvidenceLocker.Storage.PostgresEvidenceThreadRepository(
options.Database.ConnectionString,
logger);
});
services.AddSingleton<NullEvidenceTimelinePublisher>();
services.AddHttpClient<TimelineIndexerEvidenceTimelinePublisher>((provider, client) =>
{

View File

@@ -0,0 +1,109 @@
using StellaOps.Auth.Abstractions;
using StellaOps.EvidenceLocker.Api;
using StellaOps.TestKit;
using System.Net;
using System.Net.Http.Json;
using System.Net.Http.Headers;
namespace StellaOps.EvidenceLocker.Tests;
[Collection(EvidenceLockerTestCollection.Name)]
public sealed class EvidenceAuditEndpointsTests : IDisposable
{
private readonly EvidenceLockerWebApplicationFactory _factory;
private readonly HttpClient _client;
public EvidenceAuditEndpointsTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
_factory.ResetTestState();
_client = factory.CreateClient();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EvidenceHomeAndPacks_AreDeterministic()
{
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"), StellaOpsScopes.EvidenceRead);
var homeResponse = await _client.GetAsync("/api/v1/evidence", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, homeResponse.StatusCode);
var home = await homeResponse.Content.ReadFromJsonAsync<EvidenceHomeDto>(TestContext.Current.CancellationToken);
Assert.NotNull(home);
Assert.True(home!.QuickStats.LatestPacks24h > 0);
var firstPacksResponse = await _client.GetAsync("/api/v1/evidence/packs", TestContext.Current.CancellationToken);
var secondPacksResponse = await _client.GetAsync("/api/v1/evidence/packs", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, firstPacksResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondPacksResponse.StatusCode);
var first = await firstPacksResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
var second = await secondPacksResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Equal(first, second);
var payload = await firstPacksResponse.Content.ReadFromJsonAsync<EvidencePackListResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.NotEmpty(payload!.Items);
Assert.Equal("pack-9001", payload.Items[0].PackId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EvidenceAuditRoutes_ReturnExpectedPayloads()
{
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"), StellaOpsScopes.EvidenceRead);
var packDetail = await _client.GetFromJsonAsync<EvidencePackDetailDto>(
"/api/v1/evidence/packs/pack-9001",
TestContext.Current.CancellationToken);
Assert.NotNull(packDetail);
Assert.Equal("chain-9912", packDetail!.ProofChainId);
var proof = await _client.GetFromJsonAsync<ProofChainDetailDto>(
"/api/v1/evidence/proofs/sha256:beef000000000000000000000000000000000000000000000000000000000003",
TestContext.Current.CancellationToken);
Assert.NotNull(proof);
Assert.Equal("valid", proof!.Status);
var audit = await _client.GetFromJsonAsync<EvidenceAuditResponseDto>(
"/api/v1/evidence/audit",
TestContext.Current.CancellationToken);
Assert.NotNull(audit);
Assert.True(audit!.Total >= 1);
var receipt = await _client.GetFromJsonAsync<CvssReceiptDto>(
"/api/v1/evidence/receipts/cvss/CVE-2026-1234",
TestContext.Current.CancellationToken);
Assert.NotNull(receipt);
Assert.Equal(9.8m, receipt!.BaseScore);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EvidenceAuditRoutes_UnknownResources_ReturnNotFound()
{
ConfigureAuthHeaders(_client, Guid.NewGuid().ToString("D"), StellaOpsScopes.EvidenceRead);
var packResponse = await _client.GetAsync("/api/v1/evidence/packs/missing-pack", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, packResponse.StatusCode);
var proofResponse = await _client.GetAsync("/api/v1/evidence/proofs/sha256:missing", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, proofResponse.StatusCode);
var receiptResponse = await _client.GetAsync("/api/v1/evidence/receipts/cvss/CVE-0000-0000", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, receiptResponse.StatusCode);
}
private static void ConfigureAuthHeaders(HttpClient client, string tenantId, string scopes)
{
client.DefaultRequestHeaders.Clear();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(EvidenceLockerTestAuthHandler.SchemeName);
client.DefaultRequestHeaders.Add("X-Test-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-Test-Scopes", scopes);
}
public void Dispose()
{
_client.Dispose();
}
}

View File

@@ -440,6 +440,12 @@ app.MapExportEndpoints();
// Verdict attestation endpoints
app.MapVerdictEndpoints();
// Evidence & audit adapter endpoints (Pack v2)
app.MapEvidenceAuditEndpoints();
// Evidence Thread endpoints (Artifact Canonical Record API)
app.MapEvidenceThreadEndpoints();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);

View File

@@ -0,0 +1,39 @@
namespace StellaOps.EvidenceLocker.Storage;
/// <summary>
/// Repository for querying the Artifact Canonical Record materialized view.
/// Sprint: SPRINT_20260219_009 (CID-04)
/// </summary>
public interface IEvidenceThreadRepository
{
/// <summary>
/// Retrieves an artifact canonical record by canonical_id (sha256 hex).
/// </summary>
Task<ArtifactCanonicalRecord?> GetByCanonicalIdAsync(
string canonicalId,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves a PURL to artifact canonical records.
/// </summary>
Task<IReadOnlyList<ArtifactCanonicalRecord>> GetByPurlAsync(
string purl,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Row from proofchain.artifact_canonical_records materialized view.
/// </summary>
public sealed record ArtifactCanonicalRecord
{
public required string CanonicalId { get; init; }
public required string Format { get; init; }
public string? ArtifactDigest { get; init; }
public string? Purl { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Aggregated attestations as JSONB string from the materialized view.
/// </summary>
public required string Attestations { get; init; }
}

View File

@@ -0,0 +1,108 @@
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace StellaOps.EvidenceLocker.Storage;
/// <summary>
/// PostgreSQL implementation of <see cref="IEvidenceThreadRepository"/>.
/// Reads from the proofchain.artifact_canonical_records materialized view.
/// Sprint: SPRINT_20260219_009 (CID-04)
/// </summary>
public sealed class PostgresEvidenceThreadRepository : IEvidenceThreadRepository
{
private readonly string _connectionString;
private readonly ILogger<PostgresEvidenceThreadRepository> _logger;
public PostgresEvidenceThreadRepository(
string connectionString,
ILogger<PostgresEvidenceThreadRepository> logger)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ArtifactCanonicalRecord?> GetByCanonicalIdAsync(
string canonicalId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(canonicalId))
{
throw new ArgumentException("Canonical ID cannot be null or whitespace.", nameof(canonicalId));
}
const string sql = @"
SELECT
canonical_id AS CanonicalId,
format AS Format,
artifact_digest AS ArtifactDigest,
purl AS Purl,
created_at AS CreatedAt,
attestations::text AS Attestations
FROM proofchain.artifact_canonical_records
WHERE canonical_id = @CanonicalId;
";
try
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
var record = await connection.QuerySingleOrDefaultAsync<ArtifactCanonicalRecord>(
new CommandDefinition(
sql,
new { CanonicalId = canonicalId },
cancellationToken: cancellationToken));
return record;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve artifact canonical record for {CanonicalId}", canonicalId);
throw;
}
}
public async Task<IReadOnlyList<ArtifactCanonicalRecord>> GetByPurlAsync(
string purl,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(purl))
{
throw new ArgumentException("PURL cannot be null or whitespace.", nameof(purl));
}
const string sql = @"
SELECT
canonical_id AS CanonicalId,
format AS Format,
artifact_digest AS ArtifactDigest,
purl AS Purl,
created_at AS CreatedAt,
attestations::text AS Attestations
FROM proofchain.artifact_canonical_records
WHERE purl = @Purl
ORDER BY created_at DESC
LIMIT 100;
";
try
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
var results = await connection.QueryAsync<ArtifactCanonicalRecord>(
new CommandDefinition(
sql,
new { Purl = purl },
cancellationToken: cancellationToken));
return results.AsList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve artifact canonical records for PURL {Purl}", purl);
throw;
}
}
}