diff --git a/docs/Authorization.md b/docs/Authorization.md index c22425a..ed5ec8b 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -262,9 +262,17 @@ the gateway fails closed. Non-bulk constraint failures return gRPC `PermissionDenied`. Bulk read commands preserve input order and return a failed `SubscribeResult` for each -denied item while still forwarding allowed items to the worker. Every denial -adds an `api_key_audit` entry with the key id, command kind, target, and -blocking constraint; secured values and raw credentials are never logged. +denied item while still forwarding allowed items to the worker. A non-bulk +denial adds one `constraint-denied` audit event (canonical `audit_event` table) +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 `: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 diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs index fcc2506..ed0acf1 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs @@ -2112,26 +2112,26 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer { /// - public Task CheckReadTagAsync( + public ValueTask CheckReadTagAsync( ApiKeyIdentity? identity, string tagAddress, - CancellationToken cancellationToken) => Task.FromResult(null); + CancellationToken cancellationToken) => ValueTask.FromResult(null); /// - public Task CheckReadHandleAsync( + public ValueTask CheckReadHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); + CancellationToken cancellationToken) => ValueTask.FromResult(null); /// - public Task CheckWriteHandleAsync( + public ValueTask CheckWriteHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); + CancellationToken cancellationToken) => ValueTask.FromResult(null); /// public Task RecordDenialAsync( @@ -2141,6 +2141,14 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) ConstraintFailure failure, string? correlationId, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task RecordBulkDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + ConstraintDenialSummary summary, + string? correlationId, + CancellationToken cancellationToken) => Task.CompletedTask; } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs index 99ba149..96688a8 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs @@ -488,6 +488,7 @@ public sealed class MxAccessGatewayService( Dictionary denied = []; List 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 denied = []; List 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 denied = []; List 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 denied = []; List 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); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintDenialSummary.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintDenialSummary.cs new file mode 100644 index 0000000..bde3db7 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintDenialSummary.cs @@ -0,0 +1,90 @@ +using System.Globalization; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; + +/// +/// Accumulates the constraint denials of one bulk command so they are audited as a single +/// aggregated event rather than one event per denied item. +/// +/// +/// 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. +/// +public sealed class ConstraintDenialSummary +{ + /// The maximum number of denied targets retained as a sample. + public const int MaxSampleTargets = 20; + + private readonly List _constraintNames = []; + private readonly List _sampleTargets = []; + + /// Initializes a new instance of the class. + /// The number of items the bulk command carried. + public ConstraintDenialSummary(int totalCount) + { + TotalCount = totalCount; + } + + /// Gets the number of items the bulk command carried. + public int TotalCount { get; } + + /// Gets the number of items denied. + public int DeniedCount { get; private set; } + + /// Gets the distinct constraint names that denied at least one item, in first-seen order. + public IReadOnlyList ConstraintNames => _constraintNames; + + /// Gets up to denied targets, in item order. + public IReadOnlyList SampleTargets => _sampleTargets; + + /// Gets a value indicating whether more items were denied than the sample retains. + public bool SampleTruncated => DeniedCount > _sampleTargets.Count; + + /// Records one denied tag-address item. + /// The denied tag address. + /// The constraint failure that denied it. + public void Add(string target, ConstraintFailure failure) + { + ArgumentNullException.ThrowIfNull(failure); + AddConstraint(failure.ConstraintName); + if (_sampleTargets.Count < MaxSampleTargets) + { + _sampleTargets.Add(target); + } + + DeniedCount++; + } + + /// Records one denied item-handle item. + /// The denied MXAccess item handle. + /// The constraint failure that denied it. + /// The handle is formatted only while the sample still has room. + 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); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs index a61bcfd..21ca812 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs @@ -25,7 +25,7 @@ public sealed class ConstraintEnforcer( identity?.EffectiveConstraints.HasWriteConstraints ?? false; /// - public Task CheckReadTagAsync( + public ValueTask 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(null); + return ValueTask.FromResult(null); } - return Task.FromResult(CheckReadTarget(constraints, tagAddress)); + return ValueTask.FromResult(CheckReadTarget(constraints, tagAddress)); } /// - public Task CheckReadHandleAsync( + public ValueTask 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(null); + return ValueTask.FromResult(null); } if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) { - return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); + return ValueTask.FromResult(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)); } /// - public Task CheckWriteHandleAsync( + public ValueTask 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(null); + return ValueTask.FromResult(null); } if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) { - return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); + return ValueTask.FromResult(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(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.")); + return ValueTask.FromResult(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(new ConstraintFailure("write_scope", "Tag is outside the API key write scope.")); + return ValueTask.FromResult(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(new ConstraintFailure("max_write_classification", "Attribute security classification is not available.")); + return ValueTask.FromResult(new ConstraintFailure("max_write_classification", "Attribute security classification is not available.")); } if (attribute.SecurityClassification > maxClassification) { - return Task.FromResult(new ConstraintFailure( + return ValueTask.FromResult(new ConstraintFailure( "max_write_classification", $"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}.")); } } - return Task.FromResult(null); + return ValueTask.FromResult(null); } /// @@ -150,6 +150,46 @@ public sealed class ConstraintEnforcer( await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false); } + /// + 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 ":"-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 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 SampleTargets, + [property: System.Text.Json.Serialization.JsonPropertyName("sampleTruncated")] bool SampleTruncated, + [property: System.Text.Json.Serialization.JsonPropertyName("clientCorrelationId")] string ClientCorrelationId); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs index 95728ae..a777b49 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs @@ -3,6 +3,13 @@ using ZB.MOM.WW.MxGateway.Server.Sessions; namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; +/// Enforces API-key read/write constraints and records constraint denials for audit. +/// +/// The per-item checks return because every production check is +/// synchronous (an in-memory Galaxy hierarchy lookup) and runs once per item of a bulk command: +/// a return allocated a task per item for a value that was already +/// known. An implementation that genuinely needs to await still can. +/// public interface IConstraintEnforcer { /// @@ -34,7 +41,7 @@ public interface IConstraintEnforcer /// Tag address to check. /// Token to observe for cancellation. /// The constraint failure details if denied; otherwise null. - Task CheckReadTagAsync( + ValueTask CheckReadTagAsync( ApiKeyIdentity? identity, string tagAddress, CancellationToken cancellationToken); @@ -46,7 +53,7 @@ public interface IConstraintEnforcer /// The MXAccess item handle. /// Token to observe for cancellation. /// The constraint failure details if denied; otherwise null. - Task CheckReadHandleAsync( + ValueTask CheckReadHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, @@ -60,7 +67,7 @@ public interface IConstraintEnforcer /// The MXAccess item handle. /// Token to observe for cancellation. /// The constraint failure details if denied; otherwise null. - Task CheckWriteHandleAsync( + ValueTask CheckWriteHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, @@ -85,4 +92,25 @@ public interface IConstraintEnforcer ConstraintFailure failure, string? correlationId, CancellationToken cancellationToken); + + /// + /// Records every constraint denial of one bulk command as a single aggregated audit event. + /// + /// The API key identity. + /// The kind of bulk command whose items were denied. + /// The accumulated denials: count, constraint names and a bounded target sample. + /// The per-request client correlation id, if any (same handling as ). + /// Token to observe for cancellation. + /// A task that represents the asynchronous operation. + /// + /// Bulk callers use this instead of one 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. + /// + Task RecordBulkDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + ConstraintDenialSummary summary, + string? correlationId, + CancellationToken cancellationToken); } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs index 304a9a0..5959384 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs @@ -87,8 +87,56 @@ public sealed class MxAccessGatewayServiceConstraintTests Assert.True(merged.Results[3].WasSuccessful); Assert.Equal("Tank04.Public", merged.Results[3].TagAddress); Assert.Equal(104, merged.Results[3].ItemHandle); - // Both denied tags recorded. - Assert.Equal(2, enforcer.RecordedDenials.Count); + // Both denied tags recorded in ONE aggregated bulk denial, not one audit call per item. + 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); + } + + /// + /// 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 — so one low-privilege + /// key cannot push thousands of events into the bounded, drop-on-full audit channel. + /// + /// A task that represents the asynchronous operation. + [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); } /// diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs index 707602e..9bbeab1 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs @@ -154,6 +154,70 @@ public sealed class ConstraintEnforcerTests Assert.Equal("Read:Secret.Tag", auditEvent.Target); } + /// + /// A bulk denial is written as ONE audit event carrying the denied/total counts, the distinct + /// constraint names and a sample capped at , + /// however many items were denied. + /// + /// A task that represents the asynchronous operation. + [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()); + } + + /// An empty summary (nothing denied) writes no audit event at all. + /// A task that represents the asynchronous operation. + [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); + } + /// Verifies that historized-only constraint requires historized attribute. /// A task that represents the asynchronous operation. [Fact] diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/AllowAllConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/AllowAllConstraintEnforcer.cs index 316b061..9e77446 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/AllowAllConstraintEnforcer.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/AllowAllConstraintEnforcer.cs @@ -11,26 +11,26 @@ namespace ZB.MOM.WW.MxGateway.Tests.TestSupport; public sealed class AllowAllConstraintEnforcer : IConstraintEnforcer { /// - public Task CheckReadTagAsync( + public ValueTask CheckReadTagAsync( ApiKeyIdentity? identity, string tagAddress, - CancellationToken cancellationToken) => Task.FromResult(null); + CancellationToken cancellationToken) => ValueTask.FromResult(null); /// - public Task CheckReadHandleAsync( + public ValueTask CheckReadHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); + CancellationToken cancellationToken) => ValueTask.FromResult(null); /// - public Task CheckWriteHandleAsync( + public ValueTask CheckWriteHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, int itemHandle, - CancellationToken cancellationToken) => Task.FromResult(null); + CancellationToken cancellationToken) => ValueTask.FromResult(null); /// public Task RecordDenialAsync( @@ -40,4 +40,12 @@ public sealed class AllowAllConstraintEnforcer : IConstraintEnforcer ConstraintFailure failure, string? correlationId, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task RecordBulkDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + ConstraintDenialSummary summary, + string? correlationId, + CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/PredicateConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/PredicateConstraintEnforcer.cs index fe129fa..408fe48 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/PredicateConstraintEnforcer.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/PredicateConstraintEnforcer.cs @@ -26,23 +26,26 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer /// Recorded denial messages — (commandKind, target, correlationId) tuples. public List<(string CommandKind, string Target, string? CorrelationId)> RecordedDenials { get; } = []; + /// Recorded aggregated bulk denials — one entry per bulk command that had any item denied. + public List<(string CommandKind, ConstraintDenialSummary Summary, string? CorrelationId)> RecordedBulkDenials { get; } = []; + /// - public Task CheckReadTagAsync( + public ValueTask CheckReadTagAsync( ApiKeyIdentity? identity, string tagAddress, CancellationToken cancellationToken) { if (DenyTag(tagAddress)) { - return Task.FromResult( + return ValueTask.FromResult( new ConstraintFailure("read-tag", $"Read denied for tag '{tagAddress}'.")); } - return Task.FromResult(null); + return ValueTask.FromResult(null); } /// - public Task CheckReadHandleAsync( + public ValueTask CheckReadHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, @@ -51,15 +54,15 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer { if (DenyReadHandle(serverHandle, itemHandle)) { - return Task.FromResult( + return ValueTask.FromResult( new ConstraintFailure("read-handle", $"Read denied for handle {itemHandle}.")); } - return Task.FromResult(null); + return ValueTask.FromResult(null); } /// - public Task CheckWriteHandleAsync( + public ValueTask CheckWriteHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, @@ -68,11 +71,11 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer { if (DenyWriteHandle(serverHandle, itemHandle)) { - return Task.FromResult( + return ValueTask.FromResult( new ConstraintFailure("write-handle", $"Write denied for handle {itemHandle}.")); } - return Task.FromResult(null); + return ValueTask.FromResult(null); } /// @@ -87,4 +90,16 @@ public sealed class PredicateConstraintEnforcer : IConstraintEnforcer RecordedDenials.Add((commandKind, target, correlationId)); return Task.CompletedTask; } + + /// + public Task RecordBulkDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + ConstraintDenialSummary summary, + string? correlationId, + CancellationToken cancellationToken) + { + RecordedBulkDenials.Add((commandKind, summary, correlationId)); + return Task.CompletedTask; + } }