--- 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__` 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. - **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). - **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 bounded by `maxRegistersPerWrite`/coil cap, 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); `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. - **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.)