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
@@ -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);
}