From 2ef8ee3ade6d42c64024363e566d82b96bdb7cb0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 12:55:42 -0400 Subject: [PATCH] feat(historian): OPC UA HistoryUpdate in the node manager + HistoryUpdate authorization (T6) - OtOpcUaNodeManager overrides HistoryUpdateData / HistoryDeleteRawModified / HistoryDeleteAtTime, block-bridging to the optional IHistorianDataUpdater under the shared HistoryRead batch limiter + deadline. Insert/Replace/Update map to HistoryUpdateMode; Remove, IsDeleteModified, events and structure data are BadHistoryOperationUnsupported. Per-item codes stitched back in request order (MinValue SourceTimestamp => BadInvalidTimestamp, untypeable value => BadTypeMismatch); success clears the base's pre-seeded Unsupported error. - HistoryWrite AccessLevel bit OR-ed in for historized nodes when an updater is wired (raw + UNS); it is dispatch-load-bearing in the SDK base. OtOpcUaSdkServer.SetHistorianDataUpdater; the hosted service assigns it from an optional HistorianUpdaterHolder before SetSink, clears it on stop. - AuthorizeHistoryUpdate: ACL on => scoped OpcUaOperation.HistoryUpdate decision (replaces the role check); ACL off => #563 key re-check + new OpcUaDataPlaneRoles.HistoryUpdate role, conferred by ApiKeyInterimRoleMapper only from a Cluster-scoped HistoryUpdate grant (WriteOperate does not). - AdminUI ACL editor offers NodePermissions.HistoryUpdate. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../Enums/NodePermissions.cs | 4 +- .../Components/Pages/Clusters/AclEdit.razor | 2 +- .../OpcUa/ApiKeyInterimRoleMapper.cs | 6 +- .../OpcUa/OtOpcUaServerHostedService.cs | 16 +- .../OtOpcUaNodeManager.cs | 368 +++++++++++- .../OtOpcUaSdkServer.cs | 19 + .../Security/OpcUaDataPlaneRoles.cs | 6 + .../Historian/HistorianUpdaterHolder.cs | 15 + .../OpcUa/ApiKeyInterimRoleMapperTests.cs | 83 +++ .../History/HistoryUpdateTests.cs | 557 ++++++++++++++++++ 10 files changed, 1065 insertions(+), 11 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianUpdaterHolder.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyInterimRoleMapperTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs index 87c1937d..333a8bdd 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs @@ -30,8 +30,8 @@ public enum NodePermissions : int MethodCall = 1 << 11, // OPC UA HistoryUpdate (annotation / insert / delete) — separate from HistoryRead so a - // read-only grant cannot authorize historian writes. Not included in any composite bundle - // until the HistoryUpdate service surface is implemented. + // read-only grant cannot authorize historian writes. Deliberately not included in any + // composite bundle — it must be granted explicitly. HistoryUpdate = 1 << 12, // Bundles (one-click grants in Admin UI) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor index 88f85e7e..9b0f343a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -163,7 +163,7 @@ else NodePermissions.Browse, NodePermissions.Read, NodePermissions.Subscribe, NodePermissions.HistoryRead, NodePermissions.WriteOperate, NodePermissions.WriteTune, NodePermissions.WriteConfigure, NodePermissions.AlarmRead, NodePermissions.AlarmAcknowledge, NodePermissions.AlarmConfirm, NodePermissions.AlarmShelve, - NodePermissions.MethodCall, + NodePermissions.MethodCall, NodePermissions.HistoryUpdate, ]; [Parameter] public string ClusterId { get; set; } = ""; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs index 0fa648d6..dde43764 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs @@ -54,7 +54,7 @@ public static class ApiKeyInterimRoleMapper effective |= grant.PermissionFlags; } - var roles = new List(2); + var roles = new List(3); if (effective.HasFlag(NodePermissions.WriteOperate)) roles.Add(OpcUaDataPlaneRoles.WriteOperate); // AlarmAcknowledge is the flag an operator authors; AlarmAck is the role the Part 9 method gates @@ -62,6 +62,10 @@ public static class ApiKeyInterimRoleMapper // separately — mapping them would imply a granularity the gate does not have. if (effective.HasFlag(NodePermissions.AlarmAcknowledge)) roles.Add(OpcUaDataPlaneRoles.AlarmAck); + // HistoryUpdate has its own role: WriteOperate (live writes) never implies authority to rewrite + // recorded history, so the role is conferred by the HistoryUpdate flag alone. + if (effective.HasFlag(NodePermissions.HistoryUpdate)) roles.Add(OpcUaDataPlaneRoles.HistoryUpdate); + return roles; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index bf6d045e..aff63c4d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -45,6 +45,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private readonly IDriverPrimaryRoleSource? _primaryRoleSource; + private readonly HistorianUpdaterHolder _historianUpdater; private OpcUaApplicationHost? _appHost; private OtOpcUaSdkServer? _server; @@ -94,6 +95,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl /// Wired onto the node manager so a native-condition Acknowledge on a non-Primary node is refused rather than /// applied and then reverted. Optional: a node with no driver role registers none, and null means "allow" — /// the single-node posture UnmanagedDriverPrimaryRoleSource also answers with. + /// The selected historian backend's optional HistoryUpdate capability. + /// Optional: when unregistered it resolves to and HistoryUpdate is + /// reported unsupported on every node. public OtOpcUaServerHostedService( IOptions options, DeferredAddressSpaceSink deferredSink, @@ -108,7 +112,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl IOptions aclOptions, TimeProvider time, ILoggerFactory loggerFactory, - IDriverPrimaryRoleSource? primaryRoleSource = null) + IDriverPrimaryRoleSource? primaryRoleSource = null, + HistorianUpdaterHolder? historianUpdater = null) { ArgumentNullException.ThrowIfNull(aclOptions); ArgumentNullException.ThrowIfNull(time); @@ -131,6 +136,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _actorRegistry = actorRegistry; _loggerFactory = loggerFactory; _primaryRoleSource = primaryRoleSource; + _historianUpdater = historianUpdater ?? HistorianUpdaterHolder.None; _logger = loggerFactory.CreateLogger(); } @@ -168,6 +174,13 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl return; } + // The HistoryUpdate backend MUST be wired before SetSink: the node manager decides each historized + // node's HistoryWrite AccessLevel bit at materialise time from whether an updater is present, and the + // SDK base admits a node to HistoryUpdate only when that bit is set. Installing the real sink below is + // what lets OpcUaPublishActor's deploy applies start materialising nodes, so assigning the updater + // first guarantees every node sees it (nodes are never retro-updated). + _server.SetHistorianDataUpdater(_historianUpdater.Updater); + _deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager)); // Wire the reverse-path inbound-alarm-command router: a client Acknowledge/Confirm/Shelve that @@ -371,6 +384,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _server?.SetNativeAlarmAckPrimaryGate(null); // Restore the Null historian so a late HistoryRead doesn't hit a disposed read client. _server?.SetHistorianDataSource(null); + _server?.SetHistorianDataUpdater(null); // Restore the empty authority so a late session activation cannot authorize against a stopping node. _server?.SetDataPlaneAuthority(null); // Detach the ACL gate too. Leaving it attached alongside an emptied authority would deny every late diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 3584a5f3..f6ab9359 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -309,6 +309,29 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 set => _historianDataSource = value ?? NullHistorianDataSource.Instance; } + private volatile IHistorianDataUpdater? _historianDataUpdater; + + /// + /// Optional server-side backend for the OPC UA HistoryUpdate service (insert / replace / update / + /// delete of historized samples). null (the default) means the selected historian backend + /// cannot modify history: every HistoryUpdate is reported BadHistoryOperationUnsupported. + /// + /// ⚠️ Assign it before any node materialises. Whether an updater is present decides the + /// HistoryWrite AccessLevel bit a historized node is created with + /// (), and the SDK base admits a node to HistoryUpdate only when + /// that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Setting the updater after + /// nodes exist does NOT retro-update their AccessLevel. The Host assigns it in + /// OtOpcUaServerHostedService.StartAsync before installing the address-space sink, which + /// precedes every deploy apply. Volatile for the same start-thread / request-thread handoff as + /// . + /// + /// + public IHistorianDataUpdater? HistorianDataUpdater + { + get => _historianDataUpdater; + set => _historianDataUpdater = value; + } + /// /// #520 stage 4 — the node's current data-plane authorization inputs: which cluster it serves, /// the generation those were resolved at, and the ACL grants themselves. @@ -2861,7 +2884,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var historized = historianTagname is not null; // The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is // CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized. - var access = ComposeAccessLevel(writable, historized); + var access = ComposeAccessLevel(writable, historized, _historianDataUpdater is not null); var variable = new BaseDataVariableState(parent) { NodeId = new NodeId(variableNodeId, ns), @@ -2902,11 +2925,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Historizing flags — the single source of truth shared by (node /// creation) and (F10b surgical in-place update). The SDK exposes /// the flags separately (no CurrentReadWrite composite): ReadWrite is CurrentRead | CurrentWrite, - /// with the HistoryRead bit OR-ed in when historized. OR-ing byte constants promotes to int, so cast back. - private static byte ComposeAccessLevel(bool writable, bool historized) + /// with the HistoryRead bit OR-ed in when historized, and the HistoryWrite bit OR-ed in when historized AND + /// (a is wired). The HistoryWrite bit is + /// what admits a node to the SDK base's HistoryUpdate dispatch at all. OR-ing byte constants promotes to int, + /// so cast back. + /// The node accepts client value writes. + /// The node is historized. + /// A HistoryUpdate backend is wired. + /// The access-level byte. + internal static byte ComposeAccessLevel(bool writable, bool historized, bool historyWritable) { byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead; if (historized) access = (byte)(access | AccessLevels.HistoryRead); + if (historized && historyWritable) access = (byte)(access | AccessLevels.HistoryWrite); return access; } @@ -2949,7 +2980,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!_variables.TryGetValue(key, out var v)) return false; var historized = historianTagname is not null; - var access = ComposeAccessLevel(writable, historized); + var access = ComposeAccessLevel(writable, historized, _historianDataUpdater is not null); v.AccessLevel = access; v.UserAccessLevel = access; v.Historizing = historized; @@ -5148,6 +5179,301 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + // --------------------------------------------------------------------------------------------- + // HistoryUpdate (pluggable historian, spec §5). The SDK's public HistoryUpdate admits a variable only when + // its AccessLevel carries HistoryWrite (see ComposeAccessLevel), pre-seeds every admitted slot + // BadHistoryOperationUnsupported, RELEASES the node-manager Lock and dispatches by details type to the + // protected arms below — so, as for HistoryRead, block-bridging the async updater here is safe. Event and + // structure-data details are not overridden: the base leaves them Unsupported. + // --------------------------------------------------------------------------------------------- + + /// + /// Drops the handles in a HistoryUpdate batch the calling session may not update, marking each denied + /// slot BadUserAccessDenied. Unlike this gate exists in BOTH + /// modes, because rewriting recorded history was never ungated. + /// + /// + /// + /// ACL flag on: the scoped decision for REPLACES the + /// role check (never chained — see for why chaining would leave + /// every narrow grant inert). + /// + /// + /// ACL flag off: the session's key is re-checked first (Gitea #563 — an expired or revoked key + /// loses its activation-time roles), then the dedicated + /// role is required. WriteOperate is + /// deliberately NOT reused: writing a live value and rewriting history are different authorities. + /// + /// + /// The SDK context carrying the session identity. + /// The handles the base dispatcher admitted. + /// The service-level results list. + /// The service-level errors list. + /// The handles that may proceed. + private List AuthorizeHistoryUpdate( + ServerSystemContext context, + List nodesToProcess, + IList results, + IList errors) + { + var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity + ?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity; + + var allowed = new List(nodesToProcess.Count); + foreach (var handle in nodesToProcess) + { + var veto = AclEnforced + ? _authorizer!.Authorize(identity, OpcUaOperation.HistoryUpdate, handle.NodeId, RealmOf(handle.NodeId)) + : _authorizer?.AuthorizeKeyUsable(identity, OpcUaOperation.HistoryUpdate, handle.NodeId) + ?? EvaluateHistoryUpdateRoleGate(identity); + if (veto is null) + { + allowed.Add(handle); + continue; + } + + // Both slots explicitly: "you may not update this" is a different fact from the pre-seeded + // "this node has no updatable history". + errors[handle.Index] = veto; + results[handle.Index] = new HistoryUpdateResult { StatusCode = veto.StatusCode }; + } + + return allowed; + } + + /// + /// The flag-off role gate for HistoryUpdate: a null identity or one without the + /// role ⇒ BadUserAccessDenied; otherwise + /// null (proceed). Case-insensitive, matching the other role gates. + /// + /// The session's role-carrying identity, or null. + /// null to proceed; otherwise the veto. + internal static ServiceResult? EvaluateHistoryUpdateRoleGate(RoleCarryingUserIdentity? identity) => + identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.HistoryUpdate, StringComparer.OrdinalIgnoreCase) + ? new ServiceResult(StatusCodes.BadUserAccessDenied) + : null; + + /// + protected override void HistoryUpdateData( + ServerSystemContext context, + IList nodesToUpdate, + IList results, + IList errors, + List nodesToProcess, + IDictionary cache) + { + ServeHistoryUpdate(context, nodesToProcess, results, errors, handle => + { + var details = nodesToUpdate[handle.Index]; + HistoryUpdateMode mode; + switch (details.PerformInsertReplace) + { + case PerformUpdateType.Insert: mode = HistoryUpdateMode.Insert; break; + case PerformUpdateType.Replace: mode = HistoryUpdateMode.Replace; break; + case PerformUpdateType.Update: mode = HistoryUpdateMode.Update; break; + default: return null; // Remove (and anything unknown) ⇒ unsupported + } + + var items = (IList?)details.UpdateValues ?? Array.Empty(); + return (updater, tagname, ct) => UpdateDataAsync(updater, tagname, mode, items, ct); + }); + } + + /// + protected override void HistoryDeleteRawModified( + ServerSystemContext context, + IList nodesToUpdate, + IList results, + IList errors, + List nodesToProcess, + IDictionary cache) + { + ServeHistoryUpdate(context, nodesToProcess, results, errors, handle => + { + var details = nodesToUpdate[handle.Index]; + // We never keep modified-value history, so there is none to delete. + if (details.IsDeleteModified) return null; + var start = details.StartTime; + var end = details.EndTime; + return async (updater, tagname, ct) => + { + var code = await updater.DeleteRawAsync(tagname, start, end, ct).ConfigureAwait(false); + return new HistoryUpdateResult { StatusCode = code }; + }; + }); + } + + /// + protected override void HistoryDeleteAtTime( + ServerSystemContext context, + IList nodesToUpdate, + IList results, + IList errors, + List nodesToProcess, + IDictionary cache) + { + ServeHistoryUpdate(context, nodesToProcess, results, errors, handle => + { + var times = nodesToUpdate[handle.Index].ReqTimes?.ToList() ?? new List(); + return async (updater, tagname, ct) => + { + var codes = times.Count == 0 + ? Array.Empty() + : await updater.DeleteAtTimeAsync(tagname, times, ct).ConfigureAwait(false); + if (codes.Count != times.Count) + throw new InvalidOperationException( + $"historian updater returned {codes.Count} result(s) for {times.Count} time(s)"); + return new HistoryUpdateResult + { + StatusCode = StatusCodes.Good, + OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))), + }; + }; + }); + } + + /// + /// Applies one node's UpdateDataDetails: items the server can refuse on its own + /// (SourceTimestamp == DateTime.MinValue ⇒ BadInvalidTimestamp; a value + /// cannot type ⇒ BadTypeMismatch) are answered locally, + /// the rest go to the updater in ONE call, and the per-item codes are stitched back in request order. + /// + /// The wired updater. + /// The node's historian tagname. + /// The update mode. + /// The request's values, in order. + /// The request deadline. + /// The node's result, carrying one operation result per item. + private static async Task UpdateDataAsync( + IHistorianDataUpdater updater, string tagname, HistoryUpdateMode mode, IList items, + CancellationToken ct) + { + var codes = new uint[items.Count]; + var toSend = new List(items.Count); + var sentIndex = new List(items.Count); + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + if (item is null) + { + codes[i] = StatusCodes.BadInvalidArgument; + } + else if (item.SourceTimestamp == DateTime.MinValue) + { + codes[i] = StatusCodes.BadInvalidTimestamp; + } + else if (!HistorianValue.TryFromObject(item.Value, out var value)) + { + codes[i] = StatusCodes.BadTypeMismatch; + } + else + { + toSend.Add(new HistorizationValue(item.SourceTimestamp, value, item.StatusCode.Code)); + sentIndex.Add(i); + } + } + + if (toSend.Count > 0) + { + var backend = await updater.UpdateDataAsync(tagname, mode, toSend, ct).ConfigureAwait(false); + if (backend.Count != toSend.Count) + throw new InvalidOperationException( + $"historian updater returned {backend.Count} result(s) for {toSend.Count} value(s)"); + for (var j = 0; j < backend.Count; j++) codes[sentIndex[j]] = backend[j]; + } + + // Per-node StatusCode stays Good even when item results are mixed — Part 11 reports item outcomes in + // OperationResults; the node code is for whole-node failures only. + return new HistoryUpdateResult + { + StatusCode = StatusCodes.Good, + OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))), + }; + } + + /// + /// Shared driver for the three HistoryUpdate arms: authorize, then under the shared HistoryRead batch + /// limiter + per-request deadline resolve each node's tagname and run its operation with bounded + /// parallelism. A node that is not historized, has no updater, or whose details are out of scope + /// ( returns null) is BadHistoryOperationUnsupported. + /// + /// The SDK context. + /// The handles the base dispatcher admitted. + /// The service-level results list. + /// The service-level errors list. + /// Builds a node's operation from its details, or returns null when unsupported. + private void ServeHistoryUpdate( + ServerSystemContext context, + List nodesToProcess, + IList results, + IList errors, + Func>?> plan) + { + nodesToProcess = AuthorizeHistoryUpdate(context, nodesToProcess, results, errors); + if (nodesToProcess.Count == 0) return; + + WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () => + { + using var deadline = CreateDeadline(); + var ct = deadline?.Token ?? CancellationToken.None; + var updater = _historianDataUpdater; + var work = new List>(nodesToProcess.Count); + foreach (var handle in nodesToProcess) + { + var operation = updater is null ? null : plan(handle); + if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null) + { + errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; + results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported }; + continue; + } + + var h = handle; + var tag = tagname; + work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => operation(updater!, tag, token), ct)); + } + + RunBounded(work, HistoryReadBatchParallelism); + }); + } + + /// + /// Runs one node's HistoryUpdate operation and fills its slots. Never throws (the + /// contract): a deadline cancellation is BadTimeout, any backend failure + /// is logged and reported BadInternalError for THIS node only. + /// + /// The node handle. + /// The service-level results list. + /// The service-level errors list. + /// The node's operation. + /// The request deadline. + private async Task ServeUpdateNodeAsync( + NodeHandle handle, + IList results, + IList errors, + Func> operation, + CancellationToken ct) + { + try + { + results[handle.Index] = await operation(ct).ConfigureAwait(false); + // The base pre-seeded BadHistoryOperationUnsupported, and the master node manager copies a non-Good + // error over the result's StatusCode — so a success MUST clear it. + errors[handle.Index] = ServiceResult.Good; + } + catch (OperationCanceledException) + { + errors[handle.Index] = StatusCodes.BadTimeout; + results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTimeout }; + } + catch (Exception ex) + { + _logger.LogError(ex, "OtOpcUaNodeManager: HistoryUpdate failed for node {NodeId}", handle.NodeId); + errors[handle.Index] = StatusCodes.BadInternalError; + results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadInternalError }; + } + } + /// Clamp a request cap to a non-negative for the /// event-read surface (whose maxEvents is signed): values above /// saturate to . @@ -5298,7 +5624,37 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 List nodesToProcess, IList results, IList errors, - Action serve) + Action serve) => + WithHistoryBatchLimiter(nodesToProcess, errors, serve, handle => + results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations }); + + /// + /// HistoryUpdate counterpart of : + /// the SAME process-wide limiter and admission wait, so update batches and read batches share one + /// concurrency budget against the historian backend. + /// + /// The batch's handles — every slot is failed on limiter saturation. + /// The service-level HistoryUpdate results list. + /// The service-level errors list. + /// The arm body to run once a slot is acquired. + private void WithHistoryReadBatchLimiter( + List nodesToProcess, + IList results, + IList errors, + Action serve) => + WithHistoryBatchLimiter(nodesToProcess, errors, serve, handle => + results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTooManyOperations }); + + /// Shared body of the HistoryRead / HistoryUpdate batch limiter. + /// The batch's handles. + /// The service-level errors list. + /// The arm body. + /// Fills the per-node result slot when the batch is rejected. + private void WithHistoryBatchLimiter( + List nodesToProcess, + IList errors, + Action serve, + Action rejectResult) { var gate = GetHistoryReadBatchLimiter(); @@ -5312,7 +5668,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 foreach (var handle in nodesToProcess) { errors[handle.Index] = StatusCodes.BadTooManyOperations; - results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations }; + rejectResult(handle); } return; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs index 18da840c..11eab2fb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs @@ -128,6 +128,25 @@ public sealed class OtOpcUaSdkServer : StandardServer return true; } + /// + /// Wire the optional HistoryUpdate backend (the the node manager's + /// HistoryUpdate overrides block-bridge to) onto the created . Mirrors + /// ; null means "this backend cannot modify history". + /// + /// ⚠️ Call this BEFORE any node materialises: whether an updater is wired decides the + /// HistoryWrite AccessLevel bit at materialise time, and the SDK base admits a node to + /// HistoryUpdate only when that bit is set. Nodes materialised earlier are not retro-updated. + /// + /// + /// The update backend, or null for none. + /// true when set on a live node manager; false when no node manager exists yet. + public bool SetHistorianDataUpdater(IHistorianDataUpdater? updater) + { + if (_otOpcUaNodeManager is null) return false; + _otOpcUaNodeManager.HistorianDataUpdater = updater; + return true; + } + /// /// Wire the node's data-plane authorization inputs (#520 stage 4) onto the created /// — the cluster it serves, the current auth generation, and the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs index 5ed2968c..cefa36a4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/OpcUaDataPlaneRoles.cs @@ -28,4 +28,10 @@ public static class OpcUaDataPlaneRoles /// OnWriteValue handler to route the value to the backing driver; absent it the /// write is denied with BadUserAccessDenied before any driver call. public const string WriteOperate = "WriteOperate"; + + /// The role that grants OPC UA HistoryUpdate authority (insert / replace / update / delete of + /// historized samples) while Security:Acl:Enabled is off. Conferred only by a Cluster-scoped grant + /// carrying NodePermissions.HistoryUpdate; does NOT confer it — writing + /// a live value and rewriting recorded history are different authorities. + public const string HistoryUpdate = "HistoryUpdate"; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianUpdaterHolder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianUpdaterHolder.cs new file mode 100644 index 00000000..0fa65209 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/HistorianUpdaterHolder.cs @@ -0,0 +1,15 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +/// +/// DI carrier for the optional the selected historian backend supplies. +/// A holder (rather than a nullable registration) lets "this backend cannot modify history" be registered +/// explicitly as , so consumers resolve one type and branch on . +/// +/// The backend's updater, or null when HistoryUpdate is unsupported. +public sealed record HistorianUpdaterHolder(IHistorianDataUpdater? Updater) +{ + /// No updater: every HistoryUpdate is reported unsupported. + public static readonly HistorianUpdaterHolder None = new((IHistorianDataUpdater?)null); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyInterimRoleMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyInterimRoleMapperTests.cs new file mode 100644 index 00000000..a59d4a00 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyInterimRoleMapperTests.cs @@ -0,0 +1,83 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Host.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; + +/// +/// The flag-off role (pluggable historian, spec §5). The +/// broader mapper behaviour is covered by . +/// +public sealed class ApiKeyInterimRoleMapperTests +{ + private const string Principal = "historian-editor"; + + [Fact] + public void A_cluster_scoped_history_update_grant_confers_the_history_update_role() + { + var roles = ApiKeyInterimRoleMapper.Map( + [Grant(NodeAclScopeKind.Cluster, NodePermissions.HistoryUpdate)], Principal); + + roles.ShouldBe([OpcUaDataPlaneRoles.HistoryUpdate]); + } + + /// + /// Writing a live value and rewriting recorded history are different authorities, so a key that can + /// write must not thereby gain HistoryUpdate — neither from WriteOperate nor from any composite bundle + /// (HistoryUpdate is deliberately in none of them). + /// + [Fact] + public void Write_operate_and_the_bundles_do_not_confer_history_update() + { + foreach (var flags in new[] + { + NodePermissions.WriteOperate, NodePermissions.HistoryRead, NodePermissions.ReadOnly, + NodePermissions.Operator, NodePermissions.Engineer, NodePermissions.Admin, + }) + { + ApiKeyInterimRoleMapper.Map([Grant(NodeAclScopeKind.Cluster, flags)], Principal) + .ShouldNotContain(OpcUaDataPlaneRoles.HistoryUpdate, $"{flags} must not confer HistoryUpdate"); + } + } + + [Fact] + public void A_narrower_scoped_history_update_grant_confers_nothing() + { + foreach (var narrower in new[] + { + NodeAclScopeKind.Equipment, NodeAclScopeKind.UnsArea, + NodeAclScopeKind.UnsLine, NodeAclScopeKind.Namespace, + }) + { + ApiKeyInterimRoleMapper.Map([Grant(narrower, NodePermissions.HistoryUpdate)], Principal) + .ShouldBeEmpty($"a {narrower}-scoped grant must not confer a server-wide role"); + } + } + + [Fact] + public void History_update_unions_with_the_other_roles() + { + var roles = ApiKeyInterimRoleMapper.Map( + [ + Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate), + Grant(NodeAclScopeKind.Cluster, NodePermissions.HistoryUpdate | NodePermissions.AlarmAcknowledge), + ], Principal); + + roles.ShouldBe( + [OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck, OpcUaDataPlaneRoles.HistoryUpdate], + ignoreOrder: true); + } + + private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new() + { + NodeAclId = $"ACL-{scope}-{flags}", + ClusterId = "MAIN", + Principal = Principal, + ScopeKind = scope, + ScopeId = scope == NodeAclScopeKind.Cluster ? null : "SOME-SCOPE", + PermissionFlags = flags, + }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs new file mode 100644 index 00000000..6814f20f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs @@ -0,0 +1,557 @@ +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; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.History; + +/// +/// OPC UA HistoryUpdate on (pluggable historian, spec §5), driven through +/// the SDK's PUBLIC HistoryUpdate(OperationContext, Type, …) entry point of a real booted server — +/// so every test also exercises the base's admission check, which admits a variable only when its +/// AccessLevel carries HistoryWrite. A test that called the protected arms directly would pass +/// against a node manager that never set that bit and therefore never served a single update. +/// +public sealed class HistoryUpdateTests : IDisposable +{ + private const string Cluster = "SITE-A"; + private const string Principal = "historian-editor"; + private const string KeyId = "k-hu"; + private const string RawPath = "Plant/Modbus/dev1/Speed"; + private const string Tagname = "WW.Speed"; + + private static readonly DateTime Start = new(2026, 10, 9, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime T1 = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime T2 = T1.AddMinutes(1); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-hu-{Guid.NewGuid():N}"); + + // ───────────────────────────── authorization ───────────────────────────── + + /// + /// THE load-bearing ALLOW: ACL flag ON, a session carrying NO roles, a Cluster-scoped + /// HistoryUpdate grant ⇒ the insert reaches the updater and the item reports + /// GoodEntryInserted. A deny-only test would pass against a build that never consulted the ACL, + /// because the role gate denies a roleless session too. + /// + [Fact] + public async Task Acl_on_a_roleless_session_with_a_scoped_grant_inserts() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: true, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + + var (results, errors) = Insert(nm, KeyIdentity(), nodeId, Value(T1, 42.5)); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); + var call = updater.UpdateCalls.ShouldHaveSingleItem(); + call.Tag.ShouldBe(Tagname); + call.Mode.ShouldBe(HistoryUpdateMode.Insert); + var sent = call.Values.ShouldHaveSingleItem(); + sent.TimestampUtc.ShouldBe(T1); + sent.Value.ShouldBe(HistorianValue.FromDouble(42.5)); + sent.StatusCode.ShouldBe(StatusCodes.Good); + } + finally { await host.DisposeAsync(); } + } + + /// ACL on: a grant without the HistoryUpdate bit (even one carrying write + history read) is refused. + [Fact] + public async Task Acl_on_a_grant_without_the_bit_is_denied() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: true, NodePermissions.WriteOperate | NodePermissions.HistoryRead); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + + var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + updater.UpdateCalls.ShouldBeEmpty(); + } + finally { await host.DisposeAsync(); } + } + + /// ACL off: the dedicated HistoryUpdate role admits the update. + [Fact] + public async Task Acl_off_the_history_update_role_is_allowed() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + + var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); + updater.UpdateCalls.Count.ShouldBe(1); + } + finally { await host.DisposeAsync(); } + } + + /// ACL off: WriteOperate is NOT reused — a write-only session is denied and nothing reaches the backend. + [Fact] + public async Task Acl_off_write_operate_alone_is_denied() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.WriteOperate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + + var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d)); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + updater.UpdateCalls.ShouldBeEmpty(); + } + finally { await host.DisposeAsync(); } + } + + /// + /// ACL off, Gitea #563 shape: allow-then-deny on the same session — only the clock moves past the key's + /// ExpiresUtc, and the HistoryUpdate role stops working. + /// + [Fact] + public async Task Acl_off_an_expired_key_loses_the_role() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + var clock = new FakeClock(Start); + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate, clock, expiresUtc: Start.AddMinutes(10)); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate); + + Insert(nm, identity, nodeId, Value(T1, 1d)).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + + clock.Advance(TimeSpan.FromMinutes(20)); + + Insert(nm, identity, nodeId, Value(T2, 2d)).Errors[0].StatusCode.Code + .ShouldBe(StatusCodes.BadUserAccessDenied); + updater.UpdateCalls.Count.ShouldBe(1); + } + finally { await host.DisposeAsync(); } + } + + // ───────────────────────────── availability + AccessLevel ───────────────────────────── + + /// + /// No updater ⇒ the historized node is materialised WITHOUT the HistoryWrite bit, so the SDK base never + /// admits it and HistoryUpdate reports BadHistoryOperationUnsupported. + /// + [Fact] + public async Task No_updater_means_no_history_write_bit_and_unsupported() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.HistorianDataUpdater = null; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + + var variable = nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!; + (variable.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0); + (variable.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0); + + var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + } + finally { await host.DisposeAsync(); } + } + + /// + /// An updater removed AFTER materialisation (AccessLevel is not retro-updated) still yields unsupported + /// rather than a null dereference — the override re-checks the updater per request. + /// + [Fact] + public async Task An_updater_cleared_after_materialisation_is_unsupported() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.HistorianDataUpdater = new FakeUpdater(); + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + nm.HistorianDataUpdater = null; + + var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + } + finally { await host.DisposeAsync(); } + } + + /// With an updater wired, both the raw node and its UNS mirror carry HistoryWrite; a non-historized node does not. + [Fact] + public async Task An_updater_grants_history_write_to_raw_and_uns_historized_nodes_only() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.HistorianDataUpdater = new FakeUpdater(); + Historized(nm, AddressSpaceRealm.Raw, RawPath); + Historized(nm, AddressSpaceRealm.Uns, "Area/Line/Eq/Speed"); + nm.EnsureVariable("Plant/Modbus/dev1/Plain", parentFolderNodeId: null, displayName: "Plain", + dataType: "Float", writable: true, historianTagname: null, realm: AddressSpaceRealm.Raw); + + (nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite) + .ShouldNotBe((byte)0); + (nm.TryGetVariable("Area/Line/Eq/Speed", AddressSpaceRealm.Uns)!.AccessLevel & AccessLevels.HistoryWrite) + .ShouldNotBe((byte)0); + (nm.TryGetVariable("Plant/Modbus/dev1/Plain", AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite) + .ShouldBe((byte)0); + } + finally { await host.DisposeAsync(); } + } + + // ───────────────────────────── semantics ───────────────────────────── + + /// + /// Per-item codes come back in request order: locally refused items (MinValue timestamp ⇒ + /// BadInvalidTimestamp, an array value ⇒ BadTypeMismatch) never reach the backend, the + /// rest are sent in one call and their codes stitched back in place. The node code stays Good. + /// + [Fact] + public async Task Item_codes_pass_through_in_request_order() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater + { + UpdateCodes = [StatusCodes.GoodEntryInserted, StatusCodes.BadEntryExists], + }; + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + + var bad = new DataValue(new Variant(new[] { 1d, 2d })) { SourceTimestamp = T2 }; + var noTime = new DataValue(new Variant(7d)); // SourceTimestamp defaults to DateTime.MinValue + var uncertain = new DataValue(new Variant(3)) { SourceTimestamp = T2, StatusCode = StatusCodes.UncertainLastUsableValue }; + + var (results, errors) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, + PerformUpdateType.Insert, Value(T1, 1d), noTime, bad, uncertain); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + results[0].OperationResults.Select(c => c.Code).ShouldBe( + [ + StatusCodes.GoodEntryInserted, StatusCodes.BadInvalidTimestamp, StatusCodes.BadTypeMismatch, + StatusCodes.BadEntryExists, + ]); + var call = updater.UpdateCalls.ShouldHaveSingleItem(); + call.Values.Count.ShouldBe(2); + call.Values[1].Value.ShouldBe(HistorianValue.FromInt64(3)); + call.Values[1].StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue); + } + finally { await host.DisposeAsync(); } + } + + /// Replace / Update map to their modes; Remove is out of scope ⇒ unsupported with no backend call. + [Fact] + public async Task Perform_update_types_map_and_remove_is_unsupported() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate); + + Update(nm, identity, nodeId, PerformUpdateType.Replace, Value(T1, 1d)); + Update(nm, identity, nodeId, PerformUpdateType.Update, Value(T1, 1d)); + var (_, removeErrors) = Update(nm, identity, nodeId, PerformUpdateType.Remove, Value(T1, 1d)); + + updater.UpdateCalls.Select(c => c.Mode).ShouldBe([HistoryUpdateMode.Replace, HistoryUpdateMode.Update]); + removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + } + finally { await host.DisposeAsync(); } + } + + /// A UNS mirror resolves to the SAME historian tagname as its raw node. + [Fact] + public async Task A_uns_node_id_updates_the_raw_tags_series() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + Historized(nm, AddressSpaceRealm.Raw, RawPath); + var unsNodeId = Historized(nm, AddressSpaceRealm.Uns, "Area/Line/Eq/Speed"); + + var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), unsNodeId, Value(T1, 5d)); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + updater.UpdateCalls.ShouldHaveSingleItem().Tag.ShouldBe(Tagname); + } + finally { await host.DisposeAsync(); } + } + + /// DeleteRawModified with IsDeleteModified=false deletes the range; true is unsupported. + [Fact] + public async Task Delete_raw_routes_to_the_updater_and_delete_modified_is_unsupported() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater { DeleteRawCode = StatusCodes.BadNoData }; + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate); + + var (results, errors) = Call(nm, identity, typeof(DeleteRawModifiedDetails), + new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false }); + var (_, modifiedErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails), + new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = true }); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData); + updater.DeleteRawCalls.ShouldHaveSingleItem().ShouldBe((Tagname, T1, T2)); + modifiedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + } + finally { await host.DisposeAsync(); } + } + + /// DeleteAtTime returns the updater's per-time codes in order. + [Fact] + public async Task Delete_at_time_passes_per_time_codes_through() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater { DeleteAtTimeCodes = [StatusCodes.Good, StatusCodes.BadNoEntryExists] }; + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + + var (results, errors) = Call(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), typeof(DeleteAtTimeDetails), + new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1, T2 } }); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.Good, StatusCodes.BadNoEntryExists]); + updater.DeleteAtTimeCalls.ShouldHaveSingleItem().ShouldBe([T1, T2]); + } + finally { await host.DisposeAsync(); } + } + + /// Event history updates are out of scope on a variable ⇒ unsupported. + [Fact] + public async Task Event_details_are_unsupported() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + + var (_, errors) = Call(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), typeof(UpdateEventDetails), + new UpdateEventDetails { NodeId = nodeId, PerformInsertReplace = PerformUpdateType.Insert }); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + updater.UpdateCalls.ShouldBeEmpty(); + } + finally { await host.DisposeAsync(); } + } + + // ───────────────────────────── helpers ───────────────────────────── + + private static DataValue Value(DateTime timestamp, object value) => + new(new Variant(value)) { SourceTimestamp = timestamp, StatusCode = StatusCodes.Good }; + + private static NodeId Historized(OtOpcUaNodeManager nm, AddressSpaceRealm realm, string id) + { + nm.EnsureVariable(id, parentFolderNodeId: null, displayName: id, dataType: "Float", + writable: false, historianTagname: Tagname, realm: realm); + return nm.TryGetVariable(id, realm)!.NodeId; + } + + private static (List Results, List Errors) Insert( + OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, params DataValue[] values) => + Update(nm, identity, nodeId, PerformUpdateType.Insert, values); + + private static (List Results, List Errors) Update( + OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, PerformUpdateType type, + params DataValue[] values) => + Call(nm, identity, typeof(UpdateDataDetails), new UpdateDataDetails + { + NodeId = nodeId, + PerformInsertReplace = type, + UpdateValues = new DataValueCollection(values), + }); + + /// Drives the SDK's PUBLIC HistoryUpdate entry point (base admission + Lock + dispatch). + private static (List Results, List Errors) Call( + OtOpcUaNodeManager nm, IUserIdentity identity, Type detailsType, HistoryUpdateDetails details) + { + var context = new OperationContext( + new RequestHeader(), secureChannelContext: null, RequestType.HistoryUpdate, identity); + var nodesToUpdate = new List { details }; + var results = new List { null! }; + var errors = new List { null! }; + + nm.HistoryUpdate(context, detailsType, nodesToUpdate, results, errors); + + // Unadmitted (no HistoryWrite bit) slots are left untouched by the base and later filled by the + // master node manager; report them the way the client would see them. + if (results[0] is null) results[0] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported }; + errors[0] ??= new ServiceResult(StatusCodes.BadHistoryOperationUnsupported); + return (results, errors); + } + + private static RoleCarryingUserIdentity KeyIdentity(params string[] roles) => + new(new UserNameIdentityToken { UserName = "apikey" }, roles, Principal, KeyId); + + private static void WireAcl( + OtOpcUaNodeManager nm, bool enabled, NodePermissions flags, TimeProvider? clock = null, + DateTime? expiresUtc = null) + { + var key = new ApiKey + { + ApiKeyId = KeyId, + Name = KeyId, + SecretHash = "hash", + Principal = Principal, + Enabled = true, + CreatedUtc = Start.AddDays(-1), + ExpiresUtc = expiresUtc, + }; + var grant = new NodeAcl + { + NodeAclId = "acl-cluster", + ClusterId = Cluster, + Principal = Principal, + ScopeKind = NodeAclScopeKind.Cluster, + PermissionFlags = flags, + }; + var state = new DataPlaneAuthorityState(); + state.Apply(Cluster, "rev-1", [key], [grant], new Dictionary>()); + nm.DataPlaneAuthority = state; + nm.Authorizer = new DataPlaneAuthorizer( + state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock ?? new FakeClock(Start)); + } + + /// Records every call; completes on a pool thread like a real backend round-trip. + private sealed class FakeUpdater : IHistorianDataUpdater + { + public List<(string Tag, HistoryUpdateMode Mode, IReadOnlyList Values)> UpdateCalls { get; } = []; + public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = []; + public List> DeleteAtTimeCalls { get; } = []; + public uint[]? UpdateCodes { get; init; } + public uint DeleteRawCode { get; init; } = StatusCodes.Good; + public uint[]? DeleteAtTimeCodes { get; init; } + + public async Task> UpdateDataAsync( + string tag, HistoryUpdateMode mode, IReadOnlyList values, CancellationToken ct) + { + await Task.Delay(1, ct).ConfigureAwait(false); + lock (UpdateCalls) UpdateCalls.Add((tag, mode, values.ToList())); + return UpdateCodes ?? Enumerable.Repeat(StatusCodes.GoodEntryInserted, values.Count).ToArray(); + } + + public async Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct) + { + await Task.Delay(1, ct).ConfigureAwait(false); + lock (DeleteRawCalls) DeleteRawCalls.Add((tag, startUtc, endUtc)); + return DeleteRawCode; + } + + public async Task> DeleteAtTimeAsync( + string tag, IReadOnlyList timesUtc, CancellationToken ct) + { + await Task.Delay(1, ct).ConfigureAwait(false); + lock (DeleteAtTimeCalls) DeleteAtTimeCalls.Add(timesUtc.ToList()); + return DeleteAtTimeCodes ?? Enumerable.Repeat(StatusCodes.Good, timesUtc.Count).ToArray(); + } + } + + /// A hand-advanced clock; the authorizer reads it on every decision. + private sealed class FakeClock(DateTime utcNow) : TimeProvider + { + private DateTimeOffset _now = new(utcNow, TimeSpan.Zero); + + /// + public override DateTimeOffset GetUtcNow() => _now; + + /// Moves the clock forward. + /// How far forward. + public void Advance(TimeSpan by) => _now = _now.Add(by); + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() + { + var applicationUri = $"urn:OtOpcUa.HistoryUpdateTest:{Guid.NewGuid():N}"; + var started = await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.HistoryUpdateTest", + ApplicationUri = applicationUri, + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + return (started.Host, started.Server); + } + + /// Cleans up the PKI root directory. + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch (IOException) { /* best effort */ } + } + } +}