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

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

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

Sdk2OpaqueScriptedScopeBaselineTests deliberately flipped: it pinned the denial
and now pins owner-grant allow / sibling deny / cluster allow. Follow-up #808
(folder ids + Server-object event visibility for narrow UNS grants).
This commit is contained in:
Joseph Doherty
2026-11-10 21:23:57 -05:00
parent b36e688f0a
commit 3d1b86b451
16 changed files with 1511 additions and 37 deletions
@@ -66,6 +66,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
/// <summary>
/// Gitea #797 — the explicit binding from each LIVE scripted condition to the equipment that owns it.
/// A scripted condition's NodeId is its opaque <c>ScriptedAlarmId</c> and cannot be split into an ACL
/// scope, so with <c>Security:Acl:Enabled</c> its Part 9 commands are authorized at the owner's scope
/// instead (<see cref="Security.DataPlaneAuthorizer.AuthorizeScriptedCondition"/>), resolved through the
/// CURRENT authority generation at each call.
/// <para>
/// Written only by <see cref="MaterialiseAlarmCondition"/>, from the deployed composition's
/// <c>EquipmentId</c>, and only when the condition actually hangs under that equipment's folder.
/// Each entry pins the <see cref="AlarmConditionState"/> instance it was made for, so a call that
/// reaches a removed or replaced instance (rebuild, surgical remove, delete + recreate under another
/// equipment) finds no binding and is denied. Removed in lock-step with <see cref="_alarmConditions"/>
/// on every teardown path; cleared by <see cref="RebuildAddressSpace"/>. Never populated for native
/// conditions, which keep their RawPath-scoped decision. Guarded by <c>Lock</c>.
/// </para>
/// </summary>
private readonly Dictionary<RealmKey, ScriptedAlarmOwner> _scriptedAlarmOwners = new();
/// <summary>Every NATIVE condition's live state, captured by <see cref="RebuildAddressSpace"/>
/// just before it clears <see cref="_alarmConditions"/>, waiting for <see cref="MaterialiseAlarmCondition"/>
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
@@ -1493,7 +1510,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared
// first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate
// stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag.
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative)
// Gitea #797: a scripted condition whose OWNER changed (same id, different equipment) is not "the same"
// and falls through to the drop-and-recreate, so it is re-parented and re-bound rather than left
// under — and authorized as — the equipment it no longer belongs to.
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative
&& (isNative || SameScriptedOwner(conditionKey, equipmentNodeId, realm)))
return;
// Review 01 H2 — a variable or folder already holding this id in this realm wins; never shadow it.
if (!_alarmConditions.ContainsKey(conditionKey) && IsHeldByAnotherKind(conditionKey, "alarm condition"))
@@ -1522,6 +1543,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
// kind, so always drop the stale native flag first and only re-add it below when isNative.
_nativeAlarmNodeIds.Remove(conditionKey);
// Gitea #797: the dropped instance's owner binding goes with it; the new one is bound below.
_scriptedAlarmOwners.Remove(conditionKey);
var parent = ResolveParentFolder(equipmentNodeId, realm);
@@ -1709,6 +1732,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// H6a: record native (driver-fed) conditions so a later task can route their inbound
// Acknowledge to the driver rather than the scripted engine.
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
// Gitea #797: bind the scripted condition to its owning equipment — this exact instance, and only
// when it really hangs under that equipment's folder (an unresolved parent leaves it unbound ⇒ every
// ACL-enforced command on it is denied, never authorized at some guessed scope).
else if (BindableScriptedOwner(equipmentNodeId, realm) is { } owner && ReferenceEquals(parent, owner.Folder))
_scriptedAlarmOwners[conditionKey] = new ScriptedAlarmOwner(alarm, owner.EquipmentId);
// A native condition the rebuild in this apply discarded gets its live state back. Removed
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
@@ -2089,6 +2117,50 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId));
}
/// <summary>Gitea #797 — the owning equipment a live scripted condition is bound to, for tests and diagnostics.</summary>
/// <param name="alarmNodeId">The scripted condition's id (its <c>ScriptedAlarmId</c>).</param>
/// <param name="realm">The realm the condition lives in.</param>
/// <returns>The bound <c>EquipmentId</c>, or null when the condition is unknown, native or unbound.</returns>
internal string? ScriptedAlarmOwnerOf(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
{
var key = MapKey(realm, alarmNodeId);
return _scriptedAlarmOwners.TryGetValue(key, out var b)
&& _alarmConditions.TryGetValue(key, out var live)
&& ReferenceEquals(b.Alarm, live)
? b.EquipmentId
: null;
}
}
/// <summary>
/// The equipment folder a scripted condition materialised with <paramref name="equipmentNodeId"/> would be
/// bound to: only a UNS condition naming an EXISTING folder qualifies. Call under <c>Lock</c>.
/// </summary>
private (string EquipmentId, FolderState Folder)? BindableScriptedOwner(string? equipmentNodeId, AddressSpaceRealm realm) =>
realm == AddressSpaceRealm.Uns
&& !string.IsNullOrEmpty(equipmentNodeId)
&& _folders.TryGetValue(MapKey(realm, equipmentNodeId), out var folder)
? (equipmentNodeId, folder)
: null;
/// <summary>
/// Whether re-materialising the existing scripted condition at <paramref name="key"/> under
/// <paramref name="equipmentNodeId"/> leaves its owner binding exactly as it is. Call under <c>Lock</c>.
/// </summary>
private bool SameScriptedOwner(RealmKey key, string? equipmentNodeId, AddressSpaceRealm realm)
{
var current = _scriptedAlarmOwners.TryGetValue(key, out var b) ? b.EquipmentId : null;
var wanted = BindableScriptedOwner(equipmentNodeId, realm)?.EquipmentId;
return string.Equals(current, wanted, StringComparison.Ordinal);
}
/// <summary>Gitea #797 — one live scripted condition instance and the equipment that owns it.</summary>
/// <param name="Alarm">The exact condition instance the binding was made for.</param>
/// <param name="EquipmentId">The owning equipment's logical id (its UNS folder's <c>s=</c> id).</param>
private sealed record ScriptedAlarmOwner(AlarmConditionState Alarm, string EquipmentId);
/// <summary>
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
@@ -2218,8 +2290,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
if (AclEnforced)
{
return _authorizer!.Authorize(
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId));
// Native conditions (ConditionId = RawPath) keep the NodeId-scoped decision unchanged: their id IS
// the raw tag's path. Everything else is a scripted condition with an opaque id, authorized ONLY at
// the scope of the equipment its exact live instance is bound to (Gitea #797) — no binding, no
// authority. Never a fallback to splitting the opaque id.
var native = false;
string? owner = null;
lock (Lock)
{
var key = MapKey(condition.NodeId);
if (_nativeAlarmNodeIds.Contains(key))
{
native = true;
}
else if (_scriptedAlarmOwners.TryGetValue(key, out var binding)
&& ReferenceEquals(binding.Alarm, condition)
&& _alarmConditions.TryGetValue(key, out var live)
&& ReferenceEquals(live, condition))
{
owner = binding.EquipmentId;
}
}
return native
? _authorizer!.Authorize(
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId))
: _authorizer!.AuthorizeScriptedCondition(
identity, AlarmOperationFor(operation), condition.NodeId, owner);
}
// The activation-time AlarmAck role is honoured only while the key that earned it is
@@ -3755,6 +3852,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
_nativeAlarmNodeIds.Clear();
_scriptedAlarmOwners.Clear();
foreach (var f in _folders.Values) PredefinedNodes?.Remove(f.NodeId);
_folders.Clear();
@@ -3913,6 +4011,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
condition.Parent?.RemoveChild(condition);
ForgetPredefinedSubtree(condition);
_nativeAlarmNodeIds.Remove(key);
_scriptedAlarmOwners.Remove(key);
LowerAllEventsMonitoring(condition);
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
}
@@ -4021,6 +4120,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (c.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(c);
ForgetPredefinedSubtree(c);
_nativeAlarmNodeIds.Remove(key);
_scriptedAlarmOwners.Remove(key);
LowerAllEventsMonitoring(c);
return;
}
@@ -239,6 +239,91 @@ public sealed class DataPlaneAuthorizer
: null;
}
/// <summary>
/// Authorizes a Part 9 command on a <b>scripted</b> alarm condition at the scope of the equipment the
/// node manager has bound that exact live condition to (Gitea #797) — never at a scope parsed out of
/// the condition's NodeId.
/// </summary>
/// <remarks>
/// <para>
/// A scripted condition's NodeId is its opaque, operator-authored <c>ScriptedAlarmId</c>
/// (<c>^[A-Za-z0-9_-]+$</c>, e.g. <c>scripted-eqa-hi</c>). <see cref="Authorize"/> would split it as
/// a topology path — <c>[uns, scripted-eqa-hi]</c> — which no <c>Equipment</c> grant can ever
/// match, so before this entry point a session holding the owning equipment's grant was denied
/// while a <c>Cluster</c> grant passed. Worse, that one-segment scope would be satisfied by an
/// unrelated Area that happened to share the alarm's id.
/// </para>
/// <para>
/// <b>Every input is re-read at each call, and every gap denies.</b> The current snapshot (so the
/// current generation, trie and grants), the calling key's usability against this authorizer's
/// clock, and the owner's path in THAT generation's <see cref="DataPlaneAuthoritySnapshot.EquipmentScopePaths"/>.
/// No owner binding (an unknown, removed or replaced condition), an owner the generation cannot
/// place, or a path that does not name an equipment each deny — there is no fallback to the
/// realm root, the cluster, or the identifier. The decision cache is deliberately not used: the
/// binding lives in the address space, not in the generation, so a cached verdict keyed on the
/// NodeId could outlive a re-bind (delete + recreate of the same id under another equipment).
/// Alarm commands are not a request-rate seam, so the uncached walk costs nothing that matters.
/// </para>
/// <para>
/// The scope is the equipment's OWN node, not the equipment plus a leaf: grants on the equipment
/// or any ancestor (line, area, UNS namespace, cluster) confer the command, and a grant on a single
/// tag beneath the equipment does not — a tag grant is about that signal, not the equipment's alarms.
/// </para>
/// </remarks>
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
/// <param name="operation">The alarm operation being attempted.</param>
/// <param name="conditionNodeId">The condition the method targets (for the denial log only — never parsed).</param>
/// <param name="ownerEquipmentId">The <c>EquipmentId</c> the live condition is bound to, or null when the
/// node manager holds no binding for this exact instance.</param>
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
public ServiceResult? AuthorizeScriptedCondition(
RoleCarryingUserIdentity? identity,
OpcUaOperation operation,
NodeId conditionNodeId,
string? ownerEquipmentId)
{
if (identity is null)
return Deny(operation, conditionNodeId, principal: null, "the session carries no role-carrying identity");
if (string.IsNullOrEmpty(identity.Principal))
return Deny(operation, conditionNodeId, principal: null, "the session resolved no ACL principal");
var snapshot = _authority.Current;
if (string.IsNullOrWhiteSpace(snapshot.ClusterId))
{
return Deny(operation, conditionNodeId, identity.Principal,
"this node has no cluster identity, so no permission trie exists to evaluate against");
}
var now = _time.GetUtcNow().UtcDateTime;
if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null)
return DenyRevokedKey(operation, conditionNodeId, identity.Principal, keyId, snapshot.GenerationId, cached: false);
if (string.IsNullOrEmpty(ownerEquipmentId))
{
return Deny(operation, conditionNodeId, identity.Principal,
"the scripted condition has no live owner binding (unknown, removed or replaced)");
}
if (!snapshot.TryGetEquipmentUnsSegments(ownerEquipmentId, out var segments))
{
return Deny(operation, conditionNodeId, identity.Principal,
$"owner equipment {ownerEquipmentId} has no UNS path in generation {snapshot.GenerationId}");
}
var scope = new NodeScope
{
ClusterId = snapshot.ClusterId!,
Kind = NodeHierarchyKind.Uns,
Segments = segments,
};
return Walk(snapshot, identity, operation, scope, now)
? null
: DenyNoGrant(operation, conditionNodeId, identity.Principal, snapshot.GenerationId, cached: false);
}
/// <summary>
/// The decision itself. Ordered so the cached seams reach their answer having allocated nothing:
/// every check that must run per call comes first, the memoized verdict is consulted next, and the
@@ -62,6 +62,16 @@ public sealed record DataPlaneAuthorityProjection(
IReadOnlyList<NodeAcl> Grants,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths)
{
/// <summary>
/// <c>EquipmentId → [uns, Area, Line, Equipment]</c> for every equipment in the artifact whose UNS
/// chain resolves — the owner paths scripted alarm conditions are authorized at (Gitea #797). Resolved by
/// the SAME <see cref="AclScopePathResolver"/> instance as <see cref="ScopePaths"/>, so an
/// <c>Equipment</c> grant's planted path and the owner path of that equipment's scripted alarms are
/// equal by construction. Empty when the artifact carries no grants (nothing could be authorized).
/// </summary>
public IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths { get; init; } =
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase);
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
public static readonly DataPlaneAuthorityProjection Empty =
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
@@ -907,8 +917,11 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
grants.Add(acl);
}
return new DataPlaneAuthorityProjection(
scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
var (scopePaths, equipmentScopePaths) = BuildAclScopePaths(root, grants);
return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants, scopePaths)
{
EquipmentScopePaths = equipmentScopePaths,
};
}
/// <summary>
@@ -916,11 +929,18 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
/// topology, using the same <c>RawPathResolver</c> the address space is built through — so an ACL
/// path and the RawPath that forms a node's NodeId cannot drift apart.
/// </summary>
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> BuildAclScopePaths(
/// <returns>The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup,
/// Gitea #797) from the same resolver.</returns>
private static (IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths,
IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths) BuildAclScopePaths(
JsonElement root,
IReadOnlyList<NodeAcl> grants)
{
if (grants.Count == 0) return new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
if (grants.Count == 0)
{
return (new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(),
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase));
}
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "RawFolders"))
@@ -1011,7 +1031,17 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
var resolver = new AclScopePathResolver(
folders, drivers, devices, groups, tags, areas, lines, equipment, references);
return resolver.Resolve(grants);
// Every equipment's own path, through the SAME resolver call an Equipment grant's path takes — the
// scripted-alarm owner binding authorizes at exactly the node an Equipment grant is planted at. An
// equipment whose chain does not resolve is simply absent (its scripted conditions then deny).
var equipmentPaths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase);
foreach (var equipmentId in equipment.Keys)
{
if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path;
}
return (resolver.Resolve(grants), equipmentPaths);
}
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
@@ -1425,7 +1425,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// would be a different byte sequence and would not match the deployment's RevisionHash.
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths);
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths,
projection.EquipmentScopePaths);
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",