Merge fix/826-folder-event-acl
This commit is contained in:
@@ -121,11 +121,12 @@ Two things to know before relying on any of this:
|
||||
same-named narrow grant — except a 4-segment (reference-shaped) id, which keeps its split. ⇒ narrow grantees do
|
||||
NOT receive NodeDeleted for removed UNS folders / vtags / conditions (by decision; they re-browse). Identifiers
|
||||
containing NUL are denied before the cache (resolved-scope keys are NUL-prefixed — a client could otherwise
|
||||
poison a real folder's cached verdict). ⚠️ The folder-owned rule keys on "first id segment == nearest slash-free
|
||||
live UNS folder ancestor's id", which can only narrow (an Area named exactly like its equipment's id loses that
|
||||
variable's Tag-grant match). ⚠️ `ConditionVisibilityScopes` returns the condition ALONE for a scripted condition,
|
||||
so a moved alarm can't reach its old owner via the Server object; direct per-folder event items still ride the
|
||||
SDK fan-out of the folder it hangs under (follow-up). A UNS node with NO live hierarchy-folder ancestor (rig-only
|
||||
poison a real folder's cached verdict). The folder-owned rule keys on "first id segment == nearest slash-free
|
||||
live UNS folder ancestor's id", EXCEPT for a variable tracked in `_unsReferenceVariables` (the source of a
|
||||
UNS→Raw `Organizes` edge from `AddReference`), which is always a reference variable (#826 — an Area named exactly
|
||||
like its equipment's id used to make its references folder-owned). ⚠️ `ConditionVisibilityScopes` returns the
|
||||
condition ALONE for a scripted condition, so a moved alarm can't reach its old owner via the Server object — and
|
||||
since #826 not via a direct folder item either (below). A UNS node with NO live hierarchy-folder ancestor (rig-only
|
||||
shape) keeps the plain split. Unplaceable ⇒ denied for every grant incl. Cluster (a behaviour change for Cluster
|
||||
keys only in the stale window / corrupt topology). Tests: `UnsFolderScopeWireTests` (opaque ids, incl. one fed by
|
||||
`DeploymentArtifact.ParseDataPlaneAuthority`); rig tests (`ServerEventRig.FolderScopes()`, `EqA` ⇒ `[uns, EqA]`)
|
||||
@@ -175,6 +176,34 @@ Two things to know before relying on any of this:
|
||||
**Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral
|
||||
one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other
|
||||
event type fails closed. Auditing is off by default (never configured in this repo).
|
||||
**Direct (per-folder) event items are filtered per event too (Gitea #826).** The SDK's `MonitoredNode2` fan-out
|
||||
asks `INodeManager3.ValidateEventRolePermissions` per (event, item) on its own delivery task; the base method is
|
||||
NOT virtual, so `OtOpcUaNodeManager` **re-implements `INodeManager3`** and adds `IsNotifierEventVisibleTo`: the
|
||||
event's condition (snapshot handle, or an audit's SourceNode) must be the LIVE instance, the item's notifier must
|
||||
still be a legitimate notifier of it (an ancestor, or a wired folder the CURRENT generation still projects its
|
||||
RawPath into — `DataPlaneAuthoritySnapshot.TagReferencingEquipment`, from the artifact's `UnsTagReferences` via
|
||||
`AclScopePathResolver.ResolveTagRawPath`), and `IsConditionVisibleTo(item, ConditionVisibilityScopes(c))` — the
|
||||
Server-object rule, whose wired folders are ALSO generation-filtered now. ConditionRefresh on a direct item no
|
||||
longer calls the base (it uses the non-virtual base check, which bypasses the re-implementation): `RefreshDirectItems`
|
||||
mirrors the base loop plus the same decision. ⚠️ **The SDK memoizes that verdict per `(item, eventType,
|
||||
SourceNode)`** until the monitored node's permission generation moves — `InvalidateFolderEventVerdicts` (one
|
||||
`MonitoredNode2.InvalidatePermissionCacheForSession` per notifier×session) must run whenever the inputs change:
|
||||
the authority/authorizer setters, `RevalidateMonitoredItems` (deploy `Changed` + timer tick) and
|
||||
`CompleteAddressSpaceApply` — and is SKIPPED while enforcement is off (the authorizer setter forces it across an
|
||||
on/off transition). Without it a cached allow survives the deploy (caught by the native wire test's restore leg /
|
||||
`t2`). A rebuild tombstones the monitored nodes, so their caches go with them. ⚠️ (review) It clears each node's
|
||||
cache WHOLE through the SDK's private `MonitoredNode2.InvalidatePermissionCaches` (reflection, pinned by
|
||||
`FolderEventVerdictInvalidationTests.The_sdks_node_wide…`): the public `InvalidatePermissionCacheForSession`
|
||||
misses ORPHANED subscriptions (session closed without deleting subs ⇒ `Session` null), which then queued a moved
|
||||
alarm's events and delivered them on `TransferSubscriptions`, and rescanned the cache per item. Fallback if the SDK
|
||||
drops it: per-session drop + `Remove`/`Add` of each session-less item. ⚠️ The verdict is decided on the LIVE
|
||||
instance holding the event's condition NodeId, never the event's own instance — the SDK key has no instance
|
||||
identity, so denying a replaced instance's late event cached a deny the new instance inherited.
|
||||
`TagReferencingEquipment` holds ALARM-BEARING tags only (`TagConfigIntent.Alarm`). ⚠️ A hand-built
|
||||
authority (rigs) must pass `tagReferencingEquipment` or native alarms stop reaching equipment-folder items under
|
||||
enforcement (`ServerEventRig.TagReferences()`); null ⇒ none (fail closed). Tests:
|
||||
`UnsFolderScopeWireTests.A_moved_alarm_stops_reaching_its_old_folders_subscribers…`,
|
||||
`NativeAlarmFolderEventAclWireTests`.
|
||||
- **ns=0 standard nodes stay ungated by design, even with `Security:Acl:Enabled`** (#617) — `ServerStatus`,
|
||||
`NamespaceArray`, `ServiceLevel` etc. are served by the SDK's core node manager outside our authorizer and
|
||||
clients read them to connect and to judge redundancy health; they carry no plant data. Every raw/UNS node,
|
||||
|
||||
+31
-2
@@ -600,9 +600,13 @@ and Write under old and new names until the address space re-points them. An id
|
||||
folder / virtual tag / condition, a probe, a NodeDeleted model change) is decided only by a `Cluster` or `uns`
|
||||
`Namespace` grant — so narrow grantees do not receive NodeDeleted for removed opaque-id nodes, and a removed id can
|
||||
never match an unrelated narrow grant that shares its name; a four-segment (reference-shaped) id keeps its split.
|
||||
Identifiers containing NUL are refused. Server-object (`i=2253`) delivery of a
|
||||
Identifiers containing NUL are refused. A UNS variable the reference pass wired to its raw node (the `Organizes`
|
||||
edge) is placed as a reference variable whatever its id spells (Gitea #826 — the id rule alone made an equipment's
|
||||
references folder-owned when an Area was named exactly like that equipment's id, losing their `Tag`-grant match).
|
||||
Server-object (`i=2253`) delivery of a
|
||||
**scripted** condition is now decided on the condition alone (its folders add nothing in steady state and would
|
||||
leak a moved alarm to its old owner). So a key holding only an `Equipment` grant can navigate to its equipment,
|
||||
leak a moved alarm to its old owner). Direct event items on a folder get the same per-event decision (Gitea #826,
|
||||
below). So a key holding only an `Equipment` grant can navigate to its equipment,
|
||||
read its alarm children, receive its scripted alarm events through the Server object and Acknowledge / Confirm /
|
||||
AddComment with the EventId it received. Fail-closed throughout: an unplaceable node is denied under every grant.
|
||||
| HistoryRead (all four variants) | **nothing** | `HistoryRead` on each node's scope, decided **per node in the batch** |
|
||||
@@ -709,6 +713,31 @@ Behaviours worth knowing before enabling it:
|
||||
condition) runs on removal, rebuild and Server-object subscribe/delete. A deploy of N alarms under a
|
||||
live Server-object item is therefore N bookkeeping steps, not N²/2 under the node-manager lock.
|
||||
|
||||
**Direct (per-folder) event items are filtered per event too** (Gitea #826). An item on an equipment or raw
|
||||
device folder is fed by the SDK's own per-notifier fan-out (`MonitoredNode2`), which the #586 dispatcher never
|
||||
touches — so after a deployment moved a scripted alarm (or dropped the reference projecting a native alarm into
|
||||
an equipment), the OLD folder's subscribers kept receiving it, EventId included, until the rebuild landed, and
|
||||
indefinitely after a failed apply (commands were already denied). With the flag **on**, each event to such an
|
||||
item now also needs, against the CURRENT state:
|
||||
- the event's condition to be the **live** instance (a torn-down one is withheld);
|
||||
- the folder to still be a legitimate notifier of it — a node it hangs beneath, or a folder it is wired into
|
||||
**that the current generation still projects its raw tag into** (`DataPlaneAuthoritySnapshot.TagReferencingEquipment`,
|
||||
`RawPath → EquipmentIds`, decoded from the artifact's `UnsTagReferences` by the same resolver as the grants);
|
||||
- and the condition to pass exactly the Server-object rule above for the item's session.
|
||||
|
||||
The generation check also narrows the **Server-object** rule: a wired folder whose reference the generation
|
||||
dropped no longer admits the native alarm there either. **`ConditionRefresh`** on a folder item is filtered the
|
||||
same way. The hook is `INodeManager3.ValidateEventRolePermissions`, which the node manager re-implements (the
|
||||
base method is not virtual); the SDK memoizes that verdict per `(item, event type, SourceNode)`, so the node
|
||||
manager clears each notifier's cache whole (sessions and orphaned, session-less subscriptions alike — an orphaned
|
||||
subscription must not queue a moved alarm's events for a later `TransferSubscriptions`) on every new generation or
|
||||
authorizer, on each `AclRevalidationTimer` tick, and at the end of every apply, and not at all while enforcement is
|
||||
off. A verdict is decided on the condition currently live under the event's NodeId, so a replaced instance's late
|
||||
event cannot leave a deny behind for its successor. Only alarm-bearing raw tags are carried in the reference map. Withheld events are not replayed and the client is not told; restoring the reference or
|
||||
grant resumes the feed on the next event. ⚠️ A native alarm reaches an equipment folder's items under
|
||||
enforcement only while the installed generation carries that reference — a hand-built authority (tests) must
|
||||
supply `tagReferencingEquipment`. Inert with the flag off.
|
||||
|
||||
**Model-change and audit events on the Server object are gated too** (Gitea #588). The node manager's
|
||||
own server-wide events — `GeneralModelChangeEvent` (NodeAdded / NodeDeleted / DataTypeChanged) and the
|
||||
`AuditWriteUpdateEvent` of a rejected device write — used to go straight to `Server.ReportEvent`, where
|
||||
|
||||
@@ -231,6 +231,17 @@ public sealed class AclScopePathResolver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The RawPath of raw tag <paramref name="tagId"/> — the identifier its raw NodeId (and a native alarm on it, as
|
||||
/// <c>ConditionId</c>) carries — or null when its chain does not resolve (Gitea #826).
|
||||
/// </summary>
|
||||
/// <param name="tagId">The raw tag's logical id.</param>
|
||||
/// <returns>The RawPath, or null.</returns>
|
||||
public string? ResolveTagRawPath(string tagId) =>
|
||||
!string.IsNullOrEmpty(tagId) && _tags.TryGetValue(tagId, out var tag)
|
||||
? _rawPaths.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name)
|
||||
: null;
|
||||
|
||||
private IReadOnlyList<NodeAclPath> ResolveTag(string tagId)
|
||||
{
|
||||
var paths = new List<NodeAclPath>();
|
||||
|
||||
@@ -37,7 +37,8 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal,
|
||||
IReadOnlyDictionary<string, NodeAclPath> equipmentScopePaths,
|
||||
IReadOnlyDictionary<string, string> scriptedAlarmOwners,
|
||||
IReadOnlyDictionary<string, UnsFolderScope> unsFolderScopes)
|
||||
IReadOnlyDictionary<string, UnsFolderScope> unsFolderScopes,
|
||||
IReadOnlyDictionary<string, IReadOnlySet<string>> tagReferencingEquipment)
|
||||
{
|
||||
ClusterId = clusterId;
|
||||
RevisionHash = revisionHash;
|
||||
@@ -51,6 +52,7 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
EquipmentScopePaths = equipmentScopePaths;
|
||||
ScriptedAlarmOwners = scriptedAlarmOwners;
|
||||
UnsFolderScopes = unsFolderScopes;
|
||||
TagReferencingEquipment = tagReferencingEquipment;
|
||||
}
|
||||
|
||||
private readonly IReadOnlyDictionary<string, ApiKey> _keysById;
|
||||
@@ -160,6 +162,31 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>RawPath → the EquipmentIds whose UnsTagReference projects that ALARM-BEARING raw tag</c>, as THIS generation deploys
|
||||
/// them (Gitea #826). Keyed <c>Ordinal</c> (a RawPath is a NodeId identifier); the id sets are
|
||||
/// <c>OrdinalIgnoreCase</c> (identifier rule).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A native alarm materialises once at its raw tag and is wired as an extra event notifier into every
|
||||
/// referencing equipment's folder. The generation is installed BEFORE its address space is applied, and an
|
||||
/// apply that throws keeps the old one, so a dropped reference leaves the alarm wired into an equipment folder
|
||||
/// this generation no longer projects it into. The node manager admits a wired folder only while this map
|
||||
/// agrees. An entry confers nothing; an absent RawPath means no equipment references it.
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferencingEquipment { get; }
|
||||
|
||||
/// <summary>Whether this generation projects the raw tag at <paramref name="rawPath"/> into
|
||||
/// <paramref name="equipmentId"/> (Gitea #826).</summary>
|
||||
/// <param name="rawPath">The raw tag's RawPath (a native condition's <c>ConditionId</c>).</param>
|
||||
/// <param name="equipmentId">The equipment (its folder's <c>s=</c> identifier).</param>
|
||||
/// <returns><see langword="true"/> when a reference of this generation connects the two.</returns>
|
||||
public bool IsTagReferencedBy(string rawPath, string equipmentId) =>
|
||||
!string.IsNullOrEmpty(rawPath)
|
||||
&& !string.IsNullOrEmpty(equipmentId)
|
||||
&& TagReferencingEquipment.TryGetValue(rawPath, out var equipment)
|
||||
&& equipment.Contains(equipmentId);
|
||||
|
||||
/// <summary>
|
||||
/// The ACL path WITHIN the UNS realm (realm segment stripped) of the UNS folder <paramref name="folderId"/>,
|
||||
/// provided the live folder's ancestry <paramref name="liveIdChain"/> is exactly the one this generation
|
||||
@@ -279,6 +306,8 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
/// (see <see cref="ScriptedAlarmOwners"/>); null ⇒ none.</param>
|
||||
/// <param name="unsFolderScopes">Every UNS folder's path + id chain (see <see cref="UnsFolderScopes"/>);
|
||||
/// null ⇒ none, which denies every UNS folder-scoped node under enforcement.</param>
|
||||
/// <param name="tagReferencingEquipment"><c>RawPath → EquipmentIds</c> (see <see cref="TagReferencingEquipment"/>);
|
||||
/// null ⇒ none, so no native alarm reaches an equipment folder's event items under enforcement.</param>
|
||||
/// <returns>The built snapshot.</returns>
|
||||
public static DataPlaneAuthoritySnapshot Create(
|
||||
string? clusterId,
|
||||
@@ -290,7 +319,8 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
IReadOnlyList<PermissionTrieBuildDiagnostic>? droppedGrants = null,
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null,
|
||||
IReadOnlyDictionary<string, IReadOnlySet<string>>? tagReferencingEquipment = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(revisionHash);
|
||||
ArgumentNullException.ThrowIfNull(keys);
|
||||
@@ -359,9 +389,26 @@ public sealed class DataPlaneAuthoritySnapshot
|
||||
}
|
||||
}
|
||||
|
||||
var referencing = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal);
|
||||
if (tagReferencingEquipment is not null)
|
||||
{
|
||||
foreach (var (rawPath, equipmentIds) in tagReferencingEquipment)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawPath) || equipmentIds is null) continue;
|
||||
// Re-keyed under the identifier comparer, as the maps above are — but a set already built under it
|
||||
// (what DeploymentArtifact produces) is adopted rather than copied a second time.
|
||||
referencing[rawPath] = equipmentIds is HashSet<string> set
|
||||
&& ReferenceEquals(set.Comparer, StringComparer.OrdinalIgnoreCase)
|
||||
&& !set.Any(string.IsNullOrWhiteSpace)
|
||||
? set
|
||||
: new HashSet<string>(
|
||||
equipmentIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
return new DataPlaneAuthoritySnapshot(
|
||||
clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen,
|
||||
equipment, owners, folders);
|
||||
equipment, owners, folders, referencing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +493,9 @@ public interface IDataPlaneAuthoritySink
|
||||
/// them (Gitea #797 review). Null ⇒ none, which denies every scripted-condition command under enforcement.</param>
|
||||
/// <param name="unsFolderScopes">Every UNS Area/Line/Equipment folder's path + id chain from the same
|
||||
/// artifact (Gitea #808). Null ⇒ none, which denies every UNS folder-scoped node under enforcement.</param>
|
||||
/// <param name="tagReferencingEquipment"><c>RawPath → EquipmentIds</c> from the same artifact's
|
||||
/// UnsTagReferences (Gitea #826). Null ⇒ none, so no native alarm reaches an equipment folder's event items
|
||||
/// under enforcement.</param>
|
||||
/// <returns>The installed snapshot.</returns>
|
||||
DataPlaneAuthoritySnapshot Apply(
|
||||
string? clusterId,
|
||||
@@ -455,7 +505,8 @@ public interface IDataPlaneAuthoritySink
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null);
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null,
|
||||
IReadOnlyDictionary<string, IReadOnlySet<string>>? tagReferencingEquipment = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -506,7 +557,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null,
|
||||
IReadOnlyDictionary<string, IReadOnlySet<string>>? tagReferencingEquipment = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(revisionHash);
|
||||
|
||||
@@ -540,7 +592,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
|
||||
|
||||
published = DataPlaneAuthoritySnapshot.Create(
|
||||
clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths,
|
||||
scriptedAlarmOwners, unsFolderScopes);
|
||||
scriptedAlarmOwners, unsFolderScopes, tagReferencingEquipment);
|
||||
Volatile.Write(ref _current, published);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// the legacy <c>EquipmentNodeWalker</c> server-side integration was retired in favour of the
|
||||
/// (composer → applier → sink → node-manager) chain.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// INodeManager3 is RE-IMPLEMENTED (Gitea #826): the SDK's per-folder event delivery (MonitoredNode2) asks
|
||||
// INodeManager3.ValidateEventRolePermissions for every event and item, and the base implementation is not virtual, so
|
||||
// re-implementing the interface is the only way to put the per-event ACL decision on that path. Every other member
|
||||
// maps to the inherited implementation exactly as before.
|
||||
public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
{
|
||||
/// <summary>v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are
|
||||
/// registered with the SDK so nodes can be minted in either. <see cref="V3NodeIds.RawNamespaceUri"/> is
|
||||
@@ -57,6 +61,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
|
||||
|
||||
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _variables = new();
|
||||
|
||||
/// <summary>
|
||||
/// UNS variables PROVEN to be UNS reference variables — the source of a forward <c>Organizes</c> edge into the raw
|
||||
/// namespace (<see cref="AddReference"/>), which only the applier's reference pass wires. Gitea #826: the
|
||||
/// reference-vs-folder-owned decision in <see cref="ResolveUnsAclSubject"/> no longer rests on the id rule alone
|
||||
/// (an Area named exactly like its equipment's id made that equipment's references look folder-owned). Holds the
|
||||
/// instance, so a stale entry for a replaced node never matches; cleared with <see cref="_variables"/>.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _unsReferenceVariables = new();
|
||||
private readonly ConcurrentDictionary<RealmKey, FolderState> _folders = new();
|
||||
private readonly ConcurrentDictionary<RealmKey, AlarmConditionState> _alarmConditions = new();
|
||||
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> node ids materialised as NATIVE
|
||||
@@ -567,6 +580,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// walking its own dead item map on each deploy.
|
||||
previous.Changed -= OnAuthorityChanged;
|
||||
next.Changed += OnAuthorityChanged;
|
||||
InvalidateFolderEventVerdicts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,7 +654,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// there is no second wiring that could silently not run (an unattached resolver splits opaque ids,
|
||||
// which only ever DENIES narrow grants, so a miss would read as working enforcement).
|
||||
if (value is not null) value.UnsSubjectResolver = ResolveUnsAclSubject;
|
||||
var wasEnforced = AclEnforced;
|
||||
_authorizer = value;
|
||||
// Across an on/off transition the cached verdicts were decided under the other mode.
|
||||
InvalidateFolderEventVerdicts(force: wasEnforced || AclEnforced);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3604,6 +3621,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||
|
||||
// Gitea #826: a UNS variable organizing a raw node IS a UNS reference variable — recorded so the ACL
|
||||
// placement can say so explicitly instead of inferring it from the id's first segment.
|
||||
if (sourceRealm == AddressSpaceRealm.Uns && targetRealm == AddressSpaceRealm.Raw
|
||||
&& refType == ReferenceTypeIds.Organizes
|
||||
&& source is BaseDataVariableState variable
|
||||
&& _variables.TryGetValue(sourceKey, out var live) && ReferenceEquals(live, variable))
|
||||
{
|
||||
_unsReferenceVariables[sourceKey] = variable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3851,6 +3878,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
foreach (var v in _variables.Values) PredefinedNodes?.Remove(v.NodeId);
|
||||
_variables.Clear();
|
||||
_unsReferenceVariables.Clear();
|
||||
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
|
||||
_historizedTagnames.Clear();
|
||||
_arrayOnScalarRefused.Clear();
|
||||
@@ -3898,6 +3926,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <inheritdoc cref="IOpcUaAddressSpaceSink.CompleteAddressSpaceApply"/>
|
||||
public void CompleteAddressSpaceApply()
|
||||
{
|
||||
// Gitea #826: the apply moved conditions, wiring and folders, and the SDK memoizes each folder item's per-event
|
||||
// verdict per (item, event type, condition) until told otherwise — forget them before the restored reports
|
||||
// below go out, so the next event on every folder item is decided against the address space as applied.
|
||||
InvalidateFolderEventVerdicts();
|
||||
int reported = 0, discarded;
|
||||
lock (Lock)
|
||||
{
|
||||
@@ -3938,6 +3970,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
|
||||
// teardown inside RebuildAddressSpace, scoped to this one id.
|
||||
if (!_variables.TryRemove(key, out var variable)) return false;
|
||||
_unsReferenceVariables.TryRemove(key, out _);
|
||||
// Conservatively invalidate every outstanding history cursor on topology removal.
|
||||
Interlocked.Increment(ref _historyContinuationEpoch);
|
||||
UnindexChild(key);
|
||||
@@ -4105,6 +4138,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return;
|
||||
}
|
||||
|
||||
_unsReferenceVariables.TryRemove(key, out _);
|
||||
if (_variables.TryRemove(key, out var v))
|
||||
{
|
||||
UnindexChild(key);
|
||||
@@ -4301,6 +4335,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// (s=Area/Line/Equipment/EffectiveName): authorized at its split path, but only while that path is where the
|
||||
// current generation places the equipment it hangs under (review of #808 — a moved or renamed equipment's
|
||||
// variables keep their OLD-name ids until re-pointed, and the old names' grants must not keep them).
|
||||
// Gitea #826: a variable the reference pass wired to its raw node is a reference variable whatever its id
|
||||
// spells — so an Area named exactly like its equipment's id no longer turns that equipment's references
|
||||
// folder-owned (which only ever narrowed: it lost their Tag-grant match). Untracked nodes keep the id rule.
|
||||
if (node is BaseDataVariableState v
|
||||
&& _unsReferenceVariables.TryGetValue(key, out var reference) && ReferenceEquals(reference, v))
|
||||
{
|
||||
return UnsAclSubject.ReferenceVariable(chain);
|
||||
}
|
||||
|
||||
return firstSegment.SequenceEqual(owning.NodeId.IdentifierAsString)
|
||||
? UnsAclSubject.Folder(chain, isFolderItself: ReferenceEquals(node, owning))
|
||||
: UnsAclSubject.ReferenceVariable(chain);
|
||||
@@ -5478,13 +5521,236 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
if (_alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring)
|
||||
&& ReferenceEquals(wiring.Alarm, condition))
|
||||
{
|
||||
scopes.AddRange(wiring.Folders.Select(f => f.NodeId));
|
||||
// Gitea #826: only a folder THIS generation still projects the alarm's raw tag into. A dropped
|
||||
// reference leaves the wiring in place until the rebuild lands (or indefinitely after a failed apply),
|
||||
// and that folder's grant must stop admitting the alarm the moment the generation drops it.
|
||||
var authority = DataPlaneAuthority.Current;
|
||||
foreach (var folder in wiring.Folders)
|
||||
{
|
||||
if (IsWiredFolderCurrent(authority, condition, folder)) scopes.Add(folder.NodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="folder"/>, wired as an extra notifier of the native <paramref name="condition"/>, is
|
||||
/// one the current generation still projects the condition's raw tag into (Gitea #826).
|
||||
/// </summary>
|
||||
/// <param name="authority">The current generation.</param>
|
||||
/// <param name="condition">A native condition (<c>ConditionId</c> = RawPath).</param>
|
||||
/// <param name="folder">A folder wired for it.</param>
|
||||
/// <returns><see langword="true"/> when a reference of the generation connects them.</returns>
|
||||
/// <remarks>Only UNS equipment folders are ever wired; anything else is not a current wiring (fail closed).</remarks>
|
||||
private bool IsWiredFolderCurrent(DataPlaneAuthoritySnapshot authority, NodeState condition, FolderState folder) =>
|
||||
folder.NodeId.NamespaceIndex == NamespaceIndexForRealm(AddressSpaceRealm.Uns)
|
||||
&& folder.NodeId.TryGetValue(out string equipmentId)
|
||||
&& condition.NodeId.TryGetValue(out string rawPath)
|
||||
&& authority.IsTagReferencedBy(rawPath, equipmentId);
|
||||
|
||||
/// <summary>
|
||||
/// The per-event decision for an event item on one of this manager's own notifiers (an equipment folder, a raw
|
||||
/// device folder) — the SDK fan-out path the Server-object filtering (#586) does not reach (Gitea #826).
|
||||
/// </summary>
|
||||
/// <param name="item">The event item; its manager handle names the notifier it is attached to.</param>
|
||||
/// <param name="e">The event as the SDK delivers it (a condition snapshot, or a cloned audit event).</param>
|
||||
/// <returns><see langword="true"/> when the item's session may receive it through that notifier.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Three conditions, all against the CURRENT state: a live condition holds the event's condition NodeId;
|
||||
/// the notifier is still a legitimate notifier of THAT live instance — the condition itself, a node it hangs
|
||||
/// beneath, or a wired folder the current generation still projects its raw tag into; and the condition is
|
||||
/// visible to the session under exactly the Server-object rule (<see cref="IsConditionVisibleTo"/> over
|
||||
/// <see cref="ConditionVisibilityScopes"/>), so a moved scripted alarm is withheld from its old folder just
|
||||
/// as it is from the Server object.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An event that names no live condition of this manager is withheld (fail closed): only condition events
|
||||
/// and the condition's own method audits reach these notifiers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Decided on the live instance, never on the event's own (review of #826).</b> The SDK caches the
|
||||
/// verdict per <c>(item, event type, SourceNode)</c> — no instance identity — so a verdict must be a
|
||||
/// function of the condition's NodeId and the current state alone. Denying an event because it came from a
|
||||
/// REPLACED instance (a surgical remove + re-materialise under the same NodeId, the old instance's last
|
||||
/// event still in the SDK's queue) cached a deny the new instance then inherited until the next
|
||||
/// invalidation. A late event from a replaced instance is therefore delivered exactly when the live one's
|
||||
/// would be.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Runs on the SDK's per-node delivery task, where the inherited role-permission check already takes
|
||||
/// <c>Lock</c> (via <c>GetNodeMetadata</c>); the scope resolution here takes it the same way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool IsNotifierEventVisibleTo(IEventMonitoredItem item, IFilterTarget e)
|
||||
{
|
||||
if (item.ManagerHandle is not NodeHandle { Node: { } notifier }) return false;
|
||||
if (EventConditionOf(e) is not { } condition) return false;
|
||||
if (!_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live)) return false;
|
||||
if (!IsLiveNotifierOf(live, notifier)) return false;
|
||||
return IsConditionVisibleTo(item, ConditionVisibilityScopes(live));
|
||||
}
|
||||
|
||||
/// <summary>The condition an event delivered through a notifier is about, or null.</summary>
|
||||
/// <param name="e">The event: a condition's snapshot, the condition itself, or an event whose SourceNode names one.</param>
|
||||
/// <returns>The condition state, or null.</returns>
|
||||
private ConditionState? EventConditionOf(IFilterTarget e)
|
||||
{
|
||||
var node = ConditionOf(e);
|
||||
if (node is ConditionState condition) return condition;
|
||||
|
||||
// A condition's Part 9 method audit (cloned by the SDK on its way through a notifier): its SourceNode is the
|
||||
// condition.
|
||||
return node is BaseEventState { SourceNode.Value: { IsNull: false } source }
|
||||
&& OwnsNamespace(source)
|
||||
&& _alarmConditions.TryGetValue(MapKey(source), out var named)
|
||||
? named
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="notifier"/> is, right now, a node the condition's events legitimately bubble
|
||||
/// through (Gitea #826).</summary>
|
||||
/// <param name="condition">The live condition.</param>
|
||||
/// <param name="notifier">The node the event item is attached to.</param>
|
||||
/// <returns><see langword="true"/> for the condition itself, a node above it, or a wired folder the current
|
||||
/// generation still projects it into.</returns>
|
||||
private bool IsLiveNotifierOf(AlarmConditionState condition, NodeState notifier)
|
||||
{
|
||||
if (ReferenceEquals(notifier, condition)) return true;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
for (var ancestor = (condition as BaseInstanceState)?.Parent;
|
||||
ancestor is not null;
|
||||
ancestor = (ancestor as BaseInstanceState)?.Parent)
|
||||
{
|
||||
if (ReferenceEquals(ancestor, notifier)) return true;
|
||||
}
|
||||
|
||||
return notifier is FolderState folder
|
||||
&& _alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring)
|
||||
&& ReferenceEquals(wiring.Alarm, condition)
|
||||
&& wiring.Folders.Contains(folder)
|
||||
&& IsWiredFolderCurrent(DataPlaneAuthority.Current, condition, folder);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates whether an event item may receive an event: the SDK's role-permission check, and — with enforcement
|
||||
/// on, for an item on one of this manager's own nodes — <see cref="IsNotifierEventVisibleTo"/> (Gitea #826).
|
||||
/// </summary>
|
||||
/// <param name="monitoredItem">The event item.</param>
|
||||
/// <param name="filterTarget">The event.</param>
|
||||
/// <returns>Good to deliver; otherwise the veto.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The SDK's <c>MonitoredNode2</c> asks this, per event and per item, before it queues an event to an item
|
||||
/// attached to one of this manager's notifiers. The Server-object path (#586) and ConditionRefresh decide
|
||||
/// the same question themselves, so a folder's subscribers stop receiving a moved alarm (or a native alarm
|
||||
/// whose reference was dropped) the moment the generation says so, not when the rebuild lands.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ The SDK MEMOIZES the verdict per <c>(item, event type, SourceNode)</c> until the monitored node's
|
||||
/// permission generation moves, so <see cref="InvalidateFolderEventVerdicts"/> must run whenever the inputs
|
||||
/// change: a new authority generation or authorizer, the revalidation tick, and the end of every apply. A
|
||||
/// condition's SourceNode is the condition itself, so a verdict is never shared between two conditions.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
ServiceResult INodeManager3.ValidateEventRolePermissions(IEventMonitoredItem monitoredItem, IFilterTarget filterTarget)
|
||||
{
|
||||
var result = ValidateEventRolePermissions(monitoredItem, filterTarget);
|
||||
if (ServiceResult.IsBad(result) || !AclEnforced || !OwnsNamespace(monitoredItem.NodeId)) return result;
|
||||
return IsNotifierEventVisibleTo(monitoredItem, filterTarget) ? result : FolderEventDenied;
|
||||
}
|
||||
|
||||
/// <summary>The verdict a withheld folder event carries back to the SDK (never reaches the client).</summary>
|
||||
private static readonly ServiceResult FolderEventDenied = new(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
/// <summary>
|
||||
/// Drops every per-event verdict the SDK has memoized for event items on this manager's notifiers (Gitea #826),
|
||||
/// so the next event on each is decided afresh by <see cref="IsNotifierEventVisibleTo"/>.
|
||||
/// </summary>
|
||||
/// <param name="force">Run even while enforcement is off — the authorizer setter's on/off transition, where the
|
||||
/// cached verdicts were decided under the other mode.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Node-wide, session-agnostic.</b> Each notifier's cache is cleared whole through the SDK's own private
|
||||
/// <c>MonitoredNode2.InvalidatePermissionCaches</c> (what the SDK runs when namespace default permissions
|
||||
/// change, and on <c>Rebind</c>): it advances the node's permission generation — a verdict computed
|
||||
/// concurrently under the old one is recomputed, never stored — and clears the cache in one O(cache) pass.
|
||||
/// The public <c>InvalidatePermissionCacheForSession</c> is NOT sufficient (review of #826): it reaches only
|
||||
/// items whose <c>Session</c> is set, and an ORPHANED subscription's items (session closed without deleting
|
||||
/// its subscriptions) have none — they kept a stale allow through a deploy, queued the moved alarm's events
|
||||
/// and delivered them on <c>TransferSubscriptions</c>. It also rescanned the whole cache once per item
|
||||
/// (items² × conditions per folder).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The private method is resolved once by reflection against the pinned SDK and pinned by
|
||||
/// <c>FolderEventVerdictInvalidationGuardTests</c>. Should a future SDK drop it, this falls back to the
|
||||
/// supported route: the per-session drop for items with a session, and a detach + re-attach of each
|
||||
/// session-less item on its node (<c>Remove</c> drops that item's entries), which can miss an event racing
|
||||
/// the swap — the reason it is only the fallback.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Skipped while enforcement is off (<paramref name="force"/> aside): the verdicts are then the SDK's own
|
||||
/// role check, which nothing here changes. The node list is copied under <c>Lock</c>; the invalidation
|
||||
/// takes no lock of ours.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void InvalidateFolderEventVerdicts(bool force = false)
|
||||
{
|
||||
if (_root is null || (!force && !AclEnforced)) return;
|
||||
|
||||
List<MonitoredNode2> nodes;
|
||||
lock (Lock) nodes = MonitoredNodes.Values.Where(n => n.EventMonitoredItems is { IsEmpty: false }).ToList();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
Interlocked.Increment(ref _folderEventVerdictInvalidations);
|
||||
if (InvalidateNodePermissionCaches is { } invalidate)
|
||||
{
|
||||
invalidate(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
var sessions = new HashSet<NodeId>();
|
||||
var sessionless = new List<IEventMonitoredItem>();
|
||||
foreach (var item in node.EventMonitoredItems.Values)
|
||||
{
|
||||
if (item is null) continue;
|
||||
if (item.Session?.Id is { IsNull: false } id) sessions.Add(id);
|
||||
else sessionless.Add(item);
|
||||
}
|
||||
|
||||
foreach (var session in sessions) node.InvalidatePermissionCacheForSession(session);
|
||||
foreach (var item in sessionless)
|
||||
{
|
||||
node.Remove(item);
|
||||
node.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Notifier caches <see cref="InvalidateFolderEventVerdicts"/> has invalidated since start. Test
|
||||
/// observability (it must not run while enforcement is off).</summary>
|
||||
internal long FolderEventVerdictInvalidations => Interlocked.Read(ref _folderEventVerdictInvalidations);
|
||||
|
||||
private long _folderEventVerdictInvalidations;
|
||||
|
||||
/// <summary>
|
||||
/// The SDK's private node-wide cache invalidation (<c>MonitoredNode2.InvalidatePermissionCaches</c>), or null
|
||||
/// when the SDK no longer has it — see <see cref="InvalidateFolderEventVerdicts"/>.
|
||||
/// </summary>
|
||||
internal static readonly Action<MonitoredNode2>? InvalidateNodePermissionCaches =
|
||||
typeof(MonitoredNode2).GetMethod(
|
||||
"InvalidatePermissionCaches",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,
|
||||
Type.EmptyTypes) is { ReturnType: var returns } method && returns == typeof(void)
|
||||
? method.CreateDelegate<Action<MonitoredNode2>>()
|
||||
: null;
|
||||
|
||||
/// <summary>Whether <paramref name="item"/>'s session may see a condition with the given scopes.</summary>
|
||||
/// <param name="item">The Server-object event item.</param>
|
||||
/// <param name="scopes">From <see cref="ConditionVisibilityScopes"/>.</param>
|
||||
@@ -5530,10 +5796,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
(item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item);
|
||||
}
|
||||
|
||||
if (direct.Count > 0) base.ConditionRefresh(context, direct);
|
||||
var systemContext = SystemContext.Copy(context);
|
||||
if (direct.Count > 0) RefreshDirectItems(systemContext, direct);
|
||||
if (serverItems.Count == 0) return ServiceResult.Good;
|
||||
|
||||
var systemContext = SystemContext.Copy(context);
|
||||
List<NodeState> notifiers;
|
||||
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
|
||||
|
||||
@@ -5562,6 +5828,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConditionRefresh for event items on this manager's own notifiers, under enforcement (Gitea #826): the SDK
|
||||
/// base's loop exactly — an item no longer attached (stood down by revalidation, or tombstoned) is not
|
||||
/// refreshed — plus the per-event decision live delivery makes (<see cref="IsNotifierEventVisibleTo"/>), so a
|
||||
/// refresh cannot replay to a folder's subscriber an alarm that folder's live events withhold.
|
||||
/// </summary>
|
||||
/// <param name="systemContext">The refresh's system context.</param>
|
||||
/// <param name="items">The direct (non-Server-object) items being refreshed.</param>
|
||||
private void RefreshDirectItems(ServerSystemContext systemContext, List<IEventMonitoredItem> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
NodeState? notifier;
|
||||
lock (Lock)
|
||||
{
|
||||
if (!MonitoredItems.ContainsKey(item.Id)) continue;
|
||||
notifier = (item.ManagerHandle as NodeHandle)?.Node;
|
||||
}
|
||||
|
||||
if (notifier is null) continue;
|
||||
|
||||
var events = new List<IFilterTarget>();
|
||||
notifier.ConditionRefresh(systemContext, events, true);
|
||||
foreach (var e in events)
|
||||
{
|
||||
if (!IsNotifierEventVisibleTo(item, e)) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
item.QueueEvent(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The condition a refresh event describes, or null when it names none (withheld: fail closed).</summary>
|
||||
/// <param name="e">A refresh event.</param>
|
||||
/// <returns>The condition node.</returns>
|
||||
@@ -5663,7 +5961,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// </remarks>
|
||||
public void RevalidateMonitoredItems()
|
||||
{
|
||||
if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return;
|
||||
if (!AclEnforced) return;
|
||||
|
||||
// Gitea #826: both triggers (a deploy, the clock) also reach the per-event decision on folder event items, whose
|
||||
// verdicts the SDK memoizes — a deploy that moves an alarm must not keep serving its old folder's allow.
|
||||
InvalidateFolderEventVerdicts();
|
||||
if (_liveItems.IsEmpty && _eventItems.IsEmpty) return;
|
||||
|
||||
var now = UtcNow;
|
||||
var stoodDown = 0;
|
||||
|
||||
@@ -93,6 +93,16 @@ public sealed record DataPlaneAuthorityProjection(
|
||||
public IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes { get; init; } =
|
||||
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// <c>RawPath → EquipmentIds</c> from the artifact's <c>UnsTagReferences</c> (Gitea #826): which equipment
|
||||
/// folders this generation projects each ALARM-BEARING raw tag (<c>TagConfig.alarm</c>) into — the folders a native alarm on that tag may be
|
||||
/// delivered through. RawPaths come from the SAME <see cref="AclScopePathResolver"/> as
|
||||
/// <see cref="ScopePaths"/>, so they equal the native conditions' <c>ConditionId</c>s by construction. Empty
|
||||
/// when the artifact carries no grants.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferencingEquipment { get; init; } =
|
||||
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
|
||||
public static readonly DataPlaneAuthorityProjection Empty =
|
||||
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||
@@ -938,7 +948,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
grants.Add(acl);
|
||||
}
|
||||
|
||||
var (scopePaths, equipmentScopePaths, unsFolderScopes) = BuildAclScopePaths(root, grants);
|
||||
var (scopePaths, equipmentScopePaths, unsFolderScopes, tagReferencingEquipment) = BuildAclScopePaths(root, grants);
|
||||
// Every scripted alarm's owner, from the rows the address space is composed from. Read regardless of
|
||||
// the grant count: it is a topology fact, cheap, and an absent map only ever denies.
|
||||
var scriptedAlarmOwners = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
@@ -955,6 +965,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
EquipmentScopePaths = equipmentScopePaths,
|
||||
ScriptedAlarmOwners = scriptedAlarmOwners,
|
||||
UnsFolderScopes = unsFolderScopes,
|
||||
TagReferencingEquipment = tagReferencingEquipment,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -964,10 +975,12 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
/// path and the RawPath that forms a node's NodeId cannot drift apart.
|
||||
/// </summary>
|
||||
/// <returns>The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup,
|
||||
/// Gitea #797) and every UNS folder's path + id chain (Gitea #808) from the same resolver.</returns>
|
||||
/// Gitea #797), every UNS folder's path + id chain (Gitea #808) and every referenced raw tag's referencing
|
||||
/// equipment (Gitea #826) from the same resolver.</returns>
|
||||
private static (IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths,
|
||||
IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths,
|
||||
IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes) BuildAclScopePaths(
|
||||
IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes,
|
||||
IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferencingEquipment) BuildAclScopePaths(
|
||||
JsonElement root,
|
||||
IReadOnlyList<NodeAcl> grants)
|
||||
{
|
||||
@@ -975,7 +988,8 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
{
|
||||
return (new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(),
|
||||
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase));
|
||||
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
|
||||
@@ -1013,12 +1027,17 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
}
|
||||
|
||||
var tags = new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(StringComparer.Ordinal);
|
||||
// TagConfig kept unparsed: only a REFERENCED tag's is parsed below (Gitea #826 — alarm-bearing ones only).
|
||||
var tagConfigs = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "Tags"))
|
||||
{
|
||||
var id = ReadString(el, "TagId");
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
if (id is null || deviceId is null) continue;
|
||||
tags[id] = (deviceId, ReadNullableString(el, "TagGroupId"), ReadString(el, "Name") ?? id);
|
||||
tagConfigs[id] = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||
? tcEl.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
var areas = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
@@ -1077,7 +1096,29 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path;
|
||||
}
|
||||
|
||||
return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment));
|
||||
// Which equipment folders each ALARM-BEARING raw tag is projected into (Gitea #826) — the folders a native alarm
|
||||
// on that tag may legitimately be delivered through; only native conditions are ever looked up, so plain value
|
||||
// tags are left out. Keyed on the RawPath the SAME resolver builds, which is the native condition's ConditionId.
|
||||
// Alarm-bearing is decided by the SAME TagConfigIntent the composer materialises native conditions from.
|
||||
var alarmBearing = new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||
var referencing = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
foreach (var reference in references)
|
||||
{
|
||||
if (!alarmBearing.TryGetValue(reference.TagId, out var isAlarm))
|
||||
{
|
||||
isAlarm = TagConfigIntent.Parse(tagConfigs.GetValueOrDefault(reference.TagId)).Alarm is not null;
|
||||
alarmBearing[reference.TagId] = isAlarm;
|
||||
}
|
||||
|
||||
if (!isAlarm) continue;
|
||||
if (resolver.ResolveTagRawPath(reference.TagId) is not { } rawPath) continue;
|
||||
if (!referencing.TryGetValue(rawPath, out var set))
|
||||
referencing[rawPath] = set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
set.Add(reference.EquipmentId);
|
||||
}
|
||||
|
||||
return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment),
|
||||
referencing.ToDictionary(kv => kv.Key, kv => (IReadOnlySet<string>)kv.Value, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1426,7 +1426,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
|
||||
var snapshot = _dataPlaneAuthority.Apply(
|
||||
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths,
|
||||
projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes);
|
||||
projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes,
|
||||
projection.TagReferencingEquipment);
|
||||
|
||||
_log.Info(
|
||||
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
|
||||
|
||||
+7
-7
@@ -88,7 +88,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
|
||||
// The next deployment carries only EqB's grant.
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
rig.AttachedEventItems.ShouldBe(1);
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
@@ -100,7 +100,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
|
||||
// A deployment that restores the grant brings the feed back on its own.
|
||||
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
rig.AttachedEventItems.ShouldBe(2);
|
||||
|
||||
rig.Transition("t3", active: true);
|
||||
@@ -143,7 +143,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
|
||||
rig.AttachedEventItems.ShouldBe(2);
|
||||
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
rig.AttachedEventItems.ShouldBe(1);
|
||||
|
||||
subscription.RemoveItem(onA);
|
||||
@@ -153,7 +153,7 @@ 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(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
rig.AttachedEventItems.ShouldBe(1, "the deleted item must not be resurrected onto EqA's fan-out");
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
@@ -410,7 +410,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1"));
|
||||
|
||||
state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2"));
|
||||
@@ -418,7 +418,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
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(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
|
||||
rig.Transition("t3", active: true);
|
||||
await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3"));
|
||||
@@ -1049,7 +1049,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
|
||||
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Review of Gitea #826: the SDK memoizes each folder event item's per-event verdict per
|
||||
/// <c>(item, event type, SourceNode)</c>, so the per-event decision is only as fresh as its invalidation. These pin
|
||||
/// the three ways it went stale or cost too much: an ORPHANED subscription (no session to invalidate by), a stale
|
||||
/// DENY left by a replaced instance's late event, and invalidation running with enforcement off.
|
||||
/// </summary>
|
||||
public sealed class FolderEventVerdictInvalidationTests
|
||||
{
|
||||
private const string EqA = ServerEventRig.EqA;
|
||||
private const string EqB = ServerEventRig.EqB;
|
||||
|
||||
/// <summary>
|
||||
/// A subscription orphaned by its session (closed without deleting its subscriptions) keeps its items on the
|
||||
/// folder fan-out with NO session. A deployment that drops EqA's reference must still reach those items: the
|
||||
/// native alarm's events raised while orphaned are not queued to the EqA item, so a TransferSubscriptions to a
|
||||
/// new session delivers them only through EqB (positive control on the same subscription and event).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_orphaned_folder_item_does_not_keep_a_stale_allow_across_a_deploy_and_a_transfer()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Folder826Orphan", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
rig.MaterialiseNative();
|
||||
NodeAcl[] grants = [KeyEquipmentGrant(EqA), KeyEquipmentGrant(EqB)];
|
||||
var state = rig.Enforce(true, [ServerEventRig.Key()], grants);
|
||||
|
||||
using var first = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(first, events, ct);
|
||||
var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqA));
|
||||
var onB = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(first, EqB));
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
onA.Status.Error.ShouldBeNull();
|
||||
|
||||
// Seed the SDK's cache with an ALLOW for the EqA item.
|
||||
rig.Project(native: true, State("t1"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "t1") && events.Has(onB, "t1"));
|
||||
|
||||
// Orphan the subscription: a raw CloseSession with deleteSubscriptions=false, so the subscription and its items
|
||||
// stay on the server with no session. The raw service leaves the client session OBJECT alive (Session.CloseAsync
|
||||
// would dispose it), as a reconnecting client's is, so the high-level transfer can take the subscription over.
|
||||
var orphaned = new SubscriptionCollection(first.Subscriptions);
|
||||
await ((ISessionClient)first).CloseSessionAsync(null, deleteSubscriptions: false, ct);
|
||||
|
||||
// The deployment drops EqA's reference; the alarm reports while the subscription has no session.
|
||||
Deploy(state, "rev-2", grants, EqB);
|
||||
rig.Project(native: true, State("t2"));
|
||||
|
||||
using var second = await rig.ConnectAsync(anonymous: false, ct);
|
||||
(await second.TransferSubscriptionsAsync(orphaned, sendInitialValues: false, ct)).ShouldBeTrue();
|
||||
|
||||
rig.Project(native: true, State("t3"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onB, "t2") && events.Has(onB, "t3"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
events.Has(onA, "t2").ShouldBeFalse("an event raised while orphaned must not be queued on a stale allow");
|
||||
events.Has(onA, "t3").ShouldBeFalse();
|
||||
|
||||
await second.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A surgical replace of a scripted condition under the SAME NodeId (remove + re-materialise, the folder item
|
||||
/// survives), with the old instance's last event still waiting in the SDK's per-folder queue when the apply's
|
||||
/// invalidation runs. That late event must not leave a cached DENY the new instance inherits: the new
|
||||
/// instance's events keep reaching the folder subscriber.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_replaced_instances_late_event_does_not_leave_a_cached_deny_for_the_new_instance()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Folder826Replace", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
rig.MaterialiseScripted();
|
||||
rig.MaterialiseNative(); // also wired into EqA: its event is the deterministic blocker below
|
||||
rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly));
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
onA.Status.Error.ShouldBeNull();
|
||||
|
||||
rig.Project(native: false, State("s1"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s1"));
|
||||
|
||||
// Holding the node-manager lock parks EqA's SDK delivery task: the native event "n1" is queued first and its
|
||||
// verdict (a cache miss after the first invalidation) needs the lock in the inherited role check — its node is
|
||||
// never removed, so that check always takes it. The OLD scripted instance's "s2", queued behind it on the same
|
||||
// single-reader queue, is therefore decided only after the replace and the apply's invalidation.
|
||||
lock (rig.Nm.Lock)
|
||||
{
|
||||
rig.Nm.CompleteAddressSpaceApply(); // forget the cached allows, so both verdicts must be computed
|
||||
rig.Project(native: true, State("n1"));
|
||||
rig.Project(native: false, State("s2"));
|
||||
((ISurgicalAddressSpaceSink)rig.Sink)
|
||||
.RemoveAlarmConditionNode(ServerEventRig.ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
rig.MaterialiseScripted();
|
||||
rig.Nm.CompleteAddressSpaceApply();
|
||||
}
|
||||
|
||||
rig.Project(native: false, State("s3"));
|
||||
// The new instance's event arrives (a cached deny would withhold it until the next invalidation), and so does
|
||||
// the old instance's late one — decided on the live instance, exactly as the live one's would be.
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "s3") && events.Has(onA, "s2"));
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review of #826 (perf): with enforcement OFF the folder verdicts are the SDK's own role check, so nothing
|
||||
/// invalidates them — not the revalidation tick, not a deploy, not the end of an apply. Positive control: the
|
||||
/// same calls invalidate once enforcement is on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Folder_verdicts_are_not_invalidated_while_enforcement_is_off()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Folder826AclOff", encrypted: false, ct);
|
||||
rig.MaterialiseNative();
|
||||
var state = rig.Enforce(false, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly));
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
var before = rig.Nm.FolderEventVerdictInvalidations;
|
||||
rig.Nm.RevalidateMonitoredItems();
|
||||
rig.Nm.CompleteAddressSpaceApply();
|
||||
state.Apply(ServerEventRig.Cluster, "rev-off-2", [], [ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly)],
|
||||
ServerEventRig.ScopePaths());
|
||||
rig.Nm.FolderEventVerdictInvalidations.ShouldBe(before, "enforcement off: nothing to invalidate");
|
||||
|
||||
rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly));
|
||||
var enforced = rig.Nm.FolderEventVerdictInvalidations;
|
||||
enforced.ShouldBeGreaterThan(before, "switching enforcement on invalidates the verdicts decided while off");
|
||||
rig.Nm.CompleteAddressSpaceApply();
|
||||
rig.Nm.FolderEventVerdictInvalidations.ShouldBeGreaterThan(enforced);
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The node-wide invalidation rests on a PRIVATE method of the pinned SDK (<c>MonitoredNode2.InvalidatePermissionCaches</c>).
|
||||
/// If an SDK upgrade drops it the node manager silently falls back to the weaker supported route — this fails
|
||||
/// first, so the upgrade is looked at.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_sdks_node_wide_permission_cache_invalidation_is_still_resolvable()
|
||||
{
|
||||
OtOpcUaNodeManager.InvalidateNodePermissionCaches.ShouldNotBeNull(
|
||||
"MonitoredNode2.InvalidatePermissionCaches() is gone — re-check InvalidateFolderEventVerdicts against the new SDK");
|
||||
}
|
||||
|
||||
private static AlarmConditionSnapshot State(string message) =>
|
||||
new(true, false, false, true, AlarmShelvingKind.Unshelved, 700, message);
|
||||
|
||||
private static void Deploy(DataPlaneAuthorityState state, string revision, NodeAcl[] grants, params string[] referencing) =>
|
||||
state.Apply(
|
||||
ServerEventRig.Cluster, revision, [ServerEventRig.Key()], grants, ServerEventRig.ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(),
|
||||
new Dictionary<string, IReadOnlySet<string>> { [ServerEventRig.RawAlarmPath] = referencing.ToHashSet() });
|
||||
|
||||
private static NodeAcl KeyEquipmentGrant(string equipment) => new()
|
||||
{
|
||||
NodeAclId = $"acl-key-{equipment}",
|
||||
ClusterId = ServerEventRig.Cluster,
|
||||
Principal = ServerEventRig.KeyPrincipal,
|
||||
ScopeKind = NodeAclScopeKind.Equipment,
|
||||
ScopeId = $"eq-{equipment}",
|
||||
PermissionFlags = NodePermissions.ReadOnly,
|
||||
};
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #826, native variant, over the wire: a native alarm materialises once at its raw tag and is wired as an
|
||||
/// extra event notifier into every referencing equipment folder. A deployment that drops one equipment's reference
|
||||
/// installs its generation BEFORE the rebuild that unwires the folder (and an apply that fails keeps the old
|
||||
/// wiring), so the folder's subscribers used to keep receiving the alarm — EventId included — through the SDK's
|
||||
/// per-folder fan-out, which no ACL decision reached, and through the Server object, whose scopes admitted every
|
||||
/// wired folder. Both now ask the CURRENT generation whether it still projects the raw tag into that equipment.
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmFolderEventAclWireTests
|
||||
{
|
||||
private const string EqA = ServerEventRig.EqA;
|
||||
private const string EqB = ServerEventRig.EqB;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous holds EqA, the key holds EqB, the alarm is wired into both. The generation drops EqA's reference
|
||||
/// with the wiring still in place: EqA's folder item and the anonymous Server-object item stop receiving the
|
||||
/// alarm (live AND ConditionRefresh), while the key's EqB folder and Server-object items receive the same events
|
||||
/// (positive control). Restoring the reference restores EqA's feed with no client action — the SDK's memoized
|
||||
/// per-event verdict is invalidated by the generation change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_native_alarm_stops_reaching_a_folder_whose_reference_the_generation_dropped()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Folder826Native", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
rig.MaterialiseNative();
|
||||
NodeAcl[] grants = [ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly), KeyEquipmentGrant(EqB)];
|
||||
var state = rig.Enforce(true, [ServerEventRig.Key()], grants);
|
||||
|
||||
using var anonymous = await rig.ConnectAsync(anonymous: true, ct);
|
||||
using var keyed = await rig.ConnectAsync(anonymous: false, ct);
|
||||
|
||||
var anonEvents = new RecordedEvents();
|
||||
var anonSub = await RecordedEvents.SubscribeAsync(anonymous, anonEvents, ct);
|
||||
var onA = RecordedEvents.AddEventItem(anonSub, ServerEventRig.UnsNodeId(anonymous, EqA));
|
||||
var anonServer = RecordedEvents.AddEventItem(anonSub, ObjectIds.Server);
|
||||
await anonSub.ApplyChangesAsync(ct);
|
||||
onA.Status.Error.ShouldBeNull();
|
||||
|
||||
var keyEvents = new RecordedEvents();
|
||||
var keySub = await RecordedEvents.SubscribeAsync(keyed, keyEvents, ct);
|
||||
var onB = RecordedEvents.AddEventItem(keySub, ServerEventRig.UnsNodeId(keyed, EqB));
|
||||
var keyServer = RecordedEvents.AddEventItem(keySub, ObjectIds.Server);
|
||||
await keySub.ApplyChangesAsync(ct);
|
||||
onB.Status.Error.ShouldBeNull();
|
||||
|
||||
// Baseline: every item receives the alarm while both references are deployed.
|
||||
rig.Project(native: true, State("t1"));
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
anonEvents.Has(onA, "t1") && anonEvents.Has(anonServer, "t1")
|
||||
&& keyEvents.Has(onB, "t1") && keyEvents.Has(keyServer, "t1"));
|
||||
|
||||
// The next generation drops EqA's reference; the address space still wires the alarm into EqA.
|
||||
Deploy(state, "rev-2", grants, EqB);
|
||||
|
||||
rig.Project(native: true, State("t2"));
|
||||
await RecordedEvents.WaitUntilAsync(() => keyEvents.Has(onB, "t2") && keyEvents.Has(keyServer, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
anonEvents.Has(onA, "t2").ShouldBeFalse("the folder the generation dropped must not receive the alarm");
|
||||
anonEvents.Has(anonServer, "t2").ShouldBeFalse("nor may that folder's grant admit it through the Server object");
|
||||
|
||||
// ConditionRefresh on the stale folder respects the same rule; the still-referenced folder replays it.
|
||||
await RefreshAsync(anonymous, anonSub, ct);
|
||||
await RefreshAsync(keyed, keySub, ct);
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
anonEvents.HasType(onA, ObjectTypeIds.RefreshEndEventType)
|
||||
&& keyEvents.ConditionEvents(onB, "t2").Count == 2);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
||||
anonEvents.Has(onA, "t2").ShouldBeFalse("a refresh must not replay the alarm to the dropped folder");
|
||||
anonEvents.Has(anonServer, "t2").ShouldBeFalse();
|
||||
|
||||
// Restoring the reference restores the feed on the next event — no client action, no stale cached deny.
|
||||
Deploy(state, "rev-3", grants, EqA, EqB);
|
||||
rig.Project(native: true, State("t3"));
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
anonEvents.Has(onA, "t3") && anonEvents.Has(anonServer, "t3") && keyEvents.Has(onB, "t3"));
|
||||
|
||||
await anonymous.CloseAsync(ct);
|
||||
await keyed.CloseAsync(ct);
|
||||
}
|
||||
|
||||
private static AlarmConditionSnapshot State(string message) =>
|
||||
new(true, false, false, true, AlarmShelvingKind.Unshelved, 700, message);
|
||||
|
||||
private static void Deploy(DataPlaneAuthorityState state, string revision, NodeAcl[] grants, params string[] referencing) =>
|
||||
state.Apply(
|
||||
ServerEventRig.Cluster, revision, [ServerEventRig.Key()], grants, ServerEventRig.ScopePaths(),
|
||||
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes(),
|
||||
new Dictionary<string, IReadOnlySet<string>> { [ServerEventRig.RawAlarmPath] = referencing.ToHashSet() });
|
||||
|
||||
private static NodeAcl KeyEquipmentGrant(string equipment) => new()
|
||||
{
|
||||
NodeAclId = $"acl-key-{equipment}",
|
||||
ClusterId = ServerEventRig.Cluster,
|
||||
Principal = ServerEventRig.KeyPrincipal,
|
||||
ScopeKind = NodeAclScopeKind.Equipment,
|
||||
ScopeId = $"eq-{equipment}",
|
||||
PermissionFlags = NodePermissions.ReadOnly,
|
||||
};
|
||||
|
||||
private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) =>
|
||||
await session.CallAsync(
|
||||
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
|
||||
}
|
||||
@@ -127,7 +127,8 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(
|
||||
Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), FolderScopes());
|
||||
Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), FolderScopes(),
|
||||
TagReferences());
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);
|
||||
@@ -163,7 +164,7 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
PermissionFlags = permissions,
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
|
||||
public static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqA}")] = [new NodeAclPath([NodeAclRealms.Uns, EqA])],
|
||||
@@ -195,6 +196,13 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
[EqB] = new(new NodeAclPath([NodeAclRealms.Uns, EqB]), [EqB]),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The rig's deployed UNS references (Gitea #826): its native alarm's raw tag is projected into both equipment —
|
||||
/// the folders <see cref="MaterialiseNative"/> wires it into, so per-folder delivery of it is current.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferences() =>
|
||||
new Dictionary<string, IReadOnlySet<string>> { [RawAlarmPath] = new HashSet<string> { EqA, 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 };
|
||||
|
||||
+109
-2
@@ -210,6 +210,65 @@ public sealed class UnsFolderScopeWireTests
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #826 — the same stale window seen by a DIRECT event item on the old owner's equipment folder, which the
|
||||
/// SDK feeds through its per-folder fan-out rather than the Server object. The old owner's folder subscription
|
||||
/// receives neither the moved alarm's live events nor its ConditionRefresh replay (EventId included), while its
|
||||
/// correctly placed alarm keeps arriving on the same item (positive control). After the rebuild re-materialises
|
||||
/// the alarm under its deployed owner, the new owner's folder subscription receives it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_moved_alarm_stops_reaching_its_old_folders_subscribers_until_the_rebuild_places_it()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns826FolderMoved", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
// The key holds the OLD owner (Pump); anonymous holds the NEW one (Fan). The generation already moved Alarm.
|
||||
Install(rig, pumpLine: Line1, Deployed(alarmOwner: Fan),
|
||||
EquipmentGrant(Pump, Operator), AnonymousGrant(NodeAclScopeKind.Equipment, Fan));
|
||||
|
||||
using var oldOwner = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var newOwner = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var oldEvents = new RecordedEvents();
|
||||
var oldSubscription = await RecordedEvents.SubscribeAsync(oldOwner, oldEvents, ct);
|
||||
var pumpFolder = RecordedEvents.AddEventItem(oldSubscription, Uns(oldOwner, Pump));
|
||||
await oldSubscription.ApplyChangesAsync(ct);
|
||||
pumpFolder.Status.Error.ShouldBeNull();
|
||||
var newEvents = new RecordedEvents();
|
||||
var newSubscription = await RecordedEvents.SubscribeAsync(newOwner, newEvents, ct);
|
||||
var fanFolder = RecordedEvents.AddEventItem(newSubscription, Uns(newOwner, Fan));
|
||||
await newSubscription.ApplyChangesAsync(ct);
|
||||
fanFolder.Status.Error.ShouldBeNull();
|
||||
|
||||
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
|
||||
Project(rig, Alarm, pre with { Message = "moved" }); // reported first: the control arrives after its verdict
|
||||
Project(rig, PumpLo, pre with { Message = "control" });
|
||||
await RecordedEvents.WaitUntilAsync(() => oldEvents.Has(pumpFolder, "control"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
oldEvents.Has(pumpFolder, "moved").ShouldBeFalse("the moved alarm must not reach its old owner's folder item");
|
||||
|
||||
// ConditionRefresh on the folder item replays the placed alarm and withholds the moved one.
|
||||
await RefreshAsync(oldOwner, oldSubscription, ct);
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
oldEvents.HasType(pumpFolder, ObjectTypeIds.RefreshEndEventType)
|
||||
&& oldEvents.ConditionEvents(pumpFolder, "control").Count == 2);
|
||||
oldEvents.Has(pumpFolder, "moved").ShouldBeFalse("a refresh must not replay the moved alarm to the old folder");
|
||||
|
||||
// The rebuild places it under its deployed owner.
|
||||
((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
|
||||
rig.Nm.CompleteAddressSpaceApply();
|
||||
Project(rig, Alarm, pre with { Message = "placed" });
|
||||
Project(rig, PumpLo, pre with { Message = "control-2" });
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
newEvents.Has(fanFolder, "placed") && oldEvents.Has(pumpFolder, "control-2"));
|
||||
oldEvents.Has(pumpFolder, "placed").ShouldBeFalse();
|
||||
|
||||
await oldOwner.CloseAsync(ct);
|
||||
await newOwner.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
|
||||
@@ -294,6 +353,34 @@ public sealed class UnsFolderScopeWireTests
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #826 (secondary edge of #808): an Area NAMED exactly like one of its equipment's opaque ids. That
|
||||
/// equipment's reference variable (<c>s=<AreaName>/Line/Equipment/Name</c>) then starts with the
|
||||
/// equipment's own id, which the id rule alone read as folder-owned — losing its Tag-grant match. The variable
|
||||
/// is now known to be a reference by the Organizes edge the reference pass wires to its raw node, so the Tag
|
||||
/// grant reaches it; the equipment folder itself stays denied to that grant (control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_reference_variable_under_an_area_named_like_its_equipment_id_keeps_its_tag_grant()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Uns826AreaNamedLikeId", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
Topology(rig, pumpLine: Line1);
|
||||
var raw = rig.EnsureRawVariable("flow", "Double", writable: false);
|
||||
var reference = $"{Pump}/Line1/Pump/Flow"; // the Area's NAME is Pump's id
|
||||
rig.Sink.EnsureVariable(reference, Pump, "Flow", "Double", writable: false, AddressSpaceRealm.Uns);
|
||||
rig.Sink.AddReference(reference, AddressSpaceRealm.Uns, raw, AddressSpaceRealm.Raw);
|
||||
Install(rig, pumpLine: Line1, Deployed(), areaName: Pump, tagArea: Pump, TagGrant());
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
(await ReadAsync(session, Uns(session, reference), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
|
||||
(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 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
|
||||
@@ -481,13 +568,19 @@ public sealed class UnsFolderScopeWireTests
|
||||
/// 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)
|
||||
params NodeAcl[] grants) =>
|
||||
Install(rig, pumpLine, deployedOwners, areaName, tagArea: "Area1", grants);
|
||||
|
||||
/// <summary>As above, with the Tag grant's UNS projection under the Area NAMED <paramref name="tagArea"/>.</summary>
|
||||
private static void Install(
|
||||
ServerEventRig rig, string pumpLine, IReadOnlyDictionary<string, string> deployedOwners, string areaName,
|
||||
string tagArea, 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"])],
|
||||
[new AclScopeRef(NodeAclScopeKind.Tag, FlowTagId)] = [new NodeAclPath([NodeAclRealms.Uns, tagArea, "Line1", "Pump", "Flow"])],
|
||||
};
|
||||
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
|
||||
|
||||
@@ -535,6 +628,20 @@ public sealed class UnsFolderScopeWireTests
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static NodeAcl AnonymousGrant(NodeAclScopeKind kind, string scopeId) => new()
|
||||
{
|
||||
NodeAclId = $"anon-{scopeId}",
|
||||
ClusterId = ServerEventRig.Cluster,
|
||||
Principal = OpcUaPrincipals.Anonymous,
|
||||
ScopeKind = kind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = NodePermissions.ReadOnly,
|
||||
};
|
||||
|
||||
private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) =>
|
||||
await session.CallAsync(
|
||||
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
|
||||
|
||||
private static List<AlarmCommand> Route(ServerEventRig rig)
|
||||
{
|
||||
var routed = new List<AlarmCommand>();
|
||||
|
||||
+60
@@ -312,4 +312,64 @@ public sealed class DeploymentArtifactScopePathTests
|
||||
|
||||
projection.UnsFolderScopes.Keys.ShouldBe(["UL-ok"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #826 — the projection says which equipment each ALARM-BEARING raw tag is projected into, keyed on the
|
||||
/// RawPath the same resolver builds (a native alarm's <c>ConditionId</c>), so the node manager can tell a native
|
||||
/// alarm's still-referenced equipment folders from one whose reference the generation dropped. Plain value tags
|
||||
/// are never looked up and are left out (review: memory).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_projection_carries_each_alarm_bearing_raw_tags_referencing_equipment_under_its_raw_path()
|
||||
{
|
||||
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(AlarmArtifact), NodeId);
|
||||
|
||||
const string hot = "Plant/Line1/ModbusDrv/dev1/Fast/Hot";
|
||||
projection.TagReferencingEquipment.Keys.ShouldBe([hot], "the plain Speed tag is not alarm-bearing");
|
||||
projection.TagReferencingEquipment[hot].Order(StringComparer.Ordinal).ShouldBe(["EQ-mixer", "EQ-oven"]);
|
||||
|
||||
var snapshot = new DataPlaneAuthorityState().Apply(
|
||||
"c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths,
|
||||
projection.ScriptedAlarmOwners, projection.UnsFolderScopes, projection.TagReferencingEquipment);
|
||||
snapshot.IsTagReferencedBy(hot, "EQ-oven").ShouldBeTrue();
|
||||
snapshot.IsTagReferencedBy(hot, "eq-MIXER").ShouldBeTrue("equipment ids compare as identifiers");
|
||||
snapshot.IsTagReferencedBy(hot, "EQ-other").ShouldBeFalse();
|
||||
snapshot.IsTagReferencedBy("Plant/Line1/ModbusDrv/dev1/Fast/Speed", "EQ-mixer").ShouldBeFalse();
|
||||
new DataPlaneAuthorityState().Apply("c1", "rev-1", [], [], projection.ScopePaths)
|
||||
.IsTagReferencedBy(hot, "EQ-mixer").ShouldBeFalse("an absent map references nothing (fail closed)");
|
||||
}
|
||||
|
||||
// The fixture's topology plus an alarm-bearing Hot tag referenced by two equipment; Speed stays a plain value.
|
||||
private const string AlarmArtifact = """
|
||||
{
|
||||
"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"},
|
||||
{"TagId":"TAG-hot","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Hot",
|
||||
"TagConfig":"{\"alarm\":{\"alarmType\":\"OffNormalAlarm\"}}"}
|
||||
],
|
||||
"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"},
|
||||
{"EquipmentId":"EQ-oven","UnsLineId":"UL-1","Name":"Oven"}
|
||||
],
|
||||
"UnsTagReferences": [
|
||||
{"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed"},
|
||||
{"UnsTagReferenceId":"REF-2","EquipmentId":"EQ-mixer","TagId":"TAG-hot"},
|
||||
{"UnsTagReferenceId":"REF-3","EquipmentId":"EQ-oven","TagId":"TAG-hot"}
|
||||
],
|
||||
"NodeAcls": [
|
||||
{"NodeAclId":"acl-eq","ClusterId":"c1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
+3
-2
@@ -667,12 +667,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
|
||||
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
|
||||
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null,
|
||||
IReadOnlyDictionary<string, IReadOnlySet<string>>? tagReferencingEquipment = null)
|
||||
{
|
||||
Interlocked.Increment(ref _applies);
|
||||
return new DataPlaneAuthorityState().Apply(
|
||||
clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners,
|
||||
unsFolderScopes);
|
||||
unsFolderScopes, tagReferencingEquipment);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user