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:
@@ -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,
|
||||
|
||||
+2
-2
@@ -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")
|
||||
{
|
||||
|
||||
+370
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -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()
|
||||
{
|
||||
|
||||
+681
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user