Merge fix/808-uns-folder-scope
This commit is contained in:
+18
-7
@@ -87,7 +87,8 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1"));
|
||||
|
||||
// The next deployment carries only EqB's grant.
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths());
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
rig.AttachedEventItems.ShouldBe(1);
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
@@ -98,7 +99,8 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
collector.Has(onA, "t2").ShouldBeFalse("the revoked item must not receive events after the deployment");
|
||||
|
||||
// A deployment that restores the grant brings the feed back on its own.
|
||||
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths());
|
||||
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
rig.AttachedEventItems.ShouldBe(2);
|
||||
|
||||
rig.Transition("t3", active: true);
|
||||
@@ -140,7 +142,8 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1"));
|
||||
|
||||
rig.AttachedEventItems.ShouldBe(2);
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths());
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
rig.AttachedEventItems.ShouldBe(1);
|
||||
|
||||
subscription.RemoveItem(onA);
|
||||
@@ -149,7 +152,8 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
|
||||
// A restoring deployment must not re-attach the deleted item. The client could never see that
|
||||
// happen — it has deleted the item — so the server-side fan-out is what is asserted.
|
||||
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths());
|
||||
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
rig.AttachedEventItems.ShouldBe(1, "the deleted item must not be resurrected onto EqA's fan-out");
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
@@ -405,14 +409,16 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1"));
|
||||
|
||||
state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths());
|
||||
state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
events.Has(item, "t2").ShouldBeFalse("the revoked session must not receive alarms after the deployment");
|
||||
|
||||
state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths());
|
||||
state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
|
||||
rig.Transition("t3", active: true);
|
||||
await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3"));
|
||||
@@ -885,6 +891,10 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe,
|
||||
};
|
||||
|
||||
// Gitea #808: every Apply above also installs the placements the generation now carries — the equipment owner
|
||||
// paths + scripted-alarm owners (#797) and the UNS folder scopes (#808). This rig's folders are synthetic
|
||||
// root-level ids (EqA ⇒ [uns, EqA]), shared with ServerEventRig; production-shaped opaque ids are covered by
|
||||
// UnsFolderScopeWireTests. Without them every UNS folder and the scripted condition deny under EVERY grant.
|
||||
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
@@ -1038,7 +1048,8 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths());
|
||||
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
|
||||
+34
-6
@@ -237,18 +237,29 @@ public sealed class ScriptedAlarmOwnerScopeWireTests
|
||||
rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
|
||||
rig.Nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan);
|
||||
Project(rig, Alarm, pre with { Message = "recreated" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "recreated"));
|
||||
|
||||
// Gitea #808 — deliberate change: while the live binding (Fan) disagrees with the generation (Pump), the
|
||||
// condition is unplaceable for EVERY grant, so even the Cluster-granted observer does not receive its event
|
||||
// (it used to: the opaque id split to [uns, scripted-eqa-hi], which only a Cluster grant reached). Positive
|
||||
// control: a later event on the correctly placed sibling arrives, so "recreated" would have arrived first.
|
||||
Project(rig, FanAlarm, pre with { Message = "fan-control" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "fan-control"));
|
||||
events.Has(item, "recreated").ShouldBeFalse("a moved alarm is invisible until the generation agrees");
|
||||
|
||||
var staleEventId = rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray();
|
||||
var oldGrant = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
|
||||
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct));
|
||||
session, Uns(session, Alarm), "Acknowledge", staleEventId, ct));
|
||||
oldGrant.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
routed.Count.ShouldBe(1);
|
||||
|
||||
// The old grant was refused above with the generation STILL deploying the alarm under Pump (binding ≠
|
||||
// generation); now the deploy that moved it lands, granting the new owner.
|
||||
// generation); now the deploy that moved it lands, granting the new owner — and placing it again for the
|
||||
// observer, which sees its next event.
|
||||
Reapply(state, "rev-fan", Deployed(alarmOwner: Fan), [ServerEventRig.Key()], OwnerGrant(Fan), Observer());
|
||||
Project(rig, Alarm, pre with { Message = "moved" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "moved"));
|
||||
await ConditionMethodEventTests.CallAsync(
|
||||
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct);
|
||||
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "moved"), ct);
|
||||
|
||||
var unknown = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
|
||||
session, Uns(session, "scripted-never-deployed"), "Acknowledge", [1, 2, 3], ct));
|
||||
@@ -256,7 +267,7 @@ public sealed class ScriptedAlarmOwnerScopeWireTests
|
||||
|
||||
Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
events.ConditionEvents(item, "recreated").Count.ShouldBe(2, "the recreated base plus the new owner's one ack");
|
||||
events.ConditionEvents(item, "moved").Count.ShouldBe(2, "the re-placed base plus the new owner's one ack");
|
||||
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge"), (Alarm, "Acknowledge")]);
|
||||
|
||||
await observer.CloseAsync(ct);
|
||||
@@ -287,6 +298,23 @@ public sealed class ScriptedAlarmOwnerScopeWireTests
|
||||
new Dictionary<string, (string UnsLineId, string Name)> { [Pump] = (LineId, "Pump"), [Fan] = (LineId, "Fan") },
|
||||
[]);
|
||||
|
||||
/// <summary>
|
||||
/// The topology's UNS folder placements (Gitea #808), from the same resolver: a scripted condition is placed
|
||||
/// only while its owner's live folder ancestry matches the generation's.
|
||||
/// </summary>
|
||||
private static Dictionary<string, UnsFolderScope> FolderScopes()
|
||||
{
|
||||
var resolver = Resolver();
|
||||
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
|
||||
return new Dictionary<string, UnsFolderScope>
|
||||
{
|
||||
[AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]),
|
||||
[LineId] = new(One(NodeAclScopeKind.UnsLine, LineId), [AreaId, LineId]),
|
||||
[Pump] = new(One(NodeAclScopeKind.Equipment, Pump), [AreaId, LineId, Pump]),
|
||||
[Fan] = new(One(NodeAclScopeKind.Equipment, Fan), [AreaId, LineId, Fan]),
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, NodeAclPath> OwnerPaths()
|
||||
{
|
||||
var resolver = Resolver();
|
||||
@@ -313,7 +341,7 @@ public sealed class ScriptedAlarmOwnerScopeWireTests
|
||||
private static void Reapply(
|
||||
DataPlaneAuthorityState state, string revision, IReadOnlyDictionary<string, string> deployedOwners,
|
||||
ApiKey[] keys, params NodeAcl[] grants) =>
|
||||
state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners);
|
||||
state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners, FolderScopes());
|
||||
|
||||
/// <summary>The generation's deployed scripted-alarm owners; <paramref name="alarmOwner"/> moves the Pump alarm.</summary>
|
||||
private static Dictionary<string, string> Deployed(string alarmOwner = Pump) =>
|
||||
|
||||
+8
-3
@@ -18,6 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
public sealed class Sdk2HistoryAuthorityWireTests
|
||||
{
|
||||
private const string ValuePath = RawDeviceFolder + "/History";
|
||||
|
||||
// Gitea #808 review: a UNS variable is authorized at its placed path, and a reference-variable id that is not
|
||||
// its equipment's path + a name is denied (a moved/renamed equipment's stale ids). The alias used to reuse the
|
||||
// RAW path as its UNS id — a shape production never mints — so it now carries a UNS-shaped id under EqA.
|
||||
private const string AliasPath = EqA + "/HistoryAlias";
|
||||
private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public static TheoryData<string, string> Cases => new(
|
||||
@@ -79,20 +84,20 @@ public sealed class Sdk2HistoryAuthorityWireTests
|
||||
|
||||
private static void Apply(DataPlaneAuthorityState state, ApiKey key, NodeAcl[] grants) =>
|
||||
state.Apply(ServerEventRig.Cluster, Guid.NewGuid().ToString("N"), [key], grants,
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), unsFolderScopes: FolderScopes());
|
||||
|
||||
private static void Materialise(ServerEventRig rig)
|
||||
{
|
||||
rig.MaterialiseNative();
|
||||
rig.Sink.EnsureVariable(ValuePath, RawDeviceFolder, "History", "Double", false, AddressSpaceRealm.Raw, "same-backend-tag");
|
||||
rig.Sink.EnsureVariable(ValuePath, EqA, "HistoryAlias", "Double", false, AddressSpaceRealm.Uns, "same-backend-tag");
|
||||
rig.Sink.EnsureVariable(AliasPath, EqA, "HistoryAlias", "Double", false, AddressSpaceRealm.Uns, "same-backend-tag");
|
||||
}
|
||||
|
||||
private static async Task<Opc.Ua.HistoryReadResult> PageAsync(ISession session, string kind, bool alias,
|
||||
ByteString point, CancellationToken ct)
|
||||
{
|
||||
var realm = alias ? V3NodeIds.UnsNamespaceUri : V3NodeIds.RawNamespaceUri;
|
||||
var path = kind == "events" ? alias ? EqA : RawDeviceFolder : ValuePath;
|
||||
var path = kind == "events" ? alias ? EqA : RawDeviceFolder : alias ? AliasPath : ValuePath;
|
||||
var node = new NodeId(path, (ushort)session.NamespaceUris.GetIndex(realm));
|
||||
HistoryReadDetails details = kind == "events" ? new ReadEventDetails
|
||||
{
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ public sealed class Sdk2RepeatedAlarmAuthorityWireTests
|
||||
if (change == "grant") grants = [];
|
||||
if (change == "key") { var disabled = Key(); disabled.Enabled = false; keys = [disabled]; }
|
||||
if (change == "expiry") clock.Advance(TimeSpan.FromMinutes(20));
|
||||
else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners());
|
||||
else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners(), FolderScopes());
|
||||
|
||||
var error = await Should.ThrowAsync<ServiceResultException>(() => session.CallAsync(condition.NodeId, methodId, ct,
|
||||
Variant.From(currentId), Variant.From(new LocalizedText("must not be recorded"))));
|
||||
@@ -107,7 +107,7 @@ public sealed class Sdk2RepeatedAlarmAuthorityWireTests
|
||||
confirmNode.Value.ShouldBe(beforeConfirm);
|
||||
// A subsequent authorized same-item sentinel drains any queued method event before the assertion.
|
||||
clock.Reset();
|
||||
state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners());
|
||||
state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners(), FolderScopes());
|
||||
rig.Project(native, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "sentinel"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
events.Of(item).Count.ShouldBe(beforeEvents + 1, "the denied retry emits nothing before the sentinel");
|
||||
|
||||
@@ -126,7 +126,8 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
IReadOnlyDictionary<string, NodeAclPath> equipmentOwnerPaths, params NodeAcl[] grants)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners());
|
||||
state.Apply(
|
||||
Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), FolderScopes());
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);
|
||||
@@ -181,6 +182,19 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
[EqB] = new NodeAclPath([NodeAclRealms.Uns, EqB]),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The rig's UNS folder placements (Gitea #808): each equipment folder hangs directly under the root, so its
|
||||
/// id chain is itself and its path is <c>[uns, EqA]</c> / <c>[uns, EqB]</c> — the synthetic shape every
|
||||
/// pre-#808 rig test was written against. ⚠️ Production folder ids are opaque and their paths are NAMES; the
|
||||
/// #808 tests (<c>UnsFolderScopeWireTests</c>) use that shape instead, which this rig's coincidence hid.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, UnsFolderScope> FolderScopes() =>
|
||||
new Dictionary<string, UnsFolderScope>
|
||||
{
|
||||
[EqA] = new(new NodeAclPath([NodeAclRealms.Uns, EqA]), [EqA]),
|
||||
[EqB] = new(new NodeAclPath([NodeAclRealms.Uns, EqB]), [EqB]),
|
||||
};
|
||||
|
||||
/// <summary>The rig's deployed scripted-alarm owners: its one scripted alarm belongs to <see cref="EqA"/>.</summary>
|
||||
public static IReadOnlyDictionary<string, string> ScriptedAlarmOwners() =>
|
||||
new Dictionary<string, string> { [ScriptedAlarmId] = EqA };
|
||||
|
||||
+605
@@ -0,0 +1,605 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #808 over the wire — a real activated <c>apikey</c> session with <b>zero</b> server-wide roles and only a
|
||||
/// NARROW UNS grant, against a production-shaped UNS tree: every Area / Line / Equipment folder is minted under its
|
||||
/// opaque entity id (<c>UA-…</c>, <c>UL-…</c>, <c>EQ-…</c>), virtual tags under <c>EQ-…/Name</c>, scripted conditions
|
||||
/// under their opaque <c>ScriptedAlarmId</c>, while every grant is planted at a NAME path resolved by
|
||||
/// <see cref="AclScopePathResolver"/>. Before #808 such a session could not browse, read or subscribe any of those
|
||||
/// nodes, and never received a scripted-alarm event through the Server object — so it never held the EventId that
|
||||
/// Acknowledge / Confirm / AddComment need. <see cref="ServerEventRig"/>'s own folders (<c>EqA</c> ⇒
|
||||
/// <c>[uns, EqA]</c>) are deliberately NOT used: their id coincides with their path, which is what hid this.
|
||||
/// </summary>
|
||||
public sealed class UnsFolderScopeWireTests
|
||||
{
|
||||
private const string AreaId = "UA-0000000001";
|
||||
private const string Line1 = "UL-0000000001";
|
||||
private const string Line2 = "UL-0000000002";
|
||||
private const string Pump = "EQ-00000000a1b2";
|
||||
private const string Fan = "EQ-00000000c3d4";
|
||||
private const string Alarm = ServerEventRig.ScriptedAlarmId; // "scripted-eqa-hi", deployed under Pump
|
||||
private const string PumpLo = "scripted-pump-lo";
|
||||
private const string FanAlarm = "scripted-fan-hi";
|
||||
private const string PumpSpeed = Pump + "/Speed"; // a virtual tag: s=<EquipmentId>/<Name>
|
||||
private const string FanSpeed = Fan + "/Speed";
|
||||
private const string PumpFlow = "Area1/Line1/Pump/Flow"; // a UNS reference variable: s=<Area>/<Line>/<Equipment>/<Name>
|
||||
private const string FlowTagId = "TAG-flow";
|
||||
|
||||
private const NodePermissions Operator =
|
||||
NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm;
|
||||
|
||||
/// <summary>
|
||||
/// The ScadaBridge path end to end on an Equipment grant alone: navigate from the shared root down to the
|
||||
/// equipment, read its folder / alarm children / virtual tag / reference variable, receive its scripted alarm
|
||||
/// through the Server object, and Acknowledge with the EventId the session obtained ITSELF. Every sibling
|
||||
/// (another equipment's folder, alarm, alarm child, virtual tag, folder event item) is denied on the same
|
||||
/// session, and the sibling's alarm event — reported FIRST — never arrives.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_narrow_equipment_grant_browses_reads_receives_and_acknowledges_end_to_end()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns808EndToEnd", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
Install(rig, pumpLine: Line1, Deployed(), EquipmentGrant(Pump, Operator));
|
||||
var routed = Route(rig);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
AssertNoRoles(rig);
|
||||
|
||||
// Navigation: shared root → area → line → equipment, each step by Browse transit / the grant itself.
|
||||
(await BrowseAsync(session, RootNodeId(session), ct)).Ids.ShouldContain(AreaId);
|
||||
(await BrowseAsync(session, Uns(session, AreaId), ct)).Ids.ShouldBe([Line1], "Line2 holds nothing granted");
|
||||
(await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump], "the sibling equipment is withheld");
|
||||
var inPump = (await BrowseAsync(session, Uns(session, Pump), ct)).Ids;
|
||||
inPump.ShouldContain(Alarm);
|
||||
inPump.ShouldContain(PumpLo);
|
||||
inPump.ShouldContain(PumpSpeed);
|
||||
inPump.ShouldContain(PumpFlow);
|
||||
(await BrowseAsync(session, Uns(session, Fan), ct)).Status.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
// Reads: the folder, a scripted condition child (#629 instance-scoped id), the virtual tag, the reference.
|
||||
var pumpEnabled = ChildId(rig, Alarm, c => c.EnabledState!.Id!);
|
||||
var fanEnabled = ChildId(rig, FanAlarm, c => c.EnabledState!.Id!);
|
||||
pumpEnabled.ShouldStartWith(Alarm + "/");
|
||||
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, pumpEnabled), Attributes.Value, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, FanAlarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, fanEnabled), Attributes.Value, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, FanSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, AreaId), Attributes.DisplayName, ct))
|
||||
.ShouldBe(StatusCodes.BadUserAccessDenied, "transit confers Browse on the ancestors, never Read");
|
||||
|
||||
// Events: the session's OWN Server-object item, plus direct items on its equipment folder and the sibling's.
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
var pumpFolder = RecordedEvents.AddEventItem(subscription, Uns(session, Pump));
|
||||
var fanFolder = RecordedEvents.AddEventItem(subscription, Uns(session, Fan));
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
server.Status.Error.ShouldBeNull();
|
||||
pumpFolder.Status.Error.ShouldBeNull();
|
||||
fanFolder.Status.Error.ShouldNotBeNull().StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
|
||||
Project(rig, FanAlarm, pre with { Message = "fan-base" }); // reported first: a late copy would precede "base"
|
||||
Project(rig, Alarm, pre);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "base") && events.Has(pumpFolder, "base"));
|
||||
events.Has(server, "fan-base").ShouldBeFalse("the sibling's alarm is not delivered through the Server object");
|
||||
|
||||
// Acknowledge with the EventId this narrow session received itself — the ScadaBridge flow #797 alone could
|
||||
// not complete.
|
||||
await ConditionMethodEventTests.CallAsync(
|
||||
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(server, "base"), ct);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(server, "base").Count == 2);
|
||||
events.ConditionEvents(server, "base")[1][RecordedEvents.AckedField].TryGetValue(out bool acked).ShouldBeTrue();
|
||||
acked.ShouldBeTrue();
|
||||
|
||||
var siblingEventId = rig.Nm.TryGetAlarmCondition(FanAlarm)!.EventId!.Value.ToArray();
|
||||
var denied = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
|
||||
session, Uns(session, FanAlarm), "Acknowledge", siblingEventId, ct));
|
||||
denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
Project(rig, FanAlarm, pre with { Message = "fan-sentinel" });
|
||||
Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "sentinel"));
|
||||
events.Of(server).Count(f => RecordedEvents.Message(f)?.StartsWith("fan-", StringComparison.Ordinal) == true)
|
||||
.ShouldBe(0);
|
||||
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge")]);
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A principal holding only a TAG grant (one reference variable beneath the equipment) can navigate to it and
|
||||
/// read it, but is NOT handed the equipment's other contents: its folder is browsable by transit and nothing
|
||||
/// more, and the equipment's scripted alarms and virtual tags stay hidden from Browse and Read and its events
|
||||
/// do not reach the Server object — a Tag grant is about that signal, not the equipment.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_tag_grant_reaches_its_reference_without_exposing_the_equipments_alarms_or_virtual_tags()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns808TagOnly", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
Install(rig, pumpLine: Line1, Deployed(), TagGrant());
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
|
||||
(await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump]);
|
||||
(await BrowseAsync(session, Uns(session, Pump), ct)).Ids.ShouldBe([PumpFlow]);
|
||||
(await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scripted alarm the generation has MOVED (deployed under Fan) while the address space still binds it to Pump —
|
||||
/// the window between installing a generation and rebuilding its address space, or indefinitely after a failed
|
||||
/// apply. Under BOTH owners' grants it is invisible (no Server-object event, no Read of the condition or its
|
||||
/// children) and not commandable; the owner's other, correctly placed alarm keeps working (positive control).
|
||||
/// Once the address space catches up the new owner's grant sees it and the old owner's does not.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(Pump)]
|
||||
[InlineData(Fan)]
|
||||
public async Task A_moved_alarm_is_denied_under_both_owners_until_the_address_space_catches_up(string grantedEquipment)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync($"Uns808Moved{grantedEquipment[^4..]}", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
Install(rig, pumpLine: Line1, Deployed(alarmOwner: Fan), EquipmentGrant(grantedEquipment, Operator));
|
||||
var routed = Route(rig);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
var control = grantedEquipment == Pump ? PumpLo : FanAlarm;
|
||||
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
|
||||
Project(rig, Alarm, pre with { Message = "moved" });
|
||||
Project(rig, control, pre with { Message = "control" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "control"));
|
||||
events.Has(server, "moved").ShouldBeFalse("a moved alarm is unplaceable for every grant during the window");
|
||||
|
||||
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, ChildId(rig, Alarm, c => c.EnabledState!.Id!)), Attributes.Value, ct))
|
||||
.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
var staleEventId = rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray();
|
||||
(await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
|
||||
session, Uns(session, Alarm), "Acknowledge", staleEventId, ct)))
|
||||
.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
routed.ShouldBeEmpty();
|
||||
|
||||
// The address space catches up: the alarm is re-materialised under its deployed owner.
|
||||
((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
|
||||
Project(rig, Alarm, pre with { Message = "placed" });
|
||||
Project(rig, control, pre with { Message = "control-2" });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "control-2"));
|
||||
|
||||
var visible = grantedEquipment == Fan;
|
||||
events.Has(server, "placed").ShouldBe(visible);
|
||||
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct))
|
||||
.ShouldBe(visible ? StatusCodes.Good : StatusCodes.BadUserAccessDenied);
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An EQUIPMENT moved to another line by the generation while its folder still hangs under the old line: the live
|
||||
/// folder ancestry disagrees with the generation's, so the folder, everything it owns, its scripted alarm (read
|
||||
/// AND Acknowledge) and its UNS reference variable (still on its OLD-name id — read AND write) are denied under
|
||||
/// the new line's grant AND the old one's. After the rebuild re-parents it (and re-ids the reference variable),
|
||||
/// the new line's grant reaches all of it and the old line's reaches none of it.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(Line1)]
|
||||
[InlineData(Line2)]
|
||||
public async Task A_moved_equipment_and_everything_under_it_is_denied_until_the_rebuild_reparents_it(string grantedLine)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync($"Uns808Folder{grantedLine[^1..]}", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
Install(rig, pumpLine: Line2, Deployed(), LineGrant(grantedLine));
|
||||
var routed = Route(rig);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var oldFlow = FlowId(Line1);
|
||||
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, oldFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await WriteAsync(session, Uns(session, oldFlow), 1.5, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
// The condition must carry a real EventId first, or the SDK refuses the call before any gate runs.
|
||||
Project(rig, Alarm, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"));
|
||||
(await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
|
||||
session, Uns(session, Alarm), "Acknowledge", rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(), ct)))
|
||||
.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
routed.ShouldBeEmpty();
|
||||
// Positive control: the sibling, placed identically by both generations, follows the grant.
|
||||
(await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct))
|
||||
.ShouldBe(grantedLine == Line1 ? StatusCodes.Good : StatusCodes.BadUserAccessDenied);
|
||||
|
||||
rig.Sink.RebuildAddressSpace();
|
||||
Topology(rig, pumpLine: Line2);
|
||||
|
||||
var allowed = grantedLine == Line2;
|
||||
var expected = allowed ? StatusCodes.Good : StatusCodes.BadUserAccessDenied;
|
||||
var newFlow = FlowId(Line2);
|
||||
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(expected);
|
||||
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(expected);
|
||||
(await ReadAsync(session, Uns(session, newFlow), Attributes.DisplayName, ct)).ShouldBe(expected);
|
||||
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(expected);
|
||||
var write = await WriteAsync(session, Uns(session, newFlow), 1.5, ct);
|
||||
if (allowed) write.ShouldNotBe(StatusCodes.BadUserAccessDenied, "the new line's grant reaches the re-ided variable");
|
||||
else write.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
Project(rig, Alarm, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "rebuilt"));
|
||||
var ack = ConditionMethodEventTests.CallAsync(
|
||||
session, Uns(session, Alarm), "Acknowledge", rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(), ct);
|
||||
if (allowed) await ack;
|
||||
else (await Should.ThrowAsync<ServiceResultException>(ack)).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
routed.Count.ShouldBe(allowed ? 1 : 0);
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An Area RENAMED by the generation before the address space re-ids its reference variables: the folders keep
|
||||
/// their opaque ids (still placed — the Equipment grant reads the folder and its virtual tag), but the reference
|
||||
/// variable's id still spells the OLD name, so it is denied until it is re-pointed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_reference_variable_on_a_stale_name_path_is_denied_while_its_folder_is_placed()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns808Rename", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
// The Tag grant's path still spells the OLD Area name (Install plants it at Area1/Line1/Pump/Flow) — standing in
|
||||
// for any grant that names the old path, e.g. another Area that has since been given the name "Area1".
|
||||
Install(rig, pumpLine: Line1, Deployed(), areaName: "AreaRenamed", EquipmentGrant(Pump, Operator), TagGrant());
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review of #808 (cache poisoning): the resolved-scope cache keys are NUL-prefixed, so a client that first
|
||||
/// Reads the nonexistent <c>s="\0Area1\0Line1\0Pump"</c> — the exact cache key of the real Pump folder — must
|
||||
/// not memoize a deny the real folder then inherits. A NUL-bearing id is refused before the cache.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_nul_bearing_identifier_cannot_poison_a_resolved_scopes_cached_verdict()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns808Nul", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
Install(rig, pumpLine: Line1, Deployed(), EquipmentGrant(Pump, Operator));
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
(await ReadAsync(session, Uns(session, "\u0000Area1\u0000Line1\u0000Pump"), Attributes.DisplayName, ct))
|
||||
.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review of #808: an identifier NO live node holds (a removed folder, virtual tag or scripted alarm — what a
|
||||
/// NodeDeleted model change names — or a probe) is decided only by a realm-wide grant. It no longer splits, so a
|
||||
/// removed opaque id never matches a narrow grant on an unrelated entity of the same NAME (an Area named like a
|
||||
/// removed alarm id). A reference-variable-shaped id (four segments) keeps its name-path split.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_unknown_uns_identifier_is_decided_only_by_a_realm_wide_grant()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns808Unknown", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
Install(rig, pumpLine: Line1, Deployed(),
|
||||
KeyGrant("area", NodeAclScopeKind.UnsArea, AreaId, NodePermissions.ReadOnly),
|
||||
new NodeAcl
|
||||
{
|
||||
NodeAclId = "anon-cluster", ClusterId = ServerEventRig.Cluster, Principal = OpcUaPrincipals.Anonymous,
|
||||
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.ReadOnly,
|
||||
});
|
||||
|
||||
using var narrow = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var wide = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
// "Area1" is the granted Area's NAME — a removed scripted alarm with that id used to split onto it.
|
||||
foreach (var gone in new[] { "Area1", "EQ-00000000dead", "Area1/Line1", "scripted-removed/EnabledState" })
|
||||
{
|
||||
(await ReadAsync(narrow, Uns(narrow, gone), Attributes.DisplayName, ct))
|
||||
.ShouldBe(StatusCodes.BadUserAccessDenied, gone);
|
||||
(await ReadAsync(wide, Uns(wide, gone), Attributes.DisplayName, ct))
|
||||
.ShouldBe(StatusCodes.BadNodeIdUnknown, $"{gone}: a realm-wide grant still decides it");
|
||||
}
|
||||
|
||||
// A four-segment (reference-variable-shaped) id keeps its name-path split under the Area grant.
|
||||
(await ReadAsync(narrow, Uns(narrow, "Area1/Line1/Pump/Gone"), Attributes.DisplayName, ct))
|
||||
.ShouldBe(StatusCodes.BadNodeIdUnknown);
|
||||
// Positive control: the live Area folder itself.
|
||||
(await ReadAsync(narrow, Uns(narrow, AreaId), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
|
||||
await narrow.CloseAsync(ct);
|
||||
await wide.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The placements through the PRODUCTION producer: a deployment artifact's UNS rows, scripted alarms and grant →
|
||||
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> (its folder scopes, owner paths, deployed owners and grant
|
||||
/// paths) → install → wire decisions, with no hand-built scope anywhere.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Placements_decoded_from_a_deployment_artifact_authorize_a_narrow_grant_over_the_wire()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns808Artifact", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
var routed = Route(rig);
|
||||
|
||||
var artifact = $$"""
|
||||
{
|
||||
"UnsAreas": [{"UnsAreaId":"{{AreaId}}","ClusterId":"{{ServerEventRig.Cluster}}","Name":"Area1"}],
|
||||
"UnsLines": [
|
||||
{"UnsLineId":"{{Line1}}","UnsAreaId":"{{AreaId}}","Name":"Line1"},
|
||||
{"UnsLineId":"{{Line2}}","UnsAreaId":"{{AreaId}}","Name":"Line2"}
|
||||
],
|
||||
"Equipment": [
|
||||
{"EquipmentId":"{{Pump}}","UnsLineId":"{{Line1}}","Name":"Pump"},
|
||||
{"EquipmentId":"{{Fan}}","UnsLineId":"{{Line1}}","Name":"Fan"}
|
||||
],
|
||||
"ScriptedAlarms": [
|
||||
{"ScriptedAlarmId":"{{Alarm}}","EquipmentId":"{{Pump}}","Name":"hi"},
|
||||
{"ScriptedAlarmId":"{{PumpLo}}","EquipmentId":"{{Pump}}","Name":"lo"},
|
||||
{"ScriptedAlarmId":"{{FanAlarm}}","EquipmentId":"{{Fan}}","Name":"fan hi"}
|
||||
],
|
||||
"NodeAcls": [
|
||||
{"NodeAclId":"acl-pump","ClusterId":"{{ServerEventRig.Cluster}}","Principal":"{{ServerEventRig.KeyPrincipal}}",
|
||||
"ScopeKind":{{(int)NodeAclScopeKind.Equipment}},"ScopeId":"{{Pump}}","PermissionFlags":{{(int)Operator}}}
|
||||
]
|
||||
}
|
||||
""";
|
||||
var projection = ZB.MOM.WW.OtOpcUa.Runtime.Drivers.DeploymentArtifact.ParseDataPlaneAuthority(
|
||||
System.Text.Encoding.UTF8.GetBytes(artifact), "node-a:4840");
|
||||
projection.UnsFolderScopes.Keys.Order(StringComparer.Ordinal).ShouldBe([Pump, Fan, AreaId, Line1, Line2]);
|
||||
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(ServerEventRig.Cluster, "rev-artifact", [ServerEventRig.Key()], projection.Grants,
|
||||
projection.ScopePaths, projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes);
|
||||
rig.Nm.DataPlaneAuthority = state;
|
||||
rig.Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, TimeProvider.System);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
(await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump]);
|
||||
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, ChildId(rig, Alarm, c => c.EnabledState!.Id!)), Attributes.Value, ct))
|
||||
.ShouldBe(StatusCodes.Good);
|
||||
(await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
|
||||
Project(rig, FanAlarm, pre with { Message = "fan-base" });
|
||||
Project(rig, Alarm, pre);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "base"));
|
||||
events.Has(server, "fan-base").ShouldBeFalse();
|
||||
|
||||
await ConditionMethodEventTests.CallAsync(
|
||||
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(server, "base"), ct);
|
||||
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge")]);
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Area1 / {Line1, Line2}; Pump under <paramref name="pumpLine"/> and Fan under Line1 — every folder an opaque
|
||||
/// entity id, as <c>AddressSpaceApplier.MaterialiseHierarchy</c> mints them. Each equipment carries scripted
|
||||
/// alarms and a virtual tag; Pump also carries a UNS reference variable (a NAME-path id).
|
||||
/// </summary>
|
||||
private static void Topology(ServerEventRig rig, string pumpLine)
|
||||
{
|
||||
rig.Sink.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns);
|
||||
rig.Sink.EnsureFolder(Line1, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns);
|
||||
rig.Sink.EnsureFolder(Line2, parentNodeId: AreaId, displayName: "Line2", realm: AddressSpaceRealm.Uns);
|
||||
rig.Sink.EnsureFolder(Pump, parentNodeId: pumpLine, displayName: "Pump", realm: AddressSpaceRealm.Uns);
|
||||
rig.Sink.EnsureFolder(Fan, parentNodeId: Line1, displayName: "Fan", realm: AddressSpaceRealm.Uns);
|
||||
rig.Sink.EnsureVariable(PumpSpeed, Pump, "Speed", "Double", writable: false, AddressSpaceRealm.Uns);
|
||||
rig.Sink.EnsureVariable(FanSpeed, Fan, "Speed", "Double", writable: false, AddressSpaceRealm.Uns);
|
||||
rig.Sink.EnsureVariable(FlowId(pumpLine), Pump, "Flow", "Double", writable: true, AddressSpaceRealm.Uns);
|
||||
rig.Sink.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
|
||||
rig.Sink.MaterialiseAlarmCondition(PumpLo, Pump, "lo", "AlarmCondition", 300, AddressSpaceRealm.Uns);
|
||||
rig.Sink.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
|
||||
}
|
||||
|
||||
/// <summary>The live reference variable's id when Pump hangs under <paramref name="pumpLine"/> (its NAME path).</summary>
|
||||
private static string FlowId(string pumpLine) => $"Area1/{(pumpLine == Line1 ? "Line1" : "Line2")}/Pump/Flow";
|
||||
|
||||
private static AclScopePathResolver Resolver(string pumpLine, string areaName) => new(
|
||||
new Dictionary<string, (string? ParentId, string Name)>(),
|
||||
new Dictionary<string, (string? RawFolderId, string Name)>(),
|
||||
new Dictionary<string, (string DriverInstanceId, string Name)>(),
|
||||
new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
|
||||
new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(),
|
||||
new Dictionary<string, string> { [AreaId] = areaName },
|
||||
new Dictionary<string, (string UnsAreaId, string Name)> { [Line1] = (AreaId, "Line1"), [Line2] = (AreaId, "Line2") },
|
||||
new Dictionary<string, (string UnsLineId, string Name)> { [Pump] = (pumpLine, "Pump"), [Fan] = (Line1, "Fan") },
|
||||
[]);
|
||||
|
||||
/// <summary>
|
||||
/// Installs a generation whose topology puts Pump under <paramref name="pumpLine"/> — the owner paths, folder
|
||||
/// scopes and grant paths all from ONE resolver, as <c>DeploymentArtifact.ParseDataPlaneAuthority</c> does.
|
||||
/// </summary>
|
||||
private static void Install(
|
||||
ServerEventRig rig, string pumpLine, IReadOnlyDictionary<string, string> deployedOwners, params NodeAcl[] grants) =>
|
||||
Install(rig, pumpLine, deployedOwners, "Area1", grants);
|
||||
|
||||
/// <summary>As above, with the generation naming the Area <paramref name="areaName"/> (a rename the address space
|
||||
/// has not caught up with when it differs from the live <c>Area1</c>).</summary>
|
||||
private static void Install(
|
||||
ServerEventRig rig, string pumpLine, IReadOnlyDictionary<string, string> deployedOwners, string areaName,
|
||||
params NodeAcl[] grants)
|
||||
{
|
||||
var resolver = Resolver(pumpLine, areaName);
|
||||
var scopePaths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(resolver.Resolve(grants))
|
||||
{
|
||||
// A Tag grant's UNS projection (the resolver would derive it from raw rows this rig does not model).
|
||||
[new AclScopeRef(NodeAclScopeKind.Tag, FlowTagId)] = [new NodeAclPath([NodeAclRealms.Uns, "Area1", "Line1", "Pump", "Flow"])],
|
||||
};
|
||||
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
|
||||
|
||||
var owners = new Dictionary<string, NodeAclPath>
|
||||
{
|
||||
[Pump] = One(NodeAclScopeKind.Equipment, Pump),
|
||||
[Fan] = One(NodeAclScopeKind.Equipment, Fan),
|
||||
};
|
||||
var folders = new Dictionary<string, UnsFolderScope>
|
||||
{
|
||||
[AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]),
|
||||
[Line1] = new(One(NodeAclScopeKind.UnsLine, Line1), [AreaId, Line1]),
|
||||
[Line2] = new(One(NodeAclScopeKind.UnsLine, Line2), [AreaId, Line2]),
|
||||
[Pump] = new(owners[Pump], [AreaId, pumpLine, Pump]),
|
||||
[Fan] = new(owners[Fan], [AreaId, Line1, Fan]),
|
||||
};
|
||||
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(
|
||||
ServerEventRig.Cluster, "rev-1", [ServerEventRig.Key()], grants, scopePaths, owners, deployedOwners, folders);
|
||||
rig.Nm.DataPlaneAuthority = state;
|
||||
rig.Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, TimeProvider.System);
|
||||
}
|
||||
|
||||
/// <summary>The generation's deployed scripted-alarm owners; <paramref name="alarmOwner"/> moves <see cref="Alarm"/>.</summary>
|
||||
private static Dictionary<string, string> Deployed(string alarmOwner = Pump) =>
|
||||
new() { [Alarm] = alarmOwner, [PumpLo] = Pump, [FanAlarm] = Fan };
|
||||
|
||||
private static NodeAcl EquipmentGrant(string equipmentId, NodePermissions flags) =>
|
||||
KeyGrant($"eq-{equipmentId}", NodeAclScopeKind.Equipment, equipmentId, flags);
|
||||
|
||||
private static NodeAcl LineGrant(string lineId) =>
|
||||
KeyGrant($"line-{lineId}", NodeAclScopeKind.UnsLine, lineId, Operator | NodePermissions.WriteOperate);
|
||||
|
||||
private static NodeAcl TagGrant() => KeyGrant("tag-flow", NodeAclScopeKind.Tag, FlowTagId, Operator);
|
||||
|
||||
private static NodeAcl KeyGrant(string id, NodeAclScopeKind kind, string scopeId, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclId = id,
|
||||
ClusterId = ServerEventRig.Cluster,
|
||||
Principal = ServerEventRig.KeyPrincipal,
|
||||
ScopeKind = kind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static List<AlarmCommand> Route(ServerEventRig rig)
|
||||
{
|
||||
var routed = new List<AlarmCommand>();
|
||||
rig.Nm.AlarmCommandRouter = cmd => { lock (routed) routed.Add(cmd); };
|
||||
return routed;
|
||||
}
|
||||
|
||||
private static void AssertNoRoles(ServerEventRig rig) =>
|
||||
rig.Server.CurrentInstance.SessionManager.GetSessions()
|
||||
.Select(s => s.EffectiveIdentity).OfType<RoleCarryingUserIdentity>()
|
||||
.Single(i => i.ApiKeyId == ServerEventRig.KeyId)
|
||||
.Roles.ShouldBeEmpty("the narrow grant alone must authorize — the session holds no server-wide role");
|
||||
|
||||
/// <summary>The <c>s=</c> id of one of a live condition's instance-scoped children.</summary>
|
||||
private static string ChildId(ServerEventRig rig, string alarmId, Func<AlarmConditionState, NodeState> child) =>
|
||||
child(rig.Nm.TryGetAlarmCondition(alarmId).ShouldNotBeNull()).NodeId.IdentifierAsString;
|
||||
|
||||
private static void Project(ServerEventRig rig, string alarmId, AlarmConditionSnapshot state) =>
|
||||
rig.Sink.WriteAlarmCondition(alarmId, state, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
private static NodeId Uns(ISession session, string id) => ServerEventRig.UnsNodeId(session, id);
|
||||
|
||||
private static NodeId RootNodeId(ISession session) =>
|
||||
new(V3NodeIds.SharedRootFolderId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
|
||||
private static async Task<StatusCode> ReadAsync(ISession session, NodeId node, uint attribute, CancellationToken ct)
|
||||
{
|
||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueId[]
|
||||
{
|
||||
new() { NodeId = node, AttributeId = attribute },
|
||||
}, ct);
|
||||
return response.Results.ToList().ShouldHaveSingleItem().StatusCode;
|
||||
}
|
||||
|
||||
private static async Task<StatusCode> WriteAsync(ISession session, NodeId node, double value, CancellationToken ct)
|
||||
{
|
||||
var response = await session.WriteAsync(null, new WriteValue[]
|
||||
{
|
||||
new() { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(SdkTestValues.FromClr(value)) },
|
||||
}, ct);
|
||||
return response.Results.ToList().ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static async Task<(StatusCode Status, List<string> Ids)> BrowseAsync(
|
||||
ISession session, NodeId parent, CancellationToken ct)
|
||||
{
|
||||
var response = await session.BrowseAsync(
|
||||
requestHeader: null,
|
||||
view: null,
|
||||
requestedMaxReferencesPerNode: 0,
|
||||
nodesToBrowse:
|
||||
[
|
||||
new BrowseDescription
|
||||
{
|
||||
NodeId = parent,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = 0,
|
||||
ResultMask = (uint)BrowseResultMask.All,
|
||||
},
|
||||
],
|
||||
ct: ct);
|
||||
|
||||
var result = response.Results[0];
|
||||
return (result.StatusCode, result.References.ToList().Select(r => r.NodeId.IdentifierAsString).ToList());
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,75 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #808 review — NodeDeleted delivery on a production-shaped (opaque-id) tree. A NodesAdded change for a
|
||||
/// LIVE equipment folder reaches the narrow Equipment grantee (it is placed); a NodeDeleted change names a node
|
||||
/// no live map holds any more, so only a realm-wide grantee receives it — the removed opaque id (a virtual tag,
|
||||
/// a scripted alarm whose id equals the granted Area's NAME) is never split onto an unrelated narrow grant.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_removed_opaque_uns_node_is_announced_only_to_realm_wide_grantees()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
sink.EnsureFolder("UA-1", null, "Area1", AddressSpaceRealm.Uns);
|
||||
sink.EnsureFolder("UL-1", "UA-1", "Line1", AddressSpaceRealm.Uns);
|
||||
sink.EnsureFolder("EQ-1", "UL-1", "Pump", AddressSpaceRealm.Uns);
|
||||
|
||||
var state = new DataPlaneAuthorityState();
|
||||
NodeAcl[] grants =
|
||||
[
|
||||
new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-eq", ClusterId = Cluster, Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.Equipment, ScopeId = "EQ-1", PermissionFlags = NodePermissions.Browse,
|
||||
},
|
||||
new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-area", ClusterId = Cluster, Principal = "area-key",
|
||||
ScopeKind = NodeAclScopeKind.UnsArea, ScopeId = "UA-1", PermissionFlags = NodePermissions.Browse,
|
||||
},
|
||||
new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-all", ClusterId = Cluster, Principal = "everything",
|
||||
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Browse,
|
||||
},
|
||||
];
|
||||
var area = new NodeAclPath([NodeAclRealms.Uns, "Area1"]);
|
||||
var line = new NodeAclPath([NodeAclRealms.Uns, "Area1", "Line1"]);
|
||||
var pump = new NodeAclPath([NodeAclRealms.Uns, "Area1", "Line1", "Pump"]);
|
||||
state.Apply(Cluster, "rev-1", [], grants,
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-1")] = [pump],
|
||||
[new AclScopeRef(NodeAclScopeKind.UnsArea, "UA-1")] = [area],
|
||||
},
|
||||
new Dictionary<string, NodeAclPath> { ["EQ-1"] = pump },
|
||||
unsFolderScopes: new Dictionary<string, UnsFolderScope>
|
||||
{
|
||||
["UA-1"] = new(area, ["UA-1"]),
|
||||
["UL-1"] = new(line, ["UA-1", "UL-1"]),
|
||||
["EQ-1"] = new(pump, ["UA-1", "UL-1", "EQ-1"]),
|
||||
});
|
||||
nm.Authorizer = new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance);
|
||||
|
||||
var added = nm.BuildNodesAddedModelChange("EQ-1", AddressSpaceRealm.Uns);
|
||||
nm.FilterModelChangeFor(Identity(Principal), added).ShouldBeSameAs(added, "the live folder is placed");
|
||||
|
||||
foreach (var gone in new[] { "EQ-1/Speed", "Area1", "scripted-gone" })
|
||||
{
|
||||
var deleted = nm.BuildNodesRemovedModelChange(gone, AddressSpaceRealm.Uns);
|
||||
nm.FilterModelChangeFor(Identity(Principal), deleted).ShouldBeNull(gone);
|
||||
nm.FilterModelChangeFor(Identity("area-key"), deleted).ShouldBeNull($"{gone}: no name-collision match");
|
||||
nm.FilterModelChangeFor(Identity("everything"), deleted).ShouldBeSameAs(deleted, gone);
|
||||
}
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private static DataPlaneAuthorizer Authorizer(params NodeAcl[] grants)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
@@ -79,6 +148,13 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.Equipment, "eq-EqA")] = [new NodeAclPath([NodeAclRealms.Uns, "EqA"])],
|
||||
},
|
||||
// Gitea #808: a UNS folder is placed through the generation's folder scopes, never by splitting its id.
|
||||
// This test is about model-change narrowing, so it keeps its synthetic root-level folder (id EqA, path
|
||||
// [uns, EqA]) and now says so explicitly; production-shaped placement is UnsFolderScopeWireTests' job.
|
||||
unsFolderScopes: new Dictionary<string, UnsFolderScope>
|
||||
{
|
||||
["EqA"] = new(new NodeAclPath([NodeAclRealms.Uns, "EqA"]), ["EqA"]),
|
||||
});
|
||||
return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance);
|
||||
}
|
||||
|
||||
+21
-4
@@ -234,7 +234,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
|
||||
// The generation no longer places the owner (equipment deleted from the artifact while the
|
||||
// node is still served): the grant's own scope stays resolvable, the owner does not.
|
||||
state.Apply(Cluster, "rev-2", [key], [grant], Resolver().Resolve([grant]),
|
||||
new Dictionary<string, NodeAclPath>(), Deployed());
|
||||
new Dictionary<string, NodeAclPath>(), Deployed(), FolderScopes());
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -595,7 +595,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev1")] = [new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])],
|
||||
};
|
||||
state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths(), Deployed());
|
||||
state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths(), Deployed(), FolderScopes());
|
||||
condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("raw grant")).ShouldBe(ServiceResult.Good);
|
||||
condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("raw note")).ShouldBe(ServiceResult.Good);
|
||||
|
||||
@@ -684,6 +684,23 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #808 review: the scripted-condition placement also requires the owner's LIVE folder ancestry to match
|
||||
/// the generation's, so every install here carries the topology's folder scopes (same resolver as the owners).
|
||||
/// </summary>
|
||||
private static Dictionary<string, UnsFolderScope> FolderScopes()
|
||||
{
|
||||
var resolver = Resolver();
|
||||
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
|
||||
return new Dictionary<string, UnsFolderScope>
|
||||
{
|
||||
[AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]),
|
||||
[LineId] = new(One(NodeAclScopeKind.UnsLine, LineId), [AreaId, LineId]),
|
||||
[Pump] = new(One(NodeAclScopeKind.Equipment, Pump), [AreaId, LineId, Pump]),
|
||||
[Fan] = new(One(NodeAclScopeKind.Equipment, Fan), [AreaId, LineId, Fan]),
|
||||
};
|
||||
}
|
||||
|
||||
private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire(
|
||||
OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants) =>
|
||||
Wire(nm, clock, keys, enabled: true, Areas(), grants);
|
||||
@@ -702,7 +719,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
|
||||
IReadOnlyDictionary<string, string> areas, NodeAcl[] grants)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths(), Deployed());
|
||||
state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths(), Deployed(), FolderScopes());
|
||||
var authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock);
|
||||
nm.DataPlaneAuthority = state;
|
||||
@@ -717,7 +734,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
|
||||
private static void Reapply(
|
||||
DataPlaneAuthorityState state, string revision, IReadOnlyDictionary<string, string> deployedOwners,
|
||||
ApiKey[] keys, params NodeAcl[] grants) =>
|
||||
state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners);
|
||||
state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners, FolderScopes());
|
||||
|
||||
/// <summary>
|
||||
/// The generation's deployed scripted-alarm owners (the artifact's <c>ScriptedAlarms</c> rows);
|
||||
|
||||
+58
@@ -254,4 +254,62 @@ public sealed class DeploymentArtifactScopePathTests
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -666,11 +666,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
|
||||
IReadOnlyList<ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl> grants,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null)
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
|
||||
{
|
||||
Interlocked.Increment(ref _applies);
|
||||
return new DataPlaneAuthorityState().Apply(
|
||||
clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners);
|
||||
clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners,
|
||||
unsFolderScopes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user