dc2df628e3
The remediation reviews approved every task but left a tail of small notes. This lands the gateway-side half of them. Hardening (behavior changes, all narrow): - BuildFilteredWriteBulkCommand's unreachable default case failed OPEN: a fifth bulk-write kind added upstream without a filter case here would have shipped the DENIED entries to the worker while reporting them denied to the caller. It now throws UnreachableException. - SqliteCanonicalAuditStore.ListRecentAsync no longer throws on a row it cannot date. The retention sweep deliberately preserves such rows (SQLite's datetime() yields NULL, so the DELETE never matches), which guaranteed the dashboard's recent-audit view would meet one eventually and lose the whole page to it. The row is now reported at DateTimeOffset.MinValue with every other column intact, behind an optional logger. - The audit drain loop's finally now completes the channel writer alongside detaching the drain, so a producer that raced past the attached check takes the write-through branch instead of stranding its event in a buffer nobody reads until shutdown. TryComplete is idempotent, so StopAsync is unaffected. Tests: - MapCommandReply ownership (Assert.Same on the inner reply), mirroring the existing MapEvent ownership test. - Redactor key-id length boundary at exactly 64 and 65 characters, pinning which way it fails. Nothing validates key-id length at creation, so docs/Diagnostics.md's "which no issued key id does" is now stated as the heuristic it is. - ApiKeyFailureLimiter.Reset with a PartitionResolution whose partition was evicted between the Check and the Reset: inert, and clears nobody else's block. - Constraint-cache concurrency stress: the cap is enforced by the inserting thread, so overshoot must be transient and proportional to the in-flight inserters, and the cache must settle at or under the cap. - ListRecentAsync against a raw-SQL undateable row. Comment/doc accuracy: - EventsHubViewerRegistry.ReleaseConnection records that it relies on SignalR's default sequential per-connection dispatch (MaximumParallelInvocationsPerClient = 1). - A PERF(followup) note on Invoke's double session resolve and why removing it needs a SessionManager overload. - SessionEventDistributor: the volatile-field comment named the pump as the lock-free reader, but the pump's single capture point is inside _replayLock; the genuinely lock-free reader is SubscriberCount. OnSubscriberOverflow's "cannot be observed here" now excepts the DisposeAsync abandon path. The churn test names its ConcurrentDictionary bucket-order assumption and that a violation surfaces as a read timeout, not a silent pass. - The two "restores the sequential drain's behavior" claims (SessionManager, docs/Sessions.md) were wrong: the sequential drain leaked too, because KillWorkerAsync's entry ThrowIfCancellationRequested aborted the whole loop on the first session for zero kills. Reworded to "fixes a leak the sequential drain also had", with the sweep-bound/shutdown-unbound ParallelOptions asymmetry explained. - ISessionManager.ShutdownAsync's token doc: it degrades the drain to a kill sweep rather than cancelling it, with the bounded overrun stated. SessionShutdownHostedService.StopAsync records that its cancellation-logging branch is now unreachable.
409 lines
18 KiB
C#
409 lines
18 KiB
C#
using Microsoft.Data.Sqlite;
|
|
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, writes through
|
|
/// whenever nothing is draining) and <see cref="AuditDrainService"/> (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.
|
|
/// </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>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <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);
|
|
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);
|
|
}
|
|
|
|
/// <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>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <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 sweep compares instants, not stored text, and never deletes a row it cannot date.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SqliteStore_DeleteOlderThan_ComparesInstantsAndKeepsUnparseableRows()
|
|
{
|
|
(SqliteCanonicalAuditStore store, AuthSqliteConnectionFactory factory) = CreateStore();
|
|
|
|
DateTimeOffset cutoff = new(2026, 5, 17, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
await store.InsertBatchAsync(
|
|
[
|
|
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);
|
|
|
|
// 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");
|
|
|
|
// 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The sweep above deliberately preserves rows it cannot date, so the dashboard's recent-audit
|
|
/// view is guaranteed to meet one eventually. Reading must therefore be defensive: the
|
|
/// undateable row is reported at <see cref="DateTimeOffset.MinValue"/> with every other column
|
|
/// intact, rather than one bad row throwing the whole page away.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SqliteStore_ListRecent_WithUnparseableRow_ReportsMinValueInsteadOfThrowing()
|
|
{
|
|
(SqliteCanonicalAuditStore store, AuthSqliteConnectionFactory factory) = CreateStore();
|
|
|
|
DateTimeOffset occurred = new(2026, 5, 18, 0, 0, 0, TimeSpan.Zero);
|
|
await store.InsertAsync(MakeEvent("dateable", occurred), CancellationToken.None);
|
|
await InsertRawRowAsync(factory, "undateable", "0000-not-a-timestamp");
|
|
|
|
IReadOnlyList<AuditEvent> recent = await store.ListRecentAsync(10, CancellationToken.None);
|
|
|
|
Assert.Equal(2, recent.Count);
|
|
Assert.Equal(occurred, recent.Single(auditEvent => auditEvent.Action == "dateable").OccurredAtUtc);
|
|
|
|
AuditEvent undateable = recent.Single(auditEvent => auditEvent.Action == "undateable");
|
|
Assert.Equal(DateTimeOffset.MinValue, undateable.OccurredAtUtc);
|
|
Assert.Equal("operator01", undateable.Actor);
|
|
Assert.Equal(AuditOutcome.Denied, undateable.Outcome);
|
|
}
|
|
|
|
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 Store, AuthSqliteConnectionFactory Factory) CreateStore()
|
|
{
|
|
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-channel-audit");
|
|
_tempDirectories.Add(directory);
|
|
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 so the sweep assertion depends on the DELETE alone, with no
|
|
// opinion from the store's read path about how an undateable row is surfaced.
|
|
private static async Task<List<string>> 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<string> 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()
|
|
{
|
|
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. Setting
|
|
/// <see cref="PoisonAction"/> makes any write containing that action fail, modelling a row
|
|
/// the store refuses.
|
|
/// </summary>
|
|
private sealed class CountingAuditSink : IAuditEventSink
|
|
{
|
|
private readonly Lock _gate = new();
|
|
|
|
/// <summary>Gets the action whose presence makes a write fail, or null to accept everything.</summary>
|
|
public string? PoisonAction { get; init; }
|
|
|
|
/// <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 accepted.</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 accepted <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)
|
|
{
|
|
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);
|
|
Events.AddRange(auditEvents);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
DeleteCutoffs.Add(cutoffUtc);
|
|
}
|
|
|
|
return Task.FromResult(0);
|
|
}
|
|
}
|
|
}
|