From 40fb4590400913cfa4ddf1288586cd8f475e84fb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 09:27:44 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206.2=20Stream=20B=20=E2=80=94=20permissi?= =?UTF-8?q?on-trie=20evaluator=20in=20Core.Authorization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../OpcUaOperation.cs | 59 +++++++ .../Authorization/AuthorizationDecision.cs | 48 ++++++ .../Authorization/IPermissionEvaluator.cs | 23 +++ .../Authorization/NodeScope.cs | 58 +++++++ .../Authorization/PermissionTrie.cs | 125 ++++++++++++++ .../Authorization/PermissionTrieBuilder.cs | 97 +++++++++++ .../Authorization/PermissionTrieCache.cs | 88 ++++++++++ .../Authorization/TriePermissionEvaluator.cs | 70 ++++++++ .../Authorization/UserAuthorizationState.cs | 69 ++++++++ .../Authorization/PermissionTrieCacheTests.cs | 104 ++++++++++++ .../Authorization/PermissionTrieTests.cs | 157 ++++++++++++++++++ .../TriePermissionEvaluatorTests.cs | 154 +++++++++++++++++ .../UserAuthorizationStateTests.cs | 60 +++++++ 13 files changed, 1112 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/Authorization/IPermissionEvaluator.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieCache.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieCacheTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/UserAuthorizationStateTests.cs 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"); + } +}