From 7b2d04605e7b5892748242c613ae7e8c1a55d736 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 12:37:09 -0400 Subject: [PATCH] fix(audit): write-through on completed channel, poison-batch isolation, drain-fault fallback --- docs/DesignDecisions.md | 35 ++- .../Security/Audit/AuditDrainService.cs | 274 ++++++++++++++++++ .../Security/Audit/ChannelAuditWriter.cs | 217 +------------- .../Security/Audit/IAuditEventSink.cs | 38 +++ .../Audit/SqliteCanonicalAuditStore.cs | 47 +-- .../Security/Audit/ChannelAuditWriterTests.cs | 155 ++++++++-- 6 files changed, 492 insertions(+), 274 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Security/Audit/AuditDrainService.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Security/Audit/IAuditEventSink.cs diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index 2d1e479..2068527 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -244,14 +244,39 @@ authenticated call reads. The schema bootstrap now runs once, from the drain's ` When the channel is full the newest event is dropped and counted rather than blocking the producer: a stalled audit database must cost audit completeness, not gateway availability. Drops are logged once and reported in aggregate on each sweep. Shutdown drains what is buffered under a -2-second cap. Where no hosted service runs — the `apikey` admin CLI — the writer falls back to the -synchronous path, so audit is never buffered into a channel nobody drains. +2-second cap. + +Every other failure mode degrades to synchronous writes rather than to silent loss. The writer +falls back to the direct path whenever nothing is draining: before the drain attaches, after it +detaches, where no hosted service runs at all (the `apikey` admin CLI), and when the channel has +been completed — so no attach/detach sequence can leave producers filling a buffer with no reader. +If the drain loop itself dies it detaches the writer on the way out, which reverts every producer +to the direct path. A batch that will not commit is retried one event at a time, so an unwritable +row costs only itself instead of the up-to-63 good events sharing its transaction. + +**All** audit is channelled, including admin and CRUD records — dashboard key create/revoke/rotate, +session Close/Kill, and the library-forwarded API-key lifecycle entries. The alternative considered +was keeping those on the synchronous writer and channelling only high-volume denial audit. It was +rejected because a single dashboard key-create emits two records through two different seams (the +library's `create-key` via `IApiKeyAuditStore`, and the enriching `dashboard-create-key` via +`IAuditWriter`); splitting them across two durability regimes gives an auditor a per-producer +matrix to reason about instead of one rule. The residual exposure is explicit: **if the gateway +process dies between the enqueue and the batch commit, buffered audit events are lost.** The window +is bounded by drain latency — the drain wakes on every write and commits immediately, so it is +sub-millisecond under normal load — and it does not apply to the `apikey` CLI, which writes +synchronously. Audit is a best-effort record of what the gateway did, not a write-ahead log of what +it is about to do; a deployment that needs crash-durable admin audit should ship the events off-box +rather than rely on this table. `MxGateway:Security:AuditRetentionDays` (default 90, minimum 1) bounds the table: the drain sweeps at startup and hourly, deleting older rows. Retention cannot be configured off. The sweep compares -through SQLite's `datetime()` rather than on the stored ISO-8601 text, because timestamps written -from a non-UTC offset do not sort lexicographically against a UTC cutoff; a row whose timestamp -cannot be parsed yields NULL and is kept rather than deleted. +through SQLite's `datetime()` rather than on the stored ISO-8601 text. Text comparison is correct +only while every row is UTC-normalized — which the canonical model guarantees for rows written +through the store, but not for rows that entered the table any other way — and on a mixed-format +column it silently deletes live audit, because `2026-05-17T09:00:00-05:00` is two hours after a +`2026-05-17T12:00:00+00:00` cutoff yet sorts before it. Comparing instants is correct however the +text got there, and a timestamp `datetime()` cannot parse yields NULL, so undateable audit is kept +rather than swept. ## Authorization diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/AuditDrainService.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/AuditDrainService.cs new file mode 100644 index 0000000..d4519fb --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/AuditDrainService.cs @@ -0,0 +1,274 @@ +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; + +/// +/// Drains onto the durable , +/// owns the one-time schema bootstrap, and sweeps audit rows past their retention window. +/// +/// +/// Batching is the point: up to 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 CREATE TABLE IF NOT EXISTS round-trip. +/// +/// Every failure mode ends in synchronous audit rather than silent loss: a batch that will not +/// commit is retried one event at a time so only the offending row is dropped, and a drain loop +/// that dies detaches the writer, which reverts every producer to the direct write path. +/// +/// +/// The channel writer whose buffered events are drained. +/// The durable sink events are committed to. +/// Security options carrying the audit retention window. +/// Clock used for the retention cutoff and sweep interval. +/// Logger for bootstrap, drain and sweep diagnostics. +public sealed class AuditDrainService( + ChannelAuditWriter writer, + IAuditEventSink sink, + SecurityOptions security, + TimeProvider timeProvider, + ILogger logger) : BackgroundService +{ + /// Maximum number of audit events committed in one transaction per drain pass. + public const int MaxBatchSize = 64; + + /// How often the retention sweep runs while the gateway is up. + public static readonly TimeSpan RetentionSweepInterval = TimeSpan.FromHours(1); + + /// Upper bound on how long shutdown waits for the remaining buffered events. + private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2); + + /// + /// Bootstraps the audit table, runs one retention sweep, then attaches the drain so the + /// writer switches from synchronous write-through to enqueueing. + /// + /// Token to observe for cancellation. + /// A task that represents the asynchronous operation. + 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); + } + + /// + /// Detaches the drain (so late writes go straight to the sink) and gives the buffered + /// events a bounded window to reach the store. + /// + /// Token to observe for cancellation. + /// A task that represents the asynchronous operation. + 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); + } + } + + /// + /// Commits every event currently buffered, in batches of at most . + /// + /// Token to observe for cancellation. + /// The number of events persisted. + /// + /// The drain was cancelled — at shutdown this is the 2-second cap expiring, which the caller + /// reports as unpersisted audit rather than as a store fault. + /// + public async Task DrainPendingAsync(CancellationToken cancellationToken) + { + int persisted = 0; + List 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 (OperationCanceledException) + { + // Cancellation is the shutdown cap, not a store fault: surface it so StopAsync + // reports unpersisted audit instead of misreporting it as a failed write. + throw; + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Failed to commit a batch of {Count} audit events; retrying them individually.", + batch.Count); + + persisted += await InsertIndividuallyAsync(batch, cancellationToken).ConfigureAwait(false); + } + } + + return persisted; + } + + /// + /// Deletes audit rows older than MxGateway:Security:AuditRetentionDays, and reports the + /// running total of audit events dropped by channel pressure since startup. + /// + /// Token to observe for cancellation. + /// A task that represents the asynchronous operation. + 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); + } + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.WhenAll( + DrainLoopAsync(stoppingToken), + RetentionLoopAsync(stoppingToken)).ConfigureAwait(false); + } + + // Re-inserts a failed batch one event at a time so a single unwritable row costs only itself + // rather than the up-to-MaxBatchSize good events that happened to share its transaction. + private async Task InsertIndividuallyAsync( + IReadOnlyList batch, + CancellationToken cancellationToken) + { + int persisted = 0; + + foreach (AuditEvent auditEvent in batch) + { + try + { + await sink.InsertAsync(auditEvent, cancellationToken).ConfigureAwait(false); + persisted++; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Dropped audit event {EventId} (action {Action}); it could not be persisted individually.", + auditEvent.EventId, + auditEvent.Action); + } + } + + logger.LogWarning( + "Recovered {Persisted} of {Count} audit events from a failed batch.", + persisted, + batch.Count); + + return persisted; + } + + 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. + } + catch (Exception exception) + { + // A dead drain loop would silently discard every later audit write, because producers + // keep enqueueing into a channel nobody reads. Detaching (below) reverts them to the + // synchronous path, so audit degrades in latency rather than disappearing. + logger.LogError(exception, "Audit drain loop failed; reverting to synchronous audit writes."); + } + finally + { + writer.DetachDrain(); + } + } + + private async Task RetentionLoopAsync(CancellationToken stoppingToken) + { + try + { + using PeriodicTimer timer = new(RetentionSweepInterval, timeProvider); + + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + await SweepRetentionAsync(stoppingToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutdown. + } + catch (Exception exception) + { + // Retention is unbounded growth if it stops: say so loudly rather than letting the + // audit table grow forever behind a silently dead timer loop. + logger.LogError(exception, "Audit retention loop failed; expired audit rows will no longer be swept."); + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/ChannelAuditWriter.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/ChannelAuditWriter.cs index 3b204cf..d85c5a2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/ChannelAuditWriter.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/ChannelAuditWriter.cs @@ -1,6 +1,5 @@ using System.Threading.Channels; using ZB.MOM.WW.Audit; -using ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; @@ -27,6 +26,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; /// straight through to . Enqueueing into a channel nobody will /// ever read would silently discard audit in the processes that have no hosted services — the /// apikey admin CLI and the DI-only tests — so those keep the original synchronous path. +/// The same fallback covers a completed channel, so no combination of attach/detach can leave +/// producers writing into a buffer that will never be read. /// /// public sealed class ChannelAuditWriter : IAuditWriter @@ -79,8 +80,9 @@ public sealed class ChannelAuditWriter : IAuditWriter public void AttachDrain() => Volatile.Write(ref _drainAttached, 1); /// - /// 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. + /// Marks the drain as no longer running, so writes revert to the synchronous path. Called at + /// shutdown, and whenever the drain loop dies, so late audit is still persisted rather than + /// buffered into a channel with no reader. /// public void DetachDrain() => Volatile.Write(ref _drainAttached, 0); @@ -100,11 +102,13 @@ public sealed class ChannelAuditWriter : IAuditWriter 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. + // Under DropWrite a full channel still reports SUCCESS — the discard surfaces through the + // itemDropped callback. So a false here does not mean "full", it means the channel has + // been completed and no drain will ever read it again (shutdown, or a re-attach onto a + // dead channel). Writing through is the only outcome that keeps the event. if (!_channel.Writer.TryWrite(auditEvent)) { - RecordDrop(auditEvent); + return _directWriter.WriteAsync(auditEvent, cancellationToken); } return Task.CompletedTask; @@ -130,204 +134,3 @@ public sealed class ChannelAuditWriter : IAuditWriter } } } - -/// -/// Drains onto the durable , -/// owns the one-time schema bootstrap, and sweeps audit rows past their retention window. -/// -/// -/// Batching is the point: up to 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 CREATE TABLE IF NOT EXISTS round-trip. -/// -/// The channel writer whose buffered events are drained. -/// The durable sink events are committed to. -/// Security options carrying the audit retention window. -/// Clock used for the retention cutoff and sweep interval. -/// Logger for bootstrap, drain and sweep diagnostics. -public sealed class AuditDrainService( - ChannelAuditWriter writer, - IAuditEventSink sink, - SecurityOptions security, - TimeProvider timeProvider, - ILogger logger) : BackgroundService -{ - /// Maximum number of audit events committed in one transaction per drain pass. - public const int MaxBatchSize = 64; - - /// How often the retention sweep runs while the gateway is up. - public static readonly TimeSpan RetentionSweepInterval = TimeSpan.FromHours(1); - - /// Upper bound on how long shutdown waits for the remaining buffered events. - private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2); - - /// - /// Bootstraps the audit table, runs one retention sweep, then attaches the drain so the - /// writer switches from synchronous write-through to enqueueing. - /// - /// Token to observe for cancellation. - /// A task that represents the asynchronous operation. - 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); - } - - /// - /// Detaches the drain (so late writes go straight to the sink) and gives the buffered - /// events a bounded window to reach the store. - /// - /// Token to observe for cancellation. - /// A task that represents the asynchronous operation. - 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); - } - } - - /// - /// Commits every event currently buffered, in batches of at most . - /// - /// Token to observe for cancellation. - /// The number of events persisted. - public async Task DrainPendingAsync(CancellationToken cancellationToken) - { - int persisted = 0; - List 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; - } - - /// - /// Deletes audit rows older than MxGateway:Security:AuditRetentionDays, and reports - /// any audit events dropped by channel pressure since the last sweep. - /// - /// Token to observe for cancellation. - /// A task that represents the asynchronous operation. - 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); - } - } - - /// - 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. - } - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/IAuditEventSink.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/IAuditEventSink.cs new file mode 100644 index 0000000..7f12fb5 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/IAuditEventSink.cs @@ -0,0 +1,38 @@ +using ZB.MOM.WW.Audit; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; + +/// +/// Durable sink the audit pipeline persists canonical s through. +/// It exists so the write path () and the batching drain +/// () depend on the storage contract rather than on the +/// concrete . +/// +public interface IAuditEventSink +{ + /// + /// 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. + /// + /// Token to observe for cancellation. + /// A task that represents the asynchronous operation. + Task EnsureInitializedAsync(CancellationToken cancellationToken); + + /// Persists a single canonical audit event. + /// The canonical event to persist. + /// Token to observe for cancellation. + /// A task that represents the asynchronous operation. + Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken); + + /// Persists a batch of canonical audit events as one unit of work. + /// The canonical events to persist. + /// Token to observe for cancellation. + /// A task that represents the asynchronous operation. + Task InsertBatchAsync(IReadOnlyList auditEvents, CancellationToken cancellationToken); + + /// Deletes every audit row that occurred strictly before . + /// The retention cutoff; rows older than this are removed. + /// Token to observe for cancellation. + /// The number of rows deleted. + Task DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/SqliteCanonicalAuditStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/SqliteCanonicalAuditStore.cs index db83c62..3ad7f91 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/SqliteCanonicalAuditStore.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Audit/SqliteCanonicalAuditStore.cs @@ -5,41 +5,6 @@ using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; -/// -/// Durable sink the audit pipeline persists canonical s through. -/// It exists so the write path () and the batching drain -/// () depend on the storage contract rather than on the -/// concrete . -/// -public interface IAuditEventSink -{ - /// - /// 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. - /// - /// Token to observe for cancellation. - /// A task that represents the asynchronous operation. - Task EnsureInitializedAsync(CancellationToken cancellationToken); - - /// Persists a single canonical audit event. - /// The canonical event to persist. - /// Token to observe for cancellation. - /// A task that represents the asynchronous operation. - Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken); - - /// Persists a batch of canonical audit events as one unit of work. - /// The canonical events to persist. - /// Token to observe for cancellation. - /// A task that represents the asynchronous operation. - Task InsertBatchAsync(IReadOnlyList auditEvents, CancellationToken cancellationToken); - - /// Deletes every audit row that occurred strictly before . - /// The retention cutoff; rows older than this are removed. - /// Token to observe for cancellation. - /// The number of rows deleted. - Task DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken); -} - /// /// MxGateway-owned, append-only SQLite store for canonical /// s. It writes to a NEW audit_event table in the @@ -178,10 +143,14 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec /// /// /// The comparison goes through SQLite's datetime() rather than comparing the stored - /// ISO-8601 text directly: rows written from a non-UTC (the - /// library's CreatedUtc is caller-supplied) do not sort lexicographically against a - /// UTC cutoff. A row whose timestamp datetime() cannot parse yields NULL and is - /// therefore never deleted — audit that cannot be dated is kept, not swept. + /// ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized + /// ISO-8601 — which guarantees for rows written through + /// this store, but not for rows that entered the table any other way (a repair script, an + /// older schema, a future producer). On a mixed-format column a text comparison silently + /// deletes live audit: 2026-05-17T09:00:00-05:00 is two hours AFTER a + /// 2026-05-17T12:00:00+00:00 cutoff yet sorts before it. Comparing instants is correct + /// regardless of how the text got there, and anything datetime() cannot parse yields + /// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept. /// public async Task DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken) { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/ChannelAuditWriterTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/ChannelAuditWriterTests.cs index c96dce5..3b5a273 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/ChannelAuditWriterTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Audit/ChannelAuditWriterTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using ZB.MOM.WW.Audit; @@ -10,10 +11,11 @@ namespace ZB.MOM.WW.MxGateway.Tests.Security.Audit; /// /// Tests the bounded, asynchronous audit path: (enqueue-only -/// once a drain is attached, drops rather than blocks when the channel is full) and -/// (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. +/// once a drain is attached, drops rather than blocks when the channel is full, writes through +/// whenever nothing is draining) and (batched drain, poison-batch +/// isolation, 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. /// public sealed class ChannelAuditWriterTests : IDisposable { @@ -71,6 +73,29 @@ public sealed class ChannelAuditWriterTests : IDisposable Assert.All(sink.BatchSizes, size => Assert.True(size <= AuditDrainService.MaxBatchSize)); } + /// + /// A batch that will not commit is retried one event at a time, so a single unwritable row + /// costs only itself instead of taking the up-to-63 good events sharing its transaction. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DrainPendingAsync_WhenBatchFails_RetriesIndividuallyAndKeepsSurvivors() + { + CountingAuditSink sink = new() { PoisonAction = "poison" }; + (ChannelAuditWriter writer, AuditDrainService drain) = CreateWriterAndDrain(sink); + writer.AttachDrain(); + + await writer.WriteAsync(MakeEvent("good-1"), CancellationToken.None); + await writer.WriteAsync(MakeEvent("poison"), CancellationToken.None); + await writer.WriteAsync(MakeEvent("good-2"), CancellationToken.None); + + int persisted = await drain.DrainPendingAsync(CancellationToken.None); + + // The batch commit fails on the poison event; the per-event retry still lands both others. + Assert.Equal(2, persisted); + Assert.Equal(["good-1", "good-2"], sink.Events.Select(auditEvent => auditEvent.Action)); + } + /// /// 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. @@ -89,13 +114,12 @@ public sealed class ChannelAuditWriterTests : IDisposable // Every call must complete synchronously: the channel never blocks a producer. Task write = writer.WriteAsync(MakeEvent($"denied-{index}"), CancellationToken.None); Assert.True(write.IsCompletedSuccessfully); + await write; } 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; } /// @@ -114,6 +138,26 @@ public sealed class ChannelAuditWriterTests : IDisposable Assert.Equal("dashboard-create-key", Assert.Single(sink.Events).Action); } + /// + /// A completed channel has no future reader, so an attached writer must write through rather + /// than discard. This is the re-attach-after-shutdown footgun: enqueueing would lose 100% of + /// audit silently. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task WriteAsync_AfterChannelCompleted_WritesThroughInsteadOfDiscarding() + { + CountingAuditSink sink = new(); + (ChannelAuditWriter writer, _) = CreateWriterAndDrain(sink); + writer.AttachDrain(); + writer.CompleteWriting(); + + await writer.WriteAsync(MakeEvent("constraint-denied"), CancellationToken.None); + + Assert.Equal("constraint-denied", Assert.Single(sink.Events).Action); + Assert.Equal(0, writer.DroppedCount); + } + /// /// The retention sweep deletes rows older than AuditRetentionDays, measured from the /// injected clock, and runs once at startup after the one-time table bootstrap. @@ -137,29 +181,42 @@ public sealed class ChannelAuditWriterTests : IDisposable } /// - /// The real SQLite sink deletes only rows older than the cutoff, and its bootstrap latch - /// means the CREATE TABLE round-trip is not repeated on every subsequent write. + /// The sweep compares instants, not stored text, and never deletes a row it cannot date. /// /// A task that represents the asynchronous operation. [Fact] - public async Task SqliteStore_DeleteOlderThan_RemovesOnlyRowsBeyondTheRetentionWindow() + public async Task SqliteStore_DeleteOlderThan_ComparesInstantsAndKeepsUnparseableRows() { - SqliteCanonicalAuditStore store = CreateStore(); - DateTimeOffset now = new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero); + (SqliteCanonicalAuditStore store, AuthSqliteConnectionFactory factory) = CreateStore(); + + DateTimeOffset cutoff = new(2026, 5, 17, 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)), + MakeEvent("swept", new DateTimeOffset(2026, 5, 17, 11, 0, 0, TimeSpan.Zero)), + MakeEvent("kept-fresh", new DateTimeOffset(2026, 5, 18, 0, 0, 0, TimeSpan.Zero)), ], CancellationToken.None); - int deleted = await store.DeleteOlderThanAsync(now.AddDays(-90), CancellationToken.None); + // Both discriminating rows are written as raw text, because AuditEvent.OccurredAtUtc + // normalizes to UTC and so cannot express them. They stand for audit that reached the + // table any other way — a repair script, an older schema, a future producer. + // + // 09:00 at -05:00 is 14:00 UTC, two hours AFTER the cutoff, yet its text sorts BEFORE + // "2026-05-17T12:00:00.0000000+00:00". A lexicographic sweep deletes it; comparing + // instants keeps it. + await InsertRawRowAsync(factory, "kept-offset", "2026-05-17T09:00:00.1234567-05:00"); - Assert.Equal(2, deleted); - Assert.Equal("fresh", Assert.Single(await store.ListRecentAsync(10, CancellationToken.None)).Action); + // Undateable audit is kept, never guessed at. This text sorts BELOW the cutoff, so a + // lexicographic sweep deletes it, while datetime() yields NULL and leaves it alone. + await InsertRawRowAsync(factory, "kept-unparseable", "0000-not-a-timestamp"); + + int deleted = await store.DeleteOlderThanAsync(cutoff, CancellationToken.None); + + Assert.Equal(1, deleted); + Assert.Equal( + ["kept-fresh", "kept-offset", "kept-unparseable"], + (await ListActionsAsync(factory)).OrderBy(action => action, StringComparer.Ordinal)); } private static (ChannelAuditWriter Writer, AuditDrainService Drain) CreateWriterAndDrain( @@ -179,11 +236,51 @@ public sealed class ChannelAuditWriterTests : IDisposable return (writer, drain); } - private SqliteCanonicalAuditStore CreateStore() + private (SqliteCanonicalAuditStore Store, AuthSqliteConnectionFactory Factory) CreateStore() { TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-channel-audit"); _tempDirectories.Add(directory); - return new SqliteCanonicalAuditStore(new AuthSqliteConnectionFactory(directory.DatabasePath())); + AuthSqliteConnectionFactory factory = new(directory.DatabasePath()); + return (new SqliteCanonicalAuditStore(factory), factory); + } + + // Writes an audit row whose occurred_at_utc bypasses the store's DateTimeOffset formatting, + // so the sweep can be shown to leave undateable audit alone. + private static async Task InsertRawRowAsync( + AuthSqliteConnectionFactory factory, + string action, + string occurredAtUtc) + { + await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = + """ + INSERT INTO audit_event (event_id, occurred_at_utc, actor, action, outcome) + VALUES ($event_id, $occurred_at_utc, 'operator01', $action, 'Denied'); + """; + command.Parameters.AddWithValue("$event_id", Guid.NewGuid().ToString()); + command.Parameters.AddWithValue("$occurred_at_utc", occurredAtUtc); + command.Parameters.AddWithValue("$action", action); + + await command.ExecuteNonQueryAsync(CancellationToken.None); + } + + // Reads actions straight from SQL: ListRecentAsync would throw on the unparseable timestamp. + private static async Task> ListActionsAsync(AuthSqliteConnectionFactory factory) + { + await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT action FROM audit_event;"; + + List actions = []; + + await using SqliteDataReader reader = await command.ExecuteReaderAsync(CancellationToken.None); + while (await reader.ReadAsync(CancellationToken.None)) + { + actions.Add(reader.GetString(0)); + } + + return actions; } private static AuditEvent MakeEvent(string action, DateTimeOffset? occurredAtUtc = null) => new() @@ -209,22 +306,27 @@ public sealed class ChannelAuditWriterTests : IDisposable /// /// In-memory 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. + /// write path did NOT touch the sink and that the drain batched what it did write. Setting + /// makes any write containing that action fail, modelling a row + /// the store refuses. /// private sealed class CountingAuditSink : IAuditEventSink { private readonly Lock _gate = new(); + /// Gets the action whose presence makes a write fail, or null to accept everything. + public string? PoisonAction { get; init; } + /// Gets the events handed to the sink, in the order they were written. public List Events { get; } = []; - /// Gets the size of each batch the sink was asked to insert. + /// Gets the size of each batch the sink accepted. public List BatchSizes { get; } = []; /// Gets the cutoffs the sink was asked to delete below. public List DeleteCutoffs { get; } = []; - /// Gets the number of calls. + /// Gets the number of accepted calls. public int InsertBatchCalls => BatchSizes.Count; /// Gets the number of calls. @@ -248,6 +350,13 @@ public sealed class ChannelAuditWriterTests : IDisposable /// public Task InsertBatchAsync(IReadOnlyList auditEvents, CancellationToken cancellationToken) { + if (PoisonAction is not null + && auditEvents.Any(auditEvent => auditEvent.Action == PoisonAction)) + { + return Task.FromException( + new InvalidOperationException($"Refused a write of {auditEvents.Count} audit events.")); + } + lock (_gate) { BatchSizes.Add(auditEvents.Count);