53ede679c3
deferment.md §8.1 asked for a decision: wire IPermissionEvaluator into the node manager, or say plainly that ACLs are not enforced. Decision: the latter. The evaluator stays in the tree; #520 tracks the wire-up. Verifying the claim turned up four things worse than the audit recorded: - docs/security.md, not ReadWriteOperations.md, was the highest-risk doc. It claimed an anonymous session is "default-denie[d] any node a session has no ACL grant for". The opposite holds: anonymous can Browse/Read/Subscribe/ HistoryRead the entire address space, and is refused writes and alarm acks only because it carries no roles. - Three of five documented data-plane role strings do nothing. OpcUaDataPlaneRoles declares exactly WriteOperate and AlarmAck; ReadOnly, WriteTune and WriteConfigure are compared against nowhere in src/. The doc called all five "exact, case-insensitive, and code-true". - ReadWriteOperations.md was fiction beyond the ACL claims. OnReadValue has zero occurrences in src/ — the documented read path did not exist. Reads never reach a driver: the node manager is push-model and the SDK serves a Read from the cached pushed value. WriteAuthzPolicy, _sourceByFullRef, _writeIdempotentByFullRef and IRoleBearer are likewise zero-hit, and CapabilityInvoker is not referenced by the OpcUaServer project at all. This file is rewritten rather than bannered. - v2-release-readiness.md claimed a whole enforcement layer shipped — FilterBrowseReferences, GateCallMethodRequests, MapCallOperation, AuthorizationBootstrap, Node:Authorization:* keys. None exist. Whatever landed on the v2 branch did not survive into the shipped tree. The UI change is the operative one: ClusterAcls.razor and AclEdit.razor now warn that a grant is saved and shipped in every deployment artifact but never evaluated, so an operator cannot author a deny rule believing it works. deferment.md gains a §9 execution log tracking §8 remediation.
121 lines
7.0 KiB
Markdown
121 lines
7.0 KiB
Markdown
# Read/Write Operations
|
|
|
|
> **Rewritten 2026-07-27** against `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`.
|
|
> The previous revision described a v2-era `DriverNodeManager` with per-variable `OnReadValue` hooks,
|
|
> an `AuthorizationGate`/`WriteAuthzPolicy` ACL pair and a `NodeSourceKind` dispatch switch. **None of
|
|
> those exist** — `OnReadValue`, `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef`
|
|
> and `IRoleBearer` all have zero occurrences in `src/`. The read path in particular worked nothing
|
|
> like the way it was documented. See `deferment.md` §3.1 and §7.
|
|
|
|
## The shape of it: push for reads, pull for writes
|
|
|
|
The live server is `OtOpcUaNodeManager`, a **push-model** `CustomNodeManager2`. This asymmetry is the
|
|
single most important thing on this page:
|
|
|
|
- **Reads never reach a driver.** There is no `Read` override and no `OnReadValue` / `OnSimpleReadValue`
|
|
handler anywhere in the node manager. Driver values are *pushed in* from the Akka actor layer
|
|
(`DriverInstanceActor` polls or subscribes, `DriverHostActor` fans the value to the raw NodeId and
|
|
every referencing UNS NodeId), and a client Read is served by the OPC UA SDK straight from the
|
|
cached node value. A client read therefore costs nothing on the wire to the device, and cannot fail
|
|
with a device error — it returns whatever quality was last pushed.
|
|
- **Writes are synchronous pull-through.** A client write runs `OnWriteValue` → role gate → driver.
|
|
|
|
Everything the old page said about `CapabilityInvoker` wrapping OPC UA reads was misplaced: the
|
|
invoker is real, but it lives on the **driver-actor** side wrapping the poll/subscribe calls. The
|
|
`OpcUaServer` project does not reference it at all.
|
|
|
|
## Read path
|
|
|
|
1. The SDK resolves the NodeId in the Raw (`ns=2`) or UNS (`ns=3`) namespace.
|
|
2. It returns the cached `DataValue` — value, `StatusCode` and source timestamp as last pushed.
|
|
3. **No authorization check runs.** Not a per-node ACL, not a role check, nothing. Any admitted
|
|
session — including an Anonymous one — can read every node in the address space. The only gating is
|
|
the `AccessLevels` bitmask set at materialization, which is a per-node *capability* declaration, not
|
|
a per-user decision.
|
|
|
|
Authored `NodeAcl` deny rules have **no effect on reads** (or on anything else — see
|
|
[docs/security.md](security.md) § Data-Plane Authorization).
|
|
|
|
## Write path — `OnEquipmentTagWrite`
|
|
|
|
The handler is attached in `EnsureVariable` **only when the tag was materialized `writable`**, and
|
|
re-attached or cleared by `UpdateTagAttributes` on an in-place edit. A non-writable node has no
|
|
handler, so the SDK rejects the write before any of this runs.
|
|
|
|
1. **Role gate.** `EvaluateEquipmentWriteGate(identity, gatewayWired)` requires the session identity to
|
|
be a `RoleCarryingUserIdentity` carrying the `WriteOperate` role. No identity or no role ⇒
|
|
`BadUserAccessDenied`, fail-closed. Gate passed but no write gateway wired (admin-only node,
|
|
pre-boot) ⇒ `BadNotWritable`.
|
|
- This is a **server-wide** check. It takes no node and no realm: a session that may write one tag
|
|
may write every writable tag on that node.
|
|
- `WriteTune` and `WriteConfigure` are **never checked**. `OpcUaDataPlaneRoles` declares only
|
|
`WriteOperate` and `AlarmAck`; the classification tiers exist in the permission vocabulary but no
|
|
code path reads them.
|
|
2. **Optimistic local apply.** The new value is written to the node so subscribers see it immediately.
|
|
3. **Realm-qualified dispatch.** `RealmOf(node.NodeId)` selects Raw or UNS, and the value routes to the
|
|
backing driver ref through the node write gateway. Both NodeIds for a fanned value resolve to the
|
|
same driver ref, so a write via either one reaches the same device point.
|
|
4. **Write-outcome self-correction.** If the device write fails, the optimistic value is **reverted**
|
|
on the raw NodeId and every referencing UNS NodeId through the shared fan-out, so a failed write
|
|
cannot leave a phantom Good value behind. (Galaxy is the exception: its write is fire-and-forget, so
|
|
it can never surface a write failure.)
|
|
|
|
`OnWriteValue` is invoked by the SDK **while holding the node-manager `Lock`**, so the handler must not
|
|
block. Anything asynchronous is dispatched fire-and-forget with the revert wired as a continuation.
|
|
|
|
## Alarm method calls
|
|
|
|
Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve)
|
|
are gated by a second role check requiring `AlarmAck` — one role covering all five methods; the
|
|
`operation` argument is passed to the router but the gate does not consult it. Scripted alarms go
|
|
through `HandleAlarmCommand`, driver-fed native alarms through `HandleNativeAlarmAck`; both fail closed
|
|
with `BadUserAccessDenied`.
|
|
|
|
## HistoryRead
|
|
|
|
Four overrides — `HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`,
|
|
`HistoryReadEvents` — route to the **server-wide** `IHistorianDataSource` (the HistorianGateway-backed
|
|
reader, or `NullHistorianDataSource` when `ServerHistorian:Enabled=false`, which returns `GoodNoData`).
|
|
This is not a per-driver `IHistoryProvider` dispatch.
|
|
|
|
**No authorization check runs on any of the four.** Unlike `OnWriteValue`, the SDK does *not* hold the
|
|
node-manager `Lock` while invoking them, so these paths may await.
|
|
|
|
See [docs/Historian.md](Historian.md).
|
|
|
|
## Failure isolation
|
|
|
|
Exceptions in a driver capability call are logged and converted to a per-node bad status rather than
|
|
unwinding into the master node manager, so one driver's outage cannot disrupt sibling drivers in the
|
|
same process.
|
|
|
|
## What is *not* enforced anywhere
|
|
|
|
Collected here because the previous revision of this page claimed several of these:
|
|
|
|
| Surface | Check |
|
|
|---|---|
|
|
| Read, Browse, TranslateBrowsePaths | none |
|
|
| CreateMonitoredItems / Subscribe / TransferSubscriptions | none |
|
|
| HistoryRead (all four variants) | none |
|
|
| Non-Value attribute writes | none (the handler is on `OnWriteValue`) |
|
|
| Value write | `WriteOperate` role, server-wide |
|
|
| Alarm methods | `AlarmAck` role, server-wide |
|
|
| Per-node ACLs (`NodeAcl` / `PermissionTrie`) | **authored and deployed, never evaluated** |
|
|
|
|
## Key source files
|
|
|
|
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — the live node manager;
|
|
`EnsureVariable` / `WriteValue` / `OnEquipmentTagWrite` / `EvaluateEquipmentWriteGate` and the four
|
|
HistoryRead overrides
|
|
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`,
|
|
`Security/OpcUaDataPlaneRoles.cs` — how roles reach the gates
|
|
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — materialization; where the write
|
|
handler is attached per realm
|
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — the push side: value fan-out to
|
|
raw + UNS NodeIds
|
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — driver-side resilience pipeline
|
|
(**not** on the OPC UA read path)
|
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — the permission trie + evaluator, built and
|
|
unit-tested but with **zero production consumers**
|