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) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 18:29:09 +03:00
parent 53f294400f
commit 414049ef82
6 changed files with 887 additions and 114 deletions

View File

@@ -0,0 +1,33 @@
// <copyright file="VulnExplorerRepositories.cs" company="StellaOps">
// SPDX-License-Identifier: BUSL-1.1
// </copyright>
//
// 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;
/// <summary>
/// Shared JSON serializer options for VulnExplorer Postgres repositories.
/// </summary>
internal static class VulnExplorerJsonDefaults
{
internal static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
}

View File

@@ -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;

View File

@@ -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<string, FixVerificationRecord> records = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider timeProvider;
private readonly ConcurrentDictionary<string, FixVerificationRecord>? _memoryFallback;
private readonly NpgsqlDataSource? _dataSource;
private readonly ILogger<FixVerificationStore>? _logger;
private readonly TimeProvider _timeProvider;
private bool UsePostgres => _dataSource is not null;
/// <summary>Production constructor: Postgres-backed.</summary>
public FixVerificationStore(
NpgsqlDataSource dataSource,
ILogger<FixVerificationStore> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>Test/offline constructor: in-memory fallback.</summary>
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<FixVerificationRecord> 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<FixVerificationRecord?> 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<List<FixVerificationTransition>>(
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<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(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<AuditBundleStore>? _logger;
private readonly TimeProvider _timeProvider;
private bool UsePostgres => _dataSource is not null;
/// <summary>Production constructor: Postgres-backed.</summary>
public AuditBundleStore(
NpgsqlDataSource dataSource,
ILogger<AuditBundleStore> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>Test/offline constructor: in-memory fallback.</summary>
public AuditBundleStore(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public AuditBundleResponse Create(string tenant, IReadOnlyList<VexDecisionDto> 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<EvidenceRefDto>())
.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<AuditBundleResponse> CreateAsync(
string tenantId,
IReadOnlyList<VexDecisionDto> 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;

View File

@@ -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) }
};
}
/// <summary>
/// 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).
/// </summary>
public sealed class VexDecisionStore
{
private readonly ConcurrentDictionary<Guid, VexDecisionDto> _decisions = new();
// ── fallback in-memory path (tests only) ───────────────────────────
private readonly ConcurrentDictionary<Guid, VexDecisionDto>? _memoryFallback;
// ── postgres path ──────────────────────────────────────────────────
private readonly NpgsqlDataSource? _dataSource;
private readonly ILogger<VexDecisionStore>? _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly IVexOverrideAttestorClient? _attestorClient;
/// <summary>
/// Production constructor: Postgres-backed.
/// </summary>
public VexDecisionStore(
NpgsqlDataSource dataSource,
ILogger<VexDecisionStore> 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;
}
/// <summary>
/// Test/offline constructor: in-memory fallback.
/// </summary>
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<VexDecisionDto> Query(
string? vulnerabilityId = null,
@@ -85,24 +140,16 @@ public sealed class VexDecisionStore
int skip = 0,
int take = 50)
{
IEnumerable<VexDecisionDto> query = _decisions.Values;
if (_memoryFallback is null) return [];
IEnumerable<VexDecisionDto> 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<VexDecisionDto> 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<VexDecisionDto?> 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<VexDecisionDto?> 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<VexDecisionDto> 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<string> { "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<VexDecisionDto>();
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)
/// <summary>
/// Creates a VEX decision with a signed attestation.
/// </summary>
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);
}
/// <summary>
/// Updates a VEX decision and optionally creates a new attestation.
/// </summary>
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<IReadOnlyDictionary<string, string>>(
r.GetString(r.GetOrdinal("subject_digest")),
VexJsonDefaults.Options) ?? new Dictionary<string, string>();
var subjectType = Enum.TryParse<SubjectType>(r.GetString(r.GetOrdinal("subject_type")), true, out var st)
? st : SubjectType.Other;
IReadOnlyList<EvidenceRefDto>? evidenceRefs = null;
var evidenceRefsOrd = r.GetOrdinal("evidence_refs");
if (!r.IsDBNull(evidenceRefsOrd))
{
evidenceRefs = JsonSerializer.Deserialize<IReadOnlyList<EvidenceRefDto>>(
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<DateTimeOffset>(notBeforeOrd),
NotAfter: r.IsDBNull(notAfterOrd) ? null : r.GetFieldValue<DateTimeOffset>(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<IReadOnlyDictionary<string, string>>(
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<VexOverrideAttestationDto>(
r.GetString(signedOverrideOrd), VexJsonDefaults.Options);
}
var statusStr = r.GetString(r.GetOrdinal("status"));
var vexStatus = Enum.TryParse<VexStatus>(statusStr, true, out var vs) ? vs : VexStatus.AffectedUnmitigated;
var justTypeStr = r.GetString(r.GetOrdinal("justification_type"));
var justType = Enum.TryParse<VexJustificationType>(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<DateTimeOffset>(r.GetOrdinal("created_at")),
UpdatedAt: r.IsDBNull(updatedAtOrd) ? null : r.GetFieldValue<DateTimeOffset>(updatedAtOrd));
}
}

View File

@@ -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<IVexOverrideAttestorClient, StubVexOverrideAttestorClient>();
// Wire stores: use Postgres when NpgsqlDataSource is registered, else in-memory
builder.Services.AddSingleton<VexDecisionStore>(sp =>
new VexDecisionStore(attestorClient: sp.GetRequiredService<IVexOverrideAttestorClient>()));
builder.Services.AddSingleton<FixVerificationStore>();
builder.Services.AddSingleton<AuditBundleStore>();
{
var ds = sp.GetService<NpgsqlDataSource>();
var attestorClient = sp.GetRequiredService<IVexOverrideAttestorClient>();
if (ds is not null)
return new VexDecisionStore(ds, sp.GetRequiredService<ILogger<VexDecisionStore>>(), attestorClient: attestorClient);
return new VexDecisionStore(attestorClient: attestorClient);
});
builder.Services.AddSingleton<FixVerificationStore>(sp =>
{
var ds = sp.GetService<NpgsqlDataSource>();
if (ds is not null)
return new FixVerificationStore(ds, sp.GetRequiredService<ILogger<FixVerificationStore>>());
return new FixVerificationStore();
});
builder.Services.AddSingleton<AuditBundleStore>(sp =>
{
var ds = sp.GetService<NpgsqlDataSource>();
if (ds is not null)
return new AuditBundleStore(ds, sp.GetRequiredService<ILogger<AuditBundleStore>>());
return new AuditBundleStore();
});
builder.Services.AddSingleton<EvidenceSubgraphStore>();
// 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<VexDecisionDto>()
.ToArray();
var selected = new List<VexDecisionDto>();
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")

View File

@@ -10,6 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>