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:
+11
-3
@@ -262,9 +262,17 @@ the gateway fails closed.
|
|||||||
|
|
||||||
Non-bulk constraint failures return gRPC `PermissionDenied`. Bulk read
|
Non-bulk constraint failures return gRPC `PermissionDenied`. Bulk read
|
||||||
commands preserve input order and return a failed `SubscribeResult` for each
|
commands preserve input order and return a failed `SubscribeResult` for each
|
||||||
denied item while still forwarding allowed items to the worker. Every denial
|
denied item while still forwarding allowed items to the worker. A non-bulk
|
||||||
adds an `api_key_audit` entry with the key id, command kind, target, and
|
denial adds one `constraint-denied` audit event (canonical `audit_event` table)
|
||||||
blocking constraint; secured values and raw credentials are never logged.
|
with the key id, command kind, target, and blocking constraint. A bulk command
|
||||||
|
adds **one aggregated** `constraint-denied` event per command, not one per
|
||||||
|
denied item: its target is `<commandKind>:bulk` and its details carry the
|
||||||
|
denied and total item counts, the distinct blocking constraint names, a sample
|
||||||
|
of at most 20 denied targets (`sampleTruncated` says whether more were
|
||||||
|
denied), and the client correlation id. The audit writer is a bounded channel
|
||||||
|
that drops on full, so per-item events let a single low-privilege key flush
|
||||||
|
every other pending audit record with one large bulk command. Secured values
|
||||||
|
and raw credentials are never logged.
|
||||||
|
|
||||||
## Scope Catalog
|
## Scope Catalog
|
||||||
|
|
||||||
|
|||||||
@@ -2112,26 +2112,26 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
public ValueTask<ConstraintFailure?> CheckReadTagAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
string tagAddress,
|
string tagAddress,
|
||||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
CancellationToken cancellationToken) => ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
public ValueTask<ConstraintFailure?> CheckReadHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
CancellationToken cancellationToken) => ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
public ValueTask<ConstraintFailure?> CheckWriteHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
CancellationToken cancellationToken) => ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task RecordDenialAsync(
|
public Task RecordDenialAsync(
|
||||||
@@ -2141,6 +2141,14 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
ConstraintFailure failure,
|
ConstraintFailure failure,
|
||||||
string? correlationId,
|
string? correlationId,
|
||||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task RecordBulkDenialAsync(
|
||||||
|
ApiKeyIdentity? identity,
|
||||||
|
string commandKind,
|
||||||
|
ConstraintDenialSummary summary,
|
||||||
|
string? correlationId,
|
||||||
|
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ public sealed class MxAccessGatewayService(
|
|||||||
|
|
||||||
Dictionary<int, SubscribeResult> denied = [];
|
Dictionary<int, SubscribeResult> denied = [];
|
||||||
List<string> allowed = [];
|
List<string> allowed = [];
|
||||||
|
ConstraintDenialSummary? denialSummary = null;
|
||||||
for (int index = 0; index < tagAddresses.Count; index++)
|
for (int index = 0; index < tagAddresses.Count; index++)
|
||||||
{
|
{
|
||||||
string tagAddress = tagAddresses[index];
|
string tagAddress = tagAddresses[index];
|
||||||
@@ -500,8 +501,7 @@ public sealed class MxAccessGatewayService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, correlationId, cancellationToken)
|
(denialSummary ??= new ConstraintDenialSummary(tagAddresses.Count)).Add(tagAddress, failure);
|
||||||
.ConfigureAwait(false);
|
|
||||||
denied[index] = new SubscribeResult
|
denied[index] = new SubscribeResult
|
||||||
{
|
{
|
||||||
ServerHandle = serverHandle,
|
ServerHandle = serverHandle,
|
||||||
@@ -511,11 +511,17 @@ public sealed class MxAccessGatewayService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (denied.Count == 0)
|
if (denialSummary is null)
|
||||||
{
|
{
|
||||||
return 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:
|
// 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
|
// 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
|
// (server_handle) are copied across explicitly. Nothing aliases the request here — these
|
||||||
@@ -556,6 +562,7 @@ public sealed class MxAccessGatewayService(
|
|||||||
|
|
||||||
Dictionary<int, BulkReadResult> denied = [];
|
Dictionary<int, BulkReadResult> denied = [];
|
||||||
List<string> allowed = [];
|
List<string> allowed = [];
|
||||||
|
ConstraintDenialSummary? denialSummary = null;
|
||||||
for (int index = 0; index < tagAddresses.Count; index++)
|
for (int index = 0; index < tagAddresses.Count; index++)
|
||||||
{
|
{
|
||||||
string tagAddress = tagAddresses[index];
|
string tagAddress = tagAddresses[index];
|
||||||
@@ -568,8 +575,7 @@ public sealed class MxAccessGatewayService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, correlationId, cancellationToken)
|
(denialSummary ??= new ConstraintDenialSummary(tagAddresses.Count)).Add(tagAddress, failure);
|
||||||
.ConfigureAwait(false);
|
|
||||||
denied[index] = new BulkReadResult
|
denied[index] = new BulkReadResult
|
||||||
{
|
{
|
||||||
ServerHandle = serverHandle,
|
ServerHandle = serverHandle,
|
||||||
@@ -580,11 +586,17 @@ public sealed class MxAccessGatewayService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (denied.Count == 0)
|
if (denialSummary is null)
|
||||||
{
|
{
|
||||||
return 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 };
|
MxCommand filtered = new() { Kind = command.Kind };
|
||||||
ReadBulkCommand payload = new()
|
ReadBulkCommand payload = new()
|
||||||
{
|
{
|
||||||
@@ -621,6 +633,7 @@ public sealed class MxAccessGatewayService(
|
|||||||
|
|
||||||
Dictionary<int, BulkWriteResult> denied = [];
|
Dictionary<int, BulkWriteResult> denied = [];
|
||||||
List<TEntry> allowed = [];
|
List<TEntry> allowed = [];
|
||||||
|
ConstraintDenialSummary? denialSummary = null;
|
||||||
for (int index = 0; index < entries.Count; index++)
|
for (int index = 0; index < entries.Count; index++)
|
||||||
{
|
{
|
||||||
TEntry entry = entries[index];
|
TEntry entry = entries[index];
|
||||||
@@ -634,14 +647,7 @@ public sealed class MxAccessGatewayService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await constraintEnforcer.RecordDenialAsync(
|
(denialSummary ??= new ConstraintDenialSummary(entries.Count)).Add(itemHandle, failure);
|
||||||
identity,
|
|
||||||
command.Kind.ToString(),
|
|
||||||
itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
||||||
failure,
|
|
||||||
correlationId,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
denied[index] = new BulkWriteResult
|
denied[index] = new BulkWriteResult
|
||||||
{
|
{
|
||||||
ServerHandle = serverHandle,
|
ServerHandle = serverHandle,
|
||||||
@@ -651,11 +657,17 @@ public sealed class MxAccessGatewayService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (denied.Count == 0)
|
if (denialSummary is null)
|
||||||
{
|
{
|
||||||
return 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(
|
return new WriteBulkConstraintPlan(
|
||||||
BuildFilteredWriteBulkCommand(command, allowed),
|
BuildFilteredWriteBulkCommand(command, allowed),
|
||||||
entries.Count,
|
entries.Count,
|
||||||
@@ -742,6 +754,7 @@ public sealed class MxAccessGatewayService(
|
|||||||
|
|
||||||
Dictionary<int, SubscribeResult> denied = [];
|
Dictionary<int, SubscribeResult> denied = [];
|
||||||
List<int> allowed = [];
|
List<int> allowed = [];
|
||||||
|
ConstraintDenialSummary? denialSummary = null;
|
||||||
for (int index = 0; index < itemHandles.Count; index++)
|
for (int index = 0; index < itemHandles.Count; index++)
|
||||||
{
|
{
|
||||||
int itemHandle = itemHandles[index];
|
int itemHandle = itemHandles[index];
|
||||||
@@ -754,8 +767,7 @@ public sealed class MxAccessGatewayService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, correlationId, cancellationToken)
|
(denialSummary ??= new ConstraintDenialSummary(itemHandles.Count)).Add(itemHandle, failure);
|
||||||
.ConfigureAwait(false);
|
|
||||||
denied[index] = new SubscribeResult
|
denied[index] = new SubscribeResult
|
||||||
{
|
{
|
||||||
ServerHandle = serverHandle,
|
ServerHandle = serverHandle,
|
||||||
@@ -765,11 +777,17 @@ public sealed class MxAccessGatewayService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (denied.Count == 0)
|
if (denialSummary is null)
|
||||||
{
|
{
|
||||||
return 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 };
|
MxCommand filtered = new() { Kind = command.Kind };
|
||||||
AdviseItemBulkCommand payload = new() { ServerHandle = command.AdviseItemBulk.ServerHandle };
|
AdviseItemBulkCommand payload = new() { ServerHandle = command.AdviseItemBulk.ServerHandle };
|
||||||
payload.ItemHandles.Add(allowed);
|
payload.ItemHandles.Add(allowed);
|
||||||
|
|||||||
@@ -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;
|
identity?.EffectiveConstraints.HasWriteConstraints ?? false;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
public ValueTask<ConstraintFailure?> CheckReadTagAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
string tagAddress,
|
string tagAddress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -33,14 +33,14 @@ public sealed class ConstraintEnforcer(
|
|||||||
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||||
if (!constraints.HasReadConstraints)
|
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 />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
public ValueTask<ConstraintFailure?> CheckReadHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
@@ -50,19 +50,19 @@ public sealed class ConstraintEnforcer(
|
|||||||
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||||
if (!constraints.HasReadConstraints)
|
if (!constraints.HasReadConstraints)
|
||||||
{
|
{
|
||||||
return Task.FromResult<ConstraintFailure?>(null);
|
return ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
|
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 />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
public ValueTask<ConstraintFailure?> CheckWriteHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
@@ -72,23 +72,23 @@ public sealed class ConstraintEnforcer(
|
|||||||
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||||
if (!constraints.HasWriteConstraints)
|
if (!constraints.HasWriteConstraints)
|
||||||
{
|
{
|
||||||
return Task.FromResult<ConstraintFailure?>(null);
|
return ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
|
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);
|
GalaxyTagLookup? target = ResolveTarget(registration.TagAddress);
|
||||||
if (target is null)
|
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))
|
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)
|
if (constraints.MaxWriteClassification is { } maxClassification)
|
||||||
@@ -96,18 +96,18 @@ public sealed class ConstraintEnforcer(
|
|||||||
GalaxyAttribute? attribute = target.Attribute;
|
GalaxyAttribute? attribute = target.Attribute;
|
||||||
if (attribute is null)
|
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)
|
if (attribute.SecurityClassification > maxClassification)
|
||||||
{
|
{
|
||||||
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure(
|
return ValueTask.FromResult<ConstraintFailure?>(new ConstraintFailure(
|
||||||
"max_write_classification",
|
"max_write_classification",
|
||||||
$"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}."));
|
$"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult<ConstraintFailure?>(null);
|
return ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -150,6 +150,46 @@ public sealed class ConstraintEnforcer(
|
|||||||
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
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(
|
private ConstraintFailure? CheckReadTarget(
|
||||||
ApiKeyConstraints constraints,
|
ApiKeyConstraints constraints,
|
||||||
string tagAddress)
|
string tagAddress)
|
||||||
@@ -240,4 +280,15 @@ public sealed class ConstraintEnforcer(
|
|||||||
|
|
||||||
return false;
|
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;
|
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
|
public interface IConstraintEnforcer
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -34,7 +41,7 @@ public interface IConstraintEnforcer
|
|||||||
/// <param name="tagAddress">Tag address to check.</param>
|
/// <param name="tagAddress">Tag address to check.</param>
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
/// <returns>The constraint failure details if denied; otherwise null.</returns>
|
/// <returns>The constraint failure details if denied; otherwise null.</returns>
|
||||||
Task<ConstraintFailure?> CheckReadTagAsync(
|
ValueTask<ConstraintFailure?> CheckReadTagAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
string tagAddress,
|
string tagAddress,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
@@ -46,7 +53,7 @@ public interface IConstraintEnforcer
|
|||||||
/// <param name="itemHandle">The MXAccess item handle.</param>
|
/// <param name="itemHandle">The MXAccess item handle.</param>
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
/// <returns>The constraint failure details if denied; otherwise null.</returns>
|
/// <returns>The constraint failure details if denied; otherwise null.</returns>
|
||||||
Task<ConstraintFailure?> CheckReadHandleAsync(
|
ValueTask<ConstraintFailure?> CheckReadHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
@@ -60,7 +67,7 @@ public interface IConstraintEnforcer
|
|||||||
/// <param name="itemHandle">The MXAccess item handle.</param>
|
/// <param name="itemHandle">The MXAccess item handle.</param>
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
/// <returns>The constraint failure details if denied; otherwise null.</returns>
|
/// <returns>The constraint failure details if denied; otherwise null.</returns>
|
||||||
Task<ConstraintFailure?> CheckWriteHandleAsync(
|
ValueTask<ConstraintFailure?> CheckWriteHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
@@ -85,4 +92,25 @@ public interface IConstraintEnforcer
|
|||||||
ConstraintFailure failure,
|
ConstraintFailure failure,
|
||||||
string? correlationId,
|
string? correlationId,
|
||||||
CancellationToken cancellationToken);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-2
@@ -87,8 +87,56 @@ public sealed class MxAccessGatewayServiceConstraintTests
|
|||||||
Assert.True(merged.Results[3].WasSuccessful);
|
Assert.True(merged.Results[3].WasSuccessful);
|
||||||
Assert.Equal("Tank04.Public", merged.Results[3].TagAddress);
|
Assert.Equal("Tank04.Public", merged.Results[3].TagAddress);
|
||||||
Assert.Equal(104, merged.Results[3].ItemHandle);
|
Assert.Equal(104, merged.Results[3].ItemHandle);
|
||||||
// Both denied tags recorded.
|
// Both denied tags recorded in ONE aggregated bulk denial, not one audit call per item.
|
||||||
Assert.Equal(2, enforcer.RecordedDenials.Count);
|
Assert.Empty(enforcer.RecordedDenials);
|
||||||
|
(string commandKind, ConstraintDenialSummary summary, _) = Assert.Single(enforcer.RecordedBulkDenials);
|
||||||
|
Assert.Equal(nameof(MxCommandKind.AddItemBulk), commandKind);
|
||||||
|
Assert.Equal(2, summary.DeniedCount);
|
||||||
|
Assert.Equal(4, summary.TotalCount);
|
||||||
|
Assert.Equal(["Tank01.Locked", "Tank03.Secret"], summary.SampleTargets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit-flood guard: a bulk write whose every entry is denied records exactly one
|
||||||
|
/// aggregated denial carrying the full denied count, the client correlation id and a sample
|
||||||
|
/// capped at <see cref="ConstraintDenialSummary.MaxSampleTargets"/> — so one low-privilege
|
||||||
|
/// key cannot push thousands of events into the bounded, drop-on-full audit channel.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Invoke_WriteBulk_WithManyDeniedEntries_RecordsOneAggregatedDenial()
|
||||||
|
{
|
||||||
|
const int EntryCount = 500;
|
||||||
|
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
|
||||||
|
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||||
|
WriteBulkCommand payload = new() { ServerHandle = 7 };
|
||||||
|
for (int index = 0; index < EntryCount; index++)
|
||||||
|
{
|
||||||
|
payload.Entries.Add(new WriteBulkEntry { ItemHandle = index + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
MxCommandRequest request = new()
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
ClientCorrelationId = "bulk-flood-1",
|
||||||
|
Command = new MxCommand { Kind = MxCommandKind.WriteBulk, WriteBulk = payload },
|
||||||
|
};
|
||||||
|
|
||||||
|
MxCommandReply reply = await service.Invoke(request, new TestServerCallContext());
|
||||||
|
|
||||||
|
Assert.Equal(0, sessionManager.InvokeCount);
|
||||||
|
Assert.Equal(EntryCount, reply.WriteBulk.Results.Count);
|
||||||
|
Assert.Empty(enforcer.RecordedDenials);
|
||||||
|
(string commandKind, ConstraintDenialSummary summary, string? correlationId) =
|
||||||
|
Assert.Single(enforcer.RecordedBulkDenials);
|
||||||
|
Assert.Equal(nameof(MxCommandKind.WriteBulk), commandKind);
|
||||||
|
Assert.Equal("bulk-flood-1", correlationId);
|
||||||
|
Assert.Equal(EntryCount, summary.DeniedCount);
|
||||||
|
Assert.Equal(EntryCount, summary.TotalCount);
|
||||||
|
Assert.Equal(ConstraintDenialSummary.MaxSampleTargets, summary.SampleTargets.Count);
|
||||||
|
Assert.True(summary.SampleTruncated);
|
||||||
|
Assert.Equal(["write-handle"], summary.ConstraintNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -154,6 +154,70 @@ public sealed class ConstraintEnforcerTests
|
|||||||
Assert.Equal("Read:Secret.Tag", auditEvent.Target);
|
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>
|
/// <summary>Verifies that historized-only constraint requires historized attribute.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -11,26 +11,26 @@ namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
|||||||
public sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
public sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
public ValueTask<ConstraintFailure?> CheckReadTagAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
string tagAddress,
|
string tagAddress,
|
||||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
CancellationToken cancellationToken) => ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
public ValueTask<ConstraintFailure?> CheckReadHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
CancellationToken cancellationToken) => ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
public ValueTask<ConstraintFailure?> CheckWriteHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
CancellationToken cancellationToken) => ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task RecordDenialAsync(
|
public Task RecordDenialAsync(
|
||||||
@@ -40,4 +40,12 @@ public sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
|||||||
ConstraintFailure failure,
|
ConstraintFailure failure,
|
||||||
string? correlationId,
|
string? correlationId,
|
||||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task RecordBulkDenialAsync(
|
||||||
|
ApiKeyIdentity? identity,
|
||||||
|
string commandKind,
|
||||||
|
ConstraintDenialSummary summary,
|
||||||
|
string? correlationId,
|
||||||
|
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,23 +26,26 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer
|
|||||||
/// <summary>Recorded denial messages — (commandKind, target, correlationId) tuples.</summary>
|
/// <summary>Recorded denial messages — (commandKind, target, correlationId) tuples.</summary>
|
||||||
public List<(string CommandKind, string Target, string? CorrelationId)> RecordedDenials { get; } = [];
|
public List<(string CommandKind, string Target, string? CorrelationId)> RecordedDenials { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Recorded aggregated bulk denials — one entry per bulk command that had any item denied.</summary>
|
||||||
|
public List<(string CommandKind, ConstraintDenialSummary Summary, string? CorrelationId)> RecordedBulkDenials { get; } = [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
public ValueTask<ConstraintFailure?> CheckReadTagAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
string tagAddress,
|
string tagAddress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (DenyTag(tagAddress))
|
if (DenyTag(tagAddress))
|
||||||
{
|
{
|
||||||
return Task.FromResult<ConstraintFailure?>(
|
return ValueTask.FromResult<ConstraintFailure?>(
|
||||||
new ConstraintFailure("read-tag", $"Read denied for tag '{tagAddress}'."));
|
new ConstraintFailure("read-tag", $"Read denied for tag '{tagAddress}'."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult<ConstraintFailure?>(null);
|
return ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
public ValueTask<ConstraintFailure?> CheckReadHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
@@ -51,15 +54,15 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer
|
|||||||
{
|
{
|
||||||
if (DenyReadHandle(serverHandle, itemHandle))
|
if (DenyReadHandle(serverHandle, itemHandle))
|
||||||
{
|
{
|
||||||
return Task.FromResult<ConstraintFailure?>(
|
return ValueTask.FromResult<ConstraintFailure?>(
|
||||||
new ConstraintFailure("read-handle", $"Read denied for handle {itemHandle}."));
|
new ConstraintFailure("read-handle", $"Read denied for handle {itemHandle}."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult<ConstraintFailure?>(null);
|
return ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
public ValueTask<ConstraintFailure?> CheckWriteHandleAsync(
|
||||||
ApiKeyIdentity? identity,
|
ApiKeyIdentity? identity,
|
||||||
GatewaySession session,
|
GatewaySession session,
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
@@ -68,11 +71,11 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer
|
|||||||
{
|
{
|
||||||
if (DenyWriteHandle(serverHandle, itemHandle))
|
if (DenyWriteHandle(serverHandle, itemHandle))
|
||||||
{
|
{
|
||||||
return Task.FromResult<ConstraintFailure?>(
|
return ValueTask.FromResult<ConstraintFailure?>(
|
||||||
new ConstraintFailure("write-handle", $"Write denied for handle {itemHandle}."));
|
new ConstraintFailure("write-handle", $"Write denied for handle {itemHandle}."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult<ConstraintFailure?>(null);
|
return ValueTask.FromResult<ConstraintFailure?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -87,4 +90,16 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer
|
|||||||
RecordedDenials.Add((commandKind, target, correlationId));
|
RecordedDenials.Add((commandKind, target, correlationId));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task RecordBulkDenialAsync(
|
||||||
|
ApiKeyIdentity? identity,
|
||||||
|
string commandKind,
|
||||||
|
ConstraintDenialSummary summary,
|
||||||
|
string? correlationId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
RecordedBulkDenials.Add((commandKind, summary, correlationId));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user