Files
lmxopcua/.claude/rules/data-plane-auth.md
T
Joseph Doherty 2314a53842 fix(opcua): parallel UserName activations wait for a slot instead of being refused (#833)
A legitimate consumer opening several keyed sessions at once from one key
(e.g. a ScadaBridge site node behind one NAT IP on a None endpoint) must
never see BadUserAccessDenied for parallelism. BeginAsync now hands out at
most MaxFailedAttempts - recordedFailures verification slots per key;
further activations queue FIFO and are woken as slots free. A waiter is
refused only when the key locks out (never reaching the authenticator) or,
uncounted, after OpcUa:CredentialLockout:SlotWaitSeconds (default 10,
BadTimeout). Cancelled waiters are dequeued; pruning accounts for waiters.
The security property is unchanged: no more than MaxFailedAttempts
unverified guesses in progress-or-failed per window.
2026-11-11 02:43:38 -05:00

306 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
paths:
- "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/**"
- "src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/**"
- "src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/**"
- "src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/**/*Acl*"
- "docs/security.md"
---
# Data-plane authentication (API keys), node ACL enforcement, anonymous principal, AdminUI LDAP
> Moved verbatim from the root `CLAUDE.md` (2026-10-13) so it loads only when working in this area.
> Root `CLAUDE.md` § "Subsystem guides" indexes it. Edit it here, not in the root file.
## Authentication: API keys on the data plane, LDAP on AdminUI (#520)
⚠️ **Changed 2026-07-31. The OPC UA data plane no longer uses LDAP at all.** It is machine-to-machine,
so a client presents an OPC UA **UserName** token with the fixed username **`apikey`** and an
`otua_<keyId>_<secret>` credential as the password. `ApiKeyOpcUaUserAuthenticator`
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/`) implements `IOpcUaUserAuthenticator`: one indexed lookup
on `keyId` plus one peppered HMAC-SHA256, `FixedTimeEquals`, **no network I/O**. The key resolves to a
**principal**; `NodeAcl.Principal` rows grant it permissions. `LdapOpcUaUserAuthenticator` is
**deleted**, along with four `Security:Ldap` keys that existed only for it (`AuthTimeoutMs`,
`MaxConcurrentAuthentications`, `OutageFailureThreshold`, `OutageCooldownSeconds`) — they bounded an
LDAP bind running inline in the SDK's synchronous, `SessionManager`-lock-holding impersonation
callback, and nothing on the remaining LDAP path has that property.
**LDAP remains, for AdminUI login only** (`Security:Ldap`, `OtOpcUaLdapAuthService` →
`ILdapAuthService`, group→role mapping via `GroupToRole` + `LdapGroupRoleMapping`). Do not "restore"
the data-plane authenticator when you see LDAP wiring in `Program.cs` — it is there for the AdminUI.
Two things to know before relying on any of this:
- ⚠️ **Node ACLs are enforced only behind `Security:Acl:Enabled` (default `false`).** With the flag
**off**, a key's grants collapse into the two server-wide role gates (`WriteOperate` on Value writes,
`AlarmAck` on Part 9 methods), **only `Cluster`-scoped grants count** — `ApiKeyInterimRoleMapper`
deliberately under-grants, because honouring a narrower grant's flags while unable to honour its
*scope* would turn "WriteOperate on Equipment X" into server-wide write authority — and **read,
browse, subscribe and HistoryRead are ungated for any admitted session including Anonymous**. With the
flag **on** (`DataPlaneAuthorizer`), scoped grants decide **every** data-plane operation: write and
alarm commands and HistoryRead (stage 5), read and browse and subscription creation (stage 6).
- **The two role gates re-check the session's KEY on every call, flag off or on (Gitea #563).** Roles
are resolved once at activation, so an expired / deployment-disabled key used to keep `WriteOperate` /
`AlarmAck` on its open session until the client disconnected. `DataPlaneAuthorizer.AuthorizeKeyUsable`
runs the same `FindUsableKey` probe against the same snapshot + clock as the ACL path (one dictionary
lookup, no I/O) and denies `BadUserAccessDenied` before the role check. **Only those two gates** —
reads/subscriptions stay ungated with the flag off, by design. Anonymous carries no key and is
unaffected. No flag: a dead key keeping write authority is a defect, not a rollout choice. ⚠️ On a
fused node a key minted after the last deploy authenticates (ConfigDb) but cannot write/ack until a
deployment carries it — the flag-on path always behaved this way.
- ⚠️ **The ACL decision REPLACES the role check — the two are never chained.** Chained, a grant would
have to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped
grants confer a role, so every narrow grant would stay inert and the flag would appear to do
nothing. The load-bearing test is therefore an **allow** — a session carrying *no roles* writing
successfully on the strength of its grant. A deny-only test passes against a build where the ACL is
never consulted, because the role gate denies too.
- **The trie is built inside `DataPlaneAuthorityState.Apply`**, not by a second wiring at the publish
actor, and the state owns its `PermissionTrieCache` rather than being handed one. An uninstalled
trie denies everything, which is indistinguishable from working enforcement until someone checks
that an ALLOW is possible at all.
- **A deploy reaches sessions that are already open**, in both directions: each decision stamps the
*current* generation rather than the one the session activated under, and re-checks the
authenticating key — so revoking a key or a grant stops an open session immediately instead of at
the client's next reconnect. On a driver-only node the artifact is the only revocation channel.
- `UserAuthorizationState` is built **per decision walk, not per session** — and since the hot-path
change it is allocated only on a cache **miss**, so a cache hit never constructs one. Its LDAP-shaped
freshness/staleness predicates are switched off EXPLICITLY (`AuthCacheMaxStaleness = MaxValue`).
⚠️ Relying on "the stamp is always fresh" was not safe: `MembershipResolvedUtc` comes from
`DataPlaneAuthorizer`'s `TimeProvider` while `IsStale` is evaluated against
`TriePermissionEvaluator`'s, so **any divergence between the two clocks denies every request** with a
verdict indistinguishable from "no grant matched". Found by a test clock two hours ahead.
- **Stage 6's hot seams (read/browse/subscribe) go through a per-`(session, node, operation, realm)`
decision cache**, cleared wholesale when the generation advances. It is **sharded per API key**
(`ApiKeyId ?? Principal` — despite the type name, sessions sharing one key share one shard and one
budget), so a client crawling a large tree past the per-key ceiling evicts only that key's shard and
not every other key's decisions. ⚠️ It caches the **grant walk only** — key revocation and expiry are
re-checked **before** the cache lookup on every call, because expiry moves with the clock rather than
with a deployment.
- **Browse needs ancestor visibility, and it must NOT be a synthesized grant on the ancestors.** A
grant deep in the tree is reached by browsing the nodes above it, and the trie inherits downward
only, so `PermissionTrieNode.BrowseTransitPrincipals` marks the path. Planting a real `Browse` grant
on each ancestor instead would be collected by the additive downward walk and leak `Browse` onto
every sibling subtree beneath that ancestor. ⚠️ **The topmost ancestor is the single shared root
folder** (`s=OtOpcUa`, raw namespace) that BOTH realm trees hang under — its identifier is a display
name, not a path, so `DataPlaneAuthorizer.BuildScope` maps it onto the trie's **zero-segment
realm-root scope** (`V3NodeIds.SharedRootFolderId`, realm-qualified: in the UNS namespace that
identifier is an ordinary Area), and its Browse considers transit in **either** realm because it
fronts both trees. Before this every narrow Browse grant was unreachable by navigation — the Browse
gate denies the whole call when its source node is denied, so clients were stopped at the front door.
- **Scripted-alarm commands are authorized at the OWNING EQUIPMENT, never by splitting the id (#797).**
Production `ScriptedAlarmId`s are opaque (`^[A-Za-z0-9_-]+$`, e.g. `scripted-eqa-hi`) and become the
condition NodeId, so `BuildScope` on them can never match an Equipment grant. Two halves, both re-read
per call and uncached: (1) the node manager binds each live condition **instance** to its `EquipmentId`
in `MaterialiseAlarmCondition` (only when parented under that equipment's folder; cleared on every
teardown + rebuild); (2) the authority generation carries `EquipmentScopePaths` (built by the same
`AclScopePathResolver` call that places Equipment grants) **and** `ScriptedAlarmOwners`
(alarmId → EquipmentId from the artifact). `DataPlaneAuthorizer.AuthorizeScriptedCondition` denies
unless the bound instance is the live one AND its equipment equals the generation's owner — this
closes the install-authority-then-rebuild window and a failed apply leaving a moved alarm on its old
equipment. ⚠️ **Fail closed, even for Cluster grants:** an unbound / replaced / undeployed / line-less
owner is denied under every grant (no realm-root fallback). Native alarms still use RawPath; ACL-off
is unchanged.
- **Opaque-id UNS nodes are PLACED, never split, for every operation (#808).** UNS Area/Line/Equipment folders
are minted under their entity ids (`s=UA-…/UL-…/EQ-…`), virtual tags under `s=EQ-…/Name`, scripted
conditions + children under `s=<alarmId>[/…]`, while grants sit at NAME paths. `OtOpcUaNodeManager.Authorizer`'s
setter attaches `ResolveUnsAclSubject` as `DataPlaneAuthorizer.UnsSubjectResolver` (one wiring, no second
site), which reports LIVE facts (`UnsAclSubject`: folder id ancestry / owner binding); `Evaluate` places them
against the CURRENT generation **before** the decision cache and caches only the walk, keyed on the resolved
scope (`\0`-prefixed) — never on the NodeId, or a deny would stick past a rebuild. Folders use the generation's
`UnsFolderScopes` (folderId → path + id chain, `DeploymentArtifact.BuildUnsFolderScopes`, same resolver as the
grants; ids two kinds share are dropped) and **require live chain == generation chain** (moved equipment ⇒
denied under old AND new line until the rebuild). Owned nodes (vtags, conditions, children) get
`owner path + DataPlaneAuthorizer.OwnedNodeSegment` — only equipment-or-ancestor grants confer them, and a Tag
grant's Browse transit can't enumerate them. Conditions share `TryPlaceScriptedCondition` with the #797 command
gate, which ALSO requires the owner folder's live chain to match (review: else the new line could command a moved
equipment's alarm while its folder is denied). Reference variables (`s=Area/Line/Equip/Name`) keep their split
path (so Tag grants match) but only while it equals their live equipment folder's placement + one name — a
moved/renamed equipment's old-name variables are denied (Read AND Write) under old and new names until
re-pointed. An id NO live node holds (removed / probe / NodeDeleted `Affected`) is `Unknown`: placed at
`[\0unknown]`, so only Cluster / Namespace-uns grants decide it and a removed opaque id can never split onto a
same-named narrow grant — except a 4-segment (reference-shaped) id, which keeps its split. ⇒ narrow grantees do
NOT receive NodeDeleted for removed UNS folders / vtags / conditions (by decision; they re-browse). Identifiers
containing NUL are denied before the cache (resolved-scope keys are NUL-prefixed — a client could otherwise
poison a real folder's cached verdict). The folder-owned rule keys on "first id segment == nearest slash-free
live UNS folder ancestor's id", EXCEPT for a variable tracked in `_unsReferenceVariables` (the source of a
UNS→Raw `Organizes` edge from `AddReference`), which is always a reference variable (#826 — an Area named exactly
like its equipment's id used to make its references folder-owned). ⚠️ `ConditionVisibilityScopes` returns the
condition ALONE for a scripted condition, so a moved alarm can't reach its old owner via the Server object — and
since #826 not via a direct folder item either (below). A UNS node with NO live hierarchy-folder ancestor (rig-only
shape) keeps the plain split. Unplaceable ⇒ denied for every grant incl. Cluster (a behaviour change for Cluster
keys only in the stale window / corrupt topology). Tests: `UnsFolderScopeWireTests` (opaque ids, incl. one fed by
`DeploymentArtifact.ParseDataPlaneAuthority`); rig tests (`ServerEventRig.FolderScopes()`, `EqA` ⇒ `[uns, EqA]`)
install folder scopes explicitly — without them every UNS folder and scripted condition denies.
- **Revocation reaches live subscriptions** (stage 6): every other operation re-asks per request, but a
subscription is authorized once and then streams — and streaming is how machine consumers actually
read this server. `DataPlaneAuthorityState.Changed` fires `RevalidateMonitoredItems`. ⚠️ A denied
item is queued `BadUserAccessDenied` and then **detached from its `MonitoredNode2` fan-out — NOT set
`MonitoringMode.Disabled`**: disabling discards the item's queued notifications, so the client is
silenced without ever being told why (caught only by the over-the-wire test). Detaching is also
reversible, so restoring the grant resumes the feed with no client action. ⚠️ **`Changed` is no longer
the only trigger (arch-review 03/S19):** `AclRevalidationTimer`, armed by `OtOpcUaServerHostedService`
at `Security:Acl:RevalidationIntervalSeconds` (default 60 s, `0` disables, inert while
`Security:Acl:Enabled` is false), runs the same pass on the clock — because `ExpiresUtc` passes with
the clock, not with a deployment. The pass's verdict memo is **pass-local and must stay that way**;
hoisting it across ticks would serve an expiry that landed between two ticks the earlier tick's allow.
**Event/alarm items ride both triggers too (Gitea #562)** — a second map (`_eventItems`), fed by the
`SubscribeToEvents` override, holds every *direct* notifier subscription; a denied one is detached via
the SDK's own protected `SubscribeToEvents(…, unsubscribe: true)` (what a client delete does) and
re-attached with `unsubscribe: false` on restore — again never `Disabled`. Unlike a data item it is
**not told** (no value slot for a status; the feed goes quiet), and a client delete of a stood-down
item is absorbed by the override (the SDK would otherwise remove an item that is not attached).
**Server-object (`i=2253`) event subscriptions are filtered per event at delivery (Gitea #586).** The
`SubscribeToAllEvents` override attaches them to **nothing** (just records them in `_allEventsItems`);
each condition's own `OnReportEvent` (`OnConditionReportEvent`, fires once per event ⇒ still one
Server-object copy) queues the event to each recorded item whose session holds `Subscribe` on the
condition (RawPath) **or** any notifier folder it reports through — the per-folder path's question. The
root-notifier forward to the core Server object (`OnReportEvent` override) is **dropped**: it was the
ungated path, and it is the only one that served an item created before the alarm was deployed (the
SDK's `AddRootNotifier` subscribes existing all-events items with `unsubscribe: true`). Revalidation is
**implicit** — every event re-asks the authorizer, so a deploy or expiry lands on the next event with no
pass. `ConditionRefresh` on an `i=2253` item is filtered the same way. The dispatcher **must** keep
the SDK's audit guard (`AuditEventState` only while `Server.Auditing` over SignAndEncrypt/https),
because conditions report their Part 9 method audit events through themselves.
`ReconcileAllEventsMonitoring` must keep `AreEventsMonitored` raised on every condition while any
`i=2253` item exists, because `ConditionState` gates its method **audit** reports on
`EventsMonitored()` (method-driven state-change events no longer depend on it — #587, below). The
protected `SubscribeToEvents` override that no-ops all-events items is what
stops the SDK's `AddRootNotifier` (`unsubscribe: true` bug) taking that unit back. Both review
findings (review of `75e559f6`). ⚠️ The materialise path raises only the **new** condition's unit
(`RaiseAllEventsMonitoring`); a full reconcile there made a deploy of N alarms O(N²) under `Lock` with
ScadaBridge connected — keep the full reconcile to remove/rebuild/(un)subscribe.
**Model-change and audit events are gated too (Gitea #588).** The manager's own server-wide events
(`GeneralModelChangeEvent`, the rejected-write `AuditWriteUpdateEvent`) go through
`ReportToServerObject`: flag **off** ⇒ `Server.ReportEvent` as before; flag **on** ⇒ delivered per item to
`_allEventsItems` (same audience), a model change narrowed to the `Affected` nodes the session may
**Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral
one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other
event type fails closed. Auditing is off by default (never configured in this repo).
**Direct (per-folder) event items are filtered per event too (Gitea #826).** The SDK's `MonitoredNode2` fan-out
asks `INodeManager3.ValidateEventRolePermissions` per (event, item) on its own delivery task; the base method is
NOT virtual, so `OtOpcUaNodeManager` **re-implements `INodeManager3`** and adds `IsNotifierEventVisibleTo`: the
event's condition (snapshot handle, or an audit's SourceNode) must be the LIVE instance, the item's notifier must
still be a legitimate notifier of it (an ancestor, or a wired folder the CURRENT generation still projects its
RawPath into — `DataPlaneAuthoritySnapshot.TagReferencingEquipment`, from the artifact's `UnsTagReferences` via
`AclScopePathResolver.ResolveTagRawPath`), and `IsConditionVisibleTo(item, ConditionVisibilityScopes(c))` — the
Server-object rule, whose wired folders are ALSO generation-filtered now. ConditionRefresh on a direct item no
longer calls the base (it uses the non-virtual base check, which bypasses the re-implementation): `RefreshDirectItems`
mirrors the base loop plus the same decision. ⚠️ **The SDK memoizes that verdict per `(item, eventType,
SourceNode)`** until the monitored node's permission generation moves — `InvalidateFolderEventVerdicts` (one
`MonitoredNode2.InvalidatePermissionCacheForSession` per notifier×session) must run whenever the inputs change:
the authority/authorizer setters, `RevalidateMonitoredItems` (deploy `Changed` + timer tick) and
`CompleteAddressSpaceApply` — and is SKIPPED while enforcement is off (the authorizer setter forces it across an
on/off transition). Without it a cached allow survives the deploy (caught by the native wire test's restore leg /
`t2`). A rebuild tombstones the monitored nodes, so their caches go with them. ⚠️ (review) It clears each node's
cache WHOLE through the SDK's private `MonitoredNode2.InvalidatePermissionCaches` (reflection, pinned by
`FolderEventVerdictInvalidationTests.The_sdks_node_wide…`): the public `InvalidatePermissionCacheForSession`
misses ORPHANED subscriptions (session closed without deleting subs ⇒ `Session` null), which then queued a moved
alarm's events and delivered them on `TransferSubscriptions`, and rescanned the cache per item. Fallback if the SDK
drops it: per-session drop + `Remove`/`Add` of each session-less item. ⚠️ The verdict is decided on the LIVE
instance holding the event's condition NodeId, never the event's own instance — the SDK key has no instance
identity, so denying a replaced instance's late event cached a deny the new instance inherited.
`TagReferencingEquipment` holds ALARM-BEARING tags only (`TagConfigIntent.Alarm`). ⚠️ A hand-built
authority (rigs) must pass `tagReferencingEquipment` or native alarms stop reaching equipment-folder items under
enforcement (`ServerEventRig.TagReferences()`); null ⇒ none (fail closed). Tests:
`UnsFolderScopeWireTests.A_moved_alarm_stops_reaching_its_old_folders_subscribers…`,
`NativeAlarmFolderEventAclWireTests`.
- **ns=0 standard nodes stay ungated by design, even with `Security:Acl:Enabled`** (#617) — `ServerStatus`,
`NamespaceArray`, `ServiceLevel` etc. are served by the SDK's core node manager outside our authorizer and
clients read them to connect and to judge redundancy health; they carry no plant data. Every raw/UNS node,
event subscription and HistoryRead is still gated. `docs/security.md`.
- **Equipment-node writes: `EvaluateEquipmentWriteStructure` is the ONLY type check.** `OnWriteValue` bypasses
the SDK's own type check on every writable equipment node, so `OtOpcUaNodeManager.EvaluateEquipmentWriteStructure`
(plus `EvaluateEquipmentWriteRange`) is the whole gate. Scalars are deliberately lenient (drivers
range-check; ScadaBridge sends Double to a Float node). **Array writes** (#610): a numeric array of another
element type is **converted, checked, element by element** to the node's element type (ScadaBridge writes
CLR `int[]`/`double[]` lists) — any out-of-range, fractional-to-integer or NaN/∞ element refuses the whole
write `BadTypeMismatch` with nothing sent, so a `UInt32` > `Int32.MaxValue` can never reach a Galaxy Int32
attribute as reinterpreted bits; non-numeric mismatches are refused (`Byte[]` ≈ ByteString); wrong length ⇒
`BadTypeMismatch`; an array to a **scalar** node is refused only on Modbus/S7 (`EnsureVariable`'s
`refuseArrayOnScalar` — pass-through drivers keep their browse-committed scalar-shaped array tags writable;
a new sink wrapper must forward that argument, and the reflection guard checks only that the call arrives);
`IndexRange` writes ⇒ `BadWriteNotSupported`, `ValueRank > 1` out of scope. An
array node is writable only when the driver can write the array (`DriverArrayWriteSupport`: Modbus keys-addressed tags bounded
by `maxRegistersPerWrite` (never above FC16's 123)/coil cap/address 65535 incl. String/BitInRegister arrays
(BitInRegister also by `maxRegistersPerRead`: its RMW is one unchunked FC03) (#741; a string-only `addressString` tag
stays read-only — family unknown at compose time), S7 non-Bool/non-wide types, OpcUaClient, Galaxy signed types **with a
declared `arrayLength`** — an undeclared length stays read-only because a short whole-array write would
resize the Galaxy attribute; AbCip atomic non-BOOL types, AbLegacy Int/Long/Float on the matching data file
(no `/bit`/`.sub`/indirect/`S:`/`I:`/`O:`, not native PLC-5, ≤ 256), TwinCAT every codec type but a BOOL
bit-in-word path — #737/#738/#739; those three CONVERT elements, so they never key on an exact node type); `arrayLength >= 1` is required, and the node's `Writable && (!IsArray ||
ArrayWritable)` is projected onto the raw node and every UNS mirror.
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
the same `RoleCarryingUserIdentity` a key produces. Set it `false` to make keys mandatory. A key may
**not** use the `anonymous` principal, and a failure to read anonymous's grants **denies** rather
than admitting with no roles.
- ⚠️ **The SDK's failed-auth lockout is OFF; ours is UserName-only (#833).** SDK 2.0.1 `SessionManager` refuses
EVERY activation (Anonymous included, before the token is read) from a client with 5 failures in 60 s, for 5 min,
keyed cert-thumbprint-else-peer-IP. `OpcUaApplicationHost.DisableSdkAuthenticationLockout` zeroes
`MaxFailedAuthenticationAttempts` on built AND loaded configs; `FailedCredentialLockout` (`OpcUa:CredentialLockout`,
5 / 60 s / 300 s) counts and refuses UserName activations only, keyed thumbprint+peer together. Do not re-enable the
SDK setting. The check takes a verification SLOT (`BeginAsync`/`Complete`/`Abandon`) — a check and a
separate record let a parallel burst through. At most `max − failures` verify at once; the rest WAIT (never refused
for parallelism — ScadaBridge opens many keyed sessions from one IP), refused only on lockout or, uncounted, after
`SlotWaitSeconds`. An expired lockout resets the count. On
`None` endpoints the key is effectively IP-only (NAT neighbours share it, UserName only). `docs/security.md`
§"Failed-credential lockout".
- **Keys reach a driver-only node inside the deployment artifact** (stage 4). `ConfigComposer` snapshots
the `ApiKey` rows — the peppered HMAC only, never a secret, and the pepper is node config that is
deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them
node-side alongside the cluster's `NodeAcl` rows. `OpcUaPublishActor` installs both on
`IDataPlaneAuthoritySource` on every apply, **before** its `plan.IsEmpty` early-return, so a deployment
that only revokes a key still lands. A node with a ConfigDb keeps `ConfigDbApiKeyStore` (per-activation
read, wrapped — see below); a driver-only node gets `ArtifactApiKeyStore`. ⚠️ **The asymmetry is
ACTIVATION-scoped, not session-scoped — this line used to say "immediate on a ConfigDb node" and that was
wrong.** Clearing `Enabled` in the ConfigDb stops the **next session activation** on an admin/fused node
(bounded by `Security:ApiKeys:StoreCacheSeconds`, default 5 s) and takes until the next deployment on a
driver-only one — but an **already-open** session re-checks its key against the in-memory *artifact
snapshot* (`DataPlaneAuthorizer.Evaluate` → `snapshot.FindUsableKey`), never against `IApiKeyStore`, so on
**both** node kinds an `Enabled` revocation reaches an open session only at the next deployment.
**Expiry is the exception**: `FindUsableKey` compares `ExpiresUtc` against the authorizer's clock on every
call, so it is immediate everywhere — and since arch-review 03/S19 it reaches live subscriptions too, on
the host's `Security:Acl:RevalidationIntervalSeconds` timer (default 60 s; the session is **silenced with
`BadUserAccessDenied`, not closed**, and recovers on re-activation). Direct event/alarm subscriptions
are revalidated on both triggers as well (#562); with the flag **off**, expiry and revocation reach the
two role gates (#563) but not reads or subscriptions.
- ⚠️ **The activation lookup is BOUNDED (arch-review 03/S18).** The SDK raises impersonation synchronously
under a `SessionManager`-wide lock, so a ConfigDb that is *slow* rather than *refusing* stalled **every**
session activation on the server, Anonymous and X.509 included. `BoundedApiKeyStore` wraps the ConfigDb
store — and only it, never `ArtifactApiKeyStore`, which is two in-memory dictionary probes — with
`Security:ApiKeys:StoreTimeoutMs` (2000; exceeding it **denies**, via the authenticator's existing
catch-all) plus a `StoreCacheSeconds` (5) read-through cache. Faults and timeouts are never cached, a
cached hit whose `ExpiresUtc` has passed is re-read rather than served, and a timeout logs a distinct
Warning + `otopcua.opcua.apikey_store.bounded{outcome=timeout}`. Both knobs at `0` restore the old
behaviour exactly.
- ⚠️ **The auth generation is a node-local COUNTER driven by the `RevisionHash`, not derived from its
bits.** `PermissionTrieCache` treats the highest `GenerationId` as current, so a generation taken from
hash bytes would install a *lower* number about half the time and the cache would go on serving the
previous deployment's ACLs with nothing logged. Pinned by
`DataPlaneAuthorityStateTests.The_generation_only_ever_increases_even_when_the_hash_decreases`.
- Note `OtOpcUaServerHostedService` takes `IOpcUaUserAuthenticator` **and** `IDataPlaneAuthoritySource`
as **required** ctor parameters — the built-in DI container does not fall back to a default value for
an unregistered service, so both must be registered explicitly on every node (deny-all / empty on a
node with neither role) or it does not boot.
**Grants are authored through a scope picker, not free text (stage 7).** `/clusters/{id}/acls` opens
`AclScopePickerModal` over the cluster's raw + UNS trees, and selecting a node supplies `ScopeKind`
**and** `ScopeId` together — so `RawDevice` + a TagId, a cross-cluster scope, a `Cluster` grant carrying
an id, and the retired `FolderSegment` kind are all unauthorable by construction rather than merely
rejected later. ⚠️ **A `Cluster` row must produce a NULL `ScopeId`** — the deploy gate rejects one that
carries an id (`NodeAclScopeIdUnexpected`), so a picker that helpfully filled in the cluster id would
author rows that cannot be deployed. `IAclScopeService.CheckScopeAsync` mirrors
`DraftValidator.ValidateNodeAclScopes` rule for rule and **blocks the save** when a scope resolves to
nothing (re-checked on submit, since a row loaded unchanged is exactly where the operator never touches
the picker); the list page marks such rows `unresolved`. **UNS tags are deliberately not offered** — a
UNS signal is a `UnsTagReference`, and a `Tag` grant already covers the raw node *and* every UNS
projection of it. The picker is `AclScopeTree`, a separate component from `RawTree`, which already
carries two modes.
**All eight stages are done** (`d4389f57` enforcement, stage 7 authoring after it), each live-gated on
docker-dev. The rationale for every decision above is in
`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. See `docs/security.md` for the full guide.
Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.)