diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
index 062e3593..bf93602a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
@@ -87,7 +87,9 @@ internal sealed record HistoryContinuationState(
long EmptyFromTicks = 0,
long EmptyToTicks = 0,
HistoryEndBound? PendingEndBound = null,
- bool WithinTieCluster = false)
+ bool WithinTieCluster = false,
+ string? BoundNodeKey = null,
+ long AddressSpaceEpoch = -1)
{
/// The walk-back hint this cursor carries.
public ReverseWalkHint WalkHint => new(SegmentWidthTicks, EmptyFromTicks, EmptyToTicks);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
index 53d44384..6dd731c1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
@@ -202,6 +202,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// A task that represents the asynchronous operation.
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
{
+ if (server is not OtOpcUaSdkServer sdkServer)
+ throw new ArgumentException("The server must support application authentication before its listeners open.", nameof(server));
_server = server;
// 1.5.378 requires an ITelemetryContext on the ApplicationInstance ctor (the parameterless ctor
// is obsolete). DefaultTelemetry.Create wires the SDK's internal logging; an empty builder keeps
@@ -213,27 +215,32 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
ConfigSectionName = "OtOpcUa",
};
- _ = await BuildConfigurationAsync(cancellationToken);
- await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
- await _application.StartAsync(server).ConfigureAwait(false);
-
- AttachUserAuthenticator();
- PopulateServerArray();
+ try
+ {
+ _ = await BuildConfigurationAsync(cancellationToken);
+ await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
+ AttachUserAuthenticator(sdkServer);
+ await _application.StartAsync(server, cancellationToken).ConfigureAwait(false);
+ PopulateServerArray();
+ }
+ catch
+ {
+ await DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
_options.PublicHostname, _options.OpcUaPort);
}
- private void AttachUserAuthenticator()
+ private void AttachUserAuthenticator(OtOpcUaSdkServer server)
{
- var registry = _server?.CurrentInstance?.IdentityRegistry
- ?? throw new InvalidOperationException("OPC UA identity registry unavailable after start.");
foreach (var type in new[] { UserTokenType.Anonymous, UserTokenType.UserName })
{
var adapter = new ApplicationTokenAuthenticator(this, type);
- registry.Register(adapter);
_identityAuthenticators.Add(adapter);
}
+ server.ConfigureIdentityAuthenticators(_identityAuthenticators);
}
private sealed class ApplicationTokenAuthenticator(OpcUaApplicationHost host, UserTokenType tokenType)
@@ -634,22 +641,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// A task that represents the asynchronous operation.
public async ValueTask DisposeAsync()
{
- try
- {
- if (_server?.CurrentInstance?.IdentityRegistry is { } registry)
- foreach (var authenticator in _identityAuthenticators) registry.Unregister(authenticator);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "OpcUaApplicationHost: detaching identity authenticators threw");
- }
- _identityAuthenticators.Clear();
-
+ // Authenticators remain installed until the SDK closes every listener and disposes its registry.
if (_application is { } application)
{
_application = null;
try { await application.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); }
}
+ _identityAuthenticators.Clear();
+ _server = null;
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 1dfad616..c2fcf660 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -698,6 +698,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private volatile SemaphoreSlim? _historyReadBatchLimiter;
private readonly object _historyReadBatchLimiterLock = new();
+ private long _historyContinuationEpoch;
private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore();
///
@@ -2315,6 +2316,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId));
var range = EvaluateEquipmentWriteRange(indexRange);
if (range is not null) return range;
+ if (value.TypeInfo.ValueRank > 1) return StatusCodes.BadTypeMismatch;
var structure = EvaluateEquipmentWriteStructure(SdkValueBoundary.ToClr(value), node, refuseArrayOnScalar, out var coerced);
if (structure is not null) return structure;
value = SdkValueBoundary.FromClr(coerced, node is BaseDataVariableState { DataType: var dt, ValueRank: ValueRanks.OneDimension } && dt == DataTypeIds.Byte);
@@ -3688,6 +3690,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
lock (Lock)
{
+ Interlocked.Increment(ref _historyContinuationEpoch);
// Review 01 L2 — every monitored item on a node this rebuild discards is told and forgotten BEFORE
// the nodes go, so a later create on the same NodeId binds to the re-created node (see
// TombstoneMonitoredNode). The shared root survives the rebuild and keeps its items.
@@ -3812,6 +3815,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
// teardown inside RebuildAddressSpace, scoped to this one id.
if (!_variables.TryRemove(key, out var variable)) return false;
+ // Conservatively invalidate every outstanding history cursor on topology removal.
+ Interlocked.Increment(ref _historyContinuationEpoch);
UnindexChild(key);
TombstoneMonitoredNode(variable.NodeId, UtcNow);
// 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds
@@ -3880,6 +3885,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
// equipment goes).
if (!_alarmConditions.TryRemove(key, out var condition)) return false;
+ // Conservatively invalidate every outstanding history cursor on topology removal.
+ Interlocked.Increment(ref _historyContinuationEpoch);
UnindexChild(key);
TombstoneMonitoredSubtree(condition, UtcNow);
// unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
@@ -3908,6 +3915,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
if (!_folders.ContainsKey(equipKey)) return false;
+ // Conservatively invalidate every outstanding history cursor on topology removal.
+ Interlocked.Increment(ref _historyContinuationEpoch);
// Review 01 P2 — walk the parent→children index the materialise calls maintain instead of
// prefix-scanning every map (variables, conditions, folders, notifier folders, event sources and
@@ -4868,9 +4877,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue;
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
- if (reportingSession is not null
+ if (reportingSession is { IsNull: false } reportingId
&& item.Session?.Id is { } own
- && !own.Equals(reportingSession))
+ && !own.Equals(reportingId))
{
continue;
}
@@ -5825,6 +5834,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
IList errors,
CancellationToken ct)
{
+ var historyEpoch = Volatile.Read(ref _historyContinuationEpoch);
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
var resumed = inboundCp is { Length: > 0 };
// One snapshot of the (hot-swappable) source for the whole read: the capability check and every call must agree.
@@ -5844,7 +5854,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// for THIS node only.
var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray());
// A point is bound to the node that issued it: another node's source name is a miss too.
- if (state is null || state.Kind != HistoryCursorKind.Events || state.Tagname != (sourceName ?? string.Empty))
+ if (state is null || !HistoryContinuationMatches(state, handle.NodeId, historyEpoch) || state.Kind != HistoryCursorKind.Events || state.Tagname != (sourceName ?? string.Empty))
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
@@ -6019,7 +6029,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// boundary skip) is exactly as without a filter: a page may come back with fewer events — even none — and a
// continuation point, never skipping or repeating one.
var historyEvent = ProjectEvents(events, selectClauses, where, filterContext);
- var outboundCp = pendingCursor is null ? null : _historyContinuationStore.Save(session, pendingCursor);
+ var outboundCp = pendingCursor is null ? null : SaveHistoryContinuation(session, handle.NodeId, historyEpoch, pendingCursor);
results[handle.Index] = new SdkHistoryReadResult
{
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events — or none matched).
@@ -6888,6 +6898,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
CancellationToken ct,
bool returnBounds = false)
{
+ var historyEpoch = Volatile.Read(ref _historyContinuationEpoch);
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
try
@@ -6904,7 +6915,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒
// BadContinuationPointInvalid for THIS node.
var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray());
- if (state is null || state.Kind != HistoryCursorKind.Raw
+ if (state is null || !HistoryContinuationMatches(state, handle.NodeId, historyEpoch) || state.Kind != HistoryCursorKind.Raw
|| !TryGetHistorizedTagname(handle.NodeId, out var boundTag) || boundTag != state.Tagname)
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
@@ -6971,7 +6982,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return null;
}
- return _historyContinuationStore.Save(session, new HistoryContinuationState(
+ return SaveHistoryContinuation(session, handle.NodeId, historyEpoch, new HistoryContinuationState(
tagname, previous, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(endBound!, page)! with { DataExhausted = true }));
@@ -7037,7 +7048,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (startBound is not null && numValuesPerNode == 1 && anything)
{
// The page is the start bound alone; the data starts at StartTime on the next page.
- var boundOnlyCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
+ var boundOnlyCp = SaveHistoryContinuation(session, handle.NodeId, historyEpoch, new HistoryContinuationState(
tagname, startUtc, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(pending, [startBound])));
@@ -7224,7 +7235,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(endBound, slice),
WithinTieCluster: resumeAt == startUtc);
- clusterCp = _historyContinuationStore.Save(session, clusterState);
+ clusterCp = SaveHistoryContinuation(session, handle.NodeId, historyEpoch, clusterState);
}
else if (endBound is not null)
{
@@ -7279,7 +7290,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// Save may return null (no session on this request) ⇒ degrade to single-shot for this node.
// Built AFTER historyData so a failure projecting samples can never orphan a stored cursor.
- outboundCp = _historyContinuationStore.Save(session, nextState);
+ outboundCp = SaveHistoryContinuation(session, handle.NodeId, historyEpoch, nextState);
}
else if (endBound is not null)
{
@@ -7366,6 +7377,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
uint numValuesPerNode,
CancellationToken ct)
{
+ var historyEpoch = Volatile.Read(ref _historyContinuationEpoch);
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
try
{
@@ -7381,7 +7393,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (inboundCp is { Length: > 0 })
{
var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray());
- if (state is null || state.Kind != HistoryCursorKind.Modified || state.Tagname != tagname)
+ if (state is null || !HistoryContinuationMatches(state, handle.NodeId, historyEpoch) || state.Kind != HistoryCursorKind.Modified || state.Tagname != tagname)
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
@@ -7411,7 +7423,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (page.HasMore && numValuesPerNode > 0 && page.Values.Count > 0)
{
var last = page.Values[^1];
- outboundCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
+ outboundCp = SaveHistoryContinuation(session, handle.NodeId, historyEpoch, new HistoryContinuationState(
tagname, last.SourceTimeUtc, endUtc, 0, numValuesPerNode,
HistoryCursorKind.Modified, last.ModifiedId, startUtc));
}
@@ -7489,6 +7501,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
+ private byte[]? SaveHistoryContinuation(ISession? session, NodeId nodeId, long epoch, HistoryContinuationState state)
+ {
+ if (epoch != Volatile.Read(ref _historyContinuationEpoch)) return null;
+ return _historyContinuationStore.Save(session, state with
+ {
+ BoundNodeKey = nodeId.ToString(), AddressSpaceEpoch = epoch,
+ });
+ }
+
+ private static bool HistoryContinuationMatches(HistoryContinuationState state, NodeId nodeId, long epoch) =>
+ state.BoundNodeKey == nodeId.ToString() && state.AddressSpaceEpoch == epoch;
+
/// Transport failures are retryable; unsupported operations retain their distinct status.
private static StatusCode HistoryReadFailureStatus(Exception error)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
index c3ff4f08..507e9ec3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
@@ -1,6 +1,8 @@
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
+using Opc.Ua.Identity;
+using Opc.Ua.Server.Hosting;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
@@ -25,6 +27,21 @@ public sealed class OtOpcUaSdkServer : StandardServer
public OtOpcUaSdkServer(ILogger? nodeManagerLogger = null)
: base(DefaultTelemetry.Create(_ => { })) => _nodeManagerLogger = nodeManagerLogger;
+ internal void ConfigureIdentityAuthenticators(IEnumerable authenticators) =>
+ AddPreStartupTask(new IdentityStartupTask(authenticators.ToArray()));
+
+ private sealed class IdentityStartupTask(IReadOnlyList authenticators) : IServerPreStartupTask
+ {
+ public ValueTask OnServerStartingAsync(IServerContext server, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (server is not IServerInternal internalServer)
+ throw new InvalidOperationException("Application identity registry unavailable during server startup.");
+ foreach (var authenticator in authenticators) internalServer.IdentityRegistry.Register(authenticator);
+ return ValueTask.CompletedTask;
+ }
+ }
+
private readonly AsyncLocal _activationPeer = new();
/// The transport-observed peer of the current activation request, when available.
@@ -40,16 +57,23 @@ public sealed class OtOpcUaSdkServer : StandardServer
ExtensionObject userIdentityToken,
SignatureData? userTokenSignature,
RequestLifetime requestLifetime)
+ {
+ return await WithActivationPeerAddressAsync(secureChannelContext.PeerAddress,
+ () => base.ActivateSessionAsync(secureChannelContext, requestHeader, clientSignature,
+ clientSoftwareCertificates, localeIds, userIdentityToken, userTokenSignature, requestLifetime))
+ .ConfigureAwait(false);
+ }
+
+ internal async ValueTask WithActivationPeerAddressAsync(
+ System.Net.IPAddress? peer, Func> activation)
{
// AuthenticationContext does not carry the request peer in 2.0.1. Keep the public
// transport evidence in this activation's async flow, including reactivation on a new channel.
var previous = _activationPeer.Value;
- _activationPeer.Value = secureChannelContext.PeerAddress;
+ _activationPeer.Value = peer;
try
{
- return await base.ActivateSessionAsync(secureChannelContext, requestHeader, clientSignature,
- clientSoftwareCertificates, localeIds, userIdentityToken, userTokenSignature, requestLifetime)
- .ConfigureAwait(false);
+ return await activation().ConfigureAwait(false);
}
finally
{
@@ -267,6 +291,7 @@ public sealed class OtOpcUaSdkServer : StandardServer
defaults.PercentDataGood = ServerAggregateConfiguration.PercentDataGood;
defaults.UseSlopedExtrapolation = ServerAggregateConfiguration.UseSlopedExtrapolation;
manager.SetDefaultConfiguration(defaults);
+ await Compatibility.Aggregates.CompatibilityAggregateFactory.RegisterAsync(manager, cancellationToken).ConfigureAwait(false);
_otOpcUaNodeManager?.SyncAdvertisedAggregates();
return manager;
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs
index f6542ec7..96de455e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs
@@ -1,18 +1,18 @@
using Opc.Ua;
+using Opc.Ua.Server;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
///
/// A that additionally carries the LDAP-resolved roles
-/// () for the authenticated session. The SDK stores the
-/// instance we assign to ImpersonateEventArgs.Identity by reference onto
-/// Session.Identity/Session.EffectiveIdentity (no wrapping or copying), so a method
+/// () for the authenticated session. The SDK may augment identities with mandatory and dynamically mapped roles. This subtype
+/// preserves the application identity when those roles are added, so a method
/// handler can later recover the roles via
/// (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity — the
/// seam the inbound alarm ack/shelve AlarmAck gate (T18) reads. This type only carries the
/// roles; it makes no auth decision.
///
-public sealed class RoleCarryingUserIdentity : UserIdentity
+public sealed class RoleCarryingUserIdentity : RoleBasedIdentity
{
///
/// Initializes a new instance, building the base identity from the same UserName token the
@@ -35,7 +35,14 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
/// The public id of the key that authenticated, or null for anonymous.
public RoleCarryingUserIdentity(
UserIdentityToken token, IReadOnlyList roles, string principal, string? apiKeyId)
- : base(token)
+ : this(new UserIdentity(token), roles, principal, apiKeyId, [], new NamespaceTable())
+ {
+ }
+
+ private RoleCarryingUserIdentity(
+ IUserIdentity identity, IReadOnlyList roles, string principal, string? apiKeyId,
+ IEnumerable sdkRoles, NamespaceTable namespaces)
+ : base(identity, sdkRoles, namespaces)
{
ArgumentNullException.ThrowIfNull(roles);
// Freeze to a defensive copy so a caller mutating the source list can never retroactively
@@ -46,8 +53,13 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
ApiKeyId = apiKeyId;
}
+ ///
+ public override RoleBasedIdentity WithAdditionalRoles(IEnumerable additionalRoles, NamespaceTable namespaces) =>
+ new RoleCarryingUserIdentity(InnerIdentity, Roles, Principal, ApiKeyId,
+ base.Roles.Concat(additionalRoles), namespaces);
+
/// The roles the authenticator granted this session, used by downstream permission gates.
- public IReadOnlyList Roles { get; }
+ public new IReadOnlyList Roles { get; }
///
/// The ACL principal this session authenticates as — the name NodeAcl rows key on, and what