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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user