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
@@ -154,6 +154,70 @@ public sealed class ConstraintEnforcerTests
Assert.Equal("Read:Secret.Tag", auditEvent.Target);
}
/// <summary>
/// A bulk denial is written as ONE audit event carrying the denied/total counts, the distinct
/// constraint names and a sample capped at <see cref="ConstraintDenialSummary.MaxSampleTargets"/>,
/// however many items were denied.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordBulkDenialAsync_WritesOneAggregatedEventWithBoundedSample()
{
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
ConstraintDenialSummary summary = new(totalCount: 1000);
for (int index = 0; index < 999; index++)
{
summary.Add(
$"Area1.Tag{index}",
index % 2 == 0
? new ConstraintFailure("read_scope", "Tag is outside the API key read scope.")
: new ConstraintFailure("tag_metadata", "Tag metadata is not available."));
}
await enforcer.RecordBulkDenialAsync(
CreateIdentity(ApiKeyConstraints.Empty),
"ReadBulk",
summary,
"trace-1",
CancellationToken.None);
AuditEvent auditEvent = Assert.Single(auditWriter.Events);
Assert.Equal("operator01", auditEvent.Actor);
Assert.Equal("constraint-denied", auditEvent.Action);
Assert.Equal(AuditOutcome.Denied, auditEvent.Outcome);
Assert.Equal("ReadBulk:bulk", auditEvent.Target);
Assert.Null(auditEvent.CorrelationId);
using JsonDocument details = JsonDocument.Parse(auditEvent.DetailsJson!);
JsonElement root = details.RootElement;
Assert.Equal(999, root.GetProperty("deniedCount").GetInt32());
Assert.Equal(1000, root.GetProperty("totalCount").GetInt32());
Assert.True(root.GetProperty("sampleTruncated").GetBoolean());
Assert.Equal(ConstraintDenialSummary.MaxSampleTargets, root.GetProperty("sampleTargets").GetArrayLength());
Assert.Equal(
["read_scope", "tag_metadata"],
root.GetProperty("constraints").EnumerateArray().Select(e => e.GetString() ?? string.Empty).ToArray());
Assert.Equal("ReadBulk", root.GetProperty("commandKind").GetString());
Assert.Equal("trace-1", root.GetProperty("clientCorrelationId").GetString());
}
/// <summary>An empty summary (nothing denied) writes no audit event at all.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordBulkDenialAsync_WithNoDenials_WritesNothing()
{
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter);
await enforcer.RecordBulkDenialAsync(
identity: null,
"ReadBulk",
new ConstraintDenialSummary(totalCount: 3),
correlationId: null,
CancellationToken.None);
Assert.Empty(auditWriter.Events);
}
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]