Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs
T
Joseph Doherty 7c80d36eb6 fix(historian): #668 review follow-ups (explicit modified source time, paging tests, ACL test, QuestDB orphan warning, docs)
- HistoryModifiedValue gains explicit SourceTimeUtc (SqlServer + QuestDB populate it); the node manager's resume cursor uses it instead of the prior value's nullable source timestamp (MinValue for inserts).
- ACL test now uses a trail-returning data source and asserts Good + data on the allow leg.
- Node-manager tests: end-only backward open-ended paging, reverse resume cursor, null-prior cursor.
- QuestDB updater logs a Warning (tag + time range) when a sample write fails after its trail rows landed.
- docs/Historian.md: rolling-upgrade window, IDENTITY-vs-commit ordering, QuestDB cross-node ModifiedId clock ordering.
2026-10-11 09:37:17 -04:00

455 lines
21 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.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;
/// <summary>
/// #520 stage 5 — enforcement at the node manager's own seams, through a REAL booted
/// <see cref="OtOpcUaSdkServer"/> and the SDK's own dispatch. <see cref="DataPlaneAuthorizerTests"/>
/// 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).
/// </summary>
/// <remarks>
/// 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 <b>no authorization check of any kind</b>
/// before this stage — so a green result here is the difference between "any session can read all
/// history" and "a grant is required".
/// </remarks>
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}");
/// <summary>
/// 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.
/// </summary>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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(); }
}
/// <summary>
/// #668: a modified-value read (<c>IsReadModified = true</c>) 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).
/// </summary>
[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<HistoryModifiedData>();
data.DataValues.Count.ShouldBe(1);
data.ModificationInfos[0].UserName.ShouldBe("alice");
}
finally { await host.DisposeAsync(); }
}
/// <summary>A granted principal reads normally — the gate is not simply denying everything.</summary>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
[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<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[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 ─────────────────────────────
/// <summary>
/// Every Part 9 method name the node manager routes maps onto the permission it should require.
/// The <c>AlarmAck</c> 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.
/// </summary>
/// <remarks>
/// The inputs are the exact literals the <c>OnAcknowledge</c> / <c>OnShelve</c> / <c>OnEnableDisable</c>
/// 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.
/// </remarks>
[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 ─────────────────────────────
/// <summary>
/// With enforcement ON, the ACL decision REPLACES the <c>WriteOperate</c> role check — an identity
/// carrying NO roles at all writes successfully on the strength of its grant alone.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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(); }
}
/// <summary>
/// And the converse: a principal whose grant lacks <c>WriteOperate</c> is refused, and — the part
/// that matters beyond the status code — <b>nothing is dispatched to the device</b>. A gate that
/// denied the client but still routed the write would be worse than no gate at all.
/// </summary>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
[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<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, so a denial can be shown to route nothing.</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 backend whose modified-value trail holds exactly one entry; every other read is empty.</summary>
private sealed class OneModificationTrail : IHistorianDataSource
{
public Task<HistoryModifiedReadResult> 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<ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
public Task<ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult> ReadProcessedAsync(
string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
public Task<ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
public Task<HistoricalEventsResult> 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<SdkHistoryReadResult> Results, List<ServiceResult> Errors) ReadRaw(
OtOpcUaNodeManager nm, IUserIdentity? identity, params NodeId[] nodeIds) =>
ReadRaw(nm, identity, modified: false, nodeIds);
private static (List<SdkHistoryReadResult> Results, List<ServiceResult> 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<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
var errors = Enumerable.Repeat<ServiceResult>(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<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopePaths = null)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", [], [grant],
scopePaths ?? new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
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<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 */ }
}
}
}