docs(security): sweep out pre-#520 authz claims (#547)
v2-ci / build (push) Successful in 6m1s
v2-ci / unit-tests (push) Failing after 16m16s

The summary list still described the pre-#520 world: NodeAcl/PermissionTrie
'built and unit-tested but never wired', reads/browse/subscribe/HistoryRead
carrying no authorization at all, and UserName tokens authenticated by LDAP
bind. The body had been updated by #520 stages 5-7 but the summary, the
Anonymous auth-table row, the LDAP bind-flow paragraph (still routing the OPC
UA UserName path through the deleted LdapOpcUaUserAuthenticator), the
'sourced from Security:Ldap:GroupToRole' role-source section, and the
'Dispatch gate (no such gate exists)' section all contradicted it — a reader
who stopped at any of them got the pre-#520 answer.

All now state the flag-gated reality: with Security:Acl:Enabled false (the
default) the only enforcement is the two coarse server-wide role gates and
reads are ungated; with it true, scoped NodeAcl grants decide every
data-plane operation via DataPlaneAuthorizer -> TriePermissionEvaluator.
Data-plane roles derive from cluster-scoped NodeAcl grants
(ApiKeyInterimRoleMapper); GroupToRole and LdapGroupRoleMapping feed AdminUI
login only. The acl-design.md pointer no longer calls the design 'unshipped
work'.

Closes #547.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-06 20:46:38 -04:00
parent 5b9ff9d07c
commit 0acfc79ad9
+42 -41
View File
@@ -20,8 +20,8 @@
OtOpcUa has four independent security concerns. This document covers all four: 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. A UserName token is an **API key** (username literally `apikey`, password `otua_<keyId>_<secret>`) verified by peppered HMAC — **LDAP backs Admin UI login only** since #520 (2026-08-01).
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). 3. **Data-plane authorization** — who can do what on an OtOpcUa endpoint. ⚠️ Governed by **one flag** (#520 stages 5–6, 2026-08-01). With `Security:Acl:Enabled` **false — the default** — the only enforcement is two coarse, server-wide role gates (`WriteOperate` on Value writes, `AlarmAck` on the alarm methods), and Read, Browse, Subscribe and HistoryRead are **ungated** for any admitted session. With it **true**, scoped `NodeAcl` grants (the `PermissionTrie` subsystem) decide **every** data-plane operation — read, browse, subscribe, write, alarm commands and HistoryRead — *replacing* the server-wide roles. 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.
@@ -169,7 +169,7 @@ 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. | ⚠️ 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. | | Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ Since #520 an anonymous session is a **normal grantable principal** (`anonymous`), not a bypass — see [Anonymous is a normal principal](#anonymous-is-a-normal-principal). With `Security:Acl:Enabled` **off (the default)** it typically holds no roles, so every write and alarm ack is refused — but it can **Browse, Read, Subscribe and HistoryRead the entire address space** (those operations are ungated). With the flag **on**, those operations too are decided by the `anonymous` principal's `NodeAcl` grants (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. |
| UserName/Password | `ApiKeyOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by an `IApiKeyStore`. | The username is the literal `apikey` and the password is an `otua_<keyId>_<secret>` credential (#520). One indexed lookup plus one peppered HMAC-SHA256, no network I/O. The key resolves to a **principal**, whose `NodeAcl` grants become roles on a `RoleCarryingUserIdentity`. **LDAP is not on this path** — it backs AdminUI login only. | | UserName/Password | `ApiKeyOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by an `IApiKeyStore`. | The username is the literal `apikey` and the password is an `otua_<keyId>_<secret>` credential (#520). One indexed lookup plus one peppered HMAC-SHA256, no network I/O. The key resolves to a **principal**, whose `NodeAcl` grants become roles on a `RoleCarryingUserIdentity`. **LDAP is not on this path** — it backs AdminUI login only. |
| 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. | | 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. |
@@ -189,7 +189,7 @@ node does not boot at all.
### LDAP bind flow (`OtOpcUaLdapAuthService`) ### LDAP bind flow (`OtOpcUaLdapAuthService`)
LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. The same `ILdapAuthService` instance serves **both** the Admin UI cookie login (`/auth/login`) and the OPC UA UserName path (via `LdapOpcUaUserAuthenticator`), so operators use one credential across both planes. LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)).
`OtOpcUaLdapAuthService.AuthenticateAsync`: `OtOpcUaLdapAuthService.AuthenticateAsync`:
@@ -199,7 +199,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 (`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. **Group → role mapping happens downstream**, not in the auth service: the `/auth/login` handler (`AuthEndpoints`) resolves `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) per login — it applies the appsettings `GroupToRole` baseline, merges system-wide DB grants from the user's LDAP groups, and unions any pre-resolved roles (the DevStub grant) — and the resulting roles land in the Admin UI cookie principal. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated login. **Since #520 nothing on the OPC UA data plane consumes LDAP groups** — data-plane session roles come from the authenticated principal's `NodeAcl` grants (see [Role grant source](#role-grant-source-data-plane)).
`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):
@@ -345,8 +345,8 @@ coarse, **server-wide role string checks** with no per-node component.
Both gates fail closed — a session with no identity or no matching role gets Both gates fail closed — a session with no identity or no matching role gets
`BadUserAccessDenied` — and read `identity.Roles` off the session's `RoleCarryingUserIdentity` `BadUserAccessDenied` — and read `identity.Roles` off the session's `RoleCarryingUserIdentity`
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`). Role strings are (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`). Role strings are
the constants in `OpcUaDataPlaneRoles`, sourced from `Security:Ldap:GroupToRole` (see the constants in `OpcUaDataPlaneRoles`, derived from the principal's cluster-scoped `NodeAcl` grants
[Role grant source](#role-grant-source-data-plane)). (see [Role grant source](#role-grant-source-data-plane)).
**What has no authorization check at all:** **What has no authorization check at all:**
@@ -632,56 +632,57 @@ 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` (design; **no such gate exists**) ### Dispatch gate — `DataPlaneAuthorizer` over `IPermissionEvaluator`
`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`. `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 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). **The dispatch gate exists since #520 stages 5–6** (2026-08-01): `DataPlaneAuthorizer`
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs`, registered in
`Host/Program.cs`, consulted by `OtOpcUaNodeManager`) calls `Authorize` at every data-plane seam —
read, browse, subscribe, write, alarm commands and HistoryRead — **when `Security:Acl:Enabled` is
true**. With the flag **off (the default)** the authorizer is constructed but not consulted, and the
effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW (see
[What is enforced with the flag off](#what-is-enforced-with-the-flag-off)).
Intended properties, of which only the last is true today: Properties of the wired path:
- **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. - **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. ⚠️ This describes the evaluator's *internal* behaviour when called. Because nothing calls it, **the effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW.** - **Strictly fail-closed (default-deny) when consulted.** 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. A misconfigured node therefore denies everything rather than allowing everything, which presents as an outage of the gated operations rather than a subtle permission bug.
- **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. - **Evaluator stays pure.** `TriePermissionEvaluator` has no OPC UA stack dependency and is tested directly from xUnit.
### 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). Read it as a **design document for unshipped work**, not as a description of running behaviour. 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). Most of it shipped with #520 stages 5–7 (behind `Security:Acl:Enabled`); where it and this document disagree, this document plus `docs/plans/2026-07-31-apikey-acl-enforcement-design.md` describe the 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`. 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)
Data-plane roles come from `Security:Ldap:GroupToRole` (appsettings), **not** from the Config-DB Since #520 (2026-08-01), data-plane roles come from the authenticated principal's **`NodeAcl` grants**,
`LdapGroupRoleMapping` table. That table's `Role` column is the `AdminRole` enum not from LDAP. `ApiKeyInterimRoleMapper` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs`,
(`Administrator`/`Designer`/`Viewer`) and supplies **control-plane** roles only — it cannot emit the #520 stage 2) derives the session's server-wide role set from the grants naming the principal — a key's
data-plane role strings the OPC UA gates read (`ReadOnly`, `WriteOperate`, `WriteTune`, principal, or the reserved `anonymous` — and attaches it to the `RoleCarryingUserIdentity` the gates
`WriteConfigure`, `AlarmAck`). A deployment therefore **must** map its data-plane LDAP groups read. Neither `Security:Ldap:GroupToRole` nor the Config-DB `LdapGroupRoleMapping` table touches this
to those role strings via `GroupToRole`, e.g.: path any more; both feed **Admin UI login only**.
```json Two properties of that derivation matter to an operator:
"GroupToRole": {
"ot-operators": "WriteOperate",
"ot-alarm-ack": "AlarmAck"
}
```
⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and - ⚠️ **Only `Cluster`-scoped grants confer a role.** This is deliberate, and it under-grants: honouring
`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and a narrower grant's flags while unable to honour its scope would turn "WriteOperate on Equipment X"
`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group into server-wide write authority. A scope-narrower-than-cluster grant is simply **inert with the flag
to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (with the flag off, off** (the AdminUI banner says so) — it takes effect only when `Security:Acl:Enabled` turns scoped
reads are ungated for everyone regardless; with it on, these role strings are not consulted at all — evaluation on.
the scoped ACL decision replaces them). Earlier revisions of this document listed all five as - **Flag vocabulary ≠ role vocabulary.** The `WriteOperate` permission flag maps to the `WriteOperate`
"code-true"; that was wrong for three of them. role; the `AlarmAcknowledge` flag (the spelling an operator authors) maps to the `AlarmAck` role,
which covers acknowledge/confirm/shelve as one gate. `ReadOnly`, `WriteTune` and `WriteConfigure`
flags confer **no role** — with the flag off no gate reads them, and with it on the scoped ACL
decision replaces roles entirely.
If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return If a principal has no cluster-scoped grant carrying `WriteOperate` / `AlarmAcknowledge`, **writes and
`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both alarm acknowledgement default-deny**: they return `BadUserAccessDenied` even for sessions that
the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses, authenticate successfully. (The same requirement gates both the scripted-alarm and the native
subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry. Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses, subscribes and history reads are
unaffected with the flag off** — they succeed with or without any grant.
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.
--- ---