diff --git a/docs/security.md b/docs/security.md index db465f21..a2bf5c0d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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__`) 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__` 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` (`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` (`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. ---