diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs index d7075936..65573c1d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs @@ -6,6 +6,8 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; 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.Host.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; @@ -104,6 +106,47 @@ public sealed class HistoryUpdateOverTheWireTests finally { await rig.DisposeAsync(); } } + /// + /// ACL flag OFF over the wire: a key's grants collapse into server-wide roles at activation (the production + /// ). A key whose principal holds only a Cluster-scoped WriteOperate grant + /// is denied — live-write authority never implies rewriting history — and the same key with a Cluster-scoped + /// HistoryUpdate grant is admitted. The allow half keeps the deny from passing against a build that refuses + /// every HistoryUpdate with the flag off. + /// + [Fact] + public async Task Acl_off_a_cluster_history_update_grant_allows_and_write_operate_alone_is_denied() + { + var ct = TestContext.Current.CancellationToken; + var store = new SharedStore(); + var rig = await StartAsync("HuWireAclOff", encrypted: false, ct); + try + { + rig.Nm.HistorianDataUpdater = store; + rig.Nm.HistorianDataSource = store; + var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname); + + var writeOnly = ClusterGrant(KeyPrincipal, "acl-writer", NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate); + rig.Enforce(false, [Key()], writeOnly); + rig.KeyRoles = ApiKeyInterimRoleMapper.Map([writeOnly], KeyPrincipal); + rig.KeyRoles.ShouldNotContain(OpcUaDataPlaneRoles.HistoryUpdate); + using (var writer = await rig.ConnectAsync(anonymous: false, ct)) + { + var denial = await UpdateAsync(writer, RawNode(writer, id), PerformUpdateType.Insert, ct, (T1, 1.0)); + denial.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + } + store.UpdateCalls.ShouldBe(0, "a denied session never reaches the updater"); + + var editor = ClusterGrant(KeyPrincipal, "acl-editor", NodePermissions.Browse | NodePermissions.Read | NodePermissions.HistoryUpdate); + rig.Enforce(false, [Key()], editor); + rig.KeyRoles = ApiKeyInterimRoleMapper.Map([editor], KeyPrincipal); + using var allowed = await rig.ConnectAsync(anonymous: false, ct); + var ok = await UpdateAsync(allowed, RawNode(allowed, id), PerformUpdateType.Insert, ct, (T1, 1.0)); + ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); + store.UpdateCalls.ShouldBe(1); + } + finally { await rig.DisposeAsync(); } + } + /// The HistoryWrite bit is visible on both AccessLevel and UserAccessLevel when an updater is wired. [Fact] public async Task The_history_write_bit_is_visible_over_the_wire() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs index 18641ee2..63bad0a9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs @@ -46,6 +46,13 @@ internal sealed class ServerEventRig : IAsyncDisposable public OtOpcUaNodeManager Nm => _server.NodeManager!; + /// + /// The roles the apikey session activates with (read at each activation). Defaults to the two + /// server-wide roles; a flag-off test sets it from ApiKeyInterimRoleMapper.Map over its own grants, as + /// the production authenticator does. + /// + public IReadOnlyList KeyRoles { get; set; } = [OpcUaDataPlaneRoles.AlarmAck, OpcUaDataPlaneRoles.WriteOperate]; + public SdkAddressSpaceSink Sink { get; private set; } = null!; public static async Task StartAsync(string name, bool encrypted, CancellationToken ct) @@ -83,7 +90,7 @@ internal sealed class ServerEventRig : IAsyncDisposable } private OpcUaApplicationHost NewHost() => - new(_hostOptions, NullLogger.Instance, new KeyPresentingAuthenticator()); + new(_hostOptions, NullLogger.Instance, new KeyPresentingAuthenticator(() => KeyRoles)); /// /// Stops the server and boots a fresh one on the SAME port, application URI and PKI store (a process @@ -300,18 +307,17 @@ internal sealed class ServerEventRig : IAsyncDisposable } /// - /// Admits apikey as carrying the two server-wide roles (used only - /// while enforcement is off); anonymous falls through to the anonymous principal. + /// Admits apikey as carrying (used only while + /// enforcement is off); anonymous falls through to the anonymous principal. /// - private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator + private sealed class KeyPresentingAuthenticator(Func> roles) : IOpcUaUserAuthenticator { /// public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) => Task.FromResult( string.Equals(username, "apikey", StringComparison.Ordinal) && password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal) - ? OpcUaUserAuthResult.AllowKey( - KeyPrincipal, [OpcUaDataPlaneRoles.AlarmAck, OpcUaDataPlaneRoles.WriteOperate], KeyId) + ? OpcUaUserAuthResult.AllowKey(KeyPrincipal, roles(), KeyId) : OpcUaUserAuthResult.Deny("Invalid API key.")); } }