Files
lmxopcua/.claude/rules/data-plane-auth.md
T
Joseph Doherty 8c6067b62e docs: move subsystem gotchas out of CLAUDE.md into path-scoped .claude/rules
Keeps the root CLAUDE.md lean by lazy-loading per-area detail (drivers, topology,
data-plane auth, alarms, historian) only when matching files are touched.
2026-10-15 06:14:58 -04:00

20 KiB

paths
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.
    • 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.)