wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TenantIsolationTests.cs
|
||||
// Description: Tenant isolation unit tests for Findings Ledger module.
|
||||
// Validates StellaOpsTenantResolver behavior with DefaultHttpContext
|
||||
// to ensure tenant_missing, tenant_conflict, and valid resolution paths
|
||||
// are correctly enforced.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.TestKit;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class TenantIsolationTests
|
||||
{
|
||||
// ── 1. Missing tenant returns error ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_WithNoClaims_AndNoHeaders_ReturnsFalse_WithTenantMissing()
|
||||
{
|
||||
// Arrange: bare context -- no claims, no headers
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("no tenant claim or header was provided");
|
||||
tenantId.Should().BeEmpty();
|
||||
error.Should().Be("tenant_missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithNoClaims_AndNoHeaders_ReturnsFalse_WithTenantMissing()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolve(context, out var tenantContext, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("no tenant claim or header was provided");
|
||||
tenantContext.Should().BeNull();
|
||||
error.Should().Be("tenant_missing");
|
||||
}
|
||||
|
||||
// ── 2. Valid tenant via canonical claim succeeds ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_WithCanonicalClaim_ReturnsTenantId()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-a") },
|
||||
authenticationType: "test"));
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
tenantId.Should().Be("tenant-a");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithCanonicalClaim_ReturnsTenantContext_WithClaimSource()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[]
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "TENANT-B"),
|
||||
new Claim(StellaOpsClaimTypes.Subject, "user-42"),
|
||||
},
|
||||
authenticationType: "test"));
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolve(context, out var tenantContext, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
error.Should().BeNull();
|
||||
tenantContext.Should().NotBeNull();
|
||||
tenantContext!.TenantId.Should().Be("tenant-b", "tenant IDs are normalised to lower-case");
|
||||
tenantContext.Source.Should().Be(TenantSource.Claim);
|
||||
tenantContext.ActorId.Should().Be("user-42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_WithCanonicalHeader_ReturnsTenantId()
|
||||
{
|
||||
// Arrange
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-header";
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
tenantId.Should().Be("tenant-header");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_WithLegacyTidClaim_ReturnsTenantId()
|
||||
{
|
||||
// Arrange: legacy "tid" claim should also resolve
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[] { new Claim("tid", "legacy-tenant") },
|
||||
authenticationType: "test"));
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
tenantId.Should().Be("legacy-tenant");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── 3. Conflicting headers return tenant_conflict ───────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_WithConflictingHeaders_ReturnsFalse_WithTenantConflict()
|
||||
{
|
||||
// Arrange: canonical X-StellaOps-Tenant and legacy X-Stella-Tenant have different values
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-alpha";
|
||||
context.Request.Headers["X-Stella-Tenant"] = "tenant-beta";
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("conflicting headers should be rejected");
|
||||
error.Should().Be("tenant_conflict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_WithConflictingCanonicalAndAlternateHeaders_ReturnsFalse_WithTenantConflict()
|
||||
{
|
||||
// Arrange: canonical X-StellaOps-Tenant and alternate X-Tenant-Id have different values
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-one";
|
||||
context.Request.Headers["X-Tenant-Id"] = "tenant-two";
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("conflicting headers should be rejected");
|
||||
error.Should().Be("tenant_conflict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_WithClaimHeaderMismatch_ReturnsFalse_WithTenantConflict()
|
||||
{
|
||||
// Arrange: claim says "tenant-claim" but header says "tenant-header" -- mismatch
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-claim") },
|
||||
authenticationType: "test"));
|
||||
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-header";
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse("claim-header mismatch is a conflict");
|
||||
error.Should().Be("tenant_conflict");
|
||||
}
|
||||
|
||||
// ── 4. Matching claim + header is not a conflict ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryResolveTenantId_WithMatchingClaimAndHeader_ReturnsTrue()
|
||||
{
|
||||
// Arrange: claim and header agree on the same tenant
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-same") },
|
||||
authenticationType: "test"));
|
||||
context.Request.Headers[StellaOpsHttpHeaderNames.Tenant] = "tenant-same";
|
||||
|
||||
// Act
|
||||
var result = StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue("claim and header agree");
|
||||
tenantId.Should().Be("tenant-same");
|
||||
error.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
@@ -21,21 +22,26 @@ public static class BackportEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Backport Evidence")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization("scoring.read")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/backport
|
||||
group.MapGet("/{findingId:guid}/backport", GetBackportEvidence)
|
||||
.WithName("GetBackportEvidence")
|
||||
.WithDescription("Get backport verification evidence for a finding")
|
||||
.WithSummary("Get backport verification evidence for a finding")
|
||||
.WithDescription("Returns backport verification evidence for a specific finding, detailing whether upstream patches have been ported to the affected package version and the confidence level of the backport determination.")
|
||||
.Produces<BackportEvidenceResponse>(200)
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.RequireAuthorization("scoring.read");
|
||||
|
||||
// GET /api/v1/findings/{findingId}/patches
|
||||
group.MapGet("/{findingId:guid}/patches", GetPatches)
|
||||
.WithName("GetPatches")
|
||||
.WithDescription("Get patch signatures for a finding")
|
||||
.WithSummary("Get patch signatures for a finding")
|
||||
.WithDescription("Returns the set of patch signatures associated with a finding, including cryptographic commit references and verification status used to confirm whether a given patch has been applied to the affected artifact.")
|
||||
.Produces<PatchesResponse>(200)
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.RequireAuthorization("scoring.read");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -44,6 +50,7 @@ public static class BackportEndpoints
|
||||
private static async Task<Results<Ok<BackportEvidenceResponse>, NotFound>> GetBackportEvidence(
|
||||
Guid findingId,
|
||||
IBackportEvidenceService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var evidence = await service.GetBackportEvidenceAsync(findingId, ct);
|
||||
@@ -59,6 +66,7 @@ public static class BackportEndpoints
|
||||
private static async Task<Results<Ok<PatchesResponse>, NotFound>> GetPatches(
|
||||
Guid findingId,
|
||||
IBackportEvidenceService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var patches = await service.GetPatchesAsync(findingId, ct);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
@@ -11,12 +12,14 @@ public static class EvidenceGraphEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Evidence Graph")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization("scoring.read")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/evidence-graph
|
||||
group.MapGet("/{findingId:guid}/evidence-graph", async Task<Results<Ok<EvidenceGraphResponse>, NotFound>> (
|
||||
Guid findingId,
|
||||
IEvidenceGraphBuilder builder,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct,
|
||||
[FromQuery] bool includeContent = false) =>
|
||||
{
|
||||
@@ -26,15 +29,18 @@ public static class EvidenceGraphEndpoints
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetEvidenceGraph")
|
||||
.WithDescription("Get evidence graph for finding visualization")
|
||||
.WithSummary("Get evidence graph for finding visualization")
|
||||
.WithDescription("Returns the evidence graph for a finding as a set of typed nodes (scanner events, attestations, runtime observations, SBOM matches) and directed edges representing causal and corroborating relationships, suitable for interactive graph visualization in the UI.")
|
||||
.Produces<EvidenceGraphResponse>(200)
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.RequireAuthorization("scoring.read");
|
||||
|
||||
// GET /api/v1/findings/{findingId}/evidence/{nodeId}
|
||||
group.MapGet("/{findingId:guid}/evidence/{nodeId}", async Task<Results<Ok<object>, NotFound>> (
|
||||
Guid findingId,
|
||||
string nodeId,
|
||||
IEvidenceContentService contentService,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var content = await contentService.GetContentAsync(findingId, nodeId, ct);
|
||||
@@ -43,9 +49,11 @@ public static class EvidenceGraphEndpoints
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetEvidenceNodeContent")
|
||||
.WithDescription("Get raw content for an evidence node")
|
||||
.WithSummary("Get raw content for an evidence node")
|
||||
.WithDescription("Returns the raw content payload of a specific evidence node within a finding's evidence graph. Content format varies by node type (JSON for scanner events, JWS for signed attestations, plain text for trace logs).")
|
||||
.Produces<object>(200)
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.RequireAuthorization("scoring.read");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
@@ -12,12 +13,14 @@ public static class FindingSummaryEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Findings")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization("scoring.read")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/summary
|
||||
group.MapGet("/{findingId}/summary", async Task<Results<Ok<FindingSummary>, NotFound, ProblemHttpResult>> (
|
||||
string findingId,
|
||||
IFindingSummaryService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!Guid.TryParse(findingId, out var parsedId))
|
||||
@@ -34,14 +37,17 @@ public static class FindingSummaryEndpoints
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetFindingSummary")
|
||||
.WithDescription("Get condensed finding summary for vulnerability-first UX")
|
||||
.WithSummary("Get condensed finding summary for vulnerability-first UX")
|
||||
.WithDescription("Returns a condensed summary of a finding optimized for the vulnerability-first UI view, including severity, status, confidence, affected component, and evidence highlights. The findingId must be a valid GUID.")
|
||||
.Produces<FindingSummary>(200)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.RequireAuthorization("scoring.read");
|
||||
|
||||
// GET /api/v1/findings/summaries
|
||||
group.MapGet("/summaries", async Task<Ok<FindingSummaryPage>> (
|
||||
IFindingSummaryService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
@@ -66,7 +72,9 @@ public static class FindingSummaryEndpoints
|
||||
return TypedResults.Ok(result);
|
||||
})
|
||||
.WithName("GetFindingSummaries")
|
||||
.WithDescription("Get paginated list of finding summaries")
|
||||
.Produces<FindingSummaryPage>(200);
|
||||
.WithSummary("Get paginated list of finding summaries")
|
||||
.WithDescription("Returns a paginated list of finding summaries with optional filtering by status, severity, and minimum confidence score. Results are sortable by any summary field and support both ascending and descending direction.")
|
||||
.Produces<FindingSummaryPage>(200)
|
||||
.RequireAuthorization("scoring.read");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scanner.Reachability.MiniMap;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
@@ -10,12 +11,14 @@ public static class ReachabilityMapEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Reachability")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization("scoring.read")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/reachability-map
|
||||
group.MapGet("/{findingId:guid}/reachability-map", async Task<Results<Ok<ReachabilityMiniMap>, NotFound>> (
|
||||
Guid findingId,
|
||||
IReachabilityMapService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct,
|
||||
[FromQuery] int maxPaths = 10) =>
|
||||
{
|
||||
@@ -25,9 +28,11 @@ public static class ReachabilityMapEndpoints
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetReachabilityMiniMap")
|
||||
.WithDescription("Get condensed reachability visualization")
|
||||
.WithSummary("Get condensed reachability visualization")
|
||||
.WithDescription("Returns a condensed reachability mini-map for a finding, showing the call graph paths from entry points to the affected vulnerable function. Limits the number of displayed paths via the maxPaths parameter to keep the visualization manageable.")
|
||||
.Produces<ReachabilityMiniMap>(200)
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.RequireAuthorization("scoring.read");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture.Timeline;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
@@ -10,12 +11,14 @@ public static class RuntimeTimelineEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Runtime")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization("scoring.read")
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/findings/{findingId}/runtime-timeline
|
||||
group.MapGet("/{findingId:guid}/runtime-timeline", async Task<Results<Ok<RuntimeTimeline>, NotFound>> (
|
||||
Guid findingId,
|
||||
IRuntimeTimelineService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct,
|
||||
[FromQuery] DateTimeOffset? from = null,
|
||||
[FromQuery] DateTimeOffset? to = null,
|
||||
@@ -34,9 +37,11 @@ public static class RuntimeTimelineEndpoints
|
||||
: TypedResults.NotFound();
|
||||
})
|
||||
.WithName("GetRuntimeTimeline")
|
||||
.WithDescription("Get runtime corroboration timeline")
|
||||
.WithSummary("Get runtime corroboration timeline")
|
||||
.WithDescription("Returns a bucketed timeline of runtime corroboration events for a finding over a configurable time window. Each bucket represents the observation count within the bucket interval (1-24 hours), enabling trend analysis of whether the vulnerable code path has been exercised in production.")
|
||||
.Produces<RuntimeTimeline>(200)
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.RequireAuthorization("scoring.read");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||
@@ -22,29 +23,36 @@ public static class RuntimeTracesEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Runtime Evidence")
|
||||
.RequireAuthorization();
|
||||
.RequireAuthorization("scoring.read")
|
||||
.RequireTenant();
|
||||
|
||||
// POST /api/v1/findings/{findingId}/runtime/traces
|
||||
group.MapPost("/{findingId:guid}/runtime/traces", IngestRuntimeTrace)
|
||||
.WithName("IngestRuntimeTrace")
|
||||
.WithDescription("Ingest runtime trace observation for a finding")
|
||||
.WithSummary("Ingest runtime trace observation for a finding")
|
||||
.WithDescription("Accepts a runtime trace observation from an eBPF or APM agent, recording which function frames were observed executing within a vulnerable component at runtime. Requires artifact digest and component PURL for cross-referencing. Returns 202 Accepted; the trace is processed asynchronously.")
|
||||
.Accepts<RuntimeTraceIngestRequest>("application/json")
|
||||
.Produces<RuntimeTraceIngestResponse>(202)
|
||||
.ProducesValidationProblem();
|
||||
.ProducesValidationProblem()
|
||||
.RequireAuthorization("ledger.events.write");
|
||||
|
||||
// GET /api/v1/findings/{findingId}/runtime/traces
|
||||
group.MapGet("/{findingId:guid}/runtime/traces", GetRuntimeTraces)
|
||||
.WithName("GetRuntimeTraces")
|
||||
.WithDescription("Get runtime function traces for a finding")
|
||||
.WithSummary("Get runtime function traces for a finding")
|
||||
.WithDescription("Returns the aggregated runtime function traces recorded for a finding, sorted by hit count or recency. Each trace entry includes the function frame, hit count, artifact digest, and component PURL for cross-referencing with SBOM data.")
|
||||
.Produces<RuntimeTracesResponse>(200)
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.RequireAuthorization("scoring.read");
|
||||
|
||||
// GET /api/v1/findings/{findingId}/runtime/score
|
||||
group.MapGet("/{findingId:guid}/runtime/score", GetRtsScore)
|
||||
.WithName("GetRtsScore")
|
||||
.WithDescription("Get Runtime Trustworthiness Score for a finding")
|
||||
.WithSummary("Get Runtime Trustworthiness Score for a finding")
|
||||
.WithDescription("Returns the Runtime Trustworthiness Score (RTS) for a finding, derived from observed runtime trace density and recency. A higher RTS indicates that the vulnerable code path has been recently and frequently exercised in production, increasing remediation priority.")
|
||||
.Produces<RtsScoreResponse>(200)
|
||||
.Produces(404);
|
||||
.Produces(404)
|
||||
.RequireAuthorization("scoring.read");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,6 +62,7 @@ public static class RuntimeTracesEndpoints
|
||||
Guid findingId,
|
||||
RuntimeTraceIngestRequest request,
|
||||
IRuntimeTracesService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
@@ -87,6 +96,7 @@ public static class RuntimeTracesEndpoints
|
||||
private static async Task<Results<Ok<RuntimeTracesResponse>, NotFound>> GetRuntimeTraces(
|
||||
Guid findingId,
|
||||
IRuntimeTracesService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct,
|
||||
[FromQuery] int? limit = null,
|
||||
[FromQuery] string? sortBy = null)
|
||||
@@ -110,6 +120,7 @@ public static class RuntimeTracesEndpoints
|
||||
private static async Task<Results<Ok<RtsScoreResponse>, NotFound>> GetRtsScore(
|
||||
Guid findingId,
|
||||
IRuntimeTracesService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var score = await service.GetRtsScoreAsync(findingId, ct);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
using System.Diagnostics;
|
||||
@@ -26,16 +27,19 @@ public static class ScoringEndpoints
|
||||
public static void MapScoringEndpoints(this WebApplication app)
|
||||
{
|
||||
var findingsGroup = app.MapGroup("/api/v1/findings")
|
||||
.WithTags("Scoring");
|
||||
.WithTags("Scoring")
|
||||
.RequireTenant();
|
||||
|
||||
var scoringGroup = app.MapGroup("/api/v1/scoring")
|
||||
.WithTags("Scoring");
|
||||
.WithTags("Scoring")
|
||||
.RequireTenant();
|
||||
|
||||
// POST /api/v1/findings/{findingId}/score - Calculate score
|
||||
// Rate limit: 100/min (via API Gateway)
|
||||
findingsGroup.MapPost("/{findingId}/score", CalculateScore)
|
||||
.WithName("CalculateFindingScore")
|
||||
.WithDescription("Calculate evidence-weighted score for a finding")
|
||||
.WithSummary("Calculate evidence-weighted score for a finding")
|
||||
.WithDescription("Computes and persists an evidence-weighted severity score for a finding by aggregating all available evidence signals (scanner severity, reachability, runtime corroboration, backport status). The result replaces any previously cached score. Returns 404 if the finding does not exist or has no evidence.")
|
||||
.RequireAuthorization(ScoringWritePolicy)
|
||||
.Produces<EvidenceWeightedScoreResponse>(200)
|
||||
.Produces<ScoringErrorResponse>(400)
|
||||
@@ -46,7 +50,8 @@ public static class ScoringEndpoints
|
||||
// Rate limit: 1000/min (via API Gateway)
|
||||
findingsGroup.MapGet("/{findingId}/score", GetCachedScore)
|
||||
.WithName("GetFindingScore")
|
||||
.WithDescription("Get cached evidence-weighted score for a finding")
|
||||
.WithSummary("Get cached evidence-weighted score for a finding")
|
||||
.WithDescription("Returns the most recently computed evidence-weighted score for a finding without triggering a recalculation. Returns 404 if no score has been computed yet; callers should use POST /score to trigger an initial computation.")
|
||||
.RequireAuthorization(ScoringReadPolicy)
|
||||
.Produces<EvidenceWeightedScoreResponse>(200)
|
||||
.Produces(404);
|
||||
@@ -55,7 +60,8 @@ public static class ScoringEndpoints
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
findingsGroup.MapPost("/scores", CalculateScoresBatch)
|
||||
.WithName("CalculateFindingScoresBatch")
|
||||
.WithDescription("Calculate evidence-weighted scores for multiple findings")
|
||||
.WithSummary("Calculate evidence-weighted scores for multiple findings")
|
||||
.WithDescription("Computes evidence-weighted scores for up to 100 findings in a single request. Each finding is scored independently; partial results are returned if some findings are missing evidence. Batch size exceeding 100 returns 400.")
|
||||
.RequireAuthorization(ScoringWritePolicy)
|
||||
.Produces<CalculateScoresBatchResponse>(200)
|
||||
.Produces<ScoringErrorResponse>(400)
|
||||
@@ -65,7 +71,8 @@ public static class ScoringEndpoints
|
||||
// Rate limit: 100/min (via API Gateway)
|
||||
findingsGroup.MapGet("/{findingId}/score-history", GetScoreHistory)
|
||||
.WithName("GetFindingScoreHistory")
|
||||
.WithDescription("Get score history for a finding")
|
||||
.WithSummary("Get score history for a finding")
|
||||
.WithDescription("Returns a paginated history of evidence-weighted score computations for a finding, optionally filtered by time range. Each entry records the score value, contributing evidence weights, and the policy version used for that computation.")
|
||||
.RequireAuthorization(ScoringReadPolicy)
|
||||
.Produces<ScoreHistoryResponse>(200)
|
||||
.Produces(404);
|
||||
@@ -74,7 +81,8 @@ public static class ScoringEndpoints
|
||||
// Rate limit: 100/min (via API Gateway)
|
||||
scoringGroup.MapGet("/policy", GetActivePolicy)
|
||||
.WithName("GetActiveScoringPolicy")
|
||||
.WithDescription("Get the active scoring policy configuration")
|
||||
.WithSummary("Get the active scoring policy configuration")
|
||||
.WithDescription("Returns the currently active evidence-weighted scoring policy, including the version identifier, evidence type weights, severity multipliers, and effective date. The active policy is used for all new score computations.")
|
||||
.RequireAuthorization(ScoringReadPolicy)
|
||||
.Produces<ScoringPolicyResponse>(200);
|
||||
|
||||
@@ -82,7 +90,8 @@ public static class ScoringEndpoints
|
||||
// Rate limit: 100/min (via API Gateway)
|
||||
scoringGroup.MapGet("/policy/{version}", GetPolicyVersion)
|
||||
.WithName("GetScoringPolicyVersion")
|
||||
.WithDescription("Get a specific scoring policy version")
|
||||
.WithSummary("Get a specific scoring policy version")
|
||||
.WithDescription("Returns the scoring policy configuration for a specific version identifier. Useful for auditing historical score computations by confirming which weights and multipliers were in effect at the time a score was recorded.")
|
||||
.RequireAuthorization(ScoringReadPolicy)
|
||||
.Produces<ScoringPolicyResponse>(200)
|
||||
.Produces(404);
|
||||
@@ -92,7 +101,8 @@ public static class ScoringEndpoints
|
||||
// Task: API-8200-029
|
||||
scoringGroup.MapGet("/policy/versions", ListPolicyVersions)
|
||||
.WithName("ListScoringPolicyVersions")
|
||||
.WithDescription("List all available scoring policy versions")
|
||||
.WithSummary("List all available scoring policy versions")
|
||||
.WithDescription("Returns a list of all scoring policy versions available in the system, including version identifiers, effective dates, and which version is currently active. Used for audit log cross-referencing and policy governance.")
|
||||
.RequireAuthorization(ScoringReadPolicy)
|
||||
.Produces<PolicyVersionListResponse>(200);
|
||||
}
|
||||
@@ -101,6 +111,7 @@ public static class ScoringEndpoints
|
||||
string findingId,
|
||||
[FromBody] CalculateScoreRequest? request,
|
||||
IFindingScoringService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
request ??= new CalculateScoreRequest();
|
||||
@@ -134,6 +145,7 @@ public static class ScoringEndpoints
|
||||
private static async Task<Results<Ok<EvidenceWeightedScoreResponse>, NotFound>> GetCachedScore(
|
||||
string findingId,
|
||||
IFindingScoringService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await service.GetCachedScoreAsync(findingId, ct);
|
||||
@@ -148,6 +160,7 @@ public static class ScoringEndpoints
|
||||
private static async Task<Results<Ok<CalculateScoresBatchResponse>, BadRequest<ScoringErrorResponse>>> CalculateScoresBatch(
|
||||
[FromBody] CalculateScoresBatchRequest request,
|
||||
IFindingScoringService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Validate batch size
|
||||
@@ -190,6 +203,7 @@ public static class ScoringEndpoints
|
||||
private static async Task<Results<Ok<ScoreHistoryResponse>, NotFound>> GetScoreHistory(
|
||||
string findingId,
|
||||
IFindingScoringService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct,
|
||||
[FromQuery] DateTimeOffset? from = null,
|
||||
[FromQuery] DateTimeOffset? to = null,
|
||||
@@ -209,6 +223,7 @@ public static class ScoringEndpoints
|
||||
|
||||
private static async Task<Ok<ScoringPolicyResponse>> GetActivePolicy(
|
||||
IFindingScoringService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policy = await service.GetActivePolicyAsync(ct);
|
||||
@@ -218,6 +233,7 @@ public static class ScoringEndpoints
|
||||
private static async Task<Results<Ok<ScoringPolicyResponse>, NotFound>> GetPolicyVersion(
|
||||
string version,
|
||||
IFindingScoringService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policy = await service.GetPolicyVersionAsync(version, ct);
|
||||
@@ -231,6 +247,7 @@ public static class ScoringEndpoints
|
||||
|
||||
private static async Task<Ok<PolicyVersionListResponse>> ListPolicyVersions(
|
||||
IFindingScoringService service,
|
||||
IStellaOpsTenantAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var versions = await service.ListPolicyVersionsAsync(ct);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
using StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
@@ -17,13 +18,15 @@ public static class WebhookEndpoints
|
||||
public static void MapWebhookEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/scoring/webhooks")
|
||||
.WithTags("Webhooks");
|
||||
.WithTags("Webhooks")
|
||||
.RequireTenant();
|
||||
|
||||
// POST /api/v1/scoring/webhooks - Register webhook
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapPost("/", RegisterWebhook)
|
||||
.WithName("RegisterScoringWebhook")
|
||||
.WithDescription("Register a webhook for score change notifications")
|
||||
.WithSummary("Register a webhook for score change notifications")
|
||||
.WithDescription("Registers an HTTPS callback URL to receive score change notifications. Supports optional HMAC-SHA256 signing via a shared secret, finding pattern filters, minimum score change threshold, and bucket transition triggers. The webhook is activated immediately upon registration.")
|
||||
.Produces<WebhookResponse>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem()
|
||||
.RequireAuthorization(ScoringAdminPolicy);
|
||||
@@ -32,7 +35,8 @@ public static class WebhookEndpoints
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapGet("/", ListWebhooks)
|
||||
.WithName("ListScoringWebhooks")
|
||||
.WithDescription("List all registered webhooks")
|
||||
.WithSummary("List all registered webhooks")
|
||||
.WithDescription("Returns all currently registered score change webhooks with their configuration, including URL, filter patterns, minimum score change threshold, and creation timestamp. Secrets are not returned in responses.")
|
||||
.Produces<WebhookListResponse>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScoringAdminPolicy);
|
||||
|
||||
@@ -40,7 +44,8 @@ public static class WebhookEndpoints
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapGet("/{id:guid}", GetWebhook)
|
||||
.WithName("GetScoringWebhook")
|
||||
.WithDescription("Get a specific webhook by ID")
|
||||
.WithSummary("Get a specific webhook by ID")
|
||||
.WithDescription("Returns the configuration of a specific webhook by its UUID. Inactive webhooks (soft-deleted) return 404. Secrets are not included in the response body.")
|
||||
.Produces<WebhookResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScoringAdminPolicy);
|
||||
@@ -49,7 +54,8 @@ public static class WebhookEndpoints
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapPut("/{id:guid}", UpdateWebhook)
|
||||
.WithName("UpdateScoringWebhook")
|
||||
.WithDescription("Update a webhook configuration")
|
||||
.WithSummary("Update a webhook configuration")
|
||||
.WithDescription("Replaces the full configuration of an existing webhook. All fields in the request body are applied as-is; partial updates are not supported. To update a secret, supply the new secret value; omitting the secret field retains the existing secret.")
|
||||
.Produces<WebhookResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesValidationProblem()
|
||||
@@ -59,7 +65,8 @@ public static class WebhookEndpoints
|
||||
// Rate limit: 10/min (via API Gateway)
|
||||
group.MapDelete("/{id:guid}", DeleteWebhook)
|
||||
.WithName("DeleteScoringWebhook")
|
||||
.WithDescription("Delete a webhook")
|
||||
.WithSummary("Delete a webhook")
|
||||
.WithDescription("Permanently removes a webhook registration by its UUID. No further score change notifications will be delivered to the associated URL after deletion. Returns 204 on success, 404 if the webhook does not exist.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScoringAdminPolicy);
|
||||
@@ -67,7 +74,8 @@ public static class WebhookEndpoints
|
||||
|
||||
private static Results<Created<WebhookResponse>, ValidationProblem> RegisterWebhook(
|
||||
[FromBody] RegisterWebhookRequest request,
|
||||
[FromServices] IWebhookStore store)
|
||||
[FromServices] IWebhookStore store,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
|
||||
{
|
||||
// Validate URL
|
||||
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) ||
|
||||
@@ -86,7 +94,8 @@ public static class WebhookEndpoints
|
||||
}
|
||||
|
||||
private static Ok<WebhookListResponse> ListWebhooks(
|
||||
[FromServices] IWebhookStore store)
|
||||
[FromServices] IWebhookStore store,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
|
||||
{
|
||||
var webhooks = store.List();
|
||||
var response = new WebhookListResponse
|
||||
@@ -100,7 +109,8 @@ public static class WebhookEndpoints
|
||||
|
||||
private static Results<Ok<WebhookResponse>, NotFound> GetWebhook(
|
||||
Guid id,
|
||||
[FromServices] IWebhookStore store)
|
||||
[FromServices] IWebhookStore store,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
|
||||
{
|
||||
var webhook = store.Get(id);
|
||||
if (webhook is null || !webhook.IsActive)
|
||||
@@ -114,7 +124,8 @@ public static class WebhookEndpoints
|
||||
private static Results<Ok<WebhookResponse>, NotFound, ValidationProblem> UpdateWebhook(
|
||||
Guid id,
|
||||
[FromBody] RegisterWebhookRequest request,
|
||||
[FromServices] IWebhookStore store)
|
||||
[FromServices] IWebhookStore store,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
|
||||
{
|
||||
// Validate URL
|
||||
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) ||
|
||||
@@ -137,7 +148,8 @@ public static class WebhookEndpoints
|
||||
|
||||
private static Results<NoContent, NotFound> DeleteWebhook(
|
||||
Guid id,
|
||||
[FromServices] IWebhookStore store)
|
||||
[FromServices] IWebhookStore store,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
|
||||
{
|
||||
if (!store.Delete(id))
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
@@ -300,6 +301,7 @@ var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("findings");
|
||||
var app = builder.Build();
|
||||
@@ -327,6 +329,7 @@ app.UseExceptionHandler(exceptionApp =>
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.Findings.Ledger.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Findings.Ledger.EfCore.CompiledModels
|
||||
{
|
||||
[DbContext(typeof(FindingsLedgerDbContext))]
|
||||
public partial class FindingsLedgerDbContextModel : RuntimeModel
|
||||
{
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static FindingsLedgerDbContextModel()
|
||||
{
|
||||
var model = new FindingsLedgerDbContextModel();
|
||||
|
||||
if (_useOldBehavior31751)
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
void RunInitialization()
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
model.Customize();
|
||||
_instance = (FindingsLedgerDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static FindingsLedgerDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Findings.Ledger.EfCore.CompiledModels
|
||||
{
|
||||
public partial class FindingsLedgerDbContextModel
|
||||
{
|
||||
private FindingsLedgerDbContextModel()
|
||||
: base(skipDetectChanges: false, modelId: new Guid("a1e2c3d4-5f67-8a9b-0c1d-2e3f4a5b6c7d"), entityTypeCount: 11)
|
||||
{
|
||||
}
|
||||
|
||||
partial void Initialize()
|
||||
{
|
||||
// Stub: entity type initialization will be generated by dotnet ef dbcontext optimize.
|
||||
// For now, this enables runtime model resolution for the default schema path.
|
||||
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
AddAnnotation("ProductVersion", "10.0.0");
|
||||
AddAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Context;
|
||||
|
||||
public partial class FindingsLedgerDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public FindingsLedgerDbContext(DbContextOptions<FindingsLedgerDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "public"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<LedgerEventEntity> LedgerEvents { get; set; } = null!;
|
||||
public virtual DbSet<LedgerMerkleRootEntity> LedgerMerkleRoots { get; set; } = null!;
|
||||
public virtual DbSet<FindingsProjectionEntity> FindingsProjections { get; set; } = null!;
|
||||
public virtual DbSet<FindingHistoryEntity> FindingHistories { get; set; } = null!;
|
||||
public virtual DbSet<TriageActionEntity> TriageActions { get; set; } = null!;
|
||||
public virtual DbSet<LedgerProjectionOffsetEntity> LedgerProjectionOffsets { get; set; } = null!;
|
||||
public virtual DbSet<AirgapImportEntity> AirgapImports { get; set; } = null!;
|
||||
public virtual DbSet<LedgerAttestationPointerEntity> LedgerAttestationPointers { get; set; } = null!;
|
||||
public virtual DbSet<OrchestratorExportEntity> OrchestratorExports { get; set; } = null!;
|
||||
public virtual DbSet<LedgerSnapshotEntity> LedgerSnapshots { get; set; } = null!;
|
||||
public virtual DbSet<ObservationEntity> Observations { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
modelBuilder.Entity<LedgerEventEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.ChainId, e.SequenceNo })
|
||||
.HasName("pk_ledger_events");
|
||||
|
||||
entity.ToTable("ledger_events", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.EventId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("uq_ledger_events_event_id");
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.FindingId, e.PolicyVersion })
|
||||
.HasDatabaseName("ix_ledger_events_finding");
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.EventType, e.RecordedAt })
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("ix_ledger_events_type");
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.RecordedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_ledger_events_recorded_at");
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ChainId).HasColumnName("chain_id");
|
||||
entity.Property(e => e.SequenceNo).HasColumnName("sequence_no");
|
||||
entity.Property(e => e.EventId).HasColumnName("event_id");
|
||||
entity.Property(e => e.EventType).HasColumnName("event_type");
|
||||
entity.Property(e => e.PolicyVersion).HasColumnName("policy_version");
|
||||
entity.Property(e => e.FindingId).HasColumnName("finding_id");
|
||||
entity.Property(e => e.ArtifactId).HasColumnName("artifact_id");
|
||||
entity.Property(e => e.SourceRunId).HasColumnName("source_run_id");
|
||||
entity.Property(e => e.ActorId).HasColumnName("actor_id");
|
||||
entity.Property(e => e.ActorType).HasColumnName("actor_type");
|
||||
entity.Property(e => e.OccurredAt).HasColumnName("occurred_at");
|
||||
entity.Property(e => e.RecordedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.HasColumnName("recorded_at");
|
||||
entity.Property(e => e.EventBody)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("event_body");
|
||||
entity.Property(e => e.EventHash)
|
||||
.HasMaxLength(64)
|
||||
.IsFixedLength()
|
||||
.HasColumnName("event_hash");
|
||||
entity.Property(e => e.PreviousHash)
|
||||
.HasMaxLength(64)
|
||||
.IsFixedLength()
|
||||
.HasColumnName("previous_hash");
|
||||
entity.Property(e => e.MerkleLeafHash)
|
||||
.HasMaxLength(64)
|
||||
.IsFixedLength()
|
||||
.HasColumnName("merkle_leaf_hash");
|
||||
entity.Property(e => e.EvidenceBundleRef).HasColumnName("evidence_bundle_ref");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LedgerMerkleRootEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.AnchorId })
|
||||
.HasName("pk_ledger_merkle_roots");
|
||||
|
||||
entity.ToTable("ledger_merkle_roots", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.SequenceEnd })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_merkle_sequences");
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.AnchorId).HasColumnName("anchor_id");
|
||||
entity.Property(e => e.WindowStart).HasColumnName("window_start");
|
||||
entity.Property(e => e.WindowEnd).HasColumnName("window_end");
|
||||
entity.Property(e => e.SequenceStart).HasColumnName("sequence_start");
|
||||
entity.Property(e => e.SequenceEnd).HasColumnName("sequence_end");
|
||||
entity.Property(e => e.RootHash)
|
||||
.HasMaxLength(64)
|
||||
.IsFixedLength()
|
||||
.HasColumnName("root_hash");
|
||||
entity.Property(e => e.LeafCount).HasColumnName("leaf_count");
|
||||
entity.Property(e => e.AnchoredAt).HasColumnName("anchored_at");
|
||||
entity.Property(e => e.AnchorReference).HasColumnName("anchor_reference");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<FindingsProjectionEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.FindingId, e.PolicyVersion })
|
||||
.HasName("pk_findings_projection");
|
||||
|
||||
entity.ToTable("findings_projection", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.Status, e.Severity })
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("ix_projection_status");
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.FindingId).HasColumnName("finding_id");
|
||||
entity.Property(e => e.PolicyVersion).HasColumnName("policy_version");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Severity)
|
||||
.HasColumnType("numeric(6,3)")
|
||||
.HasColumnName("severity");
|
||||
entity.Property(e => e.RiskScore)
|
||||
.HasColumnType("numeric")
|
||||
.HasColumnName("risk_score");
|
||||
entity.Property(e => e.RiskSeverity).HasColumnName("risk_severity");
|
||||
entity.Property(e => e.RiskProfileVersion).HasColumnName("risk_profile_version");
|
||||
entity.Property(e => e.RiskExplanationId).HasColumnName("risk_explanation_id");
|
||||
entity.Property(e => e.RiskEventSequence).HasColumnName("risk_event_sequence");
|
||||
entity.Property(e => e.Labels)
|
||||
.HasDefaultValueSql("'{}'::jsonb")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("labels");
|
||||
entity.Property(e => e.CurrentEventId).HasColumnName("current_event_id");
|
||||
entity.Property(e => e.ExplainRef).HasColumnName("explain_ref");
|
||||
entity.Property(e => e.PolicyRationale)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("policy_rationale");
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.HasColumnName("updated_at");
|
||||
entity.Property(e => e.CycleHash)
|
||||
.HasMaxLength(64)
|
||||
.IsFixedLength()
|
||||
.HasColumnName("cycle_hash");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<FindingHistoryEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.FindingId, e.EventId })
|
||||
.HasName("pk_finding_history");
|
||||
|
||||
entity.ToTable("finding_history", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.FindingId, e.OccurredAt })
|
||||
.IsDescending(false, false, true)
|
||||
.HasDatabaseName("ix_finding_history_timeline");
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.FindingId).HasColumnName("finding_id");
|
||||
entity.Property(e => e.PolicyVersion).HasColumnName("policy_version");
|
||||
entity.Property(e => e.EventId).HasColumnName("event_id");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Severity)
|
||||
.HasColumnType("numeric(6,3)")
|
||||
.HasColumnName("severity");
|
||||
entity.Property(e => e.ActorId).HasColumnName("actor_id");
|
||||
entity.Property(e => e.Comment).HasColumnName("comment");
|
||||
entity.Property(e => e.OccurredAt).HasColumnName("occurred_at");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TriageActionEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.ActionId })
|
||||
.HasName("pk_triage_actions");
|
||||
|
||||
entity.ToTable("triage_actions", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.EventId })
|
||||
.HasDatabaseName("ix_triage_actions_event");
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("ix_triage_actions_created_at");
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ActionId).HasColumnName("action_id");
|
||||
entity.Property(e => e.EventId).HasColumnName("event_id");
|
||||
entity.Property(e => e.FindingId).HasColumnName("finding_id");
|
||||
entity.Property(e => e.ActionType).HasColumnName("action_type");
|
||||
entity.Property(e => e.Payload)
|
||||
.HasDefaultValueSql("'{}'::jsonb")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LedgerProjectionOffsetEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.WorkerId)
|
||||
.HasName("pk_ledger_projection_offsets");
|
||||
|
||||
entity.ToTable("ledger_projection_offsets", schemaName);
|
||||
|
||||
entity.Property(e => e.WorkerId).HasColumnName("worker_id");
|
||||
entity.Property(e => e.LastRecordedAt).HasColumnName("last_recorded_at");
|
||||
entity.Property(e => e.LastEventId).HasColumnName("last_event_id");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AirgapImportEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.BundleId, e.TimeAnchor })
|
||||
.HasName("pk_airgap_imports");
|
||||
|
||||
entity.ToTable("airgap_imports", schemaName);
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.BundleId).HasColumnName("bundle_id");
|
||||
entity.Property(e => e.MirrorGeneration).HasColumnName("mirror_generation");
|
||||
entity.Property(e => e.MerkleRoot).HasColumnName("merkle_root");
|
||||
entity.Property(e => e.TimeAnchor).HasColumnName("time_anchor");
|
||||
entity.Property(e => e.Publisher).HasColumnName("publisher");
|
||||
entity.Property(e => e.HashAlgorithm).HasColumnName("hash_algorithm");
|
||||
entity.Property(e => e.Contents)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("contents");
|
||||
entity.Property(e => e.ImportedAt).HasColumnName("imported_at");
|
||||
entity.Property(e => e.ImportOperator).HasColumnName("import_operator");
|
||||
entity.Property(e => e.LedgerEventId).HasColumnName("ledger_event_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LedgerAttestationPointerEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.PointerId })
|
||||
.HasName("pk_ledger_attestation_pointers");
|
||||
|
||||
entity.ToTable("ledger_attestation_pointers", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.FindingId })
|
||||
.HasDatabaseName("ix_attestation_pointers_finding");
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.PointerId).HasColumnName("pointer_id");
|
||||
entity.Property(e => e.FindingId).HasColumnName("finding_id");
|
||||
entity.Property(e => e.AttestationType).HasColumnName("attestation_type");
|
||||
entity.Property(e => e.Relationship).HasColumnName("relationship");
|
||||
entity.Property(e => e.AttestationRef)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("attestation_ref");
|
||||
entity.Property(e => e.VerificationResult)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification_result");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata");
|
||||
entity.Property(e => e.LedgerEventId).HasColumnName("ledger_event_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<OrchestratorExportEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.RunId })
|
||||
.HasName("pk_orchestrator_exports");
|
||||
|
||||
entity.ToTable("orchestrator_exports", schemaName);
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.RunId).HasColumnName("run_id");
|
||||
entity.Property(e => e.JobType).HasColumnName("job_type");
|
||||
entity.Property(e => e.ArtifactHash).HasColumnName("artifact_hash");
|
||||
entity.Property(e => e.PolicyHash).HasColumnName("policy_hash");
|
||||
entity.Property(e => e.StartedAt).HasColumnName("started_at");
|
||||
entity.Property(e => e.CompletedAt).HasColumnName("completed_at");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.ManifestPath).HasColumnName("manifest_path");
|
||||
entity.Property(e => e.LogsPath).HasColumnName("logs_path");
|
||||
entity.Property(e => e.MerkleRoot).HasColumnName("merkle_root");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LedgerSnapshotEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.SnapshotId })
|
||||
.HasName("pk_ledger_snapshots");
|
||||
|
||||
entity.ToTable("ledger_snapshots", schemaName);
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.SnapshotId).HasColumnName("snapshot_id");
|
||||
entity.Property(e => e.Label).HasColumnName("label");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.SequenceNumber).HasColumnName("sequence_number");
|
||||
entity.Property(e => e.SnapshotTimestamp).HasColumnName("snapshot_timestamp");
|
||||
entity.Property(e => e.FindingsCount).HasColumnName("findings_count");
|
||||
entity.Property(e => e.VexStatementsCount).HasColumnName("vex_statements_count");
|
||||
entity.Property(e => e.AdvisoriesCount).HasColumnName("advisories_count");
|
||||
entity.Property(e => e.SbomsCount).HasColumnName("sboms_count");
|
||||
entity.Property(e => e.EventsCount).HasColumnName("events_count");
|
||||
entity.Property(e => e.SizeBytes).HasColumnName("size_bytes");
|
||||
entity.Property(e => e.MerkleRoot).HasColumnName("merkle_root");
|
||||
entity.Property(e => e.DsseDigest).HasColumnName("dsse_digest");
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata");
|
||||
entity.Property(e => e.IncludeEntityTypes)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("include_entity_types");
|
||||
entity.Property(e => e.SignRequested).HasColumnName("sign_requested");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ObservationEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id)
|
||||
.HasName("pk_observations");
|
||||
|
||||
entity.ToTable("observations", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.CveId, e.TenantId })
|
||||
.HasDatabaseName("ix_observations_cve_tenant");
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.State })
|
||||
.HasDatabaseName("ix_observations_tenant_state");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.CveId).HasColumnName("cve_id");
|
||||
entity.Property(e => e.Product).HasColumnName("product");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.FindingId).HasColumnName("finding_id");
|
||||
entity.Property(e => e.State).HasColumnName("state");
|
||||
entity.Property(e => e.PreviousState).HasColumnName("previous_state");
|
||||
entity.Property(e => e.Reason).HasColumnName("reason");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.EvidenceRef).HasColumnName("evidence_ref");
|
||||
entity.Property(e => e.Signals)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("signals");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Context;
|
||||
|
||||
public sealed class FindingsLedgerDesignTimeDbContextFactory : IDesignTimeDbContextFactory<FindingsLedgerDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString = "Host=localhost;Port=55434;Database=postgres;Username=postgres;Password=postgres;Search Path=public";
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_FINDINGSLEDGER_EF_CONNECTION";
|
||||
|
||||
public FindingsLedgerDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<FindingsLedgerDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new FindingsLedgerDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for airgap_imports table.
|
||||
/// </summary>
|
||||
public class AirgapImportEntity
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string BundleId { get; set; } = null!;
|
||||
public string? MirrorGeneration { get; set; }
|
||||
public string MerkleRoot { get; set; } = null!;
|
||||
public DateTimeOffset TimeAnchor { get; set; }
|
||||
public string? Publisher { get; set; }
|
||||
public string? HashAlgorithm { get; set; }
|
||||
public string Contents { get; set; } = "[]";
|
||||
public DateTimeOffset ImportedAt { get; set; }
|
||||
public string? ImportOperator { get; set; }
|
||||
public Guid? LedgerEventId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for finding_history table.
|
||||
/// </summary>
|
||||
public class FindingHistoryEntity
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string FindingId { get; set; } = null!;
|
||||
public string PolicyVersion { get; set; } = null!;
|
||||
public Guid EventId { get; set; }
|
||||
public string Status { get; set; } = null!;
|
||||
public decimal? Severity { get; set; }
|
||||
public string ActorId { get; set; } = null!;
|
||||
public string? Comment { get; set; }
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for findings_projection table.
|
||||
/// </summary>
|
||||
public class FindingsProjectionEntity
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string FindingId { get; set; } = null!;
|
||||
public string PolicyVersion { get; set; } = null!;
|
||||
public string Status { get; set; } = null!;
|
||||
public decimal? Severity { get; set; }
|
||||
public decimal? RiskScore { get; set; }
|
||||
public string? RiskSeverity { get; set; }
|
||||
public string? RiskProfileVersion { get; set; }
|
||||
public Guid? RiskExplanationId { get; set; }
|
||||
public long? RiskEventSequence { get; set; }
|
||||
public string Labels { get; set; } = "{}";
|
||||
public Guid CurrentEventId { get; set; }
|
||||
public string? ExplainRef { get; set; }
|
||||
public string PolicyRationale { get; set; } = "[]";
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string CycleHash { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for ledger_attestation_pointers table.
|
||||
/// </summary>
|
||||
public class LedgerAttestationPointerEntity
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid PointerId { get; set; }
|
||||
public string FindingId { get; set; } = null!;
|
||||
public string AttestationType { get; set; } = null!;
|
||||
public string Relationship { get; set; } = null!;
|
||||
public string AttestationRef { get; set; } = null!;
|
||||
public string? VerificationResult { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string CreatedBy { get; set; } = null!;
|
||||
public string? Metadata { get; set; }
|
||||
public Guid? LedgerEventId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for ledger_events table.
|
||||
/// </summary>
|
||||
public class LedgerEventEntity
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid ChainId { get; set; }
|
||||
public long SequenceNo { get; set; }
|
||||
public Guid EventId { get; set; }
|
||||
public string EventType { get; set; } = null!;
|
||||
public string PolicyVersion { get; set; } = null!;
|
||||
public string FindingId { get; set; } = null!;
|
||||
public string ArtifactId { get; set; } = null!;
|
||||
public Guid? SourceRunId { get; set; }
|
||||
public string ActorId { get; set; } = null!;
|
||||
public string ActorType { get; set; } = null!;
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
public DateTimeOffset RecordedAt { get; set; }
|
||||
public string EventBody { get; set; } = null!;
|
||||
public string EventHash { get; set; } = null!;
|
||||
public string PreviousHash { get; set; } = null!;
|
||||
public string MerkleLeafHash { get; set; } = null!;
|
||||
public string? EvidenceBundleRef { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for ledger_merkle_roots table.
|
||||
/// </summary>
|
||||
public class LedgerMerkleRootEntity
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid AnchorId { get; set; }
|
||||
public DateTimeOffset WindowStart { get; set; }
|
||||
public DateTimeOffset WindowEnd { get; set; }
|
||||
public long SequenceStart { get; set; }
|
||||
public long SequenceEnd { get; set; }
|
||||
public string RootHash { get; set; } = null!;
|
||||
public int LeafCount { get; set; }
|
||||
public DateTimeOffset AnchoredAt { get; set; }
|
||||
public string? AnchorReference { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for ledger_projection_offsets table.
|
||||
/// </summary>
|
||||
public class LedgerProjectionOffsetEntity
|
||||
{
|
||||
public string WorkerId { get; set; } = null!;
|
||||
public DateTimeOffset LastRecordedAt { get; set; }
|
||||
public Guid LastEventId { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for ledger_snapshots table.
|
||||
/// </summary>
|
||||
public class LedgerSnapshotEntity
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid SnapshotId { get; set; }
|
||||
public string? Label { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string Status { get; set; } = null!;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public long SequenceNumber { get; set; }
|
||||
public DateTimeOffset SnapshotTimestamp { get; set; }
|
||||
public long FindingsCount { get; set; }
|
||||
public long VexStatementsCount { get; set; }
|
||||
public long AdvisoriesCount { get; set; }
|
||||
public long SbomsCount { get; set; }
|
||||
public long EventsCount { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
public string? MerkleRoot { get; set; }
|
||||
public string? DsseDigest { get; set; }
|
||||
public string? Metadata { get; set; }
|
||||
public string? IncludeEntityTypes { get; set; }
|
||||
public bool SignRequested { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for observations table.
|
||||
/// </summary>
|
||||
public class ObservationEntity
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string CveId { get; set; } = null!;
|
||||
public string Product { get; set; } = null!;
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string? FindingId { get; set; }
|
||||
public string State { get; set; } = null!;
|
||||
public string? PreviousState { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? EvidenceRef { get; set; }
|
||||
public string? Signals { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for orchestrator_exports table.
|
||||
/// </summary>
|
||||
public class OrchestratorExportEntity
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid RunId { get; set; }
|
||||
public string JobType { get; set; } = null!;
|
||||
public string ArtifactHash { get; set; } = null!;
|
||||
public string PolicyHash { get; set; } = null!;
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
public string Status { get; set; } = null!;
|
||||
public string? ManifestPath { get; set; }
|
||||
public string? LogsPath { get; set; }
|
||||
public string MerkleRoot { get; set; } = null!;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Findings.Ledger.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for triage_actions table.
|
||||
/// </summary>
|
||||
public class TriageActionEntity
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid ActionId { get; set; }
|
||||
public Guid EventId { get; set; }
|
||||
public string FindingId { get; set; } = null!;
|
||||
public string ActionType { get; set; } = null!;
|
||||
public string Payload { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string CreatedBy { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Findings.Ledger.EfCore.CompiledModels;
|
||||
using StellaOps.Findings.Ledger.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
internal static class FindingsLedgerDbContextFactory
|
||||
{
|
||||
public const string DefaultSchemaName = "public";
|
||||
|
||||
public static FindingsLedgerDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<FindingsLedgerDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
// Guard: only apply if compiled model has entity types registered.
|
||||
try
|
||||
{
|
||||
var compiledModel = FindingsLedgerDbContextModel.Instance;
|
||||
if (compiledModel.GetEntityTypes().Any())
|
||||
{
|
||||
optionsBuilder.UseModel(compiledModel);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to reflection model if compiled model is not fully initialized.
|
||||
}
|
||||
}
|
||||
|
||||
return new FindingsLedgerDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
@@ -10,7 +11,7 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
|
||||
{
|
||||
private const string InsertSql = """
|
||||
private const string UpsertSql = """
|
||||
INSERT INTO airgap_imports (
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
@@ -46,51 +47,6 @@ public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
|
||||
ledger_event_id = EXCLUDED.ledger_event_id;
|
||||
""";
|
||||
|
||||
private const string SelectLatestByDomainSql = """
|
||||
SELECT
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
mirror_generation,
|
||||
merkle_root,
|
||||
time_anchor,
|
||||
publisher,
|
||||
hash_algorithm,
|
||||
contents,
|
||||
imported_at,
|
||||
import_operator,
|
||||
ledger_event_id
|
||||
FROM airgap_imports
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND bundle_id = @domain_id
|
||||
ORDER BY time_anchor DESC
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
private const string SelectAllLatestByDomainSql = """
|
||||
SELECT DISTINCT ON (bundle_id)
|
||||
tenant_id,
|
||||
bundle_id,
|
||||
mirror_generation,
|
||||
merkle_root,
|
||||
time_anchor,
|
||||
publisher,
|
||||
hash_algorithm,
|
||||
contents,
|
||||
imported_at,
|
||||
import_operator,
|
||||
ledger_event_id
|
||||
FROM airgap_imports
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY bundle_id, time_anchor DESC;
|
||||
""";
|
||||
|
||||
private const string SelectBundleCountSql = """
|
||||
SELECT COUNT(*)
|
||||
FROM airgap_imports
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND bundle_id = @domain_id;
|
||||
""";
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresAirgapImportRepository> _logger;
|
||||
|
||||
@@ -110,26 +66,25 @@ public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
|
||||
var contentsJson = canonicalContents.ToJsonString();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "airgap-import", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(InsertSql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", record.TenantId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("bundle_id", record.BundleId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string?>("mirror_generation", record.MirrorGeneration) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("merkle_root", record.MerkleRoot) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<DateTimeOffset>("time_anchor", record.TimeAnchor) { NpgsqlDbType = NpgsqlDbType.TimestampTz });
|
||||
command.Parameters.Add(new NpgsqlParameter<string?>("publisher", record.Publisher) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string?>("hash_algorithm", record.HashAlgorithm) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("contents", contentsJson) { NpgsqlDbType = NpgsqlDbType.Jsonb });
|
||||
command.Parameters.Add(new NpgsqlParameter<DateTimeOffset>("imported_at", record.ImportedAt) { NpgsqlDbType = NpgsqlDbType.TimestampTz });
|
||||
command.Parameters.Add(new NpgsqlParameter<string?>("import_operator", record.ImportOperator) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<Guid?>("ledger_event_id", record.LedgerEventId) { NpgsqlDbType = NpgsqlDbType.Uuid });
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
// Use ExecuteSqlRawAsync for UPSERT (ON CONFLICT DO UPDATE) which EF Core LINQ does not support.
|
||||
try
|
||||
{
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ctx.Database.ExecuteSqlRawAsync(
|
||||
UpsertSql,
|
||||
new NpgsqlParameter<string>("tenant_id", record.TenantId) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string>("bundle_id", record.BundleId) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string?>("mirror_generation", record.MirrorGeneration) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string>("merkle_root", record.MerkleRoot) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<DateTimeOffset>("time_anchor", record.TimeAnchor) { NpgsqlDbType = NpgsqlDbType.TimestampTz },
|
||||
new NpgsqlParameter<string?>("publisher", record.Publisher) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string?>("hash_algorithm", record.HashAlgorithm) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string>("contents", contentsJson) { NpgsqlDbType = NpgsqlDbType.Jsonb },
|
||||
new NpgsqlParameter<DateTimeOffset>("imported_at", record.ImportedAt) { NpgsqlDbType = NpgsqlDbType.TimestampTz },
|
||||
new NpgsqlParameter<string?>("import_operator", record.ImportOperator) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<Guid?>("ledger_event_id", record.LedgerEventId) { NpgsqlDbType = NpgsqlDbType.Uuid },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PostgresException ex)
|
||||
{
|
||||
@@ -147,21 +102,16 @@ public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(domainId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "airgap-query", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectLatestByDomainSql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("domain_id", domainId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
var entity = await ctx.AirgapImports
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.BundleId == domainId)
|
||||
.OrderByDescending(e => e.TimeAnchor)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapRecord(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
return entity is null ? null : MapEntityToRecord(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AirgapImportRecord>> GetAllLatestByDomainAsync(
|
||||
@@ -170,23 +120,25 @@ public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var results = new List<AirgapImportRecord>();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "airgap-query", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectAllLatestByDomainSql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
// DISTINCT ON (bundle_id) with ORDER BY bundle_id, time_anchor DESC cannot be expressed
|
||||
// directly in EF Core LINQ. Use FromSqlRaw for this PostgreSQL-specific query.
|
||||
var entities = await ctx.AirgapImports
|
||||
.FromSqlRaw("""
|
||||
SELECT DISTINCT ON (bundle_id)
|
||||
tenant_id, bundle_id, mirror_generation, merkle_root, time_anchor,
|
||||
publisher, hash_algorithm, contents, imported_at, import_operator, ledger_event_id
|
||||
FROM airgap_imports
|
||||
WHERE tenant_id = {0}
|
||||
ORDER BY bundle_id, time_anchor DESC
|
||||
""", tenantId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
return entities.Select(MapEntityToRecord).ToList();
|
||||
}
|
||||
|
||||
public async Task<int> GetBundleCountByDomainAsync(
|
||||
@@ -198,34 +150,29 @@ public sealed class PostgresAirgapImportRepository : IAirgapImportRepository
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(domainId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "airgap-query", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectBundleCountSql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("domain_id", domainId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
return await ctx.AirgapImports
|
||||
.AsNoTracking()
|
||||
.CountAsync(e => e.TenantId == tenantId && e.BundleId == domainId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AirgapImportRecord MapRecord(NpgsqlDataReader reader)
|
||||
private static AirgapImportRecord MapEntityToRecord(EfCore.Models.AirgapImportEntity entity)
|
||||
{
|
||||
var contentsJson = reader.GetString(7);
|
||||
var contents = JsonNode.Parse(contentsJson) as JsonArray ?? new JsonArray();
|
||||
var contents = JsonNode.Parse(entity.Contents) as JsonArray ?? new JsonArray();
|
||||
|
||||
return new AirgapImportRecord(
|
||||
TenantId: reader.GetString(0),
|
||||
BundleId: reader.GetString(1),
|
||||
MirrorGeneration: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
MerkleRoot: reader.GetString(3),
|
||||
TimeAnchor: reader.GetDateTime(4),
|
||||
Publisher: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
HashAlgorithm: reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
TenantId: entity.TenantId,
|
||||
BundleId: entity.BundleId,
|
||||
MirrorGeneration: entity.MirrorGeneration,
|
||||
MerkleRoot: entity.MerkleRoot,
|
||||
TimeAnchor: entity.TimeAnchor,
|
||||
Publisher: entity.Publisher,
|
||||
HashAlgorithm: entity.HashAlgorithm,
|
||||
Contents: contents,
|
||||
ImportedAt: reader.GetDateTime(8),
|
||||
ImportOperator: reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
LedgerEventId: reader.IsDBNull(10) ? null : reader.GetGuid(10));
|
||||
ImportedAt: entity.ImportedAt,
|
||||
ImportOperator: entity.ImportOperator,
|
||||
LedgerEventId: entity.LedgerEventId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.EfCore.Models;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -10,6 +12,9 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Postgres-backed repository for attestation pointers.
|
||||
/// Simple CRUD uses EF Core. Complex search, summary aggregations, and JSONB-based queries
|
||||
/// are retained as raw SQL because they use PostgreSQL-specific operators (->>, ::boolean casts,
|
||||
/// array_agg, FILTER) that EF Core LINQ cannot express.
|
||||
/// </summary>
|
||||
public sealed class PostgresAttestationPointerRepository : IAttestationPointerRepository
|
||||
{
|
||||
@@ -34,46 +39,31 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
{
|
||||
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 ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
var entity = new LedgerAttestationPointerEntity
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
TenantId = record.TenantId,
|
||||
PointerId = record.PointerId,
|
||||
FindingId = record.FindingId,
|
||||
AttestationType = record.AttestationType.ToString(),
|
||||
Relationship = record.Relationship.ToString(),
|
||||
AttestationRef = JsonSerializer.Serialize(record.AttestationRef, JsonOptions),
|
||||
VerificationResult = record.VerificationResult is not null
|
||||
? JsonSerializer.Serialize(record.VerificationResult, JsonOptions)
|
||||
: null,
|
||||
CreatedAt = record.CreatedAt,
|
||||
CreatedBy = record.CreatedBy,
|
||||
Metadata = record.Metadata is not null
|
||||
? JsonSerializer.Serialize(record.Metadata, JsonOptions)
|
||||
: null,
|
||||
LedgerEventId = record.LedgerEventId
|
||||
};
|
||||
|
||||
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);
|
||||
ctx.LedgerAttestationPointers.Add(entity);
|
||||
await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Inserted attestation pointer {PointerId} for finding {FindingId} with type {AttestationType}",
|
||||
@@ -87,33 +77,16 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
{
|
||||
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 ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
var entity = await ctx.LedgerAttestationPointers
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.PointerId == pointerId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
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;
|
||||
return entity is null ? null : MapEntityToRecord(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> GetByFindingIdAsync(
|
||||
@@ -124,27 +97,18 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
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 ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
var entities = await ctx.LedgerAttestationPointers
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.FindingId == findingId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
|
||||
return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return entities.Select(MapEntityToRecord).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestationPointerRecord>> GetByDigestAsync(
|
||||
@@ -155,6 +119,8 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
// JSONB operator (attestation_ref->>'digest') cannot be expressed in EF Core LINQ.
|
||||
// Retained as raw SQL via NpgsqlCommand.
|
||||
const string sql = """
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
@@ -186,6 +152,9 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.TenantId);
|
||||
|
||||
// Dynamic SQL builder with JSONB operators, ::boolean casts, and complex verification
|
||||
// status filtering. Retained as raw SQL because these PostgreSQL-specific operators
|
||||
// cannot be expressed in EF Core LINQ.
|
||||
var sqlBuilder = new StringBuilder("""
|
||||
SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
|
||||
attestation_ref, verification_result, created_at, created_by,
|
||||
@@ -281,6 +250,7 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
// Aggregate with FILTER, array_agg, ::boolean casts -- PostgreSQL-specific, retained as raw SQL.
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
@@ -360,6 +330,7 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
return Array.Empty<FindingAttestationSummary>();
|
||||
}
|
||||
|
||||
// GROUP BY with FILTER, array_agg, ::boolean casts -- PostgreSQL-specific, retained as raw SQL.
|
||||
const string sql = """
|
||||
SELECT
|
||||
finding_id,
|
||||
@@ -451,6 +422,8 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
|
||||
// JSONB operator (attestation_ref->>'digest') cannot be expressed in EF Core LINQ.
|
||||
// Retained as raw SQL via NpgsqlCommand.
|
||||
const string sql = """
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM ledger_attestation_pointers
|
||||
@@ -487,30 +460,23 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
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 ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection)
|
||||
var entity = await ctx.LedgerAttestationPointers
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.PointerId == pointerId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is not null)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
entity.VerificationResult = JsonSerializer.Serialize(verificationResult, JsonOptions);
|
||||
await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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);
|
||||
_logger.LogDebug(
|
||||
"Updated verification result for attestation pointer {PointerId}, verified={Verified}",
|
||||
pointerId, verificationResult.Verified);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> GetCountAsync(
|
||||
@@ -521,25 +487,14 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
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 ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
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);
|
||||
return await ctx.LedgerAttestationPointers
|
||||
.AsNoTracking()
|
||||
.CountAsync(e => e.TenantId == tenantId && e.FindingId == findingId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetFindingIdsWithAttestationsAsync(
|
||||
@@ -552,6 +507,8 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
// Dynamic SQL with JSONB ::boolean casts for verification status filter.
|
||||
// Retained as raw SQL because EF Core cannot express these PostgreSQL-specific operators.
|
||||
var sqlBuilder = new StringBuilder("""
|
||||
SELECT DISTINCT finding_id
|
||||
FROM ledger_attestation_pointers
|
||||
@@ -666,4 +623,36 @@ public sealed class PostgresAttestationPointerRepository : IAttestationPointerRe
|
||||
metadata,
|
||||
ledgerEventId);
|
||||
}
|
||||
|
||||
private static AttestationPointerRecord MapEntityToRecord(LedgerAttestationPointerEntity entity)
|
||||
{
|
||||
var attestationType = Enum.Parse<AttestationType>(entity.AttestationType);
|
||||
var relationship = Enum.Parse<AttestationRelationship>(entity.Relationship);
|
||||
var attestationRef = JsonSerializer.Deserialize<AttestationRef>(entity.AttestationRef, JsonOptions)!;
|
||||
|
||||
VerificationResult? verificationResult = null;
|
||||
if (!string.IsNullOrEmpty(entity.VerificationResult))
|
||||
{
|
||||
verificationResult = JsonSerializer.Deserialize<VerificationResult>(entity.VerificationResult, JsonOptions);
|
||||
}
|
||||
|
||||
Dictionary<string, object>? metadata = null;
|
||||
if (!string.IsNullOrEmpty(entity.Metadata))
|
||||
{
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(entity.Metadata, JsonOptions);
|
||||
}
|
||||
|
||||
return new AttestationPointerRecord(
|
||||
entity.TenantId,
|
||||
entity.PointerId,
|
||||
entity.FindingId,
|
||||
attestationType,
|
||||
relationship,
|
||||
attestationRef,
|
||||
verificationResult,
|
||||
entity.CreatedAt,
|
||||
entity.CreatedBy,
|
||||
metadata,
|
||||
entity.LedgerEventId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.EfCore.Models;
|
||||
using StellaOps.Findings.Ledger.Hashing;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
@@ -13,6 +15,8 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepository
|
||||
{
|
||||
// CTE-based projection query that joins with attestation summaries.
|
||||
// Retained as raw SQL because the CTE + LEFT JOIN + aggregate pattern cannot be expressed in EF Core LINQ.
|
||||
private const string GetProjectionSql = """
|
||||
WITH attestation_summary AS (
|
||||
SELECT
|
||||
@@ -153,14 +157,6 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
DO NOTHING;
|
||||
""";
|
||||
|
||||
private const string SelectCheckpointSql = """
|
||||
SELECT last_recorded_at,
|
||||
last_event_id,
|
||||
updated_at
|
||||
FROM ledger_projection_offsets
|
||||
WHERE worker_id = @worker_id
|
||||
""";
|
||||
|
||||
private const string UpsertCheckpointSql = """
|
||||
INSERT INTO ledger_projection_offsets (
|
||||
worker_id,
|
||||
@@ -179,6 +175,8 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
updated_at = EXCLUDED.updated_at;
|
||||
""";
|
||||
|
||||
// Complex aggregate query with dynamic CASE expressions.
|
||||
// Retained as raw SQL because conditional SUM with CASE is not expressible in EF Core LINQ.
|
||||
private const string SelectFindingStatsSql = """
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status = 'new' AND updated_at >= @since THEN 1 ELSE 0 END), 0) as new_findings,
|
||||
@@ -213,6 +211,8 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
|
||||
public async Task<FindingProjection?> GetAsync(string tenantId, string findingId, string policyVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
// Uses CTE with attestation summary aggregation -- retained as raw SQL via NpgsqlCommand
|
||||
// because EF Core cannot express CTE + LEFT JOIN lateral + FILTER aggregate pattern.
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "projector", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(GetProjectionSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
@@ -234,34 +234,33 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
ArgumentNullException.ThrowIfNull(projection);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(projection.TenantId, "projector", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(UpsertProjectionSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", projection.TenantId);
|
||||
command.Parameters.AddWithValue("finding_id", projection.FindingId);
|
||||
command.Parameters.AddWithValue("policy_version", projection.PolicyVersion);
|
||||
command.Parameters.AddWithValue("status", projection.Status);
|
||||
command.Parameters.AddWithValue("severity", projection.Severity.HasValue ? projection.Severity.Value : (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("risk_score", projection.RiskScore.HasValue ? projection.RiskScore.Value : (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("risk_severity", projection.RiskSeverity ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("risk_profile_version", projection.RiskProfileVersion ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("risk_explanation_id", projection.RiskExplanationId ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("risk_event_sequence", projection.RiskEventSequence.HasValue ? projection.RiskEventSequence.Value : (object)DBNull.Value);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var labelsCanonical = LedgerCanonicalJsonSerializer.Canonicalize(projection.Labels);
|
||||
var labelsJson = labelsCanonical.ToJsonString();
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("labels", NpgsqlDbType.Jsonb) { TypedValue = labelsJson });
|
||||
|
||||
command.Parameters.AddWithValue("current_event_id", projection.CurrentEventId);
|
||||
command.Parameters.AddWithValue("explain_ref", projection.ExplainRef ?? (object)DBNull.Value);
|
||||
var rationaleCanonical = LedgerCanonicalJsonSerializer.Canonicalize(projection.PolicyRationale);
|
||||
var rationaleJson = rationaleCanonical.ToJsonString();
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("policy_rationale", NpgsqlDbType.Jsonb) { TypedValue = rationaleJson });
|
||||
|
||||
command.Parameters.AddWithValue("updated_at", projection.UpdatedAt);
|
||||
command.Parameters.AddWithValue("cycle_hash", projection.CycleHash);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Use ExecuteSqlRawAsync for UPSERT (ON CONFLICT DO UPDATE) which EF Core LINQ does not support.
|
||||
await ctx.Database.ExecuteSqlRawAsync(
|
||||
UpsertProjectionSql,
|
||||
new NpgsqlParameter("tenant_id", projection.TenantId),
|
||||
new NpgsqlParameter("finding_id", projection.FindingId),
|
||||
new NpgsqlParameter("policy_version", projection.PolicyVersion),
|
||||
new NpgsqlParameter("status", projection.Status),
|
||||
new NpgsqlParameter("severity", projection.Severity.HasValue ? projection.Severity.Value : DBNull.Value),
|
||||
new NpgsqlParameter("risk_score", projection.RiskScore.HasValue ? projection.RiskScore.Value : DBNull.Value),
|
||||
new NpgsqlParameter("risk_severity", projection.RiskSeverity ?? (object)DBNull.Value),
|
||||
new NpgsqlParameter("risk_profile_version", projection.RiskProfileVersion ?? (object)DBNull.Value),
|
||||
new NpgsqlParameter("risk_explanation_id", projection.RiskExplanationId ?? (object)DBNull.Value),
|
||||
new NpgsqlParameter("risk_event_sequence", projection.RiskEventSequence.HasValue ? projection.RiskEventSequence.Value : DBNull.Value),
|
||||
new NpgsqlParameter<string>("labels", NpgsqlDbType.Jsonb) { TypedValue = labelsJson },
|
||||
new NpgsqlParameter("current_event_id", projection.CurrentEventId),
|
||||
new NpgsqlParameter("explain_ref", projection.ExplainRef ?? (object)DBNull.Value),
|
||||
new NpgsqlParameter<string>("policy_rationale", NpgsqlDbType.Jsonb) { TypedValue = rationaleJson },
|
||||
new NpgsqlParameter("updated_at", projection.UpdatedAt),
|
||||
new NpgsqlParameter("cycle_hash", projection.CycleHash),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task InsertHistoryAsync(FindingHistoryEntry entry, CancellationToken cancellationToken)
|
||||
@@ -269,20 +268,21 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(entry.TenantId, "projector", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(InsertHistorySql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", entry.TenantId);
|
||||
command.Parameters.AddWithValue("finding_id", entry.FindingId);
|
||||
command.Parameters.AddWithValue("policy_version", entry.PolicyVersion);
|
||||
command.Parameters.AddWithValue("event_id", entry.EventId);
|
||||
command.Parameters.AddWithValue("status", entry.Status);
|
||||
command.Parameters.AddWithValue("severity", entry.Severity.HasValue ? entry.Severity.Value : (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("actor_id", entry.ActorId);
|
||||
command.Parameters.AddWithValue("comment", entry.Comment ?? (object)DBNull.Value);
|
||||
command.Parameters.AddWithValue("occurred_at", entry.OccurredAt);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Use ExecuteSqlRawAsync for ON CONFLICT DO NOTHING which EF Core LINQ does not support.
|
||||
await ctx.Database.ExecuteSqlRawAsync(
|
||||
InsertHistorySql,
|
||||
new NpgsqlParameter("tenant_id", entry.TenantId),
|
||||
new NpgsqlParameter("finding_id", entry.FindingId),
|
||||
new NpgsqlParameter("policy_version", entry.PolicyVersion),
|
||||
new NpgsqlParameter("event_id", entry.EventId),
|
||||
new NpgsqlParameter("status", entry.Status),
|
||||
new NpgsqlParameter("severity", entry.Severity.HasValue ? entry.Severity.Value : DBNull.Value),
|
||||
new NpgsqlParameter("actor_id", entry.ActorId),
|
||||
new NpgsqlParameter("comment", entry.Comment ?? (object)DBNull.Value),
|
||||
new NpgsqlParameter("occurred_at", entry.OccurredAt),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task InsertActionAsync(TriageActionEntry entry, CancellationToken cancellationToken)
|
||||
@@ -290,41 +290,40 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(entry.TenantId, "projector", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(InsertActionSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", entry.TenantId);
|
||||
command.Parameters.AddWithValue("action_id", entry.ActionId);
|
||||
command.Parameters.AddWithValue("event_id", entry.EventId);
|
||||
command.Parameters.AddWithValue("finding_id", entry.FindingId);
|
||||
command.Parameters.AddWithValue("action_type", entry.ActionType);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var payloadJson = entry.Payload.ToJsonString();
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("payload", NpgsqlDbType.Jsonb) { TypedValue = payloadJson });
|
||||
|
||||
command.Parameters.AddWithValue("created_at", entry.CreatedAt);
|
||||
command.Parameters.AddWithValue("created_by", entry.CreatedBy);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Use ExecuteSqlRawAsync for ON CONFLICT DO NOTHING which EF Core LINQ does not support.
|
||||
await ctx.Database.ExecuteSqlRawAsync(
|
||||
InsertActionSql,
|
||||
new NpgsqlParameter("tenant_id", entry.TenantId),
|
||||
new NpgsqlParameter("action_id", entry.ActionId),
|
||||
new NpgsqlParameter("event_id", entry.EventId),
|
||||
new NpgsqlParameter("finding_id", entry.FindingId),
|
||||
new NpgsqlParameter("action_type", entry.ActionType),
|
||||
new NpgsqlParameter<string>("payload", NpgsqlDbType.Jsonb) { TypedValue = payloadJson },
|
||||
new NpgsqlParameter("created_at", entry.CreatedAt),
|
||||
new NpgsqlParameter("created_by", entry.CreatedBy),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ProjectionCheckpoint> GetCheckpointAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(string.Empty, "projector", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectCheckpointSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("worker_id", DefaultWorkerId);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
var entity = await ctx.LedgerProjectionOffsets
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.WorkerId == DefaultWorkerId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return ProjectionCheckpoint.Initial(_timeProvider);
|
||||
}
|
||||
|
||||
var lastRecordedAt = reader.GetFieldValue<DateTimeOffset>(0);
|
||||
var lastEventId = reader.GetGuid(1);
|
||||
var updatedAt = reader.GetFieldValue<DateTimeOffset>(2);
|
||||
return new ProjectionCheckpoint(lastRecordedAt, lastEventId, updatedAt);
|
||||
return new ProjectionCheckpoint(entity.LastRecordedAt, entity.LastEventId, entity.UpdatedAt);
|
||||
}
|
||||
|
||||
public async Task SaveCheckpointAsync(ProjectionCheckpoint checkpoint, CancellationToken cancellationToken)
|
||||
@@ -332,17 +331,18 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
ArgumentNullException.ThrowIfNull(checkpoint);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(string.Empty, "projector", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(UpsertCheckpointSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
|
||||
command.Parameters.AddWithValue("worker_id", DefaultWorkerId);
|
||||
command.Parameters.AddWithValue("last_recorded_at", checkpoint.LastRecordedAt);
|
||||
command.Parameters.AddWithValue("last_event_id", checkpoint.LastEventId);
|
||||
command.Parameters.AddWithValue("updated_at", checkpoint.UpdatedAt);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
// Use ExecuteSqlRawAsync for UPSERT (ON CONFLICT DO UPDATE) which EF Core LINQ does not support.
|
||||
try
|
||||
{
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ctx.Database.ExecuteSqlRawAsync(
|
||||
UpsertCheckpointSql,
|
||||
new NpgsqlParameter("worker_id", DefaultWorkerId),
|
||||
new NpgsqlParameter("last_recorded_at", checkpoint.LastRecordedAt),
|
||||
new NpgsqlParameter("last_event_id", checkpoint.LastEventId),
|
||||
new NpgsqlParameter("updated_at", checkpoint.UpdatedAt),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PostgresException ex)
|
||||
{
|
||||
@@ -358,6 +358,8 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
// Complex aggregate with conditional CASE expressions -- retained as raw SQL via NpgsqlCommand
|
||||
// because EF Core cannot express conditional SUM with CASE pattern.
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "projector", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectFindingStatsSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
@@ -387,6 +389,10 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.TenantId);
|
||||
|
||||
// This method builds dynamic CTE-based SQL with attestation summaries, optional filtering
|
||||
// on JSONB verification_result fields, and dynamic ORDER BY. The complexity of this query
|
||||
// (CTE + dynamic WHERE + JSONB aggregate FILTER + parameterized LIMIT/ORDER) exceeds what
|
||||
// EF Core LINQ can express. Retained as raw SQL via NpgsqlCommand.
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(query.TenantId, "projector", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Build dynamic query
|
||||
@@ -570,6 +576,8 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
// Complex aggregate with conditional CASE expressions per severity bucket.
|
||||
// Retained as raw SQL because EF Core LINQ cannot express conditional SUM with CASE.
|
||||
var sql = @"
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN risk_severity = 'critical' THEN 1 ELSE 0 END), 0) as critical,
|
||||
@@ -620,6 +628,8 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
// Complex aggregate with conditional CASE expressions per score bucket.
|
||||
// Retained as raw SQL because EF Core LINQ cannot express conditional SUM with CASE.
|
||||
var sql = @"
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN risk_score >= 0 AND risk_score < 0.2 THEN 1 ELSE 0 END), 0) as score_0_20,
|
||||
@@ -668,6 +678,8 @@ public sealed class PostgresFindingProjectionRepository : IFindingProjectionRepo
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
// Aggregate query with multiple aggregate functions. Retained as raw SQL
|
||||
// because the mix of COUNT(*), COUNT(column), AVG, and MAX requires explicit SQL.
|
||||
var sql = @"
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.EfCore.Models;
|
||||
using StellaOps.Findings.Ledger.Hashing;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
@@ -10,80 +12,6 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
{
|
||||
private const string SelectByEventIdSql = """
|
||||
SELECT chain_id,
|
||||
sequence_no,
|
||||
event_type,
|
||||
policy_version,
|
||||
finding_id,
|
||||
artifact_id,
|
||||
source_run_id,
|
||||
actor_id,
|
||||
actor_type,
|
||||
occurred_at,
|
||||
recorded_at,
|
||||
event_body,
|
||||
event_hash,
|
||||
previous_hash,
|
||||
merkle_leaf_hash,
|
||||
evidence_bundle_ref
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND event_id = @event_id
|
||||
""";
|
||||
|
||||
private const string SelectChainHeadSql = """
|
||||
SELECT sequence_no,
|
||||
event_hash,
|
||||
recorded_at
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND chain_id = @chain_id
|
||||
ORDER BY sequence_no DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
private const string InsertEventSql = """
|
||||
INSERT INTO ledger_events (
|
||||
tenant_id,
|
||||
chain_id,
|
||||
sequence_no,
|
||||
event_id,
|
||||
event_type,
|
||||
policy_version,
|
||||
finding_id,
|
||||
artifact_id,
|
||||
source_run_id,
|
||||
actor_id,
|
||||
actor_type,
|
||||
occurred_at,
|
||||
recorded_at,
|
||||
event_body,
|
||||
event_hash,
|
||||
previous_hash,
|
||||
merkle_leaf_hash,
|
||||
evidence_bundle_ref)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@chain_id,
|
||||
@sequence_no,
|
||||
@event_id,
|
||||
@event_type,
|
||||
@policy_version,
|
||||
@finding_id,
|
||||
@artifact_id,
|
||||
@source_run_id,
|
||||
@actor_id,
|
||||
@actor_type,
|
||||
@occurred_at,
|
||||
@recorded_at,
|
||||
@event_body,
|
||||
@event_hash,
|
||||
@previous_hash,
|
||||
@merkle_leaf_hash,
|
||||
@evidence_bundle_ref)
|
||||
""";
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresLedgerEventRepository> _logger;
|
||||
|
||||
@@ -98,86 +26,133 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
public async Task<LedgerEventRecord?> GetByEventIdAsync(string tenantId, Guid eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectByEventIdSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("event_id", eventId);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var entity = await ctx.LedgerEvents
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.EventId == eventId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return MapLedgerEventRecord(tenantId, eventId, reader);
|
||||
return entity is null ? null : MapEntityToRecord(entity);
|
||||
}
|
||||
|
||||
public async Task<LedgerChainHead?> GetChainHeadAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectChainHeadSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("chain_id", chainId);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var entity = await ctx.LedgerEvents
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.ChainId == chainId)
|
||||
.OrderByDescending(e => e.SequenceNo)
|
||||
.Select(e => new { e.SequenceNo, e.EventHash, e.RecordedAt })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var sequenceNumber = reader.GetInt64(0);
|
||||
var eventHash = reader.GetString(1);
|
||||
var recordedAt = reader.GetFieldValue<DateTimeOffset>(2);
|
||||
return new LedgerChainHead(sequenceNumber, eventHash, recordedAt);
|
||||
return entity is null ? null : new LedgerChainHead(entity.SequenceNo, entity.EventHash, entity.RecordedAt);
|
||||
}
|
||||
|
||||
public async Task AppendAsync(LedgerEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(InsertEventSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", record.TenantId);
|
||||
command.Parameters.AddWithValue("chain_id", record.ChainId);
|
||||
command.Parameters.AddWithValue("sequence_no", record.SequenceNumber);
|
||||
command.Parameters.AddWithValue("event_id", record.EventId);
|
||||
command.Parameters.AddWithValue("event_type", record.EventType);
|
||||
command.Parameters.AddWithValue("policy_version", record.PolicyVersion);
|
||||
command.Parameters.AddWithValue("finding_id", record.FindingId);
|
||||
command.Parameters.AddWithValue("artifact_id", record.ArtifactId);
|
||||
|
||||
if (record.SourceRunId.HasValue)
|
||||
var entity = new LedgerEventEntity
|
||||
{
|
||||
command.Parameters.AddWithValue("source_run_id", record.SourceRunId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
command.Parameters.AddWithValue("source_run_id", DBNull.Value);
|
||||
}
|
||||
TenantId = record.TenantId,
|
||||
ChainId = record.ChainId,
|
||||
SequenceNo = record.SequenceNumber,
|
||||
EventId = record.EventId,
|
||||
EventType = record.EventType,
|
||||
PolicyVersion = record.PolicyVersion,
|
||||
FindingId = record.FindingId,
|
||||
ArtifactId = record.ArtifactId,
|
||||
SourceRunId = record.SourceRunId,
|
||||
ActorId = record.ActorId,
|
||||
ActorType = record.ActorType,
|
||||
OccurredAt = record.OccurredAt,
|
||||
RecordedAt = record.RecordedAt,
|
||||
EventBody = record.EventBody.ToJsonString(),
|
||||
EventHash = record.EventHash,
|
||||
PreviousHash = record.PreviousHash,
|
||||
MerkleLeafHash = record.MerkleLeafHash,
|
||||
EvidenceBundleRef = record.EvidenceBundleReference
|
||||
};
|
||||
|
||||
command.Parameters.AddWithValue("actor_id", record.ActorId);
|
||||
command.Parameters.AddWithValue("actor_type", record.ActorType);
|
||||
command.Parameters.AddWithValue("occurred_at", record.OccurredAt);
|
||||
command.Parameters.AddWithValue("recorded_at", record.RecordedAt);
|
||||
|
||||
var eventBody = record.EventBody.ToJsonString();
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("event_body", NpgsqlDbType.Jsonb) { TypedValue = eventBody });
|
||||
command.Parameters.AddWithValue("event_hash", record.EventHash);
|
||||
command.Parameters.AddWithValue("previous_hash", record.PreviousHash);
|
||||
command.Parameters.AddWithValue("merkle_leaf_hash", record.MerkleLeafHash);
|
||||
command.Parameters.AddWithValue("evidence_bundle_ref", (object?)record.EvidenceBundleReference ?? DBNull.Value);
|
||||
ctx.LedgerEvents.Add(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PostgresException ex) when (string.Equals(ex.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal))
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
{
|
||||
throw new LedgerDuplicateEventException(record.EventId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var entities = await ctx.LedgerEvents
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.ChainId == chainId)
|
||||
.OrderBy(e => e.SequenceNo)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapEntityToRecord).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var results = await ctx.LedgerEvents
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.FindingId == findingId && e.EvidenceBundleRef != null)
|
||||
.OrderByDescending(e => e.RecordedAt)
|
||||
.Select(e => new EvidenceReference(e.EventId, e.EvidenceBundleRef!, e.RecordedAt))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
internal static LedgerEventRecord MapEntityToRecord(LedgerEventEntity entity)
|
||||
{
|
||||
var eventBody = JsonNode.Parse(entity.EventBody)?.AsObject()
|
||||
?? throw new InvalidOperationException("Failed to parse ledger event body.");
|
||||
|
||||
var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(eventBody);
|
||||
var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(canonicalEnvelope);
|
||||
|
||||
return new LedgerEventRecord(
|
||||
entity.TenantId,
|
||||
entity.ChainId,
|
||||
entity.SequenceNo,
|
||||
entity.EventId,
|
||||
entity.EventType,
|
||||
entity.PolicyVersion,
|
||||
entity.FindingId,
|
||||
entity.ArtifactId,
|
||||
entity.SourceRunId,
|
||||
entity.ActorId,
|
||||
entity.ActorType,
|
||||
entity.OccurredAt,
|
||||
entity.RecordedAt,
|
||||
eventBody,
|
||||
entity.EventHash,
|
||||
entity.PreviousHash,
|
||||
entity.MerkleLeafHash,
|
||||
canonicalJson,
|
||||
entity.EvidenceBundleRef);
|
||||
}
|
||||
|
||||
// Legacy mapping kept for backward compatibility with code that may use NpgsqlDataReader directly.
|
||||
internal static LedgerEventRecord MapLedgerEventRecord(string tenantId, Guid eventId, NpgsqlDataReader reader)
|
||||
{
|
||||
var chainId = reader.GetFieldValue<Guid>(0);
|
||||
@@ -225,77 +200,4 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
canonicalJson,
|
||||
evidenceBundleRef);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT chain_id,
|
||||
sequence_no,
|
||||
event_type,
|
||||
policy_version,
|
||||
finding_id,
|
||||
artifact_id,
|
||||
source_run_id,
|
||||
actor_id,
|
||||
actor_type,
|
||||
occurred_at,
|
||||
recorded_at,
|
||||
event_body,
|
||||
event_hash,
|
||||
previous_hash,
|
||||
merkle_leaf_hash,
|
||||
evidence_bundle_ref,
|
||||
event_id
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND chain_id = @chain_id
|
||||
ORDER BY sequence_no ASC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("chain_id", chainId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<LedgerEventRecord>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var eventId = reader.GetGuid(16);
|
||||
results.Add(MapLedgerEventRecord(tenantId, eventId, reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT event_id, evidence_bundle_ref, recorded_at
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND finding_id = @finding_id
|
||||
AND evidence_bundle_ref IS NOT NULL
|
||||
ORDER BY recorded_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.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);
|
||||
var results = new List<EvidenceReference>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new EvidenceReference(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.GetFieldValue<DateTimeOffset>(2)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.EfCore.Models;
|
||||
using StellaOps.Findings.Ledger.Hashing;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
@@ -9,31 +11,6 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
public sealed class PostgresLedgerEventStream : ILedgerEventStream
|
||||
{
|
||||
private const string ReadEventsSql = """
|
||||
SELECT tenant_id,
|
||||
chain_id,
|
||||
sequence_no,
|
||||
event_id,
|
||||
event_type,
|
||||
policy_version,
|
||||
finding_id,
|
||||
artifact_id,
|
||||
source_run_id,
|
||||
actor_id,
|
||||
actor_type,
|
||||
occurred_at,
|
||||
recorded_at,
|
||||
event_body,
|
||||
event_hash,
|
||||
previous_hash,
|
||||
merkle_leaf_hash
|
||||
FROM ledger_events
|
||||
WHERE recorded_at > @last_recorded_at
|
||||
OR (recorded_at = @last_recorded_at AND event_id > @last_event_id)
|
||||
ORDER BY recorded_at, event_id
|
||||
LIMIT @page_size
|
||||
""";
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresLedgerEventStream> _logger;
|
||||
|
||||
@@ -56,76 +33,60 @@ public sealed class PostgresLedgerEventStream : ILedgerEventStream
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be greater than zero.");
|
||||
}
|
||||
|
||||
var records = new List<LedgerEventRecord>(batchSize);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(string.Empty, "projector", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(ReadEventsSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("last_recorded_at", checkpoint.LastRecordedAt);
|
||||
command.Parameters.AddWithValue("last_event_id", checkpoint.LastEventId);
|
||||
command.Parameters.AddWithValue("page_size", batchSize);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
try
|
||||
{
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
records.Add(MapLedgerEvent(reader));
|
||||
}
|
||||
var lastRecordedAt = checkpoint.LastRecordedAt;
|
||||
var lastEventId = checkpoint.LastEventId;
|
||||
|
||||
var entities = await ctx.LedgerEvents
|
||||
.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.RecordedAt > lastRecordedAt ||
|
||||
(e.RecordedAt == lastRecordedAt && e.EventId.CompareTo(lastEventId) > 0))
|
||||
.OrderBy(e => e.RecordedAt)
|
||||
.ThenBy(e => e.EventId)
|
||||
.Take(batchSize)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapEntityToRecord).ToList();
|
||||
}
|
||||
catch (PostgresException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read ledger event batch for projection replay.");
|
||||
throw;
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private static LedgerEventRecord MapLedgerEvent(NpgsqlDataReader reader)
|
||||
internal static LedgerEventRecord MapEntityToRecord(LedgerEventEntity entity)
|
||||
{
|
||||
var tenantId = reader.GetString(0);
|
||||
var chainId = reader.GetFieldValue<Guid>(1);
|
||||
var sequenceNumber = reader.GetInt64(2);
|
||||
var eventId = reader.GetGuid(3);
|
||||
var eventType = reader.GetString(4);
|
||||
var policyVersion = reader.GetString(5);
|
||||
var findingId = reader.GetString(6);
|
||||
var artifactId = reader.GetString(7);
|
||||
var sourceRunId = reader.IsDBNull(8) ? (Guid?)null : reader.GetGuid(8);
|
||||
var actorId = reader.GetString(9);
|
||||
var actorType = reader.GetString(10);
|
||||
var occurredAt = reader.GetFieldValue<DateTimeOffset>(11);
|
||||
var recordedAt = reader.GetFieldValue<DateTimeOffset>(12);
|
||||
|
||||
var eventBodyJson = reader.GetFieldValue<string>(13);
|
||||
var eventBodyParsed = JsonNode.Parse(eventBodyJson)?.AsObject()
|
||||
var eventBody = JsonNode.Parse(entity.EventBody)?.AsObject()
|
||||
?? throw new InvalidOperationException("Failed to parse ledger event payload.");
|
||||
var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(eventBodyParsed);
|
||||
|
||||
var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(eventBody);
|
||||
var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(canonicalEnvelope);
|
||||
|
||||
var eventHash = reader.GetString(14);
|
||||
var previousHash = reader.GetString(15);
|
||||
var merkleLeafHash = reader.GetString(16);
|
||||
|
||||
return new LedgerEventRecord(
|
||||
tenantId,
|
||||
chainId,
|
||||
sequenceNumber,
|
||||
eventId,
|
||||
eventType,
|
||||
policyVersion,
|
||||
findingId,
|
||||
artifactId,
|
||||
sourceRunId,
|
||||
actorId,
|
||||
actorType,
|
||||
occurredAt,
|
||||
recordedAt,
|
||||
entity.TenantId,
|
||||
entity.ChainId,
|
||||
entity.SequenceNo,
|
||||
entity.EventId,
|
||||
entity.EventType,
|
||||
entity.PolicyVersion,
|
||||
entity.FindingId,
|
||||
entity.ArtifactId,
|
||||
entity.SourceRunId,
|
||||
entity.ActorId,
|
||||
entity.ActorType,
|
||||
entity.OccurredAt,
|
||||
entity.RecordedAt,
|
||||
canonicalEnvelope,
|
||||
eventHash,
|
||||
previousHash,
|
||||
merkleLeafHash,
|
||||
entity.EventHash,
|
||||
entity.PreviousHash,
|
||||
entity.MerkleLeafHash,
|
||||
canonicalJson);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,12 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Findings.Ledger.EfCore.Models;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Merkle;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
public sealed class PostgresMerkleAnchorRepository : IMerkleAnchorRepository
|
||||
{
|
||||
private const string InsertAnchorSql = """
|
||||
INSERT INTO ledger_merkle_roots (
|
||||
tenant_id,
|
||||
anchor_id,
|
||||
window_start,
|
||||
window_end,
|
||||
sequence_start,
|
||||
sequence_end,
|
||||
root_hash,
|
||||
leaf_count,
|
||||
anchored_at,
|
||||
anchor_reference)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@anchor_id,
|
||||
@window_start,
|
||||
@window_end,
|
||||
@sequence_start,
|
||||
@sequence_end,
|
||||
@root_hash,
|
||||
@leaf_count,
|
||||
@anchored_at,
|
||||
@anchor_reference)
|
||||
""";
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresMerkleAnchorRepository> _logger;
|
||||
|
||||
@@ -56,23 +32,27 @@ public sealed class PostgresMerkleAnchorRepository : IMerkleAnchorRepository
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "anchor", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(InsertAnchorSql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("anchor_id", anchorId);
|
||||
command.Parameters.AddWithValue("window_start", windowStart);
|
||||
command.Parameters.AddWithValue("window_end", windowEnd);
|
||||
command.Parameters.AddWithValue("sequence_start", sequenceStart);
|
||||
command.Parameters.AddWithValue("sequence_end", sequenceEnd);
|
||||
command.Parameters.AddWithValue("root_hash", rootHash);
|
||||
command.Parameters.AddWithValue("leaf_count", leafCount);
|
||||
command.Parameters.AddWithValue("anchored_at", anchoredAt);
|
||||
command.Parameters.AddWithValue("anchor_reference", anchorReference ?? (object)DBNull.Value);
|
||||
var entity = new LedgerMerkleRootEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
AnchorId = anchorId,
|
||||
WindowStart = windowStart,
|
||||
WindowEnd = windowEnd,
|
||||
SequenceStart = sequenceStart,
|
||||
SequenceEnd = sequenceEnd,
|
||||
RootHash = rootHash,
|
||||
LeafCount = leafCount,
|
||||
AnchoredAt = anchoredAt,
|
||||
AnchorReference = anchorReference
|
||||
};
|
||||
|
||||
ctx.LedgerMerkleRoots.Add(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PostgresException ex)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
@@ -48,24 +49,6 @@ public sealed class PostgresOrchestratorExportRepository : IOrchestratorExportRe
|
||||
created_at = EXCLUDED.created_at;
|
||||
""";
|
||||
|
||||
private const string SelectByArtifactSql = """
|
||||
SELECT run_id,
|
||||
job_type,
|
||||
artifact_hash,
|
||||
policy_hash,
|
||||
started_at,
|
||||
completed_at,
|
||||
status,
|
||||
manifest_path,
|
||||
logs_path,
|
||||
merkle_root,
|
||||
created_at
|
||||
FROM orchestrator_exports
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND artifact_hash = @artifact_hash
|
||||
ORDER BY completed_at DESC NULLS LAST, started_at DESC;
|
||||
""";
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresOrchestratorExportRepository> _logger;
|
||||
|
||||
@@ -82,27 +65,26 @@ public sealed class PostgresOrchestratorExportRepository : IOrchestratorExportRe
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "orchestrator-export", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(UpsertSql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", record.TenantId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<Guid>("run_id", record.RunId) { NpgsqlDbType = NpgsqlDbType.Uuid });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("job_type", record.JobType) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("artifact_hash", record.ArtifactHash) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("policy_hash", record.PolicyHash) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<DateTimeOffset>("started_at", record.StartedAt) { NpgsqlDbType = NpgsqlDbType.TimestampTz });
|
||||
command.Parameters.Add(new NpgsqlParameter<DateTimeOffset?>("completed_at", record.CompletedAt) { NpgsqlDbType = NpgsqlDbType.TimestampTz });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("status", record.Status) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string?>("manifest_path", record.ManifestPath) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string?>("logs_path", record.LogsPath) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("merkle_root", record.MerkleRoot) { NpgsqlDbType = NpgsqlDbType.Char });
|
||||
command.Parameters.Add(new NpgsqlParameter<DateTimeOffset>("created_at", record.CreatedAt) { NpgsqlDbType = NpgsqlDbType.TimestampTz });
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
// Use ExecuteSqlRawAsync for UPSERT (ON CONFLICT DO UPDATE) which EF Core LINQ does not support.
|
||||
try
|
||||
{
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ctx.Database.ExecuteSqlRawAsync(
|
||||
UpsertSql,
|
||||
new NpgsqlParameter<string>("tenant_id", record.TenantId) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<Guid>("run_id", record.RunId) { NpgsqlDbType = NpgsqlDbType.Uuid },
|
||||
new NpgsqlParameter<string>("job_type", record.JobType) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string>("artifact_hash", record.ArtifactHash) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string>("policy_hash", record.PolicyHash) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<DateTimeOffset>("started_at", record.StartedAt) { NpgsqlDbType = NpgsqlDbType.TimestampTz },
|
||||
new NpgsqlParameter<DateTimeOffset?>("completed_at", record.CompletedAt) { NpgsqlDbType = NpgsqlDbType.TimestampTz },
|
||||
new NpgsqlParameter<string>("status", record.Status) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string?>("manifest_path", record.ManifestPath) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string?>("logs_path", record.LogsPath) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<string>("merkle_root", record.MerkleRoot) { NpgsqlDbType = NpgsqlDbType.Text },
|
||||
new NpgsqlParameter<DateTimeOffset>("created_at", record.CreatedAt) { NpgsqlDbType = NpgsqlDbType.TimestampTz },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (PostgresException ex)
|
||||
{
|
||||
@@ -113,34 +95,29 @@ public sealed class PostgresOrchestratorExportRepository : IOrchestratorExportRe
|
||||
|
||||
public async Task<IReadOnlyList<OrchestratorExportRecord>> GetByArtifactAsync(string tenantId, string artifactHash, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<OrchestratorExportRecord>();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "orchestrator-export", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectByArtifactSql, connection)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds
|
||||
};
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("tenant_id", tenantId) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
command.Parameters.Add(new NpgsqlParameter<string>("artifact_hash", artifactHash) { NpgsqlDbType = NpgsqlDbType.Text });
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new OrchestratorExportRecord(
|
||||
TenantId: tenantId,
|
||||
RunId: reader.GetGuid(0),
|
||||
JobType: reader.GetString(1),
|
||||
ArtifactHash: reader.GetString(2),
|
||||
PolicyHash: reader.GetString(3),
|
||||
StartedAt: reader.GetFieldValue<DateTimeOffset>(4),
|
||||
CompletedAt: reader.IsDBNull(5) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(5),
|
||||
Status: reader.GetString(6),
|
||||
ManifestPath: reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
LogsPath: reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
MerkleRoot: reader.GetString(9),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(10)));
|
||||
}
|
||||
var entities = await ctx.OrchestratorExports
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.ArtifactHash == artifactHash)
|
||||
.OrderByDescending(e => e.CompletedAt)
|
||||
.ThenByDescending(e => e.StartedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results;
|
||||
return entities.Select(e => new OrchestratorExportRecord(
|
||||
TenantId: tenantId,
|
||||
RunId: e.RunId,
|
||||
JobType: e.JobType,
|
||||
ArtifactHash: e.ArtifactHash,
|
||||
PolicyHash: e.PolicyHash,
|
||||
StartedAt: e.StartedAt,
|
||||
CompletedAt: e.CompletedAt,
|
||||
Status: e.Status,
|
||||
ManifestPath: e.ManifestPath,
|
||||
LogsPath: e.LogsPath,
|
||||
MerkleRoot: e.MerkleRoot,
|
||||
CreatedAt: e.CreatedAt)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.EfCore.Models;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -10,6 +12,8 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of snapshot repository.
|
||||
/// Note: This repository uses NpgsqlDataSource directly (not LedgerDataSource) because snapshots
|
||||
/// include cross-tenant expiration operations. EF Core context is created via direct connection opening.
|
||||
/// </summary>
|
||||
public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
{
|
||||
@@ -50,45 +54,35 @@ public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
? 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 connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
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);
|
||||
var entity = new LedgerSnapshotEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SnapshotId = snapshotId,
|
||||
Label = input.Label,
|
||||
Description = input.Description,
|
||||
Status = SnapshotStatus.Creating.ToString(),
|
||||
CreatedAt = createdAt,
|
||||
ExpiresAt = expiresAt,
|
||||
SequenceNumber = sequenceNumber,
|
||||
SnapshotTimestamp = timestamp,
|
||||
FindingsCount = initialStats.FindingsCount,
|
||||
VexStatementsCount = initialStats.VexStatementsCount,
|
||||
AdvisoriesCount = initialStats.AdvisoriesCount,
|
||||
SbomsCount = initialStats.SbomsCount,
|
||||
EventsCount = initialStats.EventsCount,
|
||||
SizeBytes = initialStats.SizeBytes,
|
||||
MerkleRoot = null,
|
||||
DsseDigest = null,
|
||||
Metadata = metadataJson,
|
||||
IncludeEntityTypes = entityTypesJson,
|
||||
SignRequested = input.Sign
|
||||
};
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
ctx.LedgerSnapshots.Add(entity);
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
|
||||
return new LedgerSnapshot(
|
||||
tenantId,
|
||||
@@ -111,90 +105,60 @@ public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
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 connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||
var entity = await ctx.LedgerSnapshots
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.SnapshotId == snapshotId, ct);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return null;
|
||||
|
||||
return MapSnapshot(reader);
|
||||
return entity is null ? null : MapEntityToSnapshot(entity);
|
||||
}
|
||||
|
||||
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
|
||||
""");
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenantId", query.TenantId)
|
||||
};
|
||||
IQueryable<LedgerSnapshotEntity> queryable = ctx.LedgerSnapshots
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == query.TenantId);
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
sql.Append(" AND status = @status");
|
||||
parameters.Add(new NpgsqlParameter("status", query.Status.Value.ToString()));
|
||||
var statusStr = query.Status.Value.ToString();
|
||||
queryable = queryable.Where(e => e.Status == statusStr);
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
sql.Append(" AND created_at >= @createdAfter");
|
||||
parameters.Add(new NpgsqlParameter("createdAfter", query.CreatedAfter.Value));
|
||||
queryable = queryable.Where(e => e.CreatedAt >= query.CreatedAfter.Value);
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
sql.Append(" AND created_at < @createdBefore");
|
||||
parameters.Add(new NpgsqlParameter("createdBefore", query.CreatedBefore.Value));
|
||||
queryable = queryable.Where(e => e.CreatedAt < query.CreatedBefore.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.PageToken))
|
||||
if (!string.IsNullOrEmpty(query.PageToken) && Guid.TryParse(query.PageToken, out var lastId))
|
||||
{
|
||||
if (Guid.TryParse(query.PageToken, out var lastId))
|
||||
{
|
||||
sql.Append(" AND snapshot_id > @lastId");
|
||||
parameters.Add(new NpgsqlParameter("lastId", lastId));
|
||||
}
|
||||
queryable = queryable.Where(e => e.SnapshotId.CompareTo(lastId) > 0);
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY created_at DESC, snapshot_id");
|
||||
sql.Append(" LIMIT @limit");
|
||||
parameters.Add(new NpgsqlParameter("limit", query.PageSize + 1));
|
||||
queryable = queryable
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ThenBy(e => e.SnapshotId);
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql.ToString());
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
var entities = await queryable
|
||||
.Take(query.PageSize + 1)
|
||||
.ToListAsync(ct);
|
||||
|
||||
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));
|
||||
}
|
||||
var snapshots = entities.Take(query.PageSize).Select(MapEntityToSnapshot).ToList();
|
||||
|
||||
string? nextPageToken = null;
|
||||
if (await reader.ReadAsync(ct))
|
||||
if (entities.Count > query.PageSize)
|
||||
{
|
||||
nextPageToken = snapshots.Last().SnapshotId.ToString();
|
||||
}
|
||||
@@ -208,19 +172,19 @@ public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
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 connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
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);
|
||||
var entity = await ctx.LedgerSnapshots
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.SnapshotId == snapshotId, ct);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
if (entity is null)
|
||||
return false;
|
||||
|
||||
entity.Status = newStatus.ToString();
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
return await ctx.SaveChangesAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatisticsAsync(
|
||||
@@ -229,30 +193,24 @@ public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
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 connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
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);
|
||||
var entity = await ctx.LedgerSnapshots
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.SnapshotId == snapshotId, ct);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
if (entity is null)
|
||||
return false;
|
||||
|
||||
entity.FindingsCount = statistics.FindingsCount;
|
||||
entity.VexStatementsCount = statistics.VexStatementsCount;
|
||||
entity.AdvisoriesCount = statistics.AdvisoriesCount;
|
||||
entity.SbomsCount = statistics.SbomsCount;
|
||||
entity.EventsCount = statistics.EventsCount;
|
||||
entity.SizeBytes = statistics.SizeBytes;
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
return await ctx.SaveChangesAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> SetMerkleRootAsync(
|
||||
@@ -262,43 +220,45 @@ public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
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 connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
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);
|
||||
var entity = await ctx.LedgerSnapshots
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.SnapshotId == snapshotId, ct);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
if (entity is null)
|
||||
return false;
|
||||
|
||||
entity.MerkleRoot = merkleRoot;
|
||||
entity.DsseDigest = dsseDigest;
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
return await ctx.SaveChangesAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<int> ExpireSnapshotsAsync(
|
||||
DateTimeOffset cutoff,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
// Batch update across tenants -- use ExecuteSqlRaw for efficiency.
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var expiredStatus = SnapshotStatus.Expired.ToString();
|
||||
var availableStatus = SnapshotStatus.Available.ToString();
|
||||
|
||||
return await ctx.Database.ExecuteSqlRawAsync("""
|
||||
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);
|
||||
""",
|
||||
new NpgsqlParameter("expiredStatus", expiredStatus),
|
||||
new NpgsqlParameter("availableStatus", availableStatus),
|
||||
new NpgsqlParameter("cutoff", cutoff),
|
||||
new NpgsqlParameter("updatedAt", DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(
|
||||
@@ -306,46 +266,37 @@ public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
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 connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
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);
|
||||
var entity = await ctx.LedgerSnapshots
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.SnapshotId == snapshotId, ct);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
if (entity is null)
|
||||
return false;
|
||||
|
||||
entity.Status = SnapshotStatus.Deleted.ToString();
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
return await ctx.SaveChangesAsync(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 connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("status", SnapshotStatus.Available.ToString());
|
||||
var availableStatus = SnapshotStatus.Available.ToString();
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
return null;
|
||||
var entity = await ctx.LedgerSnapshots
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.Status == availableStatus)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return MapSnapshot(reader);
|
||||
return entity is null ? null : MapEntityToSnapshot(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(
|
||||
@@ -353,51 +304,41 @@ public sealed class PostgresSnapshotRepository : ISnapshotRepository
|
||||
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 connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(connection, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
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);
|
||||
return await ctx.LedgerSnapshots
|
||||
.AsNoTracking()
|
||||
.AnyAsync(e => e.TenantId == tenantId && e.SnapshotId == snapshotId, ct);
|
||||
}
|
||||
|
||||
private LedgerSnapshot MapSnapshot(NpgsqlDataReader reader)
|
||||
private LedgerSnapshot MapEntityToSnapshot(LedgerSnapshotEntity entity)
|
||||
{
|
||||
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("metadata"));
|
||||
|
||||
Dictionary<string, object>? metadata = null;
|
||||
if (!string.IsNullOrEmpty(metadataJson))
|
||||
if (!string.IsNullOrEmpty(entity.Metadata))
|
||||
{
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metadataJson, _jsonOptions);
|
||||
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(entity.Metadata, _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")),
|
||||
TenantId: entity.TenantId,
|
||||
SnapshotId: entity.SnapshotId,
|
||||
Label: entity.Label,
|
||||
Description: entity.Description,
|
||||
Status: Enum.Parse<SnapshotStatus>(entity.Status),
|
||||
CreatedAt: entity.CreatedAt,
|
||||
ExpiresAt: entity.ExpiresAt,
|
||||
SequenceNumber: entity.SequenceNumber,
|
||||
Timestamp: entity.SnapshotTimestamp,
|
||||
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")),
|
||||
FindingsCount: entity.FindingsCount,
|
||||
VexStatementsCount: entity.VexStatementsCount,
|
||||
AdvisoriesCount: entity.AdvisoriesCount,
|
||||
SbomsCount: entity.SbomsCount,
|
||||
EventsCount: entity.EventsCount,
|
||||
SizeBytes: entity.SizeBytes),
|
||||
MerkleRoot: entity.MerkleRoot,
|
||||
DsseDigest: entity.DsseDigest,
|
||||
Metadata: metadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of time-travel repository.
|
||||
///
|
||||
/// RATIONALE FOR RETAINING RAW SQL (EF Core migration decision):
|
||||
/// This repository is intentionally excluded from the EF Core DAL conversion because:
|
||||
/// 1. All queries use complex CTEs with window functions (ROW_NUMBER, PARTITION BY) for event-sourced
|
||||
/// state reconstruction at arbitrary points in time.
|
||||
/// 2. The diff computation uses nested CTEs with CASE-based entity type classification.
|
||||
/// 3. Dynamic SQL builders with event type LIKE pattern filtering across multiple entity types.
|
||||
/// 4. The replay query builds dynamic WHERE clauses with optional sequence/timestamp/chain/type filters.
|
||||
/// 5. The changelog query uses COALESCE with JSONB path extraction (payload->>'summary').
|
||||
/// 6. The staleness check uses conditional MAX with CASE across event types.
|
||||
/// None of these patterns can be expressed in EF Core LINQ without losing the query semantics.
|
||||
/// The NpgsqlDataSource is used directly because these queries do not require tenant-scoped
|
||||
/// session variables (they filter by tenant_id in the WHERE clause).
|
||||
/// </summary>
|
||||
public sealed class PostgresTimeTravelRepository : ITimeTravelRepository
|
||||
{
|
||||
|
||||
@@ -6,6 +6,11 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
/// <summary>
|
||||
/// Service for validating Row-Level Security configuration on Findings Ledger tables.
|
||||
/// Used for compliance checks and deployment verification.
|
||||
///
|
||||
/// RATIONALE FOR RETAINING RAW SQL (EF Core migration decision):
|
||||
/// This service queries PostgreSQL system catalogs (pg_tables, pg_class, pg_policies, pg_proc, pg_namespace)
|
||||
/// which are not part of the application schema and cannot be modeled via EF Core DbContext entities.
|
||||
/// These are infrastructure-level diagnostic queries that operate outside the domain model.
|
||||
/// </summary>
|
||||
public sealed class RlsValidationService
|
||||
{
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
// PostgresObservationRepository.cs
|
||||
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
|
||||
// Task: DBI-013 - Implement PostgresObservationRepository
|
||||
// Description: PostgreSQL implementation of observation repository
|
||||
// Description: PostgreSQL implementation of observation repository (EF Core)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using System.Globalization;
|
||||
using StellaOps.Findings.Ledger.EfCore.Models;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of observation repository.
|
||||
/// Note: Uses NpgsqlDataSource directly (not LedgerDataSource) because observation queries
|
||||
/// do not require tenant-scoped session variables -- they filter by tenant_id in the WHERE clause.
|
||||
/// </summary>
|
||||
public sealed class PostgresObservationRepository : IObservationRepository
|
||||
{
|
||||
@@ -43,42 +47,32 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
Observation observation,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO observations (
|
||||
id, cve_id, product, tenant_id, finding_id, state, previous_state,
|
||||
reason, user_id, evidence_ref, signals, created_at, expires_at
|
||||
) VALUES (
|
||||
@id, @cve_id, @product, @tenant_id, @finding_id, @state, @previous_state,
|
||||
@reason, @user_id, @evidence_ref, @signals, @created_at, @expires_at
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", observation.Id);
|
||||
cmd.Parameters.AddWithValue("cve_id", observation.CveId);
|
||||
cmd.Parameters.AddWithValue("product", observation.Product);
|
||||
cmd.Parameters.AddWithValue("tenant_id", observation.TenantId);
|
||||
cmd.Parameters.AddWithValue("finding_id", (object?)observation.FindingId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("state", observation.State.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("previous_state",
|
||||
(object?)observation.PreviousState?.ToString().ToLowerInvariant() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("reason", (object?)observation.Reason ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("user_id", (object?)observation.UserId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("evidence_ref", (object?)observation.EvidenceRef ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("signals", SerializeSignals(observation.Signals));
|
||||
cmd.Parameters.AddWithValue("created_at", observation.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("expires_at", (object?)observation.ExpiresAt ?? DBNull.Value);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
var entity = new ObservationEntity
|
||||
{
|
||||
return MapFromReader(reader);
|
||||
}
|
||||
Id = observation.Id,
|
||||
CveId = observation.CveId,
|
||||
Product = observation.Product,
|
||||
TenantId = observation.TenantId,
|
||||
FindingId = observation.FindingId,
|
||||
State = observation.State.ToString().ToLowerInvariant(),
|
||||
PreviousState = observation.PreviousState?.ToString().ToLowerInvariant(),
|
||||
Reason = observation.Reason,
|
||||
UserId = observation.UserId,
|
||||
EvidenceRef = observation.EvidenceRef,
|
||||
Signals = observation.Signals is not null
|
||||
? JsonSerializer.Serialize(observation.Signals, JsonOptions)
|
||||
: null,
|
||||
CreatedAt = observation.CreatedAt,
|
||||
ExpiresAt = observation.ExpiresAt
|
||||
};
|
||||
|
||||
throw new InvalidOperationException("Insert did not return a row");
|
||||
ctx.Observations.Add(entity);
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
|
||||
return MapEntityToObservation(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -86,21 +80,14 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
string id,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapFromReader(reader);
|
||||
}
|
||||
var entity = await ctx.Observations
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == id, ct);
|
||||
|
||||
return null;
|
||||
return entity is null ? null : MapEntityToObservation(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -109,13 +96,16 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE cve_id = @cve_id AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { cve_id = cveId, tenant_id = tenantId }, ct);
|
||||
var entities = await ctx.Observations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.CveId == cveId && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(MapEntityToObservation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -124,13 +114,16 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE product = @product AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { product, tenant_id = tenantId }, ct);
|
||||
var entities = await ctx.Observations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Product == product && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(MapEntityToObservation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -138,13 +131,16 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
string findingId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE finding_id = @finding_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { finding_id = findingId }, ct);
|
||||
var entities = await ctx.Observations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.FindingId == findingId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(MapEntityToObservation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -154,15 +150,16 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE cve_id = @cve_id AND product = @product AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var results = await ExecuteQueryAsync(sql, new { cve_id = cveId, product, tenant_id = tenantId }, ct);
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
var entity = await ctx.Observations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.CveId == cveId && e.Product == product && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return entity is null ? null : MapEntityToObservation(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -173,14 +170,17 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE cve_id = @cve_id AND product = @product AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { cve_id = cveId, product, tenant_id = tenantId, limit }, ct);
|
||||
var entities = await ctx.Observations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.CveId == cveId && e.Product == product && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(MapEntityToObservation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -191,20 +191,20 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
int offset = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE state = @state AND tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
return await ExecuteQueryAsync(sql, new
|
||||
{
|
||||
state = state.ToString().ToLowerInvariant(),
|
||||
tenant_id = tenantId,
|
||||
limit,
|
||||
offset
|
||||
}, ct);
|
||||
var stateStr = state.ToString().ToLowerInvariant();
|
||||
|
||||
var entities = await ctx.Observations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.State == stateStr && e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(MapEntityToObservation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -213,15 +213,16 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM observations
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at <= @before
|
||||
AND tenant_id = @tenant_id
|
||||
ORDER BY expires_at ASC
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
return await ExecuteQueryAsync(sql, new { before, tenant_id = tenantId }, ct);
|
||||
var entities = await ctx.Observations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ExpiresAt != null && e.ExpiresAt <= before && e.TenantId == tenantId)
|
||||
.OrderBy(e => e.ExpiresAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(MapEntityToObservation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -229,115 +230,58 @@ public sealed class PostgresObservationRepository : IObservationRepository
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT state, COUNT(*) as count
|
||||
FROM observations
|
||||
WHERE tenant_id = @tenant_id
|
||||
GROUP BY state
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
await using var ctx = FindingsLedgerDbContextFactory.Create(conn, 30, FindingsLedgerDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var groups = await ctx.Observations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId)
|
||||
.GroupBy(e => e.State)
|
||||
.Select(g => new { State = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = new Dictionary<ObservationState, int>();
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var stateStr = reader.GetString(0);
|
||||
var count = reader.GetInt32(1);
|
||||
|
||||
if (Enum.TryParse<ObservationState>(stateStr, ignoreCase: true, out var state))
|
||||
if (Enum.TryParse<ObservationState>(group.State, ignoreCase: true, out var state))
|
||||
{
|
||||
result[state] = count;
|
||||
result[state] = group.Count;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<Observation>> ExecuteQueryAsync(
|
||||
string sql,
|
||||
object parameters,
|
||||
CancellationToken ct)
|
||||
private static Observation MapEntityToObservation(ObservationEntity entity)
|
||||
{
|
||||
var results = new List<Observation>();
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
foreach (var prop in parameters.GetType().GetProperties())
|
||||
{
|
||||
var value = prop.GetValue(parameters);
|
||||
cmd.Parameters.AddWithValue(prop.Name, value ?? DBNull.Value);
|
||||
}
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapFromReader(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Observation MapFromReader(NpgsqlDataReader reader)
|
||||
{
|
||||
var previousStateOrdinal = reader.GetOrdinal("previous_state");
|
||||
ObservationState? previousState = null;
|
||||
if (!reader.IsDBNull(previousStateOrdinal))
|
||||
if (!string.IsNullOrEmpty(entity.PreviousState) &&
|
||||
Enum.TryParse<ObservationState>(entity.PreviousState, ignoreCase: true, out var ps))
|
||||
{
|
||||
var prevStr = reader.GetString(previousStateOrdinal);
|
||||
if (Enum.TryParse<ObservationState>(prevStr, ignoreCase: true, out var ps))
|
||||
{
|
||||
previousState = ps;
|
||||
}
|
||||
previousState = ps;
|
||||
}
|
||||
|
||||
SignalSnapshotSummary? signals = null;
|
||||
if (!string.IsNullOrEmpty(entity.Signals))
|
||||
{
|
||||
signals = JsonSerializer.Deserialize<SignalSnapshotSummary>(entity.Signals, JsonOptions);
|
||||
}
|
||||
|
||||
return new Observation
|
||||
{
|
||||
Id = reader.GetString(reader.GetOrdinal("id")),
|
||||
CveId = reader.GetString(reader.GetOrdinal("cve_id")),
|
||||
Product = reader.GetString(reader.GetOrdinal("product")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
FindingId = GetNullableString(reader, "finding_id"),
|
||||
State = Enum.Parse<ObservationState>(
|
||||
reader.GetString(reader.GetOrdinal("state")),
|
||||
ignoreCase: true),
|
||||
Id = entity.Id,
|
||||
CveId = entity.CveId,
|
||||
Product = entity.Product,
|
||||
TenantId = entity.TenantId,
|
||||
FindingId = entity.FindingId,
|
||||
State = Enum.Parse<ObservationState>(entity.State, ignoreCase: true),
|
||||
PreviousState = previousState,
|
||||
Reason = GetNullableString(reader, "reason"),
|
||||
UserId = GetNullableString(reader, "user_id"),
|
||||
EvidenceRef = GetNullableString(reader, "evidence_ref"),
|
||||
Signals = DeserializeSignals(reader),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
ExpiresAt = GetNullableDateTime(reader, "expires_at")
|
||||
Reason = entity.Reason,
|
||||
UserId = entity.UserId,
|
||||
EvidenceRef = entity.EvidenceRef,
|
||||
Signals = signals,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
ExpiresAt = entity.ExpiresAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetNullableString(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetNullableDateTime(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetFieldValue<DateTimeOffset>(ordinal);
|
||||
}
|
||||
|
||||
private static object SerializeSignals(SignalSnapshotSummary? signals)
|
||||
{
|
||||
if (signals is null) return DBNull.Value;
|
||||
return JsonSerializer.Serialize(signals, JsonOptions);
|
||||
}
|
||||
|
||||
private static SignalSnapshotSummary? DeserializeSignals(NpgsqlDataReader reader)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal("signals");
|
||||
if (reader.IsDBNull(ordinal)) return null;
|
||||
var json = reader.GetString(ordinal);
|
||||
return JsonSerializer.Deserialize<SignalSnapshotSummary>(json, JsonOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\FindingsLedgerDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,3 +9,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0342-T | DONE | Revalidated 2026-01-07; test coverage audit for Findings Ledger. |
|
||||
| AUDIT-0342-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| FIND-EF-01 | DONE | Migration registry wiring verified and implemented (FindingsLedgerMigrationModulePlugin). Sprint 094. |
|
||||
| FIND-EF-02 | DONE | EF Core model baseline scaffolded (11 entities, DbContext, design-time factory). Sprint 094. |
|
||||
| FIND-EF-03 | DONE | All 9 Postgres repositories converted to EF Core. 2 retained as raw SQL (TimeTravelRepository, RlsValidationService). Sprint 094. |
|
||||
| FIND-EF-04 | DONE | Compiled model stubs and runtime FindingsLedgerDbContextFactory created. Sprint 094. |
|
||||
| FIND-EF-05 | DONE | Sequential build validated (0 warnings, 0 errors). Sprint and docs updated. Sprint 094. |
|
||||
|
||||
Reference in New Issue
Block a user