fix(acl): per-event visibility for direct folder event items; generation-checked native wiring (#826)

Event items on an equipment / raw device folder were fed by the SDK's MonitoredNode2 fan-out with no ACL
decision, so after a deploy moved a scripted alarm (or dropped the reference projecting a native alarm into
an equipment) the OLD folder's subscribers kept receiving it - EventId included - until the rebuild landed,
indefinitely after a failed apply.

- OtOpcUaNodeManager re-implements INodeManager3 so ValidateEventRolePermissions (the SDK's per-event,
  per-item hook; base not virtual) adds IsNotifierEventVisibleTo: live condition instance, notifier still a
  legitimate notifier of it, and the #586/#808 Server-object rule (IsConditionVisibleTo over
  ConditionVisibilityScopes).
- The SDK memoizes that verdict per (item, event type, SourceNode): InvalidateFolderEventVerdicts runs on the
  authority/authorizer setters, RevalidateMonitoredItems (deploy + timer) and CompleteAddressSpaceApply.
- ConditionRefresh on direct items goes through RefreshDirectItems (base loop + the same decision).
- New generation field TagReferencingEquipment (RawPath -> EquipmentIds, from the artifact's
  UnsTagReferences via AclScopePathResolver.ResolveTagRawPath): a native alarm's wired equipment folder only
  counts (folder delivery AND Server-object scopes) while the current generation still references it.
- Secondary #808 edge: UNS variables wired UNS->Raw Organizes by AddReference are tracked and always placed
  as reference variables (an Area named like its equipment's id no longer narrows them).

Tests: UnsFolderScopeWireTests (moved alarm on a folder item + ConditionRefresh + rebuild; area named like
equipment id), NativeAlarmFolderEventAclWireTests (dropped reference: folder + Server object + refresh +
restore), DeploymentArtifactScopePathTests (projection). Rig/event tests now supply tag references.
This commit is contained in:
Joseph Doherty
2026-11-11 01:39:06 -05:00
parent 5bbe1e3c7a
commit 8e1e73f93c
13 changed files with 666 additions and 37 deletions
@@ -231,6 +231,17 @@ public sealed class AclScopePathResolver
}
}
/// <summary>
/// The RawPath of raw tag <paramref name="tagId"/> — the identifier its raw NodeId (and a native alarm on it, as
/// <c>ConditionId</c>) carries — or null when its chain does not resolve (Gitea #826).
/// </summary>
/// <param name="tagId">The raw tag's logical id.</param>
/// <returns>The RawPath, or null.</returns>
public string? ResolveTagRawPath(string tagId) =>
!string.IsNullOrEmpty(tagId) && _tags.TryGetValue(tagId, out var tag)
? _rawPaths.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name)
: null;
private IReadOnlyList<NodeAclPath> ResolveTag(string tagId)
{
var paths = new List<NodeAclPath>();
@@ -37,7 +37,8 @@ public sealed class DataPlaneAuthoritySnapshot
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal,
IReadOnlyDictionary<string, NodeAclPath> equipmentScopePaths,
IReadOnlyDictionary<string, string> scriptedAlarmOwners,
IReadOnlyDictionary<string, UnsFolderScope> unsFolderScopes)
IReadOnlyDictionary<string, UnsFolderScope> unsFolderScopes,
IReadOnlyDictionary<string, IReadOnlySet<string>> tagReferencingEquipment)
{
ClusterId = clusterId;
RevisionHash = revisionHash;
@@ -51,6 +52,7 @@ public sealed class DataPlaneAuthoritySnapshot
EquipmentScopePaths = equipmentScopePaths;
ScriptedAlarmOwners = scriptedAlarmOwners;
UnsFolderScopes = unsFolderScopes;
TagReferencingEquipment = tagReferencingEquipment;
}
private readonly IReadOnlyDictionary<string, ApiKey> _keysById;
@@ -160,6 +162,31 @@ public sealed class DataPlaneAuthoritySnapshot
/// </remarks>
public IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes { get; }
/// <summary>
/// <c>RawPath → the EquipmentIds whose UnsTagReference projects that raw tag</c>, as THIS generation deploys
/// them (Gitea #826). Keyed <c>Ordinal</c> (a RawPath is a NodeId identifier); the id sets are
/// <c>OrdinalIgnoreCase</c> (identifier rule).
/// </summary>
/// <remarks>
/// A native alarm materialises once at its raw tag and is wired as an extra event notifier into every
/// referencing equipment's folder. The generation is installed BEFORE its address space is applied, and an
/// apply that throws keeps the old one, so a dropped reference leaves the alarm wired into an equipment folder
/// this generation no longer projects it into. The node manager admits a wired folder only while this map
/// agrees. An entry confers nothing; an absent RawPath means no equipment references it.
/// </remarks>
public IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferencingEquipment { get; }
/// <summary>Whether this generation projects the raw tag at <paramref name="rawPath"/> into
/// <paramref name="equipmentId"/> (Gitea #826).</summary>
/// <param name="rawPath">The raw tag's RawPath (a native condition's <c>ConditionId</c>).</param>
/// <param name="equipmentId">The equipment (its folder's <c>s=</c> identifier).</param>
/// <returns><see langword="true"/> when a reference of this generation connects the two.</returns>
public bool IsTagReferencedBy(string rawPath, string equipmentId) =>
!string.IsNullOrEmpty(rawPath)
&& !string.IsNullOrEmpty(equipmentId)
&& TagReferencingEquipment.TryGetValue(rawPath, out var equipment)
&& equipment.Contains(equipmentId);
/// <summary>
/// The ACL path WITHIN the UNS realm (realm segment stripped) of the UNS folder <paramref name="folderId"/>,
/// provided the live folder's ancestry <paramref name="liveIdChain"/> is exactly the one this generation
@@ -279,6 +306,8 @@ public sealed class DataPlaneAuthoritySnapshot
/// (see <see cref="ScriptedAlarmOwners"/>); null ⇒ none.</param>
/// <param name="unsFolderScopes">Every UNS folder's path + id chain (see <see cref="UnsFolderScopes"/>);
/// null ⇒ none, which denies every UNS folder-scoped node under enforcement.</param>
/// <param name="tagReferencingEquipment"><c>RawPath → EquipmentIds</c> (see <see cref="TagReferencingEquipment"/>);
/// null ⇒ none, so no native alarm reaches an equipment folder's event items under enforcement.</param>
/// <returns>The built snapshot.</returns>
public static DataPlaneAuthoritySnapshot Create(
string? clusterId,
@@ -290,7 +319,8 @@ public sealed class DataPlaneAuthoritySnapshot
IReadOnlyList<PermissionTrieBuildDiagnostic>? droppedGrants = null,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null,
IReadOnlyDictionary<string, IReadOnlySet<string>>? tagReferencingEquipment = null)
{
ArgumentNullException.ThrowIfNull(revisionHash);
ArgumentNullException.ThrowIfNull(keys);
@@ -359,9 +389,21 @@ public sealed class DataPlaneAuthoritySnapshot
}
}
var referencing = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal);
if (tagReferencingEquipment is not null)
{
foreach (var (rawPath, equipmentIds) in tagReferencingEquipment)
{
if (string.IsNullOrEmpty(rawPath) || equipmentIds is null) continue;
// Re-keyed under the identifier comparer, as the maps above are.
referencing[rawPath] = new HashSet<string>(
equipmentIds.Where(id => !string.IsNullOrWhiteSpace(id)), StringComparer.OrdinalIgnoreCase);
}
}
return new DataPlaneAuthoritySnapshot(
clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen,
equipment, owners, folders);
equipment, owners, folders, referencing);
}
}
@@ -446,6 +488,9 @@ public interface IDataPlaneAuthoritySink
/// them (Gitea #797 review). Null ⇒ none, which denies every scripted-condition command under enforcement.</param>
/// <param name="unsFolderScopes">Every UNS Area/Line/Equipment folder's path + id chain from the same
/// artifact (Gitea #808). Null ⇒ none, which denies every UNS folder-scoped node under enforcement.</param>
/// <param name="tagReferencingEquipment"><c>RawPath → EquipmentIds</c> from the same artifact's
/// UnsTagReferences (Gitea #826). Null ⇒ none, so no native alarm reaches an equipment folder's event items
/// under enforcement.</param>
/// <returns>The installed snapshot.</returns>
DataPlaneAuthoritySnapshot Apply(
string? clusterId,
@@ -455,7 +500,8 @@ public interface IDataPlaneAuthoritySink
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null);
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null,
IReadOnlyDictionary<string, IReadOnlySet<string>>? tagReferencingEquipment = null);
}
/// <summary>
@@ -506,7 +552,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null,
IReadOnlyDictionary<string, IReadOnlySet<string>>? tagReferencingEquipment = null)
{
ArgumentNullException.ThrowIfNull(revisionHash);
@@ -540,7 +587,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
published = DataPlaneAuthoritySnapshot.Create(
clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths,
scriptedAlarmOwners, unsFolderScopes);
scriptedAlarmOwners, unsFolderScopes, tagReferencingEquipment);
Volatile.Write(ref _current, published);
}
@@ -39,7 +39,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// the legacy <c>EquipmentNodeWalker</c> server-side integration was retired in favour of the
/// (composer → applier → sink → node-manager) chain.
/// </summary>
public sealed class OtOpcUaNodeManager : CustomNodeManager2
// INodeManager3 is RE-IMPLEMENTED (Gitea #826): the SDK's per-folder event delivery (MonitoredNode2) asks
// INodeManager3.ValidateEventRolePermissions for every event and item, and the base implementation is not virtual, so
// re-implementing the interface is the only way to put the per-event ACL decision on that path. Every other member
// maps to the inherited implementation exactly as before.
public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
{
/// <summary>v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are
/// registered with the SDK so nodes can be minted in either. <see cref="V3NodeIds.RawNamespaceUri"/> is
@@ -57,6 +61,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _variables = new();
/// <summary>
/// UNS variables PROVEN to be UNS reference variables — the source of a forward <c>Organizes</c> edge into the raw
/// namespace (<see cref="AddReference"/>), which only the applier's reference pass wires. Gitea #826: the
/// reference-vs-folder-owned decision in <see cref="ResolveUnsAclSubject"/> no longer rests on the id rule alone
/// (an Area named exactly like its equipment's id made that equipment's references look folder-owned). Holds the
/// instance, so a stale entry for a replaced node never matches; cleared with <see cref="_variables"/>.
/// </summary>
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _unsReferenceVariables = new();
private readonly ConcurrentDictionary<RealmKey, FolderState> _folders = new();
private readonly ConcurrentDictionary<RealmKey, AlarmConditionState> _alarmConditions = new();
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> node ids materialised as NATIVE
@@ -567,6 +580,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// walking its own dead item map on each deploy.
previous.Changed -= OnAuthorityChanged;
next.Changed += OnAuthorityChanged;
InvalidateFolderEventVerdicts();
}
}
@@ -641,6 +655,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// which only ever DENIES narrow grants, so a miss would read as working enforcement).
if (value is not null) value.UnsSubjectResolver = ResolveUnsAclSubject;
_authorizer = value;
InvalidateFolderEventVerdicts();
}
}
@@ -3604,6 +3619,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false);
if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false);
// Gitea #826: a UNS variable organizing a raw node IS a UNS reference variable — recorded so the ACL
// placement can say so explicitly instead of inferring it from the id's first segment.
if (sourceRealm == AddressSpaceRealm.Uns && targetRealm == AddressSpaceRealm.Raw
&& refType == ReferenceTypeIds.Organizes
&& source is BaseDataVariableState variable
&& _variables.TryGetValue(sourceKey, out var live) && ReferenceEquals(live, variable))
{
_unsReferenceVariables[sourceKey] = variable;
}
}
}
@@ -3851,6 +3876,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
foreach (var v in _variables.Values) PredefinedNodes?.Remove(v.NodeId);
_variables.Clear();
_unsReferenceVariables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
@@ -3898,6 +3924,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <inheritdoc cref="IOpcUaAddressSpaceSink.CompleteAddressSpaceApply"/>
public void CompleteAddressSpaceApply()
{
// Gitea #826: the apply moved conditions, wiring and folders, and the SDK memoizes each folder item's per-event
// verdict per (item, event type, condition) until told otherwise — forget them before the restored reports
// below go out, so the next event on every folder item is decided against the address space as applied.
InvalidateFolderEventVerdicts();
int reported = 0, discarded;
lock (Lock)
{
@@ -3938,6 +3968,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
// teardown inside RebuildAddressSpace, scoped to this one id.
if (!_variables.TryRemove(key, out var variable)) return false;
_unsReferenceVariables.TryRemove(key, out _);
// Conservatively invalidate every outstanding history cursor on topology removal.
Interlocked.Increment(ref _historyContinuationEpoch);
UnindexChild(key);
@@ -4105,6 +4136,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return;
}
_unsReferenceVariables.TryRemove(key, out _);
if (_variables.TryRemove(key, out var v))
{
UnindexChild(key);
@@ -4301,6 +4333,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// (s=Area/Line/Equipment/EffectiveName): authorized at its split path, but only while that path is where the
// current generation places the equipment it hangs under (review of #808 — a moved or renamed equipment's
// variables keep their OLD-name ids until re-pointed, and the old names' grants must not keep them).
// Gitea #826: a variable the reference pass wired to its raw node is a reference variable whatever its id
// spells — so an Area named exactly like its equipment's id no longer turns that equipment's references
// folder-owned (which only ever narrowed: it lost their Tag-grant match). Untracked nodes keep the id rule.
if (node is BaseDataVariableState v
&& _unsReferenceVariables.TryGetValue(key, out var reference) && ReferenceEquals(reference, v))
{
return UnsAclSubject.ReferenceVariable(chain);
}
return firstSegment.SequenceEqual(owning.NodeId.IdentifierAsString)
? UnsAclSubject.Folder(chain, isFolderItself: ReferenceEquals(node, owning))
: UnsAclSubject.ReferenceVariable(chain);
@@ -5478,13 +5519,174 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (_alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring)
&& ReferenceEquals(wiring.Alarm, condition))
{
scopes.AddRange(wiring.Folders.Select(f => f.NodeId));
// Gitea #826: only a folder THIS generation still projects the alarm's raw tag into. A dropped
// reference leaves the wiring in place until the rebuild lands (or indefinitely after a failed apply),
// and that folder's grant must stop admitting the alarm the moment the generation drops it.
var authority = DataPlaneAuthority.Current;
foreach (var folder in wiring.Folders)
{
if (IsWiredFolderCurrent(authority, condition, folder)) scopes.Add(folder.NodeId);
}
}
}
return scopes;
}
/// <summary>
/// Whether <paramref name="folder"/>, wired as an extra notifier of the native <paramref name="condition"/>, is
/// one the current generation still projects the condition's raw tag into (Gitea #826).
/// </summary>
/// <param name="authority">The current generation.</param>
/// <param name="condition">A native condition (<c>ConditionId</c> = RawPath).</param>
/// <param name="folder">A folder wired for it.</param>
/// <returns><see langword="true"/> when a reference of the generation connects them.</returns>
/// <remarks>Only UNS equipment folders are ever wired; anything else is not a current wiring (fail closed).</remarks>
private bool IsWiredFolderCurrent(DataPlaneAuthoritySnapshot authority, NodeState condition, FolderState folder) =>
folder.NodeId.NamespaceIndex == NamespaceIndexForRealm(AddressSpaceRealm.Uns)
&& folder.NodeId.TryGetValue(out string equipmentId)
&& condition.NodeId.TryGetValue(out string rawPath)
&& authority.IsTagReferencedBy(rawPath, equipmentId);
/// <summary>
/// The per-event decision for an event item on one of this manager's own notifiers (an equipment folder, a raw
/// device folder) — the SDK fan-out path the Server-object filtering (#586) does not reach (Gitea #826).
/// </summary>
/// <param name="item">The event item; its manager handle names the notifier it is attached to.</param>
/// <param name="e">The event as the SDK delivers it (a condition snapshot, or a cloned audit event).</param>
/// <returns><see langword="true"/> when the item's session may receive it through that notifier.</returns>
/// <remarks>
/// <para>
/// Three conditions, all against the CURRENT state: the event's condition is the LIVE instance (a torn-down
/// one denies); the notifier is still a legitimate notifier of it — the condition itself, a node it hangs
/// beneath, or a wired folder the current generation still projects its raw tag into; and the condition is
/// visible to the session under exactly the Server-object rule (<see cref="IsConditionVisibleTo"/> over
/// <see cref="ConditionVisibilityScopes"/>), so a moved scripted alarm is withheld from its old folder just
/// as it is from the Server object.
/// </para>
/// <para>
/// An event that names no live condition of this manager is withheld (fail closed): only condition events
/// and the condition's own method audits reach these notifiers.
/// </para>
/// <para>
/// Runs on the SDK's per-node delivery task, where the inherited role-permission check already takes
/// <c>Lock</c> (via <c>GetNodeMetadata</c>); the scope resolution here takes it the same way.
/// </para>
/// </remarks>
private bool IsNotifierEventVisibleTo(IEventMonitoredItem item, IFilterTarget e)
{
if (item.ManagerHandle is not NodeHandle { Node: { } notifier }) return false;
if (EventConditionOf(e) is not { } condition) return false;
if (!_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live) || !ReferenceEquals(live, condition))
return false;
if (!IsLiveNotifierOf(live, notifier)) return false;
return IsConditionVisibleTo(item, ConditionVisibilityScopes(live));
}
/// <summary>The condition an event delivered through a notifier is about, or null.</summary>
/// <param name="e">The event: a condition's snapshot, the condition itself, or an event whose SourceNode names one.</param>
/// <returns>The condition state, or null.</returns>
private ConditionState? EventConditionOf(IFilterTarget e)
{
var node = ConditionOf(e);
if (node is ConditionState condition) return condition;
// A condition's Part 9 method audit (cloned by the SDK on its way through a notifier): its SourceNode is the
// condition.
return node is BaseEventState { SourceNode.Value: { IsNull: false } source }
&& OwnsNamespace(source)
&& _alarmConditions.TryGetValue(MapKey(source), out var named)
? named
: null;
}
/// <summary>Whether <paramref name="notifier"/> is, right now, a node the condition's events legitimately bubble
/// through (Gitea #826).</summary>
/// <param name="condition">The live condition.</param>
/// <param name="notifier">The node the event item is attached to.</param>
/// <returns><see langword="true"/> for the condition itself, a node above it, or a wired folder the current
/// generation still projects it into.</returns>
private bool IsLiveNotifierOf(AlarmConditionState condition, NodeState notifier)
{
if (ReferenceEquals(notifier, condition)) return true;
lock (Lock)
{
for (var ancestor = (condition as BaseInstanceState)?.Parent;
ancestor is not null;
ancestor = (ancestor as BaseInstanceState)?.Parent)
{
if (ReferenceEquals(ancestor, notifier)) return true;
}
return notifier is FolderState folder
&& _alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring)
&& ReferenceEquals(wiring.Alarm, condition)
&& wiring.Folders.Contains(folder)
&& IsWiredFolderCurrent(DataPlaneAuthority.Current, condition, folder);
}
}
/// <summary>
/// Validates whether an event item may receive an event: the SDK's role-permission check, and — with enforcement
/// on, for an item on one of this manager's own nodes — <see cref="IsNotifierEventVisibleTo"/> (Gitea #826).
/// </summary>
/// <param name="monitoredItem">The event item.</param>
/// <param name="filterTarget">The event.</param>
/// <returns>Good to deliver; otherwise the veto.</returns>
/// <remarks>
/// <para>
/// The SDK's <c>MonitoredNode2</c> asks this, per event and per item, before it queues an event to an item
/// attached to one of this manager's notifiers. The Server-object path (#586) and ConditionRefresh decide
/// the same question themselves, so a folder's subscribers stop receiving a moved alarm (or a native alarm
/// whose reference was dropped) the moment the generation says so, not when the rebuild lands.
/// </para>
/// <para>
/// ⚠️ The SDK MEMOIZES the verdict per <c>(item, event type, SourceNode)</c> until the monitored node's
/// permission generation moves, so <see cref="InvalidateFolderEventVerdicts"/> must run whenever the inputs
/// change: a new authority generation or authorizer, the revalidation tick, and the end of every apply. A
/// condition's SourceNode is the condition itself, so a verdict is never shared between two conditions.
/// </para>
/// </remarks>
ServiceResult INodeManager3.ValidateEventRolePermissions(IEventMonitoredItem monitoredItem, IFilterTarget filterTarget)
{
var result = ValidateEventRolePermissions(monitoredItem, filterTarget);
if (ServiceResult.IsBad(result) || !AclEnforced || !OwnsNamespace(monitoredItem.NodeId)) return result;
return IsNotifierEventVisibleTo(monitoredItem, filterTarget) ? result : FolderEventDenied;
}
/// <summary>The verdict a withheld folder event carries back to the SDK (never reaches the client).</summary>
private static readonly ServiceResult FolderEventDenied = new(StatusCodes.BadUserAccessDenied);
/// <summary>
/// Drops every per-event verdict the SDK has memoized for event items on this manager's notifiers (Gitea #826),
/// so the next event on each is decided afresh by <see cref="IsNotifierEventVisibleTo"/>.
/// </summary>
/// <remarks>
/// <c>MonitoredNode2.InvalidatePermissionCacheForSession</c> is the SDK's only public lever: it advances that
/// node's permission generation (a verdict computed concurrently under the old one is recomputed, not stored)
/// and drops the session's entries. Cost is one call per (notifier, session) pair — never per condition. The
/// node list is copied under <c>Lock</c>; the invalidation itself takes no lock of ours.
/// </remarks>
private void InvalidateFolderEventVerdicts()
{
if (_root is null) return;
List<MonitoredNode2> nodes;
lock (Lock) nodes = MonitoredNodes.Values.Where(n => n.EventMonitoredItems is { IsEmpty: false }).ToList();
foreach (var node in nodes)
{
var sessions = new HashSet<NodeId>();
foreach (var item in node.EventMonitoredItems.Values)
{
if (item?.Session?.Id is { IsNull: false } id) sessions.Add(id);
}
foreach (var session in sessions) node.InvalidatePermissionCacheForSession(session);
}
}
/// <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>
@@ -5530,10 +5732,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
(item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item);
}
if (direct.Count > 0) base.ConditionRefresh(context, direct);
var systemContext = SystemContext.Copy(context);
if (direct.Count > 0) RefreshDirectItems(systemContext, direct);
if (serverItems.Count == 0) return ServiceResult.Good;
var systemContext = SystemContext.Copy(context);
List<NodeState> notifiers;
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
@@ -5562,6 +5764,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return ServiceResult.Good;
}
/// <summary>
/// ConditionRefresh for event items on this manager's own notifiers, under enforcement (Gitea #826): the SDK
/// base's loop exactly — an item no longer attached (stood down by revalidation, or tombstoned) is not
/// refreshed — plus the per-event decision live delivery makes (<see cref="IsNotifierEventVisibleTo"/>), so a
/// refresh cannot replay to a folder's subscriber an alarm that folder's live events withhold.
/// </summary>
/// <param name="systemContext">The refresh's system context.</param>
/// <param name="items">The direct (non-Server-object) items being refreshed.</param>
private void RefreshDirectItems(ServerSystemContext systemContext, List<IEventMonitoredItem> items)
{
foreach (var item in items)
{
NodeState? notifier;
lock (Lock)
{
if (!MonitoredItems.ContainsKey(item.Id)) continue;
notifier = (item.ManagerHandle as NodeHandle)?.Node;
}
if (notifier is null) continue;
var events = new List<IFilterTarget>();
notifier.ConditionRefresh(systemContext, events, true);
foreach (var e in events)
{
if (!IsNotifierEventVisibleTo(item, e)) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
item.QueueEvent(e);
}
}
}
/// <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>
@@ -5663,7 +5897,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </remarks>
public void RevalidateMonitoredItems()
{
if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return;
if (!AclEnforced) return;
// Gitea #826: both triggers (a deploy, the clock) also reach the per-event decision on folder event items, whose
// verdicts the SDK memoizes — a deploy that moves an alarm must not keep serving its old folder's allow.
InvalidateFolderEventVerdicts();
if (_liveItems.IsEmpty && _eventItems.IsEmpty) return;
var now = UtcNow;
var stoodDown = 0;
@@ -93,6 +93,16 @@ public sealed record DataPlaneAuthorityProjection(
public IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes { get; init; } =
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// <c>RawPath → EquipmentIds</c> from the artifact's <c>UnsTagReferences</c> (Gitea #826): which equipment
/// folders this generation projects each raw tag into — the folders a native alarm on that tag may be
/// delivered through. RawPaths come from the SAME <see cref="AclScopePathResolver"/> as
/// <see cref="ScopePaths"/>, so they equal the native conditions' <c>ConditionId</c>s by construction. Empty
/// when the artifact carries no grants.
/// </summary>
public IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferencingEquipment { get; init; } =
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal);
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
public static readonly DataPlaneAuthorityProjection Empty =
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
@@ -938,7 +948,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
grants.Add(acl);
}
var (scopePaths, equipmentScopePaths, unsFolderScopes) = BuildAclScopePaths(root, grants);
var (scopePaths, equipmentScopePaths, unsFolderScopes, tagReferencingEquipment) = BuildAclScopePaths(root, grants);
// Every scripted alarm's owner, from the rows the address space is composed from. Read regardless of
// the grant count: it is a topology fact, cheap, and an absent map only ever denies.
var scriptedAlarmOwners = new Dictionary<string, string>(StringComparer.Ordinal);
@@ -955,6 +965,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
EquipmentScopePaths = equipmentScopePaths,
ScriptedAlarmOwners = scriptedAlarmOwners,
UnsFolderScopes = unsFolderScopes,
TagReferencingEquipment = tagReferencingEquipment,
};
}
@@ -964,10 +975,12 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
/// path and the RawPath that forms a node's NodeId cannot drift apart.
/// </summary>
/// <returns>The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup,
/// Gitea #797) and every UNS folder's path + id chain (Gitea #808) from the same resolver.</returns>
/// Gitea #797), every UNS folder's path + id chain (Gitea #808) and every referenced raw tag's referencing
/// equipment (Gitea #826) from the same resolver.</returns>
private static (IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths,
IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths,
IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes) BuildAclScopePaths(
IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes,
IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferencingEquipment) BuildAclScopePaths(
JsonElement root,
IReadOnlyList<NodeAcl> grants)
{
@@ -975,7 +988,8 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
{
return (new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(),
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase),
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase));
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase),
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal));
}
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
@@ -1077,7 +1091,20 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path;
}
return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment));
// Which equipment folders each raw tag is projected into (Gitea #826) — the folders a native alarm on that tag
// may legitimately be delivered through. Keyed on the RawPath the SAME resolver builds, which is the native
// condition's ConditionId.
var referencing = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var reference in references)
{
if (resolver.ResolveTagRawPath(reference.TagId) is not { } rawPath) continue;
if (!referencing.TryGetValue(rawPath, out var set))
referencing[rawPath] = set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
set.Add(reference.EquipmentId);
}
return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment),
referencing.ToDictionary(kv => kv.Key, kv => (IReadOnlySet<string>)kv.Value, StringComparer.Ordinal));
}
/// <summary>
@@ -1426,7 +1426,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths,
projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes);
projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes,
projection.TagReferencingEquipment);
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",