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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>Per decision #129 and the Phase 6.2 Stream B plan the hierarchy is
|
/// <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
|
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> for all
|
||||||
/// (Equipment-kind) namespaces. Galaxy (SystemPlatform-kind) namespaces instead use
|
/// (Equipment-kind) namespaces. Galaxy is a standard Equipment-kind driver, so Galaxy
|
||||||
/// <c>Cluster → Namespace → FolderSegment(s) → Tag</c>, and each folder segment takes
|
/// points are ordinary equipment tags that resolve through this same walk.</para>
|
||||||
/// one trie level — so a deeply-nested Galaxy folder implicitly reaches the same
|
|
||||||
/// depth as a full UNS path.</para>
|
|
||||||
///
|
///
|
||||||
/// <para>Unset mid-path levels (e.g. a Cluster-scoped request with no UnsArea) leave
|
/// <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 +
|
/// 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>
|
/// <summary>Namespace within the cluster. Null is not allowed for a request against a real node.</summary>
|
||||||
public string? NamespaceId { get; init; }
|
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; }
|
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; }
|
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; }
|
public string? EquipmentId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Target tag id when the scope addresses a specific tag; null for equipment-level scopes.</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>
|
|
||||||
public string? TagId { get; init; }
|
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; }
|
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
|
public enum NodeHierarchyKind
|
||||||
{
|
{
|
||||||
/// <summary><c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> — UNS / Equipment kind.</summary>
|
/// <summary><c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> — UNS / Equipment kind.</summary>
|
||||||
Equipment,
|
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>
|
/// <summary>
|
||||||
/// In-memory permission trie for one <c>(ClusterId, GenerationId)</c>. Walk from the cluster
|
/// 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
|
/// <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
|
/// LDAP groups. The accumulated bitmask is compared to the permission required by the
|
||||||
/// requested <see cref="Abstractions.OpcUaOperation"/>.
|
/// requested <see cref="Abstractions.OpcUaOperation"/>.
|
||||||
@@ -51,11 +51,10 @@ public sealed class PermissionTrie
|
|||||||
if (!Root.Children.TryGetValue(scope.NamespaceId, out var ns)) return matches;
|
if (!Root.Children.TryGetValue(scope.NamespaceId, out var ns)) return matches;
|
||||||
CollectAtLevel(ns, NodeAclScopeKind.Namespace, groups, matches);
|
CollectAtLevel(ns, NodeAclScopeKind.Namespace, groups, matches);
|
||||||
|
|
||||||
// Two hierarchies diverge below the namespace.
|
// Below the namespace every kind walks the Equipment hierarchy — Galaxy is a standard
|
||||||
if (scope.Kind == NodeHierarchyKind.Equipment)
|
// Equipment-kind driver, so Galaxy points are ordinary equipment tags keyed by
|
||||||
WalkEquipment(ns, scope, groups, matches);
|
// EquipmentId/path and resolve through this same walk.
|
||||||
else
|
WalkEquipment(ns, scope, groups, matches);
|
||||||
WalkSystemPlatform(ns, scope, groups, matches);
|
|
||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
@@ -79,25 +78,6 @@ public sealed class PermissionTrie
|
|||||||
CollectAtLevel(tag, NodeAclScopeKind.Tag, groups, matches);
|
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)
|
private static void CollectAtLevel(PermissionTrieNode node, NodeAclScopeKind level, HashSet<string> groups, List<MatchedGrant> matches)
|
||||||
{
|
{
|
||||||
foreach (var grant in node.Grants)
|
foreach (var grant in node.Grants)
|
||||||
|
|||||||
@@ -121,8 +121,7 @@ public static class PermissionTrieBuilder
|
|||||||
/// <see cref="NodeAclScopeKind.UnsLine"/>-scoped row sits in the hierarchy.
|
/// <see cref="NodeAclScopeKind.UnsLine"/>-scoped row sits in the hierarchy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Segments">
|
/// <param name="Segments">
|
||||||
/// Namespace id, then (for Equipment kind) UnsAreaId / UnsLineId / EquipmentId / TagId as
|
/// Namespace id, then UnsAreaId / UnsLineId / EquipmentId / TagId as applicable.
|
||||||
/// applicable; or (for SystemPlatform kind) NamespaceId / FolderSegment / .../TagId.
|
|
||||||
/// </param>
|
/// </param>
|
||||||
public sealed record NodeAclPath(IReadOnlyList<string> Segments);
|
public sealed record NodeAclPath(IReadOnlyList<string> Segments);
|
||||||
|
|
||||||
|
|||||||
@@ -33,16 +33,6 @@ public sealed class PermissionTrieTests
|
|||||||
Kind = NodeHierarchyKind.Equipment,
|
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>
|
/// <summary>Verifies cluster-level grant cascades to every tag.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ClusterLevelGrant_Cascades_ToEveryTag()
|
public void ClusterLevelGrant_Cascades_ToEveryTag()
|
||||||
@@ -111,72 +101,48 @@ public sealed class PermissionTrieTests
|
|||||||
matches.ShouldBeEmpty();
|
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]
|
[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)
|
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 trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||||
|
|
||||||
var matchA = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-A"], "tag1"), ["cn=ops"]);
|
var matchA = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-A", "tag1"), ["cn=ops"]);
|
||||||
var matchB = trie.CollectMatches(GalaxyTag("c1", "ns-gal", ["folder-B"], "tag1"), ["cn=ops"]);
|
var matchB = trie.CollectMatches(EquipmentTag("c1", "ns-gal", "area1", "line1", "gal-eq-B", "tag1"), ["cn=ops"]);
|
||||||
|
|
||||||
matchA.Count.ShouldBe(1);
|
matchA.Count.ShouldBe(1);
|
||||||
matchB.ShouldBeEmpty();
|
matchB.ShouldBeEmpty("grant at gal-eq-A must not apply to sibling gal-eq-B");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Core-003 regression: grants matched during the SystemPlatform (Galaxy) folder walk must
|
/// A grant on a Galaxy equipment tag is reported with its true Equipment-walk structural
|
||||||
/// report <see cref="NodeAclScopeKind.FolderSegment"/> in <see cref="MatchedGrant.Scope"/>,
|
/// scope (Equipment), not a Galaxy-specific scope — Galaxy now flows through the same
|
||||||
/// not <see cref="NodeAclScopeKind.Equipment"/>. This distinguishes Galaxy folder grants
|
/// Equipment permission walk as every other equipment tag.
|
||||||
/// from UNS Equipment grants in the audit trail and Admin UI "Probe this permission" panel.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[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)
|
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 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.Count.ShouldBe(1);
|
||||||
matches[0].Scope.ShouldBe(NodeAclScopeKind.FolderSegment,
|
matches[0].Scope.ShouldBe(NodeAclScopeKind.Equipment,
|
||||||
"the trie walk reports the structural level where the grant was found — FolderSegment for Galaxy, not Equipment");
|
"Galaxy equipment grants resolve via the Equipment walk and report Equipment scope");
|
||||||
}
|
|
||||||
|
|
||||||
/// <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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies cross-cluster grant does not leak.</summary>
|
/// <summary>Verifies cross-cluster grant does not leak.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user