Merge lane C: control plane, host, security (#733)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+52
-5
@@ -6,11 +6,14 @@
|
||||
> auth + role-grant pages live in `OtOpcUa.AdminUI`), and Admin auth policies
|
||||
> register from `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth`
|
||||
> (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) rather
|
||||
> than in a separate Admin process. The Admin UI uses a **single Cookie
|
||||
> authentication scheme** — there is no `AddJwtBearer` pipeline. The
|
||||
> `Security:Jwt` section configures `JwtTokenService`, which mints a JWT at the
|
||||
> `/auth/token` endpoint for **external** consumers (OPC UA clients / automation
|
||||
> scripts); the cookie itself stores the `ClaimsPrincipal` directly. DataProtection
|
||||
> than in a separate Admin process. The Admin UI session is a **Cookie**
|
||||
> scheme; the cookie stores the `ClaimsPrincipal` directly. JWT is **opt-in by
|
||||
> `Security:Jwt:SigningKey`** (validated at host start, ≥32 bytes): with a key,
|
||||
> `/auth/token` mints an HS256 token for a **cookie session only** (a bearer
|
||||
> cannot renew itself), and a first-party `AddJwtBearer` scheme (`Bearer`,
|
||||
> `ValidAlgorithms = HS256`, parameters from `JwtTokenService.BuildValidationParameters`)
|
||||
> accepts it on `/api/*` and every `RequireAuthorization` endpoint through the
|
||||
> `OtOpcUa.CookieOrBearer` default policy scheme. Without a key neither exists. DataProtection
|
||||
> keys persist to the shared Config DB (`PersistKeysToDbContext<OtOpcUaConfigDbContext>`)
|
||||
> so cookies survive failover between admin-role nodes.
|
||||
>
|
||||
@@ -245,6 +248,25 @@ with two `Security:ApiKeys` knobs:
|
||||
a completed lookup (a hit or an authoritative miss) is stored, and a cached hit whose `ExpiresUtc` has
|
||||
since passed is re-read rather than served, so the TTL can never resurrect an expired key.
|
||||
|
||||
#### Pre-authentication guards (`KnownIdSet` / `MissBurst` / `MissRefillPerSecond`)
|
||||
|
||||
The negative cache above is keyed by **key id**, so a flood of *distinct*, well-formed `otua_<random>_x`
|
||||
credentials missed it every time and cost one indexed SQL query per attempt — under the SDK's
|
||||
`SessionManager`-wide lock, with no credential required (review 03 §3.3). `BoundedApiKeyStore` now puts two
|
||||
guards in front of the store, bound from the same `Security:ApiKeys` section
|
||||
(`ApiKeyStoreGuardOptions`, Host-owned):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `KnownIdSet` | `true` | Keep an in-memory set of every `ApiKeyId` the ConfigDb holds, refreshed every `StoreCacheSeconds` (single-flight; a failed refresh keeps the previous set). An id outside it is refused in memory — `otopcua.opcua.apikey_store.bounded{outcome=unknown_id}` — and the store is never asked. A key minted less than `StoreCacheSeconds` ago is refused until the next refresh (the same window `Enabled` revocation already has). `StoreCacheSeconds = 0` disables the set with the cache. |
|
||||
| `MissBurst` | `100` | Token bucket on lookups that **reach** the store and **miss**, for when the set is unavailable (not loaded yet, or the store cannot list ids). Exhausted ⇒ unknown ids are refused without a store read (`{outcome=miss_budget}`, a Warning-free deny through the authenticator's catch-all). A hit refunds its token, so legitimate clients are never charged. `0` disables the budget. |
|
||||
| `MissRefillPerSecond` | `20` | Refill rate of the miss bucket. |
|
||||
|
||||
The budget is **process-wide, not per remote endpoint**: the OPC UA SDK's impersonation callback (the
|
||||
`IOpcUaUserAuthenticator` seam) does not carry the client's address, so per-endpoint accounting needs that
|
||||
seam widened in the OpcUaServer project (a Wave 3 follow-up). Neither guard applies to a driver-only node:
|
||||
its `ArtifactApiKeyStore` is two in-memory dictionary probes and is never wrapped.
|
||||
|
||||
### 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`. 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)).
|
||||
@@ -1116,6 +1138,31 @@ See `archreview/plans/R3-01-mesh-grpc-allowanonymous-plan.md` (R3-01) for the fu
|
||||
record, and `docs/Configuration.md` §`MeshTransport` / §`ConfigSource`-`ConfigServe` and `docs/Telemetry.md`
|
||||
for the affected listener ports.
|
||||
|
||||
### The known-cluster gate is fail-OPEN until central's first ClusterNode load (review 03 §4.2)
|
||||
|
||||
`CentralCommandAuthInterceptor` verifies an inbound deploy ack against the PSK of the cluster named in
|
||||
`x-otopcua-cluster`. `MeshPskProvider` consults a **known-cluster gate** first — the set of cluster ids
|
||||
central read from `ClusterNode` on its last contact refresh — and refuses anything outside it in O(1),
|
||||
touching neither the config map nor the secret store. That gate is **inactive until the first successful
|
||||
load** (`CentralCommunicationActor` publishes it from `ContactsLoaded`; an empty publication also
|
||||
deactivates it, deliberately, so "the DB told me nothing" never becomes a fleet-wide authentication
|
||||
outage). During a central boot with the ConfigDb down, therefore:
|
||||
|
||||
- an unauthenticated caller can name *any* `x-otopcua-cluster` value and make central perform one bounded
|
||||
(3 s) secret-store read plus one Warning line per request — the cost/oracle the gate exists to remove;
|
||||
- **keys are still required**: nothing is admitted without the matching PSK, so this is a cost exposure,
|
||||
not an authentication bypass;
|
||||
- the window is bounded by the bootstrap retry (15 × 2 s) and the `ContactRefreshSeconds` (60 s) periodic
|
||||
refresh, and closes on the first load that succeeds. The actor logs the condition on every failed load
|
||||
(*"The known-cluster gate was NOT refreshed either … it is INACTIVE"*).
|
||||
|
||||
Since review 03 §3.4 the per-cluster keys are **refreshed asynchronously** from the same handler
|
||||
(`IMeshPskProvider.RefreshAsync`: concurrent bounded reads, atomic snapshot swap, a failed read keeps the
|
||||
previous key) instead of being invalidated on every refresh, so the first ack per cluster per minute no
|
||||
longer pays a synchronous store read on a gRPC thread. Accepted as documented, bounded behaviour; the
|
||||
alternative — refusing every cluster until SQL answers — would turn a ConfigDb outage into a deploy-ack
|
||||
outage on top of it.
|
||||
|
||||
---
|
||||
|
||||
## OTOPCUA0001 Analyzer — Compile-Time Guard
|
||||
|
||||
Reference in New Issue
Block a user