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:
@@ -0,0 +1,90 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates the constraint denials of one bulk command so they are audited as a single
|
||||
/// aggregated event rather than one event per denied item.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A bulk command can carry thousands of items, and the audit writer is a bounded, drop-on-full
|
||||
/// channel. One audit event per denied item let a single low-privilege key flood that channel and
|
||||
/// push every other audit record out of it. The summary keeps the forensic signal — how many items
|
||||
/// were denied, which constraints denied them, and a bounded sample of the targets — at a fixed
|
||||
/// cost of one audit event per command.
|
||||
/// </remarks>
|
||||
public sealed class ConstraintDenialSummary
|
||||
{
|
||||
/// <summary>The maximum number of denied targets retained as a sample.</summary>
|
||||
public const int MaxSampleTargets = 20;
|
||||
|
||||
private readonly List<string> _constraintNames = [];
|
||||
private readonly List<string> _sampleTargets = [];
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ConstraintDenialSummary"/> class.</summary>
|
||||
/// <param name="totalCount">The number of items the bulk command carried.</param>
|
||||
public ConstraintDenialSummary(int totalCount)
|
||||
{
|
||||
TotalCount = totalCount;
|
||||
}
|
||||
|
||||
/// <summary>Gets the number of items the bulk command carried.</summary>
|
||||
public int TotalCount { get; }
|
||||
|
||||
/// <summary>Gets the number of items denied.</summary>
|
||||
public int DeniedCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the distinct constraint names that denied at least one item, in first-seen order.</summary>
|
||||
public IReadOnlyList<string> ConstraintNames => _constraintNames;
|
||||
|
||||
/// <summary>Gets up to <see cref="MaxSampleTargets"/> denied targets, in item order.</summary>
|
||||
public IReadOnlyList<string> SampleTargets => _sampleTargets;
|
||||
|
||||
/// <summary>Gets a value indicating whether more items were denied than the sample retains.</summary>
|
||||
public bool SampleTruncated => DeniedCount > _sampleTargets.Count;
|
||||
|
||||
/// <summary>Records one denied tag-address item.</summary>
|
||||
/// <param name="target">The denied tag address.</param>
|
||||
/// <param name="failure">The constraint failure that denied it.</param>
|
||||
public void Add(string target, ConstraintFailure failure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(failure);
|
||||
AddConstraint(failure.ConstraintName);
|
||||
if (_sampleTargets.Count < MaxSampleTargets)
|
||||
{
|
||||
_sampleTargets.Add(target);
|
||||
}
|
||||
|
||||
DeniedCount++;
|
||||
}
|
||||
|
||||
/// <summary>Records one denied item-handle item.</summary>
|
||||
/// <param name="itemHandle">The denied MXAccess item handle.</param>
|
||||
/// <param name="failure">The constraint failure that denied it.</param>
|
||||
/// <remarks>The handle is formatted only while the sample still has room.</remarks>
|
||||
public void Add(int itemHandle, ConstraintFailure failure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(failure);
|
||||
AddConstraint(failure.ConstraintName);
|
||||
if (_sampleTargets.Count < MaxSampleTargets)
|
||||
{
|
||||
_sampleTargets.Add(itemHandle.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
DeniedCount++;
|
||||
}
|
||||
|
||||
private void AddConstraint(string constraintName)
|
||||
{
|
||||
// The constraint vocabulary is a handful of fixed names, so a linear scan beats a set.
|
||||
for (int index = 0; index < _constraintNames.Count; index++)
|
||||
{
|
||||
if (string.Equals(_constraintNames[index], constraintName, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_constraintNames.Add(constraintName);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
/// <summary>Enforces API-key read/write constraints and records constraint denials for audit.</summary>
|
||||
/// <remarks>
|
||||
/// The per-item checks return <see cref="ValueTask{TResult}"/> because every production check is
|
||||
/// synchronous (an in-memory Galaxy hierarchy lookup) and runs once per item of a bulk command:
|
||||
/// a <see cref="Task{TResult}"/> return allocated a task per item for a value that was already
|
||||
/// known. An implementation that genuinely needs to await still can.
|
||||
/// </remarks>
|
||||
public interface IConstraintEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
@@ -34,7 +41,7 @@ public interface IConstraintEnforcer
|
||||
/// <param name="tagAddress">Tag address to check.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>The constraint failure details if denied; otherwise null.</returns>
|
||||
Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ValueTask<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -46,7 +53,7 @@ public interface IConstraintEnforcer
|
||||
/// <param name="itemHandle">The MXAccess item handle.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>The constraint failure details if denied; otherwise null.</returns>
|
||||
Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ValueTask<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
@@ -60,7 +67,7 @@ public interface IConstraintEnforcer
|
||||
/// <param name="itemHandle">The MXAccess item handle.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>The constraint failure details if denied; otherwise null.</returns>
|
||||
Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ValueTask<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
@@ -85,4 +92,25 @@ public interface IConstraintEnforcer
|
||||
ConstraintFailure failure,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records every constraint denial of one bulk command as a single aggregated audit event.
|
||||
/// </summary>
|
||||
/// <param name="identity">The API key identity.</param>
|
||||
/// <param name="commandKind">The kind of bulk command whose items were denied.</param>
|
||||
/// <param name="summary">The accumulated denials: count, constraint names and a bounded target sample.</param>
|
||||
/// <param name="correlationId">The per-request client correlation id, if any (same handling as <see cref="RecordDenialAsync"/>).</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <remarks>
|
||||
/// Bulk callers use this instead of one <see cref="RecordDenialAsync"/> per item: the audit
|
||||
/// channel is bounded and drops on full, so per-item events let one bulk command evict every
|
||||
/// other pending audit record.
|
||||
/// </remarks>
|
||||
Task RecordBulkDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
ConstraintDenialSummary summary,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user