// Copyright (c) StellaOps. Licensed under the BUSL-1.1. using System.Data; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Npgsql; using NpgsqlTypes; using StellaOps.Timeline.Core.Postgres; namespace StellaOps.Timeline.WebService.Audit; /// /// PostgreSQL-backed audit event store with SHA-256 hash chain integrity. /// Replaces the in-memory to ensure audit events /// survive service restarts and provide tamper-evident chain verification. /// /// /// Hash chain pattern follows the JobEngine audit_entries design: /// - Each event gets a content_hash computed from canonical JSON of its fields. /// - Each event links to the previous event's content_hash via previous_entry_hash. /// - Sequence numbers are monotonically increasing per tenant. /// - SERIALIZABLE isolation ensures chain integrity under concurrent writes. /// public sealed class PostgresUnifiedAuditEventStore { private readonly TimelineCoreDataSource _dataSource; private readonly ILogger _logger; private static readonly JsonSerializerOptions CanonicalJsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.Never, WriteIndented = false, PropertyNamingPolicy = null, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; public PostgresUnifiedAuditEventStore( TimelineCoreDataSource dataSource, ILogger logger) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Persists an audit event with hash chain integrity. /// Uses SERIALIZABLE isolation to prevent concurrent chain corruption. /// public async Task AddAsync(UnifiedAuditEvent auditEvent, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(auditEvent); var tenantId = auditEvent.TenantId ?? "default"; await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken) .ConfigureAwait(false); await using var transaction = await connection.BeginTransactionAsync( IsolationLevel.Serializable, cancellationToken).ConfigureAwait(false); try { // Step 1: Get next sequence number and previous hash atomically var (sequenceNumber, previousHash) = await GetNextSequenceAsync( connection, transaction, tenantId, cancellationToken).ConfigureAwait(false); // Step 2: Compute content hash from canonical JSON var contentHash = ComputeContentHash(auditEvent, tenantId, sequenceNumber); // Step 3: Insert the audit event await InsertEventAsync( connection, transaction, auditEvent, tenantId, sequenceNumber, previousHash, contentHash, cancellationToken).ConfigureAwait(false); // Step 4: Update the sequence tracker with the new hash await UpdateSequenceHashAsync( connection, transaction, tenantId, contentHash, cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); _logger.LogDebug( "Persisted audit event {EventId} for tenant {TenantId} (seq={Seq}, hash={Hash})", auditEvent.Id, tenantId, sequenceNumber, contentHash[..16]); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Failed to persist audit event {EventId} for tenant {TenantId}", auditEvent.Id, tenantId); try { await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); } catch (Exception rollbackEx) { _logger.LogWarning(rollbackEx, "Rollback failed for audit event {EventId}", auditEvent.Id); } throw; } } /// /// Returns all persisted audit events, ordered by timestamp descending. /// public async Task> GetAllAsync(CancellationToken cancellationToken = default) { await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken) .ConfigureAwait(false); const string sql = """ SELECT id, tenant_id, timestamp, module, action, severity, actor_id, actor_name, actor_email, actor_type, actor_ip, actor_user_agent, resource_type, resource_id, resource_name, description, details_jsonb, diff_jsonb, correlation_id, parent_event_id, tags FROM timeline.unified_audit_events ORDER BY timestamp DESC, id ASC LIMIT 10000 """; await using var command = new NpgsqlCommand(sql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); var events = new List(); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { events.Add(MapReaderToEvent(reader)); } return events; } /// /// Returns persisted audit events for a specific tenant, ordered by timestamp descending. /// public async Task> GetByTenantAsync( string tenantId, int limit = 1000, CancellationToken cancellationToken = default) { await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken) .ConfigureAwait(false); const string sql = """ SELECT id, tenant_id, timestamp, module, action, severity, actor_id, actor_name, actor_email, actor_type, actor_ip, actor_user_agent, resource_type, resource_id, resource_name, description, details_jsonb, diff_jsonb, correlation_id, parent_event_id, tags FROM timeline.unified_audit_events WHERE tenant_id = @tenantId ORDER BY timestamp DESC, id ASC LIMIT @limit """; await using var command = new NpgsqlCommand(sql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenantId", tenantId); command.Parameters.AddWithValue("limit", Math.Clamp(limit, 1, 10000)); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); var events = new List(); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { events.Add(MapReaderToEvent(reader)); } return events; } /// /// Verifies the hash chain integrity for a tenant. /// public async Task VerifyChainAsync( string tenantId, long? startSequence = null, long? endSequence = null, CancellationToken cancellationToken = default) { await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken) .ConfigureAwait(false); const string sql = """ SELECT is_valid, invalid_event_id, invalid_sequence, error_message FROM timeline.verify_unified_audit_chain(@tenantId, @startSeq, @endSeq) """; await using var command = new NpgsqlCommand(sql, connection); command.CommandTimeout = _dataSource.CommandTimeoutSeconds; command.Parameters.AddWithValue("tenantId", tenantId); command.Parameters.AddWithValue("startSeq", startSequence ?? 1L); command.Parameters.AddWithValue("endSeq", (object?)endSequence ?? DBNull.Value); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { return new AuditChainVerificationResult { IsValid = reader.GetBoolean(0), InvalidEventId = reader.IsDBNull(1) ? null : reader.GetString(1), InvalidSequence = reader.IsDBNull(2) ? null : reader.GetInt64(2), ErrorMessage = reader.IsDBNull(3) ? null : reader.GetString(3) }; } // No rows returned means empty chain, which is valid return new AuditChainVerificationResult { IsValid = true }; } // ── Private helpers ────────────────────────────────────────────────────── private static async Task<(long SequenceNumber, string? PreviousHash)> GetNextSequenceAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, string tenantId, CancellationToken cancellationToken) { const string sql = "SELECT next_seq, prev_hash FROM timeline.next_unified_audit_sequence(@tenantId)"; await using var command = new NpgsqlCommand(sql, connection, transaction); command.Parameters.AddWithValue("tenantId", tenantId); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { throw new InvalidOperationException($"Failed to acquire audit sequence for tenant {tenantId}"); } var sequenceNumber = reader.GetInt64(0); var previousHash = reader.IsDBNull(1) ? null : reader.GetString(1); return (sequenceNumber, previousHash); } private static async Task InsertEventAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, UnifiedAuditEvent auditEvent, string tenantId, long sequenceNumber, string? previousHash, string contentHash, CancellationToken cancellationToken) { const string sql = """ INSERT INTO timeline.unified_audit_events ( id, tenant_id, timestamp, module, action, severity, actor_id, actor_name, actor_email, actor_type, actor_ip, actor_user_agent, resource_type, resource_id, resource_name, description, details_jsonb, diff_jsonb, correlation_id, parent_event_id, tags, content_hash, previous_entry_hash, sequence_number, created_at ) VALUES ( @id, @tenantId, @timestamp, @module, @action, @severity, @actorId, @actorName, @actorEmail, @actorType, @actorIp, @actorUserAgent, @resourceType, @resourceId, @resourceName, @description, @detailsJsonb::jsonb, @diffJsonb::jsonb, @correlationId, @parentEventId, @tags, @contentHash, @previousHash, @sequenceNumber, NOW() ) ON CONFLICT (id, tenant_id) DO NOTHING """; await using var command = new NpgsqlCommand(sql, connection, transaction); command.Parameters.AddWithValue("id", auditEvent.Id); command.Parameters.AddWithValue("tenantId", tenantId); command.Parameters.AddWithValue("timestamp", auditEvent.Timestamp); command.Parameters.AddWithValue("module", auditEvent.Module); command.Parameters.AddWithValue("action", auditEvent.Action); command.Parameters.AddWithValue("severity", auditEvent.Severity); command.Parameters.AddWithValue("actorId", auditEvent.Actor.Id); command.Parameters.AddWithValue("actorName", auditEvent.Actor.Name); command.Parameters.AddWithValue("actorEmail", (object?)auditEvent.Actor.Email ?? DBNull.Value); command.Parameters.AddWithValue("actorType", auditEvent.Actor.Type); command.Parameters.AddWithValue("actorIp", (object?)auditEvent.Actor.IpAddress ?? DBNull.Value); command.Parameters.AddWithValue("actorUserAgent", (object?)auditEvent.Actor.UserAgent ?? DBNull.Value); command.Parameters.AddWithValue("resourceType", auditEvent.Resource.Type); command.Parameters.AddWithValue("resourceId", auditEvent.Resource.Id); command.Parameters.AddWithValue("resourceName", (object?)auditEvent.Resource.Name ?? DBNull.Value); command.Parameters.AddWithValue("description", auditEvent.Description); command.Parameters.AddWithValue("detailsJsonb", SerializeDetails(auditEvent.Details)); command.Parameters.AddWithValue("diffJsonb", (object?)SerializeDiff(auditEvent.Diff) ?? DBNull.Value); command.Parameters.AddWithValue("correlationId", (object?)auditEvent.CorrelationId ?? DBNull.Value); command.Parameters.AddWithValue("parentEventId", (object?)auditEvent.ParentEventId ?? DBNull.Value); command.Parameters.Add(new NpgsqlParameter("tags", NpgsqlDbType.Array | NpgsqlDbType.Text) { Value = auditEvent.Tags.ToArray() }); command.Parameters.AddWithValue("contentHash", contentHash); command.Parameters.AddWithValue("previousHash", (object?)previousHash ?? DBNull.Value); command.Parameters.AddWithValue("sequenceNumber", sequenceNumber); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } private static async Task UpdateSequenceHashAsync( NpgsqlConnection connection, NpgsqlTransaction transaction, string tenantId, string contentHash, CancellationToken cancellationToken) { const string sql = "SELECT timeline.update_unified_audit_sequence_hash(@tenantId, @contentHash)"; await using var command = new NpgsqlCommand(sql, connection, transaction); command.Parameters.AddWithValue("tenantId", tenantId); command.Parameters.AddWithValue("contentHash", contentHash); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } /// /// Computes a SHA-256 content hash from the canonical JSON representation of an audit event. /// Fields are sorted lexicographically for deterministic hashing. /// internal static string ComputeContentHash(UnifiedAuditEvent auditEvent, string tenantId, long sequenceNumber) { // Build canonical representation with sorted keys var canonical = new { Action = auditEvent.Action, ActorId = auditEvent.Actor.Id, ActorName = auditEvent.Actor.Name, ActorType = auditEvent.Actor.Type, CorrelationId = auditEvent.CorrelationId, Description = auditEvent.Description, Id = auditEvent.Id, Module = auditEvent.Module, ResourceId = auditEvent.Resource.Id, ResourceType = auditEvent.Resource.Type, SequenceNumber = sequenceNumber, Severity = auditEvent.Severity, TenantId = tenantId, Timestamp = auditEvent.Timestamp }; var canonicalJson = ToCanonicalJson(canonical); var bytes = Encoding.UTF8.GetBytes(canonicalJson); var hash = SHA256.HashData(bytes); return Convert.ToHexStringLower(hash); } private static string ToCanonicalJson(T value) { var node = JsonSerializer.SerializeToNode(value, CanonicalJsonOptions) ?? new JsonObject(); var ordered = OrderNode(node.DeepClone()); return ordered.ToJsonString(CanonicalJsonOptions); } private static JsonNode OrderNode(JsonNode node) { switch (node) { case JsonObject obj: var orderedObj = new JsonObject(); foreach (var kvp in obj.OrderBy(x => x.Key, StringComparer.Ordinal)) { orderedObj.Add(kvp.Key, kvp.Value is null ? null : OrderNode(kvp.Value.DeepClone())); } return orderedObj; case JsonArray arr: var orderedArr = new JsonArray(); foreach (var item in arr) { orderedArr.Add(item is null ? null : OrderNode(item.DeepClone())); } return orderedArr; default: return node.DeepClone(); } } private static string SerializeDetails(IReadOnlyDictionary details) { return JsonSerializer.Serialize(details, CanonicalJsonOptions); } private static string? SerializeDiff(UnifiedAuditDiff? diff) { if (diff is null) { return null; } return JsonSerializer.Serialize(diff, CanonicalJsonOptions); } private static UnifiedAuditEvent MapReaderToEvent(NpgsqlDataReader reader) { var detailsJson = reader.IsDBNull(16) ? "{}" : reader.GetString(16); var diffJson = reader.IsDBNull(17) ? null : reader.GetString(17); var tagsArray = reader.IsDBNull(20) ? Array.Empty() : reader.GetFieldValue(20); return new UnifiedAuditEvent { Id = reader.GetString(0), TenantId = reader.GetString(1), Timestamp = reader.GetFieldValue(2), Module = reader.GetString(3), Action = reader.GetString(4), Severity = reader.GetString(5), Actor = new UnifiedAuditActor { Id = reader.GetString(6), Name = reader.GetString(7), Email = reader.IsDBNull(8) ? null : reader.GetString(8), Type = reader.GetString(9), IpAddress = reader.IsDBNull(10) ? null : reader.GetString(10), UserAgent = reader.IsDBNull(11) ? null : reader.GetString(11) }, Resource = new UnifiedAuditResource { Type = reader.GetString(12), Id = reader.GetString(13), Name = reader.IsDBNull(14) ? null : reader.GetString(14) }, Description = reader.GetString(15), Details = DeserializeDetails(detailsJson), Diff = DeserializeDiff(diffJson), CorrelationId = reader.IsDBNull(18) ? null : reader.GetString(18), ParentEventId = reader.IsDBNull(19) ? null : reader.GetString(19), Tags = tagsArray }; } private static IReadOnlyDictionary DeserializeDetails(string json) { try { return JsonSerializer.Deserialize>(json) ?? new Dictionary(); } catch (JsonException) { return new Dictionary(); } } private static UnifiedAuditDiff? DeserializeDiff(string? json) { if (string.IsNullOrWhiteSpace(json)) { return null; } try { return JsonSerializer.Deserialize(json); } catch (JsonException) { return null; } } } /// /// Result of hash chain verification for a tenant's audit events. /// public sealed record AuditChainVerificationResult { /// Whether the chain is intact. public required bool IsValid { get; init; } /// Event ID where the chain breaks, if any. public string? InvalidEventId { get; init; } /// Sequence number where the chain breaks, if any. public long? InvalidSequence { get; init; } /// Human-readable error message if the chain is broken. public string? ErrorMessage { get; init; } }