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);