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
This commit is contained in:
Joseph Doherty
2026-10-09 12:55:42 -04:00
parent ff7c6c531a
commit 2ef8ee3ade
10 changed files with 1065 additions and 11 deletions
@@ -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)
@@ -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; } = "";
@@ -54,7 +54,7 @@ public static class ApiKeyInterimRoleMapper
effective |= grant.PermissionFlags;
}
var roles = new List<string>(2);
var roles = new List<string>(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;
}
}
@@ -45,6 +45,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OtOpcUaServerHostedService> _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 <c>UnmanagedDriverPrimaryRoleSource</c> also answers with.</param>
/// <param name="historianUpdater">The selected historian backend's optional HistoryUpdate capability.
/// Optional: when unregistered it resolves to <see cref="HistorianUpdaterHolder.None"/> and HistoryUpdate is
/// reported unsupported on every node.</param>
public OtOpcUaServerHostedService(
IOptions<OpcUaApplicationHostOptions> options,
DeferredAddressSpaceSink deferredSink,
@@ -108,7 +112,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
IOptions<AclOptions> 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<OtOpcUaServerHostedService>();
}
@@ -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
@@ -309,6 +309,29 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
}
private volatile IHistorianDataUpdater? _historianDataUpdater;
/// <summary>
/// Optional server-side backend for the OPC UA HistoryUpdate service (insert / replace / update /
/// delete of historized samples). <c>null</c> (the default) means the selected historian backend
/// cannot modify history: every HistoryUpdate is reported <c>BadHistoryOperationUnsupported</c>.
/// <para>
/// ⚠️ <b>Assign it before any node materialises.</b> Whether an updater is present decides the
/// <c>HistoryWrite</c> AccessLevel bit a historized node is created with
/// (<see cref="ComposeAccessLevel"/>), 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
/// <c>OtOpcUaServerHostedService.StartAsync</c> before installing the address-space sink, which
/// precedes every deploy apply. Volatile for the same start-thread / request-thread handoff as
/// <see cref="HistorianDataSource"/>.
/// </para>
/// </summary>
public IHistorianDataUpdater? HistorianDataUpdater
{
get => _historianDataUpdater;
set => _historianDataUpdater = value;
}
/// <summary>
/// #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 <see cref="EnsureVariable"/> (node
/// creation) and <see cref="UpdateTagAttributes"/> (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.</summary>
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
/// <paramref name="historyWritable"/> (a <see cref="HistorianDataUpdater"/> 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.</summary>
/// <param name="writable">The node accepts client value writes.</param>
/// <param name="historized">The node is historized.</param>
/// <param name="historyWritable">A HistoryUpdate backend is wired.</param>
/// <returns>The access-level byte.</returns>
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.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Drops the handles in a HistoryUpdate batch the calling session may not update, marking each denied
/// slot <c>BadUserAccessDenied</c>. Unlike <see cref="AuthorizeHistoryRead"/> this gate exists in BOTH
/// modes, because rewriting recorded history was never ungated.
/// </summary>
/// <remarks>
/// <para>
/// ACL flag <b>on</b>: the scoped decision for <see cref="OpcUaOperation.HistoryUpdate"/> REPLACES the
/// role check (never chained — see <see cref="OnEquipmentTagWrite"/> for why chaining would leave
/// every narrow grant inert).
/// </para>
/// <para>
/// ACL flag <b>off</b>: the session's key is re-checked first (Gitea #563 — an expired or revoked key
/// loses its activation-time roles), then the dedicated
/// <see cref="OpcUaDataPlaneRoles.HistoryUpdate"/> role is required. <c>WriteOperate</c> is
/// deliberately NOT reused: writing a live value and rewriting history are different authorities.
/// </para>
/// </remarks>
/// <param name="context">The SDK context carrying the session identity.</param>
/// <param name="nodesToProcess">The handles the base dispatcher admitted.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <returns>The handles that may proceed.</returns>
private List<NodeHandle> AuthorizeHistoryUpdate(
ServerSystemContext context,
List<NodeHandle> nodesToProcess,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors)
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity
?? context.OperationContext?.UserIdentity as RoleCarryingUserIdentity;
var allowed = new List<NodeHandle>(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;
}
/// <summary>
/// The flag-off role gate for HistoryUpdate: a null identity or one without the
/// <see cref="OpcUaDataPlaneRoles.HistoryUpdate"/> role ⇒ <c>BadUserAccessDenied</c>; otherwise
/// <c>null</c> (proceed). Case-insensitive, matching the other role gates.
/// </summary>
/// <param name="identity">The session's role-carrying identity, or null.</param>
/// <returns><c>null</c> to proceed; otherwise the veto.</returns>
internal static ServiceResult? EvaluateHistoryUpdateRoleGate(RoleCarryingUserIdentity? identity) =>
identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.HistoryUpdate, StringComparer.OrdinalIgnoreCase)
? new ServiceResult(StatusCodes.BadUserAccessDenied)
: null;
/// <inheritdoc />
protected override void HistoryUpdateData(
ServerSystemContext context,
IList<UpdateDataDetails> nodesToUpdate,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> 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<DataValue>?)details.UpdateValues ?? Array.Empty<DataValue>();
return (updater, tagname, ct) => UpdateDataAsync(updater, tagname, mode, items, ct);
});
}
/// <inheritdoc />
protected override void HistoryDeleteRawModified(
ServerSystemContext context,
IList<DeleteRawModifiedDetails> nodesToUpdate,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> 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 };
};
});
}
/// <inheritdoc />
protected override void HistoryDeleteAtTime(
ServerSystemContext context,
IList<DeleteAtTimeDetails> nodesToUpdate,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
{
var times = nodesToUpdate[handle.Index].ReqTimes?.ToList() ?? new List<DateTime>();
return async (updater, tagname, ct) =>
{
var codes = times.Count == 0
? Array.Empty<uint>()
: 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))),
};
};
});
}
/// <summary>
/// Applies one node's UpdateDataDetails: items the server can refuse on its own
/// (<c>SourceTimestamp == DateTime.MinValue</c> ⇒ <c>BadInvalidTimestamp</c>; a value
/// <see cref="HistorianValue.TryFromObject"/> cannot type ⇒ <c>BadTypeMismatch</c>) are answered locally,
/// the rest go to the updater in ONE call, and the per-item codes are stitched back in request order.
/// </summary>
/// <param name="updater">The wired updater.</param>
/// <param name="tagname">The node's historian tagname.</param>
/// <param name="mode">The update mode.</param>
/// <param name="items">The request's values, in order.</param>
/// <param name="ct">The request deadline.</param>
/// <returns>The node's result, carrying one operation result per item.</returns>
private static async Task<HistoryUpdateResult> UpdateDataAsync(
IHistorianDataUpdater updater, string tagname, HistoryUpdateMode mode, IList<DataValue> items,
CancellationToken ct)
{
var codes = new uint[items.Count];
var toSend = new List<HistorizationValue>(items.Count);
var sentIndex = new List<int>(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))),
};
}
/// <summary>
/// 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
/// (<paramref name="plan"/> returns null) is <c>BadHistoryOperationUnsupported</c>.
/// </summary>
/// <param name="context">The SDK context.</param>
/// <param name="nodesToProcess">The handles the base dispatcher admitted.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="plan">Builds a node's operation from its details, or returns null when unsupported.</param>
private void ServeHistoryUpdate(
ServerSystemContext context,
List<NodeHandle> nodesToProcess,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
Func<NodeHandle, Func<IHistorianDataUpdater, string, CancellationToken, Task<HistoryUpdateResult>>?> 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<Func<Task>>(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);
});
}
/// <summary>
/// Runs one node's HistoryUpdate operation and fills its slots. Never throws (the
/// <see cref="RunBounded"/> contract): a deadline cancellation is <c>BadTimeout</c>, any backend failure
/// is logged and reported <c>BadInternalError</c> for THIS node only.
/// </summary>
/// <param name="handle">The node handle.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="operation">The node's operation.</param>
/// <param name="ct">The request deadline.</param>
private async Task ServeUpdateNodeAsync(
NodeHandle handle,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
Func<CancellationToken, Task<HistoryUpdateResult>> 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 };
}
}
/// <summary>Clamp a <see cref="uint"/> request cap to a non-negative <see cref="int"/> for the
/// event-read surface (whose <c>maxEvents</c> is signed): values above <see cref="int.MaxValue"/>
/// saturate to <see cref="int.MaxValue"/>.</summary>
@@ -5298,7 +5624,37 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
List<NodeHandle> nodesToProcess,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
Action serve)
Action serve) =>
WithHistoryBatchLimiter(nodesToProcess, errors, serve, handle =>
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations });
/// <summary>
/// HistoryUpdate counterpart of <see cref="WithHistoryReadBatchLimiter(List{NodeHandle}, IList{SdkHistoryReadResult}, IList{ServiceResult}, Action)"/>:
/// the SAME process-wide limiter and admission wait, so update batches and read batches share one
/// concurrency budget against the historian backend.
/// </summary>
/// <param name="nodesToProcess">The batch's handles — every slot is failed on limiter saturation.</param>
/// <param name="results">The service-level HistoryUpdate results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="serve">The arm body to run once a slot is acquired.</param>
private void WithHistoryReadBatchLimiter(
List<NodeHandle> nodesToProcess,
IList<HistoryUpdateResult> results,
IList<ServiceResult> errors,
Action serve) =>
WithHistoryBatchLimiter(nodesToProcess, errors, serve, handle =>
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTooManyOperations });
/// <summary>Shared body of the HistoryRead / HistoryUpdate batch limiter.</summary>
/// <param name="nodesToProcess">The batch's handles.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="serve">The arm body.</param>
/// <param name="rejectResult">Fills the per-node result slot when the batch is rejected.</param>
private void WithHistoryBatchLimiter(
List<NodeHandle> nodesToProcess,
IList<ServiceResult> errors,
Action serve,
Action<NodeHandle> 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;
}
@@ -128,6 +128,25 @@ public sealed class OtOpcUaSdkServer : StandardServer
return true;
}
/// <summary>
/// Wire the optional HistoryUpdate backend (the <see cref="IHistorianDataUpdater"/> the node manager's
/// HistoryUpdate overrides block-bridge to) onto the created <see cref="OtOpcUaNodeManager"/>. Mirrors
/// <see cref="SetHistorianDataSource"/>; <c>null</c> means "this backend cannot modify history".
/// <para>
/// ⚠️ Call this BEFORE any node materialises: whether an updater is wired decides the
/// <c>HistoryWrite</c> 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.
/// </para>
/// </summary>
/// <param name="updater">The update backend, or <c>null</c> for none.</param>
/// <returns><c>true</c> when set on a live node manager; <c>false</c> when no node manager exists yet.</returns>
public bool SetHistorianDataUpdater(IHistorianDataUpdater? updater)
{
if (_otOpcUaNodeManager is null) return false;
_otOpcUaNodeManager.HistorianDataUpdater = updater;
return true;
}
/// <summary>
/// Wire the node's data-plane authorization inputs (#520 stage 4) onto the created
/// <see cref="OtOpcUaNodeManager"/> — the cluster it serves, the current auth generation, and the
@@ -28,4 +28,10 @@ public static class OpcUaDataPlaneRoles
/// <c>OnWriteValue</c> handler to route the value to the backing driver; absent it the
/// write is denied with <c>BadUserAccessDenied</c> before any driver call.</summary>
public const string WriteOperate = "WriteOperate";
/// <summary>The role that grants OPC UA HistoryUpdate authority (insert / replace / update / delete of
/// historized samples) while <c>Security:Acl:Enabled</c> is off. Conferred only by a Cluster-scoped grant
/// carrying <c>NodePermissions.HistoryUpdate</c>; <see cref="WriteOperate"/> does NOT confer it — writing
/// a live value and rewriting recorded history are different authorities.</summary>
public const string HistoryUpdate = "HistoryUpdate";
}
@@ -0,0 +1,15 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// <summary>
/// DI carrier for the optional <see cref="IHistorianDataUpdater"/> the selected historian backend supplies.
/// A holder (rather than a nullable registration) lets "this backend cannot modify history" be registered
/// explicitly as <see cref="None"/>, so consumers resolve one type and branch on <see cref="Updater"/>.
/// </summary>
/// <param name="Updater">The backend's updater, or <c>null</c> when HistoryUpdate is unsupported.</param>
public sealed record HistorianUpdaterHolder(IHistorianDataUpdater? Updater)
{
/// <summary>No updater: every HistoryUpdate is reported unsupported.</summary>
public static readonly HistorianUpdaterHolder None = new((IHistorianDataUpdater?)null);
}
@@ -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;
/// <summary>
/// The flag-off <see cref="OpcUaDataPlaneRoles.HistoryUpdate"/> role (pluggable historian, spec §5). The
/// broader mapper behaviour is covered by <see cref="ApiKeyAuthenticationTests"/>.
/// </summary>
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]);
}
/// <summary>
/// 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).
/// </summary>
[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,
};
}
@@ -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;
/// <summary>
/// OPC UA HistoryUpdate on <see cref="OtOpcUaNodeManager"/> (pluggable historian, spec §5), driven through
/// the SDK's PUBLIC <c>HistoryUpdate(OperationContext, Type, …)</c> 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 <c>HistoryWrite</c>. 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.
/// </summary>
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 ─────────────────────────────
/// <summary>
/// THE load-bearing ALLOW: ACL flag ON, a session carrying NO roles, a Cluster-scoped
/// <c>HistoryUpdate</c> grant ⇒ the insert reaches the updater and the item reports
/// <c>GoodEntryInserted</c>. A deny-only test would pass against a build that never consulted the ACL,
/// because the role gate denies a roleless session too.
/// </summary>
[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(); }
}
/// <summary>ACL on: a grant without the HistoryUpdate bit (even one carrying write + history read) is refused.</summary>
[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(); }
}
/// <summary>ACL off: the dedicated <c>HistoryUpdate</c> role admits the update.</summary>
[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(); }
}
/// <summary>ACL off: <c>WriteOperate</c> is NOT reused — a write-only session is denied and nothing reaches the backend.</summary>
[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(); }
}
/// <summary>
/// ACL off, Gitea #563 shape: allow-then-deny on the same session — only the clock moves past the key's
/// <c>ExpiresUtc</c>, and the HistoryUpdate role stops working.
/// </summary>
[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 ─────────────────────────────
/// <summary>
/// No updater ⇒ the historized node is materialised WITHOUT the HistoryWrite bit, so the SDK base never
/// admits it and HistoryUpdate reports <c>BadHistoryOperationUnsupported</c>.
/// </summary>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
[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(); }
}
/// <summary>With an updater wired, both the raw node and its UNS mirror carry HistoryWrite; a non-historized node does not.</summary>
[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 ─────────────────────────────
/// <summary>
/// Per-item codes come back in request order: locally refused items (MinValue timestamp ⇒
/// <c>BadInvalidTimestamp</c>, an array value ⇒ <c>BadTypeMismatch</c>) never reach the backend, the
/// rest are sent in one call and their codes stitched back in place. The node code stays Good.
/// </summary>
[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(); }
}
/// <summary>Replace / Update map to their modes; Remove is out of scope ⇒ unsupported with no backend call.</summary>
[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(); }
}
/// <summary>A UNS mirror resolves to the SAME historian tagname as its raw node.</summary>
[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(); }
}
/// <summary>DeleteRawModified with IsDeleteModified=false deletes the range; true is unsupported.</summary>
[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(); }
}
/// <summary>DeleteAtTime returns the updater's per-time codes in order.</summary>
[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(); }
}
/// <summary>Event history updates are out of scope on a variable ⇒ unsupported.</summary>
[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<HistoryUpdateResult> Results, List<ServiceResult> Errors) Insert(
OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, params DataValue[] values) =>
Update(nm, identity, nodeId, PerformUpdateType.Insert, values);
private static (List<HistoryUpdateResult> Results, List<ServiceResult> 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),
});
/// <summary>Drives the SDK's PUBLIC HistoryUpdate entry point (base admission + Lock + dispatch).</summary>
private static (List<HistoryUpdateResult> Results, List<ServiceResult> 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<HistoryUpdateDetails> { details };
var results = new List<HistoryUpdateResult> { null! };
var errors = new List<ServiceResult> { 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<AclScopeRef, IReadOnlyList<NodeAclPath>>());
nm.DataPlaneAuthority = state;
nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock ?? new FakeClock(Start));
}
/// <summary>Records every call; completes on a pool thread like a real backend round-trip.</summary>
private sealed class FakeUpdater : IHistorianDataUpdater
{
public List<(string Tag, HistoryUpdateMode Mode, IReadOnlyList<HistorizationValue> Values)> UpdateCalls { get; } = [];
public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = [];
public List<IReadOnlyList<DateTime>> DeleteAtTimeCalls { get; } = [];
public uint[]? UpdateCodes { get; init; }
public uint DeleteRawCode { get; init; } = StatusCodes.Good;
public uint[]? DeleteAtTimeCodes { get; init; }
public async Task<IReadOnlyList<uint>> UpdateDataAsync(
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> 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<uint> 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<IReadOnlyList<uint>> DeleteAtTimeAsync(
string tag, IReadOnlyList<DateTime> 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();
}
}
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
private sealed class FakeClock(DateTime utcNow) : TimeProvider
{
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Moves the clock forward.</summary>
/// <param name="by">How far forward.</param>
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var applicationUri = $"urn:OtOpcUa.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<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
return (started.Host, started.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 */ }
}
}
}