diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/CanonicalAuditWriter.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/CanonicalAuditWriter.cs
new file mode 100644
index 0000000..06a7ad7
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/CanonicalAuditWriter.cs
@@ -0,0 +1,42 @@
+using ZB.MOM.WW.Audit;
+
+namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
+
+///
+/// Best-effort over the MxGateway-owned
+/// . It honours the canonical
+/// contract: a failed audit write is swallowed and logged
+/// rather than propagated, so it can never abort the user-facing action that produced it.
+///
+///
+/// This is the single sink through which ALL MxGateway audit flows — the library admin
+/// verbs (via ) and the gateway's own
+/// dashboard / constraint-denial producers, which write canonical events directly. The
+/// best-effort wrapping here also closes the gap that the library's
+/// SqliteApiKeyAuditStore.AppendAsync propagated exceptions.
+///
+public sealed class CanonicalAuditWriter(
+ SqliteCanonicalAuditStore store,
+ ILogger logger) : IAuditWriter
+{
+ ///
+ public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(auditEvent);
+
+ try
+ {
+ await store.InsertAsync(auditEvent, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ // Best-effort: a failed audit write must never abort the action that produced it.
+ // Swallow everything (including OperationCanceledException) and log for diagnosis.
+ logger.LogWarning(
+ exception,
+ "Failed to persist audit event {EventId} (action {Action}); audit write is best-effort and was suppressed.",
+ auditEvent.EventId,
+ auditEvent.Action);
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/CanonicalForwardingApiKeyAuditStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/CanonicalForwardingApiKeyAuditStore.cs
new file mode 100644
index 0000000..befb60b
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/CanonicalForwardingApiKeyAuditStore.cs
@@ -0,0 +1,143 @@
+using System.Text.Json;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+
+namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
+
+///
+/// Adapter that overrides the shared library's so that
+/// library-emitted API-key audit events (CLI / admin verbs from
+/// ApiKeyAdminCommands) are canonicalized onto and routed
+/// through the gateway's into the canonical
+/// audit_event store.
+///
+///
+/// Overriding the registered is the ONLY way to
+/// canonicalize the library-internal ApiKeyAdminCommands events, since that type
+/// cannot be edited. reads back from the canonical store
+/// and maps each to an so the
+/// existing dashboard "recent audit" view (and the CLI/store tests) keep working through
+/// this same seam, unchanged.
+///
+/// The library's own api_key_audit table is left in place but UNUSED — nothing
+/// writes to it once this adapter overrides the library's SqliteApiKeyAuditStore
+/// registration.
+///
+///
+public sealed class CanonicalForwardingApiKeyAuditStore(
+ IAuditWriter auditWriter,
+ SqliteCanonicalAuditStore store) : IApiKeyAuditStore
+{
+ /// The canonical assigned to API-key events.
+ public const string ApiKeyCategory = "ApiKey";
+
+ /// Actor used for the library's keyless init-db event.
+ private const string SystemActor = "system";
+
+ /// Actor used for any other keyless (CLI-originated) library event.
+ private const string CliActor = "cli";
+
+ /// The library event type that denotes a constraint denial.
+ private const string ConstraintDeniedEventType = "constraint-denied";
+
+ /// The library's keyless schema-init event type.
+ private const string InitDbEventType = "init-db";
+
+ ///
+ public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+
+ AuditEvent auditEvent = new()
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = entry.CreatedUtc,
+ // Keyless library events: init-db is system-originated; any other keyless event
+ // is a CLI/admin verb run without an authenticated principal.
+ Actor = entry.KeyId
+ ?? (entry.EventType == InitDbEventType ? SystemActor : CliActor),
+ Action = entry.EventType,
+ Outcome = entry.EventType == ConstraintDeniedEventType
+ ? AuditOutcome.Denied
+ : AuditOutcome.Success,
+ Category = ApiKeyCategory,
+ Target = entry.KeyId,
+ SourceNode = entry.RemoteAddress,
+ CorrelationId = null,
+ DetailsJson = WrapDetails(entry.Details),
+ };
+
+ // Best-effort: IAuditWriter swallows/logs failures, so this never throws.
+ await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task> ListRecentAsync(int limit, CancellationToken ct)
+ {
+ IReadOnlyList events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false);
+
+ ApiKeyAuditEntry[] entries = new ApiKeyAuditEntry[events.Count];
+ for (int index = 0; index < events.Count; index++)
+ {
+ AuditEvent auditEvent = events[index];
+ entries[index] = new ApiKeyAuditEntry(
+ KeyId: auditEvent.Actor switch
+ {
+ // Keyless library events were mapped to the system/cli sentinel actors on the
+ // way in; map them back to a null KeyId so the dashboard view is faithful.
+ SystemActor or CliActor => null,
+ string actor => actor,
+ },
+ EventType: auditEvent.Action,
+ RemoteAddress: auditEvent.SourceNode,
+ CreatedUtc: auditEvent.OccurredAtUtc,
+ Details: UnwrapDetails(auditEvent.DetailsJson));
+ }
+
+ return entries;
+ }
+
+ ///
+ /// Wraps a free-form library detail string into the canonical
+ /// {"detail": "<escaped>"} JSON envelope, or null when there is no detail.
+ ///
+ private static string? WrapDetails(string? details)
+ {
+ if (details is null)
+ {
+ return null;
+ }
+
+ return JsonSerializer.Serialize(new Dictionary { ["detail"] = details });
+ }
+
+ ///
+ /// Unwraps the canonical detail envelope back to the original free-form string. Falls
+ /// back to the raw JSON when it is not a recognised {"detail": ...} envelope, so
+ /// directly-emitted canonical events (whose DetailsJson is richer) still surface text.
+ ///
+ private static string? UnwrapDetails(string? detailsJson)
+ {
+ if (string.IsNullOrEmpty(detailsJson))
+ {
+ return null;
+ }
+
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(detailsJson);
+ if (document.RootElement.ValueKind == JsonValueKind.Object
+ && document.RootElement.TryGetProperty("detail", out JsonElement detail)
+ && detail.ValueKind == JsonValueKind.String)
+ {
+ return detail.GetString();
+ }
+ }
+ catch (JsonException)
+ {
+ // Not JSON we recognise; surface the raw payload below.
+ }
+
+ return detailsJson;
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/SqliteCanonicalAuditStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/SqliteCanonicalAuditStore.cs
new file mode 100644
index 0000000..64c1133
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/SqliteCanonicalAuditStore.cs
@@ -0,0 +1,138 @@
+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);
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs
index 4c99ff2..0554401 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs
@@ -1,10 +1,12 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
+using ZB.MOM.WW.MxGateway.Server.Security.Audit;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
@@ -64,6 +66,33 @@ public static class AuthStoreServiceCollectionExtensions
// migrator and the migration hosted service.
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
+ // Canonical audit (Task 2.3 — DEEP adopt ZB.MOM.WW.Audit). All MxGateway audit flows as a
+ // canonical AuditEvent through the library IAuditWriter, persisted in a NEW gateway-owned
+ // audit_event table that lives in the SAME SQLite DB file as the api-key stores (it reuses
+ // the library's AuthSqliteConnectionFactory, registered by AddZbApiKeyAuth above).
+ services.AddSingleton(sp =>
+ new SqliteCanonicalAuditStore(sp.GetRequiredService()));
+ // Resolve the logger defensively: the production host always registers ILogger, but the
+ // DI-only auth/CLI/dashboard unit tests build a bare ServiceCollection without AddLogging().
+ // Fall back to NullLogger there so the audit writer (and the IApiKeyAuditStore override that
+ // depends on it) still resolve. The write path is best-effort regardless.
+ services.AddSingleton(sp =>
+ new CanonicalAuditWriter(
+ sp.GetRequiredService(),
+ sp.GetService>()
+ ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance));
+
+ // OVERRIDE the library's IApiKeyAuditStore (AddZbApiKeyAuth registered the library's
+ // SqliteApiKeyAuditStore via TryAddSingleton) with an adapter that canonicalizes every
+ // library-emitted ApiKeyAuditEntry onto AuditEvent and forwards it through IAuditWriter.
+ // This is the only way to canonicalize the library-internal ApiKeyAdminCommands verbs
+ // (create/revoke/rotate/init-db, etc.), which we cannot edit. The adapter is registered
+ // after AddZbApiKeyAuth so it (last registration) is what ApiKeyAdminCommands resolves and
+ // what the dashboard "recent audit" view reads via IApiKeyAuditStore.ListRecentAsync.
+ // The library's api_key_audit table is left in place but is now UNUSED — nothing writes to
+ // it once this adapter replaces the library's audit store.
+ services.AddSingleton();
+
// The shared admin command set (create/revoke/rotate/list/init-db with audit) is not
// auto-registered by AddZbApiKeyAuth; the gateway CLI and dashboard drive it, so register
// it here over the already-wired stores, pepper provider and migrator.
diff --git a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
index 961f650..0f877b1 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
+++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/CanonicalAuditStoreAndAdapterTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/CanonicalAuditStoreAndAdapterTests.cs
new file mode 100644
index 0000000..2eefb63
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/CanonicalAuditStoreAndAdapterTests.cs
@@ -0,0 +1,287 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
+using ZB.MOM.WW.MxGateway.Server.Security.Audit;
+using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Security.Audit;
+
+///
+/// Tests the Task 2.3 canonical audit plumbing: the gateway-owned
+/// (round-trips canonical
+/// s through the new audit_event table), the best-effort
+/// , and the
+/// adapter that maps the library's onto the canonical model
+/// and back (so the dashboard recent-audit view keeps working). All against a real
+/// temporary SQLite database, sharing the library's connection factory.
+///
+public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
+{
+ private readonly List _tempDirectories = [];
+
+ /// A canonical event with all fields populated round-trips through the store.
+ [Fact]
+ public async Task Store_InsertThenListRecent_RoundTripsAllFields()
+ {
+ SqliteCanonicalAuditStore store = CreateStore();
+
+ Guid eventId = Guid.NewGuid();
+ Guid correlationId = Guid.NewGuid();
+ DateTimeOffset occurred = new(2026, 6, 1, 12, 34, 56, TimeSpan.Zero);
+ AuditEvent original = new()
+ {
+ EventId = eventId,
+ OccurredAtUtc = occurred,
+ Actor = "operator01",
+ Action = "dashboard-create-key",
+ Outcome = AuditOutcome.Success,
+ Category = "ApiKey",
+ Target = "operator01",
+ SourceNode = "127.0.0.1",
+ CorrelationId = correlationId,
+ DetailsJson = "{\"detail\":\"created\"}",
+ };
+
+ await store.InsertAsync(original, CancellationToken.None);
+
+ IReadOnlyList recent = await store.ListRecentAsync(10, CancellationToken.None);
+
+ AuditEvent persisted = Assert.Single(recent);
+ Assert.Equal(eventId, persisted.EventId);
+ Assert.Equal(occurred, persisted.OccurredAtUtc);
+ Assert.Equal("operator01", persisted.Actor);
+ Assert.Equal("dashboard-create-key", persisted.Action);
+ Assert.Equal(AuditOutcome.Success, persisted.Outcome);
+ Assert.Equal("ApiKey", persisted.Category);
+ Assert.Equal("operator01", persisted.Target);
+ Assert.Equal("127.0.0.1", persisted.SourceNode);
+ Assert.Equal(correlationId, persisted.CorrelationId);
+ Assert.Equal("{\"detail\":\"created\"}", persisted.DetailsJson);
+ }
+
+ /// Nullable canonical fields round-trip as null, not empty string.
+ [Fact]
+ public async Task Store_InsertWithNullOptionalFields_RoundTripsAsNull()
+ {
+ SqliteCanonicalAuditStore store = CreateStore();
+
+ AuditEvent original = new()
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "system",
+ Action = "init-db",
+ Outcome = AuditOutcome.Success,
+ };
+
+ await store.InsertAsync(original, CancellationToken.None);
+
+ AuditEvent persisted = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
+ Assert.Null(persisted.Category);
+ Assert.Null(persisted.Target);
+ Assert.Null(persisted.SourceNode);
+ Assert.Null(persisted.CorrelationId);
+ Assert.Null(persisted.DetailsJson);
+ }
+
+ /// ListRecent returns newest-first.
+ [Fact]
+ public async Task Store_ListRecent_ReturnsNewestFirst()
+ {
+ SqliteCanonicalAuditStore store = CreateStore();
+
+ await store.InsertAsync(MakeEvent("first"), CancellationToken.None);
+ await store.InsertAsync(MakeEvent("second"), CancellationToken.None);
+ await store.InsertAsync(MakeEvent("third"), CancellationToken.None);
+
+ IReadOnlyList recent = await store.ListRecentAsync(10, CancellationToken.None);
+ Assert.Equal(["third", "second", "first"], recent.Select(e => e.Action));
+ }
+
+ /// The writer is best-effort: a faulting store does not surface to the caller.
+ [Fact]
+ public async Task Writer_WhenStoreFails_DoesNotThrow()
+ {
+ // Point the connection factory at a path that cannot be created (a file used as a
+ // directory) so OpenConnectionAsync/insert fails; the writer must swallow it.
+ TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-canonical-audit-fail");
+ _tempDirectories.Add(directory);
+ string filePath = directory.DatabasePath("blocker");
+ File.WriteAllText(filePath, "not a directory");
+ // Nested path under a regular file → directory creation / open fails.
+ AuthSqliteConnectionFactory factory = new(Path.Combine(filePath, "audit.db"));
+ CanonicalAuditWriter writer = new(
+ new SqliteCanonicalAuditStore(factory),
+ NullLogger.Instance);
+
+ // Must not throw.
+ await writer.WriteAsync(MakeEvent("anything"), CancellationToken.None);
+ }
+
+ ///
+ /// A library event with a KeyId maps to a canonical Success event under category ApiKey,
+ /// and the adapter maps it back to the original entry for the dashboard view.
+ ///
+ [Fact]
+ public async Task Adapter_KeyedEvent_RoundTripsThroughCanonicalStore()
+ {
+ (CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter();
+ DateTimeOffset created = new(2026, 6, 1, 9, 0, 0, TimeSpan.Zero);
+
+ await adapter.AppendAsync(
+ new ApiKeyAuditEntry(
+ KeyId: "operator01",
+ EventType: "create-key",
+ RemoteAddress: "10.0.0.5",
+ CreatedUtc: created,
+ Details: "created"),
+ CancellationToken.None);
+
+ // Canonical persisted form.
+ AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
+ Assert.Equal("operator01", canonical.Actor);
+ Assert.Equal("create-key", canonical.Action);
+ Assert.Equal(AuditOutcome.Success, canonical.Outcome);
+ Assert.Equal(CanonicalForwardingApiKeyAuditStore.ApiKeyCategory, canonical.Category);
+ Assert.Equal("operator01", canonical.Target);
+ Assert.Equal("10.0.0.5", canonical.SourceNode);
+ Assert.Equal("{\"detail\":\"created\"}", canonical.DetailsJson);
+
+ // Dashboard-facing form via the adapter's ListRecentAsync.
+ ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None));
+ Assert.Equal("operator01", mappedBack.KeyId);
+ Assert.Equal("create-key", mappedBack.EventType);
+ Assert.Equal("10.0.0.5", mappedBack.RemoteAddress);
+ Assert.Equal(created, mappedBack.CreatedUtc);
+ Assert.Equal("created", mappedBack.Details);
+ }
+
+ /// The keyless library init-db event maps to Actor "system" and back to a null KeyId.
+ [Fact]
+ public async Task Adapter_InitDbKeylessEvent_MapsToSystemActor()
+ {
+ (CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter();
+
+ await adapter.AppendAsync(
+ new ApiKeyAuditEntry(
+ KeyId: null,
+ EventType: "init-db",
+ RemoteAddress: null,
+ CreatedUtc: DateTimeOffset.UtcNow,
+ Details: null),
+ CancellationToken.None);
+
+ AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
+ Assert.Equal("system", canonical.Actor);
+ Assert.Equal(AuditOutcome.Success, canonical.Outcome);
+ Assert.Null(canonical.DetailsJson);
+
+ ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None));
+ Assert.Null(mappedBack.KeyId);
+ Assert.Equal("init-db", mappedBack.EventType);
+ Assert.Null(mappedBack.Details);
+ }
+
+ /// Any other keyless library event maps to Actor "cli" and back to a null KeyId.
+ [Fact]
+ public async Task Adapter_OtherKeylessEvent_MapsToCliActor()
+ {
+ (CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter();
+
+ await adapter.AppendAsync(
+ new ApiKeyAuditEntry(
+ KeyId: null,
+ EventType: "list-keys",
+ RemoteAddress: null,
+ CreatedUtc: DateTimeOffset.UtcNow,
+ Details: null),
+ CancellationToken.None);
+
+ AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
+ Assert.Equal("cli", canonical.Actor);
+
+ ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None));
+ Assert.Null(mappedBack.KeyId);
+ Assert.Equal("list-keys", mappedBack.EventType);
+ }
+
+ /// A constraint-denied library event maps to Outcome.Denied.
+ [Fact]
+ public async Task Adapter_ConstraintDeniedEvent_MapsToDeniedOutcome()
+ {
+ (CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter();
+
+ await adapter.AppendAsync(
+ new ApiKeyAuditEntry(
+ KeyId: "operator01",
+ EventType: "constraint-denied",
+ RemoteAddress: null,
+ CreatedUtc: DateTimeOffset.UtcNow,
+ Details: "Write: 42: write_scope: outside scope"),
+ CancellationToken.None);
+
+ AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None));
+ Assert.Equal(AuditOutcome.Denied, canonical.Outcome);
+ Assert.Equal("operator01", canonical.Actor);
+
+ ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None));
+ Assert.Equal("constraint-denied", mappedBack.EventType);
+ Assert.Equal("Write: 42: write_scope: outside scope", mappedBack.Details);
+ }
+
+ ///
+ /// The adapter does NOT throw when the underlying write fails (it forwards through the
+ /// best-effort writer), preserving the IApiKeyAuditStore caller's flow.
+ ///
+ [Fact]
+ public async Task Adapter_WhenWriterFails_DoesNotThrow()
+ {
+ string badPath = Path.Combine(Path.GetTempPath(), "mxgateway-canonical-audit-fail", Guid.NewGuid().ToString("N"), "blocker");
+ Directory.CreateDirectory(Path.GetDirectoryName(badPath)!);
+ File.WriteAllText(badPath, "not a directory");
+ AuthSqliteConnectionFactory factory = new(Path.Combine(badPath, "audit.db"));
+ SqliteCanonicalAuditStore store = new(factory);
+ CanonicalAuditWriter writer = new(store, NullLogger.Instance);
+ CanonicalForwardingApiKeyAuditStore adapter = new(writer, store);
+
+ await adapter.AppendAsync(
+ new ApiKeyAuditEntry("k", "create-key", null, DateTimeOffset.UtcNow, null),
+ CancellationToken.None);
+ }
+
+ private SqliteCanonicalAuditStore CreateStore()
+ {
+ TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-canonical-audit");
+ _tempDirectories.Add(directory);
+ return new SqliteCanonicalAuditStore(new AuthSqliteConnectionFactory(directory.DatabasePath()));
+ }
+
+ private (CanonicalForwardingApiKeyAuditStore Adapter, SqliteCanonicalAuditStore Store) CreateAdapter()
+ {
+ SqliteCanonicalAuditStore store = CreateStore();
+ CanonicalAuditWriter writer = new(store, NullLogger.Instance);
+ return (new CanonicalForwardingApiKeyAuditStore(writer, store), store);
+ }
+
+ private static AuditEvent MakeEvent(string action) => new()
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "operator01",
+ Action = action,
+ Outcome = AuditOutcome.Success,
+ Category = "ApiKey",
+ };
+
+ /// Clears SQLite pools and deletes every temporary directory created by this test.
+ public void Dispose()
+ {
+ foreach (TempDatabaseDirectory directory in _tempDirectories)
+ {
+ directory.Dispose();
+ }
+
+ _tempDirectories.Clear();
+ }
+}