feat(audit): MxGateway canonical SQLite audit_event store + IAuditWriter + IApiKeyAuditStore->canonical adapter (Task 2.3)
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort <see cref="IAuditWriter"/> over the MxGateway-owned
|
||||
/// <see cref="SqliteCanonicalAuditStore"/>. It honours the canonical
|
||||
/// <see cref="IAuditWriter"/> contract: a failed audit write is swallowed and logged
|
||||
/// rather than propagated, so it can never abort the user-facing action that produced it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the single sink through which ALL MxGateway audit flows — the library admin
|
||||
/// verbs (via <see cref="CanonicalForwardingApiKeyAuditStore"/>) 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
|
||||
/// <c>SqliteApiKeyAuditStore.AppendAsync</c> propagated exceptions.
|
||||
/// </remarks>
|
||||
public sealed class CanonicalAuditWriter(
|
||||
SqliteCanonicalAuditStore store,
|
||||
ILogger<CanonicalAuditWriter> logger) : IAuditWriter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that overrides the shared library's <see cref="IApiKeyAuditStore"/> so that
|
||||
/// library-emitted API-key audit events (CLI / admin verbs from
|
||||
/// <c>ApiKeyAdminCommands</c>) are canonicalized onto <see cref="AuditEvent"/> and routed
|
||||
/// through the gateway's <see cref="IAuditWriter"/> into the canonical
|
||||
/// <c>audit_event</c> store.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Overriding the registered <see cref="IApiKeyAuditStore"/> is the ONLY way to
|
||||
/// canonicalize the library-internal <c>ApiKeyAdminCommands</c> events, since that type
|
||||
/// cannot be edited. <see cref="ListRecentAsync"/> reads back from the canonical store
|
||||
/// and maps each <see cref="AuditEvent"/> to an <see cref="ApiKeyAuditEntry"/> so the
|
||||
/// existing dashboard "recent audit" view (and the CLI/store tests) keep working through
|
||||
/// this same seam, unchanged.
|
||||
/// <para>
|
||||
/// The library's own <c>api_key_audit</c> table is left in place but UNUSED — nothing
|
||||
/// writes to it once this adapter overrides the library's <c>SqliteApiKeyAuditStore</c>
|
||||
/// registration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CanonicalForwardingApiKeyAuditStore(
|
||||
IAuditWriter auditWriter,
|
||||
SqliteCanonicalAuditStore store) : IApiKeyAuditStore
|
||||
{
|
||||
/// <summary>The canonical <see cref="AuditEvent.Category"/> assigned to API-key events.</summary>
|
||||
public const string ApiKeyCategory = "ApiKey";
|
||||
|
||||
/// <summary>Actor used for the library's keyless <c>init-db</c> event.</summary>
|
||||
private const string SystemActor = "system";
|
||||
|
||||
/// <summary>Actor used for any other keyless (CLI-originated) library event.</summary>
|
||||
private const string CliActor = "cli";
|
||||
|
||||
/// <summary>The library event type that denotes a constraint denial.</summary>
|
||||
private const string ConstraintDeniedEventType = "constraint-denied";
|
||||
|
||||
/// <summary>The library's keyless schema-init event type.</summary>
|
||||
private const string InitDbEventType = "init-db";
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<AuditEvent> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a free-form library detail string into the canonical
|
||||
/// <c>{"detail": "<escaped>"}</c> JSON envelope, or null when there is no detail.
|
||||
/// </summary>
|
||||
private static string? WrapDetails(string? details)
|
||||
{
|
||||
if (details is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = details });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unwraps the canonical detail envelope back to the original free-form string. Falls
|
||||
/// back to the raw JSON when it is not a recognised <c>{"detail": ...}</c> envelope, so
|
||||
/// directly-emitted canonical events (whose DetailsJson is richer) still surface text.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// MxGateway-owned, append-only SQLite store for canonical
|
||||
/// <see cref="AuditEvent"/>s. It writes to a NEW <c>audit_event</c> table in the
|
||||
/// SAME database file as the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> stores: both share
|
||||
/// the library's <see cref="AuthSqliteConnectionFactory"/> (so they target the same
|
||||
/// <c>ApiKeyOptions.SqlitePath</c> with the same WAL/busy-timeout connection config).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This store is the canonical sink for ALL MxGateway audit. The library's own
|
||||
/// <c>api_key_audit</c> table is left in place but UNUSED after adoption — the library's
|
||||
/// <c>IApiKeyAuditStore</c> registration is overridden by
|
||||
/// <see cref="CanonicalForwardingApiKeyAuditStore"/>, which forwards onto this store via
|
||||
/// <see cref="CanonicalAuditWriter"/>. The library's <c>schema_version</c> /
|
||||
/// <c>api_key_audit</c> tables are not touched here; the <c>audit_event</c> table is
|
||||
/// created idempotently (<c>CREATE TABLE IF NOT EXISTS</c>) on each write so it
|
||||
/// self-bootstraps regardless of migration ordering.
|
||||
/// </remarks>
|
||||
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
|
||||
);
|
||||
""";
|
||||
|
||||
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary>
|
||||
/// <param name="auditEvent">The canonical event to persist.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
|
||||
/// <param name="limit">Maximum number of events to return.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
public async Task<IReadOnlyList<AuditEvent>> 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<AuditEvent> 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<AuditOutcome>(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);
|
||||
}
|
||||
+29
@@ -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<AuthSqliteConnectionFactory>()));
|
||||
// Resolve the logger defensively: the production host always registers ILogger<T>, 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<IAuditWriter>(sp =>
|
||||
new CanonicalAuditWriter(
|
||||
sp.GetRequiredService<SqliteCanonicalAuditStore>(),
|
||||
sp.GetService<ILogger<CanonicalAuditWriter>>()
|
||||
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<CanonicalAuditWriter>.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<IApiKeyAuditStore, CanonicalForwardingApiKeyAuditStore>();
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.2" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
|
||||
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the Task 2.3 canonical audit plumbing: the gateway-owned
|
||||
/// <see cref="SqliteCanonicalAuditStore"/> (round-trips canonical
|
||||
/// <see cref="AuditEvent"/>s through the new <c>audit_event</c> table), the best-effort
|
||||
/// <see cref="CanonicalAuditWriter"/>, and the <see cref="CanonicalForwardingApiKeyAuditStore"/>
|
||||
/// adapter that maps the library's <see cref="ApiKeyAuditEntry"/> 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.
|
||||
/// </summary>
|
||||
public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
{
|
||||
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
||||
|
||||
/// <summary>A canonical event with all fields populated round-trips through the store.</summary>
|
||||
[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<AuditEvent> 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);
|
||||
}
|
||||
|
||||
/// <summary>Nullable canonical fields round-trip as null, not empty string.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>ListRecent returns newest-first.</summary>
|
||||
[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<AuditEvent> recent = await store.ListRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(["third", "second", "first"], recent.Select(e => e.Action));
|
||||
}
|
||||
|
||||
/// <summary>The writer is best-effort: a faulting store does not surface to the caller.</summary>
|
||||
[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<CanonicalAuditWriter>.Instance);
|
||||
|
||||
// Must not throw.
|
||||
await writer.WriteAsync(MakeEvent("anything"), CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>The keyless library <c>init-db</c> event maps to Actor "system" and back to a null KeyId.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Any other keyless library event maps to Actor "cli" and back to a null KeyId.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A <c>constraint-denied</c> library event maps to Outcome.Denied.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The adapter does NOT throw when the underlying write fails (it forwards through the
|
||||
/// best-effort writer), preserving the IApiKeyAuditStore caller's flow.
|
||||
/// </summary>
|
||||
[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<CanonicalAuditWriter>.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<CanonicalAuditWriter>.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",
|
||||
};
|
||||
|
||||
/// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (TempDatabaseDirectory directory in _tempDirectories)
|
||||
{
|
||||
directory.Dispose();
|
||||
}
|
||||
|
||||
_tempDirectories.Clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user