fix: preserve SDK role identity and continuation authority lifecycle

This commit is contained in:
Joseph Doherty
2026-11-10 10:29:23 -05:00
parent a5bed28765
commit b71620145b
5 changed files with 105 additions and 43 deletions
@@ -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)
{
/// <summary>The walk-back hint this cursor carries.</summary>
public ReverseWalkHint WalkHint => new(SegmentWidthTicks, EmptyFromTicks, EmptyToTicks);
@@ -202,6 +202,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// <returns>A task that represents the asynchronous operation.</returns>
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
/// <returns>A task that represents the asynchronous operation.</returns>
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;
}
}
@@ -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();
/// <summary>
@@ -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<ServiceResult> 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;
/// <summary>Transport failures are retryable; unsupported operations retain their distinct status.</summary>
private static StatusCode HistoryReadFailureStatus(Exception error)
{
@@ -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<OtOpcUaNodeManager>? nodeManagerLogger = null)
: base(DefaultTelemetry.Create(_ => { })) => _nodeManagerLogger = nodeManagerLogger;
internal void ConfigureIdentityAuthenticators(IEnumerable<IUserTokenAuthenticator> authenticators) =>
AddPreStartupTask(new IdentityStartupTask(authenticators.ToArray()));
private sealed class IdentityStartupTask(IReadOnlyList<IUserTokenAuthenticator> 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<System.Net.IPAddress?> _activationPeer = new();
/// <summary>The transport-observed peer of the current activation request, when available.</summary>
@@ -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<ActivateSessionResponse> WithActivationPeerAddressAsync(
System.Net.IPAddress? peer, Func<ValueTask<ActivateSessionResponse>> 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;
}
@@ -1,18 +1,18 @@
using Opc.Ua;
using Opc.Ua.Server;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
/// <summary>
/// A <see cref="UserIdentity"/> that additionally carries the LDAP-resolved roles
/// (<see cref="OpcUaUserAuthResult.Roles"/>) for the authenticated session. The SDK stores the
/// instance we assign to <c>ImpersonateEventArgs.Identity</c> by reference onto
/// <c>Session.Identity</c>/<c>Session.EffectiveIdentity</c> (no wrapping or copying), so a method
/// (<see cref="OpcUaUserAuthResult.Roles"/>) 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
/// <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c> — the
/// seam the inbound alarm ack/shelve <c>AlarmAck</c> gate (T18) reads. This type only carries the
/// roles; it makes no auth decision.
/// </summary>
public sealed class RoleCarryingUserIdentity : UserIdentity
public sealed class RoleCarryingUserIdentity : RoleBasedIdentity
{
/// <summary>
/// Initializes a new instance, building the base identity from the same UserName token the
@@ -35,7 +35,14 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
/// <param name="apiKeyId">The public id of the key that authenticated, or null for anonymous.</param>
public RoleCarryingUserIdentity(
UserIdentityToken token, IReadOnlyList<string> roles, string principal, string? apiKeyId)
: base(token)
: this(new UserIdentity(token), roles, principal, apiKeyId, [], new NamespaceTable())
{
}
private RoleCarryingUserIdentity(
IUserIdentity identity, IReadOnlyList<string> roles, string principal, string? apiKeyId,
IEnumerable<Role> 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;
}
/// <inheritdoc />
public override RoleBasedIdentity WithAdditionalRoles(IEnumerable<Role> additionalRoles, NamespaceTable namespaces) =>
new RoleCarryingUserIdentity(InnerIdentity, Roles, Principal, ApiKeyId,
base.Roles.Concat(additionalRoles), namespaces);
/// <summary>The roles the authenticator granted this session, used by downstream permission gates.</summary>
public IReadOnlyList<string> Roles { get; }
public new IReadOnlyList<string> Roles { get; }
/// <summary>
/// The ACL principal this session authenticates as — the name <c>NodeAcl</c> rows key on, and what