diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeAclScopeKind.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeAclScopeKind.cs index b6ad45c..c30262c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeAclScopeKind.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeAclScopeKind.cs @@ -8,5 +8,11 @@ public enum NodeAclScopeKind UnsArea, UnsLine, Equipment, + /// + /// A Galaxy (SystemPlatform-kind) folder segment anchored below a namespace. + /// Distinguishes folder grants from UNS grants in the + /// AuthorizationDecision.Provenance audit trail and Admin UI diagnostics. + /// + FolderSegment, Tag, } 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 225dc38..7de8644 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs @@ -79,16 +79,15 @@ public sealed class PermissionTrie 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. + // 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.Equipment, groups, matches); + CollectAtLevel(child, NodeAclScopeKind.FolderSegment, groups, matches); current = child; } 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 574d0b5..d11e116 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 @@ -125,6 +125,55 @@ public sealed class PermissionTrieTests matchB.ShouldBeEmpty(); } + /// + /// 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. + /// + [Fact] + public void Galaxy_FolderSegment_Grant_Reports_FolderSegment_Scope_Not_Equipment() + { + var paths = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["section-X"] = new(new[] { "ns-gal", "section-X" }), + }; + var rows = new[] { Row("cn=ops", NodeAclScopeKind.Equipment, "section-X", NodePermissions.Read) }; + var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths); + + var matches = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["section-X"], "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"); + } + + [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"); + } + [Fact] public void CrossCluster_Grant_DoesNotLeak() {