diff --git a/CLAUDE.md b/CLAUDE.md
index d9bb3c4a..36bafab2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -925,9 +925,17 @@ 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. ⚠️ Residual: audit/model-change
+ events still go straight 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..70e76ca8 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -531,16 +531,41 @@ 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.
+
+ 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..4c27d2a5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -349,6 +349,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
Volatile.Read(ref _dataPlaneAuthority).Changed -= OnAuthorityChanged;
_liveItems.Clear();
_eventItems.Clear();
+ _allEventsItems.Clear();
}
base.Dispose(disposing);
@@ -999,9 +1000,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,10 +1347,16 @@ 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);
@@ -2070,7 +2078,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
@@ -3376,14 +3385,254 @@ 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)
+ {
+ if (unsubscribe) _allEventsItems.TryRemove(monitoredItem, out _);
+ else _allEventsItems[monitoredItem] = 0;
+ return ServiceResult.Good;
+ }
+
+ ///
+ ///
+ ///
+ /// 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 (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 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), and each condition
+ /// is replayed once rather than once per notifier it is wired to.
+ ///
+ ///
+ /// 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,
+ };
+
///
/// 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..3989d45f 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AclEventSubscriptionEnforcementTests.cs
@@ -47,8 +47,9 @@ public sealed class AclEventSubscriptionEnforcementTests
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 +269,326 @@ 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(); }
+ }
+
// ───────────────────────────── helpers ─────────────────────────────
+ 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 +630,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 +655,12 @@ 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 HashSet<(uint Handle, NodeId Type)> _types = [];
public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable)
{
@@ -352,13 +673,26 @@ public sealed class AclEventSubscriptionEnforcementTests
{
_seen.Add((e.ClientHandle, text));
}
+
+ 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));
}
}
@@ -408,14 +742,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(