perf(acl): dedup subscription revalidation by (session, node)

This commit is contained in:
Joseph Doherty
2026-08-16 04:18:03 -04:00
parent 50035b36bb
commit fbe8aaad9d
2 changed files with 326 additions and 3 deletions
@@ -3127,6 +3127,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
{
base.OnMonitoredItemCreated(context, handle, monitoredItem);
TrackLiveMonitoredItem(handle, monitoredItem);
}
/// <summary>Starts tracking <paramref name="monitoredItem"/> for revalidation, if we own its node.</summary>
/// <param name="handle">The handle the item was created against.</param>
/// <param name="monitoredItem">The item.</param>
/// <remarks>
/// Split out of <see cref="OnMonitoredItemCreated"/> so tests can register an item without a client
/// session; the SDK reaches it only through that override.
/// </remarks>
internal void TrackLiveMonitoredItem(NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem)
{
if (OwnsNamespace(handle.NodeId)) _liveItems[monitoredItem] = new LiveItem(handle);
}
@@ -3193,6 +3205,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// subscription, and inventing one would leave the client's own bookkeeping describing items the
/// server had silently discarded.
/// </para>
/// <para>
/// <b>One verdict per (session, node), not per item.</b> A session commonly holds several items
/// on the same node — one per attribute, or one per downstream consumer sharing a session — and
/// they all ask the identical question. The pass memoizes the verdict locally so N items on a
/// node cost one <see cref="Security.DataPlaneAuthorizer.IsAllowedCached"/> call rather than N.
/// The authorizer's own decision cache does not remove this work: it memoizes the grant walk,
/// while the per-call key revocation/expiry check in front of it is deliberately outside that
/// cache and runs every time.
/// </para>
/// <para>
/// The per-item <c>lock (Lock)</c> takes are deliberately left alone. Batching them under one
/// hold was considered and rejected: it would stretch the SDK lock across the
/// <c>QueueValue</c> calls between them, and the attach/detach is what actually needs the lock.
/// </para>
/// </remarks>
public void RevalidateMonitoredItems()
{
@@ -3202,6 +3228,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var stoodDown = 0;
var restored = 0;
// Pass-local, so single-threaded by construction. A concurrent deployment can advance the
// generation mid-pass and leave later items decided on the earlier one — exactly as the
// un-memoized walk already could, and the change that advanced it runs its own pass.
var verdicts = new Dictionary<(string Session, AddressSpaceRealm Realm, string Node), bool>();
foreach (var (item, tracked) in _liveItems)
{
// The item carries the identity of the session that created it, which is what must be
@@ -3209,9 +3240,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (item is not IMonitoredItem live) continue;
var nodeId = tracked.Handle.NodeId;
var permitted = _authorizer!.IsAllowedCached(
live.EffectiveIdentity as RoleCarryingUserIdentity,
OpcUaOperation.CreateMonitoredItems, nodeId, RealmOf(nodeId));
var permitted = IsSubscriptionStillPermitted(
live.EffectiveIdentity as RoleCarryingUserIdentity, nodeId, verdicts);
if (permitted)
{
@@ -3244,6 +3274,57 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// The per-item verdict for <see cref="RevalidateMonitoredItems"/>, memoized across the pass.
/// </summary>
/// <param name="identity">The identity of the session that created the item.</param>
/// <param name="nodeId">The node the item monitors.</param>
/// <param name="verdicts">The pass-local memo, keyed by session, realm and node identifier.</param>
/// <returns><see langword="true"/> when the item may keep receiving values.</returns>
/// <remarks>
/// <para>
/// The session key is <c>ApiKeyId ?? Principal</c> — the same derivation
/// <see cref="Security.DataPlaneAuthorizer"/> uses for its own decision cache, so this memo is
/// never coarser than the cache it sits in front of. The realm is part of the key for the same
/// reason it is part of the authorizer's: the raw and UNS namespaces have independent string
/// identifier spaces, so an identifier alone does not name a node.
/// </para>
/// <para>
/// Anything the key cannot be built from — no role-carrying identity, no principal, a non-string
/// node identifier — falls through to the un-memoized call and keeps its existing verdict. Those
/// are the cases the authorizer denies anyway; memoizing them would only make a deny cheaper.
/// </para>
/// <para>
/// This memo does cover the key expiry check that the authorizer's own cache excludes, but only
/// for the duration of one pass — and a subscription is re-checked at a deployment, never on a
/// timer, so an expiry that lands inside a single pass was already going to be caught by the
/// next deployment rather than by this one.
/// </para>
/// </remarks>
private bool IsSubscriptionStillPermitted(
RoleCarryingUserIdentity? identity,
NodeId nodeId,
Dictionary<(string Session, AddressSpaceRealm Realm, string Node), bool> verdicts)
{
var realm = RealmOf(nodeId);
if (identity is null
|| (identity.ApiKeyId ?? identity.Principal) is not { Length: > 0 } sessionKey
|| nodeId.Identifier is not string identifier)
{
return _authorizer!.IsAllowedCached(
identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm);
}
var key = (sessionKey, realm, identifier);
if (verdicts.TryGetValue(key, out var memoized)) return memoized;
var permitted = _authorizer!.IsAllowedCached(
identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm);
verdicts[key] = permitted;
return permitted;
}
/// <summary>Emits the one operator-facing summary line per gated request.</summary>
/// <param name="operation">The service being filtered.</param>
/// <param name="identity">The calling session's identity.</param>