Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs
T
Joseph Doherty cfd403710e feat(historian): Part 11 modified-value history on SqlServer + QuestDB (#668)
Every HistoryUpdate a SQL backend applies now leaves a modification trail
(prior value, principal, Part 11 update type, modification time), and
HistoryReadRaw with IsReadModified=true serves it as HistoryModifiedData.

- Core: HistoryUpdateContext threaded into all three IHistorianDataUpdater
  methods; IHistorianDataSource.ReadModifiedAsync (default NotSupported =>
  BadHistoryOperationUnsupported on Gateway/Null); HistoryModified* contracts.
- SqlServer: schema v2 (002_sample_modified.sql, RequiredVersion=2, docs copy);
  trail written by OUTPUT ... INTO on the same MERGE/DELETE statement, so a
  failed update leaves no audit row; retention purges SampleModified per tag on
  the source-time cutoff; ReadModifiedAsync keyed on (SourceTimeUtc, ModifiedId).
- QuestDB: append-only {prefix}sample_modified (TTL like samples), trail row
  written BEFORE the change (orphan-on-crash, never an untracked change);
  existence reads now carry the prior value; ReadModifiedAsync.
- Node manager: modified reads dispatch + page via the #669 continuation store
  (HistoryContinuationState gains HistoryCursorKind + LastModifiedId), gated by
  the existing HistoryRead decision; session principal / key id passed to the
  updater. IsDeleteModified stays unsupported.
- Client.CLI: historyread --modified.
- Tests: node-manager dispatch/paging/cursor binding/principal threading,
  over-the-wire HistoryModifiedData, shared ModifiedValueConformance on both
  backends, SqlServer atomicity (forced trail failure => no change) + v1->v2
  upgrade + retention trail purge.
- Docs: Historian.md (Modified-value history section, schema v2 upgrade note),
  security.md, Client.CLI.md, CLAUDE.md.
2026-10-11 09:26:36 -04:00

423 lines
18 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 = NullHistorianDataSource.Instance;
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 (_, allowedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId);
allowedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
}
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));
}
}
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 */ }
}
}
}