Merge fix/808-uns-folder-scope
This commit is contained in:
@@ -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=<ScriptedAlarmId>[/…]</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=<ScriptedAlarmId></c>) or one of its instance-scoped children
|
||||
/// (<c>s=<ScriptedAlarmId>/…</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})",
|
||||
|
||||
Reference in New Issue
Block a user