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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+655
-7
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user