fix(acl): filter Server-object (i=2253) event subscriptions per event through the data-plane ACL (#586)

Server-object (all-events) items bypassed the ACL entirely: the SDK attached them to every root
notifier's fan-out via SubscribeToAllEvents, and a root notifier with no direct subscriber forwarded
to the core Server object, which this node manager never sees.

- SubscribeToAllEvents override records the item and attaches it to nothing.
- Each condition's own OnReportEvent (fires once per event) 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; otherwise mirrors MonitoredNode2.OnReportEvent.
- The root-notifier forward to the core Server object (OnReportEvent override) is dropped.
- ConditionRefresh on an i=2253 item is filtered the same way (once per condition).
- Revalidation is implicit: every event re-asks the authorizer (per-generation cache, key expiry and
  revocation re-checked per call). Inert with Security:Acl:Enabled=false.

Over-the-wire tests: no-grant vs granted (positive control), created-before-deploy, revoke/restore by
deployment, key expiry with no tick, refresh, and flag-off. Falsified: disabling the filter, or
restoring the forward, turns the enforcement tests red.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 23:37:47 -04:00
parent 35152fcaf7
commit 75e559f6d8
5 changed files with 655 additions and 30 deletions
+11 -3
View File
@@ -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
+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
+35 -10
View File
@@ -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
@@ -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
/// <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
@@ -3376,14 +3385,254 @@ 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)
{
if (unsubscribe) _allEventsItems.TryRemove(monitoredItem, out _);
else _allEventsItems[monitoredItem] = 0;
return ServiceResult.Good;
}
/// <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 (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 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), and each condition
/// is replayed once rather than once per notifier it is wired to.
/// </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>
/// 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
@@ -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 ───────────────────
/// <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(); }
}
// ───────────────────────────── 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");
}
/// <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 HashSet<(uint Handle, NodeId Type)> _types = [];
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> 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.
/// </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(