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:
Joseph Doherty
2026-07-27 18:36:43 -04:00
parent e08855fb9d
commit 53ede679c3
7 changed files with 336 additions and 93 deletions
+96 -61
View File
@@ -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**