fix(acl): authorize scripted alarm commands at their owning equipment's scope (#797)

Scripted condition NodeIds are opaque ScriptedAlarmIds, so splitting them as a
topology path could never match an Equipment grant: a narrow equipment grant was
denied while a Cluster grant passed.

- OtOpcUaNodeManager binds each LIVE scripted condition instance to the
  EquipmentId it was materialised under (only when it hangs under that folder);
  dropped on remove / drop-recreate / equipment-subtree removal / rebuild; a
  same-id re-materialise under another equipment re-parents and re-binds.
- DataPlaneAuthoritySnapshot carries EquipmentScopePaths (EquipmentId ->
  [uns, Area, Line, Equipment]) decoded from the artifact by the same
  AclScopePathResolver call that plants Equipment grants.
- DataPlaneAuthorizer.AuthorizeScriptedCondition re-reads key, grant and
  generation per call (uncached) and evaluates at the owner path; unbound,
  replaced or unplaceable owners deny under every grant. No id-split or
  realm-root fallback.
- Native conditions (RawPath) and the ACL-off role gate are unchanged.
  NodeIds and AlarmCommand.AlarmId are unchanged.

Sdk2OpaqueScriptedScopeBaselineTests deliberately flipped: it pinned the denial
and now pins owner-grant allow / sibling deny / cluster allow. Follow-up #808
(folder ids + Server-object event visibility for narrow UNS grants).
This commit is contained in:
Joseph Doherty
2026-11-10 21:23:57 -05:00
parent b36e688f0a
commit 3d1b86b451
16 changed files with 1511 additions and 37 deletions
+16
View File
@@ -561,6 +561,22 @@ the seams consult. Same dark-switch discipline as `MeshTransport`, `ConfigSource
| Alarm Acknowledge / AddComment / Enable / Disable | role `AlarmAck` | `AlarmAcknowledge` on the condition's scope |
| Alarm Confirm | role `AlarmAck` | `AlarmConfirm` |
| Alarm OneShotShelve / TimedShelve / Unshelve | role `AlarmAck` | `AlarmShelve` |
**Whose scope a condition's command is decided at (Gitea #797).** A *native* condition's id is its raw tag's
RawPath, so its commands are decided on that path (a `Tag` / `RawDevice` / … grant covering the raw tag), as
before. A *scripted* condition's id is its opaque, operator-authored `ScriptedAlarmId` (e.g. `scripted-eqa-hi`)
and is **never** split into a scope: `OtOpcUaNodeManager` binds each LIVE scripted condition instance to the
`EquipmentId` it was materialised under (only when it actually hangs under that equipment's folder), and
`DataPlaneAuthorizer.AuthorizeScriptedCondition` resolves that equipment to its `[uns, Area, Line, Equipment]`
path through the CURRENT authority generation's `EquipmentScopePaths` (decoded from the artifact by the same
`AclScopePathResolver` call that places an `Equipment` grant). So a grant on the owning equipment or any
ancestor (line, area, UNS namespace, cluster) authorizes its scripted alarms; a sibling equipment's grant or a
grant on one tag beneath the equipment does not. Fail-closed: an unbound condition (owner folder missing), a
removed/replaced instance, or an owner the generation cannot place is denied for **every** grant, `Cluster`
included — there is no realm-root or id-split fallback. The key, grant and generation are re-read on every call
(uncached). NodeIds and `AlarmCommand.AlarmId` are unchanged. With the flag **off** none of this is consulted.
⚠️ Read/Browse/Subscribe of UNS folders and scripted conditions, and Server-object event visibility, are still
decided on the NodeId's split, and UNS folder ids are opaque entity ids — Gitea #808.
| HistoryRead (all four variants) | **nothing** | `HistoryRead` on each node's scope, decided **per node in the batch** |
| HistoryUpdate (insert / replace / update / delete) | role `HistoryUpdate` (from a `Cluster`-scoped grant) + key re-check | `HistoryUpdate` on that node's scope, decided **per node in the batch** |
| Read (every attribute, not just Value) | nothing | `Read` on that node's scope, decided per node in the batch |
@@ -25,8 +25,10 @@ unchanged. Never load this assembly or old SDK into the candidate process.
The real session has no application-wide roles. The production-valid opaque
scripted ID is denied by an Equipment grant, routes nothing, and is then allowed
by an explicit Cluster grant. A passing probe pins **both** outcomes; it does not
claim narrow equipment authorization works. The independent candidate counterpart
is `Sdk2OpaqueScriptedScopeBaselineTests`.
claim narrow equipment authorization works. The candidate counterpart
`Sdk2OpaqueScriptedScopeBaselineTests` pinned the same denial until #797 was fixed; it now pins the fixed
behaviour (owning equipment grant allows, sibling equipment grant denies, cluster grant still allows). This
probe remains the record of the pre-fix behaviour on the original commit.
Do not make the ID slash-qualified to get a positive result: the current UI
rejects `/`, and that would hide the production scope-resolution defect. A future
@@ -34,7 +34,8 @@ public sealed class DataPlaneAuthoritySnapshot
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyList<PermissionTrieBuildDiagnostic> droppedGrants,
IReadOnlyDictionary<string, ApiKey> keysById,
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal)
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal,
IReadOnlyDictionary<string, NodeAclPath> equipmentScopePaths)
{
ClusterId = clusterId;
RevisionHash = revisionHash;
@@ -45,11 +46,64 @@ public sealed class DataPlaneAuthoritySnapshot
DroppedGrants = droppedGrants;
_keysById = keysById;
_grantsByPrincipal = grantsByPrincipal;
EquipmentScopePaths = equipmentScopePaths;
}
private readonly IReadOnlyDictionary<string, ApiKey> _keysById;
private readonly IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> _grantsByPrincipal;
/// <summary>
/// <c>EquipmentId → its UNS ACL path</c> (<c>[uns, Area, Line, Equipment]</c>) for every equipment in
/// the artifact this generation was built from, resolved by the same <see cref="AclScopePathResolver"/>
/// rule that places an <c>Equipment</c>-scoped grant. Keyed <c>OrdinalIgnoreCase</c> (identifier rule,
/// see <see cref="AclScopeRef"/>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists (Gitea #797).</b> A scripted alarm's condition NodeId is its opaque
/// <c>ScriptedAlarmId</c> (e.g. <c>scripted-eqa-hi</c>), and its equipment folder's NodeId is the
/// opaque <c>EquipmentId</c> — neither is a topology path, so splitting the NodeId (what
/// <c>DataPlaneAuthorizer</c> does for every other node) can never reach the owning equipment's
/// grant. The node manager instead binds each LIVE scripted condition to its owning
/// <c>EquipmentId</c>, and the authorizer resolves that id to a path HERE, against the same
/// generation the grants and the trie came from — so an equipment rename or move that a deploy
/// carries is seen by grants and by owner paths together, never one ahead of the other.
/// </para>
/// <para>
/// Not a grant table and never consulted by the trie build: an entry confers nothing; it only says
/// where in the UNS tree a given equipment sits. An equipment absent here (deleted, line-less,
/// dangling) has no owner path and its scripted conditions are denied under enforcement.
/// </para>
/// </remarks>
public IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths { get; }
/// <summary>
/// The owning equipment's path WITHIN the UNS realm (the realm segment stripped), for a scripted
/// condition bound to <paramref name="equipmentId"/>. False when this generation knows no such
/// equipment or its recorded path is not a UNS path — the caller must deny.
/// </summary>
/// <param name="equipmentId">The owning equipment's logical id.</param>
/// <param name="unsSegments">The path below the realm root, e.g. <c>[Area, Line, Equipment]</c>.</param>
/// <returns><see langword="true"/> when an owner path is known.</returns>
public bool TryGetEquipmentUnsSegments(string equipmentId, out IReadOnlyList<string> unsSegments)
{
unsSegments = [];
if (string.IsNullOrEmpty(equipmentId)) return false;
if (!EquipmentScopePaths.TryGetValue(equipmentId, out var path)) return false;
// Shape-checked rather than trusted: a path that does not start at the UNS realm, or that names the
// realm alone, describes no equipment. Accepting a bare [uns] would evaluate at the realm root — a
// parent fallback this binding exists to rule out.
if (path.Segments.Count < 2
|| !string.Equals(path.Segments[0], NodeAclRealms.Uns, StringComparison.Ordinal))
{
return false;
}
unsSegments = path.Segments.Skip(1).ToArray();
return true;
}
/// <summary>
/// The cluster this node belongs to per the artifact's <c>ClusterNode</c> row, or null on a
/// single-cluster / legacy artifact where no scoping applies.
@@ -127,6 +181,8 @@ public sealed class DataPlaneAuthoritySnapshot
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
/// <param name="droppedGrants">Grants the trie build discarded, or empty when no trie was built.</param>
/// <param name="equipmentScopePaths">Every equipment's UNS ACL path (see <see cref="EquipmentScopePaths"/>);
/// null ⇒ none, which denies every scripted-condition command under enforcement.</param>
/// <returns>The built snapshot.</returns>
public static DataPlaneAuthoritySnapshot Create(
string? clusterId,
@@ -135,7 +191,8 @@ public sealed class DataPlaneAuthoritySnapshot
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyList<PermissionTrieBuildDiagnostic>? droppedGrants = null)
IReadOnlyList<PermissionTrieBuildDiagnostic>? droppedGrants = null,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null)
{
ArgumentNullException.ThrowIfNull(revisionHash);
ArgumentNullException.ThrowIfNull(keys);
@@ -169,8 +226,22 @@ public sealed class DataPlaneAuthoritySnapshot
kv => (IReadOnlyList<NodeAcl>)kv.Value,
StringComparer.OrdinalIgnoreCase);
// Re-keyed under the identifier comparer here rather than trusted from the caller, the same reason
// AclScopePathResolver re-keys its maps: the binding side (the node manager's EquipmentId) and this
// side must not be able to disagree on case.
var equipment = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase);
if (equipmentScopePaths is not null)
{
foreach (var (equipmentId, path) in equipmentScopePaths)
{
if (string.IsNullOrWhiteSpace(equipmentId) || path is null) continue;
equipment[equipmentId] = path;
}
}
return new DataPlaneAuthoritySnapshot(
clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen);
clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen,
equipment);
}
}
@@ -236,13 +307,16 @@ public interface IDataPlaneAuthoritySink
/// <param name="keys">The API keys the artifact carries.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <param name="scopePaths">Where each grant's scope sits in the address space.</param>
/// <param name="equipmentScopePaths">Every equipment's UNS ACL path, resolved from the same artifact —
/// the owner lookup for scripted alarm conditions (Gitea #797). Null ⇒ none.</param>
/// <returns>The installed snapshot.</returns>
DataPlaneAuthoritySnapshot Apply(
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths);
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null);
}
/// <summary>
@@ -290,7 +364,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null)
{
ArgumentNullException.ThrowIfNull(revisionHash);
@@ -323,7 +398,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
var dropped = BuildAndInstallTrie(clusterId, generation, grants, scopePaths);
published = DataPlaneAuthoritySnapshot.Create(
clusterId, revisionHash, generation, keys, grants, scopePaths, dropped);
clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths);
Volatile.Write(ref _current, published);
}
@@ -66,6 +66,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
/// <summary>
/// Gitea #797 — the explicit binding from each LIVE scripted condition to the equipment that owns it.
/// A scripted condition's NodeId is its opaque <c>ScriptedAlarmId</c> and cannot be split into an ACL
/// scope, so with <c>Security:Acl:Enabled</c> its Part 9 commands are authorized at the owner's scope
/// instead (<see cref="Security.DataPlaneAuthorizer.AuthorizeScriptedCondition"/>), resolved through the
/// CURRENT authority generation at each call.
/// <para>
/// Written only by <see cref="MaterialiseAlarmCondition"/>, from the deployed composition's
/// <c>EquipmentId</c>, and only when the condition actually hangs under that equipment's folder.
/// Each entry pins the <see cref="AlarmConditionState"/> instance it was made for, so a call that
/// reaches a removed or replaced instance (rebuild, surgical remove, delete + recreate under another
/// equipment) finds no binding and is denied. Removed in lock-step with <see cref="_alarmConditions"/>
/// on every teardown path; cleared by <see cref="RebuildAddressSpace"/>. Never populated for native
/// conditions, which keep their RawPath-scoped decision. Guarded by <c>Lock</c>.
/// </para>
/// </summary>
private readonly Dictionary<RealmKey, ScriptedAlarmOwner> _scriptedAlarmOwners = new();
/// <summary>Every NATIVE condition's live state, captured by <see cref="RebuildAddressSpace"/>
/// just before it clears <see cref="_alarmConditions"/>, waiting for <see cref="MaterialiseAlarmCondition"/>
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
@@ -1493,7 +1510,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared
// first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate
// stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag.
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative)
// Gitea #797: a scripted condition whose OWNER changed (same id, different equipment) is not "the same"
// and falls through to the drop-and-recreate, so it is re-parented and re-bound rather than left
// under — and authorized as — the equipment it no longer belongs to.
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative
&& (isNative || SameScriptedOwner(conditionKey, equipmentNodeId, realm)))
return;
// Review 01 H2 — a variable or folder already holding this id in this realm wins; never shadow it.
if (!_alarmConditions.ContainsKey(conditionKey) && IsHeldByAnotherKind(conditionKey, "alarm condition"))
@@ -1522,6 +1543,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
// kind, so always drop the stale native flag first and only re-add it below when isNative.
_nativeAlarmNodeIds.Remove(conditionKey);
// Gitea #797: the dropped instance's owner binding goes with it; the new one is bound below.
_scriptedAlarmOwners.Remove(conditionKey);
var parent = ResolveParentFolder(equipmentNodeId, realm);
@@ -1709,6 +1732,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// H6a: record native (driver-fed) conditions so a later task can route their inbound
// Acknowledge to the driver rather than the scripted engine.
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
// Gitea #797: bind the scripted condition to its owning equipment — this exact instance, and only
// when it really hangs under that equipment's folder (an unresolved parent leaves it unbound ⇒ every
// ACL-enforced command on it is denied, never authorized at some guessed scope).
else if (BindableScriptedOwner(equipmentNodeId, realm) is { } owner && ReferenceEquals(parent, owner.Folder))
_scriptedAlarmOwners[conditionKey] = new ScriptedAlarmOwner(alarm, owner.EquipmentId);
// A native condition the rebuild in this apply discarded gets its live state back. Removed
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
@@ -2089,6 +2117,50 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId));
}
/// <summary>Gitea #797 — the owning equipment a live scripted condition is bound to, for tests and diagnostics.</summary>
/// <param name="alarmNodeId">The scripted condition's id (its <c>ScriptedAlarmId</c>).</param>
/// <param name="realm">The realm the condition lives in.</param>
/// <returns>The bound <c>EquipmentId</c>, or null when the condition is unknown, native or unbound.</returns>
internal string? ScriptedAlarmOwnerOf(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
{
var key = MapKey(realm, alarmNodeId);
return _scriptedAlarmOwners.TryGetValue(key, out var b)
&& _alarmConditions.TryGetValue(key, out var live)
&& ReferenceEquals(b.Alarm, live)
? b.EquipmentId
: null;
}
}
/// <summary>
/// The equipment folder a scripted condition materialised with <paramref name="equipmentNodeId"/> would be
/// bound to: only a UNS condition naming an EXISTING folder qualifies. Call under <c>Lock</c>.
/// </summary>
private (string EquipmentId, FolderState Folder)? BindableScriptedOwner(string? equipmentNodeId, AddressSpaceRealm realm) =>
realm == AddressSpaceRealm.Uns
&& !string.IsNullOrEmpty(equipmentNodeId)
&& _folders.TryGetValue(MapKey(realm, equipmentNodeId), out var folder)
? (equipmentNodeId, folder)
: null;
/// <summary>
/// Whether re-materialising the existing scripted condition at <paramref name="key"/> under
/// <paramref name="equipmentNodeId"/> leaves its owner binding exactly as it is. Call under <c>Lock</c>.
/// </summary>
private bool SameScriptedOwner(RealmKey key, string? equipmentNodeId, AddressSpaceRealm realm)
{
var current = _scriptedAlarmOwners.TryGetValue(key, out var b) ? b.EquipmentId : null;
var wanted = BindableScriptedOwner(equipmentNodeId, realm)?.EquipmentId;
return string.Equals(current, wanted, StringComparison.Ordinal);
}
/// <summary>Gitea #797 — one live scripted condition instance and the equipment that owns it.</summary>
/// <param name="Alarm">The exact condition instance the binding was made for.</param>
/// <param name="EquipmentId">The owning equipment's logical id (its UNS folder's <c>s=</c> id).</param>
private sealed record ScriptedAlarmOwner(AlarmConditionState Alarm, string EquipmentId);
/// <summary>
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
@@ -2218,8 +2290,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
if (AclEnforced)
{
return _authorizer!.Authorize(
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId));
// Native conditions (ConditionId = RawPath) keep the NodeId-scoped decision unchanged: their id IS
// the raw tag's path. Everything else is a scripted condition with an opaque id, authorized ONLY at
// the scope of the equipment its exact live instance is bound to (Gitea #797) — no binding, no
// authority. Never a fallback to splitting the opaque id.
var native = false;
string? owner = null;
lock (Lock)
{
var key = MapKey(condition.NodeId);
if (_nativeAlarmNodeIds.Contains(key))
{
native = true;
}
else if (_scriptedAlarmOwners.TryGetValue(key, out var binding)
&& ReferenceEquals(binding.Alarm, condition)
&& _alarmConditions.TryGetValue(key, out var live)
&& ReferenceEquals(live, condition))
{
owner = binding.EquipmentId;
}
}
return native
? _authorizer!.Authorize(
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId))
: _authorizer!.AuthorizeScriptedCondition(
identity, AlarmOperationFor(operation), condition.NodeId, owner);
}
// The activation-time AlarmAck role is honoured only while the key that earned it is
@@ -3755,6 +3852,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
_nativeAlarmNodeIds.Clear();
_scriptedAlarmOwners.Clear();
foreach (var f in _folders.Values) PredefinedNodes?.Remove(f.NodeId);
_folders.Clear();
@@ -3913,6 +4011,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
condition.Parent?.RemoveChild(condition);
ForgetPredefinedSubtree(condition);
_nativeAlarmNodeIds.Remove(key);
_scriptedAlarmOwners.Remove(key);
LowerAllEventsMonitoring(condition);
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
}
@@ -4021,6 +4120,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (c.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(c);
ForgetPredefinedSubtree(c);
_nativeAlarmNodeIds.Remove(key);
_scriptedAlarmOwners.Remove(key);
LowerAllEventsMonitoring(c);
return;
}
@@ -239,6 +239,91 @@ public sealed class DataPlaneAuthorizer
: null;
}
/// <summary>
/// Authorizes a Part 9 command on a <b>scripted</b> alarm condition at the scope of the equipment the
/// node manager has bound that exact live condition to (Gitea #797) — never at a scope parsed out of
/// the condition's NodeId.
/// </summary>
/// <remarks>
/// <para>
/// A scripted condition's NodeId is its opaque, operator-authored <c>ScriptedAlarmId</c>
/// (<c>^[A-Za-z0-9_-]+$</c>, e.g. <c>scripted-eqa-hi</c>). <see cref="Authorize"/> would split it as
/// a topology path — <c>[uns, scripted-eqa-hi]</c> — which no <c>Equipment</c> grant can ever
/// match, so before this entry point a session holding the owning equipment's grant was denied
/// while a <c>Cluster</c> grant passed. Worse, that one-segment scope would be satisfied by an
/// unrelated Area that happened to share the alarm's id.
/// </para>
/// <para>
/// <b>Every input is re-read at each call, and every gap denies.</b> The current snapshot (so the
/// current generation, trie and grants), the calling key's usability against this authorizer's
/// clock, and the owner's path in THAT generation's <see cref="DataPlaneAuthoritySnapshot.EquipmentScopePaths"/>.
/// No owner binding (an unknown, removed or replaced condition), an owner the generation cannot
/// place, or a path that does not name an equipment each deny — there is no fallback to the
/// realm root, the cluster, or the identifier. The decision cache is deliberately not used: the
/// binding lives in the address space, not in the generation, so a cached verdict keyed on the
/// NodeId could outlive a re-bind (delete + recreate of the same id under another equipment).
/// Alarm commands are not a request-rate seam, so the uncached walk costs nothing that matters.
/// </para>
/// <para>
/// The scope is the equipment's OWN node, not the equipment plus a leaf: grants on the equipment
/// or any ancestor (line, area, UNS namespace, cluster) confer the command, and a grant on a single
/// tag beneath the equipment does not — a tag grant is about that signal, not the equipment's alarms.
/// </para>
/// </remarks>
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
/// <param name="operation">The alarm operation being attempted.</param>
/// <param name="conditionNodeId">The condition the method targets (for the denial log only — never parsed).</param>
/// <param name="ownerEquipmentId">The <c>EquipmentId</c> the live condition is bound to, or null when the
/// node manager holds no binding for this exact instance.</param>
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
public ServiceResult? AuthorizeScriptedCondition(
RoleCarryingUserIdentity? identity,
OpcUaOperation operation,
NodeId conditionNodeId,
string? ownerEquipmentId)
{
if (identity is null)
return Deny(operation, conditionNodeId, principal: null, "the session carries no role-carrying identity");
if (string.IsNullOrEmpty(identity.Principal))
return Deny(operation, conditionNodeId, principal: null, "the session resolved no ACL principal");
var snapshot = _authority.Current;
if (string.IsNullOrWhiteSpace(snapshot.ClusterId))
{
return Deny(operation, conditionNodeId, identity.Principal,
"this node has no cluster identity, so no permission trie exists to evaluate against");
}
var now = _time.GetUtcNow().UtcDateTime;
if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null)
return DenyRevokedKey(operation, conditionNodeId, identity.Principal, keyId, snapshot.GenerationId, cached: false);
if (string.IsNullOrEmpty(ownerEquipmentId))
{
return Deny(operation, conditionNodeId, identity.Principal,
"the scripted condition has no live owner binding (unknown, removed or replaced)");
}
if (!snapshot.TryGetEquipmentUnsSegments(ownerEquipmentId, out var segments))
{
return Deny(operation, conditionNodeId, identity.Principal,
$"owner equipment {ownerEquipmentId} has no UNS path in generation {snapshot.GenerationId}");
}
var scope = new NodeScope
{
ClusterId = snapshot.ClusterId!,
Kind = NodeHierarchyKind.Uns,
Segments = segments,
};
return Walk(snapshot, identity, operation, scope, now)
? null
: DenyNoGrant(operation, conditionNodeId, identity.Principal, snapshot.GenerationId, cached: false);
}
/// <summary>
/// The decision itself. Ordered so the cached seams reach their answer having allocated nothing:
/// every check that must run per call comes first, the memoized verdict is consulted next, and the
@@ -62,6 +62,16 @@ public sealed record DataPlaneAuthorityProjection(
IReadOnlyList<NodeAcl> Grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths)
{
/// <summary>
/// <c>EquipmentId → [uns, Area, Line, Equipment]</c> for every equipment in the artifact whose UNS
/// chain resolves — the owner paths scripted alarm conditions are authorized at (Gitea #797). Resolved by
/// the SAME <see cref="AclScopePathResolver"/> instance as <see cref="ScopePaths"/>, so an
/// <c>Equipment</c> grant's planted path and the owner path of that equipment's scripted alarms are
/// equal by construction. Empty when the artifact carries no grants (nothing could be authorized).
/// </summary>
public IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths { get; init; } =
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase);
/// <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>>());
@@ -907,8 +917,11 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
grants.Add(acl);
}
return new DataPlaneAuthorityProjection(
scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
var (scopePaths, equipmentScopePaths) = BuildAclScopePaths(root, grants);
return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants, scopePaths)
{
EquipmentScopePaths = equipmentScopePaths,
};
}
/// <summary>
@@ -916,11 +929,18 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
/// topology, using the same <c>RawPathResolver</c> the address space is built through — so an ACL
/// path and the RawPath that forms a node's NodeId cannot drift apart.
/// </summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> BuildAclScopePaths(
/// <returns>The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup,
/// Gitea #797) from the same resolver.</returns>
private static (IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths,
IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths) BuildAclScopePaths(
JsonElement root,
IReadOnlyList<NodeAcl> grants)
{
if (grants.Count == 0) return new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
if (grants.Count == 0)
{
return (new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(),
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase));
}
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "RawFolders"))
@@ -1011,7 +1031,17 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
var resolver = new AclScopePathResolver(
folders, drivers, devices, groups, tags, areas, lines, equipment, references);
return resolver.Resolve(grants);
// Every equipment's own path, through the SAME resolver call an Equipment grant's path takes — the
// scripted-alarm owner binding authorizes at exactly the node an Equipment grant is planted at. An
// equipment whose chain does not resolve is simply absent (its scripted conditions then deny).
var equipmentPaths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase);
foreach (var equipmentId in equipment.Keys)
{
if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path;
}
return (resolver.Resolve(grants), equipmentPaths);
}
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
@@ -1425,7 +1425,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// would be a different byte sequence and would not match the deployment's RevisionHash.
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths);
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths,
projection.EquipmentScopePaths);
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
@@ -283,6 +283,35 @@ public sealed class DataPlaneAuthorityStateTests
snapshot.DroppedGrants.ShouldBeEmpty();
}
/// <summary>
/// Gitea #797 — the owner lookup for scripted alarm conditions: a UNS equipment path yields its segments
/// below the realm; anything that does not name an equipment (absent id, a bare realm root, a raw path)
/// yields nothing, so the caller denies rather than evaluating at some broader scope.
/// </summary>
[Fact]
public void The_equipment_owner_lookup_returns_only_real_uns_equipment_paths()
{
var sut = new DataPlaneAuthorityState();
var snapshot = sut.Apply("cluster-a", "rev-1", [], [], NoScopes, new Dictionary<string, NodeAclPath>
{
["EQ-1"] = new(["uns", "Area", "Line", "Pump"]),
["EQ-root"] = new(["uns"]),
["EQ-raw"] = new(["raw", "Plant", "dev1"]),
});
snapshot.TryGetEquipmentUnsSegments("EQ-1", out var segments).ShouldBeTrue();
segments.ShouldBe(["Area", "Line", "Pump"]);
snapshot.TryGetEquipmentUnsSegments("eq-1", out _).ShouldBeTrue("ids are identifiers: OrdinalIgnoreCase");
snapshot.TryGetEquipmentUnsSegments("EQ-root", out _).ShouldBeFalse("a bare realm root is not an equipment");
snapshot.TryGetEquipmentUnsSegments("EQ-raw", out _).ShouldBeFalse("a raw path is not a UNS equipment");
snapshot.TryGetEquipmentUnsSegments("EQ-unknown", out _).ShouldBeFalse();
snapshot.TryGetEquipmentUnsSegments("", out _).ShouldBeFalse();
// A later generation without the equipment forgets it — the lookup is per generation.
sut.Apply("cluster-a", "rev-2", [], [], NoScopes).TryGetEquipmentUnsSegments("EQ-1", out _).ShouldBeFalse();
DataPlaneAuthoritySnapshot.Empty.TryGetEquipmentUnsSegments("EQ-1", out _).ShouldBeFalse();
}
private static NodeAcl Acl(string id, string principal, NodePermissions flags) => new()
{
NodeAclId = id,
@@ -697,7 +697,7 @@ public sealed class ConditionMethodEventTests
};
}
private static void AssertNewState(string method, bool native, ArrayOf<Variant> e)
internal static void AssertNewState(string method, bool native, ArrayOf<Variant> e)
{
// A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so).
if (native) e[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
@@ -719,7 +719,7 @@ public sealed class ConditionMethodEventTests
/// Calls one Part 9 method on the condition by its type's method id (the SDK resolves it against the
/// condition's method declarations — the shelve methods included); throws on a bad result.
/// </summary>
private static async Task CallAsync(
internal static async Task CallAsync(
ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct, double shelvingTimeMs = 60_000,
string? ackComment = "ack")
{
@@ -0,0 +1,370 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Gitea #797 over the wire — a real activated <c>apikey</c> session with <b>zero</b> application-wide roles,
/// holding only an <c>Equipment</c> grant, drives the Part 9 methods on a scripted condition whose id is a
/// production-valid opaque <c>ScriptedAlarmId</c>, under a production-shaped UNS tree (every folder an opaque
/// entity id, grant + owner paths resolved by <see cref="AclScopePathResolver"/> from the NAMES).
/// </summary>
/// <remarks>
/// <para>
/// Observation is through a SEPARATE anonymous session that holds a Cluster grant carrying only
/// Browse/Read/Subscribe/AlarmRead — no alarm-command flag — so it sees every condition event while its
/// own presence can never be what authorizes a command. Every count is read after a later sentinel
/// transition on the same item, so a late duplicate would already be queued ahead of it.
/// </para>
/// <para>
/// The rig's NodeIds stay exactly what production publishes: the condition is <c>s=scripted-eqa-hi</c> in
/// the UNS namespace and every routed <see cref="AlarmCommand.AlarmId"/> is that id unchanged — the
/// ScadaBridge-visible wire contract.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmOwnerScopeWireTests
{
private const string AreaId = "UA-0000000001";
private const string LineId = "UL-0000000001";
private const string Pump = "EQ-00000000a1b2";
private const string Fan = "EQ-00000000c3d4";
private const string Alarm = ServerEventRig.ScriptedAlarmId; // "scripted-eqa-hi"
private const string FanAlarm = "scripted-fan-hi";
private const NodePermissions Commands =
NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm
| NodePermissions.AlarmShelve | NodePermissions.MethodCall;
private const NodePermissions ObserveOnly =
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead;
/// <summary>
/// Every gated condition method, authorized by the narrow owner grant alone: routes exactly once with the
/// opaque id unchanged and reports exactly one condition event — while the same method on the SIBLING
/// equipment's scripted alarm, on the same session, is denied, routes nothing and reports nothing.
/// </summary>
[Theory]
[InlineData("Acknowledge")]
[InlineData("Confirm")]
[InlineData("AddComment")]
[InlineData("OneShotShelve")]
[InlineData("TimedShelve")]
[InlineData("Unshelve")]
[InlineData("Disable")]
[InlineData("Enable")]
public async Task A_narrow_equipment_grant_authorizes_each_method_once_and_its_sibling_is_denied(string method)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync($"Owner797{method}", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig);
Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer());
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
AssertNoRoles(rig);
var (events, observer, item) = await ObserveAsync(rig, ct);
var pre = ConditionMethodEventTests.PreState(method, native: false);
Project(rig, Alarm, pre with { Message = "base" });
Project(rig, FanAlarm, pre with { Message = "fan-base" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base") && events.Has(item, "fan-base"));
await ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), method, events.LatestEventId(item, "base"), ct);
var denied = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
session, Uns(session, FanAlarm), method, events.LatestEventId(item, "fan-base"), ct));
denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
Project(rig, Alarm, pre with { Message = "sentinel" });
Project(rig, FanAlarm, pre with { Message = "fan-sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel") && events.Has(item, "fan-sentinel"));
var mine = events.ConditionEvents(item, "base");
mine.Count.ShouldBe(2, $"the base transition plus exactly one {method} event");
ConditionMethodEventTests.AssertNewState(method, native: false, mine[1]);
events.ConditionEvents(item, "fan-base").Count.ShouldBe(1, "the denied sibling call reports nothing");
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, method)]);
await observer.CloseAsync(ct);
await session.CloseAsync(ct);
}
/// <summary>
/// Initial and repeated Acknowledge / Confirm on the narrow grant: every call is authorized and routed (the
/// engine owns the retry decision), and the condition events are exactly the documented contract — the ack,
/// the COMMENTED re-ack (its comment changed), the confirm, and nothing for the re-confirm (no delta).
/// </summary>
[Fact]
public async Task Initial_and_repeated_ack_and_confirm_route_every_call_and_report_exact_events()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Owner797Repeat", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig);
Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer());
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
AssertNoRoles(rig);
var (events, observer, item) = await ObserveAsync(rig, ct);
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
Project(rig, Alarm, pre);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
var condition = Uns(session, Alarm);
await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: "ack");
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct, ackComment: "again");
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 3);
await ConditionMethodEventTests.CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct);
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 4);
await ConditionMethodEventTests.CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct);
Project(rig, Alarm, pre with { Acknowledged = true, Confirmed = true, Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
events.ConditionEvents(item, "base").Count.ShouldBe(4, "base + ack + commented re-ack + confirm; the re-confirm is silent");
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe(
[(Alarm, "Acknowledge"), (Alarm, "Acknowledge"), (Alarm, "Confirm"), (Alarm, "Confirm")]);
await observer.CloseAsync(ct);
await session.CloseAsync(ct);
}
/// <summary>
/// The grant, the key and the clock are re-read at every call on the open session: revoking any of them
/// denies the next command (no route, no event, no state change); restoring the authority restores the
/// allow, on the same session, without reconnecting.
/// </summary>
[Theory]
[InlineData("grant")]
[InlineData("key")]
[InlineData("expiry")]
public async Task A_revoked_or_expired_authority_denies_the_next_command_on_the_open_session(string change)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync($"Owner797Revoke{change}", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig);
var clock = new Clock();
var key = ServerEventRig.Key();
key.ExpiresUtc = clock.GetUtcNow().UtcDateTime.AddMinutes(10);
var state = Install(rig, clock, [key], OwnerGrant(Pump), Observer());
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
AssertNoRoles(rig);
var (events, observer, item) = await ObserveAsync(rig, ct);
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
Project(rig, Alarm, pre);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
var condition = Uns(session, Alarm);
await ConditionMethodEventTests.CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct);
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
switch (change)
{
case "grant": Reapply(state, "rev-2", [key], Observer()); break;
case "key":
var disabled = ServerEventRig.Key();
disabled.Enabled = false;
Reapply(state, "rev-2", [disabled], OwnerGrant(Pump), Observer());
break;
case "expiry": clock.Advance(TimeSpan.FromMinutes(20)); break;
}
var condState = rig.Nm.TryGetAlarmCondition(Alarm)!;
var commentBefore = condState.Comment!.Value;
foreach (var method in new[] { "AddComment", "Confirm", "OneShotShelve" })
{
var error = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
session, condition, method, events.LatestEventId(item, "base"), ct));
error.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied, method);
}
condState.Comment!.Value.ShouldBe(commentBefore);
routed.Count.ShouldBe(1);
clock.Reset();
Reapply(state, "rev-3", [key], OwnerGrant(Pump), Observer());
await ConditionMethodEventTests.CallAsync(session, condition, "AddComment", events.LatestEventId(item, "base"), ct);
Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
events.ConditionEvents(item, "base").Count.ShouldBe(3, "base + ack + the restored AddComment; the denied calls report nothing");
routed.Select(c => c.Operation).ShouldBe(["Acknowledge", "AddComment"]);
await observer.CloseAsync(ct);
await session.CloseAsync(ct);
}
/// <summary>
/// Delete + recreate of the SAME opaque id under ANOTHER equipment: the old equipment's grant is denied on
/// the recreated condition and the new equipment's grant authorizes it; the routed id never changes. An id
/// with no live condition is refused by the server before any gate, and routes nothing.
/// </summary>
[Fact]
public async Task A_recreated_alarm_follows_its_new_owner_and_an_unknown_id_routes_nothing()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Owner797Recreate", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig);
var state = Install(rig, new Clock(), [ServerEventRig.Key()], OwnerGrant(Pump), Observer());
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
AssertNoRoles(rig);
var (events, observer, item) = await ObserveAsync(rig, ct);
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
Project(rig, Alarm, pre);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
await ConditionMethodEventTests.CallAsync(session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "base"), ct);
((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue();
rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
rig.Nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan);
Project(rig, Alarm, pre with { Message = "recreated" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "recreated"));
var oldGrant = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct));
oldGrant.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.Count.ShouldBe(1);
Reapply(state, "rev-fan", [ServerEventRig.Key()], OwnerGrant(Fan), Observer());
await ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct);
var unknown = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
session, Uns(session, "scripted-never-deployed"), "Acknowledge", [1, 2, 3], ct));
unknown.StatusCode.ShouldNotBe(StatusCodes.Good);
Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
events.ConditionEvents(item, "recreated").Count.ShouldBe(2, "the recreated base plus the new owner's one ack");
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge"), (Alarm, "Acknowledge")]);
await observer.CloseAsync(ct);
await session.CloseAsync(ct);
}
// ───────────────────────────── helpers ─────────────────────────────
/// <summary>Area1 / Line1 / {Pump, Fan} under opaque entity ids, one scripted alarm under each equipment.</summary>
private static void Topology(ServerEventRig rig)
{
rig.Sink.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(LineId, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(Pump, parentNodeId: LineId, displayName: "Pump", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(Fan, parentNodeId: LineId, displayName: "Fan", realm: AddressSpaceRealm.Uns);
rig.Sink.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
rig.Sink.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
}
private static AclScopePathResolver Resolver() => new(
new Dictionary<string, (string? ParentId, string Name)>(),
new Dictionary<string, (string? RawFolderId, string Name)>(),
new Dictionary<string, (string DriverInstanceId, string Name)>(),
new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(),
new Dictionary<string, string> { [AreaId] = "Area1" },
new Dictionary<string, (string UnsAreaId, string Name)> { [LineId] = (AreaId, "Line1") },
new Dictionary<string, (string UnsLineId, string Name)> { [Pump] = (LineId, "Pump"), [Fan] = (LineId, "Fan") },
[]);
private static Dictionary<string, NodeAclPath> OwnerPaths()
{
var resolver = Resolver();
return new Dictionary<string, NodeAclPath>
{
[Pump] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Pump).ShouldHaveSingleItem(),
[Fan] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Fan).ShouldHaveSingleItem(),
};
}
private static DataPlaneAuthorityState Install(ServerEventRig rig, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
Reapply(state, "rev-1", keys, grants);
rig.Nm.DataPlaneAuthority = state;
rig.Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, clock);
return state;
}
private static void Reapply(DataPlaneAuthorityState state, string revision, ApiKey[] keys, params NodeAcl[] grants) =>
state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths());
/// <summary>The key's ONLY alarm authority: an Equipment grant on <paramref name="equipmentId"/>.</summary>
private static NodeAcl OwnerGrant(string equipmentId) => new()
{
NodeAclId = $"owner-{equipmentId}",
ClusterId = ServerEventRig.Cluster,
Principal = ServerEventRig.KeyPrincipal,
ScopeKind = NodeAclScopeKind.Equipment,
ScopeId = equipmentId,
PermissionFlags = Commands,
};
/// <summary>The anonymous observer: sees everything, may command nothing.</summary>
private static NodeAcl Observer() => new()
{
NodeAclId = "observer",
ClusterId = ServerEventRig.Cluster,
Principal = OpcUaPrincipals.Anonymous,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = ObserveOnly,
};
private static List<AlarmCommand> Route(ServerEventRig rig)
{
var routed = new List<AlarmCommand>();
rig.Nm.AlarmCommandRouter = cmd => { lock (routed) routed.Add(cmd); };
return routed;
}
private static void AssertNoRoles(ServerEventRig rig) =>
rig.Server.CurrentInstance.SessionManager.GetSessions()
.Select(s => s.EffectiveIdentity).OfType<RoleCarryingUserIdentity>()
.Single(i => i.ApiKeyId == ServerEventRig.KeyId)
.Roles.ShouldBeEmpty("the narrow grant alone must authorize — the session holds no server-wide role");
private static async Task<(RecordedEvents Events, ISession Observer, MonitoredItem Item)> ObserveAsync(
ServerEventRig rig, CancellationToken ct)
{
var observer = await rig.ConnectAsync(anonymous: true, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(observer, events, ct);
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
item.Status.Error.ShouldBeNull();
return (events, observer, item);
}
private static void Project(ServerEventRig rig, string alarmId, AlarmConditionSnapshot state) =>
rig.Sink.WriteAlarmCondition(alarmId, state, DateTime.UtcNow, AddressSpaceRealm.Uns);
private static NodeId Uns(ISession session, string id) => ServerEventRig.UnsNodeId(session, id);
private sealed class Clock : TimeProvider
{
private readonly DateTimeOffset _initial = DateTimeOffset.UtcNow;
private TimeSpan _offset;
public override DateTimeOffset GetUtcNow() => _initial + _offset;
public void Advance(TimeSpan by) => _offset += by;
public void Reset() => _offset = TimeSpan.Zero;
}
}
@@ -1,4 +1,8 @@
// The same application-authored opaque ID and two grant outcomes are independently exercised on SDK1.5.
// Gitea #797: this test USED to pin the denial (an Equipment grant could not authorize an opaque scripted
// alarm id; only a Cluster grant could). The owner binding fixed that deliberately, so it now pins the fixed
// behaviour: the owning equipment's grant allows, the sibling equipment's grant denies, a cluster grant still
// allows. The SDK 1.5 baseline probe under scripts/validation/opcua-scripted-alarm-baseline records the
// pre-fix denial on the original application commit and is unaffected.
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
@@ -13,32 +17,45 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
public sealed class Sdk2OpaqueScriptedScopeBaselineTests
{
[Fact]
public async Task Opaque_scripted_ID_is_denied_by_its_equipment_grant_but_allowed_by_a_cluster_grant()
public async Task Opaque_scripted_ID_is_allowed_by_its_equipment_grant_denied_by_a_sibling_grant_and_allowed_by_a_cluster_grant()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("BaselineScriptedScope", encrypted: false, ct);
rig.KeyRoles = [];
const NodePermissions permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall;
rig.Enforce(true, [Key()], new NodeAcl
NodeAcl Narrow(string equipment) => new()
{
NodeAclId = "narrow", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal,
ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{EqA}", PermissionFlags = permissions,
});
NodeAclId = $"narrow-{equipment}", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal,
ScopeKind = NodeAclScopeKind.Equipment, ScopeId = $"eq-{equipment}", PermissionFlags = permissions,
};
// Sibling first: a grant on EqB must not authorize EqA's alarm.
rig.Enforce(true, [Key()], Narrow(EqB));
rig.MaterialiseScripted();
rig.Nm.ScriptedAlarmOwnerOf(ScriptedAlarmId).ShouldBe(EqA);
var routed = 0;
rig.Nm.AlarmCommandRouter = _ => routed++;
using var session = await rig.ConnectAsync(anonymous: false, ct);
rig.Project(false, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "scope"));
rig.Project(false, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 500, "scope"));
var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldNotBeNull();
condition.NodeId.IdentifierAsString.ShouldBe(ScriptedAlarmId, "the opaque NodeId is unchanged");
var eventId = condition.EventId.ShouldNotBeNull().Value;
var denied = await Should.ThrowAsync<ServiceResultException>(() => session.CallAsync(condition.NodeId,
MethodIds.AcknowledgeableConditionType_Acknowledge, ct, new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("denied")) }));
MethodIds.AcknowledgeableConditionType_Acknowledge, ct, new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("sibling")) }));
denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.ShouldBe(0);
// The owning equipment's narrow grant now authorizes the same opaque id (#797 fixed).
rig.Enforce(true, [Key()], Narrow(EqA));
await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("owner")) });
routed.ShouldBe(1);
// The existing cluster contract is preserved.
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "cluster-positive", permissions));
await session.CallAsync(condition.NodeId, MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
new Variant[] { Variant.From(eventId), Variant.From(new LocalizedText("allowed")) });
routed.ShouldBe(1);
new Variant[] { Variant.From(condition.EventId!.Value), Variant.From(new LocalizedText("cluster")) });
routed.ShouldBe(2);
await session.CloseAsync(ct);
}
}
@@ -56,8 +56,9 @@ public sealed class Sdk2RepeatedAlarmAuthorityWireTests
var identity = rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldHaveSingleItem().EffectiveIdentity
.ShouldBeOfType<RoleCarryingUserIdentity>();
identity.Roles.ShouldBeEmpty("the positive retry must be authorized by the application grant alone");
// Native: narrow raw-device scope. Scripted: preserve the existing cluster-scope contract;
// opaque scripted IDs are not under their equipment ACL path (a separately tracked baseline defect).
// Native: narrow raw-device scope. Scripted: the existing cluster-scope contract (the narrow
// equipment-scope case for opaque scripted IDs is Gitea #797's ScriptedAlarmOwnerScopeWireTests).
// Every re-Apply below carries the owner paths, so a scripted denial is the key/grant/clock change's.
identity.ApiKeyId.ShouldBe(KeyId);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
@@ -94,7 +95,7 @@ public sealed class Sdk2RepeatedAlarmAuthorityWireTests
if (change == "grant") grants = [];
if (change == "key") { var disabled = Key(); disabled.Enabled = false; keys = [disabled]; }
if (change == "expiry") clock.Advance(TimeSpan.FromMinutes(20));
else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes);
else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes, EquipmentOwnerPaths());
var error = await Should.ThrowAsync<ServiceResultException>(() => session.CallAsync(condition.NodeId, methodId, ct,
Variant.From(currentId), Variant.From(new LocalizedText("must not be recorded"))));
@@ -106,7 +107,7 @@ public sealed class Sdk2RepeatedAlarmAuthorityWireTests
confirmNode.Value.ShouldBe(beforeConfirm);
// A subsequent authorized same-item sentinel drains any queued method event before the assertion.
clock.Reset();
state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes);
state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes, EquipmentOwnerPaths());
rig.Project(native, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "sentinel"));
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
events.Of(item).Count.ShouldBe(beforeEvents + 1, "the denied retry emits nothing before the sentinel");
@@ -114,10 +114,19 @@ internal sealed class ServerEventRig : IAsyncDisposable
/// <summary>Wires the authorizer over the given keys, scope paths and grants, enforcing or not.</summary>
public DataPlaneAuthorityState Enforce(
bool enabled, ApiKey[] keys, IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
params NodeAcl[] grants)
params NodeAcl[] grants) =>
Enforce(enabled, keys, scopePaths, EquipmentOwnerPaths(), grants);
/// <summary>
/// Wires the authorizer over the given keys, scope paths, equipment owner paths (the scripted-alarm owner
/// lookup, Gitea #797) and grants, enforcing or not.
/// </summary>
public DataPlaneAuthorityState Enforce(
bool enabled, ApiKey[] keys, IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath> equipmentOwnerPaths, params NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", keys, grants, scopePaths);
state.Apply(Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths);
Nm.DataPlaneAuthority = state;
Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);
@@ -160,6 +169,18 @@ internal sealed class ServerEventRig : IAsyncDisposable
[new AclScopeRef(NodeAclScopeKind.Equipment, $"eq-{EqB}")] = [new NodeAclPath([NodeAclRealms.Uns, EqB])],
};
/// <summary>
/// The rig's equipment owner paths: its equipment folders' ids ARE their EquipmentIds, sitting directly at
/// <c>[uns, EqA]</c> / <c>[uns, EqB]</c> — the same nodes <see cref="ScopePaths"/> plants the Equipment
/// grants at.
/// </summary>
public static IReadOnlyDictionary<string, NodeAclPath> EquipmentOwnerPaths() =>
new Dictionary<string, NodeAclPath>
{
[EqA] = new NodeAclPath([NodeAclRealms.Uns, EqA]),
[EqB] = new NodeAclPath([NodeAclRealms.Uns, EqB]),
};
/// <summary>The native condition, wired as an event source of both equipment folders.</summary>
public void MaterialiseNative()
{
@@ -0,0 +1,681 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #797 — a scripted alarm condition's NodeId is its opaque <c>ScriptedAlarmId</c>, so with ACLs
/// enforced its Part 9 commands are authorized at the scope of the equipment its exact live instance is
/// bound to, resolved through the CURRENT authority generation — never at a scope split out of the id.
/// </summary>
/// <remarks>
/// <para>
/// The topology is production-shaped: every UNS folder id is an opaque entity id (<c>UA-…</c>,
/// <c>UL-…</c>, <c>EQ-…</c>) exactly as <c>AddressSpaceApplier.MaterialiseHierarchy</c> mints them, and
/// the grant paths and owner paths both come from <see cref="AclScopePathResolver"/> over the area /
/// line / equipment NAMES, as the deployment artifact resolves them. Nothing in the address space is a
/// slash-qualified synthetic id, so a pass here cannot be a coincidence of the id spelling a path.
/// </para>
/// <para>
/// The handlers are driven directly (the SDK delegates on the condition), which reaches the gate
/// exactly as a client call does and additionally lets a test hold on to a REPLACED instance — the
/// one case a real client can no longer address once the NodeId resolves to its successor.
/// </para>
/// </remarks>
public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
{
private const string Cluster = "SITE-A";
private const string Principal = "scadabridge";
private const string KeyId = "k-797";
private const string AreaId = "UA-0000000001";
private const string LineId = "UL-0000000001";
private const string Pump = "EQ-00000000a1b2";
private const string Fan = "EQ-00000000c3d4";
private const string Alarm = "scripted-eqa-hi";
private const string FanAlarm = "scripted-fan-hi";
private const NodePermissions AlarmOps =
NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm
| NodePermissions.AlarmShelve | NodePermissions.MethodCall;
private static readonly DateTime Start = new(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-797-{Guid.NewGuid():N}");
// ───────────────────────────── the defect, and its positive ─────────────────────────────
/// <summary>
/// THE #797 allow: a session holding NO roles and only an <c>Equipment</c> grant on the owning equipment
/// acknowledges a scripted condition with a production-valid opaque id, and the command routes once with
/// the id unchanged. Before the binding this was <c>BadUserAccessDenied</c>.
/// </summary>
[Fact]
public async Task An_equipment_grant_authorizes_every_command_on_its_own_scripted_condition()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
Topology(nm);
Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps));
var condition = nm.TryGetAlarmCondition(Alarm)!;
var ctx = Context(server, KeyIdentity());
nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump);
condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("ack")).ShouldBe(ServiceResult.Good);
condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("re-ack")).ShouldBe(ServiceResult.Good);
condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("confirm")).ShouldBe(ServiceResult.Good);
condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("re-confirm")).ShouldBe(ServiceResult.Good);
condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("note")).ShouldBe(ServiceResult.Good);
condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0).ShouldBe(ServiceResult.Good);
condition.OnShelve!(ctx, condition, shelving: true, oneShot: false, shelvingTime: 60_000).ShouldBe(ServiceResult.Good);
condition.OnShelve!(ctx, condition, shelving: false, oneShot: false, shelvingTime: 0).ShouldBe(ServiceResult.Good);
condition.OnEnableDisable!(ctx, condition, false).ShouldBe(ServiceResult.Good);
condition.OnEnableDisable!(ctx, condition, true).ShouldBe(ServiceResult.Good);
routed.Select(c => c.Operation).ShouldBe(
["Acknowledge", "Acknowledge", "Confirm", "Confirm", "AddComment", "OneShotShelve", "TimedShelve",
"Unshelve", "Disable", "Enable"]);
routed.ShouldAllBe(c => c.AlarmId == Alarm && c.User == Principal);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// The narrow grant confers exactly its own flags at the owner: Acknowledge alone does not confer Confirm
/// or Shelve, so the operation split survives the move to owner scope.
/// </summary>
[Fact]
public async Task The_owner_scope_honours_the_grants_operation_flags()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
Topology(nm);
Wire(nm, new FakeClock(Start), [Key()],
EquipmentGrant(Pump, NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge));
var condition = nm.TryGetAlarmCondition(Alarm)!;
var ctx = Context(server, KeyIdentity());
condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("ack")).ShouldBe(ServiceResult.Good);
Denied(condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("confirm")));
Denied(condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0));
routed.Select(c => c.Operation).ShouldBe(["Acknowledge"]);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// An ancestor grant (the line) covers its equipment's alarms; a grant on the SIBLING equipment, or on a
/// single tag beneath the owner, does not. Each deny is preceded on the same session by an allow on a
/// condition the grant does cover, so the deny cannot be a gate that denies everything.
/// </summary>
[Fact]
public async Task Sibling_equipment_and_tag_grants_are_denied_and_an_ancestor_grant_is_allowed()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
Topology(nm);
var clock = new FakeClock(Start);
var (state, _) = Wire(nm, clock, [Key()], EquipmentGrant(Fan, AlarmOps));
var pump = nm.TryGetAlarmCondition(Alarm)!;
var fan = nm.TryGetAlarmCondition(FanAlarm)!;
var ctx = Context(server, KeyIdentity());
fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("fan ok")).ShouldBe(ServiceResult.Good);
Denied(pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("sibling")));
Denied(pump.OnAddComment!(ctx, pump, EventId(), new LocalizedText("sibling")));
routed.Count.ShouldBe(1);
// A tag grant beneath the owner is about that signal, not the equipment's alarms.
Reapply(state, "rev-tag", [Key()], new NodeAcl
{
NodeAclId = "tag", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.Tag,
ScopeId = "TAG-1", PermissionFlags = AlarmOps,
}, EquipmentGrant(Fan, AlarmOps));
Denied(pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("tag")));
routed.Count.ShouldBe(1);
// The line is an ancestor of both equipments: it covers both.
Reapply(state, "rev-line", [Key()], new NodeAcl
{
NodeAclId = "line", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.UnsLine,
ScopeId = LineId, PermissionFlags = AlarmOps,
});
pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("line")).ShouldBe(ServiceResult.Good);
routed.Select(c => c.AlarmId).ShouldBe([FanAlarm, Alarm]);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// The old split scope <c>[uns, scripted-eqa-hi]</c> would have been satisfied by an unrelated AREA whose
/// name happened to equal the alarm's id. The owner binding ignores the id's spelling entirely.
/// </summary>
[Fact]
public async Task An_area_whose_name_equals_the_alarm_id_confers_nothing()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
Topology(nm);
var areas = new Dictionary<string, string> { [AreaId] = "Area1", ["UA-decoy"] = Alarm };
Wire(nm, new FakeClock(Start), [Key()], areas, new NodeAcl
{
NodeAclId = "decoy", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.UnsArea,
ScopeId = "UA-decoy", PermissionFlags = AlarmOps,
});
var condition = nm.TryGetAlarmCondition(Alarm)!;
Denied(condition.OnAcknowledge!(Context(server, KeyIdentity()), condition, EventId(), new LocalizedText("decoy")));
routed.ShouldBeEmpty();
}
finally { await host.DisposeAsync(); }
}
// ───────────────────────────── re-checked at every call ─────────────────────────────
/// <summary>
/// Key, grant and generation are re-read at each call: a deployment that drops the grant or disables the
/// key, or the clock passing the key's expiry, denies the very next command on the same open session;
/// restoring the authority restores the allow.
/// </summary>
[Theory]
[InlineData("grant")]
[InlineData("key")]
[InlineData("expiry")]
[InlineData("owner-path")]
public async Task Revocation_reaches_the_next_command_and_restoration_restores_it(string change)
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
Topology(nm);
var clock = new FakeClock(Start);
var key = Key(expiresUtc: Start.AddMinutes(10));
var grant = EquipmentGrant(Pump, AlarmOps);
var (state, _) = Wire(nm, clock, [key], grant);
var condition = nm.TryGetAlarmCondition(Alarm)!;
var ctx = Context(server, KeyIdentity());
condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("before")).ShouldBe(ServiceResult.Good);
switch (change)
{
case "grant": Reapply(state, "rev-2", [key]); break;
case "key":
var disabled = Key(expiresUtc: Start.AddMinutes(10));
disabled.Enabled = false;
Reapply(state, "rev-2", [disabled], grant);
break;
case "expiry": clock.Advance(TimeSpan.FromMinutes(20)); break;
case "owner-path":
// The generation no longer places the owner (equipment deleted from the artifact while the
// node is still served): the grant's own scope stays resolvable, the owner does not.
state.Apply(Cluster, "rev-2", [key], [grant], Resolver().Resolve([grant]),
new Dictionary<string, NodeAclPath>());
break;
}
Denied(condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("after")));
Denied(condition.OnConfirm!(ctx, condition, EventId(), new LocalizedText("after")));
Denied(condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("after")));
routed.Count.ShouldBe(1);
clock.Reset(Start);
Reapply(state, "rev-3", [key], grant);
condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("restored")).ShouldBe(ServiceResult.Good);
routed.Count.ShouldBe(2);
}
finally { await host.DisposeAsync(); }
}
// ───────────────────────────── binding lifecycle ─────────────────────────────
/// <summary>
/// Delete + recreate of the same id under ANOTHER equipment: the new instance is bound to the new owner,
/// the old equipment's grant is denied on it, the new equipment's grant is allowed — and the REPLACED
/// instance, still held by a caller, is denied under every grant, even a Cluster one.
/// </summary>
[Fact]
public async Task A_recreated_alarm_binds_to_its_new_equipment_and_the_replaced_instance_is_denied()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
Topology(nm);
var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps));
var ctx = Context(server, KeyIdentity());
var original = nm.TryGetAlarmCondition(Alarm)!;
original.OnAcknowledge!(ctx, original, EventId(), new LocalizedText("original")).ShouldBe(ServiceResult.Good);
nm.RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue();
nm.ScriptedAlarmOwnerOf(Alarm).ShouldBeNull("a removed condition has no owner");
Denied(original.OnAcknowledge!(ctx, original, EventId(), new LocalizedText("removed")));
nm.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
var recreated = nm.TryGetAlarmCondition(Alarm)!;
recreated.ShouldNotBeSameAs(original);
nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan);
Denied(recreated.OnAcknowledge!(ctx, recreated, EventId(), new LocalizedText("old grant")));
routed.Count.ShouldBe(1);
Reapply(state, "rev-fan", [Key()], EquipmentGrant(Fan, AlarmOps));
recreated.OnAcknowledge!(ctx, recreated, EventId(), new LocalizedText("new grant")).ShouldBe(ServiceResult.Good);
routed.Count.ShouldBe(2);
// The replaced instance stays dead under every grant, the broadest included.
Reapply(state, "rev-cluster", [Key()], ClusterGrant(AlarmOps));
Denied(original.OnAcknowledge!(ctx, original, EventId(), new LocalizedText("stale")));
Denied(original.OnAddComment!(ctx, original, EventId(), new LocalizedText("stale")));
routed.Count.ShouldBe(2);
routed.ShouldAllBe(c => c.AlarmId == Alarm);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A re-materialise of a live scripted condition naming a DIFFERENT owner (no rebuild in between) is not
/// the idempotent no-op it is for an unchanged one: the condition is re-parented under — and re-bound to —
/// the new equipment. An unchanged re-materialise keeps the instance (and its subscriptions) and its binding.
/// </summary>
[Fact]
public async Task A_rematerialise_under_another_equipment_rebinds_and_an_unchanged_one_is_a_no_op()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
Topology(nm);
var first = nm.TryGetAlarmCondition(Alarm)!;
nm.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
nm.TryGetAlarmCondition(Alarm).ShouldBeSameAs(first);
nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump);
nm.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
var moved = nm.TryGetAlarmCondition(Alarm)!;
moved.ShouldNotBeSameAs(first);
nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Fan);
moved.Parent!.NodeId.IdentifierAsString.ShouldBe(Fan);
}
finally { await host.DisposeAsync(); }
}
/// <summary>A full rebuild and an equipment-subtree removal drop every binding they tear down.</summary>
[Fact]
public async Task Rebuild_and_equipment_subtree_removal_drop_the_binding()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
Topology(nm);
Wire(nm, new FakeClock(Start), [Key()], ClusterGrant(AlarmOps));
var ctx = Context(server, KeyIdentity());
var fan = nm.TryGetAlarmCondition(FanAlarm)!;
fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("live")).ShouldBe(ServiceResult.Good);
nm.RemoveEquipmentSubtree(Fan, AddressSpaceRealm.Uns).ShouldBeTrue();
nm.ScriptedAlarmOwnerOf(FanAlarm).ShouldBeNull();
Denied(fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("subtree gone")));
var pump = nm.TryGetAlarmCondition(Alarm)!;
nm.RebuildAddressSpace();
nm.ScriptedAlarmOwnerOf(Alarm).ShouldBeNull();
Denied(pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("rebuilt away")));
routed.Count.ShouldBe(1);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A scripted condition whose owner folder did not exist when it was materialised hangs under the root and
/// is UNBOUND: with enforcement on, nothing authorizes it — not even a Cluster grant. There is no fallback
/// to the realm root, the cluster, or the id. An unknown id has no condition at all.
/// </summary>
[Fact]
public async Task An_unbound_scripted_condition_is_denied_even_under_a_cluster_grant()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
Topology(nm);
nm.MaterialiseAlarmCondition("scripted-orphan", "EQ-not-materialised", "orphan", "AlarmCondition", 500, AddressSpaceRealm.Uns);
Wire(nm, new FakeClock(Start), [Key()], ClusterGrant(AlarmOps));
var ctx = Context(server, KeyIdentity());
var orphan = nm.TryGetAlarmCondition("scripted-orphan")!;
nm.ScriptedAlarmOwnerOf("scripted-orphan").ShouldBeNull();
nm.ScriptedAlarmOwnerOf("scripted-never-existed").ShouldBeNull();
var bound = nm.TryGetAlarmCondition(Alarm)!;
bound.OnAcknowledge!(ctx, bound, EventId(), new LocalizedText("positive control")).ShouldBe(ServiceResult.Good);
Denied(orphan.OnAcknowledge!(ctx, orphan, EventId(), new LocalizedText("orphan")));
routed.Select(c => c.AlarmId).ShouldBe([Alarm]);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// The production population path end to end: a composition applied by <see cref="AddressSpaceApplier"/>
/// through the <see cref="DeferredAddressSpaceSink"/> wrapper the actors inject (no new sink method — the
/// binding rides <c>MaterialiseAlarmCondition</c>'s existing <c>EquipmentId</c>), then an Equipment grant
/// resolved from the SAME names authorizes the alarm while the sibling equipment's grant does not.
/// </summary>
[Fact]
public async Task The_applier_binds_each_scripted_alarm_to_its_composition_equipment()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
var deferred = new DeferredAddressSpaceSink();
deferred.SetSink(new SdkAddressSpaceSink(nm));
var applier = new AddressSpaceApplier(deferred, NullLogger<AddressSpaceApplier>.Instance);
var composition = new AddressSpaceComposition(
[new UnsAreaProjection(AreaId, "Area1")],
[new UnsLineProjection(LineId, AreaId, "Line1")],
[new EquipmentNode(Pump, "Pump", LineId), new EquipmentNode(Fan, "Fan", LineId)],
[],
[])
{
EquipmentScriptedAlarms =
[
new EquipmentScriptedAlarmPlan(Alarm, Pump, "hi", "AlarmCondition", 500, "m", "s1", "return false;", [], false, true, true),
new EquipmentScriptedAlarmPlan(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, "m", "s2", "return false;", [], false, true, true),
],
};
applier.MaterialiseHierarchy(composition).ShouldBe(0);
applier.MaterialiseScriptedAlarms(composition).ShouldBe(0);
nm.ScriptedAlarmOwnerOf(Alarm).ShouldBe(Pump);
nm.ScriptedAlarmOwnerOf(FanAlarm).ShouldBe(Fan);
nm.TryGetAlarmCondition(Alarm)!.NodeId.IdentifierAsString.ShouldBe(Alarm, "the opaque NodeId is unchanged");
var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps));
var ctx = Context(server, KeyIdentity());
var pump = nm.TryGetAlarmCondition(Alarm)!;
var fan = nm.TryGetAlarmCondition(FanAlarm)!;
pump.OnAcknowledge!(ctx, pump, EventId(), new LocalizedText("owner")).ShouldBe(ServiceResult.Good);
Denied(fan.OnAcknowledge!(ctx, fan, EventId(), new LocalizedText("sibling")));
routed.Select(c => c.AlarmId).ShouldBe([Alarm]);
state.Current.GenerationId.ShouldBe(1);
}
finally { await host.DisposeAsync(); }
}
// ───────────────────────────── what must not change ─────────────────────────────
/// <summary>
/// Native conditions (ConditionId = RawPath) keep their NodeId-scoped decision: a RawDevice grant
/// authorizes Acknowledge and AddComment, and an Equipment grant on a referencing equipment does not —
/// exactly as before #797. No owner binding is ever made for them.
/// </summary>
[Fact]
public async Task Native_conditions_keep_their_raw_path_scoped_decision()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var native = new List<NativeAlarmAck>();
nm.NativeAlarmAckRouter = native.Add;
var scripted = Route(nm);
Topology(nm);
nm.EnsureFolder("Plant/Modbus/dev1", parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition("Plant/Modbus/dev1/temp_hi", "Plant/Modbus/dev1", "temp_hi", "OffNormalAlarm", 700,
AddressSpaceRealm.Raw, isNative: true);
nm.WireAlarmNotifiers("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw, [Pump], AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw)!;
nm.ScriptedAlarmOwnerOf("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw).ShouldBeNull();
var ctx = Context(server, KeyIdentity());
var (state, _) = Wire(nm, new FakeClock(Start), [Key()], EquipmentGrant(Pump, AlarmOps));
Denied(condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("equipment grant")));
var rawDevice = new NodeAcl
{
NodeAclId = "dev", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.RawDevice,
ScopeId = "dev1", PermissionFlags = AlarmOps,
};
var scopes = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev1")] = [new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])],
};
state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths());
condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("raw grant")).ShouldBe(ServiceResult.Good);
condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("raw note")).ShouldBe(ServiceResult.Good);
native.Count.ShouldBe(1);
native[0].ConditionNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
scripted.Select(c => (c.AlarmId, c.Operation)).ShouldBe([("Plant/Modbus/dev1/temp_hi", "AddComment")]);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// With <c>Security:Acl:Enabled</c> OFF nothing changes: the activation-time <c>AlarmAck</c> role decides,
/// bound or unbound, and a session without it is denied — the binding is consulted only by the ACL path.
/// </summary>
[Fact]
public async Task With_acl_off_the_alarm_ack_role_decides_bound_or_unbound()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var routed = Route(nm);
Topology(nm);
nm.MaterialiseAlarmCondition("scripted-orphan", "EQ-not-materialised", "orphan", "AlarmCondition", 500, AddressSpaceRealm.Uns);
Wire(nm, new FakeClock(Start), [Key()], enabled: false, EquipmentGrant(Fan, AlarmOps));
var bound = nm.TryGetAlarmCondition(Alarm)!;
var orphan = nm.TryGetAlarmCondition("scripted-orphan")!;
var withRole = Context(server, KeyIdentity(OpcUaDataPlaneRoles.AlarmAck));
bound.OnAcknowledge!(withRole, bound, EventId(), new LocalizedText("1")).ShouldBe(ServiceResult.Good);
orphan.OnAcknowledge!(withRole, orphan, EventId(), new LocalizedText("2")).ShouldBe(ServiceResult.Good);
var noRole = Context(server, KeyIdentity());
Denied(bound.OnAcknowledge!(noRole, bound, EventId(), new LocalizedText("3")));
routed.Select(c => c.AlarmId).ShouldBe([Alarm, "scripted-orphan"]);
}
finally { await host.DisposeAsync(); }
}
// ───────────────────────────── helpers ─────────────────────────────
/// <summary>
/// Area1 / Line1 / {Pump, Fan}, every folder under its opaque entity id, with one scripted alarm under each
/// equipment — the shape <c>AddressSpaceApplier</c> materialises in production.
/// </summary>
private static void Topology(OtOpcUaNodeManager nm)
{
nm.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns);
nm.EnsureFolder(LineId, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns);
nm.EnsureFolder(Pump, parentNodeId: LineId, displayName: "Pump", realm: AddressSpaceRealm.Uns);
nm.EnsureFolder(Fan, parentNodeId: LineId, displayName: "Fan", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
}
private static Dictionary<string, string> Areas() => new() { [AreaId] = "Area1" };
/// <summary>The production resolver over the topology's NAMES, as the deployment artifact builds it.</summary>
private static AclScopePathResolver Resolver(IReadOnlyDictionary<string, string>? areas = null) => new(
new Dictionary<string, (string? ParentId, string Name)>(),
new Dictionary<string, (string? RawFolderId, string Name)>(),
new Dictionary<string, (string DriverInstanceId, string Name)>(),
new Dictionary<string, (string? ParentId, string DeviceId, string Name)>(),
new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>
{
// A tag referenced by Pump, so a Tag grant resolves to a path BENEATH the owner.
["TAG-1"] = ("dev-x", null, "Speed"),
},
areas ?? Areas(),
new Dictionary<string, (string UnsAreaId, string Name)> { [LineId] = (AreaId, "Line1") },
new Dictionary<string, (string UnsLineId, string Name)>
{
[Pump] = (LineId, "Pump"),
[Fan] = (LineId, "Fan"),
},
[(Pump, "TAG-1", "Speed")]);
/// <summary>Every equipment's owner path, through the same resolver call the artifact makes.</summary>
private static Dictionary<string, NodeAclPath> OwnerPaths()
{
var resolver = Resolver();
return new Dictionary<string, NodeAclPath>
{
[Pump] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Pump).ShouldHaveSingleItem(),
[Fan] = resolver.ResolveOne(NodeAclScopeKind.Equipment, Fan).ShouldHaveSingleItem(),
};
}
private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire(
OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants) =>
Wire(nm, clock, keys, enabled: true, Areas(), grants);
private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire(
OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, IReadOnlyDictionary<string, string> areas,
params NodeAcl[] grants) =>
Wire(nm, clock, keys, enabled: true, areas, grants);
private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire(
OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, bool enabled, params NodeAcl[] grants) =>
Wire(nm, clock, keys, enabled, Areas(), grants);
private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire(
OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, bool enabled,
IReadOnlyDictionary<string, string> areas, NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths());
var authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock);
nm.DataPlaneAuthority = state;
nm.Authorizer = authorizer;
return (state, authorizer);
}
/// <summary>A new deployment of the same topology carrying <paramref name="keys"/> and <paramref name="grants"/>.</summary>
private static void Reapply(DataPlaneAuthorityState state, string revision, ApiKey[] keys, params NodeAcl[] grants) =>
state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths());
private static NodeAcl EquipmentGrant(string equipmentId, NodePermissions flags) => new()
{
NodeAclId = $"acl-{equipmentId}",
ClusterId = Cluster,
Principal = Principal,
ScopeKind = NodeAclScopeKind.Equipment,
ScopeId = equipmentId,
PermissionFlags = flags,
};
private static NodeAcl ClusterGrant(NodePermissions flags) => new()
{
NodeAclId = "acl-cluster",
ClusterId = Cluster,
Principal = Principal,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = flags,
};
private static ApiKey Key(DateTime? expiresUtc = null) => new()
{
ApiKeyId = KeyId,
Name = KeyId,
SecretHash = "hash",
Principal = Principal,
Enabled = true,
CreatedUtc = Start.AddDays(-1),
ExpiresUtc = expiresUtc,
};
/// <summary>A key-carrying session; no roles unless given — under enforcement the grant alone must decide.</summary>
private static RoleCarryingUserIdentity KeyIdentity(params string[] roles) =>
new(new UserNameIdentityToken { UserName = "apikey" }, roles, Principal, KeyId);
private static ServerSystemContext Context(OtOpcUaSdkServer server, RoleCarryingUserIdentity identity) =>
new(server.CurrentInstance) { UserIdentity = identity };
private static List<AlarmCommand> Route(OtOpcUaNodeManager nm)
{
var routed = new List<AlarmCommand>();
nm.AlarmCommandRouter = routed.Add;
return routed;
}
private static ByteString EventId() => ByteString.From(Guid.NewGuid().ToByteArray());
private static void Denied(ServiceResult result) =>
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
/// <summary>A hand-advanced clock; the authorizer reads it on every decision.</summary>
private sealed class FakeClock(DateTime utcNow) : TimeProvider
{
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
public void Reset(DateTime utcNow) => _now = new DateTimeOffset(utcNow, TimeSpan.Zero);
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var started = await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.ScriptedOwnerScopeTest",
ApplicationUri = $"urn:OtOpcUa.ScriptedOwnerScopeTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
return (started.Host, started.Server);
}
/// <inheritdoc />
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch (IOException) { /* best effort */ }
}
}
}
@@ -180,4 +180,49 @@ public sealed class DeploymentArtifactScopePathTests
projection.Grants.ShouldHaveSingleItem();
projection.ScopePaths.ShouldBeEmpty();
}
/// <summary>
/// Gitea #797 — the artifact also yields every equipment's OWN path (the scripted-alarm owner lookup), and
/// it is identical to the path the same artifact plants an Equipment grant at. That equality is what makes
/// a narrow equipment grant authorize the equipment's scripted alarms: if the two were produced by
/// different rules, the owner binding would silently authorize at a node no grant can reach.
/// </summary>
[Fact]
public void Every_equipment_owner_path_equals_the_path_its_equipment_grant_is_planted_at()
{
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
projection.EquipmentScopePaths.ShouldContainKey("EQ-mixer");
var owner = projection.EquipmentScopePaths["EQ-mixer"];
owner.Segments.ShouldBe(["uns", "West", "L1", "Mixer"]);
projection.ScopePaths[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")]
.ShouldHaveSingleItem().Segments.ShouldBe(owner.Segments);
// Keyed by identifier (OrdinalIgnoreCase), like every other id lookup on the authorization path.
projection.EquipmentScopePaths.ContainsKey("eq-MIXER").ShouldBeTrue();
// A grant naming an equipment the artifact does not carry yields no owner path either.
projection.EquipmentScopePaths.ContainsKey("EQ-gone").ShouldBeFalse();
var snapshot = new DataPlaneAuthorityState().Apply(
"c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths);
snapshot.TryGetEquipmentUnsSegments("EQ-mixer", out var segments).ShouldBeTrue();
segments.ShouldBe(["West", "L1", "Mixer"]);
}
/// <summary>An equipment whose UNS chain does not resolve (dangling line) has no owner path.</summary>
[Fact]
public void An_equipment_with_a_dangling_line_has_no_owner_path()
{
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
Encoding.UTF8.GetBytes("""
{
"UnsAreas": [{"UnsAreaId":"UA-w","ClusterId":"c1","Name":"West"}],
"Equipment": [{"EquipmentId":"EQ-orphan","UnsLineId":"UL-missing","Name":"Orphan"}],
"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}]
}
"""),
NodeId);
projection.EquipmentScopePaths.ShouldBeEmpty();
}
}
@@ -664,10 +664,11 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
public DataPlaneAuthoritySnapshot Apply(
string? clusterId, string revision, IReadOnlyList<ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey> keys,
IReadOnlyList<ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null)
{
Interlocked.Increment(ref _applies);
return new DataPlaneAuthorityState().Apply(clusterId, revision, keys, grants, scopePaths);
return new DataPlaneAuthorityState().Apply(clusterId, revision, keys, grants, scopePaths, equipmentScopePaths);
}
}