perf(audit): bounded async audit writer with batched inserts, one-time bootstrap, retention sweep

This commit is contained in:
Joseph Doherty
2026-08-15 12:17:22 -04:00
parent 6c5218913b
commit e2ac5d117a
9 changed files with 845 additions and 38 deletions
@@ -3,24 +3,26 @@ 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
/// Best-effort, <em>synchronous</em> <see cref="IAuditWriter"/> over the MxGateway-owned
/// <see cref="IAuditEventSink"/>. 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
/// This is the durable bottom of the audit pipeline. Callers reach it two ways: through
/// <see cref="ChannelAuditWriter"/> — the registered <see cref="IAuditWriter"/>, which
/// enqueues and lets <see cref="AuditDrainService"/> batch events onto the sink — and
/// directly, when there is no drain to batch behind (the <c>apikey</c> CLI, and any host
/// shutdown window), where writing through immediately is the only way the event survives.
/// 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,
IAuditEventSink sink,
ILogger<CanonicalAuditWriter> logger) : IAuditWriter
{
/// <summary>
/// Persists a canonical audit event to the underlying <see cref="SqliteCanonicalAuditStore"/>.
/// Persists a canonical audit event to the underlying <see cref="IAuditEventSink"/>.
/// Any failure is caught, logged, and swallowed rather than propagated to the caller.
/// </summary>
/// <param name="auditEvent">The canonical audit event to persist.</param>
@@ -32,7 +34,7 @@ public sealed class CanonicalAuditWriter(
try
{
await store.InsertAsync(auditEvent, cancellationToken).ConfigureAwait(false);
await sink.InsertAsync(auditEvent, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
@@ -0,0 +1,333 @@
using System.Threading.Channels;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// <summary>
/// Bounded, non-blocking <see cref="IAuditWriter"/>: <see cref="WriteAsync"/> enqueues onto a
/// fixed-capacity channel and returns, leaving <see cref="AuditDrainService"/> to batch the
/// events onto the durable <see cref="IAuditEventSink"/>.
/// </summary>
/// <remarks>
/// The canonical <see cref="IAuditWriter"/> contract is already best-effort — a failed audit
/// write is swallowed rather than propagated. The channel makes the <em>bound</em> on that
/// promise explicit: audit can cost the calling RPC at most one enqueue, never a SQLite
/// round-trip, and at most <see cref="ChannelCapacity"/> events of memory. This matters on the
/// constraint-denial path, where a partially denied bulk RPC previously awaited one insert per
/// denied tag, serially, against the same database file the authentication hot path reads.
/// <para>
/// When the channel is full the newest write is dropped (<see cref="BoundedChannelFullMode.DropWrite"/>)
/// and counted in <see cref="DroppedCount"/>. Dropping is the deliberate choice over blocking:
/// a stalled audit database must degrade audit completeness, not stall the gateway. Drops are
/// logged, and <see cref="AuditDrainService"/> reports the running total on its sweep.
/// </para>
/// <para>
/// Until a drain attaches (<see cref="AttachDrain"/>), and again after it detaches, writes go
/// straight through to <see cref="CanonicalAuditWriter"/>. Enqueueing into a channel nobody will
/// ever read would silently discard audit in the processes that have no hosted services — the
/// <c>apikey</c> admin CLI and the DI-only tests — so those keep the original synchronous path.
/// </para>
/// </remarks>
public sealed class ChannelAuditWriter : IAuditWriter
{
/// <summary>
/// Maximum number of audit events buffered before writes start being dropped. Sized to
/// absorb a fully denied bulk RPC (the gateway's bulk request cap) plus headroom, so a
/// realistic burst is buffered rather than lost.
/// </summary>
public const int ChannelCapacity = 4096;
private readonly CanonicalAuditWriter _directWriter;
private readonly ILogger<ChannelAuditWriter> _logger;
private readonly Channel<AuditEvent> _channel;
private long _droppedCount;
private int _drainAttached;
private int _dropLogged;
/// <summary>Creates the writer and its bounded buffer.</summary>
/// <param name="directWriter">The synchronous writer used when no drain is attached.</param>
/// <param name="logger">Logger for drop diagnostics.</param>
public ChannelAuditWriter(CanonicalAuditWriter directWriter, ILogger<ChannelAuditWriter> logger)
{
_directWriter = directWriter;
_logger = logger;
// DropWrite discards the incoming item and still reports success to the producer, so the
// itemDropped callback is the only place a drop can be observed and counted.
_channel = Channel.CreateBounded<AuditEvent>(
new BoundedChannelOptions(ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false,
},
itemDropped: RecordDrop);
}
/// <summary>Gets the number of audit events dropped because the channel was full.</summary>
public long DroppedCount => Interlocked.Read(ref _droppedCount);
/// <summary>Gets the reader the drain service consumes buffered events from.</summary>
public ChannelReader<AuditEvent> Reader => _channel.Reader;
/// <summary>
/// Marks a drain as running, so subsequent writes enqueue instead of writing through.
/// Called by <see cref="AuditDrainService"/> once its one-time bootstrap has completed.
/// </summary>
public void AttachDrain() => Volatile.Write(ref _drainAttached, 1);
/// <summary>
/// Marks the drain as no longer running, so writes revert to the synchronous path. Called
/// at shutdown so late audit is still persisted rather than buffered into a dead channel.
/// </summary>
public void DetachDrain() => Volatile.Write(ref _drainAttached, 0);
/// <summary>
/// Enqueues a canonical audit event for the drain to persist. Never blocks, never throws,
/// and never touches the store on the caller's thread while a drain is attached.
/// </summary>
/// <param name="auditEvent">The canonical audit event to persist.</param>
/// <param name="cancellationToken">Token honoured only by the direct write-through path.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(auditEvent);
if (Volatile.Read(ref _drainAttached) == 0)
{
return _directWriter.WriteAsync(auditEvent, cancellationToken);
}
// Under DropWrite a full channel still reports success (the discard surfaces through the
// itemDropped callback); a false here means the channel is completed, which is also a drop.
if (!_channel.Writer.TryWrite(auditEvent))
{
RecordDrop(auditEvent);
}
return Task.CompletedTask;
}
/// <summary>Signals that no further events will be enqueued, so the drain loop can finish.</summary>
public void CompleteWriting() => _channel.Writer.TryComplete();
private void RecordDrop(AuditEvent auditEvent)
{
Interlocked.Increment(ref _droppedCount);
// Log the first drop only; the running total is reported on the drain's periodic sweep,
// so a sustained overload cannot turn audit pressure into a log flood.
if (Interlocked.Exchange(ref _dropLogged, 1) == 0)
{
_logger.LogWarning(
"Audit channel is full ({Capacity} events); dropping audit event {EventId} (action {Action}). "
+ "Audit is best-effort and bounded; further drops are reported in aggregate.",
ChannelCapacity,
auditEvent.EventId,
auditEvent.Action);
}
}
}
/// <summary>
/// Drains <see cref="ChannelAuditWriter"/> onto the durable <see cref="IAuditEventSink"/>,
/// owns the one-time schema bootstrap, and sweeps audit rows past their retention window.
/// </summary>
/// <remarks>
/// Batching is the point: up to <see cref="MaxBatchSize"/> buffered events are committed in a
/// single transaction, so a burst of constraint denials costs a handful of commits instead of
/// one per denied tag. The bootstrap runs here — before the writer starts enqueueing — so no
/// audit write ever pays a <c>CREATE TABLE IF NOT EXISTS</c> round-trip.
/// </remarks>
/// <param name="writer">The channel writer whose buffered events are drained.</param>
/// <param name="sink">The durable sink events are committed to.</param>
/// <param name="security">Security options carrying the audit retention window.</param>
/// <param name="timeProvider">Clock used for the retention cutoff and sweep interval.</param>
/// <param name="logger">Logger for bootstrap, drain and sweep diagnostics.</param>
public sealed class AuditDrainService(
ChannelAuditWriter writer,
IAuditEventSink sink,
SecurityOptions security,
TimeProvider timeProvider,
ILogger<AuditDrainService> logger) : BackgroundService
{
/// <summary>Maximum number of audit events committed in one transaction per drain pass.</summary>
public const int MaxBatchSize = 64;
/// <summary>How often the retention sweep runs while the gateway is up.</summary>
public static readonly TimeSpan RetentionSweepInterval = TimeSpan.FromHours(1);
/// <summary>Upper bound on how long shutdown waits for the remaining buffered events.</summary>
private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2);
/// <summary>
/// Bootstraps the audit table, runs one retention sweep, then attaches the drain so the
/// writer switches from synchronous write-through to enqueueing.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public override async Task StartAsync(CancellationToken cancellationToken)
{
try
{
await sink.EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
await SweepRetentionAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
// Audit is best-effort: a bootstrap failure must not take the gateway down. The
// sink's own latch will retry the schema check on the first write.
logger.LogWarning(exception, "Audit store bootstrap failed; audit writes will retry the schema check.");
}
writer.AttachDrain();
await base.StartAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Detaches the drain (so late writes go straight to the sink) and gives the buffered
/// events a bounded window to reach the store.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public override async Task StopAsync(CancellationToken cancellationToken)
{
writer.DetachDrain();
writer.CompleteWriting();
await base.StopAsync(cancellationToken).ConfigureAwait(false);
using CancellationTokenSource drainCap = new(ShutdownDrainCap);
try
{
await DrainPendingAsync(drainCap.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
logger.LogWarning(
"Shutdown drain exceeded {CapSeconds}s; remaining buffered audit events were not persisted.",
ShutdownDrainCap.TotalSeconds);
}
}
/// <summary>
/// Commits every event currently buffered, in batches of at most <see cref="MaxBatchSize"/>.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The number of events persisted.</returns>
public async Task<int> DrainPendingAsync(CancellationToken cancellationToken)
{
int persisted = 0;
List<AuditEvent> batch = new(MaxBatchSize);
while (!cancellationToken.IsCancellationRequested)
{
batch.Clear();
while (batch.Count < MaxBatchSize && writer.Reader.TryRead(out AuditEvent? auditEvent))
{
batch.Add(auditEvent);
}
if (batch.Count == 0)
{
break;
}
try
{
await sink.InsertBatchAsync(batch, cancellationToken).ConfigureAwait(false);
persisted += batch.Count;
}
catch (Exception exception)
{
// Best-effort: the batch is lost, but the drain keeps going so one poisoned
// batch cannot stall every later audit event behind it.
logger.LogWarning(
exception,
"Failed to persist a batch of {Count} audit events; audit writes are best-effort and were suppressed.",
batch.Count);
}
}
return persisted;
}
/// <summary>
/// Deletes audit rows older than <c>MxGateway:Security:AuditRetentionDays</c>, and reports
/// any audit events dropped by channel pressure since the last sweep.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task SweepRetentionAsync(CancellationToken cancellationToken)
{
DateTimeOffset cutoff = timeProvider.GetUtcNow() - TimeSpan.FromDays(security.AuditRetentionDays);
try
{
int deleted = await sink.DeleteOlderThanAsync(cutoff, cancellationToken).ConfigureAwait(false);
if (deleted > 0)
{
logger.LogInformation(
"Audit retention sweep removed {Deleted} events older than {Cutoff:o} ({RetentionDays} days).",
deleted,
cutoff,
security.AuditRetentionDays);
}
}
catch (Exception exception)
{
logger.LogWarning(exception, "Audit retention sweep failed; it will be retried on the next interval.");
}
long dropped = writer.DroppedCount;
if (dropped > 0)
{
logger.LogWarning(
"{Dropped} audit events have been dropped since startup because the audit channel was full.",
dropped);
}
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.WhenAll(
DrainLoopAsync(stoppingToken),
RetentionLoopAsync(stoppingToken)).ConfigureAwait(false);
}
private async Task DrainLoopAsync(CancellationToken stoppingToken)
{
try
{
while (await writer.Reader.WaitToReadAsync(stoppingToken).ConfigureAwait(false))
{
await DrainPendingAsync(stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Shutdown; StopAsync performs the final bounded drain.
}
}
private async Task RetentionLoopAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(RetentionSweepInterval, timeProvider);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await SweepRetentionAsync(stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Shutdown.
}
}
}
@@ -5,6 +5,41 @@ using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// <summary>
/// Durable sink the audit pipeline persists canonical <see cref="AuditEvent"/>s through.
/// It exists so the write path (<see cref="CanonicalAuditWriter"/>) and the batching drain
/// (<see cref="AuditDrainService"/>) depend on the storage contract rather than on the
/// concrete <see cref="SqliteCanonicalAuditStore"/>.
/// </summary>
public interface IAuditEventSink
{
/// <summary>
/// Bootstraps the backing storage. Called once at startup so no write path pays a schema
/// round-trip; implementations must be idempotent and safe to call concurrently.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task EnsureInitializedAsync(CancellationToken cancellationToken);
/// <summary>Persists a single canonical audit event.</summary>
/// <param name="auditEvent">The canonical event to persist.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken);
/// <summary>Persists a batch of canonical audit events as one unit of work.</summary>
/// <param name="auditEvents">The canonical events to persist.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken);
/// <summary>Deletes every audit row that occurred strictly before <paramref name="cutoffUtc"/>.</summary>
/// <param name="cutoffUtc">The retention cutoff; rows older than this are removed.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The number of rows deleted.</returns>
Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken);
}
/// <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
@@ -18,11 +53,20 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
/// <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.
/// <c>api_key_audit</c> tables are not touched here.
/// <para>
/// The <c>audit_event</c> table is created idempotently, but the <c>CREATE TABLE IF NOT
/// EXISTS</c> is <em>latched</em>: <see cref="AuditDrainService"/> runs
/// <see cref="EnsureInitializedAsync"/> 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 <c>apikey</c> CLI and the DI-only tests — regardless of migration
/// ordering. The latch is deliberately racy: a lost race merely re-runs an idempotent
/// <c>CREATE TABLE IF NOT EXISTS</c>, and a failure leaves the latch open so the next call
/// retries.
/// </para>
/// </remarks>
public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory)
public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory) : IAuditEventSink
{
private const string CreateTableSql =
"""
@@ -40,14 +84,107 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
);
""";
/// <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>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
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);
""";
/// <summary>0 until the <c>audit_event</c> table has been created at least once by this instance.</summary>
private int _tableEnsured;
/// <inheritdoc />
public async Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(auditEvent);
return InsertBatchAsync([auditEvent], cancellationToken);
}
/// <inheritdoc />
/// <remarks>
/// One connection, one transaction and one prepared command for the whole batch: the drain
/// pays a single commit for up to <see cref="AuditDrainService.MaxBatchSize"/> events rather
/// than one round-trip per event.
/// </remarks>
public async Task InsertBatchAsync(IReadOnlyList<AuditEvent> 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);
}
/// <inheritdoc />
/// <remarks>
/// The comparison goes through SQLite's <c>datetime()</c> rather than comparing the stored
/// ISO-8601 text directly: rows written from a non-UTC <see cref="DateTimeOffset"/> (the
/// library's <c>CreatedUtc</c> is caller-supplied) do not sort lexicographically against a
/// UTC cutoff. A row whose timestamp <c>datetime()</c> cannot parse yields NULL and is
/// therefore never deleted — audit that cannot be dated is kept, not swept.
/// </remarks>
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
@@ -56,25 +193,14 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
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);
DELETE FROM audit_event
WHERE datetime(occurred_at_utc) < datetime($cutoff);
""";
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);
command.Parameters.AddWithValue(
"$cutoff",
cutoffUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
@@ -128,11 +254,20 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
return events;
}
private static async Task EnsureTableAsync(SqliteConnection connection, CancellationToken cancellationToken)
// 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);
}
private static DateTimeOffset ParseUtc(string value) =>
@@ -99,16 +99,40 @@ public static class AuthStoreServiceCollectionExtensions
services.AddSingleton(sp =>
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
services.AddSingleton<IAuditEventSink>(sp => sp.GetRequiredService<SqliteCanonicalAuditStore>());
// 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 =>
services.AddSingleton(sp =>
new CanonicalAuditWriter(
sp.GetRequiredService<SqliteCanonicalAuditStore>(),
sp.GetRequiredService<IAuditEventSink>(),
sp.GetService<ILogger<CanonicalAuditWriter>>()
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<CanonicalAuditWriter>.Instance));
// The registered IAuditWriter is the bounded, asynchronous one: audit producers — above
// all IConstraintEnforcer.RecordDenialAsync, which fires once per denied tag inside bulk
// RPC loops — enqueue and return instead of awaiting a SQLite insert each. No producer
// signature changes; the seam is entirely here. AuditDrainService batches the buffered
// events onto the sink, owns the one-time schema bootstrap and sweeps expired rows. Where
// no hosted service runs (the `apikey` CLI, the DI-only tests) the channel writer falls
// back to CanonicalAuditWriter's synchronous path, so audit is never silently buffered
// into a channel nobody drains.
services.AddSingleton(sp =>
new ChannelAuditWriter(
sp.GetRequiredService<CanonicalAuditWriter>(),
sp.GetService<ILogger<ChannelAuditWriter>>()
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<ChannelAuditWriter>.Instance));
services.AddSingleton<IAuditWriter>(sp => sp.GetRequiredService<ChannelAuditWriter>());
services.AddSingleton(sp => new AuditDrainService(
sp.GetRequiredService<ChannelAuditWriter>(),
sp.GetRequiredService<IAuditEventSink>(),
security,
sp.GetService<TimeProvider>() ?? TimeProvider.System,
sp.GetService<ILogger<AuditDrainService>>()
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<AuditDrainService>.Instance));
services.AddHostedService(sp => sp.GetRequiredService<AuditDrainService>());
// 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.