feat(authz): remove SystemPlatform scope + permission-trie walk (Galaxy resolves via Equipment)
This commit is contained in:
@@ -7,11 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Per decision #129 and the Phase 6.2 Stream B plan the hierarchy is
|
||||
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> for UNS
|
||||
/// (Equipment-kind) namespaces. Galaxy (SystemPlatform-kind) namespaces instead use
|
||||
/// <c>Cluster → Namespace → FolderSegment(s) → Tag</c>, and each folder segment takes
|
||||
/// one trie level — so a deeply-nested Galaxy folder implicitly reaches the same
|
||||
/// depth as a full UNS path.</para>
|
||||
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> 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.</para>
|
||||
///
|
||||
/// <para>Unset mid-path levels (e.g. a Cluster-scoped request with no UnsArea) leave
|
||||
/// the corresponding id <c>null</c>. The evaluator walks as far as the scope goes +
|
||||
@@ -25,34 +23,25 @@ public sealed record NodeScope
|
||||
/// <summary>Namespace within the cluster. Null is not allowed for a request against a real node.</summary>
|
||||
public string? NamespaceId { get; init; }
|
||||
|
||||
/// <summary>For Equipment-kind namespaces: UNS area (e.g. "warsaw-west"). Null on Galaxy.</summary>
|
||||
/// <summary>UNS area (e.g. "warsaw-west") below the namespace.</summary>
|
||||
public string? UnsAreaId { get; init; }
|
||||
|
||||
/// <summary>For Equipment-kind namespaces: UNS line below the area. Null on Galaxy.</summary>
|
||||
/// <summary>UNS line below the area.</summary>
|
||||
public string? UnsLineId { get; init; }
|
||||
|
||||
/// <summary>For Equipment-kind namespaces: equipment row below the line. Null on Galaxy.</summary>
|
||||
/// <summary>Equipment row below the line.</summary>
|
||||
public string? EquipmentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// For Galaxy (SystemPlatform-kind) namespaces only: the folder path segments from
|
||||
/// namespace root to the target tag, in order. Empty on Equipment namespaces.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FolderSegments { get; init; } = [];
|
||||
|
||||
/// <summary>Target tag id when the scope addresses a specific tag; null for folder / equipment-level scopes.</summary>
|
||||
/// <summary>Target tag id when the scope addresses a specific tag; null for equipment-level scopes.</summary>
|
||||
public string? TagId { get; init; }
|
||||
|
||||
/// <summary>Which hierarchy applies — Equipment-kind (UNS) or SystemPlatform-kind (Galaxy).</summary>
|
||||
/// <summary>Which hierarchy applies. Currently only Equipment-kind (UNS) exists.</summary>
|
||||
public required NodeHierarchyKind Kind { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Selector between the two scope-hierarchy shapes.</summary>
|
||||
/// <summary>Selector for the scope-hierarchy shape.</summary>
|
||||
public enum NodeHierarchyKind
|
||||
{
|
||||
/// <summary><c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> — UNS / Equipment kind.</summary>
|
||||
Equipment,
|
||||
|
||||
/// <summary><c>Cluster → Namespace → FolderSegment(s) → Tag</c> — Galaxy / SystemPlatform kind.</summary>
|
||||
SystemPlatform,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. 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
|
||||
/// <see cref="TrieGrant.PermissionFlags"/> 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 <see cref="Abstractions.OpcUaOperation"/>.
|
||||
@@ -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<string> groups, List<MatchedGrant> 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<string> groups, List<MatchedGrant> matches)
|
||||
{
|
||||
foreach (var grant in node.Grants)
|
||||
|
||||
@@ -121,8 +121,7 @@ public static class PermissionTrieBuilder
|
||||
/// <see cref="NodeAclScopeKind.UnsLine"/>-scoped row sits in the hierarchy.
|
||||
/// </summary>
|
||||
/// <param name="Segments">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public sealed record NodeAclPath(IReadOnlyList<string> Segments);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/// <summary>Verifies cluster-level grant cascades to every tag.</summary>
|
||||
[Fact]
|
||||
public void ClusterLevelGrant_Cascades_ToEveryTag()
|
||||
@@ -111,72 +101,48 @@ public sealed class PermissionTrieTests
|
||||
matches.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies Galaxy folder segment grant does not leak to sibling folder.</summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Galaxy_FolderSegment_Grant_DoesNotLeak_To_Sibling_Folder()
|
||||
public void Galaxy_EquipmentScope_Grant_DoesNotLeak_To_Sibling_Equipment()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-003 regression: grants matched during the SystemPlatform (Galaxy) folder walk must
|
||||
/// report <see cref="NodeAclScopeKind.FolderSegment"/> in <see cref="MatchedGrant.Scope"/>,
|
||||
/// not <see cref="NodeAclScopeKind.Equipment"/>. 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Galaxy_FolderSegment_Grant_Reports_FolderSegment_Scope_Not_Equipment()
|
||||
public void Galaxy_EquipmentScope_Grant_Reports_Equipment_Scope()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies Galaxy deep folder path all segments report folder segment scope.</summary>
|
||||
[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<string, NodeAclPath>(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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies cross-cluster grant does not leak.</summary>
|
||||
|
||||
Reference in New Issue
Block a user