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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user