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."));
}
}