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
@@ -488,6 +488,7 @@ public sealed class MxAccessGatewayService(
Dictionary<int, SubscribeResult> denied = [];
List<string> allowed = [];
ConstraintDenialSummary? denialSummary = null;
for (int index = 0; index < tagAddresses.Count; index++)
{
string tagAddress = tagAddresses[index];
@@ -500,8 +501,7 @@ public sealed class MxAccessGatewayService(
continue;
}
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, correlationId, cancellationToken)
.ConfigureAwait(false);
(denialSummary ??= new ConstraintDenialSummary(tagAddresses.Count)).Add(tagAddress, failure);
denied[index] = new SubscribeResult
{
ServerHandle = serverHandle,
@@ -511,11 +511,17 @@ public sealed class MxAccessGatewayService(
};
}
if (denied.Count == 0)
if (denialSummary is null)
{
return null;
}
// One aggregated audit event per bulk command, not one per denied item (see
// ConstraintDenialSummary for why).
await constraintEnforcer
.RecordBulkDenialAsync(identity, command.Kind.ToString(), denialSummary, correlationId, cancellationToken)
.ConfigureAwait(false);
// Build the filtered command directly instead of cloning the original and clearing it:
// the clone deep-copied every denied address only to drop it. The payload's other fields
// (server_handle) are copied across explicitly. Nothing aliases the request here — these
@@ -556,6 +562,7 @@ public sealed class MxAccessGatewayService(
Dictionary<int, BulkReadResult> denied = [];
List<string> allowed = [];
ConstraintDenialSummary? denialSummary = null;
for (int index = 0; index < tagAddresses.Count; index++)
{
string tagAddress = tagAddresses[index];
@@ -568,8 +575,7 @@ public sealed class MxAccessGatewayService(
continue;
}
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, correlationId, cancellationToken)
.ConfigureAwait(false);
(denialSummary ??= new ConstraintDenialSummary(tagAddresses.Count)).Add(tagAddress, failure);
denied[index] = new BulkReadResult
{
ServerHandle = serverHandle,
@@ -580,11 +586,17 @@ public sealed class MxAccessGatewayService(
};
}
if (denied.Count == 0)
if (denialSummary is null)
{
return null;
}
// One aggregated audit event per bulk command, not one per denied item (see
// ConstraintDenialSummary for why).
await constraintEnforcer
.RecordBulkDenialAsync(identity, command.Kind.ToString(), denialSummary, correlationId, cancellationToken)
.ConfigureAwait(false);
MxCommand filtered = new() { Kind = command.Kind };
ReadBulkCommand payload = new()
{
@@ -621,6 +633,7 @@ public sealed class MxAccessGatewayService(
Dictionary<int, BulkWriteResult> denied = [];
List<TEntry> allowed = [];
ConstraintDenialSummary? denialSummary = null;
for (int index = 0; index < entries.Count; index++)
{
TEntry entry = entries[index];
@@ -634,14 +647,7 @@ public sealed class MxAccessGatewayService(
continue;
}
await constraintEnforcer.RecordDenialAsync(
identity,
command.Kind.ToString(),
itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture),
failure,
correlationId,
cancellationToken)
.ConfigureAwait(false);
(denialSummary ??= new ConstraintDenialSummary(entries.Count)).Add(itemHandle, failure);
denied[index] = new BulkWriteResult
{
ServerHandle = serverHandle,
@@ -651,11 +657,17 @@ public sealed class MxAccessGatewayService(
};
}
if (denied.Count == 0)
if (denialSummary is null)
{
return null;
}
// One aggregated audit event per bulk command, not one per denied item (see
// ConstraintDenialSummary for why).
await constraintEnforcer
.RecordBulkDenialAsync(identity, command.Kind.ToString(), denialSummary, correlationId, cancellationToken)
.ConfigureAwait(false);
return new WriteBulkConstraintPlan(
BuildFilteredWriteBulkCommand(command, allowed),
entries.Count,
@@ -742,6 +754,7 @@ public sealed class MxAccessGatewayService(
Dictionary<int, SubscribeResult> denied = [];
List<int> allowed = [];
ConstraintDenialSummary? denialSummary = null;
for (int index = 0; index < itemHandles.Count; index++)
{
int itemHandle = itemHandles[index];
@@ -754,8 +767,7 @@ public sealed class MxAccessGatewayService(
continue;
}
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, correlationId, cancellationToken)
.ConfigureAwait(false);
(denialSummary ??= new ConstraintDenialSummary(itemHandles.Count)).Add(itemHandle, failure);
denied[index] = new SubscribeResult
{
ServerHandle = serverHandle,
@@ -765,11 +777,17 @@ public sealed class MxAccessGatewayService(
};
}
if (denied.Count == 0)
if (denialSummary is null)
{
return null;
}
// One aggregated audit event per bulk command, not one per denied item (see
// ConstraintDenialSummary for why).
await constraintEnforcer
.RecordBulkDenialAsync(identity, command.Kind.ToString(), denialSummary, correlationId, cancellationToken)
.ConfigureAwait(false);
MxCommand filtered = new() { Kind = command.Kind };
AdviseItemBulkCommand payload = new() { ServerHandle = command.AdviseItemBulk.ServerHandle };
payload.ItemHandles.Add(allowed);