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