perf(acl): authorizer hot path — cache lookup before scope build, lazy deny reasons
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user