Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieCache.cs
Joseph Doherty 40fb459040 Phase 6.2 Stream B — permission-trie evaluator in Core.Authorization
Ships Stream B.1-B.6 — the data-plane authorization engine Phase 6.2 runs on.
Integration into OPC UA dispatch (Stream C — Read / Write / HistoryRead /
Subscribe / Browse / Call etc.) is the next PR on this branch.

New Core.Abstractions:
- OpcUaOperation enum enumerates every OPC UA surface the evaluator gates:
  Browse, Read, WriteOperate/Tune/Configure (split by SecurityClassification),
  HistoryRead, HistoryUpdate, CreateMonitoredItems, TransferSubscriptions,
  Call, AlarmAcknowledge/Confirm/Shelve. Stream C maps each one back to its
  dispatch call site.

New Core.Authorization namespace:
- NodeScope record + NodeHierarchyKind — 6-level scope addressing for
  Equipment-kind (UNS) namespaces, folder-segment walk for SystemPlatform-kind
  (Galaxy). NodeScope carries a Kind selector so the evaluator knows which
  hierarchy to descend.
- AuthorizationDecision { Verdict, Provenance } + AuthorizationVerdict
  {Allow, NotGranted, Denied} + MatchedGrant. Tri-state per decision #149;
  Phase 6.2 only produces Allow + NotGranted, Denied stays reserved for v2.1
  Explicit Deny without API break.
- IPermissionEvaluator.Authorize(session, operation, scope).
- PermissionTrie + PermissionTrieNode + TrieGrant. In-memory trie keyed on
  the ACL scope hierarchy. CollectMatches walks Cluster → Namespace →
  UnsArea → UnsLine → Equipment → Tag (or → FolderSegment(s) → Tag on
  Galaxy). Pure additive union — matches that share an LDAP group with the
  session contribute flags; OR across levels.
- PermissionTrieBuilder static factory. Build(clusterId, generationId, rows,
  scopePaths?) returns a trie for one generation. Cross-cluster rows are
  filtered out so the trie is cluster-coherent. Stream C follow-up wires a
  real scopePaths lookup from the live DB; tests supply hand-built paths.
- PermissionTrieCache — process-singleton, keyed on (ClusterId, GenerationId).
  Install(trie) adds a generation + promotes to "current" when the id is
  highest-known (handles out-of-order installs gracefully). Prior generations
  retained so an in-flight request against a prior trie still succeeds; GC
  via Prune(cluster, keepLatest).
- UserAuthorizationState — per-session cache of resolved LDAP groups +
  AuthGenerationId + MembershipVersion + MembershipResolvedUtc. Bounded by
  MembershipFreshnessInterval (default 15 min per decision #151) +
  AuthCacheMaxStaleness (default 5 min per decision #152).
- TriePermissionEvaluator — default IPermissionEvaluator. Fails closed on
  stale sessions (IsStale check short-circuits to NotGranted), on cross-
  cluster requests, on empty trie cache. Maps OpcUaOperation → NodePermissions
  via MapOperationToPermission (total — every enum value has a mapping; tested).

Tests (27 new, all pass):
- PermissionTrieTests (7): cluster-level grant cascades to every tag;
  equipment-level grant doesn't leak to sibling equipment; multi-group union
  ORs flags; no-matching-group returns empty; Galaxy folder-segment grant
  doesn't leak to sibling folder; cross-cluster rows don't land in this
  cluster's trie; build is idempotent (B.6 invariants).
- TriePermissionEvaluatorTests (8): allow when flag matches; NotGranted when
  no matching group; NotGranted when flags insufficient; HistoryRead requires
  its own bit (decision-level requirement); cross-cluster session denied;
  stale session fails closed; no cached trie denied; MapOperationToPermission
  is total across every OpcUaOperation.
- PermissionTrieCacheTests (8): empty cache returns null; install-then-get
  round-trips; new generation becomes current; out-of-order install doesn't
  downgrade current; invalidate drops one cluster; prune retains most recent;
  prune no-op when fewer than keep; cluster isolation.
- UserAuthorizationStateTests (4): fresh is not stale; IsStale after 5 min
  default; NeedsRefresh true between freshness + staleness windows.

Full solution dotnet test: 1078 passing (baseline 906, Phase 6.1 = 1042,
Phase 6.2 Stream A = +9, Stream B = +27 = 1078). Pre-existing Client.CLI
Subscribe flake unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:27:44 -04:00

89 lines
4.1 KiB
C#

using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// Process-singleton cache of <see cref="PermissionTrie"/> instances keyed on
/// <c>(ClusterId, GenerationId)</c>. Hot-path evaluation reads
/// <see cref="GetTrie(string)"/> without awaiting DB access; the cache is populated
/// out-of-band on publish + on first reference via
/// <see cref="Install(PermissionTrie)"/>.
/// </summary>
/// <remarks>
/// Per decision #148 and Phase 6.2 Stream B.4 the cache is generation-sealed: once a
/// trie is installed for <c>(ClusterId, GenerationId)</c> the entry is immutable. When a
/// new generation publishes, the caller calls <see cref="Install"/> with the new trie
/// + the cache atomically updates its "current generation" pointer for that cluster.
/// Older generations are retained so an in-flight request evaluating the prior generation
/// still succeeds — GC via <see cref="Prune(string, int)"/>.
/// </remarks>
public sealed class PermissionTrieCache
{
private readonly ConcurrentDictionary<string, ClusterEntry> _byCluster =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Install a trie for a cluster + make it the current generation.</summary>
public void Install(PermissionTrie trie)
{
ArgumentNullException.ThrowIfNull(trie);
_byCluster.AddOrUpdate(trie.ClusterId,
_ => ClusterEntry.FromSingle(trie),
(_, existing) => existing.WithAdditional(trie));
}
/// <summary>Get the current-generation trie for a cluster; null when nothing installed.</summary>
public PermissionTrie? GetTrie(string clusterId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
return _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current : null;
}
/// <summary>Get a specific (cluster, generation) trie; null if that pair isn't cached.</summary>
public PermissionTrie? GetTrie(string clusterId, long generationId)
{
if (!_byCluster.TryGetValue(clusterId, out var entry)) return null;
return entry.Tries.TryGetValue(generationId, out var trie) ? trie : null;
}
/// <summary>The generation id the <see cref="GetTrie(string)"/> shortcut currently serves for a cluster.</summary>
public long? CurrentGenerationId(string clusterId)
=> _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
/// <summary>Drop every cached trie for one cluster.</summary>
public void Invalidate(string clusterId) => _byCluster.TryRemove(clusterId, out _);
/// <summary>
/// Retain only the most-recent <paramref name="keepLatest"/> generations for a cluster.
/// No-op when there's nothing to drop.
/// </summary>
public void Prune(string clusterId, int keepLatest = 3)
{
if (keepLatest < 1) throw new ArgumentOutOfRangeException(nameof(keepLatest), keepLatest, "keepLatest must be >= 1");
if (!_byCluster.TryGetValue(clusterId, out var entry)) return;
if (entry.Tries.Count <= keepLatest) return;
var keep = entry.Tries
.OrderByDescending(kvp => kvp.Key)
.Take(keepLatest)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
_byCluster[clusterId] = new ClusterEntry(entry.Current, keep);
}
/// <summary>Diagnostics counter: number of cached (cluster, generation) tries.</summary>
public int CachedTrieCount => _byCluster.Values.Sum(e => e.Tries.Count);
private sealed record ClusterEntry(PermissionTrie Current, IReadOnlyDictionary<long, PermissionTrie> Tries)
{
public static ClusterEntry FromSingle(PermissionTrie trie) =>
new(trie, new Dictionary<long, PermissionTrie> { [trie.GenerationId] = trie });
public ClusterEntry WithAdditional(PermissionTrie trie)
{
var next = new Dictionary<long, PermissionTrie>(Tries) { [trie.GenerationId] = trie };
// The highest generation wins as "current" — handles out-of-order installs.
var current = trie.GenerationId >= Current.GenerationId ? trie : Current;
return new ClusterEntry(current, next);
}
}
}