refactor(acl): delete the LDAP data-plane authenticator and its tests (#520)
Stage 2b. LdapOpcUaUserAuthenticator was left registered nowhere by stage 2a; this removes it, its three test files, and the config surface that existed only to serve it. Deleted: LdapOpcUaUserAuthenticator, LdapOpcUaUserAuthenticatorTests, LdapAuthResilienceTests, LdapGlauthOutageLiveTests (19 tests). Also removed four Security:Ldap keys — AuthTimeoutMs, MaxConcurrentAuthentications, OutageFailureThreshold, OutageCooldownSeconds — plus their validator rules and binding/validator test coverage. Verified first that the ONLY consumers were the deleted class and the validator rules asserting them; none appear in any checked-in appsettings. Leaving them would have created exactly the dead-config surface #521/#524/#525 were filed to clear up. Worth stating why that resilience machinery does not need relocating rather than just deleting. It existed because the OPC UA SDK raises impersonation SYNCHRONOUSLY under a SessionManager-wide event lock, so a hung LDAP bind there serialized every session activation server-wide — Anonymous and X509 included. Key verification is one indexed lookup plus one HMAC with no network I/O, so the hazard the wall-clock bound, concurrency cap and outage circuit guarded against no longer exists on that path. LDAP now runs only on AdminUI login, an ordinary async request path where a slow directory affects the one request making it. ConnectionTimeoutMs is RETAINED: it is projected into the shared library and is not authenticator-specific. Docs corrected rather than left to rot: - CLAUDE.md's "LDAP Authentication" section described the deleted file as the data-plane authenticator. Rewritten, including a warning not to "restore" it on seeing the LDAP wiring that AdminUI legitimately still needs. - infra/README §5b (the GLAuth outage live gate) marked RETIRED with the reason, rather than silently dropped — the harness GLAuth is still used by OTOPCUA_HARNESS_USE_LDAP=1 and is unaffected. - docs/security.md's five-key resilience table cut to the one surviving key, and a new API-key identity section added. Historical archreview/ records are left as-is; they are dated accounts. Host.IntegrationTests 211 tests (was 230, i.e. exactly the 19 removed) with the same 3 pre-existing failures verified against clean master. Host 37, Security 89. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
+56
-8
@@ -162,19 +162,67 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s
|
||||
|
||||
`GroupToRole` maps LDAP group names → roles (case-insensitive); a user gets every role whose source group is in their membership. The values are the canonical control-plane role strings (`Viewer` / `Designer` / `Administrator`, plus the appsettings-only `Operator` for the `DriverOperator` policy); the same map also supplies data-plane role strings (`ReadOnly`, `WriteOperate`, `WriteTune`, `WriteConfigure`, `AlarmAck`) — see [Role grant source (data-plane)](#role-grant-source-data-plane) below. `UserNameAttribute: "sAMAccountName"` is the critical AD override — the GLAuth dev default is `cn`, which is not how AD users are looked up; use `userPrincipalName` instead if operators log in with `user@corp.example.com` form. `LdapOptionsValidator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs`) fails startup when `Transport = None` and `AllowInsecure = false` on a real-LDAP (non-DevStub) config.
|
||||
|
||||
### LDAP resilience — timeout, concurrency, outage circuit (arch-review 03/S2)
|
||||
### API-key identity (OPC UA data plane)
|
||||
|
||||
The OPC UA SDK raises its impersonation (UserName) callback **synchronously**, inside a `SessionManager`-wide event lock (verified against `OPCFoundation.NetStandard.Opc.Ua.Server` 1.5.378). A slow or hung LDAP bind inside that callback therefore does not stall one SDK thread — it **serializes every session activation server-wide** (Anonymous and X509 included). Since the callback is irreducibly synchronous, `LdapOpcUaUserAuthenticator` bounds it at its own boundary with five `Security:Ldap` keys (all validated at startup by `LdapOptionsValidator` when `Enabled && !DevStubMode`):
|
||||
The OPC UA data plane is **machine-to-machine**, so a consuming system authenticates with an API key
|
||||
rather than an LDAP user (Gitea #520). The client presents an OPC UA **UserName** token whose username
|
||||
is the fixed literal `apikey` and whose password is the key:
|
||||
|
||||
```
|
||||
otua_<keyId>_<secret>
|
||||
```
|
||||
|
||||
`ApiKeyOpcUaUserAuthenticator` verifies it — one indexed lookup on `keyId` plus one peppered
|
||||
HMAC-SHA256 over the secret, compared with `CryptographicOperations.FixedTimeEquals`. No network I/O.
|
||||
The key resolves to a **principal**, and `NodeAcl` rows naming that principal are what grant it
|
||||
permissions.
|
||||
|
||||
| Key | Notes |
|
||||
|---|---|
|
||||
| `Security:ApiKeys:Username` | The static username clients present. Default `apikey`. Checked rather than ignored, so a typo is an explicit denial. |
|
||||
| `Security:ApiKeys:Pepper` | Server-side pepper for the stored hashes. **Supply via `Security__ApiKeys__Pepper` or `${secret:}` — never commit it.** Rotating it invalidates every existing key. Has no default: an empty pepper still hashes "successfully", so `ApiKeySecret.ComputeHash` throws on one rather than producing a working-looking deployment with no protection. |
|
||||
|
||||
Secrets are never stored — only the hash. The plaintext is shown **once** at creation and is
|
||||
unrecoverable afterwards.
|
||||
|
||||
**Every rejection returns the same opaque message** ("Invalid API key"), with the real reason logged
|
||||
server-side only, so the server cannot be used as an oracle for whether a key id exists or which half
|
||||
of a credential was wrong.
|
||||
|
||||
⚠️ **Revocation requires a deploy.** Per-cluster mesh Phase 4 leaves driver-only nodes with no
|
||||
ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing
|
||||
`Enabled` takes effect on a node only once a new artifact reaches it.
|
||||
|
||||
⚠️ **Node ACLs are still not enforced per node.** Until the enforcement stages land, a key's grants are
|
||||
collapsed into the two existing server-wide role gates, and **only `Cluster`-scoped grants count** —
|
||||
a narrower grant confers nothing rather than silently widening to server-wide authority. Reads,
|
||||
browses, subscriptions and history remain ungated for any admitted session. See
|
||||
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`.
|
||||
|
||||
### LDAP resilience — mostly retired with the data-plane authenticator (#520)
|
||||
|
||||
> **Changed 2026-07-31.** The OPC UA **data plane** no longer uses LDAP at all — it authenticates with
|
||||
> API keys (see [API-key identity](#api-key-identity-opc-ua-data-plane)). `LdapOpcUaUserAuthenticator`
|
||||
> is deleted, and with it four of the five `Security:Ldap` resilience keys: **`AuthTimeoutMs`**,
|
||||
> **`MaxConcurrentAuthentications`**, **`OutageFailureThreshold`** and **`OutageCooldownSeconds`**.
|
||||
> Setting them now does nothing (options binding ignores unknown keys); remove them from any config
|
||||
> that carries them.
|
||||
|
||||
Those four existed for one specific reason. The OPC UA SDK raises its impersonation callback
|
||||
**synchronously**, inside a `SessionManager`-wide event lock (verified against
|
||||
`OPCFoundation.NetStandard.Opc.Ua.Server` 1.5.378), so a slow or hung LDAP bind in that callback did
|
||||
not stall one SDK thread — it **serialized every session activation server-wide**, Anonymous and X509
|
||||
included. That constraint is gone: key verification is one indexed lookup plus one HMAC, with no
|
||||
network I/O on the path.
|
||||
|
||||
LDAP now runs only on **AdminUI login**, an ordinary async request path where a slow directory affects
|
||||
the one request making it. One key remains:
|
||||
|
||||
| Key | Default | Bounds |
|
||||
|---|---|---|
|
||||
| `ConnectionTimeoutMs` | `10000` | Projected into the shared library's `ConnectionTimeoutMs` — bounds the socket connect **and** each per-operation search. First line of defence against an outage. |
|
||||
| `AuthTimeoutMs` | `15000` | Hard whole-flow wall-clock bound (bind **and** role-map) enforced at the authenticator boundary; a slow/hung flow denies **"Authentication timed out"** (fail-closed) and releases the SDK thread. Deliberately `> ConnectionTimeoutMs` so a healthy-but-slow flow is not cut off. |
|
||||
| `MaxConcurrentAuthentications` | `8` | Max in-flight authentications; excess denies **"Authentication backend busy"** (local backpressure — does **not** feed the circuit). Bounds accumulation of orphaned/timed-out binds during an outage. |
|
||||
| `OutageFailureThreshold` | `3` | Consecutive **system-side** failures (boundary timeout, directory-unreachable, or unexpected throw) that open the outage circuit. Any success or user-credential deny resets the streak. `0` disables the circuit. |
|
||||
| `OutageCooldownSeconds` | `15` | How long the open circuit fast-denies **"Authentication backend unavailable"** without touching LDAP; after cooldown the next call half-open-probes, and one more system failure re-opens immediately. |
|
||||
| `ConnectionTimeoutMs` | `10000` | Projected into the shared library's `ConnectionTimeoutMs` — bounds the socket connect **and** each per-operation search. Validated `> 0` at startup by `LdapOptionsValidator`. |
|
||||
|
||||
|
||||
Defaults are behaviour-neutral on a healthy directory. All new paths are **fail-closed** (they deny). A user-credential deny (wrong password) is fast when the directory is up and never trips the circuit; only a directory *outage* opens it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user