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
+16
View File
@@ -561,6 +561,22 @@ the seams consult. Same dark-switch discipline as `MeshTransport`, `ConfigSource
| Alarm Acknowledge / AddComment / Enable / Disable | role `AlarmAck` | `AlarmAcknowledge` on the condition's scope |
| Alarm Confirm | role `AlarmAck` | `AlarmConfirm` |
| Alarm OneShotShelve / TimedShelve / Unshelve | role `AlarmAck` | `AlarmShelve` |
**Whose scope a condition's command is decided at (Gitea #797).** A *native* condition's id is its raw tag's
RawPath, so its commands are decided on that path (a `Tag` / `RawDevice` / … grant covering the raw tag), as
before. A *scripted* condition's id is its opaque, operator-authored `ScriptedAlarmId` (e.g. `scripted-eqa-hi`)
and is **never** split into a scope: `OtOpcUaNodeManager` binds each LIVE scripted condition instance to the
`EquipmentId` it was materialised under (only when it actually hangs under that equipment's folder), and
`DataPlaneAuthorizer.AuthorizeScriptedCondition` resolves that equipment to its `[uns, Area, Line, Equipment]`
path through the CURRENT authority generation's `EquipmentScopePaths` (decoded from the artifact by the same
`AclScopePathResolver` call that places an `Equipment` grant). So a grant on the owning equipment or any
ancestor (line, area, UNS namespace, cluster) authorizes its scripted alarms; a sibling equipment's grant or a
grant on one tag beneath the equipment does not. Fail-closed: an unbound condition (owner folder missing), a
removed/replaced instance, or an owner the generation cannot place is denied for **every** grant, `Cluster`
included — there is no realm-root or id-split fallback. The key, grant and generation are re-read on every call
(uncached). NodeIds and `AlarmCommand.AlarmId` are unchanged. With the flag **off** none of this is consulted.
⚠️ Read/Browse/Subscribe of UNS folders and scripted conditions, and Server-object event visibility, are still
decided on the NodeId's split, and UNS folder ids are opaque entity ids — Gitea #808.
| HistoryRead (all four variants) | **nothing** | `HistoryRead` on each node's scope, decided **per node in the batch** |
| HistoryUpdate (insert / replace / update / delete) | role `HistoryUpdate` (from a `Cluster`-scoped grant) + key re-check | `HistoryUpdate` on that node's scope, decided **per node in the batch** |
| Read (every attribute, not just Value) | nothing | `Read` on that node's scope, decided per node in the batch |