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
+63 -2
View File
@@ -342,9 +342,11 @@ appear in §7.1 as well.
## 8. Recommended sequencing ## 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 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. 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. 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 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. *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 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.* VPN'd gateway, or a live rig and are listed as unproven rather than assumed.*
+96 -61
View File
@@ -1,85 +1,120 @@
# Read/Write Operations # Read/Write Operations
> ⚠️ **Accuracy warning (audited 2026-07-27).** Parts of this page describe v2-era machinery that no > **Rewritten 2026-07-27** against `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`.
> longer exists. Two corrections matter most: > 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
> 1. **There is no per-node ACL gate on the read path — or anywhere else.** `WriteAuthzPolicy`, > those exist** — `OnReadValue`, `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef`
> `AuthorizationGate`, `NodeScopeResolver` and `AuthorizationBootstrap` have **zero occurrences in > and `IRoleBearer` all have zero occurrences in `src/`. The read path in particular worked nothing
> `src/`**. The ACL evaluator that *does* exist (`IPermissionEvaluator` / `TriePermissionEvaluator` > like the way it was documented. See `deferment.md` §3.1 and §7.
> / `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.
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). Everything the old page said about `CapabilityInvoker` wrapping OPC UA reads was misplaced: the
- `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. invoker is real, but it lives on the **driver-actor** side wrapping the poll/subscribe calls. The
- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim. `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`. ## Write path — `OnEquipmentTagWrite`
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`.
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`). Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve)
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`. 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
### Dispatch through `HandleAlarmCommand`, driver-fed native alarms through `HandleNativeAlarmAck`; both fail closed
with `BadUserAccessDenied`.
`_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.
## HistoryRead ## 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 ## 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 ## 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` — the live node manager;
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — push-model `CustomNodeManager2`; `EnsureVariable` / `WriteValue` are the v2 read/write path `EnsureVariable` / `WriteValue` / `OnEquipmentTagWrite` / `EvaluateEquipmentWriteGate` and the four
- `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 HistoryRead overrides
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs``ExecuteAsync` / `ExecuteWriteAsync` - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`,
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.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**
+115 -28
View File
@@ -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). 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. 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`. 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. 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 | | 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. | | 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<string>` (`OtOpcUaGroupRoleMapper`) and attached to the OPC UA session identity for the downstream ACL evaluator. | | 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<string>` (`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); finer-grain authorization happens through the data-plane ACLs. | | 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`. 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. 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". 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<string>` (`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<string>` (`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): `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
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: 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. `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. Intended properties, of which only the last is true today:
- **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. - **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 ### 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) ### Role grant source (data-plane)
@@ -271,23 +356,25 @@ to those role strings via `GroupToRole`, e.g.:
```json ```json
"GroupToRole": { "GroupToRole": {
"ot-operators": "WriteOperate", "ot-operators": "WriteOperate",
"ot-tuners": "WriteTune", "ot-alarm-ack": "AlarmAck"
"ot-engineers": "WriteConfigure",
"ot-alarm-ack": "AlarmAck",
"ot-readonly": "ReadOnly"
} }
``` ```
If this mapping is absent the data-plane evaluator is strictly default-deny: inbound operator writes ⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and
and OPC UA Part-9 alarm acknowledgement all return `BadUserAccessDenied` even for users who `AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and
authenticate successfully. (The same requirement gates both the scripted-alarm and the native `WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group
Galaxy-alarm Part-9 ack/confirm/shelve paths.) 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 If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return
against the constants in `OpcUaDataPlaneRoles` (`AlarmAck`, `WriteOperate`) and the bare strings `BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both
`ReadOnly` / `WriteTune` / `WriteConfigure`. In particular the alarm-ack role is `AlarmAck`, **not** the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses,
`AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different vocabulary); a subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry.
`GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate.
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.
--- ---
+18
View File
@@ -1,5 +1,23 @@
# OPC UA Client Authorization (ACL Design) — OtOpcUa v2 # 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). > **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path).
> >
> **Branch**: `v2` > **Branch**: `v2`
+18 -2
View File
@@ -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. 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 #143marked 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. **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. - ~~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). - ~~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).
@@ -28,6 +28,19 @@ else if (!IsNew && _existing is null)
} }
else else
{ {
<div class="alert alert-warning" role="alert">
<strong>This grant will not be enforced.</strong>
It is saved and shipped in every deployment artifact, but the OPC UA server never evaluates
ACL rows — the permission evaluator has no production call site. Nothing you set below will
change what a client can read, write, browse or acknowledge.
<br />
Real access control today is fleet-wide LDAP-group → role mapping
(<span class="mono">Security:Ldap:GroupToRole</span>):
<span class="mono">WriteOperate</span> to write any tag,
<span class="mono">AlarmAck</span> to acknowledge any alarm. Reads are ungated.
See <span class="mono">docs/security.md</span>.
</div>
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit"> <EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s"> <section class="panel rise" style="animation-delay:.02s">
@@ -19,6 +19,19 @@
} }
else else
{ {
<div class="alert alert-warning" role="alert">
<strong>These rules are not enforced.</strong>
ACL rows are saved and shipped in every deployment artifact, but the OPC UA server never
evaluates them — the permission evaluator has no production call site. Authoring a grant here
changes nothing about what a client can read or write.
<br />
What <em>is</em> enforced today is coarse and fleet-wide: a client needs the
<span class="mono">WriteOperate</span> role to write any tag and
<span class="mono">AlarmAck</span> to acknowledge any alarm, both mapped from LDAP groups via
<span class="mono">Security:Ldap:GroupToRole</span>. Reads, browses, subscriptions and history
reads are <strong>not</strong> restricted at all. See <span class="mono">docs/security.md</span>.
</div>
<section class="panel notice rise" style="animation-delay:.02s"> <section class="panel notice rise" style="animation-delay:.02s">
ACL rows grant LDAP groups specific <span class="mono">NodePermissions</span> on a scope ACL rows grant LDAP groups specific <span class="mono">NodePermissions</span> on a scope
(a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of (a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of