fix(auth): aggregate bulk constraint denials into one audit event; ValueTask constraint checks

A bulk command with N denied items wrote N audit events into the bounded,
drop-on-full audit channel, so a low-privilege key could evict every other
pending audit record. Bulk filters now accumulate a ConstraintDenialSummary
(count, constraint names, <=20 sample targets) and write one aggregated
constraint-denied event per command.

The per-item constraint checks are synchronous, so IConstraintEnforcer's
Check* methods now return ValueTask instead of allocating a Task per item.
This commit is contained in:
Joseph Doherty
2026-10-22 06:12:02 -04:00
parent 488649ce99
commit ca0cf19a1f
10 changed files with 400 additions and 62 deletions
@@ -87,8 +87,56 @@ public sealed class MxAccessGatewayServiceConstraintTests
Assert.True(merged.Results[3].WasSuccessful);
Assert.Equal("Tank04.Public", merged.Results[3].TagAddress);
Assert.Equal(104, merged.Results[3].ItemHandle);
// Both denied tags recorded.
Assert.Equal(2, enforcer.RecordedDenials.Count);
// Both denied tags recorded in ONE aggregated bulk denial, not one audit call per item.
Assert.Empty(enforcer.RecordedDenials);
(string commandKind, ConstraintDenialSummary summary, _) = Assert.Single(enforcer.RecordedBulkDenials);
Assert.Equal(nameof(MxCommandKind.AddItemBulk), commandKind);
Assert.Equal(2, summary.DeniedCount);
Assert.Equal(4, summary.TotalCount);
Assert.Equal(["Tank01.Locked", "Tank03.Secret"], summary.SampleTargets);
}
/// <summary>
/// Audit-flood guard: a bulk write whose every entry is denied records exactly one
/// aggregated denial carrying the full denied count, the client correlation id and a sample
/// capped at <see cref="ConstraintDenialSummary.MaxSampleTargets"/> — so one low-privilege
/// key cannot push thousands of events into the bounded, drop-on-full audit channel.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Invoke_WriteBulk_WithManyDeniedEntries_RecordsOneAggregatedDenial()
{
const int EntryCount = 500;
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
WriteBulkCommand payload = new() { ServerHandle = 7 };
for (int index = 0; index < EntryCount; index++)
{
payload.Entries.Add(new WriteBulkEntry { ItemHandle = index + 1 });
}
MxCommandRequest request = new()
{
SessionId = SessionId,
ClientCorrelationId = "bulk-flood-1",
Command = new MxCommand { Kind = MxCommandKind.WriteBulk, WriteBulk = payload },
};
MxCommandReply reply = await service.Invoke(request, new TestServerCallContext());
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Equal(EntryCount, reply.WriteBulk.Results.Count);
Assert.Empty(enforcer.RecordedDenials);
(string commandKind, ConstraintDenialSummary summary, string? correlationId) =
Assert.Single(enforcer.RecordedBulkDenials);
Assert.Equal(nameof(MxCommandKind.WriteBulk), commandKind);
Assert.Equal("bulk-flood-1", correlationId);
Assert.Equal(EntryCount, summary.DeniedCount);
Assert.Equal(EntryCount, summary.TotalCount);
Assert.Equal(ConstraintDenialSummary.MaxSampleTargets, summary.SampleTargets.Count);
Assert.True(summary.SampleTruncated);
Assert.Equal(["write-handle"], summary.ConstraintNames);
}
/// <summary>