perf(acl): authorizer hot path — cache lookup before scope build, lazy deny reasons

This commit is contained in:
Joseph Doherty
2026-08-16 04:19:47 -04:00
parent 7320586512
commit d8a17f2e34
3 changed files with 338 additions and 78 deletions
@@ -54,6 +54,25 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
/// </remarks>
public sealed class DataPlaneAuthorizer
{
/// <summary>
/// The single veto every denial returns. Logged, never explained to the client: the client gets an
/// undifferentiated <c>BadUserAccessDenied</c> so the server cannot be used to probe which nodes
/// exist or which grants a principal holds, and the operator debugging a denial reads the reason in
/// the log. Same posture as the authenticator's <c>DenyMessage</c>.
/// </summary>
/// <remarks>
/// <b>Shared rather than per-call, because <see cref="ServiceResult"/> is immutable to us.</b>
/// Verified by reflection against the pinned Opc.Ua.Core 1.5.378.106: every one of its properties
/// (<c>StatusCode</c>, <c>SymbolicId</c>, <c>NamespaceUri</c>, <c>LocalizedText</c>,
/// <c>AdditionalInfo</c>, <c>InnerResult</c>) has a <b>private</b> setter, and its only non-getter
/// method — <c>Append(StringBuilder)</c> — formats rather than mutates, so no code outside the type
/// can change an instance. The SDK itself publishes <c>ServiceResult.Good</c> and
/// <c>ServiceResult.Bad</c> as shared <c>static readonly</c> singletons, which is its own
/// endorsement of the pattern. Every call site here treats the result as an opaque value — it is
/// stored into an SDK errors array or returned — and none compares by reference.
/// </remarks>
private static readonly ServiceResult DeniedResult = new(StatusCodes.BadUserAccessDenied);
private readonly IDataPlaneAuthoritySource _authority;
private readonly IPermissionEvaluator _evaluator;
private readonly ILogger _log;
@@ -173,6 +192,23 @@ public sealed class DataPlaneAuthorizer
AddressSpaceRealm realm)
=> Evaluate(identity, operation, nodeId, realm, cached: false);
/// <summary>
/// The decision itself. Ordered so the cached seams reach their answer having allocated nothing:
/// every check that must run per call comes first, the memoized verdict is consulted next, and the
/// scope build and grant walk happen only on a miss.
/// </summary>
/// <remarks>
/// The ordering is a correctness constraint before it is a performance one — see the key-revocation
/// comment inline, and <c>Key_revocation_is_rechecked_on_a_cache_hit</c> /
/// <c>NodeManagerStage6EnforcementTests.An_expired_key_is_denied_even_though_its_allow_is_cached</c>,
/// which fail if the cache lookup is moved above it.
/// </remarks>
/// <param name="identity">The role-carrying identity off the SDK context.</param>
/// <param name="operation">The operation being attempted.</param>
/// <param name="nodeId">The target node.</param>
/// <param name="realm">The namespace the node was resolved in.</param>
/// <param name="cached">Whether the grant walk may be memoized (the request-rate seams).</param>
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
private ServiceResult? Evaluate(
RoleCarryingUserIdentity? identity,
OpcUaOperation operation,
@@ -203,33 +239,43 @@ public sealed class DataPlaneAuthorizer
// since then may have disabled or expired it — and on a driver-only node the artifact is the ONLY
// revocation channel. Without this a revoked key keeps its open session's authority indefinitely,
// which on a machine-to-machine data plane means until the client chooses to reconnect.
// ⚠️ Deliberately OUTSIDE the decision cache, and it must stay there. Grants and principals are
// fixed for a generation, so memoizing the grant walk is sound; a key's expiry passes with the
// CLOCK, so a cached allow would keep an expired key working until the next deploy.
// ⚠️ Deliberately OUTSIDE the decision cache, and it must stay ABOVE the lookup below. Grants and
// principals are fixed for a generation, so memoizing the grant walk is sound; a key's expiry
// passes with the CLOCK, so a cached allow consulted first would keep an expired key working until
// the next deploy — the generation never moves, so nothing would drop the entry.
var now = _time.GetUtcNow().UtcDateTime;
var sessionKey = identity.ApiKeyId ?? identity.Principal;
if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null)
{
return Deny(operation, nodeId, identity.Principal,
$"key {keyId} is no longer enabled or has expired in generation {snapshot.GenerationId}", cached);
}
return DenyRevokedKey(operation, nodeId, identity.Principal, keyId, snapshot.GenerationId, cached);
if (!TryBuildScope(nodeId, realm, snapshot.ClusterId, out var scope))
// The s= identifier, read once: it is both the cache key and the raw material the scope is split
// from, and pulling it up here absorbs what used to be TryBuildScope's own guard. A NodeId that
// carries no string addresses no ACL scope at all, so it can never be answered from the cache.
if (nodeId?.Identifier is not string identifier || identifier.Length == 0)
{
return Deny(operation, nodeId, identity.Principal,
"the node id carries no string identifier, so it addresses no ACL scope", cached);
}
var allowed = cached
? Decisions.GetOrAdd(
snapshot.GenerationId, sessionKey, operation, realm, (string)nodeId.Identifier,
() => Walk(snapshot, identity, operation, scope, now))
: Walk(snapshot, identity, operation, scope, now);
var sessionKey = identity.ApiKeyId ?? identity.Principal;
if (allowed) return null;
if (cached &&
Decisions.TryGet(snapshot.GenerationId, sessionKey, operation, realm, identifier, out var memoized))
{
// The whole point of the reorder: a hit returns HERE, having built no scope, no closure and no
// UserAuthorizationState. One Browse of a device folder asks this once per child.
return memoized
? null
: DenyNoGrant(operation, nodeId, identity.Principal, snapshot.GenerationId, cached);
}
return Deny(operation, nodeId, identity.Principal,
$"no grant in generation {snapshot.GenerationId} confers {operation} here", cached);
var scope = BuildScope(identifier, realm, snapshot.ClusterId);
var allowed = Walk(snapshot, identity, operation, scope, now);
if (cached) Decisions.Set(snapshot.GenerationId, sessionKey, operation, realm, identifier, allowed);
return allowed
? null
: DenyNoGrant(operation, nodeId, identity.Principal, snapshot.GenerationId, cached);
}
/// <summary>The grant walk itself — the only part of a decision that is safe to memoize.</summary>
@@ -274,7 +320,7 @@ public sealed class DataPlaneAuthorizer
}
/// <summary>
/// Projects an SDK <see cref="NodeId"/> onto the scope the permission trie is keyed by.
/// Projects a node's <c>s=</c> identifier onto the scope the permission trie is keyed by.
/// </summary>
/// <remarks>
/// The <c>s=</c> identifier IS the path in both realms — a RawPath
@@ -286,41 +332,108 @@ public sealed class DataPlaneAuthorizer
/// Segments are compared ordinally, matching <c>RawPaths.Comparer</c> and OPC UA NodeId
/// identity — <c>Speed</c> and <c>speed</c> are different nodes and must be different scopes.
/// </para>
/// <para>
/// Takes the identifier rather than the <see cref="NodeId"/>, and cannot fail: the "not a
/// string" guard this used to carry moved up into <c>Evaluate</c>, where it has to run before
/// the cache lookup anyway — the identifier is the cache key.
/// </para>
/// </remarks>
/// <param name="nodeId">The target node.</param>
/// <param name="identifier">The node's non-empty <c>s=</c> identifier.</param>
/// <param name="realm">The namespace the node was resolved in.</param>
/// <param name="clusterId">The node's cluster.</param>
/// <param name="scope">The resolved scope.</param>
/// <returns><see langword="true"/> when a scope could be built.</returns>
private static bool TryBuildScope(
NodeId nodeId, AddressSpaceRealm realm, string clusterId, out NodeScope scope)
/// <returns>The scope to evaluate against.</returns>
private static NodeScope BuildScope(string identifier, AddressSpaceRealm realm, string clusterId) => new()
{
scope = null!;
if (nodeId?.Identifier is not string identifier || identifier.Length == 0) return false;
scope = new NodeScope
{
ClusterId = clusterId,
Kind = realm == AddressSpaceRealm.Raw ? NodeHierarchyKind.Raw : NodeHierarchyKind.Uns,
Segments = identifier.Split('/', StringSplitOptions.RemoveEmptyEntries),
};
return true;
}
ClusterId = clusterId,
Kind = realm == AddressSpaceRealm.Raw ? NodeHierarchyKind.Raw : NodeHierarchyKind.Uns,
Segments = identifier.Split('/', StringSplitOptions.RemoveEmptyEntries),
};
/// <summary>Denies with a constant reason — nothing to build, so nothing to defer.</summary>
/// <param name="operation">The operation being attempted.</param>
/// <param name="nodeId">The target node.</param>
/// <param name="principal">The calling principal, when one resolved.</param>
/// <param name="reason">The operator-facing explanation.</param>
/// <param name="cached">Whether this came from a request-rate seam (which logs at Debug).</param>
/// <returns>The veto to return to the SDK.</returns>
private ServiceResult Deny(
OpcUaOperation operation, NodeId? nodeId, string? principal, string reason, bool cached = false)
{
// Logged, never returned. The client gets an undifferentiated BadUserAccessDenied so the server
// cannot be used to probe which nodes exist or which grants a principal holds; the operator
// debugging a denial reads the reason here. Same posture as the authenticator's DenyMessage.
//
// The hot seams log at Debug because a single Browse of an ungranted folder would otherwise emit
// one Information line per child. Their operator-facing signal is the per-request summary the node
// manager logs, which says how many references were filtered and for whom.
_log.Log(
cached ? LogLevel.Debug : LogLevel.Information,
var level = LevelFor(cached);
if (_log.IsEnabled(level)) LogDenial(level, operation, nodeId, principal, reason);
return DeniedResult;
}
/// <summary>
/// Denies because the calling key is disabled or expired, composing the reason only if it will be
/// logged.
/// </summary>
/// <param name="operation">The operation being attempted.</param>
/// <param name="nodeId">The target node.</param>
/// <param name="principal">The calling principal.</param>
/// <param name="keyId">The key that is no longer usable.</param>
/// <param name="generation">The authorization generation the check ran under.</param>
/// <param name="cached">Whether this came from a request-rate seam (which logs at Debug).</param>
/// <returns>The veto to return to the SDK.</returns>
private ServiceResult DenyRevokedKey(
OpcUaOperation operation, NodeId? nodeId, string? principal, string keyId, long generation, bool cached)
{
var level = LevelFor(cached);
if (_log.IsEnabled(level))
{
LogDenial(level, operation, nodeId, principal,
$"key {keyId} is no longer enabled or has expired in generation {generation}");
}
return DeniedResult;
}
/// <summary>
/// Denies because no grant conferred the operation — the hot denial, hit once per child of an
/// ungranted folder, so the reason string is built only when Debug is actually on.
/// </summary>
/// <param name="operation">The operation being attempted.</param>
/// <param name="nodeId">The target node.</param>
/// <param name="principal">The calling principal.</param>
/// <param name="generation">The authorization generation the walk ran under.</param>
/// <param name="cached">Whether this came from a request-rate seam (which logs at Debug).</param>
/// <returns>The veto to return to the SDK.</returns>
private ServiceResult DenyNoGrant(
OpcUaOperation operation, NodeId? nodeId, string? principal, long generation, bool cached)
{
var level = LevelFor(cached);
if (_log.IsEnabled(level))
{
LogDenial(level, operation, nodeId, principal,
$"no grant in generation {generation} confers {operation} here");
}
return DeniedResult;
}
/// <summary>
/// The hot seams log at Debug because a single Browse of an ungranted folder would otherwise emit
/// one Information line per child. Their operator-facing signal is the per-request summary
/// <see cref="LogFiltered"/> emits, which says how many references were filtered and for whom.
/// </summary>
/// <param name="cached">Whether the denial came from a request-rate seam.</param>
/// <returns>The level to log the denial at.</returns>
private static LogLevel LevelFor(bool cached) => cached ? LogLevel.Debug : LogLevel.Information;
/// <summary>
/// Emits the denial detail. Callers guard with <see cref="ILogger.IsEnabled"/> first — not for the
/// message template, which costs nothing, but because every caller's <paramref name="reason"/> is a
/// composed string and two of them interpolate on the request-rate path.
/// </summary>
/// <param name="level">The level chosen by <see cref="LevelFor"/>.</param>
/// <param name="operation">The operation being attempted.</param>
/// <param name="nodeId">The target node.</param>
/// <param name="principal">The calling principal, when one resolved.</param>
/// <param name="reason">The operator-facing explanation.</param>
private void LogDenial(
LogLevel level, OpcUaOperation operation, NodeId? nodeId, string? principal, string reason)
=> _log.Log(
level,
"AclGate: denied {Operation} on {NodeId} for principal {Principal} — {Reason}",
operation, nodeId, principal ?? "(none)", reason);
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
}
@@ -94,9 +94,101 @@ public sealed class SessionAclDecisionCache
}
}
/// <summary>
/// Looks a decision up without allocating anything — the hot half, and the reason the store half is
/// a separate method rather than a <see cref="Func{TResult}"/> parameter.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why split from <see cref="GetOrAdd"/>.</b> A closure-taking API forces its caller to
/// build the closure — and everything the closure captures — <i>before</i> it knows whether the
/// answer is already memoized, so the allocation the cache exists to avoid is paid on the hit
/// path too. Read and Browse ask this question once per node, which is precisely where paying
/// for a walk that never runs is worth removing. <see cref="GetOrAdd"/> remains, delegating
/// here, for callers that genuinely have nothing cheaper to offer.
/// </para>
/// <para>
/// The generation is checked (and a stale cache dropped) here rather than in
/// <see cref="Set"/> alone, so a lookup landing first after a deploy cannot read the previous
/// generation's verdicts.
/// </para>
/// </remarks>
/// <param name="generation">The authorization generation the decision is being taken under.</param>
/// <param name="sessionKey">Stable identity of the calling session — its key id, else its principal.</param>
/// <param name="operation">The operation being attempted.</param>
/// <param name="realm">The realm the node was resolved in.</param>
/// <param name="nodeIdentifier">The node's <c>s=</c> identifier.</param>
/// <param name="decision">The memoized verdict, when one exists.</param>
/// <returns><see langword="true"/> when a decision was memoized for this key.</returns>
public bool TryGet(
long generation,
string sessionKey,
OpcUaOperation operation,
AddressSpaceRealm realm,
string nodeIdentifier,
out bool decision)
{
InvalidateIfStale(generation);
decision = false;
// TryGetValue rather than GetOrAdd: a session that only ever hits must not mint a shard, and a
// session whose shard was just cleared must not resurrect one until it actually stores something.
return _shards.TryGetValue(sessionKey, out var shard)
&& shard.Entries.TryGetValue(new ShardKey(operation, realm, nodeIdentifier), out decision);
}
/// <summary>
/// Memoizes a decision, subject to the two entry bounds and the overtaken-generation drop rule.
/// </summary>
/// <param name="generation">The generation the decision was taken under — <b>not</b> re-read from the
/// caller, so a deploy landing mid-walk is detected here.</param>
/// <param name="sessionKey">Stable identity of the calling session — its key id, else its principal.</param>
/// <param name="operation">The operation that was attempted.</param>
/// <param name="realm">The realm the node was resolved in.</param>
/// <param name="nodeIdentifier">The node's <c>s=</c> identifier.</param>
/// <param name="decision">The verdict the grant walk produced.</param>
public void Set(
long generation,
string sessionKey,
OpcUaOperation operation,
AddressSpaceRealm realm,
string nodeIdentifier,
bool decision)
{
// Re-read the generation AFTER the walk. A deploy landing mid-walk means the verdict may have been
// taken against either generation, and storing it under the new one would pin a decision the new
// trie never made. Dropping it costs one recomputation on the next call.
if (Generation != generation) return;
var shard = _shards.GetOrAdd(sessionKey, static _ => new Shard());
if (Volatile.Read(ref _globalCount) >= MaxEntries)
{
// The outer bound: no single session is over its own ceiling, so there is nothing narrower to
// drop. Re-acquire the shard — ClearAll drops the shard objects themselves.
ClearAll();
shard = _shards.GetOrAdd(sessionKey, static _ => new Shard());
}
else if (Volatile.Read(ref shard.ApproxCount) >= MaxEntriesPerSession)
{
shard.Entries.Clear();
Interlocked.Add(ref _globalCount, -Interlocked.Exchange(ref shard.ApproxCount, 0));
}
// TryAdd, not an indexer assignment: only a genuine insert may move the counters, or a racing pair
// of walks over the same key would inflate them and pull the ceilings in for good.
if (shard.Entries.TryAdd(new ShardKey(operation, realm, nodeIdentifier), decision))
{
Interlocked.Increment(ref shard.ApproxCount);
Interlocked.Increment(ref _globalCount);
}
}
/// <summary>
/// Returns the memoized decision for this key, computing and storing it when absent or when the
/// generation has moved on.
/// generation has moved on. A convenience over <see cref="TryGet"/> + <see cref="Set"/> for callers
/// that have no cheaper way to express the walk than a closure.
/// </summary>
/// <param name="generation">The authorization generation the decision is being taken under.</param>
/// <param name="sessionKey">Stable identity of the calling session — its key id, else its principal.</param>
@@ -115,40 +207,10 @@ public sealed class SessionAclDecisionCache
{
ArgumentNullException.ThrowIfNull(compute);
InvalidateIfStale(generation);
var shard = _shards.GetOrAdd(sessionKey, static _ => new Shard());
var key = new ShardKey(operation, realm, nodeIdentifier);
if (shard.Entries.TryGetValue(key, out var cached)) return cached;
if (TryGet(generation, sessionKey, operation, realm, nodeIdentifier, out var cached)) return cached;
var decided = compute();
// Re-read the generation AFTER computing. A deploy landing mid-walk means the verdict may have been
// taken against either generation, and storing it under the new one would pin a decision the new
// trie never made. Dropping it costs one recomputation on the next call.
if (Generation != generation) return decided;
if (Volatile.Read(ref _globalCount) >= MaxEntries)
{
// The outer bound: no single session is over its own ceiling, so there is nothing narrower to
// drop. Re-acquire the shard — ClearAll drops the shard objects themselves.
ClearAll();
shard = _shards.GetOrAdd(sessionKey, static _ => new Shard());
}
else if (Volatile.Read(ref shard.ApproxCount) >= MaxEntriesPerSession)
{
shard.Entries.Clear();
Interlocked.Add(ref _globalCount, -Interlocked.Exchange(ref shard.ApproxCount, 0));
}
// TryAdd, not an indexer assignment: only a genuine insert may move the counters, or a racing pair
// of walks over the same key would inflate them and pull the ceilings in for good.
if (shard.Entries.TryAdd(key, decided))
{
Interlocked.Increment(ref shard.ApproxCount);
Interlocked.Increment(ref _globalCount);
}
Set(generation, sessionKey, operation, realm, nodeIdentifier, decided);
return decided;
}
@@ -320,6 +320,66 @@ public sealed class DataPlaneAuthorizerTests
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
}
// ───────────────────────────── the cached hot path ─────────────────────────────
/// <summary>
/// A second identical question is answered from the decision cache without re-walking the grants —
/// which on the reordered hot path also means without rebuilding the <see cref="NodeScope"/> the
/// walk would need, because the lookup happens before the scope is built at all.
/// </summary>
/// <remarks>
/// The evaluator count is the observable half: a hit that still built a scope would have nothing to
/// do with it, so the only way to see the short-circuit from outside is that the walk never ran. The
/// cache size pins that the second call was a hit rather than a second miss that happened to
/// overwrite the first entry.
/// </remarks>
[Fact]
public void A_cache_hit_does_not_rebuild_the_scope()
{
var gate = CountingGate(ClusterGrant(NodePermissions.Read), out var evaluator);
var node = RawNode("Plant/Modbus/dev1/Speed");
gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw).ShouldBeNull();
gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw).ShouldBeNull();
evaluator.Calls.ShouldBe(1);
gate.Decisions.Count.ShouldBe(1);
}
/// <summary>
/// <b>Pins the ordering the hot path must keep:</b> the key's usability is re-checked BEFORE the
/// decision cache is consulted, so a cached allow cannot outlive the key that earned it.
/// </summary>
/// <remarks>
/// Expiry rather than a disabling redeploy is the falsifiable form of this. A redeploy advances the
/// generation, which drops the cache wholesale — so a gate that consulted the cache first would
/// still deny, and the test would pass against the broken ordering. Expiry moves with the CLOCK and
/// leaves the generation (and therefore the memoized allow) exactly where it was, so the denial can
/// only have come from the key check running ahead of the lookup.
/// </remarks>
[Fact]
public void Key_revocation_is_rechecked_on_a_cache_hit()
{
var clock = new FakeTimeProvider(new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc));
var state = new DataPlaneAuthorityState();
var key = Key(enabled: true);
key.ExpiresUtc = new DateTime(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc);
state.Apply(Cluster, "rev-1", [key], [ClusterGrant(NodePermissions.Read)], Scopes());
var gate = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock);
var node = RawNode("Plant/Modbus/dev1/Speed");
gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw).ShouldBeNull();
gate.Decisions.Count.ShouldBe(1, "the allow was memoized, so the next call takes the hit path");
clock.Advance(TimeSpan.FromHours(2));
Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw));
gate.Decisions.Generation.ShouldBe(state.Current.GenerationId);
gate.Decisions.Count.ShouldBe(1, "the memoized allow is untouched — the denial came from the key check");
}
/// <summary>The flag is carried faithfully; the seams read it to decide whether to consult the gate at all.</summary>
[Theory]
[InlineData(true)]
@@ -349,6 +409,18 @@ public sealed class DataPlaneAuthorizerTests
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
}
/// <summary>
/// The same gate <see cref="Gate"/> builds, with the REAL evaluator wrapped in a call counter — the
/// chain under test stays end-to-end, and only the number of walks becomes observable.
/// </summary>
private static DataPlaneAuthorizer CountingGate(NodeAcl grant, out CountingEvaluator evaluator)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], Scopes());
evaluator = new CountingEvaluator(new TriePermissionEvaluator(state.Tries));
return new DataPlaneAuthorizer(state, evaluator, enabled: true, NullLogger.Instance);
}
private static RoleCarryingUserIdentity Identity() =>
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, KeyId);
@@ -381,6 +453,19 @@ public sealed class DataPlaneAuthorizerTests
return map;
}
/// <summary>Counts grant walks without changing any of their answers.</summary>
private sealed class CountingEvaluator(IPermissionEvaluator inner) : IPermissionEvaluator
{
public int Calls { get; private set; }
public AuthorizationDecision Authorize(
UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
{
Calls++;
return inner.Authorize(session, operation, scope);
}
}
/// <summary>Minimal controllable clock — the suite needs only "now" and "advance".</summary>
private sealed class FakeTimeProvider(DateTime utcNow) : TimeProvider
{