af5c9744b7
UNS Area/Line/Equipment folders are minted under their opaque entity ids (s=UA-/UL-/EQ-...), virtual tags under s=EQ-.../Name and scripted conditions (+ #629 children) under s=<ScriptedAlarmId>[/...], while every UNS grant is planted at a NAME path. DataPlaneAuthorizer split those ids, so with Security:Acl:Enabled a narrow (Area/Line/Equipment) grant could not Browse, Read, subscribe or HistoryRead them, and Server-object delivery never reached it — so a narrow key never held the EventId Ack/Confirm/AddComment need. - The generation carries UnsFolderScopes (folderId -> path + id chain), built by DeploymentArtifact from the same AclScopePathResolver as the grants; ids two entity kinds share are dropped; plumbed through IDataPlaneAuthoritySink. - OtOpcUaNodeManager.ResolveUnsAclSubject reports live facts (folder id ancestry / scripted owner binding); attached by the Authorizer setter. - Evaluate places the subject against the CURRENT generation before the decision cache, which now caches the walk keyed on the resolved scope. Folders require live chain == generation chain (a moved equipment is denied under old and new line until the rebuild). Owned nodes (vtags, conditions, children) get owner path + a reserved segment, so Tag grants and their Browse transit never expose them. Conditions share TryPlaceScriptedCondition with the #797 command gate. - Server-object visibility of a scripted condition is decided on the condition alone, so a moved alarm cannot reach its old owner there. Tests: UnsFolderScopeWireTests (opaque ids, end-to-end browse -> EventId -> Acknowledge on an Equipment grant; sibling denied; Tag-only grant; moved alarm and moved equipment stale windows). Rig tests now install folder scopes explicitly (ServerEventRig.FolderScopes); the #797 recreate test deliberately asserts the moved alarm is invisible to the Cluster observer in the window.
316 lines
15 KiB
C#
316 lines
15 KiB
C#
using System.Text;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// End-to-end coverage of the ACL scope path as it actually runs on a node (#520 stage 3):
|
|
/// artifact bytes → <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/> → scope paths →
|
|
/// <see cref="PermissionTrieBuilder.Build"/> → <see cref="PermissionTrie.CollectMatches"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This is the coverage the design doc called out as missing, and it was missing in a specific
|
|
/// way worth remembering: the pre-stage-3 tests all hand-built the <c>scopePaths</c> dictionary in
|
|
/// the test body with exactly the segments the walker expected, so the walker was pinned against
|
|
/// the test's idea of the format rather than against any producer. Nothing proved a real artifact
|
|
/// could yield a matching grant, and in fact nothing could have — there was no producer at all.
|
|
/// </para>
|
|
/// <para>
|
|
/// So the artifact JSON here is deliberately hand-written rather than composed: it is standing in
|
|
/// for the wire format, and the parser must agree with <em>it</em>. The composer→parser agreement
|
|
/// is pinned separately, in <c>ControlPlane.Tests/DataPlaneAuthorityArtifactTests</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DeploymentArtifactScopePathTests
|
|
{
|
|
private const string NodeId = "node-a:4840";
|
|
|
|
// Plant/Line1 → ModbusDrv → dev1 → Fast → Speed, projected into equipment West/L1/Mixer as "MixerSpeed".
|
|
private const string Artifact = """
|
|
{
|
|
"RawFolders": [
|
|
{"RawFolderId":"RF-plant","ClusterId":"c1","ParentRawFolderId":null,"Name":"Plant"},
|
|
{"RawFolderId":"RF-line1","ClusterId":"c1","ParentRawFolderId":"RF-plant","Name":"Line1"}
|
|
],
|
|
"DriverInstances": [
|
|
{"DriverInstanceId":"DI-modbus","ClusterId":"c1","RawFolderId":"RF-line1","Name":"ModbusDrv"}
|
|
],
|
|
"Devices": [{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1"}],
|
|
"TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}],
|
|
"Tags": [{"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed"}],
|
|
"UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}],
|
|
"UnsLines": [{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}],
|
|
"Equipment": [{"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer"}],
|
|
"UnsTagReferences": [
|
|
{"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed","DisplayNameOverride":"MixerSpeed"}
|
|
],
|
|
"NodeAcls": [
|
|
{"NodeAclId":"acl-dev","ClusterId":"c1","Principal":"p","ScopeKind":9,"ScopeId":"DV-1","PermissionFlags":2},
|
|
{"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16},
|
|
{"NodeAclId":"acl-dangling","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-gone","PermissionFlags":16}
|
|
]
|
|
}
|
|
""";
|
|
|
|
private static (PermissionTrie Trie, List<PermissionTrieBuildDiagnostic> Diagnostics) BuildFromArtifact()
|
|
{
|
|
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
|
|
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
|
var trie = PermissionTrieBuilder.Build("c1", 1, projection.Grants, projection.ScopePaths, diagnostics.Add);
|
|
return (trie, diagnostics);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_raw_device_grant_from_the_artifact_matches_a_tag_beneath_that_device()
|
|
{
|
|
var (trie, _) = BuildFromArtifact();
|
|
|
|
var matches = trie.CollectMatches(
|
|
new NodeScope
|
|
{
|
|
ClusterId = "c1",
|
|
Kind = NodeHierarchyKind.Raw,
|
|
Segments = ["Plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
|
|
},
|
|
["p"]);
|
|
|
|
var match = matches.ShouldHaveSingleItem();
|
|
match.PermissionFlags.ShouldBe(NodePermissions.Read);
|
|
// Provenance is the authored kind, not the depth the grant happens to sit at.
|
|
match.Scope.ShouldBe(NodeAclScopeKind.RawDevice);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_tag_under_a_DIFFERENT_device_does_not_match_that_grant()
|
|
{
|
|
// The negative control. Without it the test above passes for any trie that returns every grant it
|
|
// holds regardless of path — which is exactly what a broken walk would do.
|
|
var (trie, _) = BuildFromArtifact();
|
|
|
|
trie.CollectMatches(
|
|
new NodeScope
|
|
{
|
|
ClusterId = "c1",
|
|
Kind = NodeHierarchyKind.Raw,
|
|
Segments = ["Plant", "Line1", "ModbusDrv", "otherdev", "Fast", "Speed"],
|
|
},
|
|
["p"]).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void The_same_grant_set_matches_through_the_UNS_realm_too()
|
|
{
|
|
// The equipment grant is authored once and must be reachable at the UNS NodeId, which is a
|
|
// completely different path from the raw one for the same underlying value.
|
|
var (trie, _) = BuildFromArtifact();
|
|
|
|
var matches = trie.CollectMatches(
|
|
new NodeScope
|
|
{
|
|
ClusterId = "c1",
|
|
Kind = NodeHierarchyKind.Uns,
|
|
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
|
|
},
|
|
["p"]);
|
|
|
|
matches.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_raw_scope_does_not_pick_up_a_UNS_grant_at_the_same_depth()
|
|
{
|
|
// Realm isolation. "West/L1/Mixer" and a three-segment raw path are the same depth, so without the
|
|
// realm gate at level 1 a UNS grant would match a raw node whose folder/driver/device happened to
|
|
// be named the same. The two trees are authored independently, so that collision is not exotic.
|
|
var (trie, _) = BuildFromArtifact();
|
|
|
|
trie.CollectMatches(
|
|
new NodeScope
|
|
{
|
|
ClusterId = "c1",
|
|
Kind = NodeHierarchyKind.Raw,
|
|
Segments = ["West", "L1", "Mixer", "MixerSpeed"],
|
|
},
|
|
["p"]).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_dangling_scope_is_dropped_with_a_diagnostic_rather_than_planted()
|
|
{
|
|
var (_, diagnostics) = BuildFromArtifact();
|
|
|
|
var diagnostic = diagnostics.ShouldHaveSingleItem();
|
|
diagnostic.NodeAclId.ShouldBe("acl-dangling");
|
|
diagnostic.Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.UnresolvedScope);
|
|
}
|
|
|
|
[Fact]
|
|
public void Case_drift_in_a_path_segment_grants_nothing()
|
|
{
|
|
// Raw sibling names are unique under an ordinal comparer, so "Speed" and "speed" can both exist
|
|
// under one device. The trie compared OrdinalIgnoreCase before stage 3, which would have let a
|
|
// grant on either match both. This goes red if the comparer is reverted.
|
|
var (trie, _) = BuildFromArtifact();
|
|
|
|
trie.CollectMatches(
|
|
new NodeScope
|
|
{
|
|
ClusterId = "c1",
|
|
Kind = NodeHierarchyKind.Raw,
|
|
Segments = ["plant", "Line1", "ModbusDrv", "dev1", "Fast", "Speed"],
|
|
},
|
|
["p"]).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void An_artifact_with_no_topology_resolves_no_scopes()
|
|
{
|
|
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
|
|
Encoding.UTF8.GetBytes("""
|
|
{"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-x","PermissionFlags":1}]}
|
|
"""),
|
|
NodeId);
|
|
|
|
projection.Grants.ShouldHaveSingleItem();
|
|
projection.ScopePaths.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #797 — the artifact also yields every equipment's OWN path (the scripted-alarm owner lookup), and
|
|
/// it is identical to the path the same artifact plants an Equipment grant at. That equality is what makes
|
|
/// a narrow equipment grant authorize the equipment's scripted alarms: if the two were produced by
|
|
/// different rules, the owner binding would silently authorize at a node no grant can reach.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Every_equipment_owner_path_equals_the_path_its_equipment_grant_is_planted_at()
|
|
{
|
|
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
|
|
|
|
projection.EquipmentScopePaths.ShouldContainKey("EQ-mixer");
|
|
var owner = projection.EquipmentScopePaths["EQ-mixer"];
|
|
owner.Segments.ShouldBe(["uns", "West", "L1", "Mixer"]);
|
|
projection.ScopePaths[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")]
|
|
.ShouldHaveSingleItem().Segments.ShouldBe(owner.Segments);
|
|
|
|
// Keyed by identifier (OrdinalIgnoreCase), like every other id lookup on the authorization path.
|
|
projection.EquipmentScopePaths.ContainsKey("eq-MIXER").ShouldBeTrue();
|
|
// A grant naming an equipment the artifact does not carry yields no owner path either.
|
|
projection.EquipmentScopePaths.ContainsKey("EQ-gone").ShouldBeFalse();
|
|
|
|
var snapshot = new DataPlaneAuthorityState().Apply(
|
|
"c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths);
|
|
snapshot.TryGetEquipmentUnsSegments("EQ-mixer", out var segments).ShouldBeTrue();
|
|
segments.ShouldBe(["West", "L1", "Mixer"]);
|
|
}
|
|
|
|
/// <summary>An equipment whose UNS chain does not resolve (dangling line) has no owner path.</summary>
|
|
[Fact]
|
|
public void An_equipment_with_a_dangling_line_has_no_owner_path()
|
|
{
|
|
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
|
|
Encoding.UTF8.GetBytes("""
|
|
{
|
|
"UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}],
|
|
"Equipment": [{"EquipmentId":"EQ-orphan","UnsLineId":"UL-missing","Name":"Orphan"}],
|
|
"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}]
|
|
}
|
|
"""),
|
|
NodeId);
|
|
|
|
projection.EquipmentScopePaths.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// #797 review — the generation also carries each scripted alarm's deployed owner, decoded from the same
|
|
/// <c>ScriptedAlarms</c> rows the address space is composed from, keyed ordinally by the alarm id.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_projection_carries_each_scripted_alarms_deployed_owner()
|
|
{
|
|
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
|
|
Encoding.UTF8.GetBytes("""
|
|
{
|
|
"ScriptedAlarms": [
|
|
{"ScriptedAlarmId":"scripted-eqa-hi","EquipmentId":"EQ-mixer","Name":"hi"},
|
|
{"ScriptedAlarmId":"scripted-no-owner","Name":"x"}
|
|
],
|
|
"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}]
|
|
}
|
|
"""),
|
|
NodeId);
|
|
|
|
projection.ScriptedAlarmOwners.ShouldBe(new Dictionary<string, string> { ["scripted-eqa-hi"] = "EQ-mixer" });
|
|
projection.ScriptedAlarmOwners.ContainsKey("SCRIPTED-EQA-HI").ShouldBeFalse("alarm ids are NodeId identifiers: ordinal");
|
|
|
|
var snapshot = new DataPlaneAuthorityState().Apply("c1", "rev-1", projection.Keys, projection.Grants,
|
|
projection.ScopePaths, projection.EquipmentScopePaths, projection.ScriptedAlarmOwners);
|
|
snapshot.TryGetScriptedAlarmOwner("scripted-eqa-hi", out var owner).ShouldBeTrue();
|
|
owner.ShouldBe("EQ-mixer");
|
|
snapshot.TryGetScriptedAlarmOwner("scripted-no-owner", out _).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #808 — the artifact yields every UNS Area / Line / Equipment folder's placement: the SAME path a grant
|
|
/// on that entity is planted at, plus the folder-id chain the address space nests it under. The path equality
|
|
/// is what lets a narrow grant reach the opaque-id folder it names; the chain is what the authorizer compares
|
|
/// the live folder against.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Every_uns_folder_scope_equals_its_entity_grant_path_and_carries_its_id_chain()
|
|
{
|
|
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
|
|
|
|
var folders = projection.UnsFolderScopes;
|
|
folders.Keys.Order(StringComparer.Ordinal).ShouldBe(["EQ-mixer", "UA-w", "UL-1"]);
|
|
folders["UA-w"].Path.Segments.ShouldBe(["uns", "West"]);
|
|
folders["UA-w"].IdChain.ShouldBe(["UA-w"]);
|
|
folders["UL-1"].Path.Segments.ShouldBe(["uns", "West", "L1"]);
|
|
folders["UL-1"].IdChain.ShouldBe(["UA-w", "UL-1"]);
|
|
folders["EQ-mixer"].IdChain.ShouldBe(["UA-w", "UL-1", "EQ-mixer"]);
|
|
folders["EQ-mixer"].Path.Segments.ShouldBe(
|
|
projection.ScopePaths[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")].ShouldHaveSingleItem().Segments);
|
|
|
|
var snapshot = new DataPlaneAuthorityState().Apply(
|
|
"c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths,
|
|
projection.ScriptedAlarmOwners, projection.UnsFolderScopes);
|
|
snapshot.TryGetUnsFolderSegments("EQ-mixer", ["UA-w", "UL-1", "EQ-mixer"], out var segments).ShouldBeTrue();
|
|
segments.ShouldBe(["West", "L1", "Mixer"]);
|
|
// A live folder nested differently from what the generation deploys (moved, not yet rebuilt) is not placed
|
|
// at all — neither at the generation's path nor at its old one.
|
|
snapshot.TryGetUnsFolderSegments("EQ-mixer", ["UA-w", "UL-2", "EQ-mixer"], out _).ShouldBeFalse();
|
|
snapshot.TryGetUnsFolderSegments("EQ-mixer", ["EQ-mixer"], out _).ShouldBeFalse();
|
|
snapshot.TryGetUnsFolderSegments("EQ-gone", ["UA-w", "UL-1", "EQ-gone"], out _).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #808 — an id two UNS entity kinds share (the address space can hold only one folder for it) is dropped
|
|
/// rather than placed by whichever kind came first; an orphaned line or a line-less equipment has no scope.
|
|
/// </summary>
|
|
[Fact]
|
|
public void An_ambiguous_or_orphaned_uns_folder_has_no_scope()
|
|
{
|
|
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
|
|
Encoding.UTF8.GetBytes("""
|
|
{
|
|
"UnsAreas": [{"UnsAreaId":"X-1","ClusterId":"c1","Name":"West"}],
|
|
"UnsLines": [
|
|
{"UnsLineId":"X-1","UnsAreaId":"X-1","Name":"Clash"},
|
|
{"UnsLineId":"UL-orphan","UnsAreaId":"UA-missing","Name":"Orphan"},
|
|
{"UnsLineId":"UL-ok","UnsAreaId":"X-1","Name":"L1"}
|
|
],
|
|
"Equipment": [{"EquipmentId":"EQ-dangling","UnsLineId":"UL-missing","Name":"E"}],
|
|
"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}]
|
|
}
|
|
"""),
|
|
NodeId);
|
|
|
|
projection.UnsFolderScopes.Keys.ShouldBe(["UL-ok"]);
|
|
}
|
|
}
|