Merge #586: Server-object (i=2253) event items gated by the data-plane ACL; per-condition delivery with SDK audit guard + monitored bookkeeping

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 00:09:03 -04:00
5 changed files with 1109 additions and 30 deletions
+18 -3
View File
@@ -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
+4 -1
View File
@@ -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
+45 -10
View File
@@ -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
@@ -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
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
/// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
/// folder is a subscribable event source linked under the Server object (Server-object delivery itself is
/// the condition's own — see OnConditionReportEvent, Gitea #586).</summary>
/// <remarks>
/// Phase C: when a real historian is wired at promotion time (the source is NOT the
/// <see cref="NullHistorianDataSource"/>), 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.
/// </para>
/// <para>
/// ⚠️ <b>Server-object (all-events) subscriptions are not in this map</b>, because they are not
/// authorized by this manager in the first place: the SDK fans them out through
/// <c>SubscribeToAllEvents</c> and through the Server object's own notifier, which the core node
/// manager owns. See <c>docs/security.md</c> § Data-Plane Authorization.
/// <b>Server-object (all-events) subscriptions are not in this map.</b> They are not attached to
/// any notifier of ours at all; they are held in <see cref="_allEventsItems"/> and decided per
/// event at delivery (Gitea #586), so there is nothing for this map's stand-down to detach.
/// </para>
/// </remarks>
private readonly ConcurrentDictionary<IEventMonitoredItem, LiveEventItem> _eventItems = new();
/// <summary>
/// Live Server-object (<c>i=2253</c>) event monitored items — every item the SDK handed this manager
/// through <see cref="SubscribeToAllEvents"/> (Gitea #586).
/// </summary>
/// <remarks>
/// A set (the value is unused). Maintained by <see cref="SubscribeToAllEvents"/>'s subscribe and
/// unsubscribe arms, which the SDK's <c>MasterNodeManager</c> drives for every create, modify,
/// transfer, restore and delete of an all-events item, subscription teardown included.
/// </remarks>
private readonly ConcurrentDictionary<IEventMonitoredItem, byte> _allEventsItems = new();
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>Gitea #586.</b> 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 <c>AddRootNotifier</c> calls its subscribe with <c>unsubscribe: true</c>), through the
/// root notifier's forward to <c>Server.ReportEvent</c> 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.
/// </para>
/// <para>
/// This override attaches the item to nothing. It records it, and each condition hands its events
/// to the recorded items itself (<see cref="OnConditionReportEvent"/>), 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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;
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// 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 <c>AddRootNotifier</c>, which — an SDK bug — "subscribes"
/// every existing all-events item to a new root notifier with <c>unsubscribe: true</c>. Passed to the
/// base, that lowers <see cref="NodeState.AreEventsMonitored"/> on the new notifier, its children and
/// its forward notifiers — taking back, from every condition materialised under a live item, the
/// unit <see cref="ReconcileAllEventsMonitoring"/> just gave it (and, before #586, a folder
/// subscriber's unit too). Caught by the rebuild leg of the monitored-bookkeeping test.
/// </para>
/// <para>
/// Direct notifier items, including the #562 revalidation pass's attach and detach, go to the base
/// unchanged.
/// </para>
/// </remarks>
protected override ServiceResult SubscribeToEvents(
ServerSystemContext context, NodeState source, IEventMonitoredItem monitoredItem, bool unsubscribe) =>
monitoredItem.MonitoringAllEvents
? ServiceResult.Good
: base.SubscribeToEvents(context, source, monitoredItem, unsubscribe);
/// <summary>
/// The conditions currently carrying this manager's one "a Server-object item is listening" unit of
/// <see cref="NodeState.AreEventsMonitored"/> (Gitea #586 review).
/// </summary>
/// <remarks>Mutated only by <see cref="ReconcileAllEventsMonitoring"/>, under <c>Lock</c>.</remarks>
private readonly HashSet<AlarmConditionState> _allEventsMonitoredConditions =
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
/// <summary>
/// Keeps <see cref="NodeState.AreEventsMonitored"/> raised on every materialised condition while at
/// least one Server-object item exists, and lowered once none does. Call under <c>Lock</c>.
/// </summary>
/// <remarks>
/// <para>
/// The SDK base attached an all-events item to every root notifier through its own
/// <c>SubscribeToEvents</c>, which also calls <c>SetAreEventsMonitored(true, includeChildren)</c>
/// 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: <c>ConditionState</c>
/// reports its own method-driven state changes (and their audit events) only while
/// <c>EventsMonitored()</c> is true. A server whose only subscribers are Server-object items — the
/// common machine-consumer shape — would otherwise report none of them.
/// </para>
/// <para>
/// Held per <b>condition</b>, 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.
/// </para>
/// </remarks>
private void ReconcileAllEventsMonitoring()
{
// The protected SubscribeToEvents override is what stops the SDK taking these units back.
lock (Lock)
{
var desired = _allEventsItems.IsEmpty
? new HashSet<AlarmConditionState>(System.Collections.Generic.ReferenceEqualityComparer.Instance)
: new HashSet<AlarmConditionState>(
_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);
}
}
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// The base is the delegate <c>AddRootNotifier</c> installs on a root notifier: it forwards every
/// event reaching that folder to <c>Server.ReportEvent</c>, 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
/// (<see cref="OnConditionReportEvent"/>), so the forward could only ever deliver an ungated
/// duplicate. Dropped.
/// </para>
/// <para>
/// Only condition events bubble through this manager's notifiers; the audit and model-change
/// events it raises go to <c>Server.ReportEvent</c> directly and are untouched by this.
/// </para>
/// </remarks>
protected override void OnReportEvent(ISystemContext context, NodeState node, IFilterTarget e)
{
}
/// <summary>
/// Delivers one condition event to the Server-object (all-events) subscriptions this manager holds,
/// item by item, through the data-plane ACL (Gitea #586).
/// </summary>
/// <param name="context">The reporting context; a session-bearing one restricts delivery to that session.</param>
/// <param name="node">The condition that reported.</param>
/// <param name="e">The event — the condition's snapshot.</param>
/// <remarks>
/// <para>
/// Installed as each condition's <c>OnReportEvent</c> in <see cref="MaterialiseAlarmCondition"/>,
/// 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.
/// </para>
/// <para>
/// Everything except the ACL decision mirrors the SDK's <c>MonitoredNode2.OnReportEvent</c>, 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.
/// </para>
/// <para>
/// Inert while <c>Security:Acl:Enabled</c> is false — every item receives every condition, as it
/// always has.
/// </para>
/// </remarks>
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);
}
}
/// <summary>
/// The SDK's audit-event guard from <c>MonitoredNode2.OnReportEvent</c>, reproduced exactly: an audit
/// event reaches an item only while the server is auditing, and only over a SignAndEncrypt or
/// https-binary endpoint.
/// </summary>
/// <param name="item">The Server-object event item.</param>
/// <returns><see langword="true"/> when the audit event may be queued to the item.</returns>
/// <remarks>
/// A condition reports its Part 9 method audit events (<c>AuditConditionAcknowledgeEventType</c>,
/// Comment, Enable, Shelving…) through its own <c>ReportEvent</c>, so they pass through
/// <see cref="OnConditionReportEvent"/>. Before #586 the Server-object copy took the root-notifier
/// forward, where <c>ServerInternalData.ReportEvent</c> dropped every audit event unless auditing was
/// on; without this guard they reached every Server-object item regardless (caught in review, flag off).
/// </remarks>
private bool IsAuditDeliverableTo(IEventMonitoredItem item)
{
if (!Server.Auditing) return false;
var endpoint = item.Session?.EndpointDescription;
return endpoint?.SecurityMode == MessageSecurityMode.SignAndEncrypt
|| endpoint?.TransportProfileUri == Profiles.HttpsBinaryTransport;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="condition">The condition.</param>
/// <returns>The candidate scopes; <c>Subscribe</c> on any one of them admits the condition.</returns>
/// <remarks>
/// <para>
/// 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 <c>Equipment</c> grant covers the UNS folder a native condition is wired
/// into, but not the condition, which lives at its RawPath in the raw realm.
/// </para>
/// <para>
/// The condition itself (<c>ConditionId</c> = 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.
/// </para>
/// </remarks>
private List<NodeId> ConditionVisibilityScopes(NodeState condition)
{
var scopes = new List<NodeId> { 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;
}
/// <summary>Whether <paramref name="item"/>'s session may see a condition with the given scopes.</summary>
/// <param name="item">The Server-object event item.</param>
/// <param name="scopes">From <see cref="ConditionVisibilityScopes"/>.</param>
/// <returns><see langword="true"/> when the session holds <c>Subscribe</c> on any scope.</returns>
/// <remarks>
/// 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.
/// <see cref="Security.DataPlaneAuthorizer.IsAllowedCached"/> 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.
/// </remarks>
private bool IsConditionVisibleTo(IEventMonitoredItem item, List<NodeId> 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;
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// 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
/// <see cref="IsConditionVisibleTo"/> 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).
/// </para>
/// <para>
/// 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".
/// </para>
/// </remarks>
public override ServiceResult ConditionRefresh(OperationContext context, IList<IEventMonitoredItem> monitoredItems)
{
if (!AclEnforced) return base.ConditionRefresh(context, monitoredItems);
var direct = new List<IEventMonitoredItem>();
var serverItems = new List<IEventMonitoredItem>();
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<NodeState> notifiers;
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
var events = new List<IFilterTarget>();
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<NodeId> Scopes)>();
var seen = new HashSet<NodeState>(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;
}
/// <summary>The condition a refresh event describes, or null when it names none (withheld: fail closed).</summary>
/// <param name="e">A refresh event.</param>
/// <returns>The condition node.</returns>
private static NodeState? ConditionOf(IFilterTarget e) => e switch
{
InstanceStateSnapshot { Handle: NodeState condition } => condition,
NodeState condition => condition,
_ => null,
};
/// <summary>
/// Whether the condition at <paramref name="conditionNodeId"/> believes it is being listened to
/// (<c>ConditionState.EventsMonitored()</c>) — the gate on the SDK reporting its method-driven state
/// changes. Test observability for the Gitea #586 review.
/// </summary>
/// <param name="conditionNodeId">The condition's NodeId identifier.</param>
/// <param name="realm">The condition's realm.</param>
/// <returns><see langword="true"/> when the condition exists and reports itself monitored.</returns>
internal bool ConditionEventsMonitored(string conditionNodeId, AddressSpaceRealm realm)
{
lock (Lock)
{
return _alarmConditions.TryGetValue(MapKey(realm, conditionNodeId), out var condition)
&& condition.EventsMonitored();
}
}
/// <summary>
/// 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
@@ -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 ───────────────────
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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(); }
}
/// <summary>
/// A Server-object item created <b>before</b> the alarm is deployed is gated the same way, and still
/// receives exactly one copy when granted.
/// </summary>
/// <remarks>
/// 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 <c>AddRootNotifier</c>
/// subscribes existing all-events items with <c>unsubscribe: true</c>), 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.
/// </remarks>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The grant is an <b>Equipment</b> 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.
/// </remarks>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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
/// <c>TickRevalidation()</c> here: a build that only stood items down on the tick would pass a test
/// that ticked.
/// </remarks>
[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(); }
}
/// <summary>
/// With <c>Security:Acl:Enabled</c> false the Server object stays ungated: a session with no grant
/// receives the alarm exactly as a granted one does.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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(); }
}
/// <summary>
/// A <c>ConditionRefresh</c> 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.
/// </summary>
/// <remarks>
/// 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 <c>RefreshEndEvent</c> 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.
/// </remarks>
[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 ────────
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// A condition reports its method audit events (<c>AuditConditionAcknowledgeEventType</c>,
/// <c>AuditConditionCommentEventType</c>) through its own <c>ReportEvent</c>, 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
/// <c>i=8944</c> to every Server-object item.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// The positive control is the next transition: the method calls queue synchronously, so an audit
/// event would be ahead of <c>t2</c> in the same item's queue.
/// </para>
/// </remarks>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <c>ConditionState.EventsMonitored()</c> 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.
/// </remarks>
[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(); }
}
/// <summary>
/// A Server-object item outlives a full address-space rebuild and receives exactly one copy of each
/// transition on the rebuilt condition.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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(); }
}
/// <summary>
/// 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.
/// </summary>
[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(); }
}
/// <summary>
/// With enforcement on, the decision follows the notifier a condition reports through: an equipment
/// grant on <see cref="EqB"/> admits the native condition wired to it and withholds the scripted
/// condition under <see cref="EqA"/>; a cluster-granted session receives both (the positive control).
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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(); }
}
/// <summary>
/// With the flag off, a <c>ConditionRefresh</c> on a Server-object item replays each condition exactly
/// once — not once per notifier it is wired to — bracketed by RefreshStart and RefreshEnd.
/// </summary>
[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 ─────────────────────────────
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>Records which transition messages reached which monitored item.</summary>
/// <summary>Records which transition messages, and which event types, reached which monitored item.</summary>
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<string> 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.
/// </summary>
public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants)
public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants) => Enforce(true, keys, grants);
/// <summary>
/// As <see cref="Enforce(ApiKey[], NodeAcl[])"/>, with <paramref name="enabled"/> standing in for
/// <c>Security:Acl:Enabled</c> — false wires the same grants with enforcement switched off.
/// </summary>
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);
}
/// <summary>A scripted condition under equipment folder <see cref="EqA"/> (UNS realm, no extra notifiers).</summary>
public void MaterialiseScripted() =>
_sink.MaterialiseAlarmCondition(
ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false);
/// <summary>Fires one transition on the scripted condition, carrying <paramref name="message"/>.</summary>
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);
/// <summary>A full address-space rebuild, as a redeploy that is not surgical performs.</summary>
public void Rebuild() => _sink.RebuildAddressSpace();
/// <summary>Whether the native condition reports itself monitored (<c>ConditionState.EventsMonitored()</c>).</summary>
public bool NativeConditionEventsMonitored => Nm.ConditionEventsMonitored(RawAlarmPath, AddressSpaceRealm.Raw);
/// <summary>The native condition's NodeId in the session's raw namespace.</summary>
public NodeId RawConditionNodeId(ISession session) =>
new(RawAlarmPath, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
/// <summary>Fires one genuine condition transition, carrying <paramref name="message"/>.</summary>
public void Transition(string message, bool active) =>
_sink.WriteAlarmCondition(