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);
}