using Microsoft.Extensions.Logging; using Npgsql; using NpgsqlTypes; using StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Hashing; using System.Text.Json.Nodes; namespace StellaOps.Findings.Ledger.Infrastructure.Postgres; public sealed class PostgresLedgerEventRepository : ILedgerEventRepository { private const string SelectByEventIdSql = """ SELECT chain_id, sequence_no, event_type, policy_version, finding_id, artifact_id, source_run_id, actor_id, actor_type, occurred_at, recorded_at, event_body, event_hash, previous_hash, merkle_leaf_hash, evidence_bundle_ref FROM ledger_events WHERE tenant_id = @tenant_id AND event_id = @event_id """; private const string SelectChainHeadSql = """ SELECT sequence_no, event_hash, recorded_at FROM ledger_events WHERE tenant_id = @tenant_id AND chain_id = @chain_id ORDER BY sequence_no DESC LIMIT 1 """; private const string InsertEventSql = """ INSERT INTO ledger_events ( tenant_id, chain_id, sequence_no, event_id, event_type, policy_version, finding_id, artifact_id, source_run_id, actor_id, actor_type, occurred_at, recorded_at, event_body, event_hash, previous_hash, merkle_leaf_hash, evidence_bundle_ref) VALUES ( @tenant_id, @chain_id, @sequence_no, @event_id, @event_type, @policy_version, @finding_id, @artifact_id, @source_run_id, @actor_id, @actor_type, @occurred_at, @recorded_at, @event_body, @event_hash, @previous_hash, @merkle_leaf_hash, @evidence_bundle_ref) """; private readonly LedgerDataSource _dataSource; private readonly ILogger _logger; public PostgresLedgerEventRepository( LedgerDataSource dataSource, ILogger logger) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetByEventIdAsync(string tenantId, Guid eventId, CancellationToken cancellationToken) { await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(SelectByEventIdSql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenant_id", tenantId); command.Parameters.AddWithValue("event_id", eventId); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { return null; } return MapLedgerEventRecord(tenantId, eventId, reader); } public async Task GetChainHeadAsync(string tenantId, Guid chainId, CancellationToken cancellationToken) { await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(SelectChainHeadSql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenant_id", tenantId); command.Parameters.AddWithValue("chain_id", chainId); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { return null; } var sequenceNumber = reader.GetInt64(0); var eventHash = reader.GetString(1); var recordedAt = reader.GetFieldValue(2); return new LedgerChainHead(sequenceNumber, eventHash, recordedAt); } public async Task AppendAsync(LedgerEventRecord record, CancellationToken cancellationToken) { await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(InsertEventSql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenant_id", record.TenantId); command.Parameters.AddWithValue("chain_id", record.ChainId); command.Parameters.AddWithValue("sequence_no", record.SequenceNumber); command.Parameters.AddWithValue("event_id", record.EventId); command.Parameters.AddWithValue("event_type", record.EventType); command.Parameters.AddWithValue("policy_version", record.PolicyVersion); command.Parameters.AddWithValue("finding_id", record.FindingId); command.Parameters.AddWithValue("artifact_id", record.ArtifactId); if (record.SourceRunId.HasValue) { command.Parameters.AddWithValue("source_run_id", record.SourceRunId.Value); } else { command.Parameters.AddWithValue("source_run_id", DBNull.Value); } command.Parameters.AddWithValue("actor_id", record.ActorId); command.Parameters.AddWithValue("actor_type", record.ActorType); command.Parameters.AddWithValue("occurred_at", record.OccurredAt); command.Parameters.AddWithValue("recorded_at", record.RecordedAt); var eventBody = record.EventBody.ToJsonString(); command.Parameters.Add(new NpgsqlParameter("event_body", NpgsqlDbType.Jsonb) { TypedValue = eventBody }); command.Parameters.AddWithValue("event_hash", record.EventHash); command.Parameters.AddWithValue("previous_hash", record.PreviousHash); command.Parameters.AddWithValue("merkle_leaf_hash", record.MerkleLeafHash); command.Parameters.AddWithValue("evidence_bundle_ref", (object?)record.EvidenceBundleReference ?? DBNull.Value); try { await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } catch (PostgresException ex) when (string.Equals(ex.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal)) { throw new LedgerDuplicateEventException(record.EventId, ex); } } internal static LedgerEventRecord MapLedgerEventRecord(string tenantId, Guid eventId, NpgsqlDataReader reader) { var chainId = reader.GetFieldValue(0); var sequenceNumber = reader.GetInt64(1); var eventType = reader.GetString(2); var policyVersion = reader.GetString(3); var findingId = reader.GetString(4); var artifactId = reader.GetString(5); var sourceRunId = reader.IsDBNull(6) ? (Guid?)null : reader.GetGuid(6); var actorId = reader.GetString(7); var actorType = reader.GetString(8); var occurredAt = reader.GetFieldValue(9); var recordedAt = reader.GetFieldValue(10); var eventBodyJson = reader.GetFieldValue(11); var eventBody = JsonNode.Parse(eventBodyJson)?.AsObject() ?? throw new InvalidOperationException("Failed to parse ledger event body."); var eventHash = reader.GetString(12); var previousHash = reader.GetString(13); var merkleLeafHash = reader.GetString(14); var evidenceBundleRef = reader.IsDBNull(15) ? null : reader.GetString(15); var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(eventBody); var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(canonicalEnvelope); return new LedgerEventRecord( tenantId, chainId, sequenceNumber, eventId, eventType, policyVersion, findingId, artifactId, sourceRunId, actorId, actorType, occurredAt, recordedAt, eventBody, eventHash, previousHash, merkleLeafHash, canonicalJson, evidenceBundleRef); } public async Task> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken) { const string sql = """ SELECT event_id, evidence_bundle_ref, recorded_at FROM ledger_events WHERE tenant_id = @tenant_id AND finding_id = @finding_id AND evidence_bundle_ref IS NOT NULL ORDER BY recorded_at DESC """; await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false); await using var command = new NpgsqlCommand(sql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenant_id", tenantId); command.Parameters.AddWithValue("finding_id", findingId); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); var results = new List(); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { results.Add(new EvidenceReference( reader.GetGuid(0), reader.GetString(1), reader.GetFieldValue(2))); } return results; } }