f02067d295
The tests probe a free port (bind 0, release) and start the SDK server later; under a highly parallel run another test can take the port in between and the SDK fails with 'Failed to establish tcp listener sockets for Ipv4 and IPv6.'. OpcUaHostStart.StartAsync builds a fresh host + server on a fresh port per attempt (up to 5) when the failure is a bind failure; all 45 start sites in OpcUaServer.Tests, OpcUaServer.IntegrationTests and Runtime.Tests use it. Per-attempt GUID ApplicationUris are hoisted out of the host factory because a changed URI against a reused PKI store fails with 'Cannot access private key'. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
291 lines
12 KiB
C#
291 lines
12 KiB
C#
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.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #563 (arch-review 03/S19 residual R2) — with <c>Security:Acl:Enabled</c> <b>off</b>, the two
|
|
/// server-wide role gates (<c>WriteOperate</c> on Value writes, <c>AlarmAck</c> on the Part 9 methods)
|
|
/// re-check that the session's API key is still usable on every call.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A session's roles are resolved once, at activation, from the key's grants. Before #563 nothing
|
|
/// with the flag off ever looked at the key again, so a key whose <c>ExpiresUtc</c> passed — or that
|
|
/// a deployment disabled — kept <c>WriteOperate</c> / <c>AlarmAck</c> until the client chose to
|
|
/// disconnect, which on a machine-to-machine data plane can be never.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every test here is an <b>allow-then-deny</b> on the same session: the first call proves the
|
|
/// session genuinely holds the role, so the later denial can only have come from the key check. A
|
|
/// deny-only test would pass against a gate that denied everything.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class NodeManagerKeyUsabilityGateTests : IDisposable
|
|
{
|
|
private const string Cluster = "SITE-A";
|
|
private const string Principal = "scadabridge";
|
|
private const string KeyId = "k-563";
|
|
|
|
private static readonly DateTime Start = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(
|
|
Path.GetTempPath(), $"otopcua-keygate-{Guid.NewGuid():N}");
|
|
|
|
/// <summary>
|
|
/// THE #563 test for the write gate: ACL off, a key-carrying session holding <c>WriteOperate</c>
|
|
/// writes; only the clock moves past <c>ExpiresUtc</c>; the next write is refused and never reaches
|
|
/// the device.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_expired_key_loses_its_write_role_on_the_clock_with_acl_off()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var gateway = new RecordingWriteGateway();
|
|
nm.NodeWriteGateway = gateway;
|
|
var clock = new FakeClock(Start);
|
|
var (state, authorizer) = Wire(nm, clock, Key(expiresUtc: Start.AddMinutes(10)));
|
|
authorizer.Enabled.ShouldBeFalse();
|
|
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate);
|
|
|
|
Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(20));
|
|
|
|
Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
gateway.Writes.Count.ShouldBe(1);
|
|
state.Current.GenerationId.ShouldBe(1); // no deployment moved anything — only the clock did
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// The deployment axis of the same gate: a deployment that disables the key strips the write role
|
|
/// from the session that is already open.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_key_disabled_by_a_deployment_loses_its_write_role_with_acl_off()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var gateway = new RecordingWriteGateway();
|
|
nm.NodeWriteGateway = gateway;
|
|
var (state, _) = Wire(nm, new FakeClock(Start), Key(expiresUtc: null));
|
|
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
|
var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate);
|
|
|
|
Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
|
|
var disabled = Key(expiresUtc: null);
|
|
disabled.Enabled = false;
|
|
state.Apply(Cluster, "rev-2", [disabled], [], NoScopes);
|
|
|
|
Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
gateway.Writes.Count.ShouldBe(1);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Anonymous carries no key, so nothing can expire: an anonymous session granted
|
|
/// <c>WriteOperate</c> keeps writing however far the clock moves, against a snapshot holding no keys
|
|
/// at all. Pins that the new check keys on the presence of a key id, not on "is this session in the
|
|
/// key table".
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_anonymous_session_is_unaffected_by_the_key_check()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var gateway = new RecordingWriteGateway();
|
|
nm.NodeWriteGateway = gateway;
|
|
var clock = new FakeClock(Start);
|
|
Wire(nm, clock);
|
|
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
|
var anonymous = new RoleCarryingUserIdentity(
|
|
new AnonymousIdentityToken(), [OpcUaDataPlaneRoles.WriteOperate],
|
|
OpcUaPrincipals.Anonymous, apiKeyId: null);
|
|
|
|
Write(nm, anonymous, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
clock.Advance(TimeSpan.FromDays(400));
|
|
Write(nm, anonymous, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
|
|
gateway.Writes.Count.ShouldBe(2);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// The alarm half: ACL off, a key-carrying session holding <c>AlarmAck</c> acknowledges; the key
|
|
/// expires by the clock; the next acknowledge is refused and routes nothing.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_expired_key_loses_its_alarm_ack_role_on_the_clock_with_acl_off()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var routed = new List<AlarmCommand>();
|
|
nm.AlarmCommandRouter = routed.Add;
|
|
var clock = new FakeClock(Start);
|
|
Wire(nm, clock, Key(expiresUtc: Start.AddMinutes(10)));
|
|
|
|
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition(
|
|
"alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
|
var condition = nm.TryGetAlarmCondition("alm-1")!;
|
|
var ctx = new ServerSystemContext(server.CurrentInstance)
|
|
{
|
|
UserIdentity = KeyIdentity(OpcUaDataPlaneRoles.AlarmAck),
|
|
};
|
|
|
|
condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("1"))
|
|
.ShouldBe(ServiceResult.Good);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(20));
|
|
|
|
condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("2"))
|
|
.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
routed.Count.ShouldBe(1);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
|
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
|
|
|
private static ApiKey Key(DateTime? expiresUtc) => new()
|
|
{
|
|
ApiKeyId = KeyId,
|
|
Name = KeyId,
|
|
SecretHash = "hash",
|
|
Principal = Principal,
|
|
Enabled = true,
|
|
CreatedUtc = Start.AddDays(-1),
|
|
ExpiresUtc = expiresUtc,
|
|
};
|
|
|
|
/// <summary>A key-carrying session whose activation-time roles are <paramref name="roles"/>.</summary>
|
|
private static RoleCarryingUserIdentity KeyIdentity(params string[] roles) =>
|
|
new(new UserNameIdentityToken { UserName = "apikey" }, roles, Principal, KeyId);
|
|
|
|
/// <summary>
|
|
/// Wires a production-shaped, flag-OFF authorizer over one applied snapshot. The node manager's
|
|
/// authority source and the authorizer's are the same instance, as the host wires them.
|
|
/// </summary>
|
|
private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire(
|
|
OtOpcUaNodeManager nm, TimeProvider clock, params ApiKey[] keys)
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply(Cluster, "rev-1", keys, [], NoScopes);
|
|
var authorizer = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: false, NullLogger.Instance, clock);
|
|
nm.DataPlaneAuthority = state;
|
|
nm.Authorizer = authorizer;
|
|
return (state, authorizer);
|
|
}
|
|
|
|
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<ServiceResult> Write(
|
|
OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, object value)
|
|
{
|
|
var context = new OperationContext(
|
|
new RequestHeader(), secureChannelContext: null, RequestType.Write, identity);
|
|
var writes = new List<WriteValue>
|
|
{
|
|
new()
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value,
|
|
Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good },
|
|
},
|
|
};
|
|
var errors = Enumerable.Repeat<ServiceResult>(null!, writes.Count).ToList();
|
|
nm.Write(context, writes, errors);
|
|
return errors;
|
|
}
|
|
|
|
/// <summary>Records what actually reached the device.</summary>
|
|
private sealed class RecordingWriteGateway : IOpcUaNodeWriteGateway
|
|
{
|
|
public List<string> Writes { get; } = [];
|
|
|
|
public Task<NodeWriteOutcome> WriteAsync(
|
|
string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
|
{
|
|
lock (Writes) Writes.Add(nodeId);
|
|
return Task.FromResult(new NodeWriteOutcome(true, null));
|
|
}
|
|
}
|
|
|
|
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
|
|
private sealed class FakeClock(DateTime utcNow) : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
|
|
|
|
/// <inheritdoc />
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
/// <summary>Moves the clock forward.</summary>
|
|
/// <param name="by">How far forward.</param>
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
|
{
|
|
var applicationUri = $"urn:OtOpcUa.KeyGateTest:{Guid.NewGuid():N}";
|
|
var started = await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.KeyGateTest",
|
|
ApplicationUri = applicationUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
var host = started.Host;
|
|
var server = started.Server;
|
|
return (host, server);
|
|
}
|
|
|
|
/// <summary>Cleans up the PKI root directory.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch (IOException) { /* best effort */ }
|
|
}
|
|
}
|
|
}
|