feat(security): enforce node ACLs on read, browse and subscribe (#520 stage 6)
Completes data-plane enforcement behind Security:Acl:Enabled. Read (every attribute, not just Value), Browse, TranslateBrowsePath, CreateMonitoredItems, SubscribeToEvents and SetMonitoringMode now consult scoped NodeAcl grants. With the flag off nothing changes. Four things went beyond the stage as written, each because leaving it out would have made the feature look like it worked: - Browse needed ancestor visibility. The trie inherits downward, and a client reaches a deep node by browsing the nodes above it, so without this every grant narrower than the cluster was authored, shipped and unreachable. It is a separate non-inheriting marker, NOT a synthesized Browse grant on each ancestor: the additive downward walk would carry such a grant from Plant onto Plant/Siemens/dev9 and every other sibling subtree. Pinned by a test that goes through the evaluator — probing the trie helper directly passes against the broken design, because a sibling is absent from the trie either way. - Revocation had to reach LIVE subscriptions. Every other operation re-asks per request; a subscription is authorized once and then streams, and streaming is how machine consumers actually read this server — so stopping only the re-asking operations would have made revocation close to cosmetic. IDataPlaneAuthoritySource gained a Changed event. A denied item is queued BadUserAccessDenied and then DETACHED from its MonitoredNode2 fan-out, not set MonitoringMode.Disabled. Disabling discards the item's queued notifications, so the client is silenced without ever being told why. The over-the-wire test caught this; the unit-level path reports the item as stood down either way. Detaching is also reversible, so a restored grant resumes the feed with no client action. - The decision cache holds the grant WALK, not the decision. Grants and the trie are fixed for a generation; a key's ExpiresUtc passes with the clock, so caching the whole decision would let an expired key keep working until the next deploy — the stage-5 open-session hole in a subtler form. - TranslateBrowsePath is gated, without which browse filtering is decorative: a client refused a browse asks TranslateBrowsePathsToNodeIds for the same path. Found en route: UserAuthorizationState's staleness ceiling couples two clocks. MembershipResolvedUtc is stamped from DataPlaneAuthorizer's TimeProvider while IsStale is evaluated against TriePermissionEvaluator's; they are the same instance in production and diverge the moment anything injects a test clock, at which point the evaluator denies EVERYTHING with a verdict indistinguishable from "no grant matched". Stage 5's claim that these predicates cannot fire was accidentally true; they are now disabled explicitly. Also fixed: ContinuationPoint.NodeToBrowse is the manager HANDLE, not a NodeState. The declared type is object, so the wrong cast compiled cleanly and silently yielded null — skipping the browse source gate entirely. Falsified: disabling the transit marking reddens exactly the three tests that assert transit works; implementing transit as an ancestor grant reddens the sibling-leak test; skipping the fan-out detach reddens the revocation test. Full solution suite green. The stage 8 live gate has NOT run yet. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -32,15 +32,24 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
/// session's principals out of band — the principal comes from the key, and keys and grants both
|
||||
/// ride the deployment artifact — so the only real invalidation signal is the generation stamp,
|
||||
/// which a per-call construction picks up for free. The cost is one small record per write, alarm
|
||||
/// command or HistoryRead, none of which are hot. <b>The publish path is hot, and it is stage 6's
|
||||
/// problem</b>: that seam needs a per-(session, node) decision cache, not this.
|
||||
/// command or HistoryRead, none of which are hot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A consequence worth stating: because the state is rebuilt each call against the current
|
||||
/// generation, <see cref="UserAuthorizationState.IsStale"/> and
|
||||
/// <see cref="UserAuthorizationState.NeedsRefresh"/> can never fire on this path. They are
|
||||
/// vestigial here rather than load-bearing, and a reader should not conclude from their presence
|
||||
/// that a freshness timer is doing anything.
|
||||
/// Stage 6's request-rate seams — Read, Browse, subscription creation — go through
|
||||
/// <see cref="AuthorizeCached"/> instead, which memoizes the grant walk in
|
||||
/// <see cref="Decisions"/>. That is a cache of the walk, <b>not</b> of the decision: the key's
|
||||
/// revocation and expiry are re-checked on every call regardless, because expiry moves with the
|
||||
/// clock rather than with a generation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="UserAuthorizationState.IsStale"/> and
|
||||
/// <see cref="UserAuthorizationState.NeedsRefresh"/> are vestigial here rather than load-bearing,
|
||||
/// and a reader should not conclude from their presence that a freshness timer is doing anything.
|
||||
/// They are switched off <b>explicitly</b> (<c>AuthCacheMaxStaleness = TimeSpan.MaxValue</c>) rather
|
||||
/// than left to never be reached — see the comment at that line. Relying on "the stamp is always
|
||||
/// fresh" was not safe: the stamp comes from this class's clock and the ceiling is checked against
|
||||
/// the evaluator's, so any divergence between the two denied every request with a verdict that
|
||||
/// looks exactly like "no grant matched".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DataPlaneAuthorizer
|
||||
@@ -80,6 +89,72 @@ public sealed class DataPlaneAuthorizer
|
||||
/// </summary>
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Memoized grant walks for the request-rate seams (#520 stage 6). See
|
||||
/// <see cref="SessionAclDecisionCache"/> for what it is and is not allowed to cache.
|
||||
/// </summary>
|
||||
public SessionAclDecisionCache Decisions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes <paramref name="operation"/> on <paramref name="nodeId"/> through the decision cache —
|
||||
/// the entry point for Read, Browse and subscription creation, which ask the same question many
|
||||
/// times per request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Identical in outcome to <see cref="Authorize"/>; it differs only in memoizing the grant walk and
|
||||
/// in logging denials at Debug rather than Information. The quieter log is deliberate: a browse of a
|
||||
/// large folder by an ungranted principal would otherwise emit one Information line per child. The
|
||||
/// per-request summary the node manager logs is the operator-facing signal; this is the detail behind
|
||||
/// it.
|
||||
/// </remarks>
|
||||
/// <param name="identity">The role-carrying identity off the SDK context, or null when the session carries none.</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>
|
||||
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
|
||||
public ServiceResult? AuthorizeCached(
|
||||
RoleCarryingUserIdentity? identity,
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm)
|
||||
=> Evaluate(identity, operation, nodeId, realm, cached: true);
|
||||
|
||||
/// <summary>
|
||||
/// Emits the one operator-facing line per gated request, summarising what a hot seam withheld.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This exists because the per-node denials behind it are logged at Debug: a Browse of a large
|
||||
/// folder by an ungranted principal would otherwise emit one Information line per child, and an
|
||||
/// operator would be reading a flood rather than a fact. It lives on the authorizer rather than the
|
||||
/// node manager purely because the node manager is a <c>CustomNodeManager2</c> and carries no
|
||||
/// <c>ILogger</c> — everything it logs itself goes through the SDK's obsolete static trace.
|
||||
/// </remarks>
|
||||
/// <param name="operation">The service being filtered, e.g. <c>Browse</c>.</param>
|
||||
/// <param name="principal">The calling principal, or null when the session resolved none.</param>
|
||||
/// <param name="withheld">How many nodes or references were withheld.</param>
|
||||
/// <param name="total">How many were considered.</param>
|
||||
public void LogFiltered(string operation, string? principal, int withheld, int total)
|
||||
{
|
||||
if (withheld == 0) return;
|
||||
|
||||
_log.LogInformation(
|
||||
"AclGate: {Operation} withheld {Withheld} of {Total} node(s) for principal {Principal}",
|
||||
operation, withheld, total, principal ?? "(none)");
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="identity"/> may perform <paramref name="operation"/>, as a bool.</summary>
|
||||
/// <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>
|
||||
/// <returns><see langword="true"/> when the operation is permitted.</returns>
|
||||
public bool IsAllowedCached(
|
||||
RoleCarryingUserIdentity? identity,
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm)
|
||||
=> AuthorizeCached(identity, operation, nodeId, realm) is null;
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes <paramref name="operation"/> on <paramref name="nodeId"/> for the session behind
|
||||
/// <paramref name="identity"/>.
|
||||
@@ -96,16 +171,24 @@ public sealed class DataPlaneAuthorizer
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm)
|
||||
=> Evaluate(identity, operation, nodeId, realm, cached: false);
|
||||
|
||||
private ServiceResult? Evaluate(
|
||||
RoleCarryingUserIdentity? identity,
|
||||
OpcUaOperation operation,
|
||||
NodeId nodeId,
|
||||
AddressSpaceRealm realm,
|
||||
bool cached)
|
||||
{
|
||||
if (identity is null)
|
||||
return Deny(operation, nodeId, principal: null, "the session carries no role-carrying identity");
|
||||
return Deny(operation, nodeId, principal: null, "the session carries no role-carrying identity", cached);
|
||||
|
||||
if (string.IsNullOrEmpty(identity.Principal))
|
||||
{
|
||||
// An authenticator that predates the principal model, or a code path that built the identity
|
||||
// through the legacy two-argument constructor. Guessing a principal here — from the display
|
||||
// name, say — would silently authorize as whatever the token's username happened to be.
|
||||
return Deny(operation, nodeId, principal: null, "the session resolved no ACL principal");
|
||||
return Deny(operation, nodeId, principal: null, "the session resolved no ACL principal", cached);
|
||||
}
|
||||
|
||||
var snapshot = _authority.Current;
|
||||
@@ -113,41 +196,81 @@ public sealed class DataPlaneAuthorizer
|
||||
if (string.IsNullOrWhiteSpace(snapshot.ClusterId))
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
"this node has no cluster identity, so no permission trie exists to evaluate against");
|
||||
"this node has no cluster identity, so no permission trie exists to evaluate against", cached);
|
||||
}
|
||||
|
||||
// Re-check revocation on every call. The key was verified at session activation, but a deployment
|
||||
// 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.
|
||||
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}");
|
||||
$"key {keyId} is no longer enabled or has expired in generation {snapshot.GenerationId}", cached);
|
||||
}
|
||||
|
||||
if (!TryBuildScope(nodeId, realm, snapshot.ClusterId, out var scope))
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
"the node id carries no string identifier, so it addresses no ACL scope");
|
||||
"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);
|
||||
|
||||
if (allowed) return null;
|
||||
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"no grant in generation {snapshot.GenerationId} confers {operation} here", cached);
|
||||
}
|
||||
|
||||
/// <summary>The grant walk itself — the only part of a decision that is safe to memoize.</summary>
|
||||
/// <param name="snapshot">The authority snapshot the decision is taken against.</param>
|
||||
/// <param name="identity">The calling identity.</param>
|
||||
/// <param name="operation">The operation being attempted.</param>
|
||||
/// <param name="scope">The resolved node scope.</param>
|
||||
/// <param name="now">The current time, for the authorization state's resolution stamp.</param>
|
||||
/// <returns><see langword="true"/> when a grant confers the operation.</returns>
|
||||
private bool Walk(
|
||||
DataPlaneAuthoritySnapshot snapshot,
|
||||
RoleCarryingUserIdentity identity,
|
||||
OpcUaOperation operation,
|
||||
NodeScope scope,
|
||||
DateTime now)
|
||||
{
|
||||
var session = new UserAuthorizationState
|
||||
{
|
||||
SessionId = identity.ApiKeyId ?? identity.Principal,
|
||||
ClusterId = snapshot.ClusterId,
|
||||
ClusterId = snapshot.ClusterId!,
|
||||
Principals = [identity.Principal],
|
||||
MembershipResolvedUtc = now,
|
||||
AuthGenerationId = snapshot.GenerationId,
|
||||
MembershipVersion = snapshot.GenerationId,
|
||||
|
||||
// ⚠️ Disables the LDAP-era freshness ceiling explicitly, rather than relying on it never
|
||||
// being reached. Two separate reasons, and the second is the one that bites:
|
||||
//
|
||||
// 1. It is meaningless here. The ceiling exists so a long-lived session re-resolves group
|
||||
// membership a directory changed out of band. Nothing changes out of band under API keys
|
||||
// — principals come from the key and both keys and grants ride the artifact — so a
|
||||
// "stale membership" is not a state this model can be in.
|
||||
// 2. It couples two clocks. MembershipResolvedUtc is stamped from THIS class's TimeProvider,
|
||||
// while IsStale is evaluated against the EVALUATOR's. They are the same instance in
|
||||
// production and diverge the moment anything injects a test clock — at which point the
|
||||
// evaluator silently denies everything with a verdict indistinguishable from "no grant".
|
||||
// That is how this was found: a fake clock two hours ahead made every decision NotGranted.
|
||||
AuthCacheMaxStaleness = TimeSpan.MaxValue,
|
||||
};
|
||||
|
||||
var decision = _evaluator.Authorize(session, operation, scope);
|
||||
if (decision.IsAllowed) return null;
|
||||
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
$"no grant in generation {snapshot.GenerationId} confers {operation} here");
|
||||
return _evaluator.Authorize(session, operation, scope).IsAllowed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -184,12 +307,18 @@ public sealed class DataPlaneAuthorizer
|
||||
return true;
|
||||
}
|
||||
|
||||
private ServiceResult Deny(OpcUaOperation operation, NodeId? nodeId, string? principal, string reason)
|
||||
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.
|
||||
_log.LogInformation(
|
||||
//
|
||||
// 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,
|
||||
"AclGate: denied {Operation} on {NodeId} for principal {Principal} — {Reason}",
|
||||
operation, nodeId, principal ?? "(none)", reason);
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Memoizes the <b>grant-walk</b> half of an ACL decision per <c>(session, node, operation)</c> for one
|
||||
/// authorization generation (#520 stage 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists.</b> Stage 5 gated write, alarm commands and HistoryRead — all of which are
|
||||
/// request-rate operations where rebuilding the decision each time costs nothing. Stage 6 gates
|
||||
/// Read, Browse and subscription creation, which are not: one Browse of a device folder asks the
|
||||
/// same question once per child, and a client that reads a thousand nodes asks it a thousand times.
|
||||
/// The trie walk is cheap individually and the repetition is what makes it worth avoiding.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Only the grant walk is cached — never the key check.</b> A cached verdict is valid because
|
||||
/// grants, principals and the trie are all fixed for the life of a generation, so nothing the walk
|
||||
/// reads can change without <see cref="Generation"/> advancing. That reasoning does <b>not</b>
|
||||
/// extend to the calling key's usability: a key's <c>ExpiresUtc</c> passes with the clock, not with
|
||||
/// a deployment, so a cached allow would keep an expired key working until the next deploy —
|
||||
/// re-introducing, in a subtler form, exactly the open-session hole stage 5 closed.
|
||||
/// <see cref="DataPlaneAuthorizer"/> therefore runs revocation and expiry on every call and consults
|
||||
/// this only afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Invalidation is wholesale, by generation.</b> Per-entry invalidation would need to know which
|
||||
/// nodes a changed grant covers, which is the trie walk run backwards; dropping everything on a
|
||||
/// deploy costs one repopulation and cannot be wrong. Deployments are rare relative to reads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionAclDecisionCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry ceiling, past which the cache clears rather than growing. Large enough that a realistic
|
||||
/// address space browsed by a handful of machine consumers never reaches it, small enough that a
|
||||
/// pathological client cannot use it to exhaust memory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Clearing beats evicting here: an LRU needs per-entry bookkeeping on the hot path to save a trie
|
||||
/// walk that is itself cheap, so the eviction policy would cost more than the miss it prevents. The
|
||||
/// failure mode of clearing is a burst of recomputation, which is exactly the steady state of not
|
||||
/// having a cache at all.
|
||||
/// </remarks>
|
||||
public const int MaxEntries = 100_000;
|
||||
|
||||
private readonly ConcurrentDictionary<DecisionKey, bool> _entries = new();
|
||||
private long _generation = -1;
|
||||
|
||||
/// <summary>The authorization generation the cached decisions were taken under.</summary>
|
||||
public long Generation => Interlocked.Read(ref _generation);
|
||||
|
||||
/// <summary>Number of decisions currently memoized. Diagnostics and tests.</summary>
|
||||
public int Count => _entries.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the memoized decision for this key, computing and storing it when absent or when the
|
||||
/// generation has moved on.
|
||||
/// </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>
|
||||
/// <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="compute">The grant walk to run on a miss.</param>
|
||||
/// <returns><see langword="true"/> when the operation is permitted.</returns>
|
||||
public bool GetOrAdd(
|
||||
long generation,
|
||||
string sessionKey,
|
||||
OpcUaOperation operation,
|
||||
AddressSpaceRealm realm,
|
||||
string nodeIdentifier,
|
||||
Func<bool> compute)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(compute);
|
||||
|
||||
InvalidateIfStale(generation);
|
||||
|
||||
var key = new DecisionKey(sessionKey, operation, realm, nodeIdentifier);
|
||||
if (_entries.TryGetValue(key, 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 (_entries.Count >= MaxEntries) _entries.Clear();
|
||||
_entries[key] = decided;
|
||||
return decided;
|
||||
}
|
||||
|
||||
/// <summary>Drops every memoized decision. Called when the authorization generation advances.</summary>
|
||||
public void Clear() => _entries.Clear();
|
||||
|
||||
private void InvalidateIfStale(long generation)
|
||||
{
|
||||
var seen = Interlocked.Read(ref _generation);
|
||||
if (seen == generation) return;
|
||||
|
||||
// Only the thread that actually moves the stamp clears, so a burst of concurrent requests arriving
|
||||
// just after a deploy repopulates once rather than each clearing the others' fresh entries.
|
||||
if (Interlocked.CompareExchange(ref _generation, generation, seen) == seen) _entries.Clear();
|
||||
}
|
||||
|
||||
private readonly record struct DecisionKey(
|
||||
string SessionKey, OpcUaOperation Operation, AddressSpaceRealm Realm, string NodeIdentifier);
|
||||
}
|
||||
Reference in New Issue
Block a user