Merge fix/826-folder-event-acl
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,7 +654,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// there is no second wiring that could silently not run (an unattached resolver splits opaque ids,
|
||||
// which only ever DENIES narrow grants, so a miss would read as working enforcement).
|
||||
if (value is not null) value.UnsSubjectResolver = ResolveUnsAclSubject;
|
||||
var wasEnforced = AclEnforced;
|
||||
_authorizer = value;
|
||||
// Across an on/off transition the cached verdicts were decided under the other mode.
|
||||
InvalidateFolderEventVerdicts(force: wasEnforced || AclEnforced);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3604,6 +3621,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 +3878,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 +3926,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 +3970,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 +4138,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return;
|
||||
}
|
||||
|
||||
_unsReferenceVariables.TryRemove(key, out _);
|
||||
if (_variables.TryRemove(key, out var v))
|
||||
{
|
||||
UnindexChild(key);
|
||||
@@ -4301,6 +4335,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 +5521,236 @@ 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: a live condition holds the event's condition NodeId;
|
||||
/// the notifier is still a legitimate notifier of THAT live instance — 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>
|
||||
/// ⚠️ <b>Decided on the live instance, never on the event's own (review of #826).</b> The SDK caches the
|
||||
/// verdict per <c>(item, event type, SourceNode)</c> — no instance identity — so a verdict must be a
|
||||
/// function of the condition's NodeId and the current state alone. Denying an event because it came from a
|
||||
/// REPLACED instance (a surgical remove + re-materialise under the same NodeId, the old instance's last
|
||||
/// event still in the SDK's queue) cached a deny the new instance then inherited until the next
|
||||
/// invalidation. A late event from a replaced instance is therefore delivered exactly when the live one's
|
||||
/// would be.
|
||||
/// </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)) 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>
|
||||
/// <param name="force">Run even while enforcement is off — the authorizer setter's on/off transition, where the
|
||||
/// cached verdicts were decided under the other mode.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Node-wide, session-agnostic.</b> Each notifier's cache is cleared whole through the SDK's own private
|
||||
/// <c>MonitoredNode2.InvalidatePermissionCaches</c> (what the SDK runs when namespace default permissions
|
||||
/// change, and on <c>Rebind</c>): it advances the node's permission generation — a verdict computed
|
||||
/// concurrently under the old one is recomputed, never stored — and clears the cache in one O(cache) pass.
|
||||
/// The public <c>InvalidatePermissionCacheForSession</c> is NOT sufficient (review of #826): it reaches only
|
||||
/// items whose <c>Session</c> is set, and an ORPHANED subscription's items (session closed without deleting
|
||||
/// its subscriptions) have none — they kept a stale allow through a deploy, queued the moved alarm's events
|
||||
/// and delivered them on <c>TransferSubscriptions</c>. It also rescanned the whole cache once per item
|
||||
/// (items² × conditions per folder).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The private method is resolved once by reflection against the pinned SDK and pinned by
|
||||
/// <c>FolderEventVerdictInvalidationGuardTests</c>. Should a future SDK drop it, this falls back to the
|
||||
/// supported route: the per-session drop for items with a session, and a detach + re-attach of each
|
||||
/// session-less item on its node (<c>Remove</c> drops that item's entries), which can miss an event racing
|
||||
/// the swap — the reason it is only the fallback.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Skipped while enforcement is off (<paramref name="force"/> aside): the verdicts are then the SDK's own
|
||||
/// role check, which nothing here changes. The node list is copied under <c>Lock</c>; the invalidation
|
||||
/// takes no lock of ours.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void InvalidateFolderEventVerdicts(bool force = false)
|
||||
{
|
||||
if (_root is null || (!force && !AclEnforced)) return;
|
||||
|
||||
List<MonitoredNode2> nodes;
|
||||
lock (Lock) nodes = MonitoredNodes.Values.Where(n => n.EventMonitoredItems is { IsEmpty: false }).ToList();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
Interlocked.Increment(ref _folderEventVerdictInvalidations);
|
||||
if (InvalidateNodePermissionCaches is { } invalidate)
|
||||
{
|
||||
invalidate(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
var sessions = new HashSet<NodeId>();
|
||||
var sessionless = new List<IEventMonitoredItem>();
|
||||
foreach (var item in node.EventMonitoredItems.Values)
|
||||
{
|
||||
if (item is null) continue;
|
||||
if (item.Session?.Id is { IsNull: false } id) sessions.Add(id);
|
||||
else sessionless.Add(item);
|
||||
}
|
||||
|
||||
foreach (var session in sessions) node.InvalidatePermissionCacheForSession(session);
|
||||
foreach (var item in sessionless)
|
||||
{
|
||||
node.Remove(item);
|
||||
node.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Notifier caches <see cref="InvalidateFolderEventVerdicts"/> has invalidated since start. Test
|
||||
/// observability (it must not run while enforcement is off).</summary>
|
||||
internal long FolderEventVerdictInvalidations => Interlocked.Read(ref _folderEventVerdictInvalidations);
|
||||
|
||||
private long _folderEventVerdictInvalidations;
|
||||
|
||||
/// <summary>
|
||||
/// The SDK's private node-wide cache invalidation (<c>MonitoredNode2.InvalidatePermissionCaches</c>), or null
|
||||
/// when the SDK no longer has it — see <see cref="InvalidateFolderEventVerdicts"/>.
|
||||
/// </summary>
|
||||
internal static readonly Action<MonitoredNode2>? InvalidateNodePermissionCaches =
|
||||
typeof(MonitoredNode2).GetMethod(
|
||||
"InvalidatePermissionCaches",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,
|
||||
Type.EmptyTypes) is { ReturnType: var returns } method && returns == typeof(void)
|
||||
? method.CreateDelegate<Action<MonitoredNode2>>()
|
||||
: null;
|
||||
|
||||
/// <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 +5796,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 +5828,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 +5961,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 ALARM-BEARING raw tag (<c>TagConfig.alarm</c>) 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);
|
||||
@@ -1013,12 +1027,17 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
}
|
||||
|
||||
var tags = new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(StringComparer.Ordinal);
|
||||
// TagConfig kept unparsed: only a REFERENCED tag's is parsed below (Gitea #826 — alarm-bearing ones only).
|
||||
var tagConfigs = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "Tags"))
|
||||
{
|
||||
var id = ReadString(el, "TagId");
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
if (id is null || deviceId is null) continue;
|
||||
tags[id] = (deviceId, ReadNullableString(el, "TagGroupId"), ReadString(el, "Name") ?? id);
|
||||
tagConfigs[id] = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||
? tcEl.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
var areas = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
@@ -1077,7 +1096,29 @@ 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 ALARM-BEARING raw tag is projected into (Gitea #826) — the folders a native alarm
|
||||
// on that tag may legitimately be delivered through; only native conditions are ever looked up, so plain value
|
||||
// tags are left out. Keyed on the RawPath the SAME resolver builds, which is the native condition's ConditionId.
|
||||
// Alarm-bearing is decided by the SAME TagConfigIntent the composer materialises native conditions from.
|
||||
var alarmBearing = new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||
var referencing = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
foreach (var reference in references)
|
||||
{
|
||||
if (!alarmBearing.TryGetValue(reference.TagId, out var isAlarm))
|
||||
{
|
||||
isAlarm = TagConfigIntent.Parse(tagConfigs.GetValueOrDefault(reference.TagId)).Alarm is not null;
|
||||
alarmBearing[reference.TagId] = isAlarm;
|
||||
}
|
||||
|
||||
if (!isAlarm) continue;
|
||||
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})",
|
||||
|
||||
Reference in New Issue
Block a user