diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs index c2806c7e..353f677f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs @@ -7,11 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; /// /// /// 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. +/// Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag for all +/// (Equipment-kind) namespaces. Galaxy is a standard Equipment-kind driver, so Galaxy +/// points are ordinary equipment tags that resolve through this same walk. /// /// 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 + @@ -25,34 +23,25 @@ public sealed record NodeScope /// 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. + /// UNS area (e.g. "warsaw-west") below the namespace. public string? UnsAreaId { get; init; } - /// For Equipment-kind namespaces: UNS line below the area. Null on Galaxy. + /// UNS line below the area. public string? UnsLineId { get; init; } - /// For Equipment-kind namespaces: equipment row below the line. Null on Galaxy. + /// Equipment row below the line. 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. + /// Target tag id when the scope addresses a specific tag; null for equipment-level scopes. public string? TagId { get; init; } - /// Which hierarchy applies — Equipment-kind (UNS) or SystemPlatform-kind (Galaxy). + /// Which hierarchy applies. Currently only Equipment-kind (UNS) exists. public required NodeHierarchyKind Kind { get; init; } } -/// Selector between the two scope-hierarchy shapes. +/// Selector for the scope-hierarchy shape. 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/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs index fc82204a..df1ec481 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs @@ -4,7 +4,7 @@ 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 +/// root down through namespace → UNS levels → 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 . @@ -51,11 +51,10 @@ public sealed class PermissionTrie 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); + // Below the namespace every kind walks the Equipment hierarchy — Galaxy is a standard + // Equipment-kind driver, so Galaxy points are ordinary equipment tags keyed by + // EquipmentId/path and resolve through this same walk. + WalkEquipment(ns, scope, groups, matches); return matches; } @@ -79,25 +78,6 @@ public sealed class PermissionTrie 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. Use the - // dedicated FolderSegment scope kind so Galaxy folder grants report their true scope in - // AuthorizationDecision.Provenance — distinguishing them from UNS Equipment grants in - // the audit trail and Admin UI "Probe this permission" diagnostic. - var current = ns; - foreach (var segment in scope.FolderSegments) - { - if (!current.Children.TryGetValue(segment, out var child)) return; - CollectAtLevel(child, NodeAclScopeKind.FolderSegment, 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) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs index 3e6fd838..c9f0548a 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs @@ -121,8 +121,7 @@ public static class PermissionTrieBuilder /// -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. +/// Namespace id, then UnsAreaId / UnsLineId / EquipmentId / TagId as applicable. /// public sealed record NodeAclPath(IReadOnlyList Segments); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs index b87484c0..133b5e7c 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs @@ -33,16 +33,6 @@ public sealed class PermissionTrieTests 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, - }; - /// Verifies cluster-level grant cascades to every tag. [Fact] public void ClusterLevelGrant_Cascades_ToEveryTag() @@ -111,72 +101,48 @@ public sealed class PermissionTrieTests matches.ShouldBeEmpty(); } - /// Verifies Galaxy folder segment grant does not leak to sibling folder. + /// + /// Galaxy is now a standard Equipment-kind driver: Galaxy points are ordinary equipment + /// tags, so a grant on a Galaxy equipment resolves via the Equipment walk and must not leak + /// to a sibling Galaxy equipment. + /// [Fact] - public void Galaxy_FolderSegment_Grant_DoesNotLeak_To_Sibling_Folder() + public void Galaxy_EquipmentScope_Grant_DoesNotLeak_To_Sibling_Equipment() { var paths = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["folder-A"] = new(new[] { "ns-gal", "folder-A" }), + ["gal-eq-A"] = new(new[] { "ns-gal", "area1", "line1", "gal-eq-A" }), }; - var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "folder-A", NodePermissions.Read) }; + var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq-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"]); + var matchA = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-A", "tag1"), ["cn=ops"]); + var matchB = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-B", "tag1"), ["cn=ops"]); matchA.Count.ShouldBe(1); - matchB.ShouldBeEmpty(); + matchB.ShouldBeEmpty("grant at gal-eq-A must not apply to sibling gal-eq-B"); } /// - /// Core-003 regression: grants matched during the SystemPlatform (Galaxy) folder walk must - /// report in , - /// not . This distinguishes Galaxy folder grants - /// from UNS Equipment grants in the audit trail and Admin UI "Probe this permission" panel. + /// A grant on a Galaxy equipment tag is reported with its true Equipment-walk structural + /// scope (Equipment), not a Galaxy-specific scope — Galaxy now flows through the same + /// Equipment permission walk as every other equipment tag. /// [Fact] - public void Galaxy_FolderSegment_Grant_Reports_FolderSegment_Scope_Not_Equipment() + public void Galaxy_EquipmentScope_Grant_Reports_Equipment_Scope() { var paths = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["section-X"] = new(new[] { "ns-gal", "section-X" }), + ["gal-eq"] = new(new[] { "ns-gal", "area1", "line1", "gal-eq" }), }; - var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "section-X", NodePermissions.Read) }; + var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "gal-eq", NodePermissions.Read) }; var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths); - var matches = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["section-X"], "tag1"), ["cn=ops"]); + var matches = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq", "tag1"), ["cn=ops"]); matches.Count.ShouldBe(1); - matches[0].Scope.ShouldBe(NodeAclScopeKind.FolderSegment, - "the trie walk reports the structural level where the grant was found — FolderSegment for Galaxy, not Equipment"); - } - - /// Verifies Galaxy deep folder path all segments report folder segment scope. - [Fact] - public void Galaxy_DeepFolderPath_AllSegments_Report_FolderSegment_Scope() - { - // A three-level folder path — each visited level that carries a grant must report FolderSegment. - var paths = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["area"] = new(new[] { "ns-gal", "area" }), - ["area/line"] = new(new[] { "ns-gal", "area", "line" }), - ["area/line/cell"] = new(new[] { "ns-gal", "area", "line", "cell" }), - }; - var rows = new[] - { - Row("cn=ops", NodeAclScopeKind.Equipment, "area", NodePermissions.Read), - Row("cn=ops", NodeAclScopeKind.Equipment, "area/line", NodePermissions.Read), - Row("cn=ops", NodeAclScopeKind.Equipment, "area/line/cell", NodePermissions.Read), - }; - var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths); - - var matches = trie.CollectMatches( - GalaxyTag("c1", "ns-gal", ["area", "line", "cell"], "tag1"), ["cn=ops"]); - - matches.Count.ShouldBe(3, "one match per folder level visited"); - matches.ShouldAllBe(m => m.Scope == NodeAclScopeKind.FolderSegment, - "every matched folder level must report FolderSegment, never Equipment"); + matches[0].Scope.ShouldBe(NodeAclScopeKind.Equipment, + "Galaxy equipment grants resolve via the Equipment walk and report Equipment scope"); } /// Verifies cross-cluster grant does not leak.