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; using SdkHistoryReadResult = Opc.Ua.HistoryReadResult; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// #520 stage 5 — enforcement at the node manager's own seams, through a REAL booted /// and the SDK's own dispatch. /// proves the decision; this proves the decision is actually consulted, which is the half that has /// historically gone wrong in this repo (a correct component wired to nothing, or wired behind a /// branch that never runs). /// /// /// HistoryRead is the seam exercised end-to-end because it is reachable through the node manager's /// public entry point with no client socket, and because it had no authorization check of any kind /// before this stage — so a green result here is the difference between "any session can read all /// history" and "a grant is required". /// public sealed class NodeManagerAclEnforcementTests : IDisposable { private const string Cluster = "SITE-A"; private const string Principal = "scadabridge"; private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-acl-{Guid.NewGuid():N}"); /// /// With enforcement OFF, HistoryRead behaves exactly as it always has — no identity is required and /// nothing is denied. This is the flag's whole promise, and it is the assertion that would catch a /// change accidentally enforcing by default. /// [Fact] public async Task History_read_is_ungated_while_enforcement_is_off() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.HistorianDataSource = NullHistorianDataSource.Instance; nm.Authorizer = Authorizer(enabled: false, GrantOn(NodePermissions.HistoryRead)); var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed"); var (_, errors) = ReadRaw(nm, identity: null, nodeId); errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); } finally { await host.DisposeAsync(); } } /// /// With enforcement ON, an AUTHENTICATED session whose principal holds a grant that does not carry /// the HistoryRead bit is refused — the check the seam never had. /// /// /// The identity is deliberately real and the grant deliberately non-empty. Denying an anonymous /// context would pass against a gate that only ever checks for a null identity and never consults a /// grant at all, which is most of what could go wrong here. /// [Fact] public async Task History_read_is_denied_without_the_permission_once_enforcement_is_on() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.HistorianDataSource = NullHistorianDataSource.Instance; nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read)); var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed"); var (results, errors) = ReadRaw(nm, Identity(), nodeId); errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); // Not BadHistoryOperationUnsupported: "you may not read this" and "this node has no history" // are different facts, and reporting the wrong one sends an operator debugging historisation. results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); } finally { await host.DisposeAsync(); } } /// /// #668: a modified-value read (IsReadModified = true) is gated by the SAME HistoryRead decision — no new /// operation. Denied without the bit; the granted principal reaches the backend (Null ⇒ unsupported, which is /// "allowed but no trail", a different fact from the denial). /// [Fact] public async Task Modified_history_read_is_gated_by_the_history_read_grant() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.HistorianDataSource = new OneModificationTrail(); var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed"); nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read)); var (deniedResults, deniedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId); deniedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); deniedResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.HistoryRead)); var (allowedResults, allowedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId); // A real trail entry must come back: Null's BadHistoryOperationUnsupported is also what the SDK pre-seeds, // so asserting it would pass even if the grant never reached the modified branch. allowedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); allowedResults[0].StatusCode.Code.ShouldBe(StatusCodes.Good); var data = ExtensionObject.ToEncodeable(allowedResults[0].HistoryData).ShouldBeOfType(); data.DataValues.Count.ShouldBe(1); data.ModificationInfos[0].UserName.ShouldBe("alice"); } finally { await host.DisposeAsync(); } } /// A granted principal reads normally — the gate is not simply denying everything. [Fact] public async Task History_read_succeeds_for_a_granted_principal() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.HistorianDataSource = NullHistorianDataSource.Instance; nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.HistoryRead)); var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed"); var (_, errors) = ReadRaw(nm, Identity(), nodeId); errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); } finally { await host.DisposeAsync(); } } /// /// A mixed batch is decided per node: the granted node answers and the ungranted one is denied, in /// the same request. Failing the whole batch would be easier and would also withhold data the client /// was entitled to. /// [Fact] public async Task A_mixed_batch_denies_only_the_ungranted_node() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; nm.HistorianDataSource = NullHistorianDataSource.Instance; var grant = new NodeAcl { NodeAclId = "acl-dev1", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.RawDevice, ScopeId = "dev-1", PermissionFlags = NodePermissions.HistoryRead, }; nm.Authorizer = Authorizer( enabled: true, grant, new Dictionary> { [new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] = [new NodeAclPath(["raw", "Plant", "Modbus", "dev1"])], }); var allowed = Historized(nm, "Plant/Modbus/dev1/Speed"); var denied = Historized(nm, "Plant/Modbus/dev2/Speed"); var (_, errors) = ReadRaw(nm, Identity(), allowed, denied); errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied); errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); } finally { await host.DisposeAsync(); } } // ───────────────────────────── the alarm seam's operation mapping ───────────────────────────── /// /// Every Part 9 method name the node manager routes maps onto the permission it should require. /// The AlarmAck role this replaces could not distinguish these at all — one role covered /// acknowledge, confirm and shelve — so the mapping is new authority granularity, not a rename. /// /// /// The inputs are the exact literals the OnAcknowledge / OnShelve / OnEnableDisable /// delegates pass, not invented ones; a mapping that agreed with itself but not with its callers /// would grant the wrong permission and this test would not notice. /// [Theory] [InlineData("Acknowledge", OpcUaOperation.AlarmAcknowledge)] [InlineData("Confirm", OpcUaOperation.AlarmConfirm)] [InlineData("OneShotShelve", OpcUaOperation.AlarmShelve)] [InlineData("TimedShelve", OpcUaOperation.AlarmShelve)] [InlineData("Unshelve", OpcUaOperation.AlarmShelve)] // AddComment / Enable / Disable have no OpcUaOperation of their own, so they take the most // restrictive available reading — acknowledge is the permission "may act on alarms at all" grants. [InlineData("AddComment", OpcUaOperation.AlarmAcknowledge)] [InlineData("Enable", OpcUaOperation.AlarmAcknowledge)] [InlineData("Disable", OpcUaOperation.AlarmAcknowledge)] public void Part9_method_names_map_to_their_permission(string method, OpcUaOperation expected) => OtOpcUaNodeManager.AlarmOperationFor(method).ShouldBe(expected); // ───────────────────────────── the write seam ───────────────────────────── /// /// With enforcement ON, the ACL decision REPLACES the WriteOperate role check — an identity /// carrying NO roles at all writes successfully on the strength of its grant alone. /// /// /// This is the assertion that proves the two gates are alternatives rather than a chain. Run them in /// series and this write is refused, because only a Cluster-scoped grant confers the server-wide /// role — which is exactly the "narrow grants stay inert" outcome the stage exists to end. A test /// that only checked denials would be satisfied by a build that denies everything. /// [Fact] public async Task An_acl_grant_authorizes_a_write_from_a_session_carrying_no_roles() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var gateway = new RecordingWriteGateway(); nm.NodeWriteGateway = gateway; nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.WriteOperate)); var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint"); var errors = Write(nm, Identity(), nodeId, 42f); errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); gateway.Writes.ShouldHaveSingleItem(); } finally { await host.DisposeAsync(); } } /// /// And the converse: a principal whose grant lacks WriteOperate is refused, and — the part /// that matters beyond the status code — nothing is dispatched to the device. A gate that /// denied the client but still routed the write would be worse than no gate at all. /// [Fact] public async Task A_denied_write_never_reaches_the_device() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var gateway = new RecordingWriteGateway(); nm.NodeWriteGateway = gateway; nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read)); var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint"); var errors = Write(nm, Identity(), nodeId, 42f); errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); gateway.Writes.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } } /// /// With enforcement OFF the role gate still stands: a session with no roles is refused exactly as /// before this stage. Pins that the flag's off-path is untouched, in the direction that matters. /// [Fact] public async Task A_roleless_write_is_still_denied_while_enforcement_is_off() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var gateway = new RecordingWriteGateway(); nm.NodeWriteGateway = gateway; nm.Authorizer = Authorizer(enabled: false, GrantOn(NodePermissions.WriteOperate)); var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint"); var errors = Write(nm, Identity(), nodeId, 42f); errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); gateway.Writes.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } } // ───────────────────────────── helpers ───────────────────────────── private static NodeId Writable(OtOpcUaNodeManager nm, string rawPath) { nm.EnsureVariable(rawPath, parentFolderNodeId: null, displayName: rawPath, dataType: "Float", writable: true, historianTagname: null, realm: AddressSpaceRealm.Raw); return nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId; } private static List Write( OtOpcUaNodeManager nm, IUserIdentity? identity, NodeId nodeId, object value) { var context = new OperationContext( new RequestHeader(), secureChannelContext: null, RequestType.Write, identity); var writes = new List { new() { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good }, }, }; var errors = Enumerable.Repeat(null!, writes.Count).ToList(); nm.Write(context, writes, errors); return errors; } /// Records what actually reached the device, so a denial can be shown to route nothing. private sealed class RecordingWriteGateway : IOpcUaNodeWriteGateway { public List Writes { get; } = []; public Task WriteAsync( string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) { lock (Writes) Writes.Add(nodeId); return Task.FromResult(new NodeWriteOutcome(true, null)); } } /// A backend whose modified-value trail holds exactly one entry; every other read is empty. private sealed class OneModificationTrail : IHistorianDataSource { public Task ReadModifiedAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter, CancellationToken cancellationToken) { var t = DateTime.UtcNow.AddMinutes(-5); var prior = new DataValueSnapshot(1.5, StatusCodes.Good, t, t); return Task.FromResult(new HistoryModifiedReadResult( [new HistoryModifiedValue(prior, DateTime.UtcNow, HistoryModificationType.Replace, "alice", 1, t)], false)); } public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); public Task ReadProcessedAsync( string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) => NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } private static NodeId Historized(OtOpcUaNodeManager nm, string rawPath) { nm.EnsureVariable(rawPath, parentFolderNodeId: null, displayName: rawPath, dataType: "Float", writable: false, historianTagname: rawPath, realm: AddressSpaceRealm.Raw); return nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId; } private static (List Results, List Errors) ReadRaw( OtOpcUaNodeManager nm, IUserIdentity? identity, params NodeId[] nodeIds) => ReadRaw(nm, identity, modified: false, nodeIds); private static (List Results, List Errors) ReadRaw( OtOpcUaNodeManager nm, IUserIdentity? identity, bool modified, params NodeId[] nodeIds) { var context = new OperationContext( new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity); var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList(); var results = Enumerable.Repeat(null!, nodeIds.Length).ToList(); var errors = Enumerable.Repeat(null!, nodeIds.Length).ToList(); nm.HistoryRead( context, new ReadRawModifiedDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, IsReadModified = modified, }, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); return (results, errors); } private static DataPlaneAuthorizer Authorizer( bool enabled, NodeAcl grant, IReadOnlyDictionary>? scopePaths = null) { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", [], [grant], scopePaths ?? new Dictionary>()); return new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance); } private static NodeAcl GrantOn(NodePermissions flags) => new() { NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = flags, }; private static RoleCarryingUserIdentity Identity() => new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null); private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var applicationUri = $"urn:OtOpcUa.AclTest:{Guid.NewGuid():N}"; var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.AclTest", ApplicationUri = applicationUri, OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); var host = started.Host; var server = started.Server; return (host, server); } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch (IOException) { /* best effort */ } } } }