diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs
new file mode 100644
index 0000000..c8db117
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs
@@ -0,0 +1,59 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+///
+/// Every OPC UA operation surface the Phase 6.2 authorization evaluator gates, per
+/// docs/v2/implementation/phase-6-2-authorization-runtime.md §Stream C and
+/// decision #143. The evaluator maps each operation onto the corresponding
+/// NodePermissions bit(s) to decide whether the calling session is allowed.
+///
+///
+/// Write is split out into / /
+/// because the underlying driver-reported
+/// already carries that distinction — the
+/// evaluator maps the requested tag's security class to the matching operation value
+/// before checking the permission bit.
+///
+public enum OpcUaOperation
+{
+ ///
+ /// Browse + TranslateBrowsePathsToNodeIds. Ancestor visibility implied
+ /// when any descendant has a grant; denied ancestors filter from browse results.
+ ///
+ Browse,
+
+ /// Read on a variable node.
+ Read,
+
+ /// Write when the target has / .
+ WriteOperate,
+
+ /// Write when the target has .
+ WriteTune,
+
+ /// Write when the target has .
+ WriteConfigure,
+
+ /// HistoryRead — uses its own NodePermissions.HistoryRead bit; Read alone is NOT sufficient (decision in Phase 6.2 Compliance).
+ HistoryRead,
+
+ /// HistoryUpdate — annotation / insert / delete on historian.
+ HistoryUpdate,
+
+ /// CreateMonitoredItems. Per-item denial in mixed-authorization batches.
+ CreateMonitoredItems,
+
+ /// TransferSubscriptions. Re-evaluates transferred items against current auth state.
+ TransferSubscriptions,
+
+ /// Call on a Method node.
+ Call,
+
+ /// Alarm Acknowledge.
+ AlarmAcknowledge,
+
+ /// Alarm Confirm.
+ AlarmConfirm,
+
+ /// Alarm Shelve / Unshelve.
+ AlarmShelve,
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs
new file mode 100644
index 0000000..447ddb1
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs
@@ -0,0 +1,48 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+///
+/// Tri-state result of an call, per decision
+/// #149. Phase 6.2 only produces and
+/// ; the
+/// variant exists in the model so v2.1 Explicit Deny lands without an API break. Provenance
+/// carries the matched grants (or empty when not granted) for audit + the Admin UI "Probe
+/// this permission" diagnostic.
+///
+public sealed record AuthorizationDecision(
+ AuthorizationVerdict Verdict,
+ IReadOnlyList Provenance)
+{
+ public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
+
+ /// Convenience constructor for the common "no grants matched" outcome.
+ public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
+
+ /// Allow with the list of grants that matched.
+ public static AuthorizationDecision Allowed(IReadOnlyList provenance)
+ => new(AuthorizationVerdict.Allow, provenance);
+}
+
+/// Three-valued authorization outcome.
+public enum AuthorizationVerdict
+{
+ /// At least one grant matches the requested (operation, scope) pair.
+ Allow,
+
+ /// No grant matches. Phase 6.2 default — treated as deny at the OPC UA surface.
+ NotGranted,
+
+ /// Explicit deny grant matched. Reserved for v2.1; never produced by Phase 6.2.
+ Denied,
+}
+
+/// One grant that contributed to an Allow verdict — for audit / UI diagnostics.
+/// LDAP group the matched grant belongs to.
+/// Where in the hierarchy the grant was anchored.
+/// The bitmask the grant contributed.
+public sealed record MatchedGrant(
+ string LdapGroup,
+ NodeAclScopeKind Scope,
+ NodePermissions PermissionFlags);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/IPermissionEvaluator.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/IPermissionEvaluator.cs
new file mode 100644
index 0000000..acb0b01
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/IPermissionEvaluator.cs
@@ -0,0 +1,23 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+///
+/// Evaluates whether a session is authorized to perform an OPC UA
+/// on the node addressed by a . Phase 6.2 Stream B central surface.
+///
+///
+/// Data-plane only. Reads NodeAcl rows joined against the session's resolved LDAP
+/// groups (via ). Must not depend on
+/// LdapGroupRoleMapping (control-plane) per decision #150.
+///
+public interface IPermissionEvaluator
+{
+ ///
+ /// Authorize the requested operation for the session. Callers (DriverNodeManager
+ /// Read / Write / HistoryRead / Subscribe / Browse / Call dispatch) map their native
+ /// failure to BadUserAccessDenied per OPC UA Part 4 when the result is not
+ /// .
+ ///
+ AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs
new file mode 100644
index 0000000..c2806c7
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs
@@ -0,0 +1,58 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+///
+/// Address of a node in the 6-level scope hierarchy the Phase 6.2 evaluator walks.
+/// Assembled by the dispatch layer from the node's namespace + UNS path + tag; passed
+/// to which walks the matching trie path.
+///
+///
+/// Per decision #129 and the Phase 6.2 Stream B plan the hierarchy is
+/// Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag for UNS
+/// (Equipment-kind) namespaces. Galaxy (SystemPlatform-kind) namespaces instead use
+/// Cluster → Namespace → FolderSegment(s) → Tag, and each folder segment takes
+/// one trie level — so a deeply-nested Galaxy folder implicitly reaches the same
+/// depth as a full UNS path.
+///
+/// Unset mid-path levels (e.g. a Cluster-scoped request with no UnsArea) leave
+/// the corresponding id null. The evaluator walks as far as the scope goes +
+/// stops at the first null.
+///
+public sealed record NodeScope
+{
+ /// Cluster the node belongs to. Required.
+ public required string ClusterId { get; init; }
+
+ /// Namespace within the cluster. Null is not allowed for a request against a real node.
+ public string? NamespaceId { get; init; }
+
+ /// For Equipment-kind namespaces: UNS area (e.g. "warsaw-west"). Null on Galaxy.
+ public string? UnsAreaId { get; init; }
+
+ /// For Equipment-kind namespaces: UNS line below the area. Null on Galaxy.
+ public string? UnsLineId { get; init; }
+
+ /// For Equipment-kind namespaces: equipment row below the line. Null on Galaxy.
+ public string? EquipmentId { get; init; }
+
+ ///
+ /// For Galaxy (SystemPlatform-kind) namespaces only: the folder path segments from
+ /// namespace root to the target tag, in order. Empty on Equipment namespaces.
+ ///
+ public IReadOnlyList FolderSegments { get; init; } = [];
+
+ /// Target tag id when the scope addresses a specific tag; null for folder / equipment-level scopes.
+ public string? TagId { get; init; }
+
+ /// Which hierarchy applies — Equipment-kind (UNS) or SystemPlatform-kind (Galaxy).
+ public required NodeHierarchyKind Kind { get; init; }
+}
+
+/// Selector between the two scope-hierarchy shapes.
+public enum NodeHierarchyKind
+{
+ /// Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag — UNS / Equipment kind.
+ Equipment,
+
+ /// Cluster → Namespace → FolderSegment(s) → Tag — Galaxy / SystemPlatform kind.
+ SystemPlatform,
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs
new file mode 100644
index 0000000..225dc38
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs
@@ -0,0 +1,125 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+///
+/// In-memory permission trie for one (ClusterId, GenerationId). Walk from the cluster
+/// root down through namespace → UNS levels (or folder segments) → tag, OR-ing the
+/// granted at each visited level for each of the session's
+/// LDAP groups. The accumulated bitmask is compared to the permission required by the
+/// requested .
+///
+///
+/// Per decision #129 (additive grants, no explicit Deny in v2.0) the walk is pure union:
+/// encountering a grant at any level contributes its flags, never revokes them. A grant at
+/// the Cluster root therefore cascades to every tag below it; a grant at a deep equipment
+/// leaf is visible only on that equipment subtree.
+///
+public sealed class PermissionTrie
+{
+ /// Cluster this trie belongs to.
+ public required string ClusterId { get; init; }
+
+ /// Config generation the trie was built from — used by the cache for invalidation.
+ public required long GenerationId { get; init; }
+
+ /// Root of the trie. Level 0 (cluster-level grants) live directly here.
+ public PermissionTrieNode Root { get; init; } = new();
+
+ ///
+ /// Walk the trie collecting grants that apply to for any of the
+ /// session's . Returns the matched-grant list; the caller
+ /// OR-s the flag bits to decide whether the requested permission is carried.
+ ///
+ public IReadOnlyList CollectMatches(NodeScope scope, IEnumerable ldapGroups)
+ {
+ ArgumentNullException.ThrowIfNull(scope);
+ ArgumentNullException.ThrowIfNull(ldapGroups);
+
+ var groups = ldapGroups.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (groups.Count == 0) return [];
+
+ var matches = new List();
+
+ // Level 0 — cluster-scoped grants.
+ CollectAtLevel(Root, NodeAclScopeKind.Cluster, groups, matches);
+
+ // Level 1 — namespace.
+ if (scope.NamespaceId is null) return matches;
+ if (!Root.Children.TryGetValue(scope.NamespaceId, out var ns)) return matches;
+ CollectAtLevel(ns, NodeAclScopeKind.Namespace, groups, matches);
+
+ // Two hierarchies diverge below the namespace.
+ if (scope.Kind == NodeHierarchyKind.Equipment)
+ WalkEquipment(ns, scope, groups, matches);
+ else
+ WalkSystemPlatform(ns, scope, groups, matches);
+
+ return matches;
+ }
+
+ private static void WalkEquipment(PermissionTrieNode ns, NodeScope scope, HashSet groups, List matches)
+ {
+ if (scope.UnsAreaId is null) return;
+ if (!ns.Children.TryGetValue(scope.UnsAreaId, out var area)) return;
+ CollectAtLevel(area, NodeAclScopeKind.UnsArea, groups, matches);
+
+ if (scope.UnsLineId is null) return;
+ if (!area.Children.TryGetValue(scope.UnsLineId, out var line)) return;
+ CollectAtLevel(line, NodeAclScopeKind.UnsLine, groups, matches);
+
+ if (scope.EquipmentId is null) return;
+ if (!line.Children.TryGetValue(scope.EquipmentId, out var eq)) return;
+ CollectAtLevel(eq, NodeAclScopeKind.Equipment, groups, matches);
+
+ if (scope.TagId is null) return;
+ if (!eq.Children.TryGetValue(scope.TagId, out var tag)) return;
+ CollectAtLevel(tag, NodeAclScopeKind.Tag, groups, matches);
+ }
+
+ private static void WalkSystemPlatform(PermissionTrieNode ns, NodeScope scope, HashSet groups, List matches)
+ {
+ // FolderSegments are nested under the namespace; each is its own trie level. Reuse the
+ // UnsArea scope kind for the flags — NodeAcl rows for Galaxy tags carry ScopeKind.Tag
+ // for leaf grants and ScopeKind.Namespace for folder-root grants; deeper folder grants
+ // are modeled as Equipment-level rows today since NodeAclScopeKind doesn't enumerate
+ // a dedicated FolderSegment kind. Future-proof TODO tracked in Stream B follow-up.
+ var current = ns;
+ foreach (var segment in scope.FolderSegments)
+ {
+ if (!current.Children.TryGetValue(segment, out var child)) return;
+ CollectAtLevel(child, NodeAclScopeKind.Equipment, groups, matches);
+ current = child;
+ }
+
+ if (scope.TagId is null) return;
+ if (!current.Children.TryGetValue(scope.TagId, out var tag)) return;
+ CollectAtLevel(tag, NodeAclScopeKind.Tag, groups, matches);
+ }
+
+ private static void CollectAtLevel(PermissionTrieNode node, NodeAclScopeKind level, HashSet groups, List matches)
+ {
+ foreach (var grant in node.Grants)
+ {
+ if (groups.Contains(grant.LdapGroup))
+ matches.Add(new MatchedGrant(grant.LdapGroup, level, grant.PermissionFlags));
+ }
+ }
+}
+
+/// One node in a .
+public sealed class PermissionTrieNode
+{
+ /// Grants anchored at this trie level.
+ public List Grants { get; } = [];
+
+ ///
+ /// Children keyed by the next level's id — namespace id under cluster; UnsAreaId or
+ /// folder-segment name under namespace; etc. Comparer is OrdinalIgnoreCase so the walk
+ /// tolerates case drift between the NodeAcl row and the requested scope.
+ ///
+ public Dictionary Children { get; } = new(StringComparer.OrdinalIgnoreCase);
+}
+
+/// Projection of a row into the trie.
+public sealed record TrieGrant(string LdapGroup, NodePermissions PermissionFlags);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs
new file mode 100644
index 0000000..9d5d253
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs
@@ -0,0 +1,97 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+///
+/// Builds a from a set of rows anchored
+/// in one generation. The trie is keyed on the rows' scope hierarchy — rows with
+/// land at the trie root, rows with
+/// land at a leaf, etc.
+///
+///
+/// Intended to be called by once per published
+/// generation; the resulting trie is immutable for the life of the cache entry. Idempotent —
+/// two builds from the same rows produce equal tries (grant lists may be in insertion order;
+/// evaluators don't depend on order).
+///
+/// The builder deliberately does not know about the node-row metadata the trie path
+/// will be walked with. The caller assembles values from the live
+/// config (UnsArea parent of UnsLine, etc.); this class only honors the ScopeId
+/// each row carries.
+///
+public static class PermissionTrieBuilder
+{
+ ///
+ /// Build a trie for one cluster/generation from the supplied rows. The caller is
+ /// responsible for pre-filtering rows to the target generation + cluster.
+ ///
+ public static PermissionTrie Build(
+ string clusterId,
+ long generationId,
+ IReadOnlyList rows,
+ IReadOnlyDictionary? scopePaths = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
+ ArgumentNullException.ThrowIfNull(rows);
+
+ var trie = new PermissionTrie { ClusterId = clusterId, GenerationId = generationId };
+
+ foreach (var row in rows)
+ {
+ if (!string.Equals(row.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
+ var grant = new TrieGrant(row.LdapGroup, row.PermissionFlags);
+
+ var node = row.ScopeKind switch
+ {
+ NodeAclScopeKind.Cluster => trie.Root,
+ _ => Descend(trie.Root, row, scopePaths),
+ };
+
+ if (node is not null)
+ node.Grants.Add(grant);
+ }
+
+ return trie;
+ }
+
+ private static PermissionTrieNode? Descend(PermissionTrieNode root, NodeAcl row, IReadOnlyDictionary? scopePaths)
+ {
+ if (string.IsNullOrEmpty(row.ScopeId)) return null;
+
+ // For sub-cluster scopes the caller supplies a path lookup so we know the containing
+ // namespace / UnsArea / UnsLine ids. Without a path lookup we fall back to putting the
+ // row directly under the root using its ScopeId — works for deterministic tests, not
+ // for production where the hierarchy must be honored.
+ if (scopePaths is null || !scopePaths.TryGetValue(row.ScopeId, out var path))
+ {
+ return EnsureChild(root, row.ScopeId);
+ }
+
+ var node = root;
+ foreach (var segment in path.Segments)
+ node = EnsureChild(node, segment);
+ return node;
+ }
+
+ private static PermissionTrieNode EnsureChild(PermissionTrieNode parent, string key)
+ {
+ if (!parent.Children.TryGetValue(key, out var child))
+ {
+ child = new PermissionTrieNode();
+ parent.Children[key] = child;
+ }
+ return child;
+ }
+}
+
+///
+/// Ordered list of trie-path segments from root to the target node. Supplied to
+/// so the builder knows where a
+/// -scoped row sits in the hierarchy.
+///
+///
+/// Namespace id, then (for Equipment kind) UnsAreaId / UnsLineId / EquipmentId / TagId as
+/// applicable; or (for SystemPlatform kind) NamespaceId / FolderSegment / .../TagId.
+///
+public sealed record NodeAclPath(IReadOnlyList Segments);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieCache.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieCache.cs
new file mode 100644
index 0000000..9367830
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieCache.cs
@@ -0,0 +1,88 @@
+using System.Collections.Concurrent;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+///
+/// Process-singleton cache of instances keyed on
+/// (ClusterId, GenerationId). Hot-path evaluation reads
+/// without awaiting DB access; the cache is populated
+/// out-of-band on publish + on first reference via
+/// .
+///
+///
+/// Per decision #148 and Phase 6.2 Stream B.4 the cache is generation-sealed: once a
+/// trie is installed for (ClusterId, GenerationId) the entry is immutable. When a
+/// new generation publishes, the caller calls 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 .
+///
+public sealed class PermissionTrieCache
+{
+ private readonly ConcurrentDictionary _byCluster =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ /// Install a trie for a cluster + make it the current generation.
+ public void Install(PermissionTrie trie)
+ {
+ ArgumentNullException.ThrowIfNull(trie);
+ _byCluster.AddOrUpdate(trie.ClusterId,
+ _ => ClusterEntry.FromSingle(trie),
+ (_, existing) => existing.WithAdditional(trie));
+ }
+
+ /// Get the current-generation trie for a cluster; null when nothing installed.
+ public PermissionTrie? GetTrie(string clusterId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
+ return _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current : null;
+ }
+
+ /// Get a specific (cluster, generation) trie; null if that pair isn't cached.
+ 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;
+ }
+
+ /// The generation id the shortcut currently serves for a cluster.
+ public long? CurrentGenerationId(string clusterId)
+ => _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
+
+ /// Drop every cached trie for one cluster.
+ public void Invalidate(string clusterId) => _byCluster.TryRemove(clusterId, out _);
+
+ ///
+ /// Retain only the most-recent generations for a cluster.
+ /// No-op when there's nothing to drop.
+ ///
+ 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);
+ }
+
+ /// Diagnostics counter: number of cached (cluster, generation) tries.
+ public int CachedTrieCount => _byCluster.Values.Sum(e => e.Tries.Count);
+
+ private sealed record ClusterEntry(PermissionTrie Current, IReadOnlyDictionary Tries)
+ {
+ public static ClusterEntry FromSingle(PermissionTrie trie) =>
+ new(trie, new Dictionary { [trie.GenerationId] = trie });
+
+ public ClusterEntry WithAdditional(PermissionTrie trie)
+ {
+ var next = new Dictionary(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);
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs
new file mode 100644
index 0000000..f175505
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs
@@ -0,0 +1,70 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+///
+/// Default implementation. Resolves the
+/// for the session's cluster (via
+/// ), walks it collecting matched grants, OR-s the
+/// permission flags, and maps against the operation-specific required permission.
+///
+public sealed class TriePermissionEvaluator : IPermissionEvaluator
+{
+ private readonly PermissionTrieCache _cache;
+ private readonly TimeProvider _timeProvider;
+
+ public TriePermissionEvaluator(PermissionTrieCache cache, TimeProvider? timeProvider = null)
+ {
+ ArgumentNullException.ThrowIfNull(cache);
+ _cache = cache;
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ public AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentNullException.ThrowIfNull(scope);
+
+ // Decision #152 — beyond the staleness ceiling every call fails closed regardless of
+ // cache warmth elsewhere in the process.
+ if (session.IsStale(_timeProvider.GetUtcNow().UtcDateTime))
+ return AuthorizationDecision.NotGranted();
+
+ if (!string.Equals(session.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase))
+ return AuthorizationDecision.NotGranted();
+
+ var trie = _cache.GetTrie(scope.ClusterId);
+ if (trie is null) return AuthorizationDecision.NotGranted();
+
+ var matches = trie.CollectMatches(scope, session.LdapGroups);
+ if (matches.Count == 0) return AuthorizationDecision.NotGranted();
+
+ var required = MapOperationToPermission(operation);
+ var granted = NodePermissions.None;
+ foreach (var m in matches) granted |= m.PermissionFlags;
+
+ return (granted & required) == required
+ ? AuthorizationDecision.Allowed(matches)
+ : AuthorizationDecision.NotGranted();
+ }
+
+ /// Maps each to the bit required to grant it.
+ public static NodePermissions MapOperationToPermission(OpcUaOperation op) => op switch
+ {
+ OpcUaOperation.Browse => NodePermissions.Browse,
+ OpcUaOperation.Read => NodePermissions.Read,
+ OpcUaOperation.WriteOperate => NodePermissions.WriteOperate,
+ OpcUaOperation.WriteTune => NodePermissions.WriteTune,
+ OpcUaOperation.WriteConfigure => NodePermissions.WriteConfigure,
+ OpcUaOperation.HistoryRead => NodePermissions.HistoryRead,
+ OpcUaOperation.HistoryUpdate => NodePermissions.HistoryRead, // HistoryUpdate bit not yet in NodePermissions; TODO Stream C follow-up
+ OpcUaOperation.CreateMonitoredItems => NodePermissions.Subscribe,
+ OpcUaOperation.TransferSubscriptions=> NodePermissions.Subscribe,
+ OpcUaOperation.Call => NodePermissions.MethodCall,
+ OpcUaOperation.AlarmAcknowledge => NodePermissions.AlarmAcknowledge,
+ OpcUaOperation.AlarmConfirm => NodePermissions.AlarmConfirm,
+ OpcUaOperation.AlarmShelve => NodePermissions.AlarmShelve,
+ _ => throw new ArgumentOutOfRangeException(nameof(op), op, $"No permission mapping defined for operation {op}."),
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs
new file mode 100644
index 0000000..91f8212
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs
@@ -0,0 +1,69 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+///
+/// Per-session authorization state cached on the OPC UA session object + keyed on the
+/// session id. Captures the LDAP group memberships resolved at sign-in, the generation
+/// the membership was resolved against, and the bounded freshness window.
+///
+///
+/// Per decision #151 the membership is bounded by
+/// (default 15 min). After that, the next hot-path authz call re-resolves LDAP group
+/// memberships; failure to re-resolve (LDAP unreachable) flips the session to fail-closed
+/// until a refresh succeeds.
+///
+/// Per decision #152 (default 5 min) is separate from
+/// Phase 6.1's availability-oriented 24h cache — beyond this window the evaluator returns
+/// regardless of config-cache warmth.
+///
+public sealed record UserAuthorizationState
+{
+ /// Opaque session id (reuse OPC UA session handle when possible).
+ public required string SessionId { get; init; }
+
+ /// Cluster the session is scoped to — every request targets nodes in this cluster.
+ public required string ClusterId { get; init; }
+
+ ///
+ /// LDAP groups the user is a member of as resolved at sign-in / last membership refresh.
+ /// Case comparison is handled downstream by the evaluator (OrdinalIgnoreCase).
+ ///
+ public required IReadOnlyList LdapGroups { get; init; }
+
+ /// Timestamp when was last resolved from the directory.
+ public required DateTime MembershipResolvedUtc { get; init; }
+
+ ///
+ /// Trie generation the session is currently bound to. When
+ /// moves to a new generation, the session's
+ /// (AuthGenerationId, MembershipVersion) stamp no longer matches its
+ /// MonitoredItems and they re-evaluate on next publish (decision #153).
+ ///
+ public required long AuthGenerationId { get; init; }
+
+ ///
+ /// Monotonic counter incremented every time membership is re-resolved. Combined with
+ /// into the subscription stamp per decision #153.
+ ///
+ public required long MembershipVersion { get; init; }
+
+ /// Bounded membership freshness window; past this the next authz call refreshes.
+ public TimeSpan MembershipFreshnessInterval { get; init; } = TimeSpan.FromMinutes(15);
+
+ /// Hard staleness ceiling — beyond this, the evaluator fails closed.
+ public TimeSpan AuthCacheMaxStaleness { get; init; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// True when - exceeds
+ /// . The evaluator short-circuits to NotGranted
+ /// whenever this is true.
+ ///
+ public bool IsStale(DateTime utcNow) => utcNow - MembershipResolvedUtc > AuthCacheMaxStaleness;
+
+ ///
+ /// True when membership is past its freshness interval but still within the staleness
+ /// ceiling — a signal to the caller to kick off an async refresh, while the current
+ /// call still evaluates against the cached memberships.
+ ///
+ public bool NeedsRefresh(DateTime utcNow) =>
+ !IsStale(utcNow) && utcNow - MembershipResolvedUtc > MembershipFreshnessInterval;
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieCacheTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieCacheTests.cs
new file mode 100644
index 0000000..1f61a7d
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieCacheTests.cs
@@ -0,0 +1,104 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
+
+[Trait("Category", "Unit")]
+public sealed class PermissionTrieCacheTests
+{
+ private static PermissionTrie Trie(string cluster, long generation) => new()
+ {
+ ClusterId = cluster,
+ GenerationId = generation,
+ };
+
+ [Fact]
+ public void GetTrie_Empty_ReturnsNull()
+ {
+ new PermissionTrieCache().GetTrie("c1").ShouldBeNull();
+ }
+
+ [Fact]
+ public void Install_ThenGet_RoundTrips()
+ {
+ var cache = new PermissionTrieCache();
+ cache.Install(Trie("c1", 5));
+
+ cache.GetTrie("c1")!.GenerationId.ShouldBe(5);
+ cache.CurrentGenerationId("c1").ShouldBe(5);
+ }
+
+ [Fact]
+ public void NewGeneration_BecomesCurrent()
+ {
+ var cache = new PermissionTrieCache();
+ cache.Install(Trie("c1", 1));
+ cache.Install(Trie("c1", 2));
+
+ cache.CurrentGenerationId("c1").ShouldBe(2);
+ cache.GetTrie("c1", 1).ShouldNotBeNull("prior generation retained for in-flight requests");
+ cache.GetTrie("c1", 2).ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void OutOfOrder_Install_DoesNotDowngrade_Current()
+ {
+ var cache = new PermissionTrieCache();
+ cache.Install(Trie("c1", 3));
+ cache.Install(Trie("c1", 1)); // late-arriving older generation
+
+ cache.CurrentGenerationId("c1").ShouldBe(3, "older generation must not become current");
+ cache.GetTrie("c1", 1).ShouldNotBeNull("but older is still retrievable by explicit lookup");
+ }
+
+ [Fact]
+ public void Invalidate_DropsCluster()
+ {
+ var cache = new PermissionTrieCache();
+ cache.Install(Trie("c1", 1));
+ cache.Install(Trie("c2", 1));
+
+ cache.Invalidate("c1");
+
+ cache.GetTrie("c1").ShouldBeNull();
+ cache.GetTrie("c2").ShouldNotBeNull("sibling cluster unaffected");
+ }
+
+ [Fact]
+ public void Prune_RetainsMostRecent()
+ {
+ var cache = new PermissionTrieCache();
+ for (var g = 1L; g <= 5; g++) cache.Install(Trie("c1", g));
+
+ cache.Prune("c1", keepLatest: 2);
+
+ cache.GetTrie("c1", 5).ShouldNotBeNull();
+ cache.GetTrie("c1", 4).ShouldNotBeNull();
+ cache.GetTrie("c1", 3).ShouldBeNull();
+ cache.GetTrie("c1", 1).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Prune_LessThanKeep_IsNoOp()
+ {
+ var cache = new PermissionTrieCache();
+ cache.Install(Trie("c1", 1));
+ cache.Install(Trie("c1", 2));
+
+ cache.Prune("c1", keepLatest: 10);
+
+ cache.CachedTrieCount.ShouldBe(2);
+ }
+
+ [Fact]
+ public void ClusterIsolation()
+ {
+ var cache = new PermissionTrieCache();
+ cache.Install(Trie("c1", 1));
+ cache.Install(Trie("c2", 9));
+
+ cache.CurrentGenerationId("c1").ShouldBe(1);
+ cache.CurrentGenerationId("c2").ShouldBe(9);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs
new file mode 100644
index 0000000..574d0b5
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs
@@ -0,0 +1,157 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
+
+[Trait("Category", "Unit")]
+public sealed class PermissionTrieTests
+{
+ private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
+ new()
+ {
+ NodeAclRowId = Guid.NewGuid(),
+ NodeAclId = $"acl-{Guid.NewGuid():N}",
+ GenerationId = 1,
+ ClusterId = clusterId,
+ LdapGroup = group,
+ ScopeKind = scope,
+ ScopeId = scopeId,
+ PermissionFlags = flags,
+ };
+
+ private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
+ new()
+ {
+ ClusterId = cluster,
+ NamespaceId = ns,
+ UnsAreaId = area,
+ UnsLineId = line,
+ EquipmentId = equip,
+ TagId = tag,
+ Kind = NodeHierarchyKind.Equipment,
+ };
+
+ private static NodeScope GalaxyTag(string cluster, string ns, string[] folders, string tag) =>
+ new()
+ {
+ ClusterId = cluster,
+ NamespaceId = ns,
+ FolderSegments = folders,
+ TagId = tag,
+ Kind = NodeHierarchyKind.SystemPlatform,
+ };
+
+ [Fact]
+ public void ClusterLevelGrant_Cascades_ToEveryTag()
+ {
+ var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, scopeId: null, NodePermissions.Read) };
+ var trie = PermissionTrieBuilder.Build("c1", 1, rows);
+
+ var matches = trie.CollectMatches(
+ EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
+ ["cn=ops"]);
+
+ matches.Count.ShouldBe(1);
+ matches[0].PermissionFlags.ShouldBe(NodePermissions.Read);
+ matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
+ }
+
+ [Fact]
+ public void EquipmentScope_DoesNotLeak_ToSibling()
+ {
+ var paths = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["eq-A"] = new(new[] { "ns", "area1", "line1", "eq-A" }),
+ };
+ var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "eq-A", NodePermissions.Read) };
+ var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
+
+ var matchA = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-A", "tag1"), ["cn=ops"]);
+ var matchB = trie.CollectMatches(EquipmentTag("c1", "ns", "area1", "line1", "eq-B", "tag1"), ["cn=ops"]);
+
+ matchA.Count.ShouldBe(1);
+ matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
+ }
+
+ [Fact]
+ public void MultiGroup_Union_OrsPermissionFlags()
+ {
+ var rows = new[]
+ {
+ Row("cn=readers", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
+ Row("cn=writers", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
+ };
+ var trie = PermissionTrieBuilder.Build("c1", 1, rows);
+
+ var matches = trie.CollectMatches(
+ EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
+ ["cn=readers", "cn=writers"]);
+
+ matches.Count.ShouldBe(2);
+ var combined = matches.Aggregate(NodePermissions.None, (acc, m) => acc | m.PermissionFlags);
+ combined.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
+ }
+
+ [Fact]
+ public void NoMatchingGroup_ReturnsEmpty()
+ {
+ var rows = new[] { Row("cn=different", NodeAclScopeKind.Cluster, null, NodePermissions.Read) };
+ var trie = PermissionTrieBuilder.Build("c1", 1, rows);
+
+ var matches = trie.CollectMatches(
+ EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
+ ["cn=ops"]);
+
+ matches.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Galaxy_FolderSegment_Grant_DoesNotLeak_To_Sibling_Folder()
+ {
+ var paths = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["folder-A"] = new(new[] { "ns-gal", "folder-A" }),
+ };
+ var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "folder-A", NodePermissions.Read) };
+ var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
+
+ var matchA = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-A"], "tag1"), ["cn=ops"]);
+ var matchB = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-B"], "tag1"), ["cn=ops"]);
+
+ matchA.Count.ShouldBe(1);
+ matchB.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void CrossCluster_Grant_DoesNotLeak()
+ {
+ var rows = new[] { Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read, clusterId: "c-other") };
+ var trie = PermissionTrieBuilder.Build("c1", 1, rows);
+
+ var matches = trie.CollectMatches(
+ EquipmentTag("c1", "ns", "area1", "line1", "eq1", "tag1"),
+ ["cn=ops"]);
+
+ matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
+ }
+
+ [Fact]
+ public void Build_IsIdempotent()
+ {
+ var rows = new[]
+ {
+ Row("cn=a", NodeAclScopeKind.Cluster, null, NodePermissions.Read),
+ Row("cn=b", NodeAclScopeKind.Cluster, null, NodePermissions.WriteOperate),
+ };
+
+ var trie1 = PermissionTrieBuilder.Build("c1", 1, rows);
+ var trie2 = PermissionTrieBuilder.Build("c1", 1, rows);
+
+ trie1.Root.Grants.Count.ShouldBe(trie2.Root.Grants.Count);
+ trie1.ClusterId.ShouldBe(trie2.ClusterId);
+ trie1.GenerationId.ShouldBe(trie2.GenerationId);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs
new file mode 100644
index 0000000..fbe9bb6
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs
@@ -0,0 +1,154 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
+
+[Trait("Category", "Unit")]
+public sealed class TriePermissionEvaluatorTests
+{
+ private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
+ private readonly FakeTimeProvider _time = new();
+
+ private sealed class FakeTimeProvider : TimeProvider
+ {
+ public DateTime Utc { get; set; } = Now;
+ public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
+ }
+
+ private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags) =>
+ new()
+ {
+ NodeAclRowId = Guid.NewGuid(),
+ NodeAclId = $"acl-{Guid.NewGuid():N}",
+ GenerationId = 1,
+ ClusterId = "c1",
+ LdapGroup = group,
+ ScopeKind = scope,
+ ScopeId = scopeId,
+ PermissionFlags = flags,
+ };
+
+ private static UserAuthorizationState Session(string[] groups, DateTime? resolvedUtc = null, string clusterId = "c1") =>
+ new()
+ {
+ SessionId = "sess",
+ ClusterId = clusterId,
+ LdapGroups = groups,
+ MembershipResolvedUtc = resolvedUtc ?? Now,
+ AuthGenerationId = 1,
+ MembershipVersion = 1,
+ };
+
+ private static NodeScope Scope(string cluster = "c1") =>
+ new()
+ {
+ ClusterId = cluster,
+ NamespaceId = "ns",
+ UnsAreaId = "area",
+ UnsLineId = "line",
+ EquipmentId = "eq",
+ TagId = "tag",
+ Kind = NodeHierarchyKind.Equipment,
+ };
+
+ private TriePermissionEvaluator MakeEvaluator(NodeAcl[] rows)
+ {
+ var cache = new PermissionTrieCache();
+ cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
+ return new TriePermissionEvaluator(cache, _time);
+ }
+
+ [Fact]
+ public void Allow_When_RequiredFlag_Matched()
+ {
+ var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
+
+ var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
+
+ decision.Verdict.ShouldBe(AuthorizationVerdict.Allow);
+ decision.Provenance.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public void NotGranted_When_NoMatchingGroup()
+ {
+ var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
+
+ var decision = evaluator.Authorize(Session(["cn=unrelated"]), OpcUaOperation.Read, Scope());
+
+ decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
+ decision.Provenance.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void NotGranted_When_FlagsInsufficient()
+ {
+ var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
+
+ var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.WriteOperate, Scope());
+
+ decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
+ }
+
+ [Fact]
+ public void HistoryRead_Requires_Its_Own_Bit()
+ {
+ // User has Read but not HistoryRead
+ var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
+
+ var liveRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
+ var historyRead = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.HistoryRead, Scope());
+
+ liveRead.IsAllowed.ShouldBeTrue();
+ historyRead.IsAllowed.ShouldBeFalse("HistoryRead uses its own NodePermissions flag, not Read");
+ }
+
+ [Fact]
+ public void CrossCluster_Session_Denied()
+ {
+ var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
+ var otherSession = Session(["cn=ops"], clusterId: "c-other");
+
+ var decision = evaluator.Authorize(otherSession, OpcUaOperation.Read, Scope(cluster: "c1"));
+
+ decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
+ }
+
+ [Fact]
+ public void StaleSession_FailsClosed()
+ {
+ var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
+ var session = Session(["cn=ops"], resolvedUtc: Now);
+ _time.Utc = Now.AddMinutes(10); // well past the 5-min AuthCacheMaxStaleness default
+
+ var decision = evaluator.Authorize(session, OpcUaOperation.Read, Scope());
+
+ decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
+ }
+
+ [Fact]
+ public void NoCachedTrie_ForCluster_Denied()
+ {
+ var cache = new PermissionTrieCache(); // empty cache
+ var evaluator = new TriePermissionEvaluator(cache, _time);
+
+ var decision = evaluator.Authorize(Session(["cn=ops"]), OpcUaOperation.Read, Scope());
+
+ decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
+ }
+
+ [Fact]
+ public void OperationToPermission_Mapping_IsTotal()
+ {
+ foreach (var op in Enum.GetValues())
+ {
+ // Must not throw — every OpcUaOperation needs a mapping or the compliance-check
+ // "every operation wired" fails.
+ TriePermissionEvaluator.MapOperationToPermission(op);
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/UserAuthorizationStateTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/UserAuthorizationStateTests.cs
new file mode 100644
index 0000000..28527a4
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/UserAuthorizationStateTests.cs
@@ -0,0 +1,60 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
+
+[Trait("Category", "Unit")]
+public sealed class UserAuthorizationStateTests
+{
+ private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
+
+ private static UserAuthorizationState Fresh(DateTime resolved) => new()
+ {
+ SessionId = "s",
+ ClusterId = "c1",
+ LdapGroups = ["cn=ops"],
+ MembershipResolvedUtc = resolved,
+ AuthGenerationId = 1,
+ MembershipVersion = 1,
+ };
+
+ [Fact]
+ public void FreshlyResolved_Is_NotStale_NorNeedsRefresh()
+ {
+ var session = Fresh(Now);
+
+ session.IsStale(Now.AddMinutes(1)).ShouldBeFalse();
+ session.NeedsRefresh(Now.AddMinutes(1)).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void NeedsRefresh_FiresAfter_FreshnessInterval()
+ {
+ var session = Fresh(Now);
+
+ session.NeedsRefresh(Now.AddMinutes(16)).ShouldBeFalse("past freshness but also past the 5-min staleness ceiling — should be Stale, not NeedsRefresh");
+ }
+
+ [Fact]
+ public void NeedsRefresh_TrueBetween_Freshness_And_Staleness_Windows()
+ {
+ // Custom: freshness=2 min, staleness=10 min → between 2 and 10 min NeedsRefresh fires.
+ var session = Fresh(Now) with
+ {
+ MembershipFreshnessInterval = TimeSpan.FromMinutes(2),
+ AuthCacheMaxStaleness = TimeSpan.FromMinutes(10),
+ };
+
+ session.NeedsRefresh(Now.AddMinutes(5)).ShouldBeTrue();
+ session.IsStale(Now.AddMinutes(5)).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsStale_TrueAfter_StalenessWindow()
+ {
+ var session = Fresh(Now);
+
+ session.IsStale(Now.AddMinutes(6)).ShouldBeTrue("default AuthCacheMaxStaleness is 5 min");
+ }
+}