using System.Globalization; using Microsoft.Data.Sqlite; using ZB.MOM.WW.Audit; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; /// /// MxGateway-owned, append-only SQLite store for canonical /// s. It writes to a NEW audit_event table in the /// SAME database file as the shared ZB.MOM.WW.Auth.ApiKeys stores: both share /// the library's (so they target the same /// ApiKeyOptions.SqlitePath with the same WAL/busy-timeout connection config). /// /// /// This store is the canonical sink for ALL MxGateway audit. The library's own /// api_key_audit table is left in place but UNUSED after adoption — the library's /// IApiKeyAuditStore registration is overridden by /// , which forwards onto this store via /// . The library's schema_version / /// api_key_audit tables are not touched here; the audit_event table is /// created idempotently (CREATE TABLE IF NOT EXISTS) on each write so it /// self-bootstraps regardless of migration ordering. /// public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory) { private const string CreateTableSql = """ CREATE TABLE IF NOT EXISTS audit_event ( event_id TEXT PRIMARY KEY, occurred_at_utc TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, outcome TEXT NOT NULL, category TEXT NULL, target TEXT NULL, source_node TEXT NULL, correlation_id TEXT NULL, details_json TEXT NULL ); """; /// Inserts a canonical audit event into the audit_event table. /// The canonical event to persist. /// Token to observe for cancellation. public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(auditEvent); await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ INSERT INTO audit_event (event_id, occurred_at_utc, actor, action, outcome, category, target, source_node, correlation_id, details_json) VALUES ($event_id, $occurred_at_utc, $actor, $action, $outcome, $category, $target, $source_node, $correlation_id, $details_json); """; command.Parameters.AddWithValue("$event_id", auditEvent.EventId.ToString()); command.Parameters.AddWithValue("$occurred_at_utc", auditEvent.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture)); command.Parameters.AddWithValue("$actor", auditEvent.Actor); command.Parameters.AddWithValue("$action", auditEvent.Action); command.Parameters.AddWithValue("$outcome", auditEvent.Outcome.ToString()); command.Parameters.AddWithValue("$category", (object?)auditEvent.Category ?? DBNull.Value); command.Parameters.AddWithValue("$target", (object?)auditEvent.Target ?? DBNull.Value); command.Parameters.AddWithValue("$source_node", (object?)auditEvent.SourceNode ?? DBNull.Value); command.Parameters.AddWithValue("$correlation_id", (object?)auditEvent.CorrelationId?.ToString() ?? DBNull.Value); command.Parameters.AddWithValue("$details_json", (object?)auditEvent.DetailsJson ?? DBNull.Value); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } /// Returns the most recent canonical audit events, newest first. /// Maximum number of events to return. /// Token to observe for cancellation. public async Task> ListRecentAsync(int limit, CancellationToken cancellationToken) { if (limit <= 0) { return []; } await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ SELECT event_id, occurred_at_utc, actor, action, outcome, category, target, source_node, correlation_id, details_json FROM audit_event ORDER BY rowid DESC LIMIT $limit; """; command.Parameters.AddWithValue("$limit", limit); List events = []; await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { events.Add(new AuditEvent { EventId = Guid.Parse(reader.GetString(0)), OccurredAtUtc = ParseUtc(reader.GetString(1)), Actor = reader.GetString(2), Action = reader.GetString(3), Outcome = Enum.Parse(reader.GetString(4)), Category = reader.IsDBNull(5) ? null : reader.GetString(5), Target = reader.IsDBNull(6) ? null : reader.GetString(6), SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7), CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)), DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9), }); } return events; } private static async Task EnsureTableAsync(SqliteConnection connection, CancellationToken cancellationToken) { await using SqliteCommand command = connection.CreateCommand(); command.CommandText = CreateTableSql; await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } private static DateTimeOffset ParseUtc(string value) => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); }