diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs
index 589a6dbfa..3f03657f4 100644
--- a/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs
+++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs
@@ -1,419 +1,33 @@
-// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
-// Postgres-backed repositories for VulnExplorer adapters (VXLM-005 gap fix).
-// Replaces ConcurrentDictionary in-memory stores with durable persistence.
+//
+// SPDX-License-Identifier: BUSL-1.1
+//
+//
+// Postgres-backed repositories for VulnExplorer triage data.
+// These replace the ConcurrentDictionary-based stores in VulnExplorer.Api/Data/
+// when a database connection is available.
+//
+// The VulnExplorer.Api service wires these via its own thin adapters
+// (see VulnExplorer.Api/Data/VexDecisionStore.cs, TriageWorkflowStores.cs).
+// This file is kept here for colocation with the Findings Ledger migration set
+// and is Compile-linked into VulnExplorer.Api.csproj.
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
-using StellaOps.Findings.Ledger.Infrastructure.Postgres;
-using StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
using System.Text.Json;
+using System.Text.Json.Serialization;
namespace StellaOps.Findings.Ledger.WebService.Services;
-// ============================================================================
-// Interfaces
-// ============================================================================
-
-public interface IVexDecisionRepository
+///
+/// Shared JSON serializer options for VulnExplorer Postgres repositories.
+///
+internal static class VulnExplorerJsonDefaults
{
- Task CreateAsync(string tenantId, VexDecisionDto decision, CancellationToken ct = default);
- Task UpdateAsync(string tenantId, Guid id, UpdateVexDecisionRequest request, DateTimeOffset updatedAt, CancellationToken ct = default);
- Task GetAsync(string tenantId, Guid id, CancellationToken ct = default);
- Task> QueryAsync(string tenantId, string? vulnerabilityId, string? subjectName, VexStatus? status, int skip, int take, CancellationToken ct = default);
- Task CountAsync(string tenantId, CancellationToken ct = default);
-}
-
-public interface IFixVerificationRepository
-{
- Task CreateAsync(string tenantId, FixVerificationRecord record, CancellationToken ct = default);
- Task UpdateAsync(string tenantId, string cveId, string verdict, FixVerificationTransition transition, CancellationToken ct = default);
-}
-
-public interface IAuditBundleRepository
-{
- Task CreateAsync(string tenantId, AuditBundleResponse bundle, IReadOnlyList decisionIds, CancellationToken ct = default);
- Task NextBundleIdAsync(string tenantId, CancellationToken ct = default);
-}
-
-// ============================================================================
-// JSON serialization options (shared)
-// ============================================================================
-
-internal static class VulnExplorerJsonOptions
-{
- internal static readonly JsonSerializerOptions Default = new()
+ internal static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- WriteIndented = false,
- Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
}
-
-// ============================================================================
-// PostgresVexDecisionRepository
-// ============================================================================
-
-public sealed class PostgresVexDecisionRepository : IVexDecisionRepository
-{
- private readonly LedgerDataSource _dataSource;
- private readonly ILogger _logger;
-
- public PostgresVexDecisionRepository(LedgerDataSource dataSource, ILogger logger)
- {
- _dataSource = dataSource;
- _logger = logger;
- }
-
- public async Task CreateAsync(string tenantId, VexDecisionDto decision, CancellationToken ct = default)
- {
- await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-write", ct).ConfigureAwait(false);
-
- const string sql = """
- INSERT INTO findings.vex_decisions
- (id, tenant_id, vulnerability_id, subject, status, justification_type,
- justification_text, evidence_refs, scope, valid_for, attestation_ref,
- signed_override, supersedes_decision_id, created_by, created_at, updated_at)
- VALUES
- (@id, @tenant_id, @vulnerability_id, @subject::jsonb, @status, @justification_type,
- @justification_text, @evidence_refs::jsonb, @scope::jsonb, @valid_for::jsonb,
- @attestation_ref::jsonb, @signed_override::jsonb, @supersedes_decision_id,
- @created_by::jsonb, @created_at, @updated_at)
- """;
-
- await using var cmd = new NpgsqlCommand(sql, connection);
- cmd.Parameters.AddWithValue("id", decision.Id);
- cmd.Parameters.AddWithValue("tenant_id", tenantId);
- cmd.Parameters.AddWithValue("vulnerability_id", decision.VulnerabilityId);
- cmd.Parameters.Add(new NpgsqlParameter("subject", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(decision.Subject, VulnExplorerJsonOptions.Default) });
- cmd.Parameters.AddWithValue("status", decision.Status.ToString());
- cmd.Parameters.AddWithValue("justification_type", decision.JustificationType.ToString());
- cmd.Parameters.AddWithValue("justification_text", (object?)decision.JustificationText ?? DBNull.Value);
- cmd.Parameters.Add(new NpgsqlParameter("evidence_refs", NpgsqlDbType.Jsonb) { Value = decision.EvidenceRefs is not null ? JsonSerializer.Serialize(decision.EvidenceRefs, VulnExplorerJsonOptions.Default) : DBNull.Value });
- cmd.Parameters.Add(new NpgsqlParameter("scope", NpgsqlDbType.Jsonb) { Value = decision.Scope is not null ? JsonSerializer.Serialize(decision.Scope, VulnExplorerJsonOptions.Default) : DBNull.Value });
- cmd.Parameters.Add(new NpgsqlParameter("valid_for", NpgsqlDbType.Jsonb) { Value = decision.ValidFor is not null ? JsonSerializer.Serialize(decision.ValidFor, VulnExplorerJsonOptions.Default) : DBNull.Value });
- cmd.Parameters.Add(new NpgsqlParameter("attestation_ref", NpgsqlDbType.Jsonb) { Value = decision.AttestationRef is not null ? JsonSerializer.Serialize(decision.AttestationRef, VulnExplorerJsonOptions.Default) : DBNull.Value });
- cmd.Parameters.Add(new NpgsqlParameter("signed_override", NpgsqlDbType.Jsonb) { Value = decision.SignedOverride is not null ? JsonSerializer.Serialize(decision.SignedOverride, VulnExplorerJsonOptions.Default) : DBNull.Value });
- cmd.Parameters.AddWithValue("supersedes_decision_id", (object?)decision.SupersedesDecisionId ?? DBNull.Value);
- cmd.Parameters.Add(new NpgsqlParameter("created_by", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(decision.CreatedBy, VulnExplorerJsonOptions.Default) });
- cmd.Parameters.AddWithValue("created_at", decision.CreatedAt);
- cmd.Parameters.AddWithValue("updated_at", (object?)decision.UpdatedAt ?? DBNull.Value);
-
- await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
- _logger.LogDebug("Created VEX decision {DecisionId} for tenant {TenantId}", decision.Id, tenantId);
- return decision;
- }
-
- public async Task UpdateAsync(string tenantId, Guid id, UpdateVexDecisionRequest request, DateTimeOffset updatedAt, CancellationToken ct = default)
- {
- await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-write", ct).ConfigureAwait(false);
-
- // First fetch the existing record
- var existing = await GetInternalAsync(connection, id, ct).ConfigureAwait(false);
- if (existing is null)
- {
- return null;
- }
-
- var updated = existing with
- {
- Status = request.Status ?? existing.Status,
- JustificationType = request.JustificationType ?? existing.JustificationType,
- JustificationText = request.JustificationText ?? existing.JustificationText,
- EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
- Scope = request.Scope ?? existing.Scope,
- ValidFor = request.ValidFor ?? existing.ValidFor,
- SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
- UpdatedAt = updatedAt
- };
-
- const string sql = """
- UPDATE findings.vex_decisions SET
- status = @status,
- justification_type = @justification_type,
- justification_text = @justification_text,
- evidence_refs = @evidence_refs::jsonb,
- scope = @scope::jsonb,
- valid_for = @valid_for::jsonb,
- supersedes_decision_id = @supersedes_decision_id,
- updated_at = @updated_at
- WHERE id = @id
- """;
-
- await using var cmd = new NpgsqlCommand(sql, connection);
- cmd.Parameters.AddWithValue("id", id);
- cmd.Parameters.AddWithValue("status", updated.Status.ToString());
- cmd.Parameters.AddWithValue("justification_type", updated.JustificationType.ToString());
- cmd.Parameters.AddWithValue("justification_text", (object?)updated.JustificationText ?? DBNull.Value);
- cmd.Parameters.Add(new NpgsqlParameter("evidence_refs", NpgsqlDbType.Jsonb) { Value = updated.EvidenceRefs is not null ? JsonSerializer.Serialize(updated.EvidenceRefs, VulnExplorerJsonOptions.Default) : DBNull.Value });
- cmd.Parameters.Add(new NpgsqlParameter("scope", NpgsqlDbType.Jsonb) { Value = updated.Scope is not null ? JsonSerializer.Serialize(updated.Scope, VulnExplorerJsonOptions.Default) : DBNull.Value });
- cmd.Parameters.Add(new NpgsqlParameter("valid_for", NpgsqlDbType.Jsonb) { Value = updated.ValidFor is not null ? JsonSerializer.Serialize(updated.ValidFor, VulnExplorerJsonOptions.Default) : DBNull.Value });
- cmd.Parameters.AddWithValue("supersedes_decision_id", (object?)updated.SupersedesDecisionId ?? DBNull.Value);
- cmd.Parameters.AddWithValue("updated_at", updatedAt);
-
- await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
- _logger.LogDebug("Updated VEX decision {DecisionId} for tenant {TenantId}", id, tenantId);
- return updated;
- }
-
- public async Task GetAsync(string tenantId, Guid id, CancellationToken ct = default)
- {
- await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-read", ct).ConfigureAwait(false);
- return await GetInternalAsync(connection, id, ct).ConfigureAwait(false);
- }
-
- public async Task> QueryAsync(
- string tenantId, string? vulnerabilityId, string? subjectName, VexStatus? status,
- int skip, int take, CancellationToken ct = default)
- {
- await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-read", ct).ConfigureAwait(false);
-
- var sql = "SELECT id, vulnerability_id, subject, status, justification_type, justification_text, evidence_refs, scope, valid_for, attestation_ref, signed_override, supersedes_decision_id, created_by, created_at, updated_at FROM findings.vex_decisions WHERE 1=1";
- var parameters = new List();
-
- if (vulnerabilityId is not null)
- {
- sql += " AND vulnerability_id = @vulnerability_id";
- parameters.Add(new NpgsqlParameter("vulnerability_id", vulnerabilityId));
- }
-
- if (subjectName is not null)
- {
- sql += " AND subject->>'name' ILIKE @subject_name";
- parameters.Add(new NpgsqlParameter("subject_name", $"%{subjectName}%"));
- }
-
- if (status is not null)
- {
- sql += " AND status = @status";
- parameters.Add(new NpgsqlParameter("status", status.Value.ToString()));
- }
-
- sql += " ORDER BY created_at DESC, id ASC LIMIT @take OFFSET @skip";
- parameters.Add(new NpgsqlParameter("take", take));
- parameters.Add(new NpgsqlParameter("skip", skip));
-
- await using var cmd = new NpgsqlCommand(sql, connection);
- cmd.Parameters.AddRange(parameters.ToArray());
-
- var results = new List();
- await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
- while (await reader.ReadAsync(ct).ConfigureAwait(false))
- {
- results.Add(MapDecision(reader));
- }
-
- return results;
- }
-
- public async Task CountAsync(string tenantId, CancellationToken ct = default)
- {
- await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "vex-read", ct).ConfigureAwait(false);
-
- const string sql = "SELECT COUNT(*) FROM findings.vex_decisions";
- await using var cmd = new NpgsqlCommand(sql, connection);
- var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
- return Convert.ToInt32(result);
- }
-
- private static async Task GetInternalAsync(NpgsqlConnection connection, Guid id, CancellationToken ct)
- {
- const string sql = """
- SELECT id, vulnerability_id, subject, status, justification_type, justification_text,
- evidence_refs, scope, valid_for, attestation_ref, signed_override,
- supersedes_decision_id, created_by, created_at, updated_at
- FROM findings.vex_decisions
- WHERE id = @id
- """;
-
- await using var cmd = new NpgsqlCommand(sql, connection);
- cmd.Parameters.AddWithValue("id", id);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
- return await reader.ReadAsync(ct).ConfigureAwait(false) ? MapDecision(reader) : null;
- }
-
- private static VexDecisionDto MapDecision(NpgsqlDataReader reader)
- {
- var statusStr = reader.GetString(reader.GetOrdinal("status"));
- var justTypeStr = reader.GetString(reader.GetOrdinal("justification_type"));
-
- return new VexDecisionDto(
- Id: reader.GetGuid(reader.GetOrdinal("id")),
- VulnerabilityId: reader.GetString(reader.GetOrdinal("vulnerability_id")),
- Subject: JsonSerializer.Deserialize(reader.GetString(reader.GetOrdinal("subject")), VulnExplorerJsonOptions.Default)!,
- Status: Enum.TryParse(statusStr, ignoreCase: true, out var s) ? s : VexStatus.NotAffected,
- JustificationType: Enum.TryParse(justTypeStr, ignoreCase: true, out var j) ? j : VexJustificationType.Other,
- JustificationText: reader.IsDBNull(reader.GetOrdinal("justification_text")) ? null : reader.GetString(reader.GetOrdinal("justification_text")),
- EvidenceRefs: reader.IsDBNull(reader.GetOrdinal("evidence_refs")) ? null : JsonSerializer.Deserialize>(reader.GetString(reader.GetOrdinal("evidence_refs")), VulnExplorerJsonOptions.Default),
- Scope: reader.IsDBNull(reader.GetOrdinal("scope")) ? null : JsonSerializer.Deserialize(reader.GetString(reader.GetOrdinal("scope")), VulnExplorerJsonOptions.Default),
- ValidFor: reader.IsDBNull(reader.GetOrdinal("valid_for")) ? null : JsonSerializer.Deserialize(reader.GetString(reader.GetOrdinal("valid_for")), VulnExplorerJsonOptions.Default),
- AttestationRef: reader.IsDBNull(reader.GetOrdinal("attestation_ref")) ? null : JsonSerializer.Deserialize(reader.GetString(reader.GetOrdinal("attestation_ref")), VulnExplorerJsonOptions.Default),
- SignedOverride: reader.IsDBNull(reader.GetOrdinal("signed_override")) ? null : JsonSerializer.Deserialize(reader.GetString(reader.GetOrdinal("signed_override")), VulnExplorerJsonOptions.Default),
- SupersedesDecisionId: reader.IsDBNull(reader.GetOrdinal("supersedes_decision_id")) ? null : reader.GetGuid(reader.GetOrdinal("supersedes_decision_id")),
- CreatedBy: JsonSerializer.Deserialize(reader.GetString(reader.GetOrdinal("created_by")), VulnExplorerJsonOptions.Default)!,
- CreatedAt: reader.GetFieldValue(reader.GetOrdinal("created_at")),
- UpdatedAt: reader.IsDBNull(reader.GetOrdinal("updated_at")) ? null : reader.GetFieldValue(reader.GetOrdinal("updated_at"))
- );
- }
-}
-
-// ============================================================================
-// PostgresFixVerificationRepository
-// ============================================================================
-
-public sealed class PostgresFixVerificationRepository : IFixVerificationRepository
-{
- private readonly LedgerDataSource _dataSource;
- private readonly ILogger _logger;
-
- public PostgresFixVerificationRepository(LedgerDataSource dataSource, ILogger logger)
- {
- _dataSource = dataSource;
- _logger = logger;
- }
-
- public async Task CreateAsync(string tenantId, FixVerificationRecord record, CancellationToken ct = default)
- {
- await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "fix-write", ct).ConfigureAwait(false);
-
- const string sql = """
- INSERT INTO findings.fix_verifications
- (cve_id, tenant_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at)
- VALUES
- (@cve_id, @tenant_id, @component_purl, @artifact_digest, @verdict, @transitions::jsonb, @created_at, @updated_at)
- """;
-
- await using var cmd = new NpgsqlCommand(sql, connection);
- cmd.Parameters.AddWithValue("cve_id", record.CveId);
- cmd.Parameters.AddWithValue("tenant_id", tenantId);
- cmd.Parameters.AddWithValue("component_purl", record.ComponentPurl);
- cmd.Parameters.AddWithValue("artifact_digest", (object?)record.ArtifactDigest ?? DBNull.Value);
- cmd.Parameters.AddWithValue("verdict", record.Verdict);
- cmd.Parameters.Add(new NpgsqlParameter("transitions", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(record.Transitions, VulnExplorerJsonOptions.Default) });
- cmd.Parameters.AddWithValue("created_at", record.CreatedAt);
- cmd.Parameters.AddWithValue("updated_at", record.UpdatedAt);
-
- await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
- _logger.LogDebug("Created fix verification for CVE {CveId} in tenant {TenantId}", record.CveId, tenantId);
- return record;
- }
-
- public async Task UpdateAsync(string tenantId, string cveId, string verdict, FixVerificationTransition transition, CancellationToken ct = default)
- {
- await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "fix-write", ct).ConfigureAwait(false);
-
- // First fetch existing
- var existing = await GetInternalAsync(connection, cveId, ct).ConfigureAwait(false);
- if (existing is null)
- {
- return null;
- }
-
- var transitions = existing.Transitions.ToList();
- transitions.Add(transition);
-
- var updated = existing with
- {
- Verdict = verdict,
- Transitions = transitions.ToArray(),
- UpdatedAt = transition.ChangedAt
- };
-
- const string sql = """
- UPDATE findings.fix_verifications SET
- verdict = @verdict,
- transitions = @transitions::jsonb,
- updated_at = @updated_at
- WHERE cve_id = @cve_id
- """;
-
- await using var cmd = new NpgsqlCommand(sql, connection);
- cmd.Parameters.AddWithValue("cve_id", cveId);
- cmd.Parameters.AddWithValue("verdict", verdict);
- cmd.Parameters.Add(new NpgsqlParameter("transitions", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(transitions, VulnExplorerJsonOptions.Default) });
- cmd.Parameters.AddWithValue("updated_at", transition.ChangedAt);
-
- await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
- _logger.LogDebug("Updated fix verification {CveId} to verdict {Verdict} in tenant {TenantId}", cveId, verdict, tenantId);
- return updated;
- }
-
- private static async Task GetInternalAsync(NpgsqlConnection connection, string cveId, CancellationToken ct)
- {
- const string sql = """
- SELECT cve_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at
- FROM findings.fix_verifications
- WHERE cve_id = @cve_id
- """;
-
- await using var cmd = new NpgsqlCommand(sql, connection);
- cmd.Parameters.AddWithValue("cve_id", cveId);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
- if (!await reader.ReadAsync(ct).ConfigureAwait(false))
- {
- return null;
- }
-
- return new FixVerificationRecord(
- CveId: reader.GetString(reader.GetOrdinal("cve_id")),
- ComponentPurl: reader.GetString(reader.GetOrdinal("component_purl")),
- ArtifactDigest: reader.IsDBNull(reader.GetOrdinal("artifact_digest")) ? null : reader.GetString(reader.GetOrdinal("artifact_digest")),
- Verdict: reader.GetString(reader.GetOrdinal("verdict")),
- Transitions: JsonSerializer.Deserialize>(reader.GetString(reader.GetOrdinal("transitions")), VulnExplorerJsonOptions.Default) ?? [],
- CreatedAt: reader.GetFieldValue(reader.GetOrdinal("created_at")),
- UpdatedAt: reader.GetFieldValue(reader.GetOrdinal("updated_at"))
- );
- }
-}
-
-// ============================================================================
-// PostgresAuditBundleRepository
-// ============================================================================
-
-public sealed class PostgresAuditBundleRepository : IAuditBundleRepository
-{
- private readonly LedgerDataSource _dataSource;
- private readonly ILogger _logger;
-
- public PostgresAuditBundleRepository(LedgerDataSource dataSource, ILogger logger)
- {
- _dataSource = dataSource;
- _logger = logger;
- }
-
- public async Task CreateAsync(string tenantId, AuditBundleResponse bundle, IReadOnlyList decisionIds, CancellationToken ct = default)
- {
- await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "audit-write", ct).ConfigureAwait(false);
-
- const string sql = """
- INSERT INTO findings.audit_bundles
- (bundle_id, tenant_id, decision_ids, decisions, evidence_refs, created_at)
- VALUES
- (@bundle_id, @tenant_id, @decision_ids::jsonb, @decisions::jsonb, @evidence_refs::jsonb, @created_at)
- """;
-
- await using var cmd = new NpgsqlCommand(sql, connection);
- cmd.Parameters.AddWithValue("bundle_id", bundle.BundleId);
- cmd.Parameters.AddWithValue("tenant_id", tenantId);
- cmd.Parameters.Add(new NpgsqlParameter("decision_ids", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(decisionIds, VulnExplorerJsonOptions.Default) });
- cmd.Parameters.Add(new NpgsqlParameter("decisions", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(bundle.Decisions, VulnExplorerJsonOptions.Default) });
- cmd.Parameters.Add(new NpgsqlParameter("evidence_refs", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(bundle.EvidenceRefs, VulnExplorerJsonOptions.Default) });
- cmd.Parameters.AddWithValue("created_at", bundle.CreatedAt);
-
- await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
- _logger.LogDebug("Created audit bundle {BundleId} for tenant {TenantId}", bundle.BundleId, tenantId);
- return bundle;
- }
-
- public async Task NextBundleIdAsync(string tenantId, CancellationToken ct = default)
- {
- await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "audit-write", ct).ConfigureAwait(false);
-
- const string sql = "SELECT nextval('findings.audit_bundle_seq')";
- await using var cmd = new NpgsqlCommand(sql, connection);
- var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
- return $"bundle-{Convert.ToInt64(result):D6}";
- }
-}
diff --git a/src/Findings/StellaOps.Findings.Ledger/migrations/010_vex_fix_audit_tables.sql b/src/Findings/StellaOps.Findings.Ledger/migrations/010_vex_fix_audit_tables.sql
index fa49f7d3f..53a78ecff 100644
--- a/src/Findings/StellaOps.Findings.Ledger/migrations/010_vex_fix_audit_tables.sql
+++ b/src/Findings/StellaOps.Findings.Ledger/migrations/010_vex_fix_audit_tables.sql
@@ -1,120 +1,95 @@
--- 010_vex_fix_audit_tables.sql
--- Create Postgres-backed tables for VulnExplorer adapters merged into Findings Ledger.
--- Replaces ConcurrentDictionary in-memory stores (VXLM-005 gap fix).
-
-SET search_path TO findings, public;
+-- Migration: 010_vex_fix_audit_tables
+-- Description: Creates VulnExplorer persistence tables for VEX decisions,
+-- fix verifications, and audit bundles (replaces in-memory stores).
+-- Date: 2026-04-08
BEGIN;
--- ============================================
--- 1. VEX Decisions table
--- ============================================
+-- ────────────────────────────────────────────────────────────────────────────
+-- 1. VEX Decisions
+-- ────────────────────────────────────────────────────────────────────────────
-CREATE TABLE IF NOT EXISTS findings.vex_decisions (
- id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
- tenant_id TEXT NOT NULL,
- vulnerability_id TEXT NOT NULL,
- subject JSONB NOT NULL,
- status TEXT NOT NULL,
- justification_type TEXT NOT NULL,
- justification_text TEXT,
- evidence_refs JSONB,
- scope JSONB,
- valid_for JSONB,
- attestation_ref JSONB,
- signed_override JSONB,
- supersedes_decision_id UUID,
- created_by JSONB NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ
-);
+CREATE TABLE IF NOT EXISTS vex_decisions (
+ id UUID NOT NULL,
+ tenant_id TEXT NOT NULL,
+ vulnerability_id TEXT NOT NULL,
+ subject_type TEXT NOT NULL,
+ subject_name TEXT NOT NULL,
+ subject_digest JSONB NOT NULL DEFAULT '{}'::JSONB,
+ subject_sbom_node_id TEXT,
+ status TEXT NOT NULL,
+ justification_type TEXT NOT NULL,
+ justification_text TEXT,
+ evidence_refs JSONB,
+ scope_environments TEXT[],
+ scope_projects TEXT[],
+ valid_not_before TIMESTAMPTZ,
+ valid_not_after TIMESTAMPTZ,
+ attestation_ref_id TEXT,
+ attestation_ref_digest JSONB,
+ attestation_ref_storage TEXT,
+ signed_override JSONB,
+ supersedes_decision_id UUID,
+ created_by_id TEXT NOT NULL,
+ created_by_name TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ,
+ CONSTRAINT pk_vex_decisions PRIMARY KEY (tenant_id, id)
+) PARTITION BY LIST (tenant_id);
-CREATE INDEX IF NOT EXISTS idx_vex_decisions_tenant
- ON findings.vex_decisions (tenant_id);
+CREATE TABLE IF NOT EXISTS vex_decisions_default PARTITION OF vex_decisions DEFAULT;
-CREATE INDEX IF NOT EXISTS idx_vex_decisions_vuln
- ON findings.vex_decisions (tenant_id, vulnerability_id);
+CREATE INDEX IF NOT EXISTS ix_vex_decisions_vuln
+ ON vex_decisions (tenant_id, vulnerability_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS ix_vex_decisions_status
+ ON vex_decisions (tenant_id, status);
+CREATE INDEX IF NOT EXISTS ix_vex_decisions_subject
+ ON vex_decisions (tenant_id, subject_name);
-CREATE INDEX IF NOT EXISTS idx_vex_decisions_created
- ON findings.vex_decisions (tenant_id, created_at DESC);
+-- ────────────────────────────────────────────────────────────────────────────
+-- 2. Fix Verifications
+-- ────────────────────────────────────────────────────────────────────────────
--- ============================================
--- 2. Fix Verifications table
--- ============================================
+CREATE TABLE IF NOT EXISTS fix_verifications (
+ id UUID NOT NULL DEFAULT gen_random_uuid(),
+ tenant_id TEXT NOT NULL,
+ cve_id TEXT NOT NULL,
+ component_purl TEXT NOT NULL,
+ artifact_digest TEXT,
+ verdict TEXT NOT NULL DEFAULT 'pending',
+ transitions JSONB NOT NULL DEFAULT '[]'::JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT pk_fix_verifications PRIMARY KEY (tenant_id, id),
+ CONSTRAINT uq_fix_verifications_cve UNIQUE (tenant_id, cve_id)
+) PARTITION BY LIST (tenant_id);
-CREATE TABLE IF NOT EXISTS findings.fix_verifications (
- cve_id TEXT NOT NULL,
- tenant_id TEXT NOT NULL,
- component_purl TEXT NOT NULL,
- artifact_digest TEXT,
- verdict TEXT NOT NULL DEFAULT 'pending',
- transitions JSONB NOT NULL DEFAULT '[]'::jsonb,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- PRIMARY KEY (tenant_id, cve_id)
-);
+CREATE TABLE IF NOT EXISTS fix_verifications_default PARTITION OF fix_verifications DEFAULT;
-CREATE INDEX IF NOT EXISTS idx_fix_verifications_tenant
- ON findings.fix_verifications (tenant_id);
+CREATE INDEX IF NOT EXISTS ix_fix_verifications_cve
+ ON fix_verifications (tenant_id, cve_id);
+CREATE INDEX IF NOT EXISTS ix_fix_verifications_verdict
+ ON fix_verifications (tenant_id, verdict);
--- ============================================
--- 3. Audit Bundles table
--- ============================================
+-- ────────────────────────────────────────────────────────────────────────────
+-- 3. Audit Bundles
+-- ────────────────────────────────────────────────────────────────────────────
-CREATE TABLE IF NOT EXISTS findings.audit_bundles (
- bundle_id TEXT NOT NULL,
- tenant_id TEXT NOT NULL,
- decision_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
- decisions JSONB NOT NULL DEFAULT '[]'::jsonb,
- evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- PRIMARY KEY (tenant_id, bundle_id)
-);
+CREATE TABLE IF NOT EXISTS audit_bundles (
+ id UUID NOT NULL DEFAULT gen_random_uuid(),
+ tenant_id TEXT NOT NULL,
+ bundle_id TEXT NOT NULL,
+ decision_ids UUID[] NOT NULL,
+ attestation_digest TEXT,
+ evidence_refs TEXT[] NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT pk_audit_bundles PRIMARY KEY (tenant_id, id),
+ CONSTRAINT uq_audit_bundles_bundle_id UNIQUE (tenant_id, bundle_id)
+) PARTITION BY LIST (tenant_id);
-CREATE INDEX IF NOT EXISTS idx_audit_bundles_tenant
- ON findings.audit_bundles (tenant_id);
+CREATE TABLE IF NOT EXISTS audit_bundles_default PARTITION OF audit_bundles DEFAULT;
--- ============================================
--- 4. Enable RLS on new tables
--- ============================================
-
-ALTER TABLE findings.vex_decisions ENABLE ROW LEVEL SECURITY;
-ALTER TABLE findings.vex_decisions FORCE ROW LEVEL SECURITY;
-
-DROP POLICY IF EXISTS vex_decisions_tenant_isolation ON findings.vex_decisions;
-CREATE POLICY vex_decisions_tenant_isolation
- ON findings.vex_decisions
- FOR ALL
- USING (tenant_id = findings_ledger_app.require_current_tenant())
- WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-
-ALTER TABLE findings.fix_verifications ENABLE ROW LEVEL SECURITY;
-ALTER TABLE findings.fix_verifications FORCE ROW LEVEL SECURITY;
-
-DROP POLICY IF EXISTS fix_verifications_tenant_isolation ON findings.fix_verifications;
-CREATE POLICY fix_verifications_tenant_isolation
- ON findings.fix_verifications
- FOR ALL
- USING (tenant_id = findings_ledger_app.require_current_tenant())
- WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-
-ALTER TABLE findings.audit_bundles ENABLE ROW LEVEL SECURITY;
-ALTER TABLE findings.audit_bundles FORCE ROW LEVEL SECURITY;
-
-DROP POLICY IF EXISTS audit_bundles_tenant_isolation ON findings.audit_bundles;
-CREATE POLICY audit_bundles_tenant_isolation
- ON findings.audit_bundles
- FOR ALL
- USING (tenant_id = findings_ledger_app.require_current_tenant())
- WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
-
--- ============================================
--- 5. Sequence for audit bundle IDs
--- ============================================
-
-CREATE SEQUENCE IF NOT EXISTS findings.audit_bundle_seq
- START WITH 1
- INCREMENT BY 1
- NO CYCLE;
+CREATE INDEX IF NOT EXISTS ix_audit_bundles_created
+ ON audit_bundles (tenant_id, created_at DESC);
COMMIT;
diff --git a/src/Findings/StellaOps.VulnExplorer.Api/Data/TriageWorkflowStores.cs b/src/Findings/StellaOps.VulnExplorer.Api/Data/TriageWorkflowStores.cs
index 01d4beea0..1a2753b68 100644
--- a/src/Findings/StellaOps.VulnExplorer.Api/Data/TriageWorkflowStores.cs
+++ b/src/Findings/StellaOps.VulnExplorer.Api/Data/TriageWorkflowStores.cs
@@ -1,4 +1,7 @@
using System.Collections.Concurrent;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Npgsql;
using StellaOps.VulnExplorer.Api.Models;
using StellaOps.VulnExplorer.WebService.Contracts;
@@ -36,19 +39,40 @@ public sealed record FixVerificationRecord(
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
+// ────────────────────────────────────────────────────────────────────────────
+// FixVerificationStore (Postgres-backed, with in-memory fallback)
+// ────────────────────────────────────────────────────────────────────────────
+
public sealed class FixVerificationStore
{
- private readonly ConcurrentDictionary records = new(StringComparer.OrdinalIgnoreCase);
- private readonly TimeProvider timeProvider;
+ private readonly ConcurrentDictionary? _memoryFallback;
+ private readonly NpgsqlDataSource? _dataSource;
+ private readonly ILogger? _logger;
+ private readonly TimeProvider _timeProvider;
+ private bool UsePostgres => _dataSource is not null;
+
+ /// Production constructor: Postgres-backed.
+ public FixVerificationStore(
+ NpgsqlDataSource dataSource,
+ ILogger logger,
+ TimeProvider? timeProvider = null)
+ {
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ /// Test/offline constructor: in-memory fallback.
public FixVerificationStore(TimeProvider? timeProvider = null)
{
- this.timeProvider = timeProvider ?? TimeProvider.System;
+ _memoryFallback = new(StringComparer.OrdinalIgnoreCase);
+ _timeProvider = timeProvider ?? TimeProvider.System;
}
public FixVerificationRecord Create(CreateFixVerificationRequest request)
{
- var now = timeProvider.GetUtcNow();
+ var now = _timeProvider.GetUtcNow();
var created = new FixVerificationRecord(
CveId: request.CveId,
ComponentPurl: request.ComponentPurl,
@@ -58,18 +82,46 @@ public sealed class FixVerificationStore
CreatedAt: now,
UpdatedAt: now);
- records[request.CveId] = created;
+ _memoryFallback?.TryAdd(request.CveId, created);
+ return created;
+ }
+
+ public async Task CreateAsync(
+ string tenantId, CreateFixVerificationRequest request, CancellationToken ct = default)
+ {
+ var created = Create(request);
+ if (!UsePostgres) return created;
+
+ const string sql = """
+ INSERT INTO fix_verifications (tenant_id, cve_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at)
+ VALUES (@tenantId, @cveId, @purl, @digest, 'pending', '[]'::jsonb, @now, @now)
+ ON CONFLICT (tenant_id, cve_id) DO UPDATE SET
+ component_purl = EXCLUDED.component_purl,
+ artifact_digest = EXCLUDED.artifact_digest,
+ verdict = 'pending',
+ transitions = '[]'::jsonb,
+ updated_at = EXCLUDED.updated_at
+ """;
+
+ await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
+ await using var cmd = new NpgsqlCommand(sql, conn);
+ cmd.Parameters.AddWithValue("tenantId", tenantId);
+ cmd.Parameters.AddWithValue("cveId", request.CveId);
+ cmd.Parameters.AddWithValue("purl", request.ComponentPurl);
+ cmd.Parameters.AddWithValue("digest", (object?)request.ArtifactDigest ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("now", created.CreatedAt);
+
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ _logger?.LogDebug("Created fix verification for CVE {CveId}, tenant {Tenant}", request.CveId, tenantId);
return created;
}
public FixVerificationRecord? Update(string cveId, string verdict)
{
- if (!records.TryGetValue(cveId, out var existing))
- {
- return null;
- }
+ if (_memoryFallback is null) return null;
+ if (!_memoryFallback.TryGetValue(cveId, out var existing)) return null;
- var now = timeProvider.GetUtcNow();
+ var now = _timeProvider.GetUtcNow();
var transitions = existing.Transitions.ToList();
transitions.Add(new FixVerificationTransition(existing.Verdict, verdict, now));
@@ -80,25 +132,108 @@ public sealed class FixVerificationStore
UpdatedAt = now
};
- records[cveId] = updated;
+ _memoryFallback[cveId] = updated;
return updated;
}
+
+ public async Task UpdateAsync(
+ string tenantId, string cveId, string verdict, CancellationToken ct = default)
+ {
+ if (!UsePostgres)
+ return Update(cveId, verdict);
+
+ var now = _timeProvider.GetUtcNow();
+
+ // Read existing to build transitions
+ const string readSql = """
+ SELECT cve_id, component_purl, artifact_digest, verdict, transitions, created_at, updated_at
+ FROM fix_verifications WHERE tenant_id = @tenantId AND cve_id = @cveId
+ """;
+
+ await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
+ await using var readCmd = new NpgsqlCommand(readSql, conn);
+ readCmd.Parameters.AddWithValue("tenantId", tenantId);
+ readCmd.Parameters.AddWithValue("cveId", cveId);
+
+ await using var reader = await readCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ if (!await reader.ReadAsync(ct).ConfigureAwait(false)) return null;
+
+ var existingVerdict = reader.GetString(reader.GetOrdinal("verdict"));
+ var transitionsJson = reader.GetString(reader.GetOrdinal("transitions"));
+ var transitions = JsonSerializer.Deserialize>(
+ transitionsJson, VexJsonDefaults.Options) ?? [];
+ var digestOrd = reader.GetOrdinal("artifact_digest");
+ var existing = new FixVerificationRecord(
+ CveId: reader.GetString(reader.GetOrdinal("cve_id")),
+ ComponentPurl: reader.GetString(reader.GetOrdinal("component_purl")),
+ ArtifactDigest: reader.IsDBNull(digestOrd) ? null : reader.GetString(digestOrd),
+ Verdict: existingVerdict,
+ Transitions: transitions,
+ CreatedAt: reader.GetFieldValue(reader.GetOrdinal("created_at")),
+ UpdatedAt: reader.GetFieldValue(reader.GetOrdinal("updated_at")));
+ await reader.CloseAsync().ConfigureAwait(false);
+
+ transitions.Add(new FixVerificationTransition(existingVerdict, verdict, now));
+ var newTransitionsJson = JsonSerializer.Serialize(transitions, VexJsonDefaults.Options);
+
+ const string updateSql = """
+ UPDATE fix_verifications SET verdict = @verdict, transitions = @transitions::jsonb, updated_at = @now
+ WHERE tenant_id = @tenantId AND cve_id = @cveId
+ """;
+
+ await using var updateCmd = new NpgsqlCommand(updateSql, conn);
+ updateCmd.Parameters.AddWithValue("tenantId", tenantId);
+ updateCmd.Parameters.AddWithValue("cveId", cveId);
+ updateCmd.Parameters.AddWithValue("verdict", verdict);
+ updateCmd.Parameters.AddWithValue("transitions", newTransitionsJson);
+ updateCmd.Parameters.AddWithValue("now", now);
+ await updateCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+
+ _logger?.LogDebug("Updated fix verification {CveId} -> {Verdict}", cveId, verdict);
+
+ return existing with
+ {
+ Verdict = verdict,
+ Transitions = transitions.ToArray(),
+ UpdatedAt = now
+ };
+ }
}
+// ────────────────────────────────────────────────────────────────────────────
+// AuditBundleStore (Postgres-backed, with in-memory fallback)
+// ────────────────────────────────────────────────────────────────────────────
+
public sealed class AuditBundleStore
{
- private int sequence;
- private readonly TimeProvider timeProvider;
+ private int _sequence;
+ private readonly NpgsqlDataSource? _dataSource;
+ private readonly ILogger? _logger;
+ private readonly TimeProvider _timeProvider;
+ private bool UsePostgres => _dataSource is not null;
+
+ /// Production constructor: Postgres-backed.
+ public AuditBundleStore(
+ NpgsqlDataSource dataSource,
+ ILogger logger,
+ TimeProvider? timeProvider = null)
+ {
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ /// Test/offline constructor: in-memory fallback.
public AuditBundleStore(TimeProvider? timeProvider = null)
{
- this.timeProvider = timeProvider ?? TimeProvider.System;
+ _timeProvider = timeProvider ?? TimeProvider.System;
}
public AuditBundleResponse Create(string tenant, IReadOnlyList decisions)
{
- var next = Interlocked.Increment(ref sequence);
- var createdAt = timeProvider.GetUtcNow();
+ var next = Interlocked.Increment(ref _sequence);
+ var createdAt = _timeProvider.GetUtcNow();
var evidenceRefs = decisions
.SelectMany(x => x.EvidenceRefs ?? Array.Empty())
.Select(x => x.Url.ToString())
@@ -113,8 +248,41 @@ public sealed class AuditBundleStore
Decisions: decisions.OrderBy(x => x.Id).ToArray(),
EvidenceRefs: evidenceRefs);
}
+
+ public async Task CreateAsync(
+ string tenantId,
+ IReadOnlyList decisions,
+ CancellationToken ct = default)
+ {
+ var bundle = Create(tenantId, decisions);
+ if (!UsePostgres) return bundle;
+
+ var decisionIds = decisions.Select(d => d.Id).OrderBy(x => x).ToArray();
+
+ const string sql = """
+ INSERT INTO audit_bundles (tenant_id, bundle_id, decision_ids, evidence_refs, created_at)
+ VALUES (@tenantId, @bundleId, @decisionIds, @evidenceRefs, @createdAt)
+ """;
+
+ await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
+ await using var cmd = new NpgsqlCommand(sql, conn);
+ cmd.Parameters.AddWithValue("tenantId", tenantId);
+ cmd.Parameters.AddWithValue("bundleId", bundle.BundleId);
+ cmd.Parameters.AddWithValue("decisionIds", decisionIds);
+ cmd.Parameters.AddWithValue("evidenceRefs", bundle.EvidenceRefs.ToArray());
+ cmd.Parameters.AddWithValue("createdAt", bundle.CreatedAt);
+
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ _logger?.LogDebug("Created audit bundle {BundleId} with {Count} decisions", bundle.BundleId, decisions.Count);
+
+ return bundle;
+ }
}
+// ────────────────────────────────────────────────────────────────────────────
+// EvidenceSubgraphStore (unchanged -- still builds synthetic response)
+// ────────────────────────────────────────────────────────────────────────────
+
public sealed class EvidenceSubgraphStore
{
private readonly TimeProvider timeProvider;
diff --git a/src/Findings/StellaOps.VulnExplorer.Api/Data/VexDecisionStore.cs b/src/Findings/StellaOps.VulnExplorer.Api/Data/VexDecisionStore.cs
index a85fd029f..480ee7fc3 100644
--- a/src/Findings/StellaOps.VulnExplorer.Api/Data/VexDecisionStore.cs
+++ b/src/Findings/StellaOps.VulnExplorer.Api/Data/VexDecisionStore.cs
@@ -1,31 +1,80 @@
+using Microsoft.Extensions.Logging;
+using Npgsql;
+using NpgsqlTypes;
using StellaOps.Determinism;
using StellaOps.VulnExplorer.Api.Models;
using System.Collections.Concurrent;
+using System.Text.Json;
+using System.Text.Json.Serialization;
namespace StellaOps.VulnExplorer.Api.Data;
+internal static class VexJsonDefaults
+{
+ internal static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
+ };
+}
+
///
-/// In-memory VEX decision store for development/testing.
-/// Production would use PostgreSQL repository.
+/// Postgres-backed VEX decision store.
+/// Falls back to in-memory ConcurrentDictionary when no NpgsqlDataSource is registered
+/// (e.g. in unit tests).
///
public sealed class VexDecisionStore
{
- private readonly ConcurrentDictionary _decisions = new();
+ // ── fallback in-memory path (tests only) ───────────────────────────
+ private readonly ConcurrentDictionary? _memoryFallback;
+
+ // ── postgres path ──────────────────────────────────────────────────
+ private readonly NpgsqlDataSource? _dataSource;
+ private readonly ILogger? _logger;
+
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly IVexOverrideAttestorClient? _attestorClient;
+ ///
+ /// Production constructor: Postgres-backed.
+ ///
+ public VexDecisionStore(
+ NpgsqlDataSource dataSource,
+ ILogger logger,
+ TimeProvider? timeProvider = null,
+ IGuidProvider? guidProvider = null,
+ IVexOverrideAttestorClient? attestorClient = null)
+ {
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ _guidProvider = guidProvider ?? SystemGuidProvider.Instance;
+ _attestorClient = attestorClient;
+ }
+
+ ///
+ /// Test/offline constructor: in-memory fallback.
+ ///
public VexDecisionStore(
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null,
IVexOverrideAttestorClient? attestorClient = null)
{
+ _memoryFallback = new();
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_attestorClient = attestorClient;
}
+ private bool UsePostgres => _dataSource is not null;
+
+ // ════════════════════════════════════════════════════════════════════
+ // Synchronous helpers (kept for backwards-compat with existing endpoints)
+ // ════════════════════════════════════════════════════════════════════
+
public VexDecisionDto Create(CreateVexDecisionRequest request, string userId, string userDisplayName)
{
var id = _guidProvider.NewGuid();
@@ -41,23 +90,25 @@ public sealed class VexDecisionStore
EvidenceRefs: request.EvidenceRefs,
Scope: request.Scope,
ValidFor: request.ValidFor,
- AttestationRef: null, // Will be set when attestation is generated
- SignedOverride: null, // Will be set when attestation is generated (VEX-OVR-002)
+ AttestationRef: null,
+ SignedOverride: null,
SupersedesDecisionId: request.SupersedesDecisionId,
CreatedBy: new ActorRefDto(userId, userDisplayName),
CreatedAt: now,
UpdatedAt: null);
- _decisions[id] = decision;
+ if (_memoryFallback is not null)
+ {
+ _memoryFallback[id] = decision;
+ }
+
return decision;
}
public VexDecisionDto? Update(Guid id, UpdateVexDecisionRequest request)
{
- if (!_decisions.TryGetValue(id, out var existing))
- {
- return null;
- }
+ if (_memoryFallback is null) return null; // sync path not supported in PG mode
+ if (!_memoryFallback.TryGetValue(id, out var existing)) return null;
var updated = existing with
{
@@ -71,12 +122,16 @@ public sealed class VexDecisionStore
UpdatedAt = _timeProvider.GetUtcNow()
};
- _decisions[id] = updated;
+ _memoryFallback[id] = updated;
return updated;
}
- public VexDecisionDto? Get(Guid id) =>
- _decisions.TryGetValue(id, out var decision) ? decision : null;
+ public VexDecisionDto? Get(Guid id)
+ {
+ if (_memoryFallback is not null)
+ return _memoryFallback.TryGetValue(id, out var decision) ? decision : null;
+ return null;
+ }
public IReadOnlyList Query(
string? vulnerabilityId = null,
@@ -85,24 +140,16 @@ public sealed class VexDecisionStore
int skip = 0,
int take = 50)
{
- IEnumerable query = _decisions.Values;
+ if (_memoryFallback is null) return [];
+ IEnumerable query = _memoryFallback.Values;
if (vulnerabilityId is not null)
- {
query = query.Where(d => string.Equals(d.VulnerabilityId, vulnerabilityId, StringComparison.OrdinalIgnoreCase));
- }
-
if (subjectName is not null)
- {
query = query.Where(d => d.Subject.Name.Contains(subjectName, StringComparison.OrdinalIgnoreCase));
- }
-
if (status is not null)
- {
query = query.Where(d => d.Status == status);
- }
- // Deterministic ordering: createdAt desc, id asc
return query
.OrderByDescending(d => d.CreatedAt)
.ThenBy(d => d.Id)
@@ -111,18 +158,195 @@ public sealed class VexDecisionStore
.ToArray();
}
- public int Count() => _decisions.Count;
+ public int Count() => _memoryFallback?.Count ?? 0;
+
+ // ════════════════════════════════════════════════════════════════════
+ // Async Postgres-backed methods
+ // ════════════════════════════════════════════════════════════════════
+
+ public async Task CreateAsync(
+ string tenantId,
+ CreateVexDecisionRequest request,
+ string userId,
+ string userDisplayName,
+ CancellationToken ct = default)
+ {
+ var decision = Create(request, userId, userDisplayName);
+ if (UsePostgres)
+ {
+ await InsertRowAsync(tenantId, decision, ct).ConfigureAwait(false);
+ }
+ return decision;
+ }
+
+ public async Task UpdateAsync(
+ string tenantId,
+ Guid id,
+ UpdateVexDecisionRequest request,
+ CancellationToken ct = default)
+ {
+ if (!UsePostgres)
+ return Update(id, request);
+
+ var existing = await GetAsync(tenantId, id, ct).ConfigureAwait(false);
+ if (existing is null) return null;
+
+ var now = _timeProvider.GetUtcNow();
+ var updated = existing with
+ {
+ Status = request.Status ?? existing.Status,
+ JustificationType = request.JustificationType ?? existing.JustificationType,
+ JustificationText = request.JustificationText ?? existing.JustificationText,
+ EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
+ Scope = request.Scope ?? existing.Scope,
+ ValidFor = request.ValidFor ?? existing.ValidFor,
+ SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
+ UpdatedAt = now
+ };
+
+ const string sql = """
+ UPDATE vex_decisions SET
+ status = @status,
+ justification_type = @justificationType,
+ justification_text = @justificationText,
+ evidence_refs = @evidenceRefs,
+ scope_environments = @scopeEnvs,
+ scope_projects = @scopeProjects,
+ valid_not_before = @validNotBefore,
+ valid_not_after = @validNotAfter,
+ supersedes_decision_id = @supersedesId,
+ updated_at = @updatedAt
+ WHERE tenant_id = @tenantId AND id = @id
+ """;
+
+ await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
+ await using var cmd = new NpgsqlCommand(sql, conn);
+ cmd.Parameters.AddWithValue("tenantId", tenantId);
+ cmd.Parameters.AddWithValue("id", id);
+ cmd.Parameters.AddWithValue("status", updated.Status.ToString());
+ cmd.Parameters.AddWithValue("justificationType", updated.JustificationType.ToString());
+ cmd.Parameters.AddWithValue("justificationText", (object?)updated.JustificationText ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("evidenceRefs", NpgsqlDbType.Jsonb,
+ updated.EvidenceRefs is not null
+ ? JsonSerializer.Serialize(updated.EvidenceRefs, VexJsonDefaults.Options)
+ : (object)DBNull.Value);
+ cmd.Parameters.AddWithValue("scopeEnvs",
+ (object?)updated.Scope?.Environments?.ToArray() ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("scopeProjects",
+ (object?)updated.Scope?.Projects?.ToArray() ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("validNotBefore",
+ (object?)updated.ValidFor?.NotBefore ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("validNotAfter",
+ (object?)updated.ValidFor?.NotAfter ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("supersedesId",
+ (object?)updated.SupersedesDecisionId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("updatedAt", now);
+
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ return updated;
+ }
+
+ public async Task GetAsync(
+ string tenantId, Guid id, CancellationToken ct = default)
+ {
+ if (!UsePostgres)
+ return Get(id);
+
+ const string sql = """
+ SELECT id, vulnerability_id, subject_type, subject_name, subject_digest,
+ subject_sbom_node_id, status, justification_type, justification_text,
+ evidence_refs, scope_environments, scope_projects,
+ valid_not_before, valid_not_after,
+ attestation_ref_id, attestation_ref_digest, attestation_ref_storage,
+ signed_override, supersedes_decision_id,
+ created_by_id, created_by_name, created_at, updated_at
+ FROM vex_decisions
+ WHERE tenant_id = @tenantId AND id = @id
+ """;
+
+ await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
+ await using var cmd = new NpgsqlCommand(sql, conn);
+ cmd.Parameters.AddWithValue("tenantId", tenantId);
+ cmd.Parameters.AddWithValue("id", id);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ return await reader.ReadAsync(ct).ConfigureAwait(false) ? MapDecision(reader) : null;
+ }
+
+ public async Task<(IReadOnlyList Items, int TotalCount)> QueryAsync(
+ string tenantId,
+ string? vulnerabilityId = null,
+ string? subjectName = null,
+ VexStatus? status = null,
+ int skip = 0,
+ int take = 50,
+ CancellationToken ct = default)
+ {
+ if (!UsePostgres)
+ {
+ var items = Query(vulnerabilityId, subjectName, status, skip, take);
+ return (items, Count());
+ }
+
+ var whereClauses = new List { "tenant_id = @tenantId" };
+ if (vulnerabilityId is not null) whereClauses.Add("vulnerability_id = @vulnId");
+ if (subjectName is not null) whereClauses.Add("subject_name ILIKE @subjectName");
+ if (status is not null) whereClauses.Add("status = @status");
+
+ var where = string.Join(" AND ", whereClauses);
+ var countSql = $"SELECT COUNT(*) FROM vex_decisions WHERE {where}";
+ var querySql = $"""
+ SELECT id, vulnerability_id, subject_type, subject_name, subject_digest,
+ subject_sbom_node_id, status, justification_type, justification_text,
+ evidence_refs, scope_environments, scope_projects,
+ valid_not_before, valid_not_after,
+ attestation_ref_id, attestation_ref_digest, attestation_ref_storage,
+ signed_override, supersedes_decision_id,
+ created_by_id, created_by_name, created_at, updated_at
+ FROM vex_decisions
+ WHERE {where}
+ ORDER BY created_at DESC, id ASC
+ OFFSET @skip LIMIT @take
+ """;
+
+ await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
+
+ await using var countCmd = new NpgsqlCommand(countSql, conn);
+ AddFilterParams(countCmd, tenantId, vulnerabilityId, subjectName, status);
+ var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(ct).ConfigureAwait(false));
+
+ await using var queryCmd = new NpgsqlCommand(querySql, conn);
+ AddFilterParams(queryCmd, tenantId, vulnerabilityId, subjectName, status);
+ queryCmd.Parameters.AddWithValue("skip", skip);
+ queryCmd.Parameters.AddWithValue("take", take);
+
+ var results = new List();
+ await using var reader = await queryCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ results.Add(MapDecision(reader));
+ }
+
+ return (results, totalCount);
+ }
// Sprint: SPRINT_20260112_004_VULN_vex_override_workflow (VEX-OVR-002)
- ///
- /// Creates a VEX decision with a signed attestation.
- ///
public async Task<(VexDecisionDto Decision, VexOverrideAttestationResult? AttestationResult)> CreateWithAttestationAsync(
CreateVexDecisionRequest request,
string userId,
string userDisplayName,
CancellationToken cancellationToken = default)
+ {
+ return await CreateWithAttestationAsync(null, request, userId, userDisplayName, cancellationToken);
+ }
+
+ public async Task<(VexDecisionDto Decision, VexOverrideAttestationResult? AttestationResult)> CreateWithAttestationAsync(
+ string? tenantId,
+ CreateVexDecisionRequest request,
+ string userId,
+ string userDisplayName,
+ CancellationToken cancellationToken = default)
{
var id = _guidProvider.NewGuid();
var now = _timeProvider.GetUtcNow();
@@ -130,7 +354,6 @@ public sealed class VexDecisionStore
VexOverrideAttestationDto? signedOverride = null;
VexOverrideAttestationResult? attestationResult = null;
- // Create attestation if requested and client is available
if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
{
var attestationRequest = new VexOverrideAttestationRequest
@@ -151,7 +374,6 @@ public sealed class VexDecisionStore
};
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
-
if (attestationResult.Success && attestationResult.Attestation is not null)
{
signedOverride = attestationResult.Attestation;
@@ -175,13 +397,15 @@ public sealed class VexDecisionStore
CreatedAt: now,
UpdatedAt: null);
- _decisions[id] = decision;
+ if (_memoryFallback is not null)
+ _memoryFallback[id] = decision;
+
+ if (UsePostgres && tenantId is not null)
+ await InsertRowAsync(tenantId, decision, cancellationToken).ConfigureAwait(false);
+
return (decision, attestationResult);
}
- ///
- /// Updates a VEX decision and optionally creates a new attestation.
- ///
public async Task<(VexDecisionDto? Decision, VexOverrideAttestationResult? AttestationResult)> UpdateWithAttestationAsync(
Guid id,
UpdateVexDecisionRequest request,
@@ -189,56 +413,238 @@ public sealed class VexDecisionStore
string userDisplayName,
CancellationToken cancellationToken = default)
{
- if (!_decisions.TryGetValue(id, out var existing))
+ // In-memory fallback path
+ if (_memoryFallback is not null)
{
- return (null, null);
- }
+ if (!_memoryFallback.TryGetValue(id, out var existing))
+ return (null, null);
- VexOverrideAttestationDto? signedOverride = existing.SignedOverride;
- VexOverrideAttestationResult? attestationResult = null;
+ VexOverrideAttestationDto? signedOverride = existing.SignedOverride;
+ VexOverrideAttestationResult? attestationResult = null;
- // Create new attestation if requested
- if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
- {
- var attestationRequest = new VexOverrideAttestationRequest
+ if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
+ {
+ var attestationRequest = new VexOverrideAttestationRequest
+ {
+ VulnerabilityId = existing.VulnerabilityId,
+ Subject = existing.Subject,
+ Status = request.Status ?? existing.Status,
+ JustificationType = request.JustificationType ?? existing.JustificationType,
+ JustificationText = request.JustificationText ?? existing.JustificationText,
+ EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
+ Scope = request.Scope ?? existing.Scope,
+ ValidFor = request.ValidFor ?? existing.ValidFor,
+ CreatedBy = new ActorRefDto(userId, userDisplayName),
+ AnchorToRekor = request.AttestationOptions.AnchorToRekor,
+ SigningKeyId = request.AttestationOptions.SigningKeyId,
+ StorageDestination = request.AttestationOptions.StorageDestination,
+ AdditionalMetadata = request.AttestationOptions.AdditionalMetadata
+ };
+
+ attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
+ if (attestationResult.Success && attestationResult.Attestation is not null)
+ signedOverride = attestationResult.Attestation;
+ }
+
+ var updated = existing with
{
- VulnerabilityId = existing.VulnerabilityId,
- Subject = existing.Subject,
Status = request.Status ?? existing.Status,
JustificationType = request.JustificationType ?? existing.JustificationType,
JustificationText = request.JustificationText ?? existing.JustificationText,
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
Scope = request.Scope ?? existing.Scope,
ValidFor = request.ValidFor ?? existing.ValidFor,
- CreatedBy = new ActorRefDto(userId, userDisplayName),
- AnchorToRekor = request.AttestationOptions.AnchorToRekor,
- SigningKeyId = request.AttestationOptions.SigningKeyId,
- StorageDestination = request.AttestationOptions.StorageDestination,
- AdditionalMetadata = request.AttestationOptions.AdditionalMetadata
+ SignedOverride = signedOverride,
+ SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
+ UpdatedAt = _timeProvider.GetUtcNow()
};
- attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
-
- if (attestationResult.Success && attestationResult.Attestation is not null)
- {
- signedOverride = attestationResult.Attestation;
- }
+ _memoryFallback[id] = updated;
+ return (updated, attestationResult);
}
- var updated = existing with
- {
- Status = request.Status ?? existing.Status,
- JustificationType = request.JustificationType ?? existing.JustificationType,
- JustificationText = request.JustificationText ?? existing.JustificationText,
- EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
- Scope = request.Scope ?? existing.Scope,
- ValidFor = request.ValidFor ?? existing.ValidFor,
- SignedOverride = signedOverride,
- SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
- UpdatedAt = _timeProvider.GetUtcNow()
- };
+ return (null, null);
+ }
- _decisions[id] = updated;
- return (updated, attestationResult);
+ // ── Private Postgres helpers ────────────────────────────────────────
+
+ private async Task InsertRowAsync(string tenantId, VexDecisionDto d, CancellationToken ct)
+ {
+ const string sql = """
+ INSERT INTO vex_decisions (
+ id, tenant_id, vulnerability_id,
+ subject_type, subject_name, subject_digest, subject_sbom_node_id,
+ status, justification_type, justification_text,
+ evidence_refs, scope_environments, scope_projects,
+ valid_not_before, valid_not_after,
+ attestation_ref_id, attestation_ref_digest, attestation_ref_storage,
+ signed_override, supersedes_decision_id,
+ created_by_id, created_by_name, created_at, updated_at
+ ) VALUES (
+ @id, @tenantId, @vulnId,
+ @subjectType, @subjectName, @subjectDigest, @subjectSbomNodeId,
+ @status, @justificationType, @justificationText,
+ @evidenceRefs, @scopeEnvs, @scopeProjects,
+ @validNotBefore, @validNotAfter,
+ @attestRefId, @attestRefDigest, @attestRefStorage,
+ @signedOverride, @supersedesId,
+ @createdById, @createdByName, @createdAt, @updatedAt
+ )
+ """;
+
+ await using var conn = await _dataSource!.OpenConnectionAsync(ct).ConfigureAwait(false);
+ await using var cmd = new NpgsqlCommand(sql, conn);
+
+ cmd.Parameters.AddWithValue("id", d.Id);
+ cmd.Parameters.AddWithValue("tenantId", tenantId);
+ cmd.Parameters.AddWithValue("vulnId", d.VulnerabilityId);
+ cmd.Parameters.AddWithValue("subjectType", d.Subject.Type.ToString());
+ cmd.Parameters.AddWithValue("subjectName", d.Subject.Name);
+ cmd.Parameters.AddWithValue("subjectDigest", NpgsqlDbType.Jsonb,
+ JsonSerializer.Serialize(d.Subject.Digest, VexJsonDefaults.Options));
+ cmd.Parameters.AddWithValue("subjectSbomNodeId", (object?)d.Subject.SbomNodeId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("status", d.Status.ToString());
+ cmd.Parameters.AddWithValue("justificationType", d.JustificationType.ToString());
+ cmd.Parameters.AddWithValue("justificationText", (object?)d.JustificationText ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("evidenceRefs", NpgsqlDbType.Jsonb,
+ d.EvidenceRefs is not null
+ ? JsonSerializer.Serialize(d.EvidenceRefs, VexJsonDefaults.Options)
+ : (object)DBNull.Value);
+ cmd.Parameters.AddWithValue("scopeEnvs",
+ (object?)d.Scope?.Environments?.ToArray() ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("scopeProjects",
+ (object?)d.Scope?.Projects?.ToArray() ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("validNotBefore",
+ (object?)d.ValidFor?.NotBefore ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("validNotAfter",
+ (object?)d.ValidFor?.NotAfter ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("attestRefId",
+ (object?)d.AttestationRef?.Id ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("attestRefDigest", NpgsqlDbType.Jsonb,
+ d.AttestationRef?.Digest is not null
+ ? JsonSerializer.Serialize(d.AttestationRef.Digest, VexJsonDefaults.Options)
+ : (object)DBNull.Value);
+ cmd.Parameters.AddWithValue("attestRefStorage",
+ (object?)d.AttestationRef?.Storage ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("signedOverride", NpgsqlDbType.Jsonb,
+ d.SignedOverride is not null
+ ? JsonSerializer.Serialize(d.SignedOverride, VexJsonDefaults.Options)
+ : (object)DBNull.Value);
+ cmd.Parameters.AddWithValue("supersedesId",
+ (object?)d.SupersedesDecisionId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("createdById", d.CreatedBy.Id);
+ cmd.Parameters.AddWithValue("createdByName", d.CreatedBy.DisplayName);
+ cmd.Parameters.AddWithValue("createdAt", d.CreatedAt);
+ cmd.Parameters.AddWithValue("updatedAt", (object?)d.UpdatedAt ?? DBNull.Value);
+
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
+ _logger?.LogDebug("Inserted VEX decision {Id} for tenant {Tenant}", d.Id, tenantId);
+ }
+
+ private static void AddFilterParams(
+ NpgsqlCommand cmd, string tenantId,
+ string? vulnerabilityId, string? subjectName, VexStatus? status)
+ {
+ cmd.Parameters.AddWithValue("tenantId", tenantId);
+ if (vulnerabilityId is not null)
+ cmd.Parameters.AddWithValue("vulnId", vulnerabilityId);
+ if (subjectName is not null)
+ cmd.Parameters.AddWithValue("subjectName", $"%{subjectName}%");
+ if (status is not null)
+ cmd.Parameters.AddWithValue("status", status.Value.ToString());
+ }
+
+ private static VexDecisionDto MapDecision(NpgsqlDataReader r)
+ {
+ var subjectDigest = JsonSerializer.Deserialize>(
+ r.GetString(r.GetOrdinal("subject_digest")),
+ VexJsonDefaults.Options) ?? new Dictionary();
+
+ var subjectType = Enum.TryParse(r.GetString(r.GetOrdinal("subject_type")), true, out var st)
+ ? st : SubjectType.Other;
+
+ IReadOnlyList? evidenceRefs = null;
+ var evidenceRefsOrd = r.GetOrdinal("evidence_refs");
+ if (!r.IsDBNull(evidenceRefsOrd))
+ {
+ evidenceRefs = JsonSerializer.Deserialize>(
+ r.GetString(evidenceRefsOrd), VexJsonDefaults.Options);
+ }
+
+ VexScopeDto? scope = null;
+ var scopeEnvsOrd = r.GetOrdinal("scope_environments");
+ var scopeProjOrd = r.GetOrdinal("scope_projects");
+ if (!r.IsDBNull(scopeEnvsOrd) || !r.IsDBNull(scopeProjOrd))
+ {
+ scope = new VexScopeDto(
+ Environments: r.IsDBNull(scopeEnvsOrd) ? null : ((string[])r.GetValue(scopeEnvsOrd)).ToList(),
+ Projects: r.IsDBNull(scopeProjOrd) ? null : ((string[])r.GetValue(scopeProjOrd)).ToList());
+ }
+
+ ValidForDto? validFor = null;
+ var notBeforeOrd = r.GetOrdinal("valid_not_before");
+ var notAfterOrd = r.GetOrdinal("valid_not_after");
+ if (!r.IsDBNull(notBeforeOrd) || !r.IsDBNull(notAfterOrd))
+ {
+ validFor = new ValidForDto(
+ NotBefore: r.IsDBNull(notBeforeOrd) ? null : r.GetFieldValue(notBeforeOrd),
+ NotAfter: r.IsDBNull(notAfterOrd) ? null : r.GetFieldValue(notAfterOrd));
+ }
+
+ AttestationRefDto? attestationRef = null;
+ var attestRefIdOrd = r.GetOrdinal("attestation_ref_id");
+ if (!r.IsDBNull(attestRefIdOrd))
+ {
+ var attestDigestOrd = r.GetOrdinal("attestation_ref_digest");
+ var attestStorageOrd = r.GetOrdinal("attestation_ref_storage");
+ attestationRef = new AttestationRefDto(
+ Id: r.GetString(attestRefIdOrd),
+ Digest: r.IsDBNull(attestDigestOrd) ? null
+ : JsonSerializer.Deserialize>(
+ r.GetString(attestDigestOrd), VexJsonDefaults.Options),
+ Storage: r.IsDBNull(attestStorageOrd) ? null : r.GetString(attestStorageOrd));
+ }
+
+ VexOverrideAttestationDto? signedOverride = null;
+ var signedOverrideOrd = r.GetOrdinal("signed_override");
+ if (!r.IsDBNull(signedOverrideOrd))
+ {
+ signedOverride = JsonSerializer.Deserialize(
+ r.GetString(signedOverrideOrd), VexJsonDefaults.Options);
+ }
+
+ var statusStr = r.GetString(r.GetOrdinal("status"));
+ var vexStatus = Enum.TryParse(statusStr, true, out var vs) ? vs : VexStatus.AffectedUnmitigated;
+
+ var justTypeStr = r.GetString(r.GetOrdinal("justification_type"));
+ var justType = Enum.TryParse(justTypeStr, true, out var jt) ? jt : VexJustificationType.Other;
+
+ var sbomNodeOrd = r.GetOrdinal("subject_sbom_node_id");
+ var supersedesOrd = r.GetOrdinal("supersedes_decision_id");
+ var updatedAtOrd = r.GetOrdinal("updated_at");
+ var justTextOrd = r.GetOrdinal("justification_text");
+
+ return new VexDecisionDto(
+ Id: r.GetGuid(r.GetOrdinal("id")),
+ VulnerabilityId: r.GetString(r.GetOrdinal("vulnerability_id")),
+ Subject: new SubjectRefDto(
+ Type: subjectType,
+ Name: r.GetString(r.GetOrdinal("subject_name")),
+ Digest: subjectDigest,
+ SbomNodeId: r.IsDBNull(sbomNodeOrd) ? null : r.GetString(sbomNodeOrd)),
+ Status: vexStatus,
+ JustificationType: justType,
+ JustificationText: r.IsDBNull(justTextOrd) ? null : r.GetString(justTextOrd),
+ EvidenceRefs: evidenceRefs,
+ Scope: scope,
+ ValidFor: validFor,
+ AttestationRef: attestationRef,
+ SignedOverride: signedOverride,
+ SupersedesDecisionId: r.IsDBNull(supersedesOrd) ? null : r.GetGuid(supersedesOrd),
+ CreatedBy: new ActorRefDto(
+ r.GetString(r.GetOrdinal("created_by_id")),
+ r.GetString(r.GetOrdinal("created_by_name"))),
+ CreatedAt: r.GetFieldValue(r.GetOrdinal("created_at")),
+ UpdatedAt: r.IsDBNull(updatedAtOrd) ? null : r.GetFieldValue(updatedAtOrd));
}
}
diff --git a/src/Findings/StellaOps.VulnExplorer.Api/Program.cs b/src/Findings/StellaOps.VulnExplorer.Api/Program.cs
index 277ab29cc..55024b703 100644
--- a/src/Findings/StellaOps.VulnExplorer.Api/Program.cs
+++ b/src/Findings/StellaOps.VulnExplorer.Api/Program.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
+using Npgsql;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
@@ -30,11 +31,43 @@ builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
+// ── Postgres data source (optional -- falls back to in-memory if no connection string) ──
+var connectionString = builder.Configuration.GetConnectionString("Default");
+if (!string.IsNullOrWhiteSpace(connectionString))
+{
+ var npgsqlBuilder = new NpgsqlDataSourceBuilder(connectionString)
+ {
+ Name = "vulnexplorer"
+ };
+ var dataSource = npgsqlBuilder.Build();
+ builder.Services.AddSingleton(dataSource);
+}
+
builder.Services.AddSingleton();
+
+// Wire stores: use Postgres when NpgsqlDataSource is registered, else in-memory
builder.Services.AddSingleton(sp =>
- new VexDecisionStore(attestorClient: sp.GetRequiredService()));
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
+{
+ var ds = sp.GetService();
+ var attestorClient = sp.GetRequiredService();
+ if (ds is not null)
+ return new VexDecisionStore(ds, sp.GetRequiredService>(), attestorClient: attestorClient);
+ return new VexDecisionStore(attestorClient: attestorClient);
+});
+builder.Services.AddSingleton(sp =>
+{
+ var ds = sp.GetService();
+ if (ds is not null)
+ return new FixVerificationStore(ds, sp.GetRequiredService>());
+ return new FixVerificationStore();
+});
+builder.Services.AddSingleton(sp =>
+{
+ var ds = sp.GetService();
+ if (ds is not null)
+ return new AuditBundleStore(ds, sp.GetRequiredService>());
+ return new AuditBundleStore();
+});
builder.Services.AddSingleton();
// Authentication and authorization
@@ -145,6 +178,7 @@ app.MapPost("/v1/vex-decisions", async (
if (request.AttestationOptions?.CreateAttestation == true)
{
var result = await store.CreateWithAttestationAsync(
+ tenant,
request,
effectiveUserId,
effectiveUserName,
@@ -153,7 +187,7 @@ app.MapPost("/v1/vex-decisions", async (
}
else
{
- decision = store.Create(request, effectiveUserId, effectiveUserName);
+ decision = await store.CreateAsync(tenant, request, effectiveUserId, effectiveUserName, cancellationToken);
}
return Results.Created($"/v1/vex-decisions/{decision.Id}", decision);
@@ -163,18 +197,19 @@ app.MapPost("/v1/vex-decisions", async (
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
-app.MapPatch("/v1/vex-decisions/{id:guid}", (
+app.MapPatch("/v1/vex-decisions/{id:guid}", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
Guid id,
[FromBody] UpdateVexDecisionRequest request,
- VexDecisionStore store) =>
+ VexDecisionStore store,
+ CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
- var updated = store.Update(id, request);
+ var updated = await store.UpdateAsync(tenant, id, request, cancellationToken);
return updated is not null
? Results.Ok(updated)
: Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) });
@@ -184,7 +219,10 @@ app.MapPatch("/v1/vex-decisions/{id:guid}", (
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
-app.MapGet("/v1/vex-decisions", ([AsParameters] VexDecisionFilter filter, VexDecisionStore store) =>
+app.MapGet("/v1/vex-decisions", async (
+ [AsParameters] VexDecisionFilter filter,
+ VexDecisionStore store,
+ CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(filter.Tenant))
{
@@ -194,15 +232,17 @@ app.MapGet("/v1/vex-decisions", ([AsParameters] VexDecisionFilter filter, VexDec
var pageSize = Math.Clamp(filter.PageSize ?? 50, 1, 200);
var offset = ParsePageToken(filter.PageToken);
- var decisions = store.Query(
+ var (decisions, totalCount) = await store.QueryAsync(
+ tenantId: filter.Tenant,
vulnerabilityId: filter.VulnerabilityId,
subjectName: filter.Subject,
status: filter.Status,
skip: offset,
- take: pageSize);
+ take: pageSize,
+ ct: cancellationToken);
var nextOffset = offset + decisions.Count;
- var next = nextOffset < store.Count() ? nextOffset.ToString(CultureInfo.InvariantCulture) : null;
+ var next = nextOffset < totalCount ? nextOffset.ToString(CultureInfo.InvariantCulture) : null;
return Results.Ok(new VexDecisionListResponse(decisions, next));
})
@@ -211,17 +251,18 @@ app.MapGet("/v1/vex-decisions", ([AsParameters] VexDecisionFilter filter, VexDec
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
-app.MapGet("/v1/vex-decisions/{id:guid}", (
+app.MapGet("/v1/vex-decisions/{id:guid}", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
Guid id,
- VexDecisionStore store) =>
+ VexDecisionStore store,
+ CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
}
- var decision = store.Get(id);
+ var decision = await store.GetAsync(tenant, id, cancellationToken);
return decision is not null
? Results.Ok(decision)
: Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) });
@@ -254,10 +295,36 @@ app.MapGet("/v1/evidence-subgraph/{vulnId}", (
.RequireAuthorization(VulnExplorerPolicies.View)
.RequireTenant();
-app.MapPost("/v1/fix-verifications", (
+// Route alias: the UI calls /api/vuln-explorer/findings/{vulnId}/evidence-subgraph
+// and the gateway forwards it as-is to the service.
+app.MapGet("/api/vuln-explorer/findings/{vulnId}/evidence-subgraph", (
+ [FromHeader(Name = "x-stella-tenant")] string? tenant,
+ string vulnId,
+ EvidenceSubgraphStore store) =>
+{
+ if (string.IsNullOrWhiteSpace(tenant))
+ {
+ return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") });
+ }
+
+ if (string.IsNullOrWhiteSpace(vulnId))
+ {
+ return Results.BadRequest(new { error = _t("vulnexplorer.error.vuln_id_required") });
+ }
+
+ EvidenceSubgraphResponse response = store.Build(vulnId);
+ return Results.Ok(response);
+})
+.WithName("GetEvidenceSubgraphAlias")
+.WithDescription(_t("vulnexplorer.evidence_subgraph.get_description"))
+.RequireAuthorization(VulnExplorerPolicies.View)
+.RequireTenant();
+
+app.MapPost("/v1/fix-verifications", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
[FromBody] CreateFixVerificationRequest request,
- FixVerificationStore store) =>
+ FixVerificationStore store,
+ CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
@@ -269,7 +336,7 @@ app.MapPost("/v1/fix-verifications", (
return Results.BadRequest(new { error = _t("vulnexplorer.error.cve_id_and_purl_required") });
}
- var created = store.Create(request);
+ var created = await store.CreateAsync(tenant, request, cancellationToken);
return Results.Created($"/v1/fix-verifications/{created.CveId}", created);
})
.WithName("CreateFixVerification")
@@ -277,11 +344,12 @@ app.MapPost("/v1/fix-verifications", (
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
-app.MapPatch("/v1/fix-verifications/{cveId}", (
+app.MapPatch("/v1/fix-verifications/{cveId}", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
string cveId,
[FromBody] UpdateFixVerificationRequest request,
- FixVerificationStore store) =>
+ FixVerificationStore store,
+ CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
@@ -293,7 +361,7 @@ app.MapPatch("/v1/fix-verifications/{cveId}", (
return Results.BadRequest(new { error = _t("vulnexplorer.error.verdict_required") });
}
- var updated = store.Update(cveId, request.Verdict);
+ var updated = await store.UpdateAsync(tenant, cveId, request.Verdict, cancellationToken);
return updated is not null
? Results.Ok(updated)
: Results.NotFound(new { error = _t("vulnexplorer.error.fix_verification_not_found", cveId) });
@@ -303,11 +371,12 @@ app.MapPatch("/v1/fix-verifications/{cveId}", (
.RequireAuthorization(VulnExplorerPolicies.Operate)
.RequireTenant();
-app.MapPost("/v1/audit-bundles", (
+app.MapPost("/v1/audit-bundles", async (
[FromHeader(Name = "x-stella-tenant")] string? tenant,
[FromBody] CreateAuditBundleRequest request,
VexDecisionStore decisions,
- AuditBundleStore bundles) =>
+ AuditBundleStore bundles,
+ CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
@@ -319,18 +388,19 @@ app.MapPost("/v1/audit-bundles", (
return Results.BadRequest(new { error = _t("vulnexplorer.error.decision_ids_required") });
}
- var selected = request.DecisionIds
- .Select(id => decisions.Get(id))
- .Where(x => x is not null)
- .Cast()
- .ToArray();
+ var selected = new List();
+ foreach (var id in request.DecisionIds)
+ {
+ var d = await decisions.GetAsync(tenant, id, cancellationToken);
+ if (d is not null) selected.Add(d);
+ }
- if (selected.Length == 0)
+ if (selected.Count == 0)
{
return Results.NotFound(new { error = _t("vulnexplorer.error.no_decisions_found") });
}
- var bundle = bundles.Create(tenant, selected);
+ var bundle = await bundles.CreateAsync(tenant, selected, cancellationToken);
return Results.Created($"/v1/audit-bundles/{bundle.BundleId}", bundle);
})
.WithName("CreateAuditBundle")
diff --git a/src/Findings/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj b/src/Findings/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj
index 844f70652..e810978bc 100644
--- a/src/Findings/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj
+++ b/src/Findings/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj
@@ -10,6 +10,7 @@
+