fix(opcua): drop the reflective folder-event verdict invalidation; deny the SDK channel under enforcement (#878)
v2-ci / unit-tests (push) Waiting to run
v2-ci / unit-tests (push) Waiting to run
The SDK memoizes INodeManager3.ValidateEventRolePermissions per (item, event type, SourceNode). #826 kept it fresh by reflecting into the private MonitoredNode2.InvalidatePermissionCaches (plus a detach/re-attach fallback). Since #855 condition events never take that channel, and the search for #878 found nothing production-raised that does except a condition's SDK method audits, which reach it only with auditing on, SignAndEncrypt and a SecurityAdmin session (nothing grants that role). Under enforcement the channel verdict is now a flat deny for items on our nodes, so no cached verdict depends on a grant, and InvalidateFolderEventVerdicts, the reflection, the fallback, its counter and the guard test are removed. Method audits still reach Server-object items, decided per event. Tests: FolderEventVerdictInvalidationTests -> FolderEventVerdictFreshnessTests; new revoked-grant test (positive control: a still-granted session) and method-audit test (SecurityAdmin mapped for the test; positive controls: Server-object item, flag off); the plain-event test runs each mode on a fresh server. Mutation-checked. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -185,20 +185,22 @@ Two things to know before relying on any of this:
|
||||
`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.
|
||||
mirrors the base loop plus the same decision. Since #855 condition events at our notifiers are served per item,
|
||||
per event, UNCACHED by `DeliverToNotifierItems` (the same three checks, resolved once per hop), never through the
|
||||
SDK channel. ⚠️ **#878: the SDK channel's verdict is a flat DENY under enforcement.** The SDK memoizes whatever
|
||||
`INodeManager3.ValidateEventRolePermissions` returns per `(item, eventType, SourceNode)` until the monitored node's
|
||||
permission generation moves (item add/remove, rebind, namespace default-permission change — NOT a deploy or grant
|
||||
change); #826 kept it fresh with reflection into the SDK's private `MonitoredNode2.InvalidatePermissionCaches`
|
||||
(`InvalidateFolderEventVerdicts`, a detach/re-attach fallback, a guard test). All of it is DELETED: its only caller
|
||||
in SDK 2.0.1 is `MonitoredNode2.GetOrAddEventPermissionAsync`, and only a condition's SDK-raised method AUDITS ride
|
||||
that channel (auditing on + SignAndEncrypt + `SecurityAdmin`, the one role the SDK type check admits to audit types
|
||||
— nothing grants it) or a hand-made `folder.ReportEvent` (none in src). Denying them all keeps the cached verdict
|
||||
grant-independent. **Do not reintroduce a grant-dependent decision on that path** — it would need the invalidation
|
||||
back. Residual: a flag-OFF allow survives the switch to ON (authorizer wired just after server start). Mutation
|
||||
record: with the invalidation disabled only its own counter test and a flag-off→on plain-event test failed (347
|
||||
integration tests). Tests: `FolderEventVerdictFreshnessTests`. ⚠️ The verdict is decided on the LIVE instance
|
||||
holding the event's condition NodeId, never the event's own instance (a replaced instance's late event is treated as
|
||||
the live one's).
|
||||
`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:
|
||||
|
||||
+30
-13
@@ -823,13 +823,28 @@ Behaviours worth knowing before enabling it:
|
||||
|
||||
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
|
||||
same way. Condition events are decided **per event, per item, uncached** by the node manager's own delivery
|
||||
(`DeliverToNotifierItems`, #855), so a deploy, a revoked grant or a dropped reference decides the very next event —
|
||||
for orphaned, session-less subscriptions too (an orphaned subscription must not queue a moved alarm's events for a
|
||||
later `TransferSubscriptions`). A verdict is decided on the condition currently live under the event's NodeId, so a
|
||||
replaced instance's late event is treated exactly as the live one's.
|
||||
|
||||
**No SDK verdict cache to invalidate (Gitea #878).** Whatever still reaches one of our folders through the SDK's
|
||||
own `MonitoredNode2` channel is judged by `INodeManager3.ValidateEventRolePermissions` (re-implemented — the base
|
||||
method is not virtual), and the SDK **memoizes** that verdict per `(item, event type, SourceNode)` until the
|
||||
monitored node's permission generation moves — not when a deployment or grant changes. #826 kept it fresh by calling
|
||||
the SDK's *private* `MonitoredNode2.InvalidatePermissionCaches` by reflection (with a detach/re-attach fallback).
|
||||
#878 removed that: under enforcement every event on that channel is **withheld** from items on our nodes, so the
|
||||
cached verdict is a constant deny that no grant can make stale. What rides the channel (SDK 2.0.1, decompiled): only a
|
||||
condition's SDK-raised Part 9 method audit events — and those reach it only with auditing on
|
||||
(`ServerConfiguration.AuditingEnabled`, never set here), over SignAndEncrypt, to a session holding `SecurityAdmin`,
|
||||
the one well-known role the SDK's type check admits to audit types, which the SDK's default `RoleManager` maps to no
|
||||
identity and this server never grants — plus any event a `NodeState.ReportEvent` on one of our folders would raise
|
||||
(none is raised). Condition events never take the channel (#855), and the SDK's own condition reports stay off
|
||||
(`AutoReportStateChanges = false`). A condition's method audits still reach **Server-object** items, decided per
|
||||
event. Residual: a verdict cached while enforcement was **off** survives the switch to on; the switch happens once,
|
||||
when the host wires the authorizer just after the server starts listening, and the only allow it could carry is a
|
||||
method audit to a `SecurityAdmin` session. 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.
|
||||
@@ -846,12 +861,13 @@ Behaviours worth knowing before enabling it:
|
||||
folder it legitimately notifies through for the session), which in this ACL model is the deliberate
|
||||
generalisation of "the source": a `Tag` grant covers a native alarm's raw node and every UNS projection of it, and
|
||||
an `Equipment` grant covers the alarms it owns or references.
|
||||
- **A condition's Part 9 method audit events** (Acknowledge, Comment, Shelve…) — SourceNode is the condition, so they
|
||||
are resolved to it and follow exactly the condition rule (plus the SDK's audit/SignAndEncrypt guard on the
|
||||
Server-object path).
|
||||
- **A condition's Part 9 method audit events** (Acknowledge, Comment, Shelve…) — SourceNode is the condition. At the
|
||||
Server object they are resolved to it and follow exactly the condition rule (plus the SDK's audit/SignAndEncrypt
|
||||
guard). At a folder item they travel the SDK's channel and are **withheld** under enforcement (Gitea #878, see
|
||||
above) — stricter than Part 3, which it permits; only a `SecurityAdmin` session could receive them at all.
|
||||
- **Any other event on a folder item** (none is produced today; `GeneralModelChangeEvent` and the rejected-write
|
||||
`AuditWriteUpdateEvent` go to the Server object, #588) names no live condition and is **withheld** under
|
||||
enforcement — fail closed, which is stricter than the folder's permission and never laxer than Part 3.
|
||||
`AuditWriteUpdateEvent` go to the Server object, #588) is **withheld** under enforcement — fail closed, which is
|
||||
stricter than the folder's permission and never laxer than Part 3.
|
||||
With the flag off only the SDK's own type + SourceNode `ReceiveEvents` check applies, as before.
|
||||
|
||||
**Event bursts never block under the node-manager lock (Gitea #838).** Each notifier with event items has an SDK
|
||||
@@ -925,7 +941,8 @@ Behaviours worth knowing before enabling it:
|
||||
type/source `ReceiveEvents` + the #826 decision under enforcement — its item-independent part, the live condition,
|
||||
the notifier check and the visibility scopes, resolved once per hop) **per event, uncached**, then the item's gate and
|
||||
cut, then `QueueEvent`. The `MonitoredNode2` stays for item attachment and for what still uses its channel (a
|
||||
condition's SDK method audit events, data-change items on the folder), so `InvalidateFolderEventVerdicts` stays.
|
||||
condition's SDK method audit events, data-change items on the folder). (#878 later withheld everything on that
|
||||
channel from our folder items under enforcement and removed `InvalidateFolderEventVerdicts`, see above.)
|
||||
- **No notifier of ours defers any more.** `QueueEvent` never waits for a consumer (the item queue discards oldest at
|
||||
`QueueSize`) and the verdict runs on the reporter's thread, which already holds `Lock` — so a burst under one
|
||||
`Lock` hold completes with nothing deferred, and the #838 deadlock shape is gone for condition events. Folder items
|
||||
|
||||
@@ -43,8 +43,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// </summary>
|
||||
// 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.
|
||||
// re-implementing the interface is the only way to put an ACL decision on that path. Since #855 condition events no
|
||||
// longer take it, and since #878 the decision there is a flat deny under enforcement (the SDK memoizes it, so it must
|
||||
// not depend on grants). 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
|
||||
@@ -632,7 +633,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
// walking its own dead item map on each deploy.
|
||||
previous.Changed -= OnAuthorityChanged;
|
||||
next.Changed += OnAuthorityChanged;
|
||||
InvalidateFolderEventVerdicts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,10 +713,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1513,7 +1510,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// <c>MonitoredNode2</c> whose delivery task drains a BOUNDED channel (4096, <c>FullMode = Wait</c>), and
|
||||
/// the synchronous <c>ReportEvent</c> BLOCKS when that channel is full. Condition events are reported
|
||||
/// under <c>Lock</c>, and the delivery task takes <c>Lock</c> on every per-event verdict-cache miss (the
|
||||
/// SDK's own <c>GetNodeMetadata</c> — flag off too — and #826's <see cref="IsNotifierEventVisibleTo"/>). So
|
||||
/// SDK's own <c>GetNodeMetadata</c> — flag off too — and, then, #826's per-event check). So
|
||||
/// a burst of more than 4096 events under one hold of <c>Lock</c>, starting at a miss, deadlocked the
|
||||
/// node manager (<c>FolderEventFloodWireTests</c> stalled at exactly 4097 events before this).
|
||||
/// </para>
|
||||
@@ -1839,9 +1836,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// <param name="e">The condition's snapshot.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Mirrors the SDK's <c>MonitoredNode2.ProcessEventForItemAsync</c> — its audit guard, then the verdict through
|
||||
/// <see cref="INodeManager3.ValidateEventRolePermissions"/> (the SDK's type + SourceNode <c>ReceiveEvents</c>
|
||||
/// check and, under enforcement, #826's live-notifier + grant check), then <c>QueueEvent</c> — but per event:
|
||||
/// Mirrors the SDK's <c>MonitoredNode2.ProcessEventForItemAsync</c> — its audit guard, then the SDK's type +
|
||||
/// SourceNode <c>ReceiveEvents</c> check (the base <c>ValidateEventRolePermissions</c>), then, under
|
||||
/// enforcement, #826's live-notifier + grant check, then <c>QueueEvent</c> — but per event:
|
||||
/// nothing is memoised, so a deploy, revocation or reference removal decides the very next event. The item's
|
||||
/// gate and refresh cut apply exactly as on the Server object (<see cref="QueueConditionEventToItem"/>).
|
||||
/// </para>
|
||||
@@ -4710,10 +4707,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// <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)
|
||||
{
|
||||
@@ -6417,10 +6410,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
/// <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).
|
||||
/// device folder) — the folder twin of the Server-object filtering (#586), 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>
|
||||
/// <param name="e">The event (a condition snapshot or the condition itself).</param>
|
||||
/// <returns><see langword="true"/> when the item's session may receive it through that notifier.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -6432,24 +6425,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// 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.
|
||||
/// An event that names no live condition of this manager is withheld (fail closed).
|
||||
/// </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.
|
||||
/// <b>Decided on the live instance, never on the event's own (review of #826).</b> A late event from a
|
||||
/// REPLACED instance (a surgical remove + re-materialise under the same NodeId) is delivered exactly when the
|
||||
/// live one's would be. (When the SDK still cached this verdict, denying the replaced instance's event left a
|
||||
/// deny the new instance inherited; nothing caches it now — see #855 and #878.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Runs per event, uncached, from the per-item folder delivery (#855) — on the reporter's thread under
|
||||
/// <c>Lock</c>, or off it for a deferred event (before the item gate is taken) — and, for the events that still
|
||||
/// travel the SDK channel (a condition's method audits), 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.
|
||||
/// Asked per event, uncached, by ConditionRefresh on a folder item (<see cref="RefreshDirectItems"/>); live
|
||||
/// delivery (<see cref="DeliverToNotifierItems"/>) resolves the same three conditions once per hop. Never on
|
||||
/// the SDK's channel: that verdict is the flat deny of <see cref="INodeManager3.ValidateEventRolePermissions"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool IsNotifierEventVisibleTo(IEventMonitoredItem item, IFilterTarget e)
|
||||
@@ -6506,121 +6493,57 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// Validates whether an event item may receive an event through the SDK's own per-notifier channel: the SDK's
|
||||
/// role-permission check, and — with enforcement on, for an item on one of this manager's own nodes — a flat
|
||||
/// deny (Gitea #826, #878).
|
||||
/// </summary>
|
||||
/// <param name="monitoredItem">The event item.</param>
|
||||
/// <param name="filterTarget">The event.</param>
|
||||
/// <returns>Good to deliver; otherwise the veto.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Condition events to an item attached to one of this manager's notifiers are decided here per event and per
|
||||
/// item by <see cref="DeliverToNotifierItems"/> (#855, uncached); the SDK's <c>MonitoredNode2</c> still asks it
|
||||
/// for what travels its channel (a condition's method audit events). 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.
|
||||
/// <b>Only the SDK channel asks this.</b> In SDK 2.0.1 the sole caller of the re-implemented
|
||||
/// <c>INodeManager3.ValidateEventRolePermissions</c> is <c>MonitoredNode2.GetOrAddEventPermissionAsync</c>
|
||||
/// (through <c>AsyncNodeManagerAdapter</c>); the base <c>ConditionRefresh</c> uses the non-virtual base
|
||||
/// method, and this manager's own deliveries — <see cref="DeliverToNotifierItems"/> (#855), the Server-object
|
||||
/// path (#586), <see cref="RefreshDirectItems"/> — decide per event themselves, uncached. Condition events at
|
||||
/// our notifiers never travel the channel (#855). What still can: a condition's SDK-raised Part 9 method
|
||||
/// audit events (with auditing on, over SignAndEncrypt, to a session the SDK's type check admits — only
|
||||
/// <c>SecurityAdmin</c>), and any other event a <c>NodeState.ReportEvent</c> on one of our folders would
|
||||
/// raise (none is raised).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ On the SDK channel path 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.
|
||||
/// ⚠️ <b>The SDK MEMOIZES this verdict</b> per <c>(item, event type, SourceNode)</c> until the monitored
|
||||
/// node's permission generation moves (an item is added or removed, a rebind, a namespace default-permission
|
||||
/// change) — not when a deployment, a grant or an alarm's notifier wiring changes. A grant-dependent verdict
|
||||
/// here would therefore go stale, and keeping it fresh needed the SDK's PRIVATE
|
||||
/// <c>MonitoredNode2.InvalidatePermissionCaches</c> by reflection (#826). So under enforcement the verdict is
|
||||
/// a constant — every such event is withheld from items on our nodes — and nothing has to be invalidated:
|
||||
/// a cached deny can never hide an event a later grant would admit, because no grant admits one, and no
|
||||
/// allow is ever cached to survive a revoked grant. A condition's method audits still reach Server-object
|
||||
/// items, decided per event (<see cref="OnConditionReportEvent"/>). Withholding them from folder items is
|
||||
/// stricter than Part 3 (type + SourceNode <c>ReceiveEvents</c>), which it permits.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Residual, by construction unreachable today:</b> a verdict the SDK cached while enforcement was OFF
|
||||
/// survives the switch to ON. The switch happens once, when the host wires the authorizer just after the
|
||||
/// server starts listening, and the only cacheable allow is a method audit to a <c>SecurityAdmin</c> session:
|
||||
/// that needs <c>ServerConfiguration.AuditingEnabled</c> (never set here; reachable only through an
|
||||
/// <c>OpcUa:ApplicationConfigPath</c> XML file) AND a role the SDK's default <c>RoleManager</c> maps to no
|
||||
/// identity, which this server neither configures nor lets a session add (the RoleSet methods themselves
|
||||
/// require <c>SecurityAdmin</c>). The evidence and the removed reflection are recorded on Gitea #878.
|
||||
/// </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;
|
||||
return 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>
|
||||
@@ -7311,10 +7234,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
public void RevalidateMonitoredItems()
|
||||
{
|
||||
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;
|
||||
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
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>
|
||||
/// Folder event items must see a deployment, a grant removal or a moved alarm on the very next event (Gitea #826).
|
||||
/// The SDK memoizes the verdict of whatever travels its own per-notifier channel per <c>(item, event type,
|
||||
/// SourceNode)</c>, and #826 kept those verdicts fresh with a reflective call into a private SDK method. Gitea #878
|
||||
/// removed that: condition events are decided per item, uncached, by the node manager's own delivery (#855), and
|
||||
/// under enforcement everything that still rides the channel is denied outright, so no cached verdict depends on a
|
||||
/// grant. These pin the properties that made the invalidation exist — an ORPHANED subscription, a replaced
|
||||
/// instance's late event, a revoked grant — plus the channel itself (a condition's method audit, a plain event).
|
||||
/// </summary>
|
||||
public sealed class FolderEventVerdictFreshnessTests
|
||||
{
|
||||
private const NodePermissions All =
|
||||
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
|
||||
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
|
||||
| NodePermissions.MethodCall;
|
||||
|
||||
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>
|
||||
/// <remarks>
|
||||
/// Since #855 this holds because the decision is per event and uncached, not because a cache is invalidated
|
||||
/// (#878: it passed with the invalidation disabled). It is the per-item decision this pins: with the
|
||||
/// live-notifier check skipped in <c>DeliverToNotifierItems</c>, <c>t2</c>/<c>t3</c> reach the EqA item.
|
||||
/// </remarks>
|
||||
[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();
|
||||
|
||||
// An ALLOW for the EqA item (before #855 the SDK cached it).
|
||||
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"));
|
||||
|
||||
// Before Gitea #855, holding the node-manager lock parked EqA's SDK delivery task, so the OLD scripted instance's
|
||||
// "s2" was decided (and its verdict cached) only after the replace and the apply's invalidation. Condition events
|
||||
// are now decided per item at report time, uncached (#878 removed the invalidation) — "s2" is delivered while its
|
||||
// instance is live — and this pins that the replace still leaves the new instance's events flowing either way.
|
||||
lock (rig.Nm.Lock)
|
||||
{
|
||||
rig.Nm.CompleteAddressSpaceApply();
|
||||
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>
|
||||
/// The real security property behind the removed invalidation (Gitea #878): a deployment that revokes a session's
|
||||
/// grant stops every condition event to it on the very next event — at its folder item AND its Server-object item —
|
||||
/// while a session whose grant stays keeps receiving the same events (positive control).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mutation-checked on #878. The folder leg has two defences, and fails only when both are removed: the
|
||||
/// revalidation stand-down (#562, the item loses its folder grant and is detached) and the per-event grant check
|
||||
/// in <c>DeliverToNotifierItems</c>. The Server-object leg fails when <c>OnConditionReportEvent</c> skips its
|
||||
/// grant check.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_revoked_grant_stops_condition_events_to_that_session_while_a_granted_session_keeps_them()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Folder878Revoke", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
rig.MaterialiseNative();
|
||||
var anonymousGrant = ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly);
|
||||
var state = rig.Enforce(true, [ServerEventRig.Key()], KeyEquipmentGrant(EqA), anonymousGrant);
|
||||
|
||||
using var revoked = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var revokedEvents = new RecordedEvents();
|
||||
var revokedSubscription = await RecordedEvents.SubscribeAsync(revoked, revokedEvents, ct);
|
||||
var revokedFolder = RecordedEvents.AddEventItem(revokedSubscription, ServerEventRig.UnsNodeId(revoked, EqA));
|
||||
var revokedServer = RecordedEvents.AddEventItem(revokedSubscription, ObjectIds.Server);
|
||||
await revokedSubscription.ApplyChangesAsync(ct);
|
||||
revokedFolder.Status.Error.ShouldBeNull();
|
||||
|
||||
using var kept = await rig.ConnectAsync(anonymous: true, ct);
|
||||
var keptEvents = new RecordedEvents();
|
||||
var keptSubscription = await RecordedEvents.SubscribeAsync(kept, keptEvents, ct);
|
||||
var keptFolder = RecordedEvents.AddEventItem(keptSubscription, ServerEventRig.UnsNodeId(kept, EqA));
|
||||
var keptServer = RecordedEvents.AddEventItem(keptSubscription, ObjectIds.Server);
|
||||
await keptSubscription.ApplyChangesAsync(ct);
|
||||
keptFolder.Status.Error.ShouldBeNull();
|
||||
|
||||
rig.Project(native: true, State("t1"));
|
||||
await RecordedEvents.WaitUntilAsync(() =>
|
||||
revokedEvents.Has(revokedFolder, "t1") && revokedEvents.Has(revokedServer, "t1")
|
||||
&& keptEvents.Has(keptFolder, "t1") && keptEvents.Has(keptServer, "t1"));
|
||||
|
||||
// The key's grant is gone; the anonymous one stays, and both folders still reference the alarm.
|
||||
Deploy(state, "rev-2", [anonymousGrant], EqA, EqB);
|
||||
rig.Project(native: true, State("t2"));
|
||||
|
||||
await RecordedEvents.WaitUntilAsync(() => keptEvents.Has(keptFolder, "t2") && keptEvents.Has(keptServer, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
revokedEvents.Has(revokedFolder, "t2").ShouldBeFalse("a revoked grant must stop the folder item's next event");
|
||||
revokedEvents.Has(revokedServer, "t2").ShouldBeFalse("a revoked grant must stop the Server-object item's next event");
|
||||
|
||||
await kept.CloseAsync(ct);
|
||||
await revoked.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What still rides the SDK's own per-notifier channel — and so its MEMOIZED verdict — is a condition's SDK-raised
|
||||
/// Part 9 method audit (Gitea #878). It needs auditing on, SignAndEncrypt, and the one role the SDK's type check
|
||||
/// admits to audit types, <c>SecurityAdmin</c>, which this test maps to every authenticated session (nothing in
|
||||
/// the product does). With enforcement ON the folder item never receives it — the channel's verdict is a flat
|
||||
/// deny, so no grant-dependent verdict is cached and nothing needs invalidating. Positive controls: the same
|
||||
/// audit reaches the same session's Server-object item (decided per event there), and with enforcement OFF it
|
||||
/// reaches the folder item, so the channel really does carry it.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task A_condition_method_audit_reaches_a_folder_item_only_with_enforcement_off(bool enforced)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync($"Folder878Audit{enforced}", encrypted: true, ct);
|
||||
rig.SetAuditing(true);
|
||||
ServiceResult.IsGood(rig.Server.CurrentInstance.RoleManager.AddIdentity(
|
||||
ObjectIds.WellKnownRole_SecurityAdmin,
|
||||
new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.AuthenticatedUser }))
|
||||
.ShouldBeTrue();
|
||||
rig.Enforce(enforced, [ServerEventRig.Key()], ServerEventRig.ClusterGrant(ServerEventRig.KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseScripted();
|
||||
var current = State("base");
|
||||
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct, encrypted: true);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var folderItem = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
|
||||
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
folderItem.Status.Error.ShouldBeNull();
|
||||
|
||||
rig.Project(native: false, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "base") && events.Has(serverItem, "base"));
|
||||
|
||||
await ConditionMethodEventTests.CallAsync(
|
||||
session, ServerEventRig.ConditionNodeId(session, native: false), "AddComment",
|
||||
events.LatestEventId(folderItem, "base"), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
await RecordedEvents.WaitUntilAsync(() => events.HasType(serverItem, ObjectTypeIds.AuditConditionCommentEventType));
|
||||
|
||||
if (!enforced)
|
||||
{
|
||||
await RecordedEvents.WaitUntilAsync(() => events.HasType(folderItem, ObjectTypeIds.AuditConditionCommentEventType));
|
||||
}
|
||||
else
|
||||
{
|
||||
current = current with { Message = "sentinel" };
|
||||
rig.Project(native: false, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
events.HasType(folderItem, ObjectTypeIds.AuditConditionCommentEventType).ShouldBeFalse(
|
||||
"under enforcement nothing on the SDK channel reaches a folder item of ours");
|
||||
}
|
||||
|
||||
await session.CloseAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #838 item 3: an event on a folder that names no live condition (none is produced today — model-change and
|
||||
/// rejected-write audits go to the Server object) rides the SDK channel and is WITHHELD under enforcement, fail
|
||||
/// closed, and delivered with the flag off (the SDK's own type + SourceNode check only). Positive controls: the
|
||||
/// same event reaches the same item shape with enforcement off, and a condition event reaches it with enforcement
|
||||
/// on. Each mode runs on a fresh server: a verdict the SDK cached while enforcement was off would survive a switch
|
||||
/// on (#878 — the residual documented on <c>INodeManager3.ValidateEventRolePermissions</c>, which no event the
|
||||
/// product raises can reach).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task A_non_condition_event_on_a_folder_is_withheld_under_enforcement_and_delivered_without_it(bool enforced)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync($"Folder838NonCondition{enforced}", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
rig.MaterialiseNative();
|
||||
rig.Enforce(enforced, [], 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();
|
||||
|
||||
var folder = rig.Nm.FindPredefinedNode<FolderState>(ServerEventRig.UnsNodeId(session, EqA)).ShouldNotBeNull();
|
||||
var plain = new BaseEventState(null);
|
||||
lock (rig.Nm.Lock)
|
||||
{
|
||||
plain.Initialize(rig.Nm.SystemContext, folder, EventSeverity.Low, new LocalizedText("plain"));
|
||||
folder.ReportEvent(rig.Nm.SystemContext, plain);
|
||||
}
|
||||
|
||||
rig.Project(native: true, State("cond"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "cond"));
|
||||
if (enforced)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
events.Has(onA, "plain").ShouldBeFalse("a non-condition event on a folder fails closed under enforcement");
|
||||
}
|
||||
else
|
||||
{
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "plain"));
|
||||
}
|
||||
|
||||
await session.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,
|
||||
};
|
||||
}
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
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"));
|
||||
|
||||
// Before Gitea #855, holding the node-manager lock parked EqA's SDK delivery task, so the OLD scripted instance's
|
||||
// "s2" was decided (and its verdict cached) only after the replace and the apply's invalidation. Condition events
|
||||
// are now decided per item at report time, uncached — "s2" is delivered while its instance is live — and this
|
||||
// pins that the replace still leaves the new instance's events flowing either way.
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #838 item 3: an event on a folder that names no live condition (none is produced today — model-change and
|
||||
/// rejected-write audits go to the Server object) is WITHHELD under enforcement, fail closed, and delivered with
|
||||
/// the flag off (the SDK's own type + SourceNode check only). Positive controls: the same event reaches the same
|
||||
/// item with enforcement off, and a condition event reaches it with enforcement on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_non_condition_event_on_a_folder_is_withheld_under_enforcement_and_delivered_without_it()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("Folder838NonCondition", encrypted: false, ct);
|
||||
rig.KeyRoles = [];
|
||||
rig.MaterialiseNative();
|
||||
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);
|
||||
var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(session, EqA));
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
onA.Status.Error.ShouldBeNull();
|
||||
|
||||
var folder = rig.Nm.FindPredefinedNode<FolderState>(ServerEventRig.UnsNodeId(session, EqA)).ShouldNotBeNull();
|
||||
void ReportPlainEvent(string message)
|
||||
{
|
||||
var e = new BaseEventState(null);
|
||||
lock (rig.Nm.Lock)
|
||||
{
|
||||
e.Initialize(rig.Nm.SystemContext, folder, EventSeverity.Low, new LocalizedText(message));
|
||||
folder.ReportEvent(rig.Nm.SystemContext, e);
|
||||
}
|
||||
}
|
||||
|
||||
ReportPlainEvent("plain-off");
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "plain-off"));
|
||||
|
||||
rig.Enforce(true, [], ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly));
|
||||
rig.Project(native: true, State("cond-on"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "cond-on"));
|
||||
|
||||
ReportPlainEvent("plain-on");
|
||||
rig.Project(native: true, State("cond-on-2"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(onA, "cond-on-2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
events.Has(onA, "plain-on").ShouldBeFalse("a non-condition event on a folder fails closed under enforcement");
|
||||
|
||||
await session.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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user