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:
+115
-28
@@ -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<string>` (`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<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). 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<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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user