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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user