Files
git.stella-ops.org/src/VexLens/StellaOps.VexLens/Storage/PostgresConsensusProjectionStoreProxy.cs
2026-01-13 18:53:39 +02:00

559 lines
23 KiB
C#

// 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;
/// <summary>
/// PostgreSQL implementation of <see cref="IConsensusProjectionStore"/>.
/// 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)
/// </summary>
/// <remarks>
/// For full-featured implementation with advanced queries, use PostgresConsensusProjectionStore
/// from StellaOps.VexLens.Persistence package.
/// </remarks>
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<PostgresConsensusProjectionStoreProxy> _logger;
private readonly VexLensStorageOptions _options;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public PostgresConsensusProjectionStoreProxy(
NpgsqlDataSource dataSource,
ILogger<PostgresConsensusProjectionStoreProxy> 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)
""";
/// <inheritdoc />
public async Task<ConsensusProjection> 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;
}
}
/// <inheritdoc />
public async Task<ConsensusProjection?> 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;
}
/// <inheritdoc />
public async Task<ConsensusProjection?> 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;
}
/// <inheritdoc />
public async Task<ProjectionListResult> 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<ConsensusProjection>();
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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConsensusProjection>> 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<ConsensusProjection>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
projections.Add(MapProjection(reader));
}
return projections;
}
/// <inheritdoc />
public async Task<int> 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<DateTimeOffset>(11),
StoredAt: reader.GetFieldValue<DateTimeOffset>(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<string>();
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);
}
}
}