docs(security): sweep out pre-#520 authz claims (#547)
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:
+42
-41
@@ -20,8 +20,8 @@
|
||||
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 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).
|
||||
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 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`.
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| 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. |
|
||||
| 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 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`:
|
||||
|
||||
@@ -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.
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
`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)).
|
||||
the constants in `OpcUaDataPlaneRoles`, derived from the principal's cluster-scoped `NodeAcl` grants
|
||||
(see [Role grant source](#role-grant-source-data-plane)).
|
||||
|
||||
**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.
|
||||
|
||||
### 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`.
|
||||
|
||||
⚠️ **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.
|
||||
- **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.
|
||||
- **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) 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.** `TriePermissionEvaluator` has no OPC UA stack dependency and is tested directly from xUnit.
|
||||
|
||||
### 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`.
|
||||
|
||||
### Role grant source (data-plane)
|
||||
|
||||
Data-plane roles come from `Security:Ldap:GroupToRole` (appsettings), **not** from the Config-DB
|
||||
`LdapGroupRoleMapping` table. That table's `Role` column is the `AdminRole` enum
|
||||
(`Administrator`/`Designer`/`Viewer`) and supplies **control-plane** roles only — it cannot emit the
|
||||
data-plane role strings the OPC UA gates read (`ReadOnly`, `WriteOperate`, `WriteTune`,
|
||||
`WriteConfigure`, `AlarmAck`). A deployment therefore **must** map its data-plane LDAP groups
|
||||
to those role strings via `GroupToRole`, e.g.:
|
||||
Since #520 (2026-08-01), data-plane roles come from the authenticated principal's **`NodeAcl` grants**,
|
||||
not from LDAP. `ApiKeyInterimRoleMapper` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs`,
|
||||
#520 stage 2) derives the session's server-wide role set from the grants naming the principal — a key's
|
||||
principal, or the reserved `anonymous` — and attaches it to the `RoleCarryingUserIdentity` the gates
|
||||
read. Neither `Security:Ldap:GroupToRole` nor the Config-DB `LdapGroupRoleMapping` table touches this
|
||||
path any more; both feed **Admin UI login only**.
|
||||
|
||||
```json
|
||||
"GroupToRole": {
|
||||
"ot-operators": "WriteOperate",
|
||||
"ot-alarm-ack": "AlarmAck"
|
||||
}
|
||||
```
|
||||
Two properties of that derivation matter to an operator:
|
||||
|
||||
⚠️ **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 (with the flag off,
|
||||
reads are ungated for everyone regardless; with it on, these role strings are not consulted at all —
|
||||
the scoped ACL decision replaces them). Earlier revisions of this document listed all five as
|
||||
"code-true"; that was wrong for three of them.
|
||||
- ⚠️ **Only `Cluster`-scoped grants confer a role.** This is deliberate, and it under-grants: honouring
|
||||
a narrower grant's flags while unable to honour its scope would turn "WriteOperate on Equipment X"
|
||||
into server-wide write authority. A scope-narrower-than-cluster grant is simply **inert with the flag
|
||||
off** (the AdminUI banner says so) — it takes effect only when `Security:Acl:Enabled` turns scoped
|
||||
evaluation on.
|
||||
- **Flag vocabulary ≠ role vocabulary.** The `WriteOperate` permission flag maps to the `WriteOperate`
|
||||
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
|
||||
`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.
|
||||
If a principal has no cluster-scoped grant carrying `WriteOperate` / `AlarmAcknowledge`, **writes and
|
||||
alarm acknowledgement default-deny**: they return `BadUserAccessDenied` even for sessions that
|
||||
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 with the flag off** — they succeed with or without any grant.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user