diff --git a/CLAUDE.md b/CLAUDE.md index d9bb3c4a..5d271da7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -925,9 +925,24 @@ Two things to know before relying on any of this: re-attached with `unsubscribe: false` on restore — again never `Disabled`. Unlike a data item it is **not told** (no value slot for a status; the feed goes quiet), and a client delete of a stood-down item is absorbed by the override (the SDK would otherwise remove an item that is not attached). - ⚠️ **Server-object (`i=2253`) event subscriptions are outside the ACL entirely** — fanned out by - `SubscribeToAllEvents` + the core node manager's Server notifier, which our creation gate never sees; - live-observed: a grantless anonymous session receives every alarm through one. Open residual. + **Server-object (`i=2253`) event subscriptions are filtered per event at delivery (Gitea #586).** The + `SubscribeToAllEvents` override attaches them to **nothing** (just records them in `_allEventsItems`); + each condition's own `OnReportEvent` (`OnConditionReportEvent`, fires once per event ⇒ still one + Server-object copy) queues the event to each recorded item whose session holds `Subscribe` on the + condition (RawPath) **or** any notifier folder it reports through — the per-folder path's question. The + root-notifier forward to the core Server object (`OnReportEvent` override) is **dropped**: it was the + ungated path, and it is the only one that served an item created before the alarm was deployed (the + SDK's `AddRootNotifier` subscribes existing all-events items with `unsubscribe: true`). Revalidation is + **implicit** — every event re-asks the authorizer, so a deploy or expiry lands on the next event with no + pass. `ConditionRefresh` on an `i=2253` item is filtered the same way. The dispatcher **must** keep + the SDK's audit guard (`AuditEventState` only while `Server.Auditing` over SignAndEncrypt/https), + because conditions report their Part 9 method audit events through themselves. + `ReconcileAllEventsMonitoring` must keep `AreEventsMonitored` raised on every condition while any + `i=2253` item exists, because `ConditionState` gates its own state-change and audit reports on + `EventsMonitored()`. The protected `SubscribeToEvents` override that no-ops all-events items is what + stops the SDK's `AddRootNotifier` (`unsubscribe: true` bug) taking that unit back. Both review + findings (review of `75e559f6`). ⚠️ Residual: audit/model-change events the manager reports straight + to `Server.ReportEvent` still go to the core Server object, ungated. - **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`, preserving today's behaviour) controls *admission*; when admitted the session authenticates as the reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index ce776a35..281551cb 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -36,9 +36,12 @@ same condition is wired as an **event notifier of every referencing equipment fo `EnsureFolderIsEventNotifier(equipFolder)`. - A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder, - every referencing equipment folder, and up to the Server object. A subscriber at any one + every referencing equipment folder, and to the Server object. A subscriber at any one root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot` dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root. + The Server-object (`i=2253`) copy is delivered by the condition itself, not by the folders' + forward to the core Server object, so it can be filtered per subscriber by the data-plane ACL + (Gitea #586 — see `docs/security.md`). Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break Server-object dedup + Part 9 ack correlation). - **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls diff --git a/docs/security.md b/docs/security.md index 56abd2ab..ae72af8f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -531,16 +531,51 @@ Behaviours worth knowing before enabling it: - **A client may delete a stood-down item** normally; the delete is absorbed (the item is already off the fan-out) and a later restoring deployment does not resurrect it. - ⚠️ **Server-object (all-events) event subscriptions are outside the ACL entirely — at creation and at - revalidation.** A subscription to `i=2253` (the Server object) is fanned out by the SDK through - `SubscribeToAllEvents` and through the Server object's own notifier, which the core node manager owns — - our node manager's creation gate never sees it, and an equipment folder with no direct subscriber - forwards its events to the Server object regardless of what our node manager attaches. So with the flag - on, a session subscribed to the Server object still receives every alarm in the address space (observed - over the wire 2026-10-07: an anonymous session holding no grant at all received a native alarm through a - Server-object event item). Scope - alarm consumers by subscribing to the equipment/device folders, and treat Server-object event access as - ungated until this is closed. + **Server-object (`i=2253`, all-events) event subscriptions are filtered per event at delivery** (Gitea + #586). They used to be outside the ACL entirely: the SDK attached such an item to the fan-out of every + one of our root notifiers through `SubscribeToAllEvents`, and an equipment folder with no direct + subscriber forwarded its events to the core node manager's Server object, which our node manager never + sees — so a session holding no grant received every alarm in the server. Now: + - The node manager attaches a Server-object item to **nothing**. It records it, and each condition hands + its events to the recorded items itself (the condition's own `OnReportEvent`, which fires exactly once + per event, so the Server object still sees one copy). The root-notifier forward to the core Server + object is dropped. + - Each event is delivered to an item only if the item's session holds `Subscribe` on **any** of: the + condition itself (`ConditionId` = RawPath, raw realm — so a grant on the alarm's raw tag admits it), a + root-notifier folder the condition bubbles through (its raw device folder; a scripted alarm's + equipment folder), or an equipment folder wired as its extra notifier. That is the per-folder path's + question — a session that may subscribe to any notifier the alarm reports through already receives it + there — asked with the same authorizer and the same operation as the per-folder creation gate. The + folders must be asked as well as the condition because the trie inherits downward only within a realm: + an `Equipment` grant covers the UNS folder, not the condition at its RawPath. + - **Revalidation is implicit, not trigger-driven.** Every event re-asks the authorizer, whose decision + cache is keyed per generation and which re-checks key revocation and expiry on every call — so a + deployment takes effect on the next event, and an expired key's feed stops on the next event, without + waiting for the `AclRevalidationTimer` tick. Nothing is detached, so restoring a grant needs no + re-attach: the next event simply passes. Events raised while withheld are not replayed, and the client + is not told (the same reasoning as a stood-down direct item). + - Creation is **not** refused — an all-events item is legitimate for a partially-granted session; what + is withheld is each event it may not see. + - **`ConditionRefresh` on a Server-object item** replays only the conditions the session passes the same + check on (once per condition, not once per notifier it is wired to). Without that, a refresh would + replay every condition's current state to any session. + - Inert with the flag off: every Server-object item receives every condition, as before. + - The replacement delivery keeps the two behaviours of the SDK path it replaced. **Audit events** a + condition reports through itself (`AuditConditionAcknowledgeEventType`, Comment, Enable, Shelving) + reach a Server-object item only while the server is auditing, over SignAndEncrypt or https-binary, + which is exactly the SDK's own guard. And while any Server-object item exists, every condition counts as + **monitored** (`ConditionState.EventsMonitored()`), so the SDK still reports its method-driven state + changes on a server whose only subscribers are Server-object items. Both were caught in review: the + first cut queued `i=8944` to every Server-object item with auditing off, and left conditions + unmonitored. The second also needed the SDK's `AddRootNotifier` neutralised: its "subscribe existing + all-events items with `unsubscribe: true`" bug would otherwise take the unit back from every + condition rebuilt under a live item. + + Residual, not closed by #586: **audit and model-change events** (`AuditWriteUpdateEvent`, + `GeneralModelChangeEvent`, …) are still reported straight to the Server object and reach every + Server-object item ungated. They are delivered only when auditing / model-change reporting is on (and + audit events only over an encrypted channel), and a node manager has no seam on the core Server + object's fan-out to filter them. - **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note what this necessarily discloses — the *names* of the folders on the path to a granted node are visible diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index c09d4061..423c5629 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -349,6 +349,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 Volatile.Read(ref _dataPlaneAuthority).Changed -= OnAuthorityChanged; _liveItems.Clear(); _eventItems.Clear(); + _allEventsItems.Clear(); + lock (Lock) _allEventsMonitoredConditions.Clear(); } base.Dispose(disposing); @@ -999,9 +1001,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 try { // InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire - // time. ReportEvent walks inverse notifier references up to the root-notifier folder (promoted - // in MaterialiseAlarmCondition), whose OnReportEvent hands off to Server.ReportEvent → the - // event reaches subscribed monitored items. + // time. ReportEvent walks inverse notifier references up to the root-notifier folders (promoted + // in MaterialiseAlarmCondition), whose fan-outs deliver it to direct notifier subscriptions; the + // condition's own OnReportEvent hands the same snapshot to the Server-object (all-events) + // subscriptions, per item, through the ACL (Gitea #586 — see OnConditionReportEvent). var snapshot = new InstanceStateSnapshot(); snapshot.Initialize(SystemContext, alarm); alarm.ReportEvent(SystemContext, snapshot); @@ -1345,15 +1348,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null); }; + // Gitea #586: the condition itself delivers its events to Server-object (all-events) + // subscriptions, item by item through the ACL. A condition is never an event notifier, so no + // MonitoredNode2 ever claims this single-slot delegate — every event the condition reports, ours + // or the SDK's own method-driven state changes, passes through it exactly once. + alarm.OnReportEvent = OnConditionReportEvent; + parent.AddChild(alarm); // Promote the equipment folder to an event notifier + register it as a root notifier so - // T16's ReportEvent has a notifier path up to the Server object. Guard so repeated + // T16's ReportEvent reaches the folder's subscribers. Guard so repeated // materialise under the same folder doesn't double-add the root notifier. EnsureFolderIsEventNotifier(parent); AddPredefinedNode(SystemContext, alarm); _alarmConditions[conditionKey] = alarm; + ReconcileAllEventsMonitoring(); // H6a: record native (driver-fed) conditions so a later task can route their inbound // Acknowledge to the driver rather than the scripted engine. if (isNative) _nativeAlarmNodeIds.Add(conditionKey); @@ -2070,7 +2080,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Promote to and /// register it as a root notifier (idempotent — guarded by ) so the - /// alarm condition has a notifier path to the Server object for T16's event propagation. + /// folder is a subscribable event source linked under the Server object (Server-object delivery itself is + /// the condition's own — see OnConditionReportEvent, Gitea #586). /// /// Phase C: when a real historian is wired at promotion time (the source is NOT the /// ), the folder ALSO gets the @@ -2734,6 +2745,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Phase C: drop the event-history source registrations alongside the notifier folders // they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion. _eventNotifierSources.Clear(); + ReconcileAllEventsMonitoring(); } } @@ -2823,6 +2835,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 condition.Parent?.RemoveChild(condition); PredefinedNodes?.Remove(condition.NodeId); _nativeAlarmNodeIds.Remove(key); + ReconcileAllEventsMonitoring(); e = BuildNodesRemovedModelChange(alarmNodeId, realm); } ReportModelChangeOutsideLock(e, alarmNodeId); @@ -2872,6 +2885,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } _nativeAlarmNodeIds.Remove(id); } + ReconcileAllEventsMonitoring(); // v3 Batch 4 WP4: a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this // UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra @@ -3376,14 +3390,378 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// cannot race a client deleting it. /// /// - /// ⚠️ Server-object (all-events) subscriptions are not in this map, because they are not - /// authorized by this manager in the first place: the SDK fans them out through - /// SubscribeToAllEvents and through the Server object's own notifier, which the core node - /// manager owns. See docs/security.md § Data-Plane Authorization. + /// Server-object (all-events) subscriptions are not in this map. They are not attached to + /// any notifier of ours at all; they are held in and decided per + /// event at delivery (Gitea #586), so there is nothing for this map's stand-down to detach. /// /// private readonly ConcurrentDictionary _eventItems = new(); + /// + /// Live Server-object (i=2253) event monitored items — every item the SDK handed this manager + /// through (Gitea #586). + /// + /// + /// A set (the value is unused). Maintained by 's subscribe and + /// unsubscribe arms, which the SDK's MasterNodeManager drives for every create, modify, + /// transfer, restore and delete of an all-events item, subscription teardown included. + /// + private readonly ConcurrentDictionary _allEventsItems = new(); + + /// + /// + /// + /// Gitea #586. The SDK's base implementation attaches a Server-object item to the event + /// fan-out of every one of this manager's root notifiers, so every alarm in the server reached it + /// with no ACL decision at all — and, for an item created before a root notifier existed (the + /// SDK's AddRootNotifier calls its subscribe with unsubscribe: true), through the + /// root notifier's forward to Server.ReportEvent and the core node manager's Server object, + /// which this manager never sees. The Server object is where every condition lands exactly once, + /// so it was the one feed carrying every alarm, ungated. + /// + /// + /// This override attaches the item to nothing. It records it, and each condition hands its events + /// to the recorded items itself (), deciding every item against + /// the authorizer at delivery time. With no attachment there is no fan-out to keep in step with a + /// rebuild, and no stand-down to reverse: revalidation is implicit, because every event re-asks. + /// + /// + /// Creation is never refused: an all-events item is legitimate for a session granted part of the + /// address space, so what is withheld is each event the session may not see, not the item. + /// + /// + public override ServiceResult SubscribeToAllEvents( + OperationContext context, uint subscriptionId, IEventMonitoredItem monitoredItem, bool unsubscribe) + { + lock (Lock) + { + if (unsubscribe) _allEventsItems.TryRemove(monitoredItem, out _); + else _allEventsItems[monitoredItem] = 0; + ReconcileAllEventsMonitoring(); + } + + return ServiceResult.Good; + } + + /// + /// + /// + /// A Server-object (all-events) item is never attached to a notifier of ours (Gitea #586), so a + /// subscribe or unsubscribe of one against a notifier is a no-op here. The only SDK caller that + /// still reaches this with such an item is AddRootNotifier, which — an SDK bug — "subscribes" + /// every existing all-events item to a new root notifier with unsubscribe: true. Passed to the + /// base, that lowers on the new notifier, its children and + /// its forward notifiers — taking back, from every condition materialised under a live item, the + /// unit just gave it (and, before #586, a folder + /// subscriber's unit too). Caught by the rebuild leg of the monitored-bookkeeping test. + /// + /// + /// Direct notifier items, including the #562 revalidation pass's attach and detach, go to the base + /// unchanged. + /// + /// + protected override ServiceResult SubscribeToEvents( + ServerSystemContext context, NodeState source, IEventMonitoredItem monitoredItem, bool unsubscribe) => + monitoredItem.MonitoringAllEvents + ? ServiceResult.Good + : base.SubscribeToEvents(context, source, monitoredItem, unsubscribe); + + /// + /// The conditions currently carrying this manager's one "a Server-object item is listening" unit of + /// (Gitea #586 review). + /// + /// Mutated only by , under Lock. + private readonly HashSet _allEventsMonitoredConditions = + new(System.Collections.Generic.ReferenceEqualityComparer.Instance); + + /// + /// Keeps raised on every materialised condition while at + /// least one Server-object item exists, and lowered once none does. Call under Lock. + /// + /// + /// + /// The SDK base attached an all-events item to every root notifier through its own + /// SubscribeToEvents, which also calls SetAreEventsMonitored(true, includeChildren) + /// on the notifier — a counter that propagates to every condition beneath or wired to it. Not + /// attaching the items lost that bookkeeping, and a condition reads it: ConditionState + /// reports its own method-driven state changes (and their audit events) only while + /// EventsMonitored() is true. A server whose only subscribers are Server-object items — the + /// common machine-consumer shape — would otherwise report none of them. + /// + /// + /// Held per condition, exactly one unit each, rather than per root notifier per item as the + /// base did. The counter is shared with the folder subscribers' own units, so it must balance + /// exactly; a notifier-level unit would also reach a condition materialised under an already- + /// raised folder never, or be taken back from one it never reached. This reconciles the desired + /// set (every condition, while any item exists) against what it has raised, so materialise, + /// remove, rebuild and subscribe all converge through one place. + /// + /// + private void ReconcileAllEventsMonitoring() + { + // The protected SubscribeToEvents override is what stops the SDK taking these units back. + lock (Lock) + { + var desired = _allEventsItems.IsEmpty + ? new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance) + : new HashSet( + _alarmConditions.Values, System.Collections.Generic.ReferenceEqualityComparer.Instance); + + foreach (var condition in _allEventsMonitoredConditions.Where(c => !desired.Contains(c)).ToList()) + { + condition.SetAreEventsMonitored(SystemContext, false, includeChildren: true); + _allEventsMonitoredConditions.Remove(condition); + } + + foreach (var condition in desired) + { + if (_allEventsMonitoredConditions.Add(condition)) + condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true); + } + } + } + + /// + /// + /// + /// The base is the delegate AddRootNotifier installs on a root notifier: it forwards every + /// event reaching that folder to Server.ReportEvent, where the core node manager's Server + /// object queues it to every all-events item with no ACL decision — the bypass Gitea #586 closes. + /// Server-object items now receive conditions from the condition itself + /// (), so the forward could only ever deliver an ungated + /// duplicate. Dropped. + /// + /// + /// Only condition events bubble through this manager's notifiers; the audit and model-change + /// events it raises go to Server.ReportEvent directly and are untouched by this. + /// + /// + protected override void OnReportEvent(ISystemContext context, NodeState node, IFilterTarget e) + { + } + + /// + /// Delivers one condition event to the Server-object (all-events) subscriptions this manager holds, + /// item by item, through the data-plane ACL (Gitea #586). + /// + /// The reporting context; a session-bearing one restricts delivery to that session. + /// The condition that reported. + /// The event — the condition's snapshot. + /// + /// + /// Installed as each condition's OnReportEvent in , + /// so it sees every event the condition reports exactly once, whichever notifiers it then bubbles + /// through — the one-copy-at-the-Server-object behaviour the multi-notifier wiring relies on. + /// + /// + /// Everything except the ACL decision mirrors the SDK's MonitoredNode2.OnReportEvent, which + /// is what delivered these events before: the SDK's own role-permission check, and the rule that an + /// event reported under a session's context reaches only that session's items. + /// + /// + /// Inert while Security:Acl:Enabled is false — every item receives every condition, as it + /// always has. + /// + /// + private void OnConditionReportEvent(ISystemContext context, NodeState node, IFilterTarget e) + { + if (_allEventsItems.IsEmpty) return; + + var scopes = AclEnforced ? ConditionVisibilityScopes(node) : null; + var reportingSession = (context as ISessionSystemContext)?.SessionId?.Identifier; + + foreach (var item in _allEventsItems.Keys) + { + if (e is AuditEventState && !IsAuditDeliverableTo(item)) continue; + if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue; + if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue; + if (reportingSession is not null + && item.Session?.Id?.Identifier is { } own + && !own.Equals(reportingSession)) + { + continue; + } + + item.QueueEvent(e); + } + } + + /// + /// The SDK's audit-event guard from MonitoredNode2.OnReportEvent, reproduced exactly: an audit + /// event reaches an item only while the server is auditing, and only over a SignAndEncrypt or + /// https-binary endpoint. + /// + /// The Server-object event item. + /// when the audit event may be queued to the item. + /// + /// A condition reports its Part 9 method audit events (AuditConditionAcknowledgeEventType, + /// Comment, Enable, Shelving…) through its own ReportEvent, so they pass through + /// . Before #586 the Server-object copy took the root-notifier + /// forward, where ServerInternalData.ReportEvent dropped every audit event unless auditing was + /// on; without this guard they reached every Server-object item regardless (caught in review, flag off). + /// + private bool IsAuditDeliverableTo(IEventMonitoredItem item) + { + if (!Server.Auditing) return false; + var endpoint = item.Session?.EndpointDescription; + return endpoint?.SecurityMode == MessageSecurityMode.SignAndEncrypt + || endpoint?.TransportProfileUri == Profiles.HttpsBinaryTransport; + } + + /// + /// The nodes whose grant admits a condition to a session: the condition itself, every root-notifier + /// folder its events bubble up through, and every equipment folder wired as its extra notifier. + /// + /// The condition. + /// The candidate scopes; Subscribe on any one of them admits the condition. + /// + /// + /// The folders are exactly the per-folder path's question. A session that may subscribe to a + /// notifier the condition reports through already receives the condition there, so the Server + /// object withholding it would protect nothing; a session granted on none of them receives it + /// nowhere. The folders must be asked as well as the condition because the trie inherits downward + /// only within a realm: an Equipment grant covers the UNS folder a native condition is wired + /// into, but not the condition, which lives at its RawPath in the raw realm. + /// + /// + /// The condition itself (ConditionId = RawPath for a native alarm) admits a grant scoped to + /// the alarm's own raw tag — the tag's grant covers its alarm wherever the alarm can be named. + /// + /// + private List ConditionVisibilityScopes(NodeState condition) + { + var scopes = new List { condition.NodeId }; + + lock (Lock) + { + for (var ancestor = (condition as BaseInstanceState)?.Parent; + ancestor is not null; + ancestor = (ancestor as BaseInstanceState)?.Parent) + { + if (_notifierFolders.TryGetValue(ancestor.NodeId, out var notifier) && ReferenceEquals(notifier, ancestor)) + scopes.Add(ancestor.NodeId); + } + + if (_alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring) + && ReferenceEquals(wiring.Alarm, condition)) + { + scopes.AddRange(wiring.Folders.Select(f => f.NodeId)); + } + } + + return scopes; + } + + /// Whether 's session may see a condition with the given scopes. + /// The Server-object event item. + /// From . + /// when the session holds Subscribe on any scope. + /// + /// The identity is the one the item was created under — the thread delivering the event has no + /// session of its own — and the operation is the one the per-folder creation gate asks. + /// re-checks key revocation and expiry on + /// every call and keys its decision cache on the current generation, which is what makes revalidation + /// implicit here: a deployment or an expiry takes effect on the next event, with no pass to run. + /// + private bool IsConditionVisibleTo(IEventMonitoredItem item, List scopes) + { + var identity = item.EffectiveIdentity as RoleCarryingUserIdentity; + foreach (var scope in scopes) + { + if (_authorizer!.IsAllowedCached(identity, OpcUaOperation.CreateMonitoredItems, scope, RealmOf(scope))) + return true; + } + + return false; + } + + /// + /// + /// + /// A refresh on a Server-object item is the other way it learns about conditions — it is how a + /// client connecting after a transition gets the current alarm picture — and the SDK base replays + /// every condition under every root notifier to it after only its own role-permission check. With + /// enforcement on, those items are refreshed here instead, through the same + /// decision live delivery uses (Gitea #586); an event that names no + /// condition is withheld. With it off, the base runs unchanged (its per-notifier repeats of one + /// condition share an EventId, which the item's queue already collapses to one copy). + /// + /// + /// Items on one of this manager's own notifiers keep the base behaviour: a stood-down item is off + /// its notifier's fan-out, which the base already treats as "not refreshed". + /// + /// + public override ServiceResult ConditionRefresh(OperationContext context, IList monitoredItems) + { + if (!AclEnforced) return base.ConditionRefresh(context, monitoredItems); + + var direct = new List(); + var serverItems = new List(); + foreach (var item in monitoredItems) + { + if (item is null) continue; + (item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item); + } + + if (direct.Count > 0) base.ConditionRefresh(context, direct); + if (serverItems.Count == 0) return ServiceResult.Good; + + var systemContext = SystemContext.Copy(context); + List notifiers; + lock (Lock) notifiers = RootNotifiers?.ToList() ?? []; + + var events = new List(); + foreach (var notifier in notifiers) notifier.ConditionRefresh(systemContext, events, true); + + // One snapshot per condition, with its scopes resolved once. + var conditions = new List<(IFilterTarget Event, List Scopes)>(); + var seen = new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance); + foreach (var e in events) + { + if (ConditionOf(e) is not { } condition || !seen.Add(condition)) continue; + conditions.Add((e, ConditionVisibilityScopes(condition))); + } + + foreach (var item in serverItems) + { + foreach (var (e, scopes) in conditions) + { + if (!IsConditionVisibleTo(item, scopes)) continue; + if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue; + item.QueueEvent(e); + } + } + + return ServiceResult.Good; + } + + /// The condition a refresh event describes, or null when it names none (withheld: fail closed). + /// A refresh event. + /// The condition node. + private static NodeState? ConditionOf(IFilterTarget e) => e switch + { + InstanceStateSnapshot { Handle: NodeState condition } => condition, + NodeState condition => condition, + _ => null, + }; + + /// + /// Whether the condition at believes it is being listened to + /// (ConditionState.EventsMonitored()) — the gate on the SDK reporting its method-driven state + /// changes. Test observability for the Gitea #586 review. + /// + /// The condition's NodeId identifier. + /// The condition's realm. + /// when the condition exists and reports itself monitored. + internal bool ConditionEventsMonitored(string conditionNodeId, AddressSpaceRealm realm) + { + lock (Lock) + { + return _alarmConditions.TryGetValue(MapKey(realm, conditionNodeId), out var condition) + && condition.EventsMonitored(); + } + } + /// /// How many event items are attached to the event fan-out of nodes this manager owns. Test /// observability for Gitea #562: an item resurrected onto a notifier after its client deleted it is diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs index 4016780c..5c47420b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs @@ -42,13 +42,15 @@ public sealed class AclEventSubscriptionEnforcementTests private const string Cluster = "SITE-A"; private const string RawDeviceFolder = "Plant/Modbus/dev1"; private const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi"; + private const string ScriptedAlarmId = "scripted-eqa-hi"; private const string EqA = "EqA"; private const string EqB = "EqB"; private const string KeyPrincipal = "expiry-probe"; private const string KeyId = "k-event-expiry"; - // [0]=EventId, [1]=Message — the message is each transition's discriminator. + // [0]=EventId, [1]=Message — the message is each transition's discriminator; [2]=EventType. private const int MessageFieldIndex = 1; + private const int EventTypeFieldIndex = 2; private static readonly DateTime Start = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc); @@ -268,8 +270,601 @@ public sealed class AclEventSubscriptionEnforcementTests finally { await rig.DisposeAsync(); } } + // ─────────────────── Server-object (i=2253) subscriptions — Gitea #586 ─────────────────── + + /// + /// A session holding no grant receives nothing through a Server-object event item, while a granted + /// session's Server-object item receives the same transition. + /// + /// + /// The Server object carries every alarm in the server — each condition reports once and its one + /// copy reaches every all-events item — so before #586 this was the one feed that ignored the ACL + /// entirely. The granted item is the positive control: it proves the transition was fired and + /// published before the denied item's silence is read as anything. + /// + [Fact] + public async Task A_server_object_subscription_receives_only_the_alarms_its_session_is_granted() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerEvents", ct); + try + { + rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key")); + rig.Materialise(); + + using var granted = await rig.ConnectAsync(anonymous: false, ct); + using var ungranted = await rig.ConnectAsync(anonymous: true, ct); + + var grantedEvents = new EventCollector(); + var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct); + var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server); + await grantedSubscription.ApplyChangesAsync(ct); + grantedItem.Status.Error.ShouldBeNull(); + + var ungrantedEvents = new EventCollector(); + var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct); + var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server); + await ungrantedSubscription.ApplyChangesAsync(ct); + // Creation is not refused: an all-events item is legitimate for a session with partial grants, + // so what is withheld is each event the session may not see, not the item. + ungrantedItem.Status.Error.ShouldBeNull(); + + rig.Transition("t1", active: true); + await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1")); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + ungrantedEvents.Has(ungrantedItem, "t1").ShouldBeFalse( + "a session with no grant must not receive alarms through the Server object"); + grantedEvents.Count(grantedItem, "t1").ShouldBe(1, "the condition still reaches the Server object exactly once"); + + await ungrantedSubscription.DeleteAsync(true, ct); + await grantedSubscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// A Server-object item created before the alarm is deployed is gated the same way, and still + /// receives exactly one copy when granted. + /// + /// + /// The ordering a long-lived consumer actually sees — it subscribes once, deployments come later. The + /// SDK never attaches such an item to a root notifier registered after it (its AddRootNotifier + /// subscribes existing all-events items with unsubscribe: true), so before #586 these items were + /// served only by the root notifier's forward to the core Server object — a path this manager never + /// saw, and the one now dropped. This proves the replacement delivery covers it. + /// + [Fact] + public async Task A_server_object_subscription_created_before_the_deployment_is_gated_and_served() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerEventsEarly", ct); + try + { + rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key")); + + using var granted = await rig.ConnectAsync(anonymous: false, ct); + using var ungranted = await rig.ConnectAsync(anonymous: true, ct); + + var grantedEvents = new EventCollector(); + var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct); + var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server); + await grantedSubscription.ApplyChangesAsync(ct); + + var ungrantedEvents = new EventCollector(); + var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct); + var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server); + await ungrantedSubscription.ApplyChangesAsync(ct); + + rig.Materialise(); + + rig.Transition("t1", active: true); + await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1")); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + ungrantedEvents.Has(ungrantedItem, "t1").ShouldBeFalse(); + grantedEvents.Count(grantedItem, "t1").ShouldBe(1); + + await ungrantedSubscription.DeleteAsync(true, ct); + await grantedSubscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// The deployment axis on a Server-object item: an equipment grant admits the alarm, a deployment that + /// revokes it stops the feed, and one that restores it resumes the feed with no client action. + /// + /// + /// The grant is an Equipment grant on a UNS folder the condition is wired to, not a grant on the + /// raw condition itself — the decision has to match the per-folder path, where that grant admits the + /// same alarm through the equipment's notifier. A cluster-granted key session is the positive control. + /// + [Fact] + public async Task Revoking_then_restoring_a_grant_stops_then_resumes_a_server_object_alarm_feed() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerEventsRevoke", ct); + try + { + var key = UnexpiringKey(); + var state = rig.Enforce([key], EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")); + rig.Materialise(); + + using var control = await rig.ConnectAsync(anonymous: false, ct); + using var session = await rig.ConnectAsync(anonymous: true, ct); + + var controlEvents = new EventCollector(); + var controlSubscription = await CreateSubscriptionAsync(control, controlEvents, ct); + var controlItem = AddEventItem(controlSubscription, ObjectIds.Server); + await controlSubscription.ApplyChangesAsync(ct); + + var events = new EventCollector(); + var subscription = await CreateSubscriptionAsync(session, events, ct); + var item = AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + rig.Transition("t1", active: true); + await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1")); + + state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths()); + + rig.Transition("t2", active: false); + await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2")); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + events.Has(item, "t2").ShouldBeFalse("the revoked session must not receive alarms after the deployment"); + + state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths()); + + rig.Transition("t3", active: true); + await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3")); + events.Has(item, "t2").ShouldBeFalse("nothing withheld while revoked is replayed on restore"); + + await subscription.DeleteAsync(true, ct); + await controlSubscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// The clock axis on a Server-object item: once the session's key expires the alarm feed stops on the + /// very next event — with no deployment and no revalidation tick — while an independent session keeps + /// receiving the same event. + /// + /// + /// Server-object events are decided per event at delivery, and the authorizer re-checks key expiry on + /// every call, so revalidation is implicit rather than driven by either trigger. Deliberately no + /// TickRevalidation() here: a build that only stood items down on the tick would pass a test + /// that ticked. + /// + [Fact] + public async Task An_expired_key_stops_a_server_object_alarm_feed_on_the_next_event() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerEventsExpiry", ct); + try + { + var key = UnexpiringKey(expiresUtc: Start.AddMinutes(10)); + var state = rig.Enforce( + [key], ClusterGrant(KeyPrincipal, "acl-key"), ClusterGrant(OpcUaPrincipals.Anonymous, "acl-anon")); + rig.Materialise(); + + using var keySession = await rig.ConnectAsync(anonymous: false, ct); + using var controlSession = await rig.ConnectAsync(anonymous: true, ct); + + var keyEvents = new EventCollector(); + var keySubscription = await CreateSubscriptionAsync(keySession, keyEvents, ct); + var keyItem = AddEventItem(keySubscription, ObjectIds.Server); + await keySubscription.ApplyChangesAsync(ct); + + var controlEvents = new EventCollector(); + var controlSubscription = await CreateSubscriptionAsync(controlSession, controlEvents, ct); + var controlItem = AddEventItem(controlSubscription, ObjectIds.Server); + await controlSubscription.ApplyChangesAsync(ct); + + rig.Transition("t1", active: true); + await WaitUntilAsync(() => keyEvents.Has(keyItem, "t1") && controlEvents.Has(controlItem, "t1")); + + rig.Advance(TimeSpan.FromMinutes(20)); + + rig.Transition("t2", active: false); + await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2")); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + keyEvents.Has(keyItem, "t2").ShouldBeFalse("the expired key's Server-object feed must stop"); + state.Current.GenerationId.ShouldBe(1); + + await controlSubscription.DeleteAsync(true, ct); + await keySubscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// With Security:Acl:Enabled false the Server object stays ungated: a session with no grant + /// receives the alarm exactly as a granted one does. + /// + /// + /// Without this the tests above also pass on a build that withholds every Server-object event from + /// every session — the flag-off posture leaves reads and subscriptions ungated, and the alarm consumers + /// of every fleet that has not opted in depend on it. + /// + [Fact] + public async Task With_acl_enforcement_off_a_server_object_subscription_receives_every_alarm() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerEventsOff", ct); + try + { + rig.Enforce(enabled: false, [UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key")); + rig.Materialise(); + + using var granted = await rig.ConnectAsync(anonymous: false, ct); + using var ungranted = await rig.ConnectAsync(anonymous: true, ct); + + var grantedEvents = new EventCollector(); + var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct); + var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server); + await grantedSubscription.ApplyChangesAsync(ct); + + var ungrantedEvents = new EventCollector(); + var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct); + var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server); + await ungrantedSubscription.ApplyChangesAsync(ct); + + rig.Transition("t1", active: true); + await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1") && ungrantedEvents.Has(ungrantedItem, "t1")); + await Task.Delay(TimeSpan.FromMilliseconds(300), ct); + ungrantedEvents.Count(ungrantedItem, "t1").ShouldBe(1); + + await ungrantedSubscription.DeleteAsync(true, ct); + await grantedSubscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// A ConditionRefresh on a Server-object subscription replays only the conditions the session is + /// granted: the granted session gets the current state, the ungranted one gets the refresh brackets + /// and nothing between them. + /// + /// + /// Refresh is the other way a Server-object item learns about a condition — it is how a client + /// connecting after a transition gets the current alarm picture — and it walks every condition in the + /// server. The ungranted session's RefreshEndEvent is the positive control on its own item: it + /// proves the refresh ran for that subscription and completed, so the missing condition is withheld, + /// not late. + /// + [Fact] + public async Task A_condition_refresh_on_the_server_object_replays_only_granted_conditions() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerRefresh", ct); + try + { + rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key")); + rig.Materialise(); + // The transition precedes every subscription, so only a refresh can deliver it. + rig.Transition("before", active: true); + + using var granted = await rig.ConnectAsync(anonymous: false, ct); + using var ungranted = await rig.ConnectAsync(anonymous: true, ct); + + var grantedEvents = new EventCollector(); + var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct); + var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server); + await grantedSubscription.ApplyChangesAsync(ct); + + var ungrantedEvents = new EventCollector(); + var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct); + var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server); + await ungrantedSubscription.ApplyChangesAsync(ct); + + await RefreshAsync(granted, grantedSubscription, ct); + await RefreshAsync(ungranted, ungrantedSubscription, ct); + + await WaitUntilAsync(() => + grantedEvents.Has(grantedItem, "before") + && ungrantedEvents.HasType(ungrantedItem, ObjectTypeIds.RefreshEndEventType)); + await Task.Delay(TimeSpan.FromMilliseconds(300), ct); + ungrantedEvents.Has(ungrantedItem, "before").ShouldBeFalse( + "a refresh must not replay a condition the session is not granted"); + + await ungrantedSubscription.DeleteAsync(true, ct); + await grantedSubscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + // ──────── Server-object delivery with the flag OFF — #586 review: no regression vs. the SDK path ──────── + + /// + /// Part 9 method calls (Acknowledge, AddComment) on a condition put no audit event in front of a + /// Server-object item while the server is not auditing — with and without a folder co-subscriber — + /// and the item still receives the condition's own transitions exactly once. + /// + /// + /// + /// A condition reports its method audit events (AuditConditionAcknowledgeEventType, + /// AuditConditionCommentEventType) through its own ReportEvent, which since #586 is + /// where Server-object delivery happens. The SDK path it replaced dropped them unless auditing was + /// on over an encrypted channel; the replacement must too. Found in review: the first cut queued + /// i=8944 to every Server-object item. + /// + /// + /// Both shapes matter. With a folder co-subscriber the condition counts as monitored through the + /// folder; with none, only the Server-object item makes it so — the shape the second review + /// finding was about, where nothing at all was reported before. + /// + /// + /// The positive control is the next transition: the method calls queue synchronously, so an audit + /// event would be ahead of t2 in the same item's queue. + /// + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Method_audit_events_do_not_reach_a_server_object_item_while_not_auditing(bool folderCoSubscriber) + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync($"AclServerAudit{folderCoSubscriber}", ct); + try + { + rig.Materialise(); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var events = new EventCollector(); + var subscription = await CreateSubscriptionAsync(session, events, ct); + var serverItem = AddEventItem(subscription, ObjectIds.Server); + var folderItem = folderCoSubscriber ? AddEventItem(subscription, rig.UnsNodeId(session, EqA)) : null; + await subscription.ApplyChangesAsync(ct); + + rig.Transition("t1", active: true); + await WaitUntilAsync(() => events.Has(serverItem, "t1")); + var eventId = events.EventIdOf(serverItem, "t1"); + + var condition = rig.RawConditionNodeId(session); + await CallIgnoringStatusAsync( + session, condition, MethodIds.AcknowledgeableConditionType_Acknowledge, ct, eventId, new LocalizedText("ack")); + await CallIgnoringStatusAsync( + session, condition, MethodIds.ConditionType_AddComment, ct, eventId, new LocalizedText("note")); + + rig.Transition("t2", active: false); + await WaitUntilAsync(() => events.Has(serverItem, "t2")); + await Task.Delay(TimeSpan.FromMilliseconds(300), ct); + + events.HasType(serverItem, ObjectTypeIds.AuditConditionAcknowledgeEventType).ShouldBeFalse(); + events.HasType(serverItem, ObjectTypeIds.AuditConditionCommentEventType).ShouldBeFalse(); + events.Count(serverItem, "t1").ShouldBe(1); + events.Count(serverItem, "t2").ShouldBe(1); + if (folderItem is not null) + { + events.HasType(folderItem, ObjectTypeIds.AuditConditionAcknowledgeEventType).ShouldBeFalse(); + events.Count(folderItem, "t2").ShouldBe(1); + } + + await subscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// A Server-object item alone marks every condition as monitored, and deleting it un-marks them — + /// including a condition rebuilt by a redeploy while the item was live. + /// + /// + /// ConditionState.EventsMonitored() gates whether the SDK reports a condition's method-driven + /// state changes at all. The SDK base raised it by attaching the item to every root notifier; #586 + /// attaches to nothing, so the first cut left it false on a server whose only subscribers are + /// Server-object items — the machine-consumer default. Asserted server-side because the consequence on + /// the wire depends on a separate, still-open reporting gap. + /// + [Fact] + public async Task A_server_object_item_alone_marks_conditions_monitored_until_it_is_deleted() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerMonitored", ct); + try + { + rig.Materialise(); + rig.NativeConditionEventsMonitored.ShouldBeFalse("baseline: nobody is listening yet"); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var events = new EventCollector(); + var subscription = await CreateSubscriptionAsync(session, events, ct); + AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + rig.NativeConditionEventsMonitored.ShouldBeTrue(); + + rig.Rebuild(); + rig.Materialise(); + rig.NativeConditionEventsMonitored.ShouldBeTrue("a condition rebuilt under a live item is monitored too"); + + await subscription.DeleteAsync(true, ct); + rig.NativeConditionEventsMonitored.ShouldBeFalse("the last item's delete must give the unit back"); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// A Server-object item outlives a full address-space rebuild and receives exactly one copy of each + /// transition on the rebuilt condition. + /// + /// + /// Nothing is attached to the discarded folders, so there is no fan-out to repair; this pins that the + /// rebuilt condition's delivery is wired, and that the rebuild did not leave a second path behind. + /// + [Fact] + public async Task A_server_object_item_survives_a_rebuild_with_one_copy_per_event() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerRebuild", ct); + try + { + rig.Materialise(); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var events = new EventCollector(); + var subscription = await CreateSubscriptionAsync(session, events, ct); + var item = AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + rig.Transition("t1", active: true); + await WaitUntilAsync(() => events.Has(item, "t1")); + + rig.Rebuild(); + rig.Materialise(); + + rig.Transition("t2", active: false); + await WaitUntilAsync(() => events.Has(item, "t2")); + await Task.Delay(TimeSpan.FromMilliseconds(300), ct); + events.Count(item, "t1").ShouldBe(1); + events.Count(item, "t2").ShouldBe(1); + + await subscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// With several conditions — a native one wired to two equipment folders and a scripted one under a + /// UNS equipment folder — a Server-object item receives each condition's transition exactly once. + /// + [Fact] + public async Task A_server_object_item_receives_native_and_scripted_conditions_once_each() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerMulti", ct); + try + { + rig.Materialise(); + rig.MaterialiseScripted(); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var events = new EventCollector(); + var subscription = await CreateSubscriptionAsync(session, events, ct); + var item = AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + rig.Transition("n1", active: true); + rig.TransitionScripted("s1", active: true); + await WaitUntilAsync(() => events.Has(item, "n1") && events.Has(item, "s1")); + await Task.Delay(TimeSpan.FromMilliseconds(300), ct); + events.Count(item, "n1").ShouldBe(1); + events.Count(item, "s1").ShouldBe(1); + + await subscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// With enforcement on, the decision follows the notifier a condition reports through: an equipment + /// grant on admits the native condition wired to it and withholds the scripted + /// condition under ; a cluster-granted session receives both (the positive control). + /// + /// + /// The scripted condition's NodeId is its alarm id, not a path under its equipment, so it is admitted + /// only through its parent notifier folder — the branch of the check this pins. + /// + [Fact] + public async Task Server_object_delivery_follows_the_notifier_folders_for_native_and_scripted_conditions() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerScripted", ct); + try + { + rig.Enforce([UnexpiringKey()], EquipmentGrant(EqB), ClusterGrant(KeyPrincipal, "acl-key")); + rig.Materialise(); + rig.MaterialiseScripted(); + + using var control = await rig.ConnectAsync(anonymous: false, ct); + using var session = await rig.ConnectAsync(anonymous: true, ct); + + var controlEvents = new EventCollector(); + var controlSubscription = await CreateSubscriptionAsync(control, controlEvents, ct); + var controlItem = AddEventItem(controlSubscription, ObjectIds.Server); + await controlSubscription.ApplyChangesAsync(ct); + + var events = new EventCollector(); + var subscription = await CreateSubscriptionAsync(session, events, ct); + var item = AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + rig.Transition("n1", active: true); + rig.TransitionScripted("s1", active: true); + await WaitUntilAsync(() => + controlEvents.Has(controlItem, "n1") && controlEvents.Has(controlItem, "s1") && events.Has(item, "n1")); + await Task.Delay(TimeSpan.FromMilliseconds(500), ct); + events.Has(item, "s1").ShouldBeFalse("EqB's grant does not reach the scripted condition under EqA"); + + await subscription.DeleteAsync(true, ct); + await controlSubscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// With the flag off, a ConditionRefresh on a Server-object item replays each condition exactly + /// once — not once per notifier it is wired to — bracketed by RefreshStart and RefreshEnd. + /// + [Fact] + public async Task A_condition_refresh_on_the_server_object_replays_each_condition_once() + { + var ct = TestContext.Current.CancellationToken; + var rig = await Rig.StartAsync("AclServerRefreshOff", ct); + try + { + rig.Materialise(); + rig.MaterialiseScripted(); + rig.Transition("native-before", active: true); + rig.TransitionScripted("scripted-before", active: true); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var events = new EventCollector(); + var subscription = await CreateSubscriptionAsync(session, events, ct); + var item = AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + await RefreshAsync(session, subscription, ct); + + await WaitUntilAsync(() => events.HasType(item, ObjectTypeIds.RefreshEndEventType)); + await Task.Delay(TimeSpan.FromMilliseconds(300), ct); + events.HasType(item, ObjectTypeIds.RefreshStartEventType).ShouldBeTrue(); + events.Count(item, "native-before").ShouldBe(1, "the native condition is wired to three notifiers"); + events.Count(item, "scripted-before").ShouldBe(1); + + await subscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + // ───────────────────────────── helpers ───────────────────────────── + /// + /// Calls a condition method, ignoring its outcome: the test asserts what the call puts on the event + /// stream, which the SDK reports whether or not the call itself is accepted. + /// + private static async Task CallIgnoringStatusAsync( + ISession session, NodeId objectId, NodeId methodId, CancellationToken ct, params object[] args) + { + try { await session.CallAsync(objectId, methodId, ct, args); } + catch (ServiceResultException) { /* the outcome is not under test */ } + } + + private static ApiKey UnexpiringKey(DateTime? expiresUtc = null) => new() + { + ApiKeyId = KeyId, + Name = KeyId, + SecretHash = "hash", + Principal = KeyPrincipal, + Enabled = true, + CreatedUtc = Start.AddDays(-1), + ExpiresUtc = expiresUtc, + }; + + private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) => + await session.CallAsync( + ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id); + private static NodeAcl EquipmentGrant(string equipment) => new() { NodeAclId = $"acl-{equipment}", @@ -311,6 +906,7 @@ public sealed class AclEventSubscriptionEnforcementTests var filter = new EventFilter(); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType); var item = new MonitoredItem(subscription.DefaultItem) { @@ -335,11 +931,13 @@ public sealed class AclEventSubscriptionEnforcementTests condition().ShouldBeTrue("condition not met within timeout"); } - /// Records which transition messages reached which monitored item. + /// Records which transition messages, and which event types, reached which monitored item. private sealed class EventCollector { private readonly object _gate = new(); - private readonly HashSet<(uint Handle, string Message)> _seen = []; + private readonly List<(uint Handle, string Message)> _seen = []; + private readonly Dictionary<(uint Handle, string Message), byte[]> _eventIds = []; + private readonly HashSet<(uint Handle, NodeId Type)> _types = []; public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable) { @@ -351,14 +949,33 @@ public sealed class AclEventSubscriptionEnforcementTests && e.EventFields[MessageFieldIndex].Value is LocalizedText { Text: { } text }) { _seen.Add((e.ClientHandle, text)); + if (e.EventFields[0].Value is byte[] eventId) _eventIds[(e.ClientHandle, text)] = eventId; + } + + if (e.EventFields.Count > EventTypeFieldIndex + && e.EventFields[EventTypeFieldIndex].Value is NodeId type) + { + _types.Add((e.ClientHandle, type)); } } } } - public bool Has(MonitoredItem item, string message) + public bool Has(MonitoredItem item, string message) => Count(item, message) > 0; + + public int Count(MonitoredItem item, string message) { - lock (_gate) return _seen.Contains((item.ClientHandle, message)); + lock (_gate) return _seen.Count(s => s.Handle == item.ClientHandle && s.Message == message); + } + + public bool HasType(MonitoredItem item, NodeId type) + { + lock (_gate) return _types.Contains((item.ClientHandle, type)); + } + + public byte[] EventIdOf(MonitoredItem item, string message) + { + lock (_gate) return _eventIds[(item.ClientHandle, message)]; } } @@ -408,14 +1025,20 @@ public sealed class AclEventSubscriptionEnforcementTests /// Switches enforcement on, wiring the authorizer, the authority source (the deployment trigger) /// and the host's revalidation timer (the clock trigger) over one hand-advanced clock. /// - public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants) + public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants) => Enforce(true, keys, grants); + + /// + /// As , with standing in for + /// Security:Acl:Enabled — false wires the same grants with enforcement switched off. + /// + public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", keys, grants, ScopePaths()); Nm.DataPlaneAuthority = state; Nm.Authorizer = new DataPlaneAuthorizer( - state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, _clock); + state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, _clock); // A long interval: the tick is driven by hand through the same component the host arms. _timer = new AclRevalidationTimer( @@ -437,6 +1060,31 @@ public sealed class AclEventSubscriptionEnforcementTests _sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, [EqA, EqB], AddressSpaceRealm.Uns); } + /// A scripted condition under equipment folder (UNS realm, no extra notifiers). + public void MaterialiseScripted() => + _sink.MaterialiseAlarmCondition( + ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false); + + /// Fires one transition on the scripted condition, carrying . + public void TransitionScripted(string message, bool active) => + _sink.WriteAlarmCondition( + ScriptedAlarmId, + new AlarmConditionSnapshot( + Active: active, Acknowledged: !active, Confirmed: true, Enabled: true, + Shelving: AlarmShelvingKind.Unshelved, Severity: 500, Message: message), + DateTime.UtcNow, + AddressSpaceRealm.Uns); + + /// A full address-space rebuild, as a redeploy that is not surgical performs. + public void Rebuild() => _sink.RebuildAddressSpace(); + + /// Whether the native condition reports itself monitored (ConditionState.EventsMonitored()). + public bool NativeConditionEventsMonitored => Nm.ConditionEventsMonitored(RawAlarmPath, AddressSpaceRealm.Raw); + + /// The native condition's NodeId in the session's raw namespace. + public NodeId RawConditionNodeId(ISession session) => + new(RawAlarmPath, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); + /// Fires one genuine condition transition, carrying . public void Transition(string message, bool active) => _sink.WriteAlarmCondition(