docs(security): correct revocation latency (activation vs open session) + S18/S19 knobs

This commit is contained in:
Joseph Doherty
2026-08-20 10:04:03 -04:00
parent dfab7f614d
commit a9b592aeb8
5 changed files with 129 additions and 21 deletions
+78 -16
View File
@@ -177,16 +177,59 @@ When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpc
the Host wires the real `ApiKeyOpcUaUserAuthenticator` as a singleton in `Program.cs`. Which
**`IApiKeyStore`** backs it depends on the node's role:
| Node | Store | Revocation takes effect |
|---|---|---|
| `admin` (including fused `admin,driver`) | `ConfigDbApiKeyStore` — reads the config database per request | On the **next request** |
| `driver`-only | `ArtifactApiKeyStore` — reads the keys carried by the deployment artifact, held in memory | On the **next deployment** |
| neither role | `NullOpcUaUserAuthenticator` — denies every UserName token | n/a |
| Node | Store | An `Enabled = 0` revocation reaches a **new activation** | …and an **already-open session** |
|---|---|---|---|
| `admin` (including fused `admin,driver`) | `ConfigDbApiKeyStore` — reads the config database per activation, wrapped in `BoundedApiKeyStore` | On the **next activation**, bounded by `Security:ApiKeys:StoreCacheSeconds` (default 5 s) | On the **next deployment** |
| `driver`-only | `ArtifactApiKeyStore` — reads the keys carried by the deployment artifact, held in memory | On the **next deployment** | On the **next deployment** |
| neither role | `NullOpcUaUserAuthenticator` — denies every UserName token | n/a | n/a |
⚠️ **The last column is the correction, and it used to be missing.** This table once said a ConfigDb node
revokes "on the next request", which is true only of a **new session activation**. An operation on a
session that is *already open* does not re-read `IApiKeyStore` at all — `DataPlaneAuthorizer.Evaluate`
re-checks the key against the in-memory **artifact snapshot** (`DataPlaneAuthority.FindUsableKey`), which
`OpcUaPublishActor` installs on every apply. So clearing `Enabled` in the ConfigDb stops the *next*
connect on an admin node and nothing else until a deployment lands, on **both** node kinds.
**Expiry is the exception, and it is immediate on both.** `FindUsableKey` compares `ExpiresUtc` against
the authorizer's clock on every call, so an `ExpiresUtc` that has already passed denies at once on an
open session — and since arch-review 03/S19 it reaches a live **subscription** too, on a timer (see
[Data-Plane Authorization](#data-plane-authorization)).
The deny-all is registered **explicitly**, not left absent: `OtOpcUaServerHostedService` takes
`IOpcUaUserAuthenticator` as a required constructor parameter, so an unregistered seam fails DI and the
node does not boot at all.
### Bounding the key store (`StoreTimeoutMs` / `StoreCacheSeconds`)
The SDK raises impersonation **synchronously, holding a `SessionManager`-wide event lock**, so the key
lookup does not stall one activation — it serializes **every** session activation on the server,
Anonymous and X.509 included. On a driver-only node that is harmless (the artifact store is two
dictionary probes). On an admin or fused node it is two EF round trips to central SQL, so a ConfigDb that
is *slow or hung* rather than *refusing* could hold that lock indefinitely. `BoundedApiKeyStore`
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/BoundedApiKeyStore.cs`) wraps the ConfigDb store — and only it —
with two `Security:ApiKeys` knobs:
| Key | Default | Meaning |
|---|---|---|
| `StoreTimeoutMs` | `2000` | Hard per-call deadline on the store. Exceeding it **denies** the activation. `≤ 0` disables the deadline (the pre-R3-05 unbounded behaviour — do not do this on an admin node). |
| `StoreCacheSeconds` | `5` | TTL of a read-through cache in front of the store, so a burst of activations costs one round trip. `0` disables it (a read per activation). |
- **A timeout denies; it never admits.** The deadline surfaces as an `OperationCanceledException` into
`ApiKeyOpcUaUserAuthenticator`'s existing catch-all, which returns the same opaque denial as a bad
credential. There is no second denial path to get wrong, and the decorator can only ever return what
the inner store returned or throw.
- **What a timeout looks like:** a distinct Warning — *"ApiKeyStore: the key store did not answer within
N ms — denying this activation. Every OPC UA session activation on this server is serialized behind
this call; check ConfigDb health"* — plus
`otopcua.opcua.apikey_store.bounded{outcome=timeout}`. It is deliberately **not** the ordinary
*"no enabled, unexpired key with id X"* line, which would send an operator hunting for a key problem
that does not exist.
- **What to watch:** `otopcua.opcua.apikey_store.bounded{outcome=cache_hit}`. A busy fused central with no
`cache_hit` stream means `StoreCacheSeconds` is 0.
- **Faults and timeouts are never cached** — caching a deny would extend an outage past the outage. Only
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.
### 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)).
@@ -411,17 +454,36 @@ Behaviours worth knowing before enabling it:
- **A deploy takes effect on sessions that are already open.** The gate stamps each decision with the
current authority generation rather than the one the session activated under, so an ACL edit lands
without bouncing clients — in both directions, widening and narrowing.
- **Revocation also reaches an open session.** Every decision re-checks the authenticating key against
the current snapshot, so a key disabled or expired by a later deployment stops working immediately
rather than at the client's next reconnect. On a driver-only node the artifact is the only revocation
channel there is, so without this a revoked credential could keep its authority indefinitely.
- **Revocation reaches a live subscription too** (#520 stage 6). Every other operation re-asks on each
request; a subscription is authorized once and then streams, so it needed a push. A deployment
re-authorizes every live monitored item: one whose grant has gone is queued a `BadUserAccessDenied`
and then detached from its node's value fan-out, and a later deployment that restores the grant
re-attaches it. This matters more than it sounds — subscriptions are how machine consumers actually
read this server, so without it revoking a grant would close the operations nobody uses and leave the
feed running.
- **Revocation also reaches an open session — but read the two axes separately.** Every decision
re-checks the authenticating key against the current snapshot, so an open session is re-authorized on
every operation. What that check can *see* differs by axis:
- **The deployment axis** (a key's `Enabled` cleared, a grant edited or removed). The snapshot only
changes when an artifact is applied, so this lands **on the next deployment**, on both node kinds —
including on a fused admin node, whose ConfigDb store is consulted at *activation* only. On a
driver-only node the artifact is the only revocation channel there is.
- **The clock axis** (a key's `ExpiresUtc` passing). `FindUsableKey` compares it against the
authorizer's clock on every call, so this lands **immediately**, with no deployment.
- **Revocation reaches a live subscription too** (#520 stage 6, extended by arch-review 03/S19). Every
other operation re-asks on each request; a subscription is authorized once and then streams, so it
needed a push. A denied item is queued a `BadUserAccessDenied` and then detached from its node's value
fan-out; restoring its authority re-attaches it. There are **two triggers**:
- a **deployment**, which re-authorizes every live monitored item (the deployment axis);
- the host's **low-frequency timer**, `Security:Acl:RevalidationIntervalSeconds` (default 60 s, `0`
disables it, inert while `Security:Acl:Enabled` is false). Without it a key that expired by the
clock kept streaming until a deployment happened to land — **indefinitely** on a fleet that has been
deployed once and left running, which is exactly the shape a production fleet settles into.
⚠️ **An expired-key session is silenced, not disconnected.** Its items go `BadUserAccessDenied` and the
session stays open, deliberately: a clock-driven denial must look to a client exactly like a
deploy-driven one, OPC UA has no honest "expire this session" verb (deleting it server-side gives the
client a transport failure rather than an authorization status), and every other operation on that
session already denies. **A client that re-activates with a renewed key recovers on the next tick with
no reconnect** — closing the session instead would force a full reconnect-and-resubscribe on every
machine consumer in the fleet at once when a shared key expired.
⚠️ **Event/alarm monitored items are revalidated by neither trigger.** The live-item map is fed only
from data-change items, so an alarm subscription is gated at creation and never again. Pre-existing —
the deployment trigger has the same hole — and tracked as a residual of arch-review 03/S19.
- **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client
arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note
what this necessarily discloses — the *names* of the folders on the path to a granted node are visible