// SPDX-License-Identifier: AGPL-3.0-or-later // © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root. using System.Data.Common; using System.Diagnostics; using System.Globalization; using Microsoft.Extensions.Logging; using Npgsql; using StellaOps.Determinism; using StellaOps.VexLens.Consensus; using StellaOps.VexLens.Models; using StellaOps.VexLens.Options; namespace StellaOps.VexLens.Storage; /// /// PostgreSQL implementation of . /// This proxy implementation lives in the core VexLens project to enable DI registration. /// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-022) /// /// /// For full-featured implementation with advanced queries, use PostgresConsensusProjectionStore /// from StellaOps.VexLens.Persistence package. /// public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjectionStore { private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Storage.PostgresConsensusProjectionStoreProxy"); private readonly NpgsqlDataSource _dataSource; private readonly IConsensusEventEmitter? _eventEmitter; private readonly ILogger _logger; private readonly VexLensStorageOptions _options; private readonly TimeProvider _timeProvider; private readonly IGuidProvider _guidProvider; public PostgresConsensusProjectionStoreProxy( NpgsqlDataSource dataSource, ILogger logger, IConsensusEventEmitter? eventEmitter = null, VexLensStorageOptions? options = null, TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _eventEmitter = eventEmitter; _options = options ?? new VexLensStorageOptions(); _timeProvider = timeProvider ?? TimeProvider.System; _guidProvider = guidProvider ?? new SystemGuidProvider(); } private const string Schema = "vexlens"; private const string InsertProjectionSql = $""" INSERT INTO {Schema}.consensus_projections ( id, vulnerability_id, product_key, tenant_id, status, justification, confidence_score, outcome, statement_count, conflict_count, rationale_summary, computed_at, stored_at, previous_projection_id, status_changed ) VALUES ( @id, @vulnerability_id, @product_key, @tenant_id, @status, @justification, @confidence_score, @outcome, @statement_count, @conflict_count, @rationale_summary, @computed_at, @stored_at, @previous_projection_id, @status_changed ) """; private const string SelectByIdSql = $""" SELECT id, vulnerability_id, product_key, tenant_id, status, justification, confidence_score, outcome, statement_count, conflict_count, rationale_summary, computed_at, stored_at, previous_projection_id, status_changed FROM {Schema}.consensus_projections WHERE id = @id """; private const string SelectLatestSql = $""" SELECT id, vulnerability_id, product_key, tenant_id, status, justification, confidence_score, outcome, statement_count, conflict_count, rationale_summary, computed_at, stored_at, previous_projection_id, status_changed FROM {Schema}.consensus_projections WHERE vulnerability_id = @vulnerability_id AND product_key = @product_key AND (@tenant_id IS NULL OR tenant_id = @tenant_id) ORDER BY computed_at DESC LIMIT 1 """; private const string SelectHistorySql = $""" SELECT id, vulnerability_id, product_key, tenant_id, status, justification, confidence_score, outcome, statement_count, conflict_count, rationale_summary, computed_at, stored_at, previous_projection_id, status_changed FROM {Schema}.consensus_projections WHERE vulnerability_id = @vulnerability_id AND product_key = @product_key AND (@tenant_id IS NULL OR tenant_id = @tenant_id) ORDER BY computed_at DESC LIMIT @limit """; private const string PurgeSql = $""" DELETE FROM {Schema}.consensus_projections WHERE computed_at < @older_than AND (@tenant_id IS NULL OR tenant_id = @tenant_id) """; /// public async Task StoreAsync( VexConsensusResult result, StoreProjectionOptions options, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(result); ArgumentNullException.ThrowIfNull(options); using var activity = ActivitySource.StartActivity("StoreAsync"); activity?.SetTag("vulnerabilityId", result.VulnerabilityId); activity?.SetTag("productKey", result.ProductKey); var projectionId = _guidProvider.NewGuid(); var now = _timeProvider.GetUtcNow(); // Check for previous projection to track history ConsensusProjection? previous = null; if (options.TrackHistory) { previous = await GetLatestAsync( result.VulnerabilityId, result.ProductKey, options.TenantId, cancellationToken); } var statusChanged = previous is not null && previous.Status != result.ConsensusStatus; await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await using var transaction = await connection.BeginTransactionAsync(cancellationToken); try { await using var cmd = new NpgsqlCommand(InsertProjectionSql, connection, transaction); cmd.Parameters.AddWithValue("id", projectionId); cmd.Parameters.AddWithValue("vulnerability_id", result.VulnerabilityId); cmd.Parameters.AddWithValue("product_key", result.ProductKey); cmd.Parameters.AddWithValue("tenant_id", options.TenantId ?? (object)DBNull.Value); cmd.Parameters.AddWithValue("status", MapStatus(result.ConsensusStatus)); cmd.Parameters.AddWithValue("justification", result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : DBNull.Value); cmd.Parameters.AddWithValue("confidence_score", result.ConfidenceScore); cmd.Parameters.AddWithValue("outcome", MapOutcome(result.Outcome)); cmd.Parameters.AddWithValue("statement_count", result.Contributions.Count); cmd.Parameters.AddWithValue("conflict_count", result.Conflicts?.Count ?? 0); cmd.Parameters.AddWithValue("rationale_summary", result.Rationale.Summary ?? (object)DBNull.Value); cmd.Parameters.AddWithValue("computed_at", result.ComputedAt); cmd.Parameters.AddWithValue("stored_at", now); cmd.Parameters.AddWithValue("previous_projection_id", previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : DBNull.Value); cmd.Parameters.AddWithValue("status_changed", statusChanged); await cmd.ExecuteNonQueryAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); var projection = new ConsensusProjection( ProjectionId: projectionId.ToString(), VulnerabilityId: result.VulnerabilityId, ProductKey: result.ProductKey, TenantId: options.TenantId, Status: result.ConsensusStatus, Justification: result.ConsensusJustification, ConfidenceScore: result.ConfidenceScore, Outcome: result.Outcome, StatementCount: result.Contributions.Count, ConflictCount: result.Conflicts?.Count ?? 0, RationaleSummary: result.Rationale.Summary ?? string.Empty, ComputedAt: result.ComputedAt, StoredAt: now, PreviousProjectionId: previous?.ProjectionId, StatusChanged: statusChanged); _logger.LogDebug( "Stored consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}", projectionId, result.VulnerabilityId, result.ProductKey); // Emit events if (options.EmitEvent && _eventEmitter is not null) { await EmitEventsAsync(projection, previous, cancellationToken); } return projection; } catch { await transaction.RollbackAsync(cancellationToken); throw; } } /// public async Task GetAsync( string projectionId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(projectionId); if (!Guid.TryParse(projectionId, out var id)) { return null; } using var activity = ActivitySource.StartActivity("GetAsync"); activity?.SetTag("projectionId", projectionId); await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await using var cmd = new NpgsqlCommand(SelectByIdSql, connection); cmd.Parameters.AddWithValue("id", id); await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); if (await reader.ReadAsync(cancellationToken)) { return MapProjection(reader); } return null; } /// public async Task GetLatestAsync( string vulnerabilityId, string productKey, string? tenantId = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId); ArgumentException.ThrowIfNullOrWhiteSpace(productKey); using var activity = ActivitySource.StartActivity("GetLatestAsync"); activity?.SetTag("vulnerabilityId", vulnerabilityId); activity?.SetTag("productKey", productKey); await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await using var cmd = new NpgsqlCommand(SelectLatestSql, connection); cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId); cmd.Parameters.AddWithValue("product_key", productKey); cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value); await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); if (await reader.ReadAsync(cancellationToken)) { return MapProjection(reader); } return null; } /// public async Task ListAsync( ProjectionQuery query, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(query); using var activity = ActivitySource.StartActivity("ListAsync"); var (sql, countSql, parameters) = BuildListQuery(query); await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); // Get total count await using var countCmd = new NpgsqlCommand(countSql, connection); foreach (var (name, value) in parameters) { countCmd.Parameters.AddWithValue(name, value); } var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken)); // Get results await using var cmd = new NpgsqlCommand(sql, connection); foreach (var (name, value) in parameters) { cmd.Parameters.AddWithValue(name, value); } cmd.Parameters.AddWithValue("limit", query.Limit); cmd.Parameters.AddWithValue("offset", query.Offset); var projections = new List(); await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { projections.Add(MapProjection(reader)); } return new ProjectionListResult(projections, totalCount, query.Offset, query.Limit); } /// public async Task> GetHistoryAsync( string vulnerabilityId, string productKey, string? tenantId = null, int? limit = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId); ArgumentException.ThrowIfNullOrWhiteSpace(productKey); using var activity = ActivitySource.StartActivity("GetHistoryAsync"); activity?.SetTag("vulnerabilityId", vulnerabilityId); activity?.SetTag("productKey", productKey); await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await using var cmd = new NpgsqlCommand(SelectHistorySql, connection); cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId); cmd.Parameters.AddWithValue("product_key", productKey); cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value); cmd.Parameters.AddWithValue("limit", limit ?? _options.MaxHistoryEntries); var projections = new List(); await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { projections.Add(MapProjection(reader)); } return projections; } /// public async Task PurgeAsync( DateTimeOffset olderThan, string? tenantId = null, CancellationToken cancellationToken = default) { using var activity = ActivitySource.StartActivity("PurgeAsync"); activity?.SetTag("olderThan", olderThan.ToString("O", CultureInfo.InvariantCulture)); await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await using var cmd = new NpgsqlCommand(PurgeSql, connection); cmd.Parameters.AddWithValue("older_than", olderThan); cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value); var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken); _logger.LogInformation("Purged {Count} consensus projections older than {OlderThan}", deleted, olderThan); return deleted; } private static ConsensusProjection MapProjection(DbDataReader reader) { return new ConsensusProjection( ProjectionId: reader.GetGuid(0).ToString(), VulnerabilityId: reader.GetString(1), ProductKey: reader.GetString(2), TenantId: reader.IsDBNull(3) ? null : reader.GetString(3), Status: ParseStatus(reader.GetString(4)), Justification: reader.IsDBNull(5) ? null : ParseJustification(reader.GetString(5)), ConfidenceScore: reader.GetDouble(6), Outcome: ParseOutcome(reader.GetString(7)), StatementCount: reader.GetInt32(8), ConflictCount: reader.GetInt32(9), RationaleSummary: reader.IsDBNull(10) ? string.Empty : reader.GetString(10), ComputedAt: reader.GetFieldValue(11), StoredAt: reader.GetFieldValue(12), PreviousProjectionId: reader.IsDBNull(13) ? null : reader.GetGuid(13).ToString(), StatusChanged: reader.GetBoolean(14)); } private static string MapStatus(VexStatus status) => status switch { VexStatus.NotAffected => "not_affected", VexStatus.Affected => "affected", VexStatus.Fixed => "fixed", VexStatus.UnderInvestigation => "under_investigation", _ => "unknown" }; private static VexStatus ParseStatus(string status) => status switch { "not_affected" => VexStatus.NotAffected, "affected" => VexStatus.Affected, "fixed" => VexStatus.Fixed, "under_investigation" => VexStatus.UnderInvestigation, _ => VexStatus.UnderInvestigation }; private static string MapJustification(VexJustification justification) => justification switch { VexJustification.ComponentNotPresent => "component_not_present", VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present", VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary", VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path", VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist", _ => "unknown" }; private static VexJustification ParseJustification(string justification) => justification switch { "component_not_present" => VexJustification.ComponentNotPresent, "vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent, "vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary, "vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath, "inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist, _ => VexJustification.VulnerableCodeNotPresent }; private static string MapOutcome(ConsensusOutcome outcome) => outcome switch { ConsensusOutcome.Unanimous => "unanimous", ConsensusOutcome.Majority => "majority", ConsensusOutcome.Plurality => "plurality", ConsensusOutcome.ConflictResolved => "conflict_resolved", ConsensusOutcome.NoData => "no_data", ConsensusOutcome.Indeterminate => "indeterminate", _ => "indeterminate" }; private static ConsensusOutcome ParseOutcome(string outcome) => outcome switch { "unanimous" => ConsensusOutcome.Unanimous, "majority" => ConsensusOutcome.Majority, "plurality" => ConsensusOutcome.Plurality, "conflict_resolved" => ConsensusOutcome.ConflictResolved, "no_data" => ConsensusOutcome.NoData, "indeterminate" => ConsensusOutcome.Indeterminate, _ => ConsensusOutcome.Indeterminate }; private static (string sql, string countSql, List<(string name, object value)> parameters) BuildListQuery(ProjectionQuery query) { var parameters = new List<(string name, object value)>(); var conditions = new List(); if (!string.IsNullOrWhiteSpace(query.TenantId)) { conditions.Add("tenant_id = @tenant_id"); parameters.Add(("tenant_id", query.TenantId)); } if (!string.IsNullOrWhiteSpace(query.VulnerabilityId)) { conditions.Add("vulnerability_id ILIKE @vulnerability_id"); parameters.Add(("vulnerability_id", $"%{query.VulnerabilityId}%")); } if (!string.IsNullOrWhiteSpace(query.ProductKey)) { conditions.Add("product_key ILIKE @product_key"); parameters.Add(("product_key", $"%{query.ProductKey}%")); } if (query.Status.HasValue) { conditions.Add("status = @status"); parameters.Add(("status", MapStatus(query.Status.Value))); } if (query.Outcome.HasValue) { conditions.Add("outcome = @outcome"); parameters.Add(("outcome", MapOutcome(query.Outcome.Value))); } if (query.MinimumConfidence.HasValue) { conditions.Add("confidence_score >= @min_confidence"); parameters.Add(("min_confidence", query.MinimumConfidence.Value)); } if (query.ComputedAfter.HasValue) { conditions.Add("computed_at >= @computed_after"); parameters.Add(("computed_after", query.ComputedAfter.Value)); } if (query.ComputedBefore.HasValue) { conditions.Add("computed_at <= @computed_before"); parameters.Add(("computed_before", query.ComputedBefore.Value)); } if (query.StatusChanged.HasValue) { conditions.Add("status_changed = @status_changed"); parameters.Add(("status_changed", query.StatusChanged.Value)); } var whereClause = conditions.Count > 0 ? $"WHERE {string.Join(" AND ", conditions)}" : string.Empty; var orderColumn = query.SortBy switch { ProjectionSortField.ComputedAt => "computed_at", ProjectionSortField.StoredAt => "stored_at", ProjectionSortField.VulnerabilityId => "vulnerability_id", ProjectionSortField.ProductKey => "product_key", ProjectionSortField.ConfidenceScore => "confidence_score", _ => "computed_at" }; var orderDirection = query.SortDescending ? "DESC" : "ASC"; var sql = $""" SELECT id, vulnerability_id, product_key, tenant_id, status, justification, confidence_score, outcome, statement_count, conflict_count, rationale_summary, computed_at, stored_at, previous_projection_id, status_changed FROM {Schema}.consensus_projections {whereClause} ORDER BY {orderColumn} {orderDirection} LIMIT @limit OFFSET @offset """; var countSql = $"SELECT COUNT(*) FROM {Schema}.consensus_projections {whereClause}"; return (sql, countSql, parameters); } private async Task EmitEventsAsync( ConsensusProjection projection, ConsensusProjection? previous, CancellationToken cancellationToken) { if (_eventEmitter is null) { return; } var now = _timeProvider.GetUtcNow(); var computedEvent = new ConsensusComputedEvent( EventId: $"evt-{_guidProvider.NewGuid():N}", ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, TenantId: projection.TenantId, Status: projection.Status, Justification: projection.Justification, ConfidenceScore: projection.ConfidenceScore, Outcome: projection.Outcome, StatementCount: projection.StatementCount, ComputedAt: projection.ComputedAt, EmittedAt: now); await _eventEmitter.EmitConsensusComputedAsync(computedEvent, cancellationToken); if (projection.StatusChanged && previous is not null) { var changedEvent = new ConsensusStatusChangedEvent( EventId: $"evt-{_guidProvider.NewGuid():N}", ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, TenantId: projection.TenantId, PreviousStatus: previous.Status, NewStatus: projection.Status, ChangeReason: $"Consensus status changed from {previous.Status} to {projection.Status}", ComputedAt: projection.ComputedAt, EmittedAt: now); await _eventEmitter.EmitStatusChangedAsync(changedEvent, cancellationToken); } } }