2314a53842
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.
306 lines
30 KiB
Markdown
306 lines
30 KiB
Markdown
---
|
||
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.)
|