test(opcua): flag-OFF HistoryUpdate over the wire - Cluster HistoryUpdate grant allowed, WriteOperate alone denied
The rig's key roles are now settable; the test derives them with the production ApiKeyInterimRoleMapper from the same grants it installs. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+43
@@ -6,6 +6,8 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
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 CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||||
|
|
||||||
@@ -104,6 +106,47 @@ public sealed class HistoryUpdateOverTheWireTests
|
|||||||
finally { await rig.DisposeAsync(); }
|
finally { await rig.DisposeAsync(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ACL flag OFF over the wire: a key's grants collapse into server-wide roles at activation (the production
|
||||||
|
/// <see cref="ApiKeyInterimRoleMapper"/>). 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.
|
||||||
|
/// </summary>
|
||||||
|
[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(); }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The HistoryWrite bit is visible on both AccessLevel and UserAccessLevel when an updater is wired.</summary>
|
/// <summary>The HistoryWrite bit is visible on both AccessLevel and UserAccessLevel when an updater is wired.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task The_history_write_bit_is_visible_over_the_wire()
|
public async Task The_history_write_bit_is_visible_over_the_wire()
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
|||||||
|
|
||||||
public OtOpcUaNodeManager Nm => _server.NodeManager!;
|
public OtOpcUaNodeManager Nm => _server.NodeManager!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The roles the <c>apikey</c> session activates with (read at each activation). Defaults to the two
|
||||||
|
/// server-wide roles; a flag-off test sets it from <c>ApiKeyInterimRoleMapper.Map</c> over its own grants, as
|
||||||
|
/// the production authenticator does.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> KeyRoles { get; set; } = [OpcUaDataPlaneRoles.AlarmAck, OpcUaDataPlaneRoles.WriteOperate];
|
||||||
|
|
||||||
public SdkAddressSpaceSink Sink { get; private set; } = null!;
|
public SdkAddressSpaceSink Sink { get; private set; } = null!;
|
||||||
|
|
||||||
public static async Task<ServerEventRig> StartAsync(string name, bool encrypted, CancellationToken ct)
|
public static async Task<ServerEventRig> StartAsync(string name, bool encrypted, CancellationToken ct)
|
||||||
@@ -83,7 +90,7 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private OpcUaApplicationHost NewHost() =>
|
private OpcUaApplicationHost NewHost() =>
|
||||||
new(_hostOptions, NullLogger<OpcUaApplicationHost>.Instance, new KeyPresentingAuthenticator());
|
new(_hostOptions, NullLogger<OpcUaApplicationHost>.Instance, new KeyPresentingAuthenticator(() => KeyRoles));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stops the server and boots a fresh one on the SAME port, application URI and PKI store (a process
|
/// 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
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Admits <c>apikey</c> as <see cref="KeyPrincipal"/> carrying the two server-wide roles (used only
|
/// Admits <c>apikey</c> as <see cref="KeyPrincipal"/> carrying <see cref="KeyRoles"/> (used only while
|
||||||
/// while enforcement is off); anonymous falls through to the <c>anonymous</c> principal.
|
/// enforcement is off); anonymous falls through to the <c>anonymous</c> principal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class KeyPresentingAuthenticator : IOpcUaUserAuthenticator
|
private sealed class KeyPresentingAuthenticator(Func<IReadOnlyList<string>> roles) : IOpcUaUserAuthenticator
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
||||||
Task.FromResult(
|
Task.FromResult(
|
||||||
string.Equals(username, "apikey", StringComparison.Ordinal)
|
string.Equals(username, "apikey", StringComparison.Ordinal)
|
||||||
&& password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal)
|
&& password.StartsWith($"otua_{KeyId}_", StringComparison.Ordinal)
|
||||||
? OpcUaUserAuthResult.AllowKey(
|
? OpcUaUserAuthResult.AllowKey(KeyPrincipal, roles(), KeyId)
|
||||||
KeyPrincipal, [OpcUaDataPlaneRoles.AlarmAck, OpcUaDataPlaneRoles.WriteOperate], KeyId)
|
|
||||||
: OpcUaUserAuthResult.Deny("Invalid API key."));
|
: OpcUaUserAuthResult.Deny("Invalid API key."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user