The SDK memoizes INodeManager3.ValidateEventRolePermissions per (item, event type, SourceNode). #826 kept it fresh by reflecting into the private MonitoredNode2.InvalidatePermissionCaches (plus a detach/re-attach fallback). Since #855 condition events never take that channel, and the search for #878 found nothing production-raised that does except a condition's SDK method audits, which reach it only with auditing on, SignAndEncrypt and a SecurityAdmin session (nothing grants that role). Under enforcement the channel verdict is now a flat deny for items on our nodes, so no cached verdict depends on a grant, and InvalidateFolderEventVerdicts, the reflection, the fallback, its counter and the guard test are removed. Method audits still reach Server-object items, decided per event. Tests: FolderEventVerdictInvalidationTests -> FolderEventVerdictFreshnessTests; new revoked-grant test (positive control: a still-granted session) and method-audit test (SecurityAdmin mapped for the test; positive controls: Server-object item, flag off); the plain-event test runs each mode on a fresh server. Mutation-checked. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
34 KiB
paths
| paths | |||||
|---|---|---|---|---|---|
|
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. RootCLAUDE.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(defaultfalse). With the flag off, a key's grants collapse into the two server-wide role gates (WriteOperateon Value writes,AlarmAckon Part 9 methods), onlyCluster-scoped grants count —ApiKeyInterimRoleMapperdeliberately 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/AlarmAckon its open session until the client disconnected.DataPlaneAuthorizer.AuthorizeKeyUsableruns the sameFindUsableKeyprobe against the same snapshot + clock as the ACL path (one dictionary lookup, no I/O) and deniesBadUserAccessDeniedbefore 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 itsPermissionTrieCacherather 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.
UserAuthorizationStateis 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:MembershipResolvedUtccomes fromDataPlaneAuthorizer'sTimeProviderwhileIsStaleis evaluated againstTriePermissionEvaluator'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.BrowseTransitPrincipalsmarks the path. Planting a realBrowsegrant on each ancestor instead would be collected by the additive downward walk and leakBrowseonto 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, soDataPlaneAuthorizer.BuildScopemaps 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
ScriptedAlarmIds are opaque (^[A-Za-z0-9_-]+$, e.g.scripted-eqa-hi) and become the condition NodeId, soBuildScopeon 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 itsEquipmentIdinMaterialiseAlarmCondition(only when parented under that equipment's folder; cleared on every teardown + rebuild); (2) the authority generation carriesEquipmentScopePaths(built by the sameAclScopePathResolvercall that places Equipment grants) andScriptedAlarmOwners(alarmId → EquipmentId from the artifact).DataPlaneAuthorizer.AuthorizeScriptedConditiondenies 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 unders=EQ-…/Name, scripted conditions + children unders=<alarmId>[/…], while grants sit at NAME paths.OtOpcUaNodeManager.Authorizer's setter attachesResolveUnsAclSubjectasDataPlaneAuthorizer.UnsSubjectResolver(one wiring, no second site), which reports LIVE facts (UnsAclSubject: folder id ancestry / owner binding);Evaluateplaces 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'sUnsFolderScopes(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) getowner path + DataPlaneAuthorizer.OwnedNodeSegment— only equipment-or-ancestor grants confer them, and a Tag grant's Browse transit can't enumerate them. Conditions shareTryPlaceScriptedConditionwith 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 / NodeDeletedAffected) isUnknown: 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→RawOrganizesedge fromAddReference), which is always a reference variable (#826 — an Area named exactly like its equipment's id used to make its references folder-owned). ⚠️ConditionVisibilityScopesreturns 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 byDeploymentArtifact.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.ChangedfiresRevalidateMonitoredItems. ⚠️ A denied item is queuedBadUserAccessDeniedand then detached from itsMonitoredNode2fan-out — NOT setMonitoringMode.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. ⚠️Changedis no longer the only trigger (arch-review 03/S19):AclRevalidationTimer, armed byOtOpcUaServerHostedServiceatSecurity:Acl:RevalidationIntervalSeconds(default 60 s,0disables, inert whileSecurity:Acl:Enabledis false), runs the same pass on the clock — becauseExpiresUtcpasses 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 theSubscribeToEventsoverride, holds every direct notifier subscription; a denied one is detached via the SDK's own protectedSubscribeToEvents(…, unsubscribe: true)(what a client delete does) and re-attached withunsubscribe: falseon restore — again neverDisabled. 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). TheSubscribeToAllEventsoverride attaches them to nothing (just records them in_allEventsItems); each condition's ownOnReportEvent(OnConditionReportEvent, fires once per event ⇒ still one Server-object copy) queues the event to each recorded item whose session holdsSubscribeon 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 (OnReportEventoverride) 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'sAddRootNotifiersubscribes existing all-events items withunsubscribe: true). Revalidation is implicit — every event re-asks the authorizer, so a deploy or expiry lands on the next event with no pass.ConditionRefreshon ani=2253item is filtered the same way. The dispatcher must keep the SDK's audit guard (AuditEventStateonly whileServer.Auditingover SignAndEncrypt/https), because conditions report their Part 9 method audit events through themselves.ReconcileAllEventsMonitoringmust keepAreEventsMonitoredraised on every condition while anyi=2253item exists, becauseConditionStategates its method audit reports onEventsMonitored()(method-driven state-change events no longer depend on it — #587, below). The protectedSubscribeToEventsoverride that no-ops all-events items is what stops the SDK'sAddRootNotifier(unsubscribe: truebug) taking that unit back. Both review findings (review of75e559f6). ⚠️ The materialise path raises only the new condition's unit (RaiseAllEventsMonitoring); a full reconcile there made a deploy of N alarms O(N²) underLockwith 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-writeAuditWriteUpdateEvent) go throughReportToServerObject: flag off ⇒Server.ReportEventas before; flag on ⇒ delivered per item to_allEventsItems(same audience), a model change narrowed to theAffectednodes 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 withReadon 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'sMonitoredNode2fan-out asksINodeManager3.ValidateEventRolePermissionsper (event, item) on its own delivery task; the base method is NOT virtual, soOtOpcUaNodeManagerre-implementsINodeManager3and addsIsNotifierEventVisibleTo: 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'sUnsTagReferencesviaAclScopePathResolver.ResolveTagRawPath), andIsConditionVisibleTo(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):RefreshDirectItemsmirrors the base loop plus the same decision. Since #855 condition events at our notifiers are served per item, per event, UNCACHED byDeliverToNotifierItems(the same three checks, resolved once per hop), never through the SDK channel. ⚠️ #878: the SDK channel's verdict is a flat DENY under enforcement. The SDK memoizes whateverINodeManager3.ValidateEventRolePermissionsreturns per(item, eventType, SourceNode)until the monitored node's permission generation moves (item add/remove, rebind, namespace default-permission change — NOT a deploy or grant change); #826 kept it fresh with reflection into the SDK's privateMonitoredNode2.InvalidatePermissionCaches(InvalidateFolderEventVerdicts, a detach/re-attach fallback, a guard test). All of it is DELETED: its only caller in SDK 2.0.1 isMonitoredNode2.GetOrAddEventPermissionAsync, and only a condition's SDK-raised method AUDITS ride that channel (auditing on + SignAndEncrypt +SecurityAdmin, the one role the SDK type check admits to audit types — nothing grants it) or a hand-madefolder.ReportEvent(none in src). Denying them all keeps the cached verdict grant-independent. Do not reintroduce a grant-dependent decision on that path — it would need the invalidation back. Residual: a flag-OFF allow survives the switch to ON (authorizer wired just after server start). Mutation record: with the invalidation disabled only its own counter test and a flag-off→on plain-event test failed (347 integration tests). Tests:FolderEventVerdictFreshnessTests. ⚠️ The verdict is decided on the LIVE instance holding the event's condition NodeId, never the event's own instance (a replaced instance's late event is treated as the live one's).TagReferencingEquipmentholds ALARM-BEARING tags only (TagConfigIntent.Alarm). ⚠️ A hand-built authority (rigs) must passtagReferencingEquipmentor 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. ⚠️ Never report an event with the synchronousNodeState.ReportEventwhile holdingLock(#838). Each notifier'sMonitoredNode2drains a BOUNDED channel (4096,FullMode = Wait) and its delivery task takesLockon a verdict-cache miss (SDKGetNodeMetadata— flag off too — and #826's check); syncReportEventblocks on a full channel ⇒ a >4096 burst under oneLockhold deadlocked the node manager (stalled at 4097). Condition events go throughReportEventWithoutBlocking: our own copy of the SDK walk with a LIVENESS check before every hop (and inOnConditionReportEvent) — a deferred event of a removed/replaced condition is dropped, never a ghost oni=2253; per-CONDITION ordered queues on overflow; coalesce past 1024/condition or 65 536 total (oldest not-started gives way to newest); Warning/Information +otopcua.opcua.condition_events.{deferred,discarded}. A Server-object ConditionRefresh (and ConditionRefresh2, #854) with nothing deferred is the plain walk OFFLock; while deferring it takes a sequence CUT (one shortLockhold: watermark + frozen snapshots of Retain conditions from_alarmConditions), queues the replay offLockone event per hold of a per-item gate, and withholds older deferred events from THAT item only (re-sent condition, or Retain=true of one omitted because NOT retained — never one omitted by ACL). Item state is aConditionalWeakTablekeyed by the item; cuts clear when the deferral count hits 0. Tests:ConditionRefreshDeferralWireTests. ⚠️ Since #855 folder items are delivered per item too: at every notifier we own,DeliverConditionEventAsyncserves theMonitoredNode2's attached items itself (DeliverToNotifierItems: audit guard →INodeManager3.ValidateEventRolePermissionsper event, UNCACHED → item gate/cut →QueueEvent), never through the SDK channel — so no notifier of ours defers, a flood underLockdefers nothing, and folder refreshes (RefreshDirectItems, flag on AND off) share the #854 cut. The deferral queue + cut remain as the safety net; an event whose walk suspends is marked deferred BEFORE suspending. Tests produce deferral withNotifierStall(theNotifierDeliveryStallprobe) — holding a folder item's SDK lock now blocks the reporter.FolderConditionRefreshWireTests;docs/plans/2026-11-11-folder-event-per-item-delivery.md. Because that queueing runs underLock, event item queues areRingEventMonitoredItemQueue(#870, supplied byOtOpcUaSdkServer.CreateMonitoredItemQueueFactoryviaRingEventQueueFactory; data-change queues stay the SDK's) — the SDK queue's discard-oldest was aList.RemoveAt(0)per stalled item per report. It is storage only (overflow events + DiscardOldest live in the SDK's unchangedEventQueueHandler); parity pinned byRingEventMonitoredItemQueueParityTests— re-check them on an SDK bump. Folder events follow Part 3's shape —ReceiveEventson EventType + SourceNode (SDK check, kept first) AND our decision; non-condition events on a folder item fail closed under enforcement. Tests:FolderEventFloodWireTests. Overflow WITH the ACL doing work (#838 item 2) — denied condition interleaved, folder +i=2253items, non-publishing client, concurrent Read, grant revoked while a deferred tail waits — isEventQueueOverflowAclWireTests: the item-queue overflow is the SDK's DiscardOldest (newest kept, oneEventQueueOverflowEventTypeahead of the survivors); deferred events are decided at DELIVERY, so a revocation stops the waiting tail; events already in the item queue when a grant is revoked may still publish (authorized when queued).
- 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
- ns=0 standard nodes stay ungated by design, even with
Security:Acl:Enabled(#617) —ServerStatus,NamespaceArray,ServiceLeveletc. 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:
EvaluateEquipmentWriteStructureis the ONLY type check.OnWriteValuebypasses the SDK's own type check on every writable equipment node, soOtOpcUaNodeManager.EvaluateEquipmentWriteStructure(plusEvaluateEquipmentWriteRange) 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 CLRint[]/double[]lists) — any out-of-range, fractional-to-integer or NaN/∞ element refuses the whole writeBadTypeMismatchwith nothing sent, so aUInt32>Int32.MaxValuecan 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'srefuseArrayOnScalar— 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);IndexRangewrites ⇒BadWriteNotSupported,ValueRank > 1out of scope. An array node is writable only when the driver can write the array (DriverArrayWriteSupport: Modbus keys-addressed tags bounded bymaxRegistersPerWrite(never above FC16's 123)/coil cap/address 65535 incl. String/BitInRegister arrays (BitInRegister also bymaxRegistersPerRead: its RMW is one unchunked FC03) (#741; a string-onlyaddressStringtag stays read-only — family unknown at compose time), S7 non-Bool/non-wide types, OpcUaClient, Galaxy signed types with a declaredarrayLength— 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 >= 1is required, and the node'sWritable && (!IsArray || ArrayWritable)is projected onto the raw node and every UNS mirror. - Anonymous is a normal grantable principal, not a bypass.
OpcUa:AllowAnonymous(defaulttrue, preserving today's behaviour) controls admission; when admitted the session authenticates as the reserved principalanonymousand gets roles fromNodeAclrows naming it, arriving at the gates as the sameRoleCarryingUserIdentitya key produces. Set itfalseto make keys mandatory. A key may not use theanonymousprincipal, 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
SessionManagerrefuses 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.DisableSdkAuthenticationLockoutzeroesMaxFailedAuthenticationAttemptson 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 mostmax − failuresverify at once; the rest WAIT (never refused for parallelism — ScadaBridge opens many keyed sessions from one IP), refused only on lockout or, uncounted, afterSlotWaitSeconds. An expired lockout resets the count. OnNoneendpoints the key is effectively IP-only (NAT neighbours share it, UserName only). ⚠️ Two counts (#844): per (client, CANONICAL credential identity =IOpcUaUserAuthenticator.DescribeCredential,key:<keyId>trimmed + lower-cased, non-hex ids folded together, since SQL=is CI/trailing-space-blind /user:<name>), cleared by a success with THAT identity, locks that identity; per client (MaxFailedAttemptsPerClient, default 2 × max) — never reset by a success (else a valid key launders guesses at another key id) — locks every credential. ⚠️ Only definite bad credentials count (#845): store timeout / miss-budget / store throw / unset pepper / authenticator throw / cancel ⇒OpcUaUserAuthResult.Unavailable(Transient) ⇒Abandon, uncounted. ApplicationUri is deliberately NOT in the key (free string onNone⇒ trivial evasion).docs/security.md§"Failed-credential lockout". - Keys reach a driver-only node inside the deployment artifact (stage 4).
ConfigComposersnapshots theApiKeyrows — the peppered HMAC only, never a secret, and the pepper is node config that is deliberately not in the artifact — andDeploymentArtifact.ParseDataPlaneAuthoritydecodes them node-side alongside the cluster'sNodeAclrows.OpcUaPublishActorinstalls both onIDataPlaneAuthoritySourceon every apply, before itsplan.IsEmptyearly-return, so a deployment that only revokes a key still lands. A node with a ConfigDb keepsConfigDbApiKeyStore(per-activation read, wrapped — see below); a driver-only node getsArtifactApiKeyStore. ⚠️ The asymmetry is ACTIVATION-scoped, not session-scoped — this line used to say "immediate on a ConfigDb node" and that was wrong. ClearingEnabledin the ConfigDb stops the next session activation on an admin/fused node (bounded bySecurity: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 againstIApiKeyStore, so on both node kinds anEnabledrevocation reaches an open session only at the next deployment. Expiry is the exception:FindUsableKeycomparesExpiresUtcagainst 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'sSecurity:Acl:RevalidationIntervalSecondstimer (default 60 s; the session is silenced withBadUserAccessDenied, 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.BoundedApiKeyStorewraps the ConfigDb store — and only it, neverArtifactApiKeyStore, which is two in-memory dictionary probes — withSecurity:ApiKeys:StoreTimeoutMs(2000; exceeding it denies, via the authenticator's existing catch-all) plus aStoreCacheSeconds(5) read-through cache. Faults and timeouts are never cached, a cached hit whoseExpiresUtchas passed is re-read rather than served, and a timeout logs a distinct Warning +otopcua.opcua.apikey_store.bounded{outcome=timeout}. Both knobs at0restore the old behaviour exactly. - ⚠️ The auth generation is a node-local COUNTER driven by the
RevisionHash, not derived from its bits.PermissionTrieCachetreats the highestGenerationIdas 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 byDataPlaneAuthorityStateTests.The_generation_only_ever_increases_even_when_the_hash_decreases. - Note
OtOpcUaServerHostedServicetakesIOpcUaUserAuthenticatorandIDataPlaneAuthoritySourceas 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.)