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(); + } +}