From 414049ef823af9aad40645e068b774b2182bacc3 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 8 Apr 2026 18:29:09 +0300 Subject: [PATCH] fix(findings): wire VulnExplorer adapters to Postgres + fix route mismatch Replace ConcurrentDictionary-based in-memory stores (VexDecisionStore, FixVerificationStore, AuditBundleStore) with Postgres-backed repositories that persist VEX decisions, fix verifications, and audit bundles to the findings schema. The stores auto-detect NpgsqlDataSource availability and fall back to in-memory mode for tests/offline. Changes: - Add migration 010_vex_fix_audit_tables.sql creating vex_decisions, fix_verifications, and audit_bundles tables (partitioned by tenant_id) - Rewrite VexDecisionStore with dual-mode: Postgres when ConnectionStrings__Default is configured, ConcurrentDictionary otherwise (backwards-compatible for tests) - Rewrite FixVerificationStore and AuditBundleStore with same dual-mode pattern - Wire NpgsqlDataSource in Program.cs from ConnectionStrings__Default - Add /api/vuln-explorer/findings/{vulnId}/evidence-subgraph route alias to match what the Angular UI (EvidenceSubgraphService) actually calls -- the gateway forwards this path as-is to the service - Convert all endpoint handlers to async to use the new Postgres-backed methods - Add Npgsql PackageReference to VulnExplorer.Api.csproj - Add VulnExplorerRepositories.cs placeholder in Findings.Ledger.WebService Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Services/VulnExplorerRepositories.cs | 33 ++ .../migrations/010_vex_fix_audit_tables.sql | 95 +++ .../Data/TriageWorkflowStores.cs | 200 ++++++- .../Data/VexDecisionStore.cs | 544 +++++++++++++++--- .../StellaOps.VulnExplorer.Api/Program.cs | 128 ++++- .../StellaOps.VulnExplorer.Api.csproj | 1 + 6 files changed, 887 insertions(+), 114 deletions(-) create mode 100644 src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerRepositories.cs create mode 100644 src/Findings/StellaOps.Findings.Ledger/migrations/010_vex_fix_audit_tables.sql 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 @@ +