diff --git a/.claude/rules/data-plane-auth.md b/.claude/rules/data-plane-auth.md index 56006accb..b70ed4ca3 100644 --- a/.claude/rules/data-plane-auth.md +++ b/.claude/rules/data-plane-auth.md @@ -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: diff --git a/docs/security.md b/docs/security.md index e32ce01dd..fed4f750f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 922680aee..f45dc687e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -43,8 +43,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// // 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 { /// 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 /// MonitoredNode2 whose delivery task drains a BOUNDED channel (4096, FullMode = Wait), and /// the synchronous ReportEvent BLOCKS when that channel is full. Condition events are reported /// under Lock, and the delivery task takes Lock on every per-event verdict-cache miss (the - /// SDK's own GetNodeMetadata — flag off too — and #826's ). So + /// SDK's own GetNodeMetadata — flag off too — and, then, #826's per-event check). So /// a burst of more than 4096 events under one hold of Lock, starting at a miss, deadlocked the /// node manager (FolderEventFloodWireTests stalled at exactly 4097 events before this). /// @@ -1839,9 +1836,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// The condition's snapshot. /// /// - /// Mirrors the SDK's MonitoredNode2.ProcessEventForItemAsync — its audit guard, then the verdict through - /// (the SDK's type + SourceNode ReceiveEvents - /// check and, under enforcement, #826's live-notifier + grant check), then QueueEvent — but per event: + /// Mirrors the SDK's MonitoredNode2.ProcessEventForItemAsync — its audit guard, then the SDK's type + + /// SourceNode ReceiveEvents check (the base ValidateEventRolePermissions), then, under + /// enforcement, #826's live-notifier + grant check, then QueueEvent — 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 (). /// @@ -4710,10 +4707,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// 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 /// /// 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. /// /// The event item; its manager handle names the notifier it is attached to. - /// The event as the SDK delivers it (a condition snapshot, or a cloned audit event). + /// The event (a condition snapshot or the condition itself). /// when the item's session may receive it through that notifier. /// /// @@ -6432,24 +6425,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// as it is from the Server object. /// /// - /// 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). /// /// - /// ⚠️ Decided on the live instance, never on the event's own (review of #826). The SDK caches the - /// verdict per (item, event type, SourceNode) — 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. + /// Decided on the live instance, never on the event's own (review of #826). 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.) /// /// - /// Runs per event, uncached, from the per-item folder delivery (#855) — on the reporter's thread under - /// Lock, 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 Lock (via GetNodeMetadata); the scope - /// resolution here takes it the same way. + /// Asked per event, uncached, by ConditionRefresh on a folder item (); live + /// delivery () resolves the same three conditions once per hop. Never on + /// the SDK's channel: that verdict is the flat deny of . /// /// private bool IsNotifierEventVisibleTo(IEventMonitoredItem item, IFilterTarget e) @@ -6506,121 +6493,57 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 } /// - /// 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 — (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). /// /// The event item. /// The event. /// Good to deliver; otherwise the veto. /// /// - /// Condition events to an item attached to one of this manager's notifiers are decided here per event and per - /// item by (#855, uncached); the SDK's MonitoredNode2 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. + /// Only the SDK channel asks this. In SDK 2.0.1 the sole caller of the re-implemented + /// INodeManager3.ValidateEventRolePermissions is MonitoredNode2.GetOrAddEventPermissionAsync + /// (through AsyncNodeManagerAdapter); the base ConditionRefresh uses the non-virtual base + /// method, and this manager's own deliveries — (#855), the Server-object + /// path (#586), — 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 + /// SecurityAdmin), and any other event a NodeState.ReportEvent on one of our folders would + /// raise (none is raised). /// /// - /// ⚠️ On the SDK channel path the SDK MEMOIZES the verdict per (item, event type, SourceNode) until the monitored node's - /// permission generation moves, so 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. + /// ⚠️ The SDK MEMOIZES this verdict per (item, event type, SourceNode) 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 + /// MonitoredNode2.InvalidatePermissionCaches 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 (). Withholding them from folder items is + /// stricter than Part 3 (type + SourceNode ReceiveEvents), which it permits. + /// + /// + /// Residual, by construction unreachable today: 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 SecurityAdmin session: + /// that needs ServerConfiguration.AuditingEnabled (never set here; reachable only through an + /// OpcUa:ApplicationConfigPath XML file) AND a role the SDK's default RoleManager maps to no + /// identity, which this server neither configures nor lets a session add (the RoleSet methods themselves + /// require SecurityAdmin). The evidence and the removed reflection are recorded on Gitea #878. /// /// 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; } /// The verdict a withheld folder event carries back to the SDK (never reaches the client). private static readonly ServiceResult FolderEventDenied = new(StatusCodes.BadUserAccessDenied); - /// - /// 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 . - /// - /// Run even while enforcement is off — the authorizer setter's on/off transition, where the - /// cached verdicts were decided under the other mode. - /// - /// - /// Node-wide, session-agnostic. Each notifier's cache is cleared whole through the SDK's own private - /// MonitoredNode2.InvalidatePermissionCaches (what the SDK runs when namespace default permissions - /// change, and on Rebind): 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 InvalidatePermissionCacheForSession is NOT sufficient (review of #826): it reaches only - /// items whose Session 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 TransferSubscriptions. It also rescanned the whole cache once per item - /// (items² × conditions per folder). - /// - /// - /// The private method is resolved once by reflection against the pinned SDK and pinned by - /// FolderEventVerdictInvalidationGuardTests. 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 (Remove drops that item's entries), which can miss an event racing - /// the swap — the reason it is only the fallback. - /// - /// - /// Skipped while enforcement is off ( aside): the verdicts are then the SDK's own - /// role check, which nothing here changes. The node list is copied under Lock; the invalidation - /// takes no lock of ours. - /// - /// - private void InvalidateFolderEventVerdicts(bool force = false) - { - if (_root is null || (!force && !AclEnforced)) return; - - List 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(); - var sessionless = new List(); - 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); - } - } - } - - /// Notifier caches has invalidated since start. Test - /// observability (it must not run while enforcement is off). - internal long FolderEventVerdictInvalidations => Interlocked.Read(ref _folderEventVerdictInvalidations); - - private long _folderEventVerdictInvalidations; - - /// - /// The SDK's private node-wide cache invalidation (MonitoredNode2.InvalidatePermissionCaches), or null - /// when the SDK no longer has it — see . - /// - internal static readonly Action? 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>() - : null; - /// Whether 's session may see a condition with the given scopes. /// The Server-object event item. /// From . @@ -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; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictFreshnessTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictFreshnessTests.cs new file mode 100644 index 000000000..10a99db1d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictFreshnessTests.cs @@ -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; + +/// +/// 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 (item, event type, +/// SourceNode), 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). +/// +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; + + /// + /// 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). + /// + /// + /// 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 DeliverToNotifierItems, t2/t3 reach the EqA item. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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). + /// + /// + /// 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 DeliverToNotifierItems. The Server-object leg fails when OnConditionReportEvent skips its + /// grant check. + /// + [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); + } + + /// + /// 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, SecurityAdmin, 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. + /// + [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); + } + + /// + /// 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 INodeManager3.ValidateEventRolePermissions, which no event the + /// product raises can reach). + /// + [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(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> { [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, + }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs deleted file mode 100644 index f820e5f75..000000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/FolderEventVerdictInvalidationTests.cs +++ /dev/null @@ -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; - -/// -/// Review of Gitea #826: the SDK memoizes each folder event item's per-event verdict per -/// (item, event type, SourceNode), 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. -/// -public sealed class FolderEventVerdictInvalidationTests -{ - private const string EqA = ServerEventRig.EqA; - private const string EqB = ServerEventRig.EqB; - - /// - /// 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). - /// - [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); - } - - /// - /// 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. - /// - [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); - } - - /// - /// 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. - /// - [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); - } - - /// - /// The node-wide invalidation rests on a PRIVATE method of the pinned SDK (MonitoredNode2.InvalidatePermissionCaches). - /// 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. - /// - [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"); - } - - /// - /// 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. - /// - [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(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> { [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, - }; -}