diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs
new file mode 100644
index 000000000..3f03657f4
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs
@@ -0,0 +1,33 @@
+//
+// 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 System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace StellaOps.Findings.Ledger.WebService.Services;
+
+///
+/// Shared JSON serializer options for VulnExplorer Postgres repositories.
+///
+internal static class VulnExplorerJsonDefaults
+{
+ internal static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
+ };
+}
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
new file mode 100644
index 000000000..53a78ecff
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger/migrations/010_vex_fix_audit_tables.sql
@@ -0,0 +1,95 @@
+-- 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
+-- ────────────────────────────────────────────────────────────────────────────
+
+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 TABLE IF NOT EXISTS vex_decisions_default PARTITION OF vex_decisions DEFAULT;
+
+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);
+
+-- ────────────────────────────────────────────────────────────────────────────
+-- 2. Fix Verifications
+-- ────────────────────────────────────────────────────────────────────────────
+
+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 fix_verifications_default PARTITION OF fix_verifications DEFAULT;
+
+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
+-- ────────────────────────────────────────────────────────────────────────────
+
+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 TABLE IF NOT EXISTS audit_bundles_default PARTITION OF audit_bundles DEFAULT;
+
+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 @@
+