259 lines
10 KiB
C#
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;
|
|
}
|
|
}
|