Merge fix/826-folder-event-acl

This commit is contained in:
Joseph Doherty
2026-11-11 02:25:44 -05:00
14 changed files with 984 additions and 37 deletions
@@ -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})",