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, but the CREATE TABLE IF NOT
/// EXISTS is latched: runs
/// once at startup, and every later insert/list/delete
/// then skips the DDL round-trip. Keeping the (now free) check on each path rather than
/// dropping it means the store still self-bootstraps for callers that use it without the
/// hosted drain — the apikey CLI and the DI-only tests — regardless of migration
/// ordering. The latch is deliberately racy: a lost race merely re-runs an idempotent
/// CREATE TABLE IF NOT EXISTS, and a failure leaves the latch open so the next call
/// retries.
///
///
/// Factory for connections to the shared auth database file.
///
/// Optional logger for row-level read diagnostics. Optional because the store is also constructed
/// directly by the apikey CLI path and by DI-free tests, which have no logger to hand.
///
public sealed class SqliteCanonicalAuditStore(
AuthSqliteConnectionFactory connectionFactory,
ILogger? logger = null) : IAuditEventSink
{
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
);
""";
private const string InsertSql =
"""
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);
""";
/// 0 until the audit_event table has been created at least once by this instance.
private int _tableEnsured;
///
public async Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
}
///
public Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(auditEvent);
return InsertBatchAsync([auditEvent], cancellationToken);
}
///
// One connection, one transaction and one prepared command for the whole batch: the drain
// pays a single commit for up to AuditDrainService.MaxBatchSize events rather
// than one round-trip per event.
public async Task InsertBatchAsync(IReadOnlyList auditEvents, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(auditEvents);
if (auditEvents.Count == 0)
{
return;
}
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
await using SqliteTransaction transaction =
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (SqliteCommand command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = InsertSql;
SqliteParameter eventId = command.Parameters.Add("$event_id", SqliteType.Text);
SqliteParameter occurredAtUtc = command.Parameters.Add("$occurred_at_utc", SqliteType.Text);
SqliteParameter actor = command.Parameters.Add("$actor", SqliteType.Text);
SqliteParameter action = command.Parameters.Add("$action", SqliteType.Text);
SqliteParameter outcome = command.Parameters.Add("$outcome", SqliteType.Text);
SqliteParameter category = command.Parameters.Add("$category", SqliteType.Text);
SqliteParameter target = command.Parameters.Add("$target", SqliteType.Text);
SqliteParameter sourceNode = command.Parameters.Add("$source_node", SqliteType.Text);
SqliteParameter correlationId = command.Parameters.Add("$correlation_id", SqliteType.Text);
SqliteParameter detailsJson = command.Parameters.Add("$details_json", SqliteType.Text);
foreach (AuditEvent auditEvent in auditEvents)
{
ArgumentNullException.ThrowIfNull(auditEvent);
eventId.Value = auditEvent.EventId.ToString();
occurredAtUtc.Value = auditEvent.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture);
actor.Value = auditEvent.Actor;
action.Value = auditEvent.Action;
outcome.Value = auditEvent.Outcome.ToString();
category.Value = (object?)auditEvent.Category ?? DBNull.Value;
target.Value = (object?)auditEvent.Target ?? DBNull.Value;
sourceNode.Value = (object?)auditEvent.SourceNode ?? DBNull.Value;
correlationId.Value = (object?)auditEvent.CorrelationId?.ToString() ?? DBNull.Value;
detailsJson.Value = (object?)auditEvent.DetailsJson ?? DBNull.Value;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
///
// The comparison goes through SQLite's datetime() rather than comparing the stored
// ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized
// ISO-8601 — which AuditEvent.OccurredAtUtc guarantees for rows written through
// this store, but not for rows that entered the table any other way (a repair script, an
// older schema, a future producer). On a mixed-format column a text comparison silently
// deletes live audit: 2026-05-17T09:00:00-05:00 is two hours AFTER a
// 2026-05-17T12:00:00+00:00 cutoff yet sorts before it. Comparing instants is correct
// regardless of how the text got there, and anything datetime() cannot parse yields
// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
public async Task DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
"""
DELETE FROM audit_event
WHERE datetime(occurred_at_utc) < datetime($cutoff);
""";
command.Parameters.AddWithValue(
"$cutoff",
cutoffUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
return 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.
/// The most recent audit events, up to , newest first.
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 = ParseUtcOrMinValue(reader.GetString(1), reader.GetString(0)),
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;
}
// Latched bootstrap: after the first success this is a single volatile read, so the DDL
// round-trip is paid once per process rather than once per audit write.
private async Task EnsureTableAsync(SqliteConnection connection, CancellationToken cancellationToken)
{
if (Volatile.Read(ref _tableEnsured) == 1)
{
return;
}
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = CreateTableSql;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
Volatile.Write(ref _tableEnsured, 1);
}
// Reading is defensive where writing is not: an insert always round-trips "O", but the table is
// append-only shared state that an operator (or a future migration) can put an unparseable
// timestamp into, and the retention sweep deliberately keeps such a row — SQLite's datetime()
// yields NULL for it, so the DELETE's comparison is never true. A throwing Parse here would let
// that single row take out the dashboard's whole recent-audit view. MinValue instead sorts the
// row to the far past and keeps every other column readable, which is what an operator looking
// at the view actually needs.
private DateTimeOffset ParseUtcOrMinValue(string value, string eventId)
{
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTimeOffset parsed))
{
return parsed;
}
// Debug, not warning: the row is still returned and the timestamp text itself is not logged
// (audit rows are not secrets, but the value is attacker-influenceable in the worst case).
logger?.LogDebug(
"Audit event {EventId} has an unparseable occurred_at_utc; reporting it as DateTimeOffset.MinValue.",
eventId);
return DateTimeOffset.MinValue;
}
}