Files
git.stella-ops.org/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Postgres/PostgresLedgerEventRepository.cs
2026-02-01 21:37:40 +02:00

259 lines
10 KiB
C#

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<PostgresLedgerEventRepository> _logger;
public PostgresLedgerEventRepository(
LedgerDataSource dataSource,
ILogger<PostgresLedgerEventRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<LedgerEventRecord?> 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<LedgerChainHead?> 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<DateTimeOffset>(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<string>("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<Guid>(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<DateTimeOffset>(9);
var recordedAt = reader.GetFieldValue<DateTimeOffset>(10);
var eventBodyJson = reader.GetFieldValue<string>(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<IReadOnlyList<EvidenceReference>> 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<EvidenceReference>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(new EvidenceReference(
reader.GetGuid(0),
reader.GetString(1),
reader.GetFieldValue<DateTimeOffset>(2)));
}
return results;
}
}