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