fix(acl): place opaque-id UNS folders, virtual tags and scripted conditions for every operation (#808)

UNS Area/Line/Equipment folders are minted under their opaque entity ids
(s=UA-/UL-/EQ-...), virtual tags under s=EQ-.../Name and scripted conditions
(+ #629 children) under s=<ScriptedAlarmId>[/...], while every UNS grant is
planted at a NAME path. DataPlaneAuthorizer split those ids, so with
Security:Acl:Enabled a narrow (Area/Line/Equipment) grant could not Browse,
Read, subscribe or HistoryRead them, and Server-object delivery never reached
it — so a narrow key never held the EventId Ack/Confirm/AddComment need.

- The generation carries UnsFolderScopes (folderId -> path + id chain), built
  by DeploymentArtifact from the same AclScopePathResolver as the grants; ids
  two entity kinds share are dropped; plumbed through IDataPlaneAuthoritySink.
- OtOpcUaNodeManager.ResolveUnsAclSubject reports live facts (folder id
  ancestry / scripted owner binding); attached by the Authorizer setter.
- Evaluate places the subject against the CURRENT generation before the
  decision cache, which now caches the walk keyed on the resolved scope.
  Folders require live chain == generation chain (a moved equipment is denied
  under old and new line until the rebuild). Owned nodes (vtags, conditions,
  children) get owner path + a reserved segment, so Tag grants and their
  Browse transit never expose them. Conditions share TryPlaceScriptedCondition
  with the #797 command gate.
- Server-object visibility of a scripted condition is decided on the
  condition alone, so a moved alarm cannot reach its old owner there.

Tests: UnsFolderScopeWireTests (opaque ids, end-to-end browse -> EventId ->
Acknowledge on an Equipment grant; sibling denied; Tag-only grant; moved alarm
and moved equipment stale windows). Rig tests now install folder scopes
explicitly (ServerEventRig.FolderScopes); the #797 recreate test deliberately
asserts the moved alarm is invisible to the Cluster observer in the window.
This commit is contained in:
Joseph Doherty
2026-11-10 23:12:46 -05:00
parent 308b1b3b56
commit af5c9744b7
15 changed files with 1095 additions and 60 deletions
@@ -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;
@@ -4203,6 +4211,122 @@ 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 (<see cref="UnsAclSubjectKind.Path"/>), so a <c>Tag</c> grant keeps matching it.</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 an unknown id and keeps the split.
var conditionId = slash < 0 ? identifier : identifier[..slash];
var conditionKey = MapKey(AddressSpaceRealm.Uns, conditionId);
if (!_alarmConditions.TryGetValue(conditionKey, out var condition)) return UnsAclSubject.Path;
lock (Lock)
{
if (_nativeAlarmNodeIds.Contains(conditionKey)) return UnsAclSubject.Path;
var owner = _scriptedAlarmOwners.TryGetValue(conditionKey, out var binding)
&& ReferenceEquals(binding.Alarm, condition)
&& _alarmConditions.TryGetValue(conditionKey, out var live)
&& ReferenceEquals(live, condition)
? binding.EquipmentId
: null;
return UnsAclSubject.ScriptedCondition(conditionId, owner);
}
}
// 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;
}
if (owning is null || !firstSegment.SequenceEqual(owning.NodeId.IdentifierAsString)) return UnsAclSubject.Path;
// Its live ancestry, root-first: every live hierarchy folder up to the first non-UNS-folder (the root).
var chain = new List<string>(3);
current = owning;
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 UnsAclSubject.Folder(chain, isFolderItself: ReferenceEquals(node, owning));
}
/// <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 +5412,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,30 @@ 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>
/// 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
@@ -300,29 +324,9 @@ 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))
{
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}");
}
conditionNodeId.TryGetValue(out string alarmId);
if (!TryPlaceScriptedCondition(snapshot, alarmId, ownerEquipmentId, out var segments, out var reason))
return Deny(operation, conditionNodeId, identity.Principal, reason);
var scope = new NodeScope
{
@@ -336,6 +340,117 @@ 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="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,
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;
}
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). Every gap fails — the caller denies; there is no fallback to
/// splitting the identifier, to the realm root, or to the cluster.
/// </summary>
/// <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(
DataPlaneAuthoritySnapshot snapshot,
in UnsAclSubject subject,
out IReadOnlyList<string> segments,
out string reason)
{
IReadOnlyList<string> owner;
bool owned;
switch (subject.Kind)
{
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, 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 real <c>s=</c> identifier.
/// </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
@@ -402,8 +517,27 @@ public sealed class DataPlaneAuthorizer
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(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 +546,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 +568,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,82 @@
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 (a UNS reference variable, or an unknown id).</summary>
Path,
/// <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>Which placement rule applies.</summary>
public UnsAclSubjectKind Kind { get; init; }
/// <summary>
/// For <see cref="UnsAclSubjectKind.Folder"/>: the owning folder's LIVE id ancestry, root-first, ending at
/// the owning folder itself (e.g. <c>[UA-…, UL-…, EQ-…]</c>).
/// </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>
/// <returns>The subject.</returns>
public static UnsAclSubject ScriptedCondition(string alarmId, string? ownerEquipmentId) =>
new() { Kind = UnsAclSubjectKind.ScriptedCondition, AlarmId = alarmId, OwnerEquipmentId = ownerEquipmentId };
}
@@ -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})",