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
@@ -25,7 +25,7 @@ public sealed class ConstraintEnforcer(
identity?.EffectiveConstraints.HasWriteConstraints ?? false;
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync(
public ValueTask<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken)
@@ -33,14 +33,14 @@ public sealed class ConstraintEnforcer(
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
if (!constraints.HasReadConstraints)
{
return Task.FromResult<ConstraintFailure?>(null);
return ValueTask.FromResult<ConstraintFailure?>(null);
}
return Task.FromResult(CheckReadTarget(constraints, tagAddress));
return ValueTask.FromResult(CheckReadTarget(constraints, tagAddress));
}
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadHandleAsync(
public ValueTask<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
@@ -50,19 +50,19 @@ public sealed class ConstraintEnforcer(
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
if (!constraints.HasReadConstraints)
{
return Task.FromResult<ConstraintFailure?>(null);
return ValueTask.FromResult<ConstraintFailure?>(null);
}
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
}
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
return ValueTask.FromResult(CheckReadTarget(constraints, registration.TagAddress));
}
/// <inheritdoc />
public Task<ConstraintFailure?> CheckWriteHandleAsync(
public ValueTask<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
@@ -72,23 +72,23 @@ public sealed class ConstraintEnforcer(
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
if (!constraints.HasWriteConstraints)
{
return Task.FromResult<ConstraintFailure?>(null);
return ValueTask.FromResult<ConstraintFailure?>(null);
}
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
}
GalaxyTagLookup? target = ResolveTarget(registration.TagAddress);
if (target is null)
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."));
}
if (!MatchesPathOrTag(target.ContainedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs))
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("write_scope", "Tag is outside the API key write scope."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("write_scope", "Tag is outside the API key write scope."));
}
if (constraints.MaxWriteClassification is { } maxClassification)
@@ -96,18 +96,18 @@ public sealed class ConstraintEnforcer(
GalaxyAttribute? attribute = target.Attribute;
if (attribute is null)
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
}
if (attribute.SecurityClassification > maxClassification)
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure(
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure(
"max_write_classification",
$"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}."));
}
}
return Task.FromResult<ConstraintFailure?>(null);
return ValueTask.FromResult<ConstraintFailure?>(null);
}
/// <inheritdoc />
@@ -150,6 +150,46 @@ public sealed class ConstraintEnforcer(
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task RecordBulkDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
ConstraintDenialSummary summary,
string? correlationId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(summary);
if (summary.DeniedCount == 0)
{
return;
}
// One event for the whole command. Target stays "<commandKind>:<target>"-shaped so audit
// queries that split on ':' keep working; the "bulk" target marks the aggregated form.
AuditEvent auditEvent = new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = identity?.KeyId ?? "anonymous",
Action = "constraint-denied",
Outcome = AuditOutcome.Denied,
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
Target = $"{commandKind}:bulk",
SourceNode = null,
CorrelationId = Guid.TryParse(correlationId, out var cid) ? cid : (Guid?)null,
DetailsJson = JsonSerializer.Serialize(new BulkDenialDetails(
summary.ConstraintNames,
commandKind,
summary.DeniedCount,
summary.TotalCount,
summary.SampleTargets,
summary.SampleTruncated,
correlationId ?? "")),
};
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
}
private ConstraintFailure? CheckReadTarget(
ApiKeyConstraints constraints,
string tagAddress)
@@ -240,4 +280,15 @@ public sealed class ConstraintEnforcer(
return false;
}
// DetailsJson shape of an aggregated bulk denial. Serialized with camelCase names to match the
// single-item envelope's keys ("commandKind", "clientCorrelationId").
private sealed record BulkDenialDetails(
[property: System.Text.Json.Serialization.JsonPropertyName("constraints")] IReadOnlyList<string> Constraints,
[property: System.Text.Json.Serialization.JsonPropertyName("commandKind")] string CommandKind,
[property: System.Text.Json.Serialization.JsonPropertyName("deniedCount")] int DeniedCount,
[property: System.Text.Json.Serialization.JsonPropertyName("totalCount")] int TotalCount,
[property: System.Text.Json.Serialization.JsonPropertyName("sampleTargets")] IReadOnlyList<string> SampleTargets,
[property: System.Text.Json.Serialization.JsonPropertyName("sampleTruncated")] bool SampleTruncated,
[property: System.Text.Json.Serialization.JsonPropertyName("clientCorrelationId")] string ClientCorrelationId);
}