cfd403710e
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.
423 lines
18 KiB
C#
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 */ }
|
|
}
|
|
}
|
|
}
|