// 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. 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; namespace StellaOps.Findings.Ledger.WebService.Services; // ============================================================================ // Interfaces // ============================================================================ public interface IVexDecisionRepository { 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() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, Converters = { new System.Text.Json.Serialization.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}"; } }