diff --git a/deferment.md b/deferment.md index b10efb62..4363b39f 100644 --- a/deferment.md +++ b/deferment.md @@ -342,9 +342,11 @@ appear in §7.1 as well. ## 8. Recommended sequencing -1. **§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or +1. ~~**§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or remove the authoring UI and say plainly that ACLs are not enforced. Either way fix - `docs/ReadWriteOperations.md` first; it is the one that could mislead a security review. + `docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~ + ✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9. + (`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.) 2. **#518 + #507 together** — neither fix is observable alone. 3. **#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes. 4. **G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would @@ -356,6 +358,65 @@ appear in §7.1 as well. --- +## 9. Execution log + +Remediation of §8, on branch `feat/deferment-remediation`. Status is updated in the **same commit** as +the work, so this register never disagrees with the tree. + +| §8 item | Status | Commit | +|---|---|---| +| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` | +| 2. #518 + #507 | ⏳ Not started — decided *signal, not mutation* | — | +| 3. #516 config discard | ⏳ Not started — decided *both* (per-driver re-parse **and** seam respawn) | — | +| 4. G-4 dispatch-map parity | ⏳ Not started | — | +| 5. Bookkeeping sweep | ⏳ Not started | — | +| 6. Tier truth | ⏳ Not started — decided *document the truth, defer the code decision* | — | + +### 2026-07-27 — §8.1 ACL non-enforcement made explicit + +Decision: **do not wire the evaluator, do not delete it** — state plainly that it does not run, and +track the wire-up as **Gitea #520**. + +Applied: + +- `docs/security.md` — the `NodeScope`/`PermissionTrie`/"Dispatch gate" block now leads with a + **What is enforced today** table (the three real checks) before the design material, which is + labelled *Designed but not wired*. +- `docs/ReadWriteOperations.md` — **rewritten**, not bannered. +- `docs/v2/acl-design.md` — ⚠️ NEVER WIRED banner. +- `docs/v2/v2-release-readiness.md` §"Security — Phase 6.2 dispatch wiring" — the CLOSED claim marked + as not holding. +- `ClusterAcls.razor` + `AclEdit.razor` — warning alerts stating rules are stored and deployed but + never evaluated. + +**Four things this pass found that §7 and the original audit missed** — all worse than what was +already recorded: + +1. **`docs/security.md` was the highest-risk doc, not `ReadWriteOperations.md`.** `:115` claimed an + anonymous session is "default-denie[d] any node a session has no ACL grant for". The opposite is + true: an anonymous session can Browse, Read, Subscribe and HistoryRead the **entire** address + space. It is refused only writes and alarm acks — and only because it carries no roles. +2. **Three of the five documented data-plane role strings do nothing.** `OpcUaDataPlaneRoles` declares + exactly `WriteOperate` and `AlarmAck`. `ReadOnly`, `WriteTune` and `WriteConfigure` are compared + against nowhere in `src/`, so mapping a group to `WriteTune` grants nothing and mapping one to + `ReadOnly` restricts nothing. The doc called all five "exact, case-insensitive, and **code-true**". +3. **`ReadWriteOperations.md` was fiction well beyond the ACL claims.** `OnReadValue` has **zero** + occurrences in `src/` — the whole documented read path did not exist. Reads never reach a driver at + all: the node manager is push-model and the SDK serves a client 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. +4. **`v2-release-readiness.md` claimed a whole enforcement layer shipped.** Beyond the `:41` row §7.1 + already flagged, `:46-50` describe `FilterBrowseReferences`, `GateCallMethodRequests`, + `MapCallOperation`, `AuthorizationBootstrap` and `Node:Authorization:*` config keys as Closed or + Partial. None of them exist. Whatever landed on the v2 branch did not survive into the shipped tree. + +Also recorded in #520 and not previously known: every existing evaluator unit test runs in +`PermissionTrieBuilder`'s no-`scopePaths` *deterministic test mode*, so the production hierarchy path +is effectively untested; and `NodeAcl.ScopeId` has no FK or existence check, so scope ids dangle +silently. + +--- + *Generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents. Every `path:line` reference was read from source. Items marked CANNOT VERIFY require hardware, a VPN'd gateway, or a live rig and are listed as unproven rather than assumed.* diff --git a/docs/ReadWriteOperations.md b/docs/ReadWriteOperations.md index bf050fb0..2aea0c28 100644 --- a/docs/ReadWriteOperations.md +++ b/docs/ReadWriteOperations.md @@ -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` — 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** diff --git a/docs/security.md b/docs/security.md index d0bd9f73..5604c76d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,7 +21,7 @@ OtOpcUa has four independent security concerns. This document covers all four: 1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust). 2. **OPC UA authentication** — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind. -3. **Data-plane authorization** — who can browse, read, subscribe, write, acknowledge alarms on which nodes. Evaluated by `TriePermissionEvaluator` over a `PermissionTrie` built from the Config DB `NodeAcl` tree. +3. **Data-plane authorization** — who can write and acknowledge alarms on an OtOpcUa endpoint. ⚠️ Enforced today by **two coarse, server-wide LDAP role checks** (`WriteOperate`, `AlarmAck`) — **not** by the `NodeAcl` / `PermissionTrie` subsystem, which is built and unit-tested but never wired. Read, Browse, HistoryRead and Subscribe carry **no** authorization check at all. See [Data-Plane Authorization](#data-plane-authorization). 4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`Viewer` / `Designer` / `Administrator`) claim resolved from `LdapGroupRoleMapping`. Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap `appsettings.json`. Data-plane ACLs and Admin role grants live in the Config DB. @@ -112,9 +112,9 @@ The Server accepts three OPC UA identity-token types: | Token | Handler | Notes | |---|---|---| -| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | Data-plane authorization (below) still default-denies any node a session has no ACL grant for. | -| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) and attached to the OPC UA session identity for the downstream ACL evaluator. | -| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow); finer-grain authorization happens through the data-plane ACLs. | +| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ An anonymous session carries **no roles**, so it is refused every write and every alarm ack — but it can **Browse, Read, Subscribe and HistoryRead the entire address space**. There is no per-node ACL check on those operations (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. | +| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) and the **roles** are attached to the OPC UA session identity as a `RoleCarryingUserIdentity`. The raw group list is discarded at that point and never reaches the data plane. | +| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow). It carries **no** roles, so a cert-only session is read-everything / write-nothing. | When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`. @@ -130,7 +130,7 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s 4. Delegates the real path to the shared `ZB.MOM.WW.Auth.Ldap` client: it binds (search-then-bind via `ServiceAccountDn`, or direct-bind `cn={user},{SearchBase}` when no service account is set), verifies the password, and reads the user's group memberships. 5. Returns an `LdapAuthResult` carrying the validated username + the **groups** (never roles). Failure codes are folded into opaque user-facing error strings so a probe cannot distinguish "unknown user" from "wrong password". -**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity for the ACL evaluator. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session. +**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity (`RoleCarryingUserIdentity`), where the write and alarm-ack gates read them. **The LDAP group names themselves are consumed here and go no further** — which is one reason the `NodeAcl` subsystem cannot currently be wired, since it matches on groups rather than roles. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session. `Transport` replaces the former `UseTls` bool: `Ldaps` (implicit TLS), `StartTls` (upgrade), or `None` (plaintext, requires `AllowInsecure`). Configuration example (Active Directory production): @@ -180,11 +180,92 @@ Defaults are behaviour-neutral on a healthy directory. All new paths are **fail- ## Data-Plane Authorization -Data-plane authorization is the check run on every OPC UA operation against an OtOpcUa endpoint: *can this authenticated user Browse / Read / Subscribe / Write / HistoryRead / AckAlarm / Call on this specific node?* +> ⚠️ **Read this first (verified against source, 2026-07-27).** This section used to describe the +> `NodeAcl` / `PermissionTrie` design as if it were the live enforcement path. **It is not.** The +> evaluator is built and unit-tested but has **zero production call sites**, and +> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not even reference the project it lives in. Everything +> actually enforced today is in [What is enforced today](#what-is-enforced-today); the trie design is +> retained below, clearly marked, under [Designed but not wired](#designed-but-not-wired-nodeacl--permissiontrie). -Per decision #129 the model is **additive-only — no explicit Deny**. Grants at each hierarchy level union; absence of a grant is the default-deny. +### What is enforced today -### Hierarchy +There are exactly **three** authorization checks in the whole OPC UA server layer. All three are +coarse, **server-wide role string checks** with no per-node component. + +| Operation | Gate | Requires | +|---|---|---| +| Write (Value attribute, either namespace) | `OtOpcUaNodeManager.EvaluateEquipmentWriteGate` | role `WriteOperate` | +| Alarm Acknowledge / Confirm / AddComment / Shelve / Unshelve (scripted alarms) | `OtOpcUaNodeManager.HandleAlarmCommand` | role `AlarmAck` | +| Alarm acknowledge (driver-fed native alarms) | `OtOpcUaNodeManager.HandleNativeAlarmAck` | role `AlarmAck` | + +Both gates fail closed — a session with no identity or no matching role gets +`BadUserAccessDenied` — and read `identity.Roles` off the session's `RoleCarryingUserIdentity` +(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`). Role strings are +the constants in `OpcUaDataPlaneRoles`, sourced from `Security:Ldap:GroupToRole` (see +[Role grant source](#role-grant-source-data-plane)). + +**What has no authorization check at all:** + +- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including + an Anonymous one, sees and reads the entire address space. +- **HistoryRead** — all four overrides (`HistoryReadRawModified`, `HistoryReadProcessed`, + `HistoryReadAtTime`, `HistoryReadEvents`) run without an identity check. +- **CreateMonitoredItems / subscriptions / TransferSubscriptions**. +- **Non-Value attribute writes** — the gate hangs off `OnWriteValue`. + +**And what the write gate does *not* distinguish:** + +- **`WriteTune` and `WriteConfigure` are never checked.** They exist in the role vocabulary and in + `NodePermissions`, but every write — whatever the tag's security classification — is gated on the + single `WriteOperate` string. +- **`AlarmAcknowledge` / `AlarmConfirm` / `AlarmShelve` are not distinguished.** One `AlarmAck` role + covers all five alarm methods; the `operation` argument is passed to the router but never consulted + by the gate. +- **There is no per-node granularity.** A session that may write one tag may write **every** writable + tag on the node. + +The gate *is* realm-aware in one narrow sense: it is attached per-node during materialization for both +the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself +takes no realm and no node. + +### Designed but not wired: `NodeAcl` + `PermissionTrie` + +Everything from here to [Role grant source](#role-grant-source-data-plane) describes a subsystem that +**exists in the tree, is covered by 30 unit tests, and never executes.** It is kept because the design +is sound and the remaining work is wiring plus one genuine design gap — not because it runs. + +What is real about it: + +- Operators **can** author `NodeAcl` rows in the Admin UI (`/clusters/{id}/acls`), and those rows are + validated, versioned and persisted. +- `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL + edit shifts the artifact's `RevisionHash` and ships to every node. +- The node side **never deserializes them** — `DeploymentArtifact` has no ACL reader. The bytes arrive + and are dropped. + +So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour +to expect until the tracking issue below is closed. + +**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"): + +1. **Raw-realm nodes have no representable scope.** `NodeHierarchyKind` has exactly one member, + `Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot + express — yet the write gate is attached to raw tags. Roughly half the writable address space has + no scope to evaluate. This is a design decision, not wiring. +2. **The identity carries roles, not groups.** The trie matches `NodeAcl.LdapGroup` against the + session's LDAP groups, but `OpcUaUserAuthResult` carries only mapped role strings; the group list + is discarded inside `LdapOpcUaUserAuthenticator`. +3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster + grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have + to be derived from the artifact's UNS relations. +4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but + nothing in production constructs one, stamps a generation, or drives a refresh. + +Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in +the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead` +has no `OpcUaOperation` mapping, so it is grantable but unreachable. + +### Hierarchy (design) ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes: @@ -245,19 +326,23 @@ The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAcces `NodeScope` is described above (Equipment-kind vs SystemPlatform-kind). The evaluator unions the matched grants along the path — a tag-level ACL and an area-level ACL both contribute. -### Dispatch gate — `IPermissionEvaluator` +### Dispatch gate — `IPermissionEvaluator` (design; **no such gate exists**) -`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. The dispatch path calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call; a `NotGranted` decision denies the operation. +`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. -Key properties: +⚠️ **The intended dispatch path — "calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call" — was never built.** `Authorize` has zero production call sites. What the dispatch path really does is in [What is enforced today](#what-is-enforced-today). -- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through the evaluator. -- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. There is no `StrictMode` / fail-open mode; absence of a grant is always a deny. -- **Evaluator stays pure.** `TriePermissionEvaluator` has no OPC UA stack dependency — it's tested directly from xUnit. +Intended properties, of which only the last is true today: + +- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else was to flow through the evaluator. +- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. ⚠️ This describes the evaluator's *internal* behaviour when called. Because nothing calls it, **the effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW.** +- **Evaluator stays pure.** ✅ True — `TriePermissionEvaluator` has no OPC UA stack dependency and is tested directly from xUnit. That purity is also why it was easy to leave unwired. ### Full model -See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). +See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). Read it as a **design document for unshipped work**, not as a description of running behaviour. + +Note also that the `SystemPlatform` hierarchy kind named above was retired with the v3 dual-namespace address space; `NodeHierarchyKind` now has only `Equipment`. ### Role grant source (data-plane) @@ -271,23 +356,25 @@ to those role strings via `GroupToRole`, e.g.: ```json "GroupToRole": { "ot-operators": "WriteOperate", - "ot-tuners": "WriteTune", - "ot-engineers": "WriteConfigure", - "ot-alarm-ack": "AlarmAck", - "ot-readonly": "ReadOnly" + "ot-alarm-ack": "AlarmAck" } ``` -If this mapping is absent the data-plane evaluator is strictly default-deny: inbound operator writes -and OPC UA Part-9 alarm acknowledgement all return `BadUserAccessDenied` even for users who -authenticate successfully. (The same requirement gates both the scripted-alarm and the native -Galaxy-alarm Part-9 ack/confirm/shelve paths.) +⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and +`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and +`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group +to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for +everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was +wrong for three of them. -The role strings above are **exact, case-insensitive, and code-true** — the inbound gates compare -against the constants in `OpcUaDataPlaneRoles` (`AlarmAck`, `WriteOperate`) and the bare strings -`ReadOnly` / `WriteTune` / `WriteConfigure`. In particular the alarm-ack role is `AlarmAck`, **not** -`AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different vocabulary); a -`GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate. +If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return +`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both +the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses, +subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry. + +The two live role strings are exact and case-insensitive. In particular the alarm-ack role is +`AlarmAck`, **not** `AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different +vocabulary); a `GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate. --- diff --git a/docs/v2/acl-design.md b/docs/v2/acl-design.md index f2141313..05945771 100644 --- a/docs/v2/acl-design.md +++ b/docs/v2/acl-design.md @@ -1,5 +1,23 @@ # OPC UA Client Authorization (ACL Design) — OtOpcUa v2 +> ⚠️ **NEVER WIRED (verified against source 2026-07-27).** This is a design document for work that was +> only half-built. The trie, the builder, the cache and the evaluator all exist in +> `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and carry 30 passing unit tests — but +> `IPermissionEvaluator.Authorize` has **zero production call sites**, nothing registers it in DI, and +> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference the project it lives in. `NodeAcl` rows are +> authorable in the Admin UI and are snapshotted into every deployment artifact, but the node side never +> deserializes them. +> +> **Nothing described below is enforced.** For what actually gates OPC UA operations today, see +> [`docs/security.md`](../security.md) § Data-Plane Authorization and +> [`docs/ReadWriteOperations.md`](../ReadWriteOperations.md). Four blockers stand between this design +> and a wire-up (Raw-realm nodes have no `NodeScope` representation; the session carries roles, not LDAP +> groups; `PermissionTrieBuilder`'s `scopePaths` has no producer; no session lifecycle exists) — they +> are recorded in `deferment.md` §3.1. +> +> Note also that the `SystemPlatform` hierarchy kind used throughout was retired with the v3 +> dual-namespace address space. +> > **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path). > > **Branch**: `v2` diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md index 247c86de..790a36fe 100644 --- a/docs/v2/v2-release-readiness.md +++ b/docs/v2/v2-release-readiness.md @@ -37,11 +37,27 @@ This doc is the single view of where v2 stands against its release criteria. Upd All code-path release blockers are closed. The remaining items are live-hardware / manual validations listed under exit criteria. -### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94) +### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — marked CLOSED 2026-04-19, PR #94) + +> ⚠️ **THIS CLOSURE DOES NOT HOLD (re-verified against source 2026-07-27).** Every type named in this +> subsection — `AuthorizationGate`, `NodeScopeResolver`, `AuthorizationBootstrap`, `WriteAuthzPolicy`, +> `DriverNodeManager`, `FilterBrowseReferences`, `GateCallMethodRequests`, `MapCallOperation`, +> `EquipmentNamespaceContent`, and the `Node:Authorization:*` config keys — has **zero occurrences in +> `src/`**. There is no `OnReadValue` hook, no `Browse` override, no `CreateMonitoredItems` override and +> no `Call` override in the live node manager, and no HistoryRead path consults any gate. +> +> Whatever landed on the v2 branch did not survive into the shipped tree. The **only** data-plane +> authorization today is two server-wide role checks (`WriteOperate` for writes, `AlarmAck` for alarm +> methods); reads, browses, subscriptions and history reads are ungated, and `NodeAcl` rows are +> authored and deployed but never evaluated. See [`../security.md`](../security.md) § +> Data-Plane Authorization and `deferment.md` §3.1. +> +> The text below is retained as the historical record of what was *believed* closed. **Read none of it +> as current behaviour.** **Closed**. `AuthorizationGate` + `NodeScopeResolver` thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table. -Remaining Stream C surfaces (hardening, not release-blocking): +Remaining Stream C surfaces (hardening, not release-blocking) — **none of these shipped either**: - ~~Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.~~ **Partial, 2026-04-24.** `DriverNodeManager.Browse` override post-filters the `ReferenceDescription` list via a new `FilterBrowseReferences` helper — denied nodes disappear silently per OPC UA convention. Ancestor-visibility implication (Read-grant at `Line/Tag` implying Browse on `Line`) still to ship; needs a subtree-has-any-grant query on the trie evaluator. `TranslateBrowsePathsToNodeIds` surface not yet wired. - ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor index e61d0e9d..e07b9030 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -28,6 +28,19 @@ else if (!IsNew && _existing is null) } else { + +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor index 2fd6fdb2..7b49a930 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor @@ -19,6 +19,19 @@ } else { + +
ACL rows grant LDAP groups specific NodePermissions on a scope (a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of