perf(audit): bounded async audit writer with batched inserts, one-time bootstrap, retention sweep
This commit is contained in:
@@ -100,6 +100,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
"MxGateway:Security:ApiKeyFailureTrackedPeers must be greater than zero.",
|
||||
builder);
|
||||
|
||||
// Retention must be at least one day: 0 would sweep the audit table on every pass, which
|
||||
// is a way to silently disable auditing rather than an expression of intent.
|
||||
AddIfNotPositive(
|
||||
options.AuditRetentionDays,
|
||||
"MxGateway:Security:AuditRetentionDays must be greater than zero (at least one day of audit history is retained).",
|
||||
builder);
|
||||
|
||||
// The two-layer limiter knobs (SEC-31) accept 0 as "disable this layer": a zero aggregate
|
||||
// limit turns off cross-peer counting, and a zero probe interval restores absolute blocking.
|
||||
// Negatives express no intent.
|
||||
|
||||
@@ -88,4 +88,13 @@ public sealed class SecurityOptions
|
||||
/// ceiling of twice this value. Default is 4096.
|
||||
/// </summary>
|
||||
public int ApiKeyFailureTrackedPeers { get; init; } = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// Gets how many days of canonical audit history the gateway keeps. The audit drain sweeps
|
||||
/// <c>audit_event</c> once at startup and hourly thereafter, deleting rows older than this
|
||||
/// window; without it the table grows without bound in the same SQLite file the
|
||||
/// authentication hot path reads. Must be greater than zero — audit retention cannot be
|
||||
/// disabled by configuration, only widened. Default is 90 days.
|
||||
/// </summary>
|
||||
public int AuditRetentionDays { get; init; } = 90;
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
+26
-2
@@ -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.
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
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 bounded, asynchronous audit path: <see cref="ChannelAuditWriter"/> (enqueue-only
|
||||
/// once a drain is attached, drops rather than blocks when the channel is full) and
|
||||
/// <see cref="AuditDrainService"/> (batched drain, one-time table bootstrap, retention sweep).
|
||||
/// The channel makes the already-documented best-effort audit contract explicit and bounded:
|
||||
/// a partially denied bulk RPC no longer pays a SQLite round-trip per denied tag.
|
||||
/// </summary>
|
||||
public sealed class ChannelAuditWriterTests : IDisposable
|
||||
{
|
||||
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
||||
|
||||
/// <summary>
|
||||
/// With a drain attached, <see cref="ChannelAuditWriter.WriteAsync"/> only enqueues — the
|
||||
/// sink is not touched until the drain runs, and the event arrives once it does.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_WithDrainAttached_EnqueuesOnlyAndReachesSinkAfterDrain()
|
||||
{
|
||||
CountingAuditSink sink = new();
|
||||
(ChannelAuditWriter writer, AuditDrainService drain) = CreateWriterAndDrain(sink);
|
||||
writer.AttachDrain();
|
||||
|
||||
await writer.WriteAsync(MakeEvent("constraint-denied"), CancellationToken.None);
|
||||
|
||||
// Enqueue-only: nothing has reached the sink yet.
|
||||
Assert.Equal(0, sink.InsertBatchCalls);
|
||||
Assert.Empty(sink.Events);
|
||||
|
||||
int drained = await drain.DrainPendingAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, drained);
|
||||
Assert.Equal(1, sink.InsertBatchCalls);
|
||||
Assert.Equal("constraint-denied", Assert.Single(sink.Events).Action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A drain pass batches up to <see cref="AuditDrainService.MaxBatchSize"/> events into one
|
||||
/// sink call, so a partially denied bulk RPC costs a handful of transactions, not one per tag.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DrainPendingAsync_BatchesEventsIntoBoundedInsertCalls()
|
||||
{
|
||||
CountingAuditSink sink = new();
|
||||
(ChannelAuditWriter writer, AuditDrainService drain) = CreateWriterAndDrain(sink);
|
||||
writer.AttachDrain();
|
||||
|
||||
const int eventCount = 150;
|
||||
for (int index = 0; index < eventCount; index++)
|
||||
{
|
||||
await writer.WriteAsync(MakeEvent($"denied-{index}"), CancellationToken.None);
|
||||
}
|
||||
|
||||
int drained = await drain.DrainPendingAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(eventCount, drained);
|
||||
Assert.Equal(eventCount, sink.Events.Count);
|
||||
// 150 events at a batch size of 64 → 3 transactions (64 + 64 + 22), not 150.
|
||||
Assert.Equal(3, sink.InsertBatchCalls);
|
||||
Assert.All(sink.BatchSizes, size => Assert.True(size <= AuditDrainService.MaxBatchSize));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the bounded channel is full the write is dropped rather than blocking the caller,
|
||||
/// and the drop is counted. A stalled or slow audit database must never stall an RPC.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_WhenChannelFull_DropsWriteAndCountsItWithoutBlocking()
|
||||
{
|
||||
CountingAuditSink sink = new();
|
||||
(ChannelAuditWriter writer, _) = CreateWriterAndDrain(sink);
|
||||
writer.AttachDrain();
|
||||
|
||||
const int overflow = 32;
|
||||
for (int index = 0; index < ChannelAuditWriter.ChannelCapacity + overflow; index++)
|
||||
{
|
||||
// Every call must complete synchronously: the channel never blocks a producer.
|
||||
Task write = writer.WriteAsync(MakeEvent($"denied-{index}"), CancellationToken.None);
|
||||
Assert.True(write.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
Assert.Equal(overflow, writer.DroppedCount);
|
||||
// The channel still holds exactly its capacity; nothing reached the sink (no drain ran).
|
||||
Assert.Equal(0, sink.InsertBatchCalls);
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no drain attached (the <c>apikey</c> CLI, DI-only tests, post-shutdown) the writer
|
||||
/// writes through to the sink instead of enqueueing into a channel nobody will ever read.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteAsync_WithNoDrainAttached_WritesThroughToSink()
|
||||
{
|
||||
CountingAuditSink sink = new();
|
||||
(ChannelAuditWriter writer, _) = CreateWriterAndDrain(sink);
|
||||
|
||||
await writer.WriteAsync(MakeEvent("dashboard-create-key"), CancellationToken.None);
|
||||
|
||||
Assert.Equal("dashboard-create-key", Assert.Single(sink.Events).Action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The retention sweep deletes rows older than <c>AuditRetentionDays</c>, measured from the
|
||||
/// injected clock, and runs once at startup after the one-time table bootstrap.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StartAsync_BootstrapsTableThenSweepsRetentionAtConfiguredCutoff()
|
||||
{
|
||||
CountingAuditSink sink = new();
|
||||
FakeTimeProvider clock = new(new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
(_, AuditDrainService drain) = CreateWriterAndDrain(
|
||||
sink,
|
||||
new SecurityOptions { AuditRetentionDays = 30 },
|
||||
clock);
|
||||
|
||||
await drain.StartAsync(CancellationToken.None);
|
||||
await drain.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, sink.EnsureInitializedCalls);
|
||||
Assert.Equal(new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero), Assert.Single(sink.DeleteCutoffs));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The real SQLite sink deletes only rows older than the cutoff, and its bootstrap latch
|
||||
/// means the <c>CREATE TABLE</c> round-trip is not repeated on every subsequent write.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SqliteStore_DeleteOlderThan_RemovesOnlyRowsBeyondTheRetentionWindow()
|
||||
{
|
||||
SqliteCanonicalAuditStore store = CreateStore();
|
||||
DateTimeOffset now = new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
await store.InsertBatchAsync(
|
||||
[
|
||||
MakeEvent("stale", now.AddDays(-120)),
|
||||
// A non-UTC offset must still compare correctly against the cutoff.
|
||||
MakeEvent("stale-offset", now.AddDays(-100).ToOffset(TimeSpan.FromHours(-5))),
|
||||
MakeEvent("fresh", now.AddDays(-1)),
|
||||
],
|
||||
CancellationToken.None);
|
||||
|
||||
int deleted = await store.DeleteOlderThanAsync(now.AddDays(-90), CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, deleted);
|
||||
Assert.Equal("fresh", Assert.Single(await store.ListRecentAsync(10, CancellationToken.None)).Action);
|
||||
}
|
||||
|
||||
private static (ChannelAuditWriter Writer, AuditDrainService Drain) CreateWriterAndDrain(
|
||||
IAuditEventSink sink,
|
||||
SecurityOptions? security = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ChannelAuditWriter writer = new(
|
||||
new CanonicalAuditWriter(sink, NullLogger<CanonicalAuditWriter>.Instance),
|
||||
NullLogger<ChannelAuditWriter>.Instance);
|
||||
AuditDrainService drain = new(
|
||||
writer,
|
||||
sink,
|
||||
security ?? new SecurityOptions(),
|
||||
timeProvider ?? TimeProvider.System,
|
||||
NullLogger<AuditDrainService>.Instance);
|
||||
return (writer, drain);
|
||||
}
|
||||
|
||||
private SqliteCanonicalAuditStore CreateStore()
|
||||
{
|
||||
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-channel-audit");
|
||||
_tempDirectories.Add(directory);
|
||||
return new SqliteCanonicalAuditStore(new AuthSqliteConnectionFactory(directory.DatabasePath()));
|
||||
}
|
||||
|
||||
private static AuditEvent MakeEvent(string action, DateTimeOffset? occurredAtUtc = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = occurredAtUtc ?? DateTimeOffset.UtcNow,
|
||||
Actor = "operator01",
|
||||
Action = action,
|
||||
Outcome = AuditOutcome.Denied,
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IAuditEventSink"/> that records every call, so a test can prove the
|
||||
/// write path did NOT touch the sink and that the drain batched what it did write.
|
||||
/// </summary>
|
||||
private sealed class CountingAuditSink : IAuditEventSink
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
/// <summary>Gets the events handed to the sink, in the order they were written.</summary>
|
||||
public List<AuditEvent> Events { get; } = [];
|
||||
|
||||
/// <summary>Gets the size of each batch the sink was asked to insert.</summary>
|
||||
public List<int> BatchSizes { get; } = [];
|
||||
|
||||
/// <summary>Gets the cutoffs the sink was asked to delete below.</summary>
|
||||
public List<DateTimeOffset> DeleteCutoffs { get; } = [];
|
||||
|
||||
/// <summary>Gets the number of <see cref="InsertBatchAsync"/> calls.</summary>
|
||||
public int InsertBatchCalls => BatchSizes.Count;
|
||||
|
||||
/// <summary>Gets the number of <see cref="EnsureInitializedAsync"/> calls.</summary>
|
||||
public int EnsureInitializedCalls { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task EnsureInitializedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
EnsureInitializedCalls++;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken) =>
|
||||
InsertBatchAsync([auditEvent], cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
BatchSizes.Add(auditEvents.Count);
|
||||
Events.AddRange(auditEvents);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
DeleteCutoffs.Add(cutoffUtc);
|
||||
}
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user