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);
}