Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user