Merge fix/808-uns-folder-scope

This commit is contained in:
Joseph Doherty
2026-11-10 23:44:32 -05:00
18 changed files with 1603 additions and 75 deletions
+31 -4
View File
@@ -99,10 +99,37 @@ Two things to know before relying on any of this:
closes the install-authority-then-rebuild window and a failed apply leaving a moved alarm on its old
equipment. ⚠️ **Fail closed, even for Cluster grants:** an unbound / replaced / undeployed / line-less
owner is denied under every grant (no realm-root fallback). Native alarms still use RawPath; ACL-off
is unchanged. ⚠️ **Not end-to-end yet — #808:** UNS folders and scripted condition children still
split opaque ids for Read/Browse/Subscribe/Server-object event visibility, so a narrow principal can't
obtain the EventId Ack/Confirm/AddComment need (Shelve/Enable work). `ServerEventRig`'s folder id
`EqA` == scope path `[uns, EqA]` hides this — tests must use opaque `UA-/UL-/EQ-` ids.
is unchanged.
- **Opaque-id UNS nodes are PLACED, never split, for every operation (#808).** UNS Area/Line/Equipment folders
are minted under their entity ids (`s=UA-…/UL-…/EQ-…`), virtual tags under `s=EQ-…/Name`, scripted
conditions + children under `s=<alarmId>[/…]`, while grants sit at NAME paths. `OtOpcUaNodeManager.Authorizer`'s
setter attaches `ResolveUnsAclSubject` as `DataPlaneAuthorizer.UnsSubjectResolver` (one wiring, no second
site), which reports LIVE facts (`UnsAclSubject`: folder id ancestry / owner binding); `Evaluate` places them
against the CURRENT generation **before** the decision cache and caches only the walk, keyed on the resolved
scope (`\0`-prefixed) — never on the NodeId, or a deny would stick past a rebuild. Folders use the generation's
`UnsFolderScopes` (folderId → path + id chain, `DeploymentArtifact.BuildUnsFolderScopes`, same resolver as the
grants; ids two kinds share are dropped) and **require live chain == generation chain** (moved equipment ⇒
denied under old AND new line until the rebuild). Owned nodes (vtags, conditions, children) get
`owner path + DataPlaneAuthorizer.OwnedNodeSegment` — only equipment-or-ancestor grants confer them, and a Tag
grant's Browse transit can't enumerate them. Conditions share `TryPlaceScriptedCondition` with the #797 command
gate, which ALSO requires the owner folder's live chain to match (review: else the new line could command a moved
equipment's alarm while its folder is denied). Reference variables (`s=Area/Line/Equip/Name`) keep their split
path (so Tag grants match) but only while it equals their live equipment folder's placement + one name — a
moved/renamed equipment's old-name variables are denied (Read AND Write) under old and new names until
re-pointed. An id NO live node holds (removed / probe / NodeDeleted `Affected`) is `Unknown`: placed at
`[\0unknown]`, so only Cluster / Namespace-uns grants decide it and a removed opaque id can never split onto a
same-named narrow grant — except a 4-segment (reference-shaped) id, which keeps its split. ⇒ narrow grantees do
NOT receive NodeDeleted for removed UNS folders / vtags / conditions (by decision; they re-browse). Identifiers
containing NUL are denied before the cache (resolved-scope keys are NUL-prefixed — a client could otherwise
poison a real folder's cached verdict). ⚠️ The folder-owned rule keys on "first id segment == nearest slash-free
live UNS folder ancestor's id", which can only narrow (an Area named exactly like its equipment's id loses that
variable's Tag-grant match). ⚠️ `ConditionVisibilityScopes` returns the condition ALONE for a scripted condition,
so a moved alarm can't reach its old owner via the Server object; direct per-folder event items still ride the
SDK fan-out of the folder it hangs under (follow-up). A UNS node with NO live hierarchy-folder ancestor (rig-only
shape) keeps the plain split. Unplaceable ⇒ denied for every grant incl. Cluster (a behaviour change for Cluster
keys only in the stale window / corrupt topology). Tests: `UnsFolderScopeWireTests` (opaque ids, incl. one fed by
`DeploymentArtifact.ParseDataPlaneAuthority`); rig tests (`ServerEventRig.FolderScopes()`, `EqA` ⇒ `[uns, EqA]`)
install folder scopes explicitly — without them every UNS folder and scripted condition denies.
- **Revocation reaches live subscriptions** (stage 6): every other operation re-asks per request, but a
subscription is authorized once and then streams — and streaming is how machine consumers actually
read this server. `DataPlaneAuthorityState.Changed` fires `RevalidateMonitoredItems`. ⚠️ A denied
+26 -2
View File
@@ -579,8 +579,32 @@ re-binds it. 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.
**Where an opaque-id UNS node is placed for Read / Browse / Subscribe / HistoryRead (Gitea #808).** UNS Area /
Line / Equipment folders are minted under their opaque entity ids (`s=UA-…`, `s=UL-…`, `s=EQ-…`), an equipment's
virtual tags under `s=EQ-…/Name`, and scripted conditions plus their instance-scoped children under
`s=<ScriptedAlarmId>[/…]` — none of which is a grant path, so splitting them reached only `Cluster` / `uns` grants.
`DataPlaneAuthorizer` now asks the node manager (`ResolveUnsAclSubject`, attached when the authorizer is set) how
the node sits in the LIVE address space and places it against the CURRENT generation: a hierarchy folder at its
entity's path from the generation's `UnsFolderScopes` (built by the same `AclScopePathResolver` call that plants
`UnsArea`/`UnsLine`/`Equipment` grants), **only while the live folder-id ancestry equals the one the generation
deploys** (a moved equipment is denied under its old and new line's grants until the rebuild re-parents it); a
virtual tag / its sub-folder at its equipment's path plus a reserved segment; a scripted condition and its children
at the owning equipment's path plus that reserved segment, through exactly the #797 owner rule. The reserved segment
means only grants on the equipment or an ancestor confer them — a `Tag` grant beneath the equipment never does, and
its Browse transit does not expose the equipment's alarms or virtual tags. A scripted condition additionally requires
its owner folder's live ancestry to match the generation's (the folder rule). UNS reference variables
(`s=Area/Line/Equipment/Name`) keep their id as their path — but only while it equals their live equipment folder's
placement plus one name, so a moved or renamed equipment's variables (still on the old-name id) are denied for Read
and Write under old and new names until the address space re-points them. An id that no live node holds (a removed
folder / virtual tag / condition, a probe, a NodeDeleted model change) is decided only by a `Cluster` or `uns`
`Namespace` grant — so narrow grantees do not receive NodeDeleted for removed opaque-id nodes, and a removed id can
never match an unrelated narrow grant that shares its name; a four-segment (reference-shaped) id keeps its split.
Identifiers containing NUL are refused. Server-object (`i=2253`) delivery of a
**scripted** condition is now decided on the condition alone (its folders add nothing in steady state and would
leak a moved alarm to its old owner). So a key holding only an `Equipment` grant can navigate to its equipment,
read its alarm children, receive its scripted alarm events through the Server object and Acknowledge / Confirm /
AddComment with the EventId it received. Fail-closed throughout: an unplaceable node is denied under every grant.
| 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 |
@@ -36,7 +36,8 @@ public sealed class DataPlaneAuthoritySnapshot
IReadOnlyDictionary<string, ApiKey> keysById,
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal,
IReadOnlyDictionary<string, NodeAclPath> equipmentScopePaths,
IReadOnlyDictionary<string, string> scriptedAlarmOwners)
IReadOnlyDictionary<string, string> scriptedAlarmOwners,
IReadOnlyDictionary<string, UnsFolderScope> unsFolderScopes)
{
ClusterId = clusterId;
RevisionHash = revisionHash;
@@ -49,6 +50,7 @@ public sealed class DataPlaneAuthoritySnapshot
_grantsByPrincipal = grantsByPrincipal;
EquipmentScopePaths = equipmentScopePaths;
ScriptedAlarmOwners = scriptedAlarmOwners;
UnsFolderScopes = unsFolderScopes;
}
private readonly IReadOnlyDictionary<string, ApiKey> _keysById;
@@ -131,6 +133,69 @@ public sealed class DataPlaneAuthoritySnapshot
return true;
}
/// <summary>
/// <c>UNS folder id → (its ACL path, its folder-id chain)</c> for every UNS Area, Line and Equipment in the
/// artifact this generation was built from (Gitea #808). Keyed <c>OrdinalIgnoreCase</c> (identifier rule).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists.</b> The address space mints UNS Area / Line / Equipment folders under their opaque
/// entity ids (<c>s=UA-…</c>, <c>s=UL-…</c>, <c>s=EQ-…</c>) — and an equipment's virtual tags under
/// <c>s=EQ-…/Name</c> — while every UNS grant is planted at a NAME path
/// (<c>[uns, Area, Line, Equipment]</c>). Splitting such a NodeId can never reach a narrow grant, so the
/// authorizer resolves the folder id to its path HERE instead, against the same generation as the grants.
/// </para>
/// <para>
/// <see cref="UnsFolderScope.IdChain"/> is the folder's ancestry as THIS generation deploys it
/// (<c>[areaId, lineId, equipmentId]</c>). The authorizer requires the LIVE folder's ancestry to equal it:
/// the generation is installed before its address space is applied, and an apply that throws keeps the
/// old address space, so a folder moved to another line would otherwise be authorized at its new path
/// while it still hangs under the old one. A mismatch denies until the address space catches up.
/// </para>
/// <para>
/// An entry confers nothing and is never consulted by the trie build. A folder absent here (an orphan
/// line, a line-less equipment, an id two entity kinds share) has no path, and every node resolved
/// through it is denied under enforcement.
/// </para>
/// </remarks>
public IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes { get; }
/// <summary>
/// The ACL path WITHIN the UNS realm (realm segment stripped) of the UNS folder <paramref name="folderId"/>,
/// provided the live folder's ancestry <paramref name="liveIdChain"/> is exactly the one this generation
/// deploys. False (the caller must deny) when the folder is unknown, misshapen or placed differently.
/// </summary>
/// <param name="folderId">The folder's id (its <c>s=</c> identifier).</param>
/// <param name="liveIdChain">The live folder ancestry, root-first, ending at <paramref name="folderId"/>.</param>
/// <param name="unsSegments">The path below the realm root, e.g. <c>[Area, Line, Equipment]</c>.</param>
/// <returns><see langword="true"/> when the folder is known and placed as this generation deploys it.</returns>
public bool TryGetUnsFolderSegments(
string folderId, IReadOnlyList<string> liveIdChain, out IReadOnlyList<string> unsSegments)
{
unsSegments = [];
if (string.IsNullOrEmpty(folderId) || liveIdChain is null || liveIdChain.Count == 0) return false;
if (!string.Equals(liveIdChain[^1], folderId, StringComparison.OrdinalIgnoreCase)) return false;
if (!UnsFolderScopes.TryGetValue(folderId, out var scope)) return false;
// Shape-checked rather than trusted, as TryGetEquipmentUnsSegments is: a path that does not start at the
// UNS realm, or names the realm alone, places nothing — accepting it would evaluate at the realm root.
if (scope.Path.Segments.Count < 2
|| !string.Equals(scope.Path.Segments[0], NodeAclRealms.Uns, StringComparison.Ordinal)
|| scope.IdChain.Count != scope.Path.Segments.Count - 1
|| scope.IdChain.Count != liveIdChain.Count)
{
return false;
}
for (var i = 0; i < liveIdChain.Count; i++)
{
if (!string.Equals(scope.IdChain[i], liveIdChain[i], StringComparison.OrdinalIgnoreCase)) return false;
}
unsSegments = scope.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.
@@ -212,6 +277,8 @@ public sealed class DataPlaneAuthoritySnapshot
/// null ⇒ none, which denies every scripted-condition command under enforcement.</param>
/// <param name="scriptedAlarmOwners"><c>ScriptedAlarmId → EquipmentId</c> as this generation deploys them
/// (see <see cref="ScriptedAlarmOwners"/>); null ⇒ none.</param>
/// <param name="unsFolderScopes">Every UNS folder's path + id chain (see <see cref="UnsFolderScopes"/>);
/// null ⇒ none, which denies every UNS folder-scoped node under enforcement.</param>
/// <returns>The built snapshot.</returns>
public static DataPlaneAuthoritySnapshot Create(
string? clusterId,
@@ -222,7 +289,8 @@ public sealed class DataPlaneAuthoritySnapshot
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyList<PermissionTrieBuildDiagnostic>? droppedGrants = null,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null)
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
{
ArgumentNullException.ThrowIfNull(revisionHash);
ArgumentNullException.ThrowIfNull(keys);
@@ -279,12 +347,37 @@ public sealed class DataPlaneAuthoritySnapshot
}
}
var folders = new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase);
if (unsFolderScopes is not null)
{
foreach (var (folderId, scope) in unsFolderScopes)
{
if (string.IsNullOrWhiteSpace(folderId) || scope?.Path is null || scope.IdChain is null) continue;
// A case-variant duplicate can only come from a corrupt input; mark it unplaceable rather than let
// iteration order decide which placement authorizes.
if (!folders.TryAdd(folderId, scope)) folders[folderId] = UnsFolderScope.Unplaceable;
}
}
return new DataPlaneAuthoritySnapshot(
clusterId, revisionHash, generationId, keys, grants, scopePaths, droppedGrants ?? [], byId, frozen,
equipment, owners);
equipment, owners, folders);
}
}
/// <summary>
/// Where one UNS Area / Line / Equipment folder sits, as a generation deploys it (Gitea #808).
/// </summary>
/// <param name="Path">Its ACL path, <c>[uns, Area(, Line(, Equipment))]</c> — the exact path a grant on that
/// entity is planted at.</param>
/// <param name="IdChain">Its folder-id ancestry, root-first, ending at the folder itself
/// (<c>[areaId, lineId, equipmentId]</c>) — one id per path segment below the realm.</param>
public sealed record UnsFolderScope(NodeAclPath Path, IReadOnlyList<string> IdChain)
{
/// <summary>A placement no live folder can match (a realm-only path) — the deny marker for a corrupt duplicate.</summary>
public static readonly UnsFolderScope Unplaceable = new(new NodeAclPath([NodeAclRealms.Uns]), []);
}
/// <summary>Read side of the node's current data-plane authorization inputs.</summary>
public interface IDataPlaneAuthoritySource
{
@@ -351,6 +444,8 @@ public interface IDataPlaneAuthoritySink
/// the owner lookup for scripted alarm conditions (Gitea #797). Null ⇒ none.</param>
/// <param name="scriptedAlarmOwners"><c>ScriptedAlarmId → EquipmentId</c> as the same artifact deploys
/// them (Gitea #797 review). Null ⇒ none, which denies every scripted-condition command under enforcement.</param>
/// <param name="unsFolderScopes">Every UNS Area/Line/Equipment folder's path + id chain from the same
/// artifact (Gitea #808). Null ⇒ none, which denies every UNS folder-scoped node under enforcement.</param>
/// <returns>The installed snapshot.</returns>
DataPlaneAuthoritySnapshot Apply(
string? clusterId,
@@ -359,7 +454,8 @@ public interface IDataPlaneAuthoritySink
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null);
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null);
}
/// <summary>
@@ -409,7 +505,8 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null)
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
{
ArgumentNullException.ThrowIfNull(revisionHash);
@@ -443,7 +540,7 @@ public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPl
published = DataPlaneAuthoritySnapshot.Create(
clusterId, revisionHash, generation, keys, grants, scopePaths, dropped, equipmentScopePaths,
scriptedAlarmOwners);
scriptedAlarmOwners, unsFolderScopes);
Volatile.Write(ref _current, published);
}
@@ -633,7 +633,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public DataPlaneAuthorizer? Authorizer
{
get => _authorizer;
set => _authorizer = value;
set
{
// Gitea #808: the authorizer places UNS folders, the nodes they own and scripted conditions through THIS
// manager's live address space. Attached here — the one place an authorizer meets its node manager — so
// there is no second wiring that could silently not run (an unattached resolver splits opaque ids,
// which only ever DENIES narrow grants, so a miss would read as working enforcement).
if (value is not null) value.UnsSubjectResolver = ResolveUnsAclSubject;
_authorizer = value;
}
}
private volatile DataPlaneAuthorizer? _authorizer;
@@ -2296,6 +2304,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// authority. Never a fallback to splitting the opaque id.
var native = false;
string? owner = null;
List<string>? ownerChain = null;
lock (Lock)
{
var key = MapKey(condition.NodeId);
@@ -2303,12 +2312,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
native = true;
}
else if (_scriptedAlarmOwners.TryGetValue(key, out var binding)
&& ReferenceEquals(binding.Alarm, condition)
&& _alarmConditions.TryGetValue(key, out var live)
&& ReferenceEquals(live, condition))
else
{
owner = binding.EquipmentId;
owner = BoundScriptedOwner(key, condition);
// Gitea #808 review: the owner's folder must also sit where the generation deploys it.
ownerChain = BoundScriptedOwnerChain(key, condition);
}
}
@@ -2316,7 +2324,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
? _authorizer!.Authorize(
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId))
: _authorizer!.AuthorizeScriptedCondition(
identity, AlarmOperationFor(operation), condition.NodeId, owner);
identity, AlarmOperationFor(operation), condition.NodeId, owner, ownerChain);
}
// The activation-time AlarmAck role is honoured only while the key that earned it is
@@ -4203,6 +4211,162 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
!OwnsNamespace(nodeId)
|| _authorizer!.IsAllowedCached(identity, operation, nodeId, RealmOf(nodeId));
/// <summary>Guard against a corrupt (cyclic) parent chain in <see cref="ResolveUnsAclSubject"/>.</summary>
private const int MaxUnsAncestry = 64;
/// <summary>
/// Gitea #808 — reports how the UNS-namespace node <paramref name="identifier"/> is placed in the LIVE address
/// space, for <see cref="Security.DataPlaneAuthorizer"/> to turn into a scope against the current generation.
/// </summary>
/// <remarks>
/// <para>
/// Three shapes share the UNS namespace, and only one of them has a path for an identifier:
/// </para>
/// <list type="bullet">
/// <item><b>UNS reference variables</b> — <c>s=Area/Line/Equipment/EffectiveName</c>, NAMES: the id IS the
/// ACL path, so a <c>Tag</c> grant keeps matching it — but only while it equals its live equipment folder's
/// placement + one name (<see cref="UnsAclSubjectKind.ReferenceVariable"/>, #808 review).</item>
/// <item><b>Anything no live node holds</b> (removed, never deployed, probed):
/// <see cref="UnsAclSubjectKind.Unknown"/> — decided only by a realm-wide grant.</item>
/// <item><b>Hierarchy folders and what they own</b> — Area / Line / Equipment folders under their opaque
/// entity ids (<c>s=UA-…</c>/<c>s=UL-…</c>/<c>s=EQ-…</c>), and an equipment's virtual tags and their
/// sub-folders (<c>s=EQ-…/Name</c>): <see cref="UnsAclSubjectKind.Folder"/>, with the live id ancestry.</item>
/// <item><b>Scripted conditions and their children</b> — <c>s=&lt;ScriptedAlarmId&gt;[/…]</c>:
/// <see cref="UnsAclSubjectKind.ScriptedCondition"/>, with the exact live instance's owner binding (#797).</item>
/// </list>
/// <para>
/// <b>A folder-owned node is recognised by an id rule that can only NARROW, never widen.</b> A node is folder-
/// owned when its first id segment is the id of its NEAREST live hierarchy-folder ancestor (a folder whose id
/// carries no <c>/</c> — Area/Line/Equipment ids are <c>^[A-Za-z0-9_-]+$</c>; a virtual-tag sub-folder's id
/// always does). A reference variable's first segment is its Area's NAME, so the rule can only misfire if an
/// Area is named exactly like the referencing equipment's own id — and then the variable is authorized at that
/// very equipment (its real parent), losing only its Tag-grant match: fail closed.
/// </para>
/// <para>
/// Lock-free except for the scripted-condition branch (the native flag and owner binding are guarded by
/// <c>Lock</c>, which is reentrant): the folder and variable maps are concurrent and a node's
/// <see cref="NodeState.Parent"/> is a reference read. Every folder on the walk must be the instance the
/// folder map holds now, so a node torn down by a rebuild cannot place itself through a dead ancestry.
/// </para>
/// </remarks>
/// <param name="identifier">The node's <c>s=</c> identifier in the UNS namespace.</param>
/// <returns>The placement facts; <see cref="UnsAclSubject.Path"/> when the identifier is its own path.</returns>
internal UnsAclSubject ResolveUnsAclSubject(string identifier)
{
if (string.IsNullOrEmpty(identifier)) return UnsAclSubject.Path;
var key = MapKey(AddressSpaceRealm.Uns, identifier);
NodeState? node = _folders.TryGetValue(key, out var folder)
? folder
: _variables.TryGetValue(key, out var variable) ? variable : null;
var slash = identifier.IndexOf('/');
var firstSegment = slash < 0 ? identifier.AsSpan() : identifier.AsSpan(0, slash);
if (node is null)
{
// Not a folder or variable: a scripted condition (s=<id>) or one of its instance-scoped children
// (s=<id>/<browse path>, #629). Anything else is held by NO live node — removed, never deployed, or a
// probe — and is Unknown: only a realm-wide grant decides it (review of #808: splitting a removed opaque
// id let it match a narrow grant on an unrelated entity of the same name).
var conditionId = slash < 0 ? identifier : identifier[..slash];
var conditionKey = MapKey(AddressSpaceRealm.Uns, conditionId);
if (!_alarmConditions.TryGetValue(conditionKey, out var condition)) return UnsAclSubject.Unknown;
lock (Lock)
{
if (_nativeAlarmNodeIds.Contains(conditionKey)) return UnsAclSubject.Path;
return UnsAclSubject.ScriptedCondition(
conditionId, BoundScriptedOwner(conditionKey, condition), BoundScriptedOwnerChain(conditionKey, condition));
}
}
// The nearest live hierarchy folder at or above the node.
FolderState? owning = null;
var current = ReferenceEquals(node, folder) ? node : ParentOf(node);
for (var depth = 0; current is not null && depth < MaxUnsAncestry; depth++, current = ParentOf(current))
{
if (LiveUnsFolderId(current) is not { } id) break;
if (id.Contains('/')) continue;
owning = (FolderState)current;
break;
}
// No live hierarchy folder above it: a shape only test rigs mint (a UNS node hung straight under the root).
if (owning is null) return UnsAclSubject.Path;
var chain = LiveHierarchyChain(owning);
// A node whose first id segment is NOT its equipment's id is a UNS reference variable
// (s=Area/Line/Equipment/EffectiveName): authorized at its split path, but only while that path is where the
// current generation places the equipment it hangs under (review of #808 — a moved or renamed equipment's
// variables keep their OLD-name ids until re-pointed, and the old names' grants must not keep them).
return firstSegment.SequenceEqual(owning.NodeId.IdentifierAsString)
? UnsAclSubject.Folder(chain, isFolderItself: ReferenceEquals(node, owning))
: UnsAclSubject.ReferenceVariable(chain);
}
/// <summary>
/// The live ancestry of <paramref name="folder"/>, root-first and ending at it: every live hierarchy folder (a
/// slash-free UNS folder id) up to the first node that is not one (the shared root).
/// </summary>
/// <param name="folder">A live hierarchy folder.</param>
/// <returns>The id chain.</returns>
private List<string> LiveHierarchyChain(NodeState folder)
{
var chain = new List<string>(3);
NodeState? current = folder;
for (var depth = 0; current is not null && depth < MaxUnsAncestry; depth++, current = ParentOf(current))
{
if (LiveUnsFolderId(current) is not { } id || id.Contains('/')) break;
chain.Add(id);
}
chain.Reverse();
return chain;
}
/// <summary>The <c>EquipmentId</c> the exact live <paramref name="condition"/> is bound to, or null. Call under <c>Lock</c>.</summary>
private string? BoundScriptedOwner(RealmKey key, ConditionState condition) =>
_scriptedAlarmOwners.TryGetValue(key, out var binding)
&& ReferenceEquals(binding.Alarm, condition)
&& _alarmConditions.TryGetValue(key, out var live)
&& ReferenceEquals(live, condition)
? binding.EquipmentId
: null;
/// <summary>
/// The live folder-id ancestry of the equipment folder a BOUND scripted condition hangs under (the #808 folder
/// rule applied to the #797 owner), or null when unbound or no longer under that folder. Call under <c>Lock</c>.
/// </summary>
private List<string>? BoundScriptedOwnerChain(RealmKey key, ConditionState condition) =>
BoundScriptedOwner(key, condition) is { } owner
&& ParentOf(condition) is FolderState parent
&& LiveUnsFolderId(parent) is { } parentId
&& string.Equals(parentId, owner, StringComparison.Ordinal)
? LiveHierarchyChain(parent)
: null;
/// <summary>The node's parent in the instance tree, or null for a type/root node.</summary>
/// <param name="node">The node.</param>
/// <returns>Its parent.</returns>
private static NodeState? ParentOf(NodeState node) => (node as BaseInstanceState)?.Parent;
/// <summary>
/// The id of <paramref name="node"/> when it is a UNS folder this manager currently holds — the exact instance
/// the folder map holds, not a torn-down one — else null.
/// </summary>
/// <param name="node">A node on an ancestry walk.</param>
/// <returns>The folder's <c>s=</c> id, or null.</returns>
private string? LiveUnsFolderId(NodeState node) =>
node is FolderState f
&& f.NodeId.NamespaceIndex == NamespaceIndexForRealm(AddressSpaceRealm.Uns)
&& f.NodeId.TryGetValue(out string id)
&& _folders.TryGetValue(MapKey(AddressSpaceRealm.Uns, id), out var live)
&& ReferenceEquals(live, f)
? id
: null;
// Denied slots are answered and marked ReadValueId.Processed before the base
// runs, so a refused read never resolves the node or samples its value. Every attribute of a node
// is gated by the single Read permission rather than only Value: the finer split
@@ -5288,6 +5452,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
lock (Lock)
{
// Gitea #808: a SCRIPTED condition is admitted by its own node alone. The authorizer places it at its
// owning equipment (#797 owner binding + this generation's deployed owner), so every grant its equipment
// folder would admit admits the condition too — the folders add nothing in steady state. What they WOULD
// add is the stale window: a moved alarm still hanging under its old equipment's folder (generation
// installed, address space not yet rebuilt, or the apply failed) would keep reaching the old owner's
// sessions through that folder while its node, its children and its commands are denied to them.
var key = MapKey(condition.NodeId);
if (condition.NodeId.NamespaceIndex == NamespaceIndexForRealm(AddressSpaceRealm.Uns)
&& _alarmConditions.TryGetValue(key, out var live)
&& ReferenceEquals(live, condition)
&& !_nativeAlarmNodeIds.Contains(key))
{
return scopes;
}
for (var ancestor = (condition as BaseInstanceState)?.Parent;
ancestor is not null;
ancestor = (ancestor as BaseInstanceState)?.Parent)
@@ -123,6 +123,41 @@ public sealed class DataPlaneAuthorizer
/// </summary>
public SessionAclDecisionCache Decisions { get; } = new();
/// <summary>
/// The live-address-space lookup that says how a UNS-namespace node is placed (Gitea #808), attached by the
/// node manager that owns the address space this authorizer gates. Null ⇒ every UNS identifier is split as a
/// path, the pre-#808 behaviour (a test authorizer used without a node manager).
/// </summary>
/// <remarks>
/// Read per decision, BEFORE the decision cache: the facts it reports (a folder's live ancestry, a scripted
/// condition's live owner binding) change with the address space rather than with the generation, so they
/// must never be memoized. What the cache then memoizes is only the grant walk at the RESOLVED scope, keyed on
/// that scope — which is a pure function of the generation. Must be cheap and non-throwing: it runs on every
/// Read / Browse / subscribe decision on a UNS node.
/// </remarks>
public Func<string, UnsAclSubject>? UnsSubjectResolver { get; set; }
/// <summary>
/// The path segment appended below an owning equipment / folder for a node that is NOT a UNS reference
/// variable — a scripted condition and its children, a virtual tag (Gitea #808). It can never be a grant path
/// segment (those are entity names), so the walk confers exactly the grants on the owner or an ancestor of it:
/// a <c>Tag</c> grant beneath the same equipment never matches, and — the reason it is not simply the owner's
/// own path — the owner's Browse TRANSIT (an ancestor of a Tag grant) is not inherited, so a principal holding
/// only a Tag grant cannot enumerate the equipment's alarms and virtual tags by browsing.
/// </summary>
internal const string OwnedNodeSegment = "\u0000owned";
/// <summary>
/// The single segment, directly under the UNS realm, that an identifier no live node holds is placed at
/// (Gitea #808 review): only a realm-wide grant reaches it, and — unlike the realm root itself — it carries no
/// Browse transit, so a narrow grantee is never handed a removed opaque id (a NodeDeleted model change, a
/// probe) and a removed id can never match a narrow grant on an unrelated entity that shares its name.
/// </summary>
internal const string UnknownNodeSegment = "\u0000unknown";
/// <summary>The depth of a UNS reference-variable id: <c>Area/Line/Equipment/EffectiveName</c>.</summary>
private const int ReferenceVariableSegments = 4;
/// <summary>
/// Authorizes <paramref name="operation"/> on <paramref name="nodeId"/> through the decision cache —
/// the entry point for Read, Browse and subscription creation, which ask the same question many
@@ -275,12 +310,15 @@ public sealed class DataPlaneAuthorizer
/// <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>
/// <param name="ownerFolderChain">The LIVE folder-id ancestry of the equipment folder the condition hangs under
/// (root-first, ending at <paramref name="ownerEquipmentId"/>), or null when unbound (Gitea #808 review).</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)
string? ownerEquipmentId,
IReadOnlyList<string>? ownerFolderChain)
{
if (identity is null)
return Deny(operation, conditionNodeId, principal: null, "the session carries no role-carrying identity");
@@ -300,28 +338,11 @@ public sealed class DataPlaneAuthorizer
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))
conditionNodeId.TryGetValue(out string alarmId);
if (!TryPlaceScriptedCondition(
snapshot, alarmId, ownerEquipmentId, ownerFolderChain, out var segments, out var reason))
{
return Deny(operation, conditionNodeId, identity.Principal,
"the scripted condition has no live owner binding (unknown, removed or replaced)");
}
// The live binding describes the address space as last materialised; the generation is installed BEFORE
// its address space is applied, and a failed apply keeps the old one. Require both to name the SAME owner
// for this exact alarm id, so a moved alarm is denied — under its old AND its new owner's grant — until
// the address space catches up, rather than staying commandable with the old owner's grant.
if (!conditionNodeId.TryGetValue(out string alarmId)
|| !snapshot.TryGetScriptedAlarmOwner(alarmId, out var deployedOwner)
|| !string.Equals(deployedOwner, ownerEquipmentId, StringComparison.OrdinalIgnoreCase))
{
return Deny(operation, conditionNodeId, identity.Principal,
$"the live owner binding ({ownerEquipmentId}) does not match generation {snapshot.GenerationId}'s deployed owner of this scripted alarm");
}
if (!snapshot.TryGetEquipmentUnsSegments(ownerEquipmentId, out var segments))
{
return Deny(operation, conditionNodeId, identity.Principal,
$"owner equipment {ownerEquipmentId} has no UNS path in generation {snapshot.GenerationId}");
return Deny(operation, conditionNodeId, identity.Principal, reason);
}
var scope = new NodeScope
@@ -336,6 +357,173 @@ public sealed class DataPlaneAuthorizer
: DenyNoGrant(operation, conditionNodeId, identity.Principal, snapshot.GenerationId, cached: false);
}
/// <summary>
/// Places a scripted condition at its owning equipment's UNS path in <paramref name="snapshot"/> — the one
/// rule both the Part 9 command gate (#797) and the Read / Browse / subscribe / event-visibility path (#808)
/// use, so "can see" and "can command" can never disagree about where the alarm lives.
/// </summary>
/// <param name="snapshot">The generation in force.</param>
/// <param name="alarmId">The condition's <c>ScriptedAlarmId</c>.</param>
/// <param name="ownerEquipmentId">The live owner binding, or null when unbound.</param>
/// <param name="ownerFolderChain">The live id ancestry of the owner's folder, or null when unbound.</param>
/// <param name="segments">The owning equipment's path below the UNS realm root.</param>
/// <param name="reason">Why placement failed (the caller denies).</param>
/// <returns><see langword="true"/> when the condition is placed.</returns>
private static bool TryPlaceScriptedCondition(
DataPlaneAuthoritySnapshot snapshot,
string? alarmId,
string? ownerEquipmentId,
IReadOnlyList<string>? ownerFolderChain,
out IReadOnlyList<string> segments,
out string reason)
{
segments = [];
if (string.IsNullOrEmpty(ownerEquipmentId))
{
reason = "the scripted condition has no live owner binding (unknown, removed or replaced)";
return false;
}
// The live binding describes the address space as last materialised; the generation is installed BEFORE
// its address space is applied, and a failed apply keeps the old one. Require both to name the SAME owner
// for this exact alarm id, so a moved alarm is denied — under its old AND its new owner's grant — until
// the address space catches up, rather than staying visible / commandable with the old owner's grant.
if (string.IsNullOrEmpty(alarmId)
|| !snapshot.TryGetScriptedAlarmOwner(alarmId, out var deployedOwner)
|| !string.Equals(deployedOwner, ownerEquipmentId, StringComparison.OrdinalIgnoreCase))
{
reason = $"the live owner binding ({ownerEquipmentId}) does not match generation {snapshot.GenerationId}'s deployed owner of this scripted alarm";
return false;
}
if (!snapshot.TryGetEquipmentUnsSegments(ownerEquipmentId, out segments))
{
reason = $"owner equipment {ownerEquipmentId} has no UNS path in generation {snapshot.GenerationId}";
return false;
}
// The owner's FOLDER must also sit where this generation deploys it (the folder rule, #808 review): an
// equipment moved to another line keeps its id, so the owner checks above pass while its folder — and this
// alarm under it — still hangs under the old line. Without this the new line's grant could read and command
// the alarm while the folder above it is denied.
if (ownerFolderChain is null
|| !snapshot.TryGetUnsFolderSegments(ownerEquipmentId, ownerFolderChain, out var folderSegments)
|| !folderSegments.SequenceEqual(segments, StringComparer.Ordinal))
{
segments = [];
reason = $"owner equipment {ownerEquipmentId}'s folder is not placed as generation {snapshot.GenerationId} deploys it (moved, or not yet rebuilt)";
return false;
}
reason = string.Empty;
return true;
}
/// <summary>
/// Turns a non-<see cref="UnsAclSubjectKind.Path"/> UNS subject into the segments it is authorized at in
/// <paramref name="snapshot"/> (Gitea #808). For a folder, a node a folder owns, a reference variable and a
/// scripted condition every gap fails — the caller denies, with no fallback to splitting the identifier, to
/// the realm root or to the cluster. The one deliberate exception is <see cref="UnsAclSubjectKind.Unknown"/>
/// (no live node holds the id): it is placed at <see cref="UnknownNodeSegment"/> directly under the realm, so
/// only a realm-wide (<c>Cluster</c> / <c>Namespace uns</c>) grant decides it — never a narrow grant that
/// happens to share a name with a removed opaque id — except a reference-variable-shaped id (exactly four
/// segments), which keeps its name-path split.
/// </summary>
/// <param name="identifier">The node's <c>s=</c> identifier (split only for the shapes that are paths).</param>
/// <param name="snapshot">The generation in force.</param>
/// <param name="subject">The live facts the node manager reported.</param>
/// <param name="segments">The resolved path below the UNS realm root.</param>
/// <param name="reason">Why placement failed.</param>
/// <returns><see langword="true"/> when the subject is placed.</returns>
private static bool TryPlaceUnsSubject(
string identifier,
DataPlaneAuthoritySnapshot snapshot,
in UnsAclSubject subject,
out IReadOnlyList<string> segments,
out string reason)
{
IReadOnlyList<string> owner;
bool owned;
switch (subject.Kind)
{
case UnsAclSubjectKind.Unknown:
{
var split = identifier.Split('/', StringSplitOptions.RemoveEmptyEntries);
segments = split.Length == ReferenceVariableSegments ? split : [UnknownNodeSegment];
reason = string.Empty;
return true;
}
case UnsAclSubjectKind.ReferenceVariable:
{
// A reference variable's id IS its name path, so a Tag grant keeps matching it — but only while that
// path is where THIS generation puts its equipment (plus one effective-name segment). An equipment
// moved to another line, or an Area/Line/Equipment renamed, leaves the variable's id on the OLD names
// until the address space re-points it; the old names' grants must not keep reading / writing it.
var split = identifier.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (subject.FolderChain is not { Count: > 0 } equipmentChain
|| !snapshot.TryGetUnsFolderSegments(equipmentChain[^1], equipmentChain, out var equipment)
|| split.Length != equipment.Count + 1
|| !split.Take(equipment.Count).SequenceEqual(equipment, StringComparer.Ordinal))
{
segments = [];
reason = $"the UNS reference variable's path does not match its equipment {subject.FolderChain?.LastOrDefault() ?? "(none)"}'s placement in generation {snapshot.GenerationId} (moved, renamed, or not yet re-pointed)";
return false;
}
segments = split;
reason = string.Empty;
return true;
}
case UnsAclSubjectKind.Folder:
if (subject.FolderChain is not { Count: > 0 } chain
|| !snapshot.TryGetUnsFolderSegments(chain[^1], chain, out owner))
{
segments = [];
reason = $"the UNS folder {subject.FolderChain?.LastOrDefault() ?? "(none)"} is not placed as generation {snapshot.GenerationId} deploys it (unknown, moved, or not yet rebuilt)";
return false;
}
owned = !subject.IsFolderItself;
break;
case UnsAclSubjectKind.ScriptedCondition:
if (!TryPlaceScriptedCondition(
snapshot, subject.AlarmId, subject.OwnerEquipmentId, subject.FolderChain, out owner, out reason))
{
segments = [];
return false;
}
owned = true;
break;
default:
segments = [];
reason = "unrecognised UNS subject";
return false;
}
segments = owned ? [.. owner, OwnedNodeSegment] : owner;
reason = string.Empty;
return true;
}
/// <summary>
/// The decision-cache key for a node authorized at a RESOLVED scope rather than at its split identifier. It
/// names the scope, not the node, because the cached grant walk depends only on the scope — and keying on the
/// node would let a verdict outlive a re-bind or re-parent the resolver sees but the generation does not. The
/// leading NUL keeps it disjoint from every <c>s=</c> identifier — which <c>Evaluate</c> ENFORCES by denying any
/// identifier that contains a NUL before the cache is consulted (review of #808: otherwise a client reading
/// the nonexistent <c>s="\0Area1\0Line1\0Pump"</c> would memoize a deny under the real folder's key for its
/// whole key shard).
/// </summary>
/// <param name="segments">The resolved path below the UNS realm root.</param>
/// <returns>The cache key.</returns>
private static string ResolvedScopeCacheKey(IReadOnlyList<string> segments) =>
"\u0000" + string.Join('\u0000', segments);
/// <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
@@ -400,10 +588,38 @@ public sealed class DataPlaneAuthorizer
"the node id carries no string identifier, so it addresses no ACL scope", cached);
}
// No node this server mints carries a NUL, and the resolved-scope cache keys are NUL-prefixed: refusing NUL
// here keeps the two keyspaces disjoint, so a client-chosen identifier can never collide with (and poison)
// a resolved scope's memoized verdict. Denied uncached, before the lookup.
if (identifier.Contains('\u0000'))
{
return Deny(operation, nodeId, identity.Principal,
"the node id carries a NUL, which no node in this address space does", cached);
}
var sessionKey = identity.ApiKeyId ?? identity.Principal;
// Gitea #808: a UNS folder minted under an opaque entity id, a node it owns, and a scripted condition (or
// child) are placed through the live address space + this generation instead of by splitting the id. The
// resolver runs on EVERY call, ahead of the cache, because what it reads moves with the address space; the
// cache below then memoizes only the grant walk at the resolved scope, keyed on that scope.
var cacheIdentifier = identifier;
IReadOnlyList<string>? resolvedSegments = null;
if (realm == AddressSpaceRealm.Uns && UnsSubjectResolver is { } resolve)
{
var subject = resolve(identifier);
if (subject.Kind != UnsAclSubjectKind.Path)
{
if (!TryPlaceUnsSubject(identifier, snapshot, subject, out var placed, out var reason))
return Deny(operation, nodeId, identity.Principal, reason, cached);
resolvedSegments = placed;
cacheIdentifier = ResolvedScopeCacheKey(placed);
}
}
if (cached &&
Decisions.TryGet(snapshot.GenerationId, sessionKey, operation, realm, identifier, out var memoized))
Decisions.TryGet(snapshot.GenerationId, sessionKey, operation, realm, cacheIdentifier, out var memoized))
{
// The whole point of the reorder: a hit returns HERE, having built no scope, no closure and no
// UserAuthorizationState. One Browse of a device folder asks this once per child.
@@ -412,7 +628,9 @@ public sealed class DataPlaneAuthorizer
: DenyNoGrant(operation, nodeId, identity.Principal, snapshot.GenerationId, cached);
}
var scope = BuildScope(identifier, realm, snapshot.ClusterId);
var scope = resolvedSegments is null
? BuildScope(identifier, realm, snapshot.ClusterId)
: new NodeScope { ClusterId = snapshot.ClusterId!, Kind = NodeHierarchyKind.Uns, Segments = resolvedSegments };
var allowed = Walk(snapshot, identity, operation, scope, now);
// The single shared anchor fronts BOTH realm trees, so its browse must consider transit in
@@ -432,7 +650,7 @@ public sealed class DataPlaneAuthorizer
now);
}
if (cached) Decisions.Set(snapshot.GenerationId, sessionKey, operation, realm, identifier, allowed);
if (cached) Decisions.Set(snapshot.GenerationId, sessionKey, operation, realm, cacheIdentifier, allowed);
return allowed
? null
@@ -0,0 +1,120 @@
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
/// <summary>How a UNS-namespace node is to be placed in the ACL scope tree (Gitea #808).</summary>
public enum UnsAclSubjectKind
{
/// <summary>
/// The node's <c>s=</c> identifier IS its ACL path and nothing live vouches for it — a UNS node with no live
/// hierarchy-folder ancestor (only test rigs mint those). Split as before.
/// </summary>
Path,
/// <summary>
/// No live node holds the identifier (removed, never deployed, or a client probing). Only a realm-wide grant
/// may decide it — except a reference-variable-shaped id (exactly four segments), which keeps its split so a
/// removed reference variable's NodeDeleted still reaches the grantees of that name path.
/// </summary>
Unknown,
/// <summary>
/// A UNS reference variable (<c>s=Area/Line/Equipment/EffectiveName</c>) hanging under a live equipment folder:
/// authorized at its split path, but only while that path equals the equipment's placement in the current
/// generation plus one effective-name segment (a moved or renamed equipment's variables are denied until the
/// address space re-points them).
/// </summary>
ReferenceVariable,
/// <summary>
/// A UNS Area / Line / Equipment folder minted under its opaque entity id, or a node owned by one
/// (a virtual tag <c>EQ-…/Name</c> or its sub-folder): placed at the folder's path in the current generation.
/// </summary>
Folder,
/// <summary>
/// A scripted alarm condition (<c>s=&lt;ScriptedAlarmId&gt;</c>) or one of its instance-scoped children
/// (<c>s=&lt;ScriptedAlarmId&gt;/…</c>): placed at its owning equipment, as the #797 command gate does.
/// </summary>
ScriptedCondition,
}
/// <summary>
/// The structural facts the node manager reports about one UNS-namespace node, read from the LIVE address
/// space at decision time. The authorizer turns them into a scope against the CURRENT authority generation —
/// the node manager never computes a scope itself, so placement and grants always come from one generation.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the identifier cannot simply be split.</b> UNS folders are minted under opaque entity ids
/// (<c>s=EQ-1a2b…</c>) and scripted conditions under their opaque <c>ScriptedAlarmId</c>, while every
/// UNS grant is planted at a NAME path (<c>[uns, Area, Line, Equipment]</c>). Splitting those ids can
/// never reach a narrow grant (and a one-segment split could coincide with an unrelated Area's name).
/// </para>
/// <para>
/// A <see cref="UnsAclSubjectKind.Folder"/> subject carries the folder's live id ancestry so the authorizer
/// can require it to equal the generation's; a <see cref="UnsAclSubjectKind.ScriptedCondition"/> subject
/// carries the live owner binding (null when unbound ⇒ denied under every grant).
/// </para>
/// </remarks>
public readonly record struct UnsAclSubject
{
/// <summary>The identifier is the ACL path — the pre-#808 behaviour.</summary>
public static readonly UnsAclSubject Path = new() { Kind = UnsAclSubjectKind.Path };
/// <summary>No live node holds the identifier.</summary>
public static readonly UnsAclSubject Unknown = new() { Kind = UnsAclSubjectKind.Unknown };
/// <summary>Which placement rule applies.</summary>
public UnsAclSubjectKind Kind { get; init; }
/// <summary>
/// The owning folder's LIVE id ancestry, root-first, ending at the owning folder itself
/// (e.g. <c>[UA-…, UL-…, EQ-…]</c>): for <see cref="UnsAclSubjectKind.Folder"/> and
/// <see cref="UnsAclSubjectKind.ReferenceVariable"/> the folder at or above the node; for
/// <see cref="UnsAclSubjectKind.ScriptedCondition"/> the folder the bound condition hangs under (null when
/// unbound).
/// </summary>
public IReadOnlyList<string>? FolderChain { get; init; }
/// <summary>
/// For <see cref="UnsAclSubjectKind.Folder"/>: true when the node IS the folder; false when it is a node the
/// folder owns (a virtual tag, its sub-folder).
/// </summary>
public bool IsFolderItself { get; init; }
/// <summary>For <see cref="UnsAclSubjectKind.ScriptedCondition"/>: the condition's <c>ScriptedAlarmId</c>.</summary>
public string? AlarmId { get; init; }
/// <summary>
/// For <see cref="UnsAclSubjectKind.ScriptedCondition"/>: the <c>EquipmentId</c> the LIVE condition instance
/// is bound to, or null when the node manager holds no binding for it.
/// </summary>
public string? OwnerEquipmentId { get; init; }
/// <summary>A UNS folder (or a node it owns) with its live id ancestry.</summary>
/// <param name="chain">Root-first live ancestry ending at the owning folder.</param>
/// <param name="isFolderItself">Whether the node is the folder itself.</param>
/// <returns>The subject.</returns>
public static UnsAclSubject Folder(IReadOnlyList<string> chain, bool isFolderItself) =>
new() { Kind = UnsAclSubjectKind.Folder, FolderChain = chain, IsFolderItself = isFolderItself };
/// <summary>A scripted condition (or one of its children) and its live owner binding.</summary>
/// <param name="alarmId">The condition's <c>ScriptedAlarmId</c>.</param>
/// <param name="ownerEquipmentId">The live owner binding, or null when unbound.</param>
/// <param name="ownerFolderChain">The live id ancestry of the folder the condition hangs under, or null.</param>
/// <returns>The subject.</returns>
public static UnsAclSubject ScriptedCondition(
string alarmId, string? ownerEquipmentId, IReadOnlyList<string>? ownerFolderChain) =>
new()
{
Kind = UnsAclSubjectKind.ScriptedCondition,
AlarmId = alarmId,
OwnerEquipmentId = ownerEquipmentId,
FolderChain = ownerFolderChain,
};
/// <summary>A UNS reference variable under a live equipment folder.</summary>
/// <param name="equipmentChain">Root-first live ancestry ending at the variable's equipment folder.</param>
/// <returns>The subject.</returns>
public static UnsAclSubject ReferenceVariable(IReadOnlyList<string> equipmentChain) =>
new() { Kind = UnsAclSubjectKind.ReferenceVariable, FolderChain = equipmentChain };
}
@@ -82,6 +82,17 @@ public sealed record DataPlaneAuthorityProjection(
public IReadOnlyDictionary<string, string> ScriptedAlarmOwners { get; init; } =
new Dictionary<string, string>(StringComparer.Ordinal);
/// <summary>
/// <c>folder id → (ACL path, folder-id chain)</c> for every UNS Area, Line and Equipment whose UNS chain
/// resolves (Gitea #808) — how the authorizer places a node minted under an opaque UNS folder id
/// (<c>s=UA-…</c>, <c>s=UL-…</c>, <c>s=EQ-…</c>, <c>s=EQ-…/VirtualTag</c>). Paths come from the SAME
/// <see cref="AclScopePathResolver"/> instance as <see cref="ScopePaths"/>, so a folder and the grant planted
/// on its entity are equal by construction. An id two entity kinds share is omitted (fail closed). Empty when
/// the artifact carries no grants.
/// </summary>
public IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes { get; init; } =
new Dictionary<string, UnsFolderScope>(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>>());
@@ -927,7 +938,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
grants.Add(acl);
}
var (scopePaths, equipmentScopePaths) = BuildAclScopePaths(root, grants);
var (scopePaths, equipmentScopePaths, unsFolderScopes) = BuildAclScopePaths(root, grants);
// Every scripted alarm's owner, from the rows the address space is composed from. Read regardless of
// the grant count: it is a topology fact, cheap, and an absent map only ever denies.
var scriptedAlarmOwners = new Dictionary<string, string>(StringComparer.Ordinal);
@@ -943,6 +954,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
{
EquipmentScopePaths = equipmentScopePaths,
ScriptedAlarmOwners = scriptedAlarmOwners,
UnsFolderScopes = unsFolderScopes,
};
}
@@ -952,16 +964,18 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
/// path and the RawPath that forms a node's NodeId cannot drift apart.
/// </summary>
/// <returns>The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup,
/// Gitea #797) from the same resolver.</returns>
/// Gitea #797) and every UNS folder's path + id chain (Gitea #808) from the same resolver.</returns>
private static (IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths,
IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths) BuildAclScopePaths(
IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths,
IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes) BuildAclScopePaths(
JsonElement root,
IReadOnlyList<NodeAcl> grants)
{
if (grants.Count == 0)
{
return (new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(),
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase));
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase),
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase));
}
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
@@ -1063,7 +1077,40 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path;
}
return (resolver.Resolve(grants), equipmentPaths);
return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment));
}
/// <summary>
/// Every UNS Area / Line / Equipment folder's ACL path and folder-id chain (Gitea #808), through the SAME
/// resolver call a grant on that entity takes. A folder whose chain does not resolve is absent; an id two
/// entity kinds share (so the address space could only hold one folder for it) is dropped entirely rather
/// than placed by whichever kind came first — fail closed.
/// </summary>
private static Dictionary<string, UnsFolderScope> BuildUnsFolderScopes(
AclScopePathResolver resolver,
IReadOnlyDictionary<string, string> areas,
IReadOnlyDictionary<string, (string UnsAreaId, string Name)> lines,
IReadOnlyDictionary<string, (string UnsLineId, string Name)> equipment)
{
var scopes = new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase);
var ambiguous = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(string id, NodeAclScopeKind kind, string[] chain)
{
if (resolver.ResolveOne(kind, id) is not [var path]) return;
if (!scopes.TryAdd(id, new UnsFolderScope(path, chain))) ambiguous.Add(id);
}
foreach (var areaId in areas.Keys) Add(areaId, NodeAclScopeKind.UnsArea, [areaId]);
foreach (var (lineId, line) in lines) Add(lineId, NodeAclScopeKind.UnsLine, [line.UnsAreaId, lineId]);
foreach (var (equipmentId, equip) in equipment)
{
if (!lines.TryGetValue(equip.UnsLineId, out var line)) continue;
Add(equipmentId, NodeAclScopeKind.Equipment, [line.UnsAreaId, equip.UnsLineId, equipmentId]);
}
foreach (var id in ambiguous) scopes.Remove(id);
return scopes;
}
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
@@ -1426,7 +1426,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths,
projection.EquipmentScopePaths, projection.ScriptedAlarmOwners);
projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes);
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
@@ -87,7 +87,8 @@ public sealed class AclEventSubscriptionEnforcementTests
await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1"));
// The next deployment carries only EqB's grant.
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths());
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
rig.AttachedEventItems.ShouldBe(1);
rig.Transition("t2", active: false);
@@ -98,7 +99,8 @@ public sealed class AclEventSubscriptionEnforcementTests
collector.Has(onA, "t2").ShouldBeFalse("the revoked item must not receive events after the deployment");
// A deployment that restores the grant brings the feed back on its own.
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths());
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
rig.AttachedEventItems.ShouldBe(2);
rig.Transition("t3", active: true);
@@ -140,7 +142,8 @@ public sealed class AclEventSubscriptionEnforcementTests
await WaitUntilAsync(() => collector.Has(onA, "t1") && collector.Has(onB, "t1"));
rig.AttachedEventItems.ShouldBe(2);
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths());
state.Apply(Cluster, "rev-2", [], [EquipmentGrant(EqB)], ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
rig.AttachedEventItems.ShouldBe(1);
subscription.RemoveItem(onA);
@@ -149,7 +152,8 @@ public sealed class AclEventSubscriptionEnforcementTests
// A restoring deployment must not re-attach the deleted item. The client could never see that
// happen — it has deleted the item — so the server-side fan-out is what is asserted.
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths());
state.Apply(Cluster, "rev-3", [], [EquipmentGrant(EqA), EquipmentGrant(EqB)], ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
rig.AttachedEventItems.ShouldBe(1, "the deleted item must not be resurrected onto EqA's fan-out");
rig.Transition("t2", active: false);
@@ -405,14 +409,16 @@ public sealed class AclEventSubscriptionEnforcementTests
rig.Transition("t1", active: true);
await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1"));
state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths());
state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
rig.Transition("t2", active: false);
await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2"));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
events.Has(item, "t2").ShouldBeFalse("the revoked session must not receive alarms after the deployment");
state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths());
state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
rig.Transition("t3", active: true);
await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3"));
@@ -885,6 +891,10 @@ public sealed class AclEventSubscriptionEnforcementTests
PermissionFlags = NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe,
};
// Gitea #808: every Apply above also installs the placements the generation now carries — the equipment owner
// paths + scripted-alarm owners (#797) and the UNS folder scopes (#808). This rig's folders are synthetic
// root-level ids (EqA ⇒ [uns, EqA]), shared with ServerEventRig; production-shaped opaque ids are covered by
// UnsFolderScopeWireTests. Without them every UNS folder and the scripted condition deny under EVERY grant.
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths() =>
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
@@ -1038,7 +1048,8 @@ public sealed class AclEventSubscriptionEnforcementTests
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths());
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(), ServerEventRig.FolderScopes());
Nm.DataPlaneAuthority = state;
Nm.Authorizer = new DataPlaneAuthorizer(
@@ -237,18 +237,29 @@ public sealed class ScriptedAlarmOwnerScopeWireTests
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"));
// Gitea #808 — deliberate change: while the live binding (Fan) disagrees with the generation (Pump), the
// condition is unplaceable for EVERY grant, so even the Cluster-granted observer does not receive its event
// (it used to: the opaque id split to [uns, scripted-eqa-hi], which only a Cluster grant reached). Positive
// control: a later event on the correctly placed sibling arrives, so "recreated" would have arrived first.
Project(rig, FanAlarm, pre with { Message = "fan-control" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "fan-control"));
events.Has(item, "recreated").ShouldBeFalse("a moved alarm is invisible until the generation agrees");
var staleEventId = rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray();
var oldGrant = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct));
session, Uns(session, Alarm), "Acknowledge", staleEventId, ct));
oldGrant.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.Count.ShouldBe(1);
// The old grant was refused above with the generation STILL deploying the alarm under Pump (binding ≠
// generation); now the deploy that moved it lands, granting the new owner.
// generation); now the deploy that moved it lands, granting the new owner — and placing it again for the
// observer, which sees its next event.
Reapply(state, "rev-fan", Deployed(alarmOwner: Fan), [ServerEventRig.Key()], OwnerGrant(Fan), Observer());
Project(rig, Alarm, pre with { Message = "moved" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "moved"));
await ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "recreated"), ct);
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(item, "moved"), ct);
var unknown = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
session, Uns(session, "scripted-never-deployed"), "Acknowledge", [1, 2, 3], ct));
@@ -256,7 +267,7 @@ public sealed class ScriptedAlarmOwnerScopeWireTests
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");
events.ConditionEvents(item, "moved").Count.ShouldBe(2, "the re-placed base plus the new owner's one ack");
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge"), (Alarm, "Acknowledge")]);
await observer.CloseAsync(ct);
@@ -287,6 +298,23 @@ public sealed class ScriptedAlarmOwnerScopeWireTests
new Dictionary<string, (string UnsLineId, string Name)> { [Pump] = (LineId, "Pump"), [Fan] = (LineId, "Fan") },
[]);
/// <summary>
/// The topology's UNS folder placements (Gitea #808), from the same resolver: a scripted condition is placed
/// only while its owner's live folder ancestry matches the generation's.
/// </summary>
private static Dictionary<string, UnsFolderScope> FolderScopes()
{
var resolver = Resolver();
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
return new Dictionary<string, UnsFolderScope>
{
[AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]),
[LineId] = new(One(NodeAclScopeKind.UnsLine, LineId), [AreaId, LineId]),
[Pump] = new(One(NodeAclScopeKind.Equipment, Pump), [AreaId, LineId, Pump]),
[Fan] = new(One(NodeAclScopeKind.Equipment, Fan), [AreaId, LineId, Fan]),
};
}
private static Dictionary<string, NodeAclPath> OwnerPaths()
{
var resolver = Resolver();
@@ -313,7 +341,7 @@ public sealed class ScriptedAlarmOwnerScopeWireTests
private static void Reapply(
DataPlaneAuthorityState state, string revision, IReadOnlyDictionary<string, string> deployedOwners,
ApiKey[] keys, params NodeAcl[] grants) =>
state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners);
state.Apply(ServerEventRig.Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners, FolderScopes());
/// <summary>The generation's deployed scripted-alarm owners; <paramref name="alarmOwner"/> moves the Pump alarm.</summary>
private static Dictionary<string, string> Deployed(string alarmOwner = Pump) =>
@@ -18,6 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
public sealed class Sdk2HistoryAuthorityWireTests
{
private const string ValuePath = RawDeviceFolder + "/History";
// Gitea #808 review: a UNS variable is authorized at its placed path, and a reference-variable id that is not
// its equipment's path + a name is denied (a moved/renamed equipment's stale ids). The alias used to reuse the
// RAW path as its UNS id — a shape production never mints — so it now carries a UNS-shaped id under EqA.
private const string AliasPath = EqA + "/HistoryAlias";
private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
public static TheoryData<string, string> Cases => new(
@@ -79,20 +84,20 @@ public sealed class Sdk2HistoryAuthorityWireTests
private static void Apply(DataPlaneAuthorityState state, ApiKey key, NodeAcl[] grants) =>
state.Apply(ServerEventRig.Cluster, Guid.NewGuid().ToString("N"), [key], grants,
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(), unsFolderScopes: FolderScopes());
private static void Materialise(ServerEventRig rig)
{
rig.MaterialiseNative();
rig.Sink.EnsureVariable(ValuePath, RawDeviceFolder, "History", "Double", false, AddressSpaceRealm.Raw, "same-backend-tag");
rig.Sink.EnsureVariable(ValuePath, EqA, "HistoryAlias", "Double", false, AddressSpaceRealm.Uns, "same-backend-tag");
rig.Sink.EnsureVariable(AliasPath, EqA, "HistoryAlias", "Double", false, AddressSpaceRealm.Uns, "same-backend-tag");
}
private static async Task<Opc.Ua.HistoryReadResult> PageAsync(ISession session, string kind, bool alias,
ByteString point, CancellationToken ct)
{
var realm = alias ? V3NodeIds.UnsNamespaceUri : V3NodeIds.RawNamespaceUri;
var path = kind == "events" ? alias ? EqA : RawDeviceFolder : ValuePath;
var path = kind == "events" ? alias ? EqA : RawDeviceFolder : alias ? AliasPath : ValuePath;
var node = new NodeId(path, (ushort)session.NamespaceUris.GetIndex(realm));
HistoryReadDetails details = kind == "events" ? new ReadEventDetails
{
@@ -95,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, EquipmentOwnerPaths(), ScriptedAlarmOwners());
else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners(), FolderScopes());
var error = await Should.ThrowAsync<ServiceResultException>(() => session.CallAsync(condition.NodeId, methodId, ct,
Variant.From(currentId), Variant.From(new LocalizedText("must not be recorded"))));
@@ -107,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, EquipmentOwnerPaths(), ScriptedAlarmOwners());
state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes, EquipmentOwnerPaths(), ScriptedAlarmOwners(), FolderScopes());
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");
@@ -126,7 +126,8 @@ internal sealed class ServerEventRig : IAsyncDisposable
IReadOnlyDictionary<string, NodeAclPath> equipmentOwnerPaths, params NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners());
state.Apply(
Cluster, "rev-1", keys, grants, scopePaths, equipmentOwnerPaths, ScriptedAlarmOwners(), FolderScopes());
Nm.DataPlaneAuthority = state;
Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);
@@ -181,6 +182,19 @@ internal sealed class ServerEventRig : IAsyncDisposable
[EqB] = new NodeAclPath([NodeAclRealms.Uns, EqB]),
};
/// <summary>
/// The rig's UNS folder placements (Gitea #808): each equipment folder hangs directly under the root, so its
/// id chain is itself and its path is <c>[uns, EqA]</c> / <c>[uns, EqB]</c> — the synthetic shape every
/// pre-#808 rig test was written against. ⚠️ Production folder ids are opaque and their paths are NAMES; the
/// #808 tests (<c>UnsFolderScopeWireTests</c>) use that shape instead, which this rig's coincidence hid.
/// </summary>
public static IReadOnlyDictionary<string, UnsFolderScope> FolderScopes() =>
new Dictionary<string, UnsFolderScope>
{
[EqA] = new(new NodeAclPath([NodeAclRealms.Uns, EqA]), [EqA]),
[EqB] = new(new NodeAclPath([NodeAclRealms.Uns, EqB]), [EqB]),
};
/// <summary>The rig's deployed scripted-alarm owners: its one scripted alarm belongs to <see cref="EqA"/>.</summary>
public static IReadOnlyDictionary<string, string> ScriptedAlarmOwners() =>
new Dictionary<string, string> { [ScriptedAlarmId] = EqA };
@@ -0,0 +1,605 @@
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 #808 over the wire — a real activated <c>apikey</c> session with <b>zero</b> server-wide roles and only a
/// NARROW UNS grant, against a production-shaped UNS tree: every Area / Line / Equipment folder is minted under its
/// opaque entity id (<c>UA-…</c>, <c>UL-…</c>, <c>EQ-…</c>), virtual tags under <c>EQ-…/Name</c>, scripted conditions
/// under their opaque <c>ScriptedAlarmId</c>, while every grant is planted at a NAME path resolved by
/// <see cref="AclScopePathResolver"/>. Before #808 such a session could not browse, read or subscribe any of those
/// nodes, and never received a scripted-alarm event through the Server object — so it never held the EventId that
/// Acknowledge / Confirm / AddComment need. <see cref="ServerEventRig"/>'s own folders (<c>EqA</c> ⇒
/// <c>[uns, EqA]</c>) are deliberately NOT used: their id coincides with their path, which is what hid this.
/// </summary>
public sealed class UnsFolderScopeWireTests
{
private const string AreaId = "UA-0000000001";
private const string Line1 = "UL-0000000001";
private const string Line2 = "UL-0000000002";
private const string Pump = "EQ-00000000a1b2";
private const string Fan = "EQ-00000000c3d4";
private const string Alarm = ServerEventRig.ScriptedAlarmId; // "scripted-eqa-hi", deployed under Pump
private const string PumpLo = "scripted-pump-lo";
private const string FanAlarm = "scripted-fan-hi";
private const string PumpSpeed = Pump + "/Speed"; // a virtual tag: s=<EquipmentId>/<Name>
private const string FanSpeed = Fan + "/Speed";
private const string PumpFlow = "Area1/Line1/Pump/Flow"; // a UNS reference variable: s=<Area>/<Line>/<Equipment>/<Name>
private const string FlowTagId = "TAG-flow";
private const NodePermissions Operator =
NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm;
/// <summary>
/// The ScadaBridge path end to end on an Equipment grant alone: navigate from the shared root down to the
/// equipment, read its folder / alarm children / virtual tag / reference variable, receive its scripted alarm
/// through the Server object, and Acknowledge with the EventId the session obtained ITSELF. Every sibling
/// (another equipment's folder, alarm, alarm child, virtual tag, folder event item) is denied on the same
/// session, and the sibling's alarm event — reported FIRST — never arrives.
/// </summary>
[Fact]
public async Task A_narrow_equipment_grant_browses_reads_receives_and_acknowledges_end_to_end()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Uns808EndToEnd", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig, pumpLine: Line1);
Install(rig, pumpLine: Line1, Deployed(), EquipmentGrant(Pump, Operator));
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
AssertNoRoles(rig);
// Navigation: shared root → area → line → equipment, each step by Browse transit / the grant itself.
(await BrowseAsync(session, RootNodeId(session), ct)).Ids.ShouldContain(AreaId);
(await BrowseAsync(session, Uns(session, AreaId), ct)).Ids.ShouldBe([Line1], "Line2 holds nothing granted");
(await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump], "the sibling equipment is withheld");
var inPump = (await BrowseAsync(session, Uns(session, Pump), ct)).Ids;
inPump.ShouldContain(Alarm);
inPump.ShouldContain(PumpLo);
inPump.ShouldContain(PumpSpeed);
inPump.ShouldContain(PumpFlow);
(await BrowseAsync(session, Uns(session, Fan), ct)).Status.ShouldBe(StatusCodes.BadUserAccessDenied);
// Reads: the folder, a scripted condition child (#629 instance-scoped id), the virtual tag, the reference.
var pumpEnabled = ChildId(rig, Alarm, c => c.EnabledState!.Id!);
var fanEnabled = ChildId(rig, FanAlarm, c => c.EnabledState!.Id!);
pumpEnabled.ShouldStartWith(Alarm + "/");
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, pumpEnabled), Attributes.Value, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, FanAlarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, fanEnabled), Attributes.Value, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, FanSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, AreaId), Attributes.DisplayName, ct))
.ShouldBe(StatusCodes.BadUserAccessDenied, "transit confers Browse on the ancestors, never Read");
// Events: the session's OWN Server-object item, plus direct items on its equipment folder and the sibling's.
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
var pumpFolder = RecordedEvents.AddEventItem(subscription, Uns(session, Pump));
var fanFolder = RecordedEvents.AddEventItem(subscription, Uns(session, Fan));
await subscription.ApplyChangesAsync(ct);
server.Status.Error.ShouldBeNull();
pumpFolder.Status.Error.ShouldBeNull();
fanFolder.Status.Error.ShouldNotBeNull().StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
Project(rig, FanAlarm, pre with { Message = "fan-base" }); // reported first: a late copy would precede "base"
Project(rig, Alarm, pre);
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "base") && events.Has(pumpFolder, "base"));
events.Has(server, "fan-base").ShouldBeFalse("the sibling's alarm is not delivered through the Server object");
// Acknowledge with the EventId this narrow session received itself — the ScadaBridge flow #797 alone could
// not complete.
await ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(server, "base"), ct);
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(server, "base").Count == 2);
events.ConditionEvents(server, "base")[1][RecordedEvents.AckedField].TryGetValue(out bool acked).ShouldBeTrue();
acked.ShouldBeTrue();
var siblingEventId = rig.Nm.TryGetAlarmCondition(FanAlarm)!.EventId!.Value.ToArray();
var denied = await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
session, Uns(session, FanAlarm), "Acknowledge", siblingEventId, ct));
denied.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
Project(rig, FanAlarm, pre with { Message = "fan-sentinel" });
Project(rig, Alarm, pre with { Acknowledged = true, Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "sentinel"));
events.Of(server).Count(f => RecordedEvents.Message(f)?.StartsWith("fan-", StringComparison.Ordinal) == true)
.ShouldBe(0);
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge")]);
await session.CloseAsync(ct);
}
/// <summary>
/// A principal holding only a TAG grant (one reference variable beneath the equipment) can navigate to it and
/// read it, but is NOT handed the equipment's other contents: its folder is browsable by transit and nothing
/// more, and the equipment's scripted alarms and virtual tags stay hidden from Browse and Read and its events
/// do not reach the Server object — a Tag grant is about that signal, not the equipment.
/// </summary>
[Fact]
public async Task A_tag_grant_reaches_its_reference_without_exposing_the_equipments_alarms_or_virtual_tags()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Uns808TagOnly", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig, pumpLine: Line1);
Install(rig, pumpLine: Line1, Deployed(), TagGrant());
using var session = await rig.ConnectAsync(anonymous: false, ct);
(await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump]);
(await BrowseAsync(session, Uns(session, Pump), ct)).Ids.ShouldBe([PumpFlow]);
(await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
await session.CloseAsync(ct);
}
/// <summary>
/// A scripted alarm the generation has MOVED (deployed under Fan) while the address space still binds it to Pump —
/// the window between installing a generation and rebuilding its address space, or indefinitely after a failed
/// apply. Under BOTH owners' grants it is invisible (no Server-object event, no Read of the condition or its
/// children) and not commandable; the owner's other, correctly placed alarm keeps working (positive control).
/// Once the address space catches up the new owner's grant sees it and the old owner's does not.
/// </summary>
[Theory]
[InlineData(Pump)]
[InlineData(Fan)]
public async Task A_moved_alarm_is_denied_under_both_owners_until_the_address_space_catches_up(string grantedEquipment)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync($"Uns808Moved{grantedEquipment[^4..]}", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig, pumpLine: Line1);
Install(rig, pumpLine: Line1, Deployed(alarmOwner: Fan), EquipmentGrant(grantedEquipment, Operator));
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
var control = grantedEquipment == Pump ? PumpLo : FanAlarm;
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
Project(rig, Alarm, pre with { Message = "moved" });
Project(rig, control, pre with { Message = "control" });
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "control"));
events.Has(server, "moved").ShouldBeFalse("a moved alarm is unplaceable for every grant during the window");
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, ChildId(rig, Alarm, c => c.EnabledState!.Id!)), Attributes.Value, ct))
.ShouldBe(StatusCodes.BadUserAccessDenied);
var staleEventId = rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray();
(await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", staleEventId, ct)))
.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.ShouldBeEmpty();
// The address space catches up: the alarm is re-materialised under its deployed owner.
((ISurgicalAddressSpaceSink)rig.Sink).RemoveAlarmConditionNode(Alarm, AddressSpaceRealm.Uns).ShouldBeTrue();
rig.Sink.MaterialiseAlarmCondition(Alarm, Fan, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
Project(rig, Alarm, pre with { Message = "placed" });
Project(rig, control, pre with { Message = "control-2" });
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "control-2"));
var visible = grantedEquipment == Fan;
events.Has(server, "placed").ShouldBe(visible);
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct))
.ShouldBe(visible ? StatusCodes.Good : StatusCodes.BadUserAccessDenied);
await session.CloseAsync(ct);
}
/// <summary>
/// An EQUIPMENT moved to another line by the generation while its folder still hangs under the old line: the live
/// folder ancestry disagrees with the generation's, so the folder, everything it owns, its scripted alarm (read
/// AND Acknowledge) and its UNS reference variable (still on its OLD-name id — read AND write) are denied under
/// the new line's grant AND the old one's. After the rebuild re-parents it (and re-ids the reference variable),
/// the new line's grant reaches all of it and the old line's reaches none of it.
/// </summary>
[Theory]
[InlineData(Line1)]
[InlineData(Line2)]
public async Task A_moved_equipment_and_everything_under_it_is_denied_until_the_rebuild_reparents_it(string grantedLine)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync($"Uns808Folder{grantedLine[^1..]}", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig, pumpLine: Line1);
Install(rig, pumpLine: Line2, Deployed(), LineGrant(grantedLine));
var routed = Route(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var oldFlow = FlowId(Line1);
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, oldFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await WriteAsync(session, Uns(session, oldFlow), 1.5, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
// The condition must carry a real EventId first, or the SDK refuses the call before any gate runs.
Project(rig, Alarm, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base"));
(await Should.ThrowAsync<ServiceResultException>(ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(), ct)))
.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.ShouldBeEmpty();
// Positive control: the sibling, placed identically by both generations, follows the grant.
(await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct))
.ShouldBe(grantedLine == Line1 ? StatusCodes.Good : StatusCodes.BadUserAccessDenied);
rig.Sink.RebuildAddressSpace();
Topology(rig, pumpLine: Line2);
var allowed = grantedLine == Line2;
var expected = allowed ? StatusCodes.Good : StatusCodes.BadUserAccessDenied;
var newFlow = FlowId(Line2);
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(expected);
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(expected);
(await ReadAsync(session, Uns(session, newFlow), Attributes.DisplayName, ct)).ShouldBe(expected);
(await ReadAsync(session, Uns(session, Alarm), Attributes.DisplayName, ct)).ShouldBe(expected);
var write = await WriteAsync(session, Uns(session, newFlow), 1.5, ct);
if (allowed) write.ShouldNotBe(StatusCodes.BadUserAccessDenied, "the new line's grant reaches the re-ided variable");
else write.ShouldBe(StatusCodes.BadUserAccessDenied);
Project(rig, Alarm, new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "rebuilt"));
var ack = ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", rig.Nm.TryGetAlarmCondition(Alarm)!.EventId!.Value.ToArray(), ct);
if (allowed) await ack;
else (await Should.ThrowAsync<ServiceResultException>(ack)).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
routed.Count.ShouldBe(allowed ? 1 : 0);
await session.CloseAsync(ct);
}
/// <summary>
/// An Area RENAMED by the generation before the address space re-ids its reference variables: the folders keep
/// their opaque ids (still placed — the Equipment grant reads the folder and its virtual tag), but the reference
/// variable's id still spells the OLD name, so it is denied until it is re-pointed.
/// </summary>
[Fact]
public async Task A_reference_variable_on_a_stale_name_path_is_denied_while_its_folder_is_placed()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Uns808Rename", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig, pumpLine: Line1);
// The Tag grant's path still spells the OLD Area name (Install plants it at Area1/Line1/Pump/Flow) — standing in
// for any grant that names the old path, e.g. another Area that has since been given the name "Area1".
Install(rig, pumpLine: Line1, Deployed(), areaName: "AreaRenamed", EquipmentGrant(Pump, Operator), TagGrant());
using var session = await rig.ConnectAsync(anonymous: false, ct);
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
await session.CloseAsync(ct);
}
/// <summary>
/// Review of #808 (cache poisoning): the resolved-scope cache keys are NUL-prefixed, so a client that first
/// Reads the nonexistent <c>s="\0Area1\0Line1\0Pump"</c> — the exact cache key of the real Pump folder — must
/// not memoize a deny the real folder then inherits. A NUL-bearing id is refused before the cache.
/// </summary>
[Fact]
public async Task A_nul_bearing_identifier_cannot_poison_a_resolved_scopes_cached_verdict()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Uns808Nul", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig, pumpLine: Line1);
Install(rig, pumpLine: Line1, Deployed(), EquipmentGrant(Pump, Operator));
using var session = await rig.ConnectAsync(anonymous: false, ct);
(await ReadAsync(session, Uns(session, "\u0000Area1\u0000Line1\u0000Pump"), Attributes.DisplayName, ct))
.ShouldBe(StatusCodes.BadUserAccessDenied);
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
await session.CloseAsync(ct);
}
/// <summary>
/// Review of #808: an identifier NO live node holds (a removed folder, virtual tag or scripted alarm — what a
/// NodeDeleted model change names — or a probe) is decided only by a realm-wide grant. It no longer splits, so a
/// removed opaque id never matches a narrow grant on an unrelated entity of the same NAME (an Area named like a
/// removed alarm id). A reference-variable-shaped id (four segments) keeps its name-path split.
/// </summary>
[Fact]
public async Task An_unknown_uns_identifier_is_decided_only_by_a_realm_wide_grant()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Uns808Unknown", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig, pumpLine: Line1);
Install(rig, pumpLine: Line1, Deployed(),
KeyGrant("area", NodeAclScopeKind.UnsArea, AreaId, NodePermissions.ReadOnly),
new NodeAcl
{
NodeAclId = "anon-cluster", ClusterId = ServerEventRig.Cluster, Principal = OpcUaPrincipals.Anonymous,
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.ReadOnly,
});
using var narrow = await rig.ConnectAsync(anonymous: false, ct);
using var wide = await rig.ConnectAsync(anonymous: true, ct);
// "Area1" is the granted Area's NAME — a removed scripted alarm with that id used to split onto it.
foreach (var gone in new[] { "Area1", "EQ-00000000dead", "Area1/Line1", "scripted-removed/EnabledState" })
{
(await ReadAsync(narrow, Uns(narrow, gone), Attributes.DisplayName, ct))
.ShouldBe(StatusCodes.BadUserAccessDenied, gone);
(await ReadAsync(wide, Uns(wide, gone), Attributes.DisplayName, ct))
.ShouldBe(StatusCodes.BadNodeIdUnknown, $"{gone}: a realm-wide grant still decides it");
}
// A four-segment (reference-variable-shaped) id keeps its name-path split under the Area grant.
(await ReadAsync(narrow, Uns(narrow, "Area1/Line1/Pump/Gone"), Attributes.DisplayName, ct))
.ShouldBe(StatusCodes.BadNodeIdUnknown);
// Positive control: the live Area folder itself.
(await ReadAsync(narrow, Uns(narrow, AreaId), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
await narrow.CloseAsync(ct);
await wide.CloseAsync(ct);
}
/// <summary>
/// The placements through the PRODUCTION producer: a deployment artifact's UNS rows, scripted alarms and grant →
/// <c>DeploymentArtifact.ParseDataPlaneAuthority</c> (its folder scopes, owner paths, deployed owners and grant
/// paths) → install → wire decisions, with no hand-built scope anywhere.
/// </summary>
[Fact]
public async Task Placements_decoded_from_a_deployment_artifact_authorize_a_narrow_grant_over_the_wire()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Uns808Artifact", encrypted: false, ct);
rig.KeyRoles = [];
Topology(rig, pumpLine: Line1);
var routed = Route(rig);
var artifact = $$"""
{
"UnsAreas": [{"UnsAreaId":"{{AreaId}}","ClusterId":"{{ServerEventRig.Cluster}}","Name":"Area1"}],
"UnsLines": [
{"UnsLineId":"{{Line1}}","UnsAreaId":"{{AreaId}}","Name":"Line1"},
{"UnsLineId":"{{Line2}}","UnsAreaId":"{{AreaId}}","Name":"Line2"}
],
"Equipment": [
{"EquipmentId":"{{Pump}}","UnsLineId":"{{Line1}}","Name":"Pump"},
{"EquipmentId":"{{Fan}}","UnsLineId":"{{Line1}}","Name":"Fan"}
],
"ScriptedAlarms": [
{"ScriptedAlarmId":"{{Alarm}}","EquipmentId":"{{Pump}}","Name":"hi"},
{"ScriptedAlarmId":"{{PumpLo}}","EquipmentId":"{{Pump}}","Name":"lo"},
{"ScriptedAlarmId":"{{FanAlarm}}","EquipmentId":"{{Fan}}","Name":"fan hi"}
],
"NodeAcls": [
{"NodeAclId":"acl-pump","ClusterId":"{{ServerEventRig.Cluster}}","Principal":"{{ServerEventRig.KeyPrincipal}}",
"ScopeKind":{{(int)NodeAclScopeKind.Equipment}},"ScopeId":"{{Pump}}","PermissionFlags":{{(int)Operator}}}
]
}
""";
var projection = ZB.MOM.WW.OtOpcUa.Runtime.Drivers.DeploymentArtifact.ParseDataPlaneAuthority(
System.Text.Encoding.UTF8.GetBytes(artifact), "node-a:4840");
projection.UnsFolderScopes.Keys.Order(StringComparer.Ordinal).ShouldBe([Pump, Fan, AreaId, Line1, Line2]);
var state = new DataPlaneAuthorityState();
state.Apply(ServerEventRig.Cluster, "rev-artifact", [ServerEventRig.Key()], projection.Grants,
projection.ScopePaths, projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes);
rig.Nm.DataPlaneAuthority = state;
rig.Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, TimeProvider.System);
using var session = await rig.ConnectAsync(anonymous: false, ct);
(await BrowseAsync(session, Uns(session, Line1), ct)).Ids.ShouldBe([Pump]);
(await ReadAsync(session, Uns(session, Pump), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, PumpSpeed), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, PumpFlow), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, ChildId(rig, Alarm, c => c.EnabledState!.Id!)), Attributes.Value, ct))
.ShouldBe(StatusCodes.Good);
(await ReadAsync(session, Uns(session, Fan), Attributes.DisplayName, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var server = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
var pre = new AlarmConditionSnapshot(true, false, false, true, AlarmShelvingKind.Unshelved, 700, "base");
Project(rig, FanAlarm, pre with { Message = "fan-base" });
Project(rig, Alarm, pre);
await RecordedEvents.WaitUntilAsync(() => events.Has(server, "base"));
events.Has(server, "fan-base").ShouldBeFalse();
await ConditionMethodEventTests.CallAsync(
session, Uns(session, Alarm), "Acknowledge", events.LatestEventId(server, "base"), ct);
routed.Select(c => (c.AlarmId, c.Operation)).ShouldBe([(Alarm, "Acknowledge")]);
await session.CloseAsync(ct);
}
// ───────────────────────────── helpers ─────────────────────────────
/// <summary>
/// Area1 / {Line1, Line2}; Pump under <paramref name="pumpLine"/> and Fan under Line1 — every folder an opaque
/// entity id, as <c>AddressSpaceApplier.MaterialiseHierarchy</c> mints them. Each equipment carries scripted
/// alarms and a virtual tag; Pump also carries a UNS reference variable (a NAME-path id).
/// </summary>
private static void Topology(ServerEventRig rig, string pumpLine)
{
rig.Sink.EnsureFolder(AreaId, parentNodeId: null, displayName: "Area1", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(Line1, parentNodeId: AreaId, displayName: "Line1", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(Line2, parentNodeId: AreaId, displayName: "Line2", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(Pump, parentNodeId: pumpLine, displayName: "Pump", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureFolder(Fan, parentNodeId: Line1, displayName: "Fan", realm: AddressSpaceRealm.Uns);
rig.Sink.EnsureVariable(PumpSpeed, Pump, "Speed", "Double", writable: false, AddressSpaceRealm.Uns);
rig.Sink.EnsureVariable(FanSpeed, Fan, "Speed", "Double", writable: false, AddressSpaceRealm.Uns);
rig.Sink.EnsureVariable(FlowId(pumpLine), Pump, "Flow", "Double", writable: true, AddressSpaceRealm.Uns);
rig.Sink.MaterialiseAlarmCondition(Alarm, Pump, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
rig.Sink.MaterialiseAlarmCondition(PumpLo, Pump, "lo", "AlarmCondition", 300, AddressSpaceRealm.Uns);
rig.Sink.MaterialiseAlarmCondition(FanAlarm, Fan, "fan hi", "AlarmCondition", 500, AddressSpaceRealm.Uns);
}
/// <summary>The live reference variable's id when Pump hangs under <paramref name="pumpLine"/> (its NAME path).</summary>
private static string FlowId(string pumpLine) => $"Area1/{(pumpLine == Line1 ? "Line1" : "Line2")}/Pump/Flow";
private static AclScopePathResolver Resolver(string pumpLine, string areaName) => 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] = areaName },
new Dictionary<string, (string UnsAreaId, string Name)> { [Line1] = (AreaId, "Line1"), [Line2] = (AreaId, "Line2") },
new Dictionary<string, (string UnsLineId, string Name)> { [Pump] = (pumpLine, "Pump"), [Fan] = (Line1, "Fan") },
[]);
/// <summary>
/// Installs a generation whose topology puts Pump under <paramref name="pumpLine"/> — the owner paths, folder
/// scopes and grant paths all from ONE resolver, as <c>DeploymentArtifact.ParseDataPlaneAuthority</c> does.
/// </summary>
private static void Install(
ServerEventRig rig, string pumpLine, IReadOnlyDictionary<string, string> deployedOwners, params NodeAcl[] grants) =>
Install(rig, pumpLine, deployedOwners, "Area1", grants);
/// <summary>As above, with the generation naming the Area <paramref name="areaName"/> (a rename the address space
/// has not caught up with when it differs from the live <c>Area1</c>).</summary>
private static void Install(
ServerEventRig rig, string pumpLine, IReadOnlyDictionary<string, string> deployedOwners, string areaName,
params NodeAcl[] grants)
{
var resolver = Resolver(pumpLine, areaName);
var scopePaths = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(resolver.Resolve(grants))
{
// A Tag grant's UNS projection (the resolver would derive it from raw rows this rig does not model).
[new AclScopeRef(NodeAclScopeKind.Tag, FlowTagId)] = [new NodeAclPath([NodeAclRealms.Uns, "Area1", "Line1", "Pump", "Flow"])],
};
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
var owners = new Dictionary<string, NodeAclPath>
{
[Pump] = One(NodeAclScopeKind.Equipment, Pump),
[Fan] = One(NodeAclScopeKind.Equipment, Fan),
};
var folders = new Dictionary<string, UnsFolderScope>
{
[AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]),
[Line1] = new(One(NodeAclScopeKind.UnsLine, Line1), [AreaId, Line1]),
[Line2] = new(One(NodeAclScopeKind.UnsLine, Line2), [AreaId, Line2]),
[Pump] = new(owners[Pump], [AreaId, pumpLine, Pump]),
[Fan] = new(owners[Fan], [AreaId, Line1, Fan]),
};
var state = new DataPlaneAuthorityState();
state.Apply(
ServerEventRig.Cluster, "rev-1", [ServerEventRig.Key()], grants, scopePaths, owners, deployedOwners, folders);
rig.Nm.DataPlaneAuthority = state;
rig.Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, TimeProvider.System);
}
/// <summary>The generation's deployed scripted-alarm owners; <paramref name="alarmOwner"/> moves <see cref="Alarm"/>.</summary>
private static Dictionary<string, string> Deployed(string alarmOwner = Pump) =>
new() { [Alarm] = alarmOwner, [PumpLo] = Pump, [FanAlarm] = Fan };
private static NodeAcl EquipmentGrant(string equipmentId, NodePermissions flags) =>
KeyGrant($"eq-{equipmentId}", NodeAclScopeKind.Equipment, equipmentId, flags);
private static NodeAcl LineGrant(string lineId) =>
KeyGrant($"line-{lineId}", NodeAclScopeKind.UnsLine, lineId, Operator | NodePermissions.WriteOperate);
private static NodeAcl TagGrant() => KeyGrant("tag-flow", NodeAclScopeKind.Tag, FlowTagId, Operator);
private static NodeAcl KeyGrant(string id, NodeAclScopeKind kind, string scopeId, NodePermissions flags) => new()
{
NodeAclId = id,
ClusterId = ServerEventRig.Cluster,
Principal = ServerEventRig.KeyPrincipal,
ScopeKind = kind,
ScopeId = scopeId,
PermissionFlags = flags,
};
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");
/// <summary>The <c>s=</c> id of one of a live condition's instance-scoped children.</summary>
private static string ChildId(ServerEventRig rig, string alarmId, Func<AlarmConditionState, NodeState> child) =>
child(rig.Nm.TryGetAlarmCondition(alarmId).ShouldNotBeNull()).NodeId.IdentifierAsString;
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 static NodeId RootNodeId(ISession session) =>
new(V3NodeIds.SharedRootFolderId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
private static async Task<StatusCode> ReadAsync(ISession session, NodeId node, uint attribute, CancellationToken ct)
{
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueId[]
{
new() { NodeId = node, AttributeId = attribute },
}, ct);
return response.Results.ToList().ShouldHaveSingleItem().StatusCode;
}
private static async Task<StatusCode> WriteAsync(ISession session, NodeId node, double value, CancellationToken ct)
{
var response = await session.WriteAsync(null, new WriteValue[]
{
new() { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(SdkTestValues.FromClr(value)) },
}, ct);
return response.Results.ToList().ShouldHaveSingleItem();
}
private static async Task<(StatusCode Status, List<string> Ids)> BrowseAsync(
ISession session, NodeId parent, CancellationToken ct)
{
var response = await session.BrowseAsync(
requestHeader: null,
view: null,
requestedMaxReferencesPerNode: 0,
nodesToBrowse:
[
new BrowseDescription
{
NodeId = parent,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = 0,
ResultMask = (uint)BrowseResultMask.All,
},
],
ct: ct);
var result = response.Results[0];
return (result.StatusCode, result.References.ToList().Select(r => r.NodeId.IdentifierAsString).ToList());
}
}
@@ -72,6 +72,75 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable
finally { await host.DisposeAsync(); }
}
/// <summary>
/// Gitea #808 review — NodeDeleted delivery on a production-shaped (opaque-id) tree. A NodesAdded change for a
/// LIVE equipment folder reaches the narrow Equipment grantee (it is placed); a NodeDeleted change names a node
/// no live map holds any more, so only a realm-wide grantee receives it — the removed opaque id (a virtual tag,
/// a scripted alarm whose id equals the granted Area's NAME) is never split onto an unrelated narrow grant.
/// </summary>
[Fact]
public async Task A_removed_opaque_uns_node_is_announced_only_to_realm_wide_grantees()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("UA-1", null, "Area1", AddressSpaceRealm.Uns);
sink.EnsureFolder("UL-1", "UA-1", "Line1", AddressSpaceRealm.Uns);
sink.EnsureFolder("EQ-1", "UL-1", "Pump", AddressSpaceRealm.Uns);
var state = new DataPlaneAuthorityState();
NodeAcl[] grants =
[
new NodeAcl
{
NodeAclId = "acl-eq", ClusterId = Cluster, Principal = Principal,
ScopeKind = NodeAclScopeKind.Equipment, ScopeId = "EQ-1", PermissionFlags = NodePermissions.Browse,
},
new NodeAcl
{
NodeAclId = "acl-area", ClusterId = Cluster, Principal = "area-key",
ScopeKind = NodeAclScopeKind.UnsArea, ScopeId = "UA-1", PermissionFlags = NodePermissions.Browse,
},
new NodeAcl
{
NodeAclId = "acl-all", ClusterId = Cluster, Principal = "everything",
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Browse,
},
];
var area = new NodeAclPath([NodeAclRealms.Uns, "Area1"]);
var line = new NodeAclPath([NodeAclRealms.Uns, "Area1", "Line1"]);
var pump = new NodeAclPath([NodeAclRealms.Uns, "Area1", "Line1", "Pump"]);
state.Apply(Cluster, "rev-1", [], grants,
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-1")] = [pump],
[new AclScopeRef(NodeAclScopeKind.UnsArea, "UA-1")] = [area],
},
new Dictionary<string, NodeAclPath> { ["EQ-1"] = pump },
unsFolderScopes: new Dictionary<string, UnsFolderScope>
{
["UA-1"] = new(area, ["UA-1"]),
["UL-1"] = new(line, ["UA-1", "UL-1"]),
["EQ-1"] = new(pump, ["UA-1", "UL-1", "EQ-1"]),
});
nm.Authorizer = new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance);
var added = nm.BuildNodesAddedModelChange("EQ-1", AddressSpaceRealm.Uns);
nm.FilterModelChangeFor(Identity(Principal), added).ShouldBeSameAs(added, "the live folder is placed");
foreach (var gone in new[] { "EQ-1/Speed", "Area1", "scripted-gone" })
{
var deleted = nm.BuildNodesRemovedModelChange(gone, AddressSpaceRealm.Uns);
nm.FilterModelChangeFor(Identity(Principal), deleted).ShouldBeNull(gone);
nm.FilterModelChangeFor(Identity("area-key"), deleted).ShouldBeNull($"{gone}: no name-collision match");
nm.FilterModelChangeFor(Identity("everything"), deleted).ShouldBeSameAs(deleted, gone);
}
}
finally { await host.DisposeAsync(); }
}
private static DataPlaneAuthorizer Authorizer(params NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
@@ -79,6 +148,13 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.Equipment, "eq-EqA")] = [new NodeAclPath([NodeAclRealms.Uns, "EqA"])],
},
// Gitea #808: a UNS folder is placed through the generation's folder scopes, never by splitting its id.
// This test is about model-change narrowing, so it keeps its synthetic root-level folder (id EqA, path
// [uns, EqA]) and now says so explicitly; production-shaped placement is UnsFolderScopeWireTests' job.
unsFolderScopes: new Dictionary<string, UnsFolderScope>
{
["EqA"] = new(new NodeAclPath([NodeAclRealms.Uns, "EqA"]), ["EqA"]),
});
return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance);
}
@@ -234,7 +234,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
// 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>(), Deployed());
new Dictionary<string, NodeAclPath>(), Deployed(), FolderScopes());
break;
}
@@ -595,7 +595,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
{
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev1")] = [new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])],
};
state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths(), Deployed());
state.Apply(Cluster, "rev-raw", [Key()], [rawDevice], scopes, OwnerPaths(), Deployed(), FolderScopes());
condition.OnAcknowledge!(ctx, condition, EventId(), new LocalizedText("raw grant")).ShouldBe(ServiceResult.Good);
condition.OnAddComment!(ctx, condition, EventId(), new LocalizedText("raw note")).ShouldBe(ServiceResult.Good);
@@ -684,6 +684,23 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
};
}
/// <summary>
/// Gitea #808 review: the scripted-condition placement also requires the owner's LIVE folder ancestry to match
/// the generation's, so every install here carries the topology's folder scopes (same resolver as the owners).
/// </summary>
private static Dictionary<string, UnsFolderScope> FolderScopes()
{
var resolver = Resolver();
NodeAclPath One(NodeAclScopeKind kind, string id) => resolver.ResolveOne(kind, id).ShouldHaveSingleItem();
return new Dictionary<string, UnsFolderScope>
{
[AreaId] = new(One(NodeAclScopeKind.UnsArea, AreaId), [AreaId]),
[LineId] = new(One(NodeAclScopeKind.UnsLine, LineId), [AreaId, LineId]),
[Pump] = new(One(NodeAclScopeKind.Equipment, Pump), [AreaId, LineId, Pump]),
[Fan] = new(One(NodeAclScopeKind.Equipment, Fan), [AreaId, LineId, Fan]),
};
}
private static (DataPlaneAuthorityState State, DataPlaneAuthorizer Authorizer) Wire(
OtOpcUaNodeManager nm, TimeProvider clock, ApiKey[] keys, params NodeAcl[] grants) =>
Wire(nm, clock, keys, enabled: true, Areas(), grants);
@@ -702,7 +719,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
IReadOnlyDictionary<string, string> areas, NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths(), Deployed());
state.Apply(Cluster, "rev-1", keys, grants, Resolver(areas).Resolve(grants), OwnerPaths(), Deployed(), FolderScopes());
var authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, clock);
nm.DataPlaneAuthority = state;
@@ -717,7 +734,7 @@ public sealed class NodeManagerScriptedAlarmOwnerScopeTests : IDisposable
private static void Reapply(
DataPlaneAuthorityState state, string revision, IReadOnlyDictionary<string, string> deployedOwners,
ApiKey[] keys, params NodeAcl[] grants) =>
state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners);
state.Apply(Cluster, revision, keys, grants, Resolver().Resolve(grants), OwnerPaths(), deployedOwners, FolderScopes());
/// <summary>
/// The generation's deployed scripted-alarm owners (the artifact's <c>ScriptedAlarms</c> rows);
@@ -254,4 +254,62 @@ public sealed class DeploymentArtifactScopePathTests
owner.ShouldBe("EQ-mixer");
snapshot.TryGetScriptedAlarmOwner("scripted-no-owner", out _).ShouldBeFalse();
}
/// <summary>
/// Gitea #808 — the artifact yields every UNS Area / Line / Equipment folder's placement: the SAME path a grant
/// on that entity is planted at, plus the folder-id chain the address space nests it under. The path equality
/// is what lets a narrow grant reach the opaque-id folder it names; the chain is what the authorizer compares
/// the live folder against.
/// </summary>
[Fact]
public void Every_uns_folder_scope_equals_its_entity_grant_path_and_carries_its_id_chain()
{
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(Artifact), NodeId);
var folders = projection.UnsFolderScopes;
folders.Keys.Order(StringComparer.Ordinal).ShouldBe(["EQ-mixer", "UA-w", "UL-1"]);
folders["UA-w"].Path.Segments.ShouldBe(["uns", "West"]);
folders["UA-w"].IdChain.ShouldBe(["UA-w"]);
folders["UL-1"].Path.Segments.ShouldBe(["uns", "West", "L1"]);
folders["UL-1"].IdChain.ShouldBe(["UA-w", "UL-1"]);
folders["EQ-mixer"].IdChain.ShouldBe(["UA-w", "UL-1", "EQ-mixer"]);
folders["EQ-mixer"].Path.Segments.ShouldBe(
projection.ScopePaths[new AclScopeRef(NodeAclScopeKind.Equipment, "EQ-mixer")].ShouldHaveSingleItem().Segments);
var snapshot = new DataPlaneAuthorityState().Apply(
"c1", "rev-1", projection.Keys, projection.Grants, projection.ScopePaths, projection.EquipmentScopePaths,
projection.ScriptedAlarmOwners, projection.UnsFolderScopes);
snapshot.TryGetUnsFolderSegments("EQ-mixer", ["UA-w", "UL-1", "EQ-mixer"], out var segments).ShouldBeTrue();
segments.ShouldBe(["West", "L1", "Mixer"]);
// A live folder nested differently from what the generation deploys (moved, not yet rebuilt) is not placed
// at all — neither at the generation's path nor at its old one.
snapshot.TryGetUnsFolderSegments("EQ-mixer", ["UA-w", "UL-2", "EQ-mixer"], out _).ShouldBeFalse();
snapshot.TryGetUnsFolderSegments("EQ-mixer", ["EQ-mixer"], out _).ShouldBeFalse();
snapshot.TryGetUnsFolderSegments("EQ-gone", ["UA-w", "UL-1", "EQ-gone"], out _).ShouldBeFalse();
}
/// <summary>
/// Gitea #808 — an id two UNS entity kinds share (the address space can hold only one folder for it) is dropped
/// rather than placed by whichever kind came first; an orphaned line or a line-less equipment has no scope.
/// </summary>
[Fact]
public void An_ambiguous_or_orphaned_uns_folder_has_no_scope()
{
var projection = DeploymentArtifact.ParseDataPlaneAuthority(
Encoding.UTF8.GetBytes("""
{
"UnsAreas": [{"UnsAreaId":"X-1","ClusterId":"c1","Name":"West"}],
"UnsLines": [
{"UnsLineId":"X-1","UnsAreaId":"X-1","Name":"Clash"},
{"UnsLineId":"UL-orphan","UnsAreaId":"UA-missing","Name":"Orphan"},
{"UnsLineId":"UL-ok","UnsAreaId":"X-1","Name":"L1"}
],
"Equipment": [{"EquipmentId":"EQ-dangling","UnsLineId":"UL-missing","Name":"E"}],
"NodeAcls":[{"NodeAclId":"a","ClusterId":"c1","Principal":"p","ScopeKind":0,"PermissionFlags":1}]
}
"""),
NodeId);
projection.UnsFolderScopes.Keys.ShouldBe(["UL-ok"]);
}
}
@@ -666,11 +666,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
IReadOnlyList<ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl> grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
IReadOnlyDictionary<string, NodeAclPath>? equipmentScopePaths = null,
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null)
IReadOnlyDictionary<string, string>? scriptedAlarmOwners = null,
IReadOnlyDictionary<string, UnsFolderScope>? unsFolderScopes = null)
{
Interlocked.Increment(ref _applies);
return new DataPlaneAuthorityState().Apply(
clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners);
clusterId, revision, keys, grants, scopePaths, equipmentScopePaths, scriptedAlarmOwners,
unsFolderScopes);
}
}