docs+ui(security): state plainly that node ACLs are not enforced (§8.1, #520)
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.
This commit is contained in:
+96
-61
@@ -1,85 +1,120 @@
|
||||
# Read/Write Operations
|
||||
|
||||
> ⚠️ **Accuracy warning (audited 2026-07-27).** Parts of this page describe v2-era machinery that no
|
||||
> longer exists. Two corrections matter most:
|
||||
>
|
||||
> 1. **There is no per-node ACL gate on the read path — or anywhere else.** `WriteAuthzPolicy`,
|
||||
> `AuthorizationGate`, `NodeScopeResolver` and `AuthorizationBootstrap` have **zero occurrences in
|
||||
> `src/`**. The ACL evaluator that *does* exist (`IPermissionEvaluator` / `TriePermissionEvaluator`
|
||||
> / `PermissionTrieCache`, `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/`) has **no production
|
||||
> consumer** — `OtOpcUaNodeManager` never references it — even though `ClusterAcls.razor` lets
|
||||
> operators author `NodeAcl` rows and `ConfigComposer.cs:51` ships them in every deployment
|
||||
> artifact. Actual enforcement today is LDAP role mapping plus the realm-qualified `WriteOperate`
|
||||
> check in `OtOpcUaNodeManager`, which is a **write** gate; **reads are ungated**. See
|
||||
> `deferment.md` §3.1.
|
||||
> 2. **`GenericDriverNodeManager` is not a production dispatch path** — it is Core test scaffolding
|
||||
> with zero production references (`GenericDriverNodeManager.cs:71`). The live server materialises
|
||||
> the address space through `AddressSpaceComposer` / `AddressSpaceApplier`.
|
||||
>
|
||||
> The `CapabilityInvoker` / Polly / `OnReadValue` / `OnWriteValue` mechanics below remain accurate.
|
||||
> **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 v2 server routes OPC UA Read and Write operations to each driver's `IReadable` and `IWritable` capabilities through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. The per-variable `OnReadValue` and `OnWriteValue` hooks described in the sections below live in `DriverNodeManager` (the planned ADR-002 Phase 7 Stream G successor to the v1 `DriverNodeManager`); `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) handles address-space population and alarm routing during discovery. The current `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) is a push-model `CustomNodeManager2` that receives values from the Akka actor layer via `WriteValue`; OPC UA client reads return the cached pushed value.
|
||||
## The shape of it: push for reads, pull for writes
|
||||
|
||||
## Driver vs virtual dispatch
|
||||
The live server is `OtOpcUaNodeManager`, a **push-model** `CustomNodeManager2`. This asymmetry is the
|
||||
single most important thing on this page:
|
||||
|
||||
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), a single `DriverNodeManager` routes reads and writes across both driver-sourced and virtual (scripted) tags. At discovery time each variable registers a `NodeSourceKind` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`) in the manager's `_sourceByFullRef` lookup; the read/write hooks pattern-match on that value to pick the backend:
|
||||
- **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.
|
||||
|
||||
- `NodeSourceKind.Driver` — dispatches to the driver's `IReadable` / `IWritable` through `CapabilityInvoker` (the rest of this doc).
|
||||
- `NodeSourceKind.Virtual` — dispatches to `VirtualTagSource` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which wraps `VirtualTagEngine`. Writes are rejected with `BadUserAccessDenied` before the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags.
|
||||
- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim.
|
||||
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.
|
||||
|
||||
~~ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.~~ **Not true** — neither type exists; there is no per-node ACL gate. The only authorization applied before the source branch is the LDAP-role write gate (`WriteOperate` / `WriteTune` / `WriteConfigure`, realm-qualified, fail-closed). See the banner at the top of this page.
|
||||
## Read path
|
||||
|
||||
## OnReadValue
|
||||
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.
|
||||
|
||||
The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace:
|
||||
Authored `NodeAcl` deny rules have **no effect on reads** (or on anything else — see
|
||||
[docs/security.md](security.md) § Data-Plane Authorization).
|
||||
|
||||
1. If the driver does not implement `IReadable`, the hook returns `BadNotReadable`.
|
||||
2. The node's `NodeId.Identifier` is used directly as the driver-side full reference — it matches `DriverAttributeInfo.FullName` registered at discovery time.
|
||||
3. ~~(Phase 6.2) If an `AuthorizationGate` + `NodeScopeResolver` are wired, the gate is consulted first via `IsAllowed(identity, OpcUaOperation.Read, scope)`. A denied read never hits the driver.~~ **Never shipped** — neither type exists in `src/`, and no ACL gate is consulted on the read path. Reads are authorized only by the `AccessLevels` bits set at materialization (and `HistoryRead` for history). Authored `NodeAcl` deny rules have **no effect on reads**.
|
||||
4. The call is wrapped by `_invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …)`. The resolved host is `IPerCallHostResolver.ResolveHost(fullRef)` for multi-host drivers; single-host drivers fall back to `DriverInstanceId` (decision #144).
|
||||
5. The first `DataValueSnapshot` from the batch populates the outgoing `value` / `statusCode` / `timestamp`. An empty batch surfaces `BadNoData`; any exception surfaces `BadInternalError`.
|
||||
## Write path — `OnEquipmentTagWrite`
|
||||
|
||||
The hook is synchronous — the async invoker call is bridged with `AsTask().GetAwaiter().GetResult()` because the OPC UA SDK's value-hook signature is sync. Idempotent-by-construction reads mean this bridge is safe to retry inside the Polly pipeline.
|
||||
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.
|
||||
|
||||
## OnWriteValue
|
||||
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` follows the same shape with two additional concerns: authorization and idempotence.
|
||||
`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.
|
||||
|
||||
### Authorization (two layers)
|
||||
## Alarm method calls
|
||||
|
||||
1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (see `docs/security.md`).
|
||||
2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`.
|
||||
|
||||
### Dispatch
|
||||
|
||||
`_invoker.ExecuteWriteAsync(host, isIdempotent, callSite, …)` honors the `WriteIdempotentAttribute` semantics per decisions #44-45 and #143:
|
||||
|
||||
- `isIdempotent = true` (tag flagged `WriteIdempotent` in the Config DB) → runs through the standard `DriverCapability.Write` pipeline; retry may apply per the tier configuration.
|
||||
- `isIdempotent = false` (default) → the invoker builds a one-off pipeline with `RetryCount = 0`. A timeout may fire after the device already accepted the pulse / alarm-ack / counter-increment; replay is the caller's decision, not the server's.
|
||||
|
||||
The `_writeIdempotentByFullRef` lookup is populated at discovery time from the `DriverAttributeInfo.WriteIdempotent` field.
|
||||
|
||||
### Per-write status
|
||||
|
||||
`IWritable.WriteAsync` returns `IReadOnlyList<WriteResult>` — one numeric `StatusCode` per requested write. A non-zero code is surfaced directly to the client; exceptions become `BadInternalError`. The OPC UA stack's pattern of batching per-service is preserved through the full chain.
|
||||
|
||||
## Array element writes
|
||||
|
||||
Array-element writes via OPC UA `IndexRange` are driver-specific. The OPC UA stack hands the dispatch an unwrapped `NumericRange` on the `indexRange` parameter of `OnWriteValue`; `DriverNodeManager` passes the full `value` object to `IWritable.WriteAsync` and the driver decides whether to support partial writes. Galaxy performs a read-modify-write inside the Galaxy driver (MXAccess has no element-level writes); other drivers generally accept only full-array writes today.
|
||||
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
|
||||
|
||||
`DriverNodeManager.HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, and `HistoryReadEvents` route through the driver's `IHistoryProvider` capability with `DriverCapability.HistoryRead`. Drivers without `IHistoryProvider` surface `BadHistoryOperationUnsupported` per node. See `docs/v1/HistoricalDataAccess.md`.
|
||||
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
|
||||
|
||||
Per decision #12, exceptions in the driver's capability call are logged and converted to a per-node `BadInternalError` — they never unwind into the master node manager. This keeps one driver's outage from disrupting sibling drivers in the same server process.
|
||||
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/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — address-space population and alarm routing during discovery
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — push-model `CustomNodeManager2`; `EnsureVariable` / `WriteValue` are the v2 read/write path
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — permission trie + evaluator (`PermissionTrie`, `PermissionTrieCache`, `TriePermissionEvaluator`) that gates Read/Write/Subscribe per the session's resolved LDAP groups
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — `ExecuteAsync` / `ExecuteWriteAsync`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.cs`
|
||||
- `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**
|
||||
|
||||
Reference in New Issue
Block a user