From a5944bbe5d740d1b4aa485292d11c175b94af487 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 10:10:38 -0400 Subject: [PATCH] feat(audit): MxGateway canonical SQLite audit_event store + IAuditWriter + IApiKeyAuditStore->canonical adapter (Task 2.3) --- .../Security/Audit/CanonicalAuditWriter.cs | 42 +++ .../CanonicalForwardingApiKeyAuditStore.cs | 143 +++++++++ .../Audit/SqliteCanonicalAuditStore.cs | 138 +++++++++ .../AuthStoreServiceCollectionExtensions.cs | 29 ++ .../ZB.MOM.WW.MxGateway.Server.csproj | 1 + .../CanonicalAuditStoreAndAdapterTests.cs | 287 ++++++++++++++++++ 6 files changed, 640 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Security/Audit/CanonicalAuditWriter.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Security/Audit/CanonicalForwardingApiKeyAuditStore.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Security/Audit/SqliteCanonicalAuditStore.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/CanonicalAuditStoreAndAdapterTests.cs 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(); + } +}