diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index b70b6fc1..92117849 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -4949,7 +4949,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
///
/// Called outside Lock, like the Server.ReportEvent call it replaces; the decision
- /// takes no node-manager lock.
+ /// briefly resolves the audit's source variable under the manager lock; permission evaluation
+ /// and queueing happen outside that lock.
///
///
private void ReportToServerObject(BaseEventState e)
@@ -4966,11 +4967,122 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
foreach (var item in _allEventsItems.Keys)
{
if (ServerObjectEventFor(item, e) is not { } visible) continue;
- if (ServiceResult.IsBad(ValidateEventRolePermissions(item, visible))) continue;
+ if (visible is AuditWriteUpdateEventState audit)
+ {
+ if (!ServiceResult.IsGood(ValidateApplicationAuditEventReceivePermissions(item, audit))) continue;
+ }
+ else if (ServiceResult.IsBad(ValidateEventRolePermissions(item, visible))) continue;
item.QueueEvent(visible);
}
}
+ // Exact SDK 2.0.1 checks ReceiveEvents on the event type and source. The i=2100 defaults
+ // exclude ordinary application readers. Compose that one permission only while our manual Read-filtered
+ // delivery is being validated. SDK-generated audits use ordinary contexts and keep their defaults.
+ internal void InstallApplicationAuditPermissions(BaseObjectTypeState auditType)
+ {
+ if (auditType.NodeId != ObjectTypeIds.AuditWriteUpdateEventType)
+ throw new ArgumentException("Only AuditWriteUpdateEventType may receive this callback.", nameof(auditType));
+ var previous = auditType.OnReadRolePermissions;
+ auditType.OnReadRolePermissions = (ISystemContext context, NodeState node, ref ArrayOf permissions) =>
+ {
+ var result = ReadAuditPermissions(previous, context, node, ref permissions);
+ var audit = (context as ServerSystemContext)?.OperationContext as ApplicationAuditOperationContext;
+ if (!ServiceResult.IsGood(result) || audit is null || node.NodeId != audit.EventType
+ || !CanComposeApplicationAuditPermissions(audit)) return result;
+
+ // Append fresh entries to a fresh value; neither the node's arrays nor existing entries change.
+ permissions = [.. permissions, .. (audit.UserIdentity.GrantedRoleIds.ToArray() ?? []).Select(role => new RolePermissionType
+ {
+ RoleId = role,
+ Permissions = (uint)PermissionType.ReceiveEvents,
+ })];
+ return result;
+ };
+ // Observe this attribute's veto as well: SDK metadata discards either attribute's read error.
+ // Its callback result and value are passed through unchanged; no permissions are composed here.
+ var previousUser = auditType.OnReadUserRolePermissions;
+ auditType.OnReadUserRolePermissions = (ISystemContext context, NodeState node, ref ArrayOf permissions) =>
+ ReadAuditPermissions(previousUser, context, node, ref permissions);
+ }
+
+ private static ServiceResult ReadAuditPermissions(NodeAttributeEventHandler>? previous,
+ ISystemContext context, NodeState node, ref ArrayOf permissions)
+ {
+ var audit = (context as ServerSystemContext)?.OperationContext as ApplicationAuditOperationContext;
+ ServiceResult result;
+ try { result = previous?.Invoke(context, node, ref permissions) ?? ServiceResult.Good; }
+ catch (Exception error)
+ {
+ // ReadAttribute maps this exception to BadUnexpectedError, but metadata discards that status.
+ audit?.RecordPermissionReadFailure(ServiceResult.Create(error, StatusCodes.BadUnexpectedError,
+ "Failed to read non value attribute from node."));
+ throw;
+ }
+ if (!ServiceResult.IsGood(result)) audit?.RecordPermissionReadFailure(result);
+ return result;
+ }
+
+ internal ServiceResult ValidateApplicationAuditEventReceivePermissions(
+ IEventMonitoredItem item, AuditWriteUpdateEventState audit)
+ {
+ var type = audit.EventType?.Value ?? NodeId.Null;
+ var source = audit.SourceNode?.Value ?? NodeId.Null;
+ if (type != ObjectTypeIds.AuditWriteUpdateEventType || !OwnsNamespace(source)
+ || !source.TryGetValue(out string _)) return StatusCodes.BadUserAccessDenied;
+ BaseDataVariableState sourceVariable;
+ lock (Lock)
+ {
+ if (!_variables.TryGetValue(MapKey(source), out sourceVariable!)) return StatusCodes.BadUserAccessDenied;
+ }
+ using var context = new ApplicationAuditOperationContext(item, type, source, sourceVariable);
+ try
+ {
+ if (!CanComposeApplicationAuditPermissions(context)) return StatusCodes.BadUserAccessDenied;
+ var result = ValidateEventReceivePermissions(context, type, source);
+ // Metadata attribute reads discard callback errors; preserve a prior callback's veto.
+ if (context.PermissionReadFailure is { } veto) return veto;
+ // Source metadata callbacks may have changed authority after the type was checked.
+ return CanComposeApplicationAuditPermissions(context) ? result : StatusCodes.BadUserAccessDenied;
+ }
+ finally
+ {
+ // The capability is gone before QueueEvent (including nested callbacks) can run.
+ context.Revoke();
+ }
+ }
+
+ private bool CanComposeApplicationAuditPermissions(ApplicationAuditOperationContext context) =>
+ context.IsActive && context.EventType == ObjectTypeIds.AuditWriteUpdateEventType
+ && IsCurrentApplicationAuditSource(context) && AclEnforced
+ && ReferenceEquals(context.UserIdentity, context.Item.EffectiveIdentity)
+ && (context.Session is null || ReferenceEquals(context.UserIdentity, context.Session.EffectiveIdentity))
+ && context.UserIdentity is RoleCarryingUserIdentity identity
+ && IsAuditDeliverableTo(context.Item) && Permits(identity, OpcUaOperation.Read, context.Source);
+
+ private bool IsCurrentApplicationAuditSource(ApplicationAuditOperationContext context)
+ {
+ lock (Lock) return _variables.TryGetValue(MapKey(context.Source), out var current)
+ && ReferenceEquals(current, context.SourceVariable);
+ }
+
+ private sealed class ApplicationAuditOperationContext(
+ IEventMonitoredItem item, NodeId eventType, NodeId source, BaseDataVariableState sourceVariable) : OperationContext(item)
+ {
+ private int _active = 1;
+ internal IEventMonitoredItem Item { get; } = item;
+ internal NodeId EventType { get; } = eventType;
+ internal NodeId Source { get; } = source;
+ internal BaseDataVariableState SourceVariable { get; } = sourceVariable;
+ internal bool IsActive => Volatile.Read(ref _active) != 0;
+ internal ServiceResult? PermissionReadFailure { get; private set; }
+ internal void RecordPermissionReadFailure(ServiceResult result)
+ {
+ if (IsActive) PermissionReadFailure ??= result;
+ }
+ internal void Revoke() => Interlocked.Exchange(ref _active, 0);
+ }
+
///
/// What of a server-wide event 's session may receive with enforcement on: the
/// event itself, a copy narrowed to what it may see, or nothing.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
index dff0415c..3af2c41a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
@@ -303,6 +303,17 @@ public sealed class OtOpcUaSdkServer : StandardServer
}
}
+ ///
+ protected override async ValueTask OnNodeManagerStartedAsync(
+ IServerInternal server, CancellationToken cancellationToken = default)
+ {
+ await base.OnNodeManagerStartedAsync(server, cancellationToken).ConfigureAwait(false);
+ var auditType = server.DiagnosticsNodeManager?
+ .FindPredefinedNode(ObjectTypeIds.AuditWriteUpdateEventType)
+ ?? throw new InvalidOperationException("AuditWriteUpdateEventType is unavailable during server startup.");
+ _otOpcUaNodeManager!.InstallApplicationAuditPermissions(auditType);
+ }
+
///
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2ApplicationAuditPermissionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2ApplicationAuditPermissionsTests.cs
new file mode 100644
index 00000000..4c4ab170
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2ApplicationAuditPermissionsTests.cs
@@ -0,0 +1,433 @@
+using System.Reflection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+public sealed class Sdk2ApplicationAuditPermissionsTests : IAsyncLifetime
+{
+ private const string Principal = "audit-reader";
+ private const string KeyId = "reader-key";
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+ private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-audit-{Guid.NewGuid():N}");
+ private OpcUaApplicationHost _host = null!;
+ private OtOpcUaSdkServer _server = null!;
+ private OtOpcUaNodeManager _manager = null!;
+ private BaseObjectTypeState _auditType = null!;
+ private BaseDataVariableState _node = null!;
+ private readonly DataPlaneAuthorityState _authority = new();
+ private readonly ManualTime _clock = new();
+ private AuditWriteUpdateEventState Audit => _manager.BuildWriteFailureAuditEvent(
+ _node, new NodeWriteOutcome(false, "device refused"), new short[] { 1, 2 }, new short[] { 7, 8 }, Principal);
+
+ public async ValueTask InitializeAsync()
+ {
+ var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "SDK2.AuditContracts", ApplicationUri = $"urn:SDK2.AuditContracts:{Guid.NewGuid():N}",
+ OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pki,
+ }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct);
+ _host = started.Host;
+ _server = started.Server;
+ _manager = _server.NodeManager!;
+ _auditType = ((DiagnosticsNodeManager)_server.CurrentInstance.DiagnosticsNodeManager)
+ .FindPredefinedNode(ObjectTypeIds.AuditWriteUpdateEventType)!;
+ _manager.EnsureVariable("v", null, "Value", "Int16", true, AddressSpaceRealm.Raw, isArray: true, arrayLength: 2);
+ _node = _manager.TryGetVariable("v", AddressSpaceRealm.Raw)!;
+ _manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, _clock.GetUtcNow().UtcDateTime, AddressSpaceRealm.Raw);
+ Apply();
+ _manager.Authorizer = new DataPlaneAuthorizer(_authority, new TriePermissionEvaluator(_authority.Tries), true,
+ NullLogger.Instance, _clock);
+ SetAuditing(true);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _host.DisposeAsync();
+ Directory.Delete(_pki, true);
+ }
+
+ [Fact]
+ public void Actual_audit_type_grants_only_the_private_application_validation_and_never_mutates_permissions()
+ {
+ var item = Item();
+ var roles = _auditType.RolePermissions;
+ var before = roles.ToArray()!.Select(x => (x.RoleId, x.Permissions)).ToArray();
+ Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).ShouldBe(ServiceResult.Good);
+ Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ _auditType.RolePermissions.ShouldBe(roles);
+ _auditType.RolePermissions.ToArray()!.Select(x => (x.RoleId, x.Permissions)).ShouldBe(before);
+ using var context = new OperationContext(item);
+ _manager.ValidateRolePermissions(context, ObjectTypeIds.AuditWriteUpdateEventType, PermissionType.Write).StatusCode
+ .ShouldBe(StatusCodes.BadUserAccessDenied);
+ _manager.ValidateEventReceivePermissions(context, ObjectTypeIds.AuditUpdateEventType, _node.NodeId).StatusCode
+ .ShouldBe(StatusCodes.BadUserAccessDenied);
+ }
+
+ [Theory]
+ [InlineData("expired")]
+ [InlineData("revoked")]
+ [InlineData("no-read")]
+ [InlineData("auditing-off")]
+ [InlineData("unencrypted")]
+ [InlineData("restricted")]
+ [InlineData("identity-changed")]
+ [InlineData("wrong-type")]
+ [InlineData("unknown-source")]
+ [InlineData("same-namespace-unknown-source")]
+ public void Current_authority_source_type_and_transport_are_rechecked(string denial)
+ {
+ var item = Item();
+ var audit = Audit;
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, audit).ShouldBe(ServiceResult.Good);
+ switch (denial)
+ {
+ case "expired": _clock.Advance(TimeSpan.FromHours(2)); break;
+ case "revoked": Apply(keys: false); break;
+ case "no-read": Apply(NodePermissions.Browse); break;
+ case "auditing-off": SetAuditing(false); break;
+ case "unencrypted": item.State.Endpoint.SecurityMode = MessageSecurityMode.Sign; break;
+ case "restricted":
+ item.State.Identity = RoleBasedIdentity.CreateRestricted(item.State.Identity, [Role.AuthenticatedUser],
+ _server.CurrentInstance.NamespaceUris);
+ break;
+ case "identity-changed": item.Identity = Identity(); break;
+ case "wrong-type": audit.EventType!.Value = ObjectTypeIds.AuditUpdateEventType; break;
+ case "unknown-source": audit.SourceNode!.Value = new NodeId("unowned", (ushort)123); break;
+ case "same-namespace-unknown-source": audit.SourceNode!.Value = new NodeId("missing", _node.NodeId.NamespaceIndex); break;
+ }
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ }
+
+ [Fact]
+ public void User_role_permission_restrictions_are_preserved()
+ {
+ var item = Item();
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).ShouldBe(ServiceResult.Good);
+ _auditType.UserRolePermissions = [new RolePermissionType
+ {
+ RoleId = ObjectIds.WellKnownRole_AuthenticatedUser, Permissions = (uint)PermissionType.Read,
+ }];
+ var original = _auditType.UserRolePermissions;
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ _auditType.UserRolePermissions.ShouldBe(original);
+ }
+
+ [Fact]
+ public void Authority_revoked_during_source_metadata_read_cannot_use_the_earlier_type_grant()
+ {
+ var item = Item();
+ _node.OnReadRolePermissions = (ISystemContext _, NodeState node, ref ArrayOf permissions) =>
+ {
+ Apply(keys: false);
+ return ServiceResult.Good;
+ };
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ }
+
+ [Fact]
+ public void Existing_source_receive_restrictions_still_deny_the_scoped_validation()
+ {
+ var item = Item();
+ _node.RolePermissions = [new RolePermissionType
+ {
+ RoleId = ObjectIds.WellKnownRole_AuthenticatedUser, Permissions = (uint)PermissionType.Read,
+ }];
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ }
+
+ [Theory]
+ [InlineData(false, false, false)]
+ [InlineData(false, true, false)]
+ [InlineData(true, false, false)]
+ [InlineData(true, true, false)]
+ [InlineData(false, false, true)]
+ [InlineData(true, false, true)]
+ public void Prior_callback_veto_or_fault_cleans_up_the_exact_context(bool userAttribute, bool uncertain, bool throws)
+ {
+ var item = Item();
+ OperationContext? captured = null;
+ var fault = new InvalidOperationException("controlled permission callback fault");
+ NodeAttributeEventHandler> previous =
+ (ISystemContext context, NodeState _, ref ArrayOf permissions) =>
+ {
+ captured = ((ServerSystemContext)context).OperationContext;
+ if (throws) throw fault;
+ return uncertain ? StatusCodes.Uncertain : StatusCodes.BadNotSupported;
+ };
+ _auditType.OnReadRolePermissions = userAttribute ? null : previous;
+ _auditType.OnReadUserRolePermissions = userAttribute ? previous : null;
+ _manager.InstallApplicationAuditPermissions(_auditType);
+ var result = _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit);
+ result.StatusCode.ShouldBe(throws ? StatusCodes.BadUnexpectedError
+ : uncertain ? StatusCodes.Uncertain : StatusCodes.BadNotSupported);
+ ServiceResult.IsGood(result).ShouldBeFalse();
+ captured.ShouldNotBeNull();
+ _auditType.OnReadRolePermissions = null;
+ _auditType.OnReadUserRolePermissions = null;
+ _manager.InstallApplicationAuditPermissions(_auditType);
+ _manager.ValidateEventReceivePermissions(captured!, ObjectTypeIds.AuditWriteUpdateEventType, _node.NodeId).StatusCode
+ .ShouldBe(StatusCodes.BadUserAccessDenied);
+ Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public void Removed_or_recreated_source_cannot_use_the_original_variable_capability(bool sourceMetadata, bool recreate)
+ {
+ var item = Item();
+ var audit = Audit;
+ NodeAttributeEventHandler> previous =
+ (ISystemContext _, NodeState node, ref ArrayOf permissions) =>
+ {
+ _manager.RemoveVariableNode("v", AddressSpaceRealm.Raw).ShouldBeTrue();
+ if (recreate)
+ _manager.EnsureVariable("v", null, "New Value", "Int16", true, AddressSpaceRealm.Raw, isArray: true, arrayLength: 2);
+ return ServiceResult.Good;
+ };
+ if (sourceMetadata) _node.OnReadRolePermissions = previous;
+ else
+ {
+ _auditType.OnReadRolePermissions = previous;
+ _manager.InstallApplicationAuditPermissions(_auditType);
+ }
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ if (recreate) _manager.TryGetVariable("v", AddressSpaceRealm.Raw).ShouldNotBeSameAs(_node);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void An_uncertain_permission_callback_never_queues_a_manual_audit(bool userAttribute)
+ {
+ var item = Item();
+ var queued = 0;
+ item.OnQueue = _ => queued++;
+ NodeAttributeEventHandler> previous =
+ (ISystemContext _, NodeState node, ref ArrayOf permissions) => StatusCodes.Uncertain;
+ _auditType.OnReadRolePermissions = userAttribute ? null : previous;
+ _auditType.OnReadUserRolePermissions = userAttribute ? previous : null;
+ _manager.InstallApplicationAuditPermissions(_auditType);
+ using var context = new OperationContext(item);
+ _manager.SubscribeToAllEvents(context, 1, item, false).ShouldBe(ServiceResult.Good);
+ _manager.EnsureVariable("scalar", null, "Scalar", "Int32", true, AddressSpaceRealm.Raw);
+ _manager.WriteValue("scalar", 2, OpcUaStatus.Good, _clock.GetUtcNow().UtcDateTime, AddressSpaceRealm.Raw);
+ // This synchronous internal correction exercises the real manual dispatcher without a timing assertion.
+ _manager.RevertOptimisticWriteIfNeeded("scalar", new NodeWriteOutcome(false, "refused"), 2, 1,
+ StatusCodes.Good, Principal, AddressSpaceRealm.Raw);
+ queued.ShouldBe(0);
+ SdkValueBoundary.ToClr(_manager.TryGetVariable("scalar", AddressSpaceRealm.Raw)!.Value).ShouldBe(1);
+ }
+
+ [Fact]
+ public void Nested_validation_uses_independent_contexts_and_calls_the_prior_callback_first()
+ {
+ var item = Item();
+ var depth = 0;
+ var nested = 0;
+ var captured = new List();
+ _auditType.OnReadRolePermissions = (ISystemContext context, NodeState _, ref ArrayOf permissions) =>
+ {
+ var operation = ((ServerSystemContext)context).OperationContext!;
+ if (operation.GetType() == typeof(OperationContext)) return ServiceResult.Good;
+ captured.Add(operation);
+ // The composition has not run yet: the prior callback sees only the persisted value.
+ permissions.ShouldBe(_auditType.RolePermissions);
+ if (depth++ == 0)
+ {
+ Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).ShouldBe(ServiceResult.Good);
+ nested++;
+ }
+ depth--;
+ return ServiceResult.Good;
+ };
+ _manager.InstallApplicationAuditPermissions(_auditType);
+ _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).ShouldBe(ServiceResult.Good);
+ nested.ShouldBe(1);
+ captured.Count.ShouldBe(2);
+ captured[0].ShouldNotBeSameAs(captured[1]);
+ _auditType.OnReadRolePermissions = null;
+ _manager.InstallApplicationAuditPermissions(_auditType);
+ foreach (var operation in captured)
+ _manager.ValidateEventReceivePermissions(operation, ObjectTypeIds.AuditWriteUpdateEventType, _node.NodeId).StatusCode
+ .ShouldBe(StatusCodes.BadUserAccessDenied);
+ }
+
+ [Fact]
+ public async Task Parallel_ordinary_context_never_inherits_a_scoped_permission()
+ {
+ var item = Item();
+ using var entered = new ManualResetEventSlim();
+ using var release = new ManualResetEventSlim();
+ _auditType.OnReadRolePermissions = (ISystemContext context, NodeState _, ref ArrayOf permissions) =>
+ {
+ if (((ServerSystemContext)context).OperationContext!.GetType() != typeof(OperationContext))
+ {
+ entered.Set();
+ release.Wait(Ct);
+ }
+ return ServiceResult.Good;
+ };
+ _manager.InstallApplicationAuditPermissions(_auditType);
+ var scoped = Task.Run(() => _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit), Ct);
+ entered.Wait(TimeSpan.FromSeconds(5), Ct).ShouldBeTrue();
+ try { Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); }
+ finally { release.Set(); }
+ (await scoped).ShouldBe(ServiceResult.Good);
+ Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ }
+
+ [Fact]
+ public async Task Actual_failed_array_write_queues_one_application_audit_after_revoking_the_capability()
+ {
+ var item = Item();
+ OperationContext? captured = null;
+ var queued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var count = 0;
+ _auditType.OnReadRolePermissions = (ISystemContext context, NodeState _, ref ArrayOf permissions) =>
+ {
+ captured ??= ((ServerSystemContext)context).OperationContext;
+ return ServiceResult.Good;
+ };
+ _manager.InstallApplicationAuditPermissions(_auditType);
+ item.OnQueue = instance =>
+ {
+ try
+ {
+ instance.ShouldBeOfType().EventType!.Value.ShouldBe(ObjectTypeIds.AuditWriteUpdateEventType);
+ _manager.ValidateEventReceivePermissions(captured!, ObjectTypeIds.AuditWriteUpdateEventType, _node.NodeId).StatusCode
+ .ShouldBe(StatusCodes.BadUserAccessDenied);
+ Interlocked.Increment(ref count);
+ queued.TrySetResult();
+ }
+ catch (Exception error) { queued.TrySetException(error); }
+ };
+ using var context = new OperationContext(item);
+ _manager.SubscribeToAllEvents(context, 1, item, false).ShouldBe(ServiceResult.Good);
+ _manager.NodeWriteGateway = new RefusingGateway();
+ var data = new DataValue(SdkValueBoundary.FromClr(new short[] { 1, 2 }));
+ _node.WriteAttribute(new ServerSystemContext(_server.CurrentInstance, context), Attributes.Value, default, data)
+ .ShouldBe(ServiceResult.Good);
+ await queued.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct);
+ count.ShouldBe(1);
+ SdkValueBoundary.ToClr(_node.Value).ShouldBeOfType().ShouldBe([7, 8]);
+ Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
+ }
+
+ private ServiceResult Ordinary(EventItem item)
+ {
+ using var context = new OperationContext(item);
+ return _manager.ValidateEventReceivePermissions(context, ObjectTypeIds.AuditWriteUpdateEventType, _node.NodeId);
+ }
+
+ private void SetAuditing(bool enabled)
+ {
+ var value = Variant.From(enabled);
+ _server.CurrentInstance.ServerObject.Auditing!.OnSimpleWriteValue!(
+ _manager.SystemContext, _server.CurrentInstance.ServerObject.Auditing, ref value).ShouldBe(ServiceResult.Good);
+ _server.CurrentInstance.Auditing.ShouldBe(enabled);
+ }
+
+ private RoleCarryingUserIdentity Identity() => (RoleCarryingUserIdentity)new RoleCarryingUserIdentity(
+ new UserNameIdentityToken { UserName = "apikey" }, [], Principal, KeyId)
+ .WithAdditionalRoles([Role.AuthenticatedUser], _server.CurrentInstance.NamespaceUris);
+
+ private EventItem Item()
+ {
+ var session = DispatchProxy.Create();
+ var state = (SessionState)(object)session;
+ state.Identity = Identity();
+ return new EventItem(session, state);
+ }
+
+ private void Apply(NodePermissions permissions = NodePermissions.Read | NodePermissions.WriteOperate, bool keys = true) =>
+ _authority.Apply("audit-cluster", Guid.NewGuid().ToString(), keys ? [new ApiKey
+ {
+ ApiKeyId = KeyId, Name = KeyId, SecretHash = "hash", Principal = Principal, Enabled = true,
+ CreatedUtc = _clock.GetUtcNow().UtcDateTime, ExpiresUtc = _clock.GetUtcNow().AddHours(1).UtcDateTime,
+ }] : [], [new NodeAcl
+ {
+ NodeAclId = "audit-grant", ClusterId = "audit-cluster", Principal = Principal,
+ ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = permissions,
+ }], new Dictionary>());
+
+ private sealed class ManualTime : TimeProvider
+ {
+ private DateTimeOffset _utc = new(2026, 11, 10, 12, 0, 0, TimeSpan.Zero);
+ public override DateTimeOffset GetUtcNow() => _utc;
+ internal void Advance(TimeSpan delta) => _utc += delta;
+ }
+
+ private sealed class RefusingGateway : IOpcUaNodeWriteGateway
+ {
+ public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
+ Task.FromResult(new NodeWriteOutcome(false, "device refused"));
+ }
+
+ public class SessionState : DispatchProxy
+ {
+ public IUserIdentity Identity { get; set; } = null!;
+ public EndpointDescription Endpoint { get; } = new() { SecurityMode = MessageSecurityMode.SignAndEncrypt };
+ protected override object? Invoke(MethodInfo? method, object?[]? args) => method?.Name switch
+ {
+ "get_EffectiveIdentity" or "get_Identity" => Identity,
+ "get_EndpointDescription" => Endpoint,
+ "get_PreferredLocales" => Array.Empty(),
+ "get_Id" => new NodeId("audit-test-session", 1),
+ _ => throw new NotSupportedException(method?.Name),
+ };
+ }
+
+ private sealed class EventItem(ISession session, SessionState state) : IEventMonitoredItem
+ {
+ internal SessionState State => state;
+ internal IUserIdentity? Identity { get; set; }
+ internal Action? OnQueue { get; set; }
+ public IUserIdentity EffectiveIdentity => Identity ?? state.Identity;
+ public ISession Session => session;
+ public bool MonitoringAllEvents => true;
+ public NodeId NodeId => ObjectIds.Server;
+ public object ManagerHandle => null!;
+ public void QueueEvent(IFilterTarget instance) => OnQueue?.Invoke(instance);
+ public void QueueEvent(IFilterTarget instance, bool bypassFilter) => QueueEvent(instance);
+ public void Dispose() { }
+ public IAsyncNodeManager NodeManager => throw new NotSupportedException();
+ public uint Id => 1;
+ public uint SubscriptionId => 1;
+ public bool IsDurable => false;
+ public uint ClientHandle => 1;
+ public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+ public int MonitoredItemType => MonitoredItemTypeMask.Events;
+ public bool IsReadyToPublish => false;
+ public bool IsReadyToTrigger { get => false; set { } }
+ public bool IsResendData => false;
+ public MonitoringMode MonitoringMode => MonitoringMode.Reporting;
+ public double SamplingInterval => 0;
+ public EventFilter EventFilter => null!;
+ public void SetupResendDataTrigger() { }
+ public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException();
+ public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException();
+ public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException();
+ public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException();
+ public bool Publish(OperationContext context, Queue notifications, uint maxNotificationsPerPublish) => throw new NotSupportedException();
+ public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn,
+ uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range,
+ double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException();
+ }
+}