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:
Joseph Doherty
2026-08-01 09:22:12 -04:00
parent 1ccc74005a
commit 00aa486677
16 changed files with 2157 additions and 70 deletions
@@ -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);
}