Files
lmxopcua/docs/security.md
T
Joseph Doherty 53ede679c3 docs+ui(security): state plainly that node ACLs are not enforced (§8.1, #520)
deferment.md §8.1 asked for a decision: wire IPermissionEvaluator into the node
manager, or say plainly that ACLs are not enforced. Decision: the latter. The
evaluator stays in the tree; #520 tracks the wire-up.

Verifying the claim turned up four things worse than the audit recorded:

- docs/security.md, not ReadWriteOperations.md, was the highest-risk doc. It
  claimed an anonymous session is "default-denie[d] any node a session has no
  ACL grant for". The opposite holds: anonymous can Browse/Read/Subscribe/
  HistoryRead the entire address space, and is refused writes and alarm acks
  only because it carries no roles.
- Three of five documented data-plane role strings do nothing.
  OpcUaDataPlaneRoles declares exactly WriteOperate and AlarmAck; ReadOnly,
  WriteTune and WriteConfigure are compared against nowhere in src/. The doc
  called all five "exact, case-insensitive, and code-true".
- ReadWriteOperations.md was fiction beyond the ACL claims. OnReadValue has
  zero occurrences in src/ — the documented read path did not exist. Reads
  never reach a driver: the node manager is push-model and the SDK serves a
  Read from the cached pushed value. WriteAuthzPolicy, _sourceByFullRef,
  _writeIdempotentByFullRef and IRoleBearer are likewise zero-hit, and
  CapabilityInvoker is not referenced by the OpcUaServer project at all. This
  file is rewritten rather than bannered.
- v2-release-readiness.md claimed a whole enforcement layer shipped —
  FilterBrowseReferences, GateCallMethodRequests, MapCallOperation,
  AuthorizationBootstrap, Node:Authorization:* keys. None exist. Whatever
  landed on the v2 branch did not survive into the shipped tree.

The UI change is the operative one: ClusterAcls.razor and AclEdit.razor now
warn that a grant is saved and shipped in every deployment artifact but never
evaluated, so an operator cannot author a deny rule believing it works.

deferment.md gains a §9 execution log tracking §8 remediation.
2026-07-27 18:36:48 -04:00

43 KiB

Security

v2 status (2026-05-26). The four security concerns below are unchanged in v2. Paths + project names moved: OtOpcUa.Server/Security/OtOpcUa.Security/ (Ldap/, Jwt/, Endpoints/AuthEndpoints.cs), OtOpcUa.Admin is gone (its auth + role-grant pages live in OtOpcUa.AdminUI), and Admin auth policies register from OtOpcUa.Host/Program.cs via AddOtOpcUaAuth (src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs) rather than in a separate Admin process. The Admin UI uses a single Cookie authentication scheme — there is no AddJwtBearer pipeline. The Security:Jwt section configures JwtTokenService, which mints a JWT at the /auth/token endpoint for external consumers (OPC UA clients / automation scripts); the cookie itself stores the ClaimsPrincipal directly. DataProtection keys persist to the shared Config DB (PersistKeysToDbContext<OtOpcUaConfigDbContext>) so cookies survive failover between admin-role nodes.

See docs/plans/2026-05-26-akka-hosting-alignment-design.md §5 for the v2 auth + DataProtection rationale.

OtOpcUa has four independent security concerns. This document covers all four:

  1. Transport security — OPC UA secure channel (signing, encryption, X.509 trust).
  2. OPC UA authentication — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind.
  3. Data-plane authorization — who can write and acknowledge alarms on an OtOpcUa endpoint. ⚠️ Enforced today by two coarse, server-wide LDAP role checks (WriteOperate, AlarmAck) — not by the NodeAcl / PermissionTrie subsystem, which is built and unit-tested but never wired. Read, Browse, HistoryRead and Subscribe carry no authorization check at all. See Data-Plane Authorization.
  4. Control-plane authorization — who can view or edit fleet configuration in the Admin UI. Gated by the AdminRole (Viewer / Designer / Administrator) claim resolved from LdapGroupRoleMapping.

Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap appsettings.json. Data-plane ACLs and Admin role grants live in the Config DB.


Transport Security

Overview

The OtOpcUa Server supports configurable OPC UA transport security profiles that control how data is protected on the wire between OPC UA clients and the server.

There are two distinct layers of security in OPC UA:

  • Transport security -- secures the communication channel itself using TLS-style certificate exchange, message signing, and encryption. This is what the OpcUa:EnabledSecurityProfiles setting controls.
  • UserName token encryption -- protects user credentials (username/password) sent during session activation. The OPC UA stack encrypts UserName tokens using the server's application certificate regardless of the transport security mode. UserName authentication therefore works on None endpoints too — the credentials themselves are always encrypted. A secure transport profile adds protection against message-level tampering and eavesdropping of data payloads.

Supported security profiles

The profiles are the members of the OpcUaSecurityProfile enum (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs). The server ships three baseline profiles; the config value is the bare enum-member name (no hyphens, no underscores):

Enum member Security Policy Message Security Mode Description
None None None No signing or encryption. Suitable for development and isolated networks only.
Basic256Sha256Sign Basic256Sha256 Sign Messages are signed but not encrypted. Protects against tampering but data is visible on the wire.
Basic256Sha256SignAndEncrypt Basic256Sha256 SignAndEncrypt Messages are both signed and encrypted. Full protection against tampering and eavesdropping.

BuildSecurityPolicies (OpcUaApplicationHost.cs) maps each configured profile to an SDK ServerSecurityPolicy. The server exposes a separate endpoint per configured profile and clients select the one they prefer at session open. The enum's XML doc notes that Aes128/Aes256 variants can be added later by extending the enum + BuildSecurityPolicies — the wiring is profile-agnostic — but they are not implemented today. There is no SecurityProfileResolver class.

Config value form. The enum binds by member name, so a profile string with hyphens (e.g. Basic256Sha256-Sign) does not bind — use the exact enum-member spelling above. If EnabledSecurityProfiles is empty, the server falls back to a single None endpoint (logged, very visible) so it still has a listening endpoint.

Configuration

Transport security is configured in the OpcUa section of the Host process's bootstrap appsettings.json (bound to OpcUaApplicationHostOptions):

{
  "OpcUa": {
    "ApplicationName": "OtOpcUa",
    "ApplicationUri": "urn:node-a:OtOpcUa",
    "PublicHostname": "0.0.0.0",
    "OpcUaPort": 4840,
    "PkiStoreRoot": "C:/ProgramData/OtOpcUa/pki",
    "AutoAcceptUntrustedClientCertificates": false,
    "EnabledSecurityProfiles": [ "Basic256Sha256Sign", "Basic256Sha256SignAndEncrypt" ]
  }
}

EnabledSecurityProfiles is a list — the server publishes one endpoint per entry. The default (when the key is omitted) is all three baseline profiles (None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt); production deployments typically drop None. The list must contain at least one entry (OpcUaApplicationHostOptionsValidator enforces MinCount(…, 1)).

The server certificate is auto-generated on first start if none exists in PkiStoreRoot/own/. Always generated even for None-only deployments because UserName token encryption depends on it.

PKI directory layout

{PkiStoreRoot}/
  own/        Server's own application certificate and private key
  issuer/     CA certificates that issued trusted client certificates
  trusted/    Explicitly trusted client (peer) certificates
  rejected/   Certificates that were presented but not trusted

Certificate trust flow

When a client connects using a secure profile (Sign or SignAndEncrypt), the following trust evaluation occurs:

  1. The client presents its application certificate during the secure channel handshake.
  2. The server checks whether the certificate exists in the trusted/ store.
  3. If found, the connection proceeds.
  4. If not found and AutoAcceptUntrustedClientCertificates is true, the certificate is automatically copied to trusted/ and the connection proceeds.
  5. If not found and AutoAcceptUntrustedClientCertificates is false, the certificate is copied to rejected/ and the connection is refused.

The Admin UI Certificates.razor page (src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor) lists the contents of each PKI sub-store (own / trusted / issuer / rejected) by reading the OpcUa:PkiStoreRoot path from configuration. FleetAdmin users get per-row actions on the trusted and rejected stores: Trust (move a rejected cert into trusted/), Untrust (move a trusted cert back into rejected/), and Delete (remove a cert from either store). Each action requires an inline confirmation, is re-checked against the FleetAdmin policy server-side, and is honored live — the SDK trust list re-enumerates the directory store on the next handshake, no restart required. (own / issuer remain read-only.) The actions are filesystem moves/deletes performed by CertificateStoreManager, which finds the target by matching the certificate thumbprint — never by building a path from caller input.

Production hardening

  • Set AutoAcceptUntrustedClientCertificates = false.
  • Drop None from EnabledSecurityProfiles.
  • Promote trusted client certs with the Admin UI Certificates page's Trust action (FleetAdmin) — or by moving the .der from rejected/ to trusted/certs/ — rather than relying on the auto-accept fallback.
  • Periodically audit the rejected/ directory; an unexpected entry is often a misconfigured client or a probe attempt.

OPC UA Authentication

The Server accepts three OPC UA identity-token types:

Token Handler Notes
Anonymous No IOpcUaUserAuthenticator call — the SDK admits anonymous sessions at the channel. ⚠️ An anonymous session carries no roles, so it is refused every write and every alarm ack — but it can Browse, Read, Subscribe and HistoryRead the entire address space. There is no per-node ACL check on those operations (see Data-Plane Authorization). Do not expose an Anonymous-admitting endpoint to an untrusted network.
UserName/Password LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync (src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs, implements IOpcUaUserAuthenticator), backed by the app ILdapAuthServiceOtOpcUaLdapAuthService (src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs). LDAP bind + group lookup. The returned LDAP groups are mapped to roles via IGroupRoleMapper<string> (OtOpcUaGroupRoleMapper) and the roles are attached to the OPC UA session identity as a RoleCarryingUserIdentity. The raw group list is discarded at that point and never reaches the data plane.
X.509 Certificate Stack-level acceptance during the secure-channel handshake. The certificate must be trusted (see PKI trust flow). It carries no roles, so a cert-only session is read-everything / write-nothing.

When no authenticator is supplied, OpcUaApplicationHost falls back to NullOpcUaUserAuthenticator; the Host wires the real LdapOpcUaUserAuthenticator as a singleton in Program.cs.

LDAP bind flow (OtOpcUaLdapAuthService)

LDAP is configured under the Security:Ldap section (bound to LdapOptions, src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs, SectionName = "Security:Ldap"). The app authenticator is OtOpcUaLdapAuthService — a thin wrapper around the shared ZB.MOM.WW.Auth.Ldap directory client that adds two app-only concerns the shared library deliberately does not model: the Enabled master switch and DevStubMode. The same ILdapAuthService instance serves both the Admin UI cookie login (/auth/login) and the OPC UA UserName path (via LdapOpcUaUserAuthenticator), so operators use one credential across both planes.

OtOpcUaLdapAuthService.AuthenticateAsync:

  1. If Enabled = false, denies outright — no bind, no DevStub bypass (the master switch wins).
  2. If DevStubMode = true, accepts any non-empty credentials and grants the Administrator role without any network bind (dev only — must be false in production).
  3. Refuses to bind over a plaintext transport (Transport = None) unless AllowInsecure = true (dev/test only). This is enforced at login, not at startup.
  4. Delegates the real path to the shared ZB.MOM.WW.Auth.Ldap client: it binds (search-then-bind via ServiceAccountDn, or direct-bind cn={user},{SearchBase} when no service account is set), verifies the password, and reads the user's group memberships.
  5. Returns an LdapAuthResult carrying the validated username + the groups (never roles). Failure codes are folded into opaque user-facing error strings so a probe cannot distinguish "unknown user" from "wrong password".

Group → role mapping happens downstream, not in the auth service: LdapOpcUaUserAuthenticator resolves IGroupRoleMapper<string> (OtOpcUaGroupRoleMapper) per call and unions its output with any pre-resolved roles (the DevStub Administrator grant). The roles are attached to the OPC UA session identity (RoleCarryingUserIdentity), where the write and alarm-ack gates read them. The LDAP group names themselves are consumed here and go no further — which is one reason the NodeAcl subsystem cannot currently be wired, since it matches on groups rather than roles. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session.

Transport replaces the former UseTls bool: Ldaps (implicit TLS), StartTls (upgrade), or None (plaintext, requires AllowInsecure). Configuration example (Active Directory production):

{
  "Security": {
    "Ldap": {
      "Enabled": true,
      "DevStubMode": false,
      "Server": "dc01.corp.example.com",
      "Port": 636,
      "Transport": "Ldaps",
      "AllowInsecure": false,
      "SearchBase": "DC=corp,DC=example,DC=com",
      "ServiceAccountDn": "CN=OtOpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
      "ServiceAccountPassword": "<from your secret store>",
      "GroupAttribute": "memberOf",
      "DisplayNameAttribute": "cn",
      "UserNameAttribute": "sAMAccountName",
      "GroupToRole": {
        "OPCUA-Designers": "Designer",
        "OPCUA-Admins": "Administrator",
        "OPCUA-Operators": "Operator"
      }
    }
  }
}

GroupToRole maps LDAP group names → roles (case-insensitive); a user gets every role whose source group is in their membership. The values are the canonical control-plane role strings (Viewer / Designer / Administrator, plus the appsettings-only Operator for the DriverOperator policy); the same map also supplies data-plane role strings (ReadOnly, WriteOperate, WriteTune, WriteConfigure, AlarmAck) — see Role grant source (data-plane) below. UserNameAttribute: "sAMAccountName" is the critical AD override — the GLAuth dev default is cn, which is not how AD users are looked up; use userPrincipalName instead if operators log in with user@corp.example.com form. LdapOptionsValidator (src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs) fails startup when Transport = None and AllowInsecure = false on a real-LDAP (non-DevStub) config.

LDAP resilience — timeout, concurrency, outage circuit (arch-review 03/S2)

The OPC UA SDK raises its impersonation (UserName) callback synchronously, inside a SessionManager-wide event lock (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378). A slow or hung LDAP bind inside that callback therefore does not stall one SDK thread — it serializes every session activation server-wide (Anonymous and X509 included). Since the callback is irreducibly synchronous, LdapOpcUaUserAuthenticator bounds it at its own boundary with five Security:Ldap keys (all validated at startup by LdapOptionsValidator when Enabled && !DevStubMode):

Key Default Bounds
ConnectionTimeoutMs 10000 Projected into the shared library's ConnectionTimeoutMs — bounds the socket connect and each per-operation search. First line of defence against an outage.
AuthTimeoutMs 15000 Hard whole-flow wall-clock bound (bind and role-map) enforced at the authenticator boundary; a slow/hung flow denies "Authentication timed out" (fail-closed) and releases the SDK thread. Deliberately > ConnectionTimeoutMs so a healthy-but-slow flow is not cut off.
MaxConcurrentAuthentications 8 Max in-flight authentications; excess denies "Authentication backend busy" (local backpressure — does not feed the circuit). Bounds accumulation of orphaned/timed-out binds during an outage.
OutageFailureThreshold 3 Consecutive system-side failures (boundary timeout, directory-unreachable, or unexpected throw) that open the outage circuit. Any success or user-credential deny resets the streak. 0 disables the circuit.
OutageCooldownSeconds 15 How long the open circuit fast-denies "Authentication backend unavailable" without touching LDAP; after cooldown the next call half-open-probes, and one more system failure re-opens immediately.

Defaults are behaviour-neutral on a healthy directory. All new paths are fail-closed (they deny). A user-credential deny (wrong password) is fast when the directory is up and never trips the circuit; only a directory outage opens it.


Data-Plane Authorization

⚠️ Read this first (verified against source, 2026-07-27). This section used to describe the NodeAcl / PermissionTrie design as if it were the live enforcement path. It is not. The evaluator is built and unit-tested but has zero production call sites, and ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj does not even reference the project it lives in. Everything actually enforced today is in What is enforced today; the trie design is retained below, clearly marked, under Designed but not wired.

What is enforced today

There are exactly three authorization checks in the whole OPC UA server layer. All three are coarse, server-wide role string checks with no per-node component.

Operation Gate Requires
Write (Value attribute, either namespace) OtOpcUaNodeManager.EvaluateEquipmentWriteGate role WriteOperate
Alarm Acknowledge / Confirm / AddComment / Shelve / Unshelve (scripted alarms) OtOpcUaNodeManager.HandleAlarmCommand role AlarmAck
Alarm acknowledge (driver-fed native alarms) OtOpcUaNodeManager.HandleNativeAlarmAck role AlarmAck

Both gates fail closed — a session with no identity or no matching role gets BadUserAccessDenied — and read identity.Roles off the session's RoleCarryingUserIdentity (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs). Role strings are the constants in OpcUaDataPlaneRoles, sourced from Security:Ldap:GroupToRole (see Role grant source).

What has no authorization check at all:

  • Read, Browse, TranslateBrowsePaths — no override, no handler. Any admitted session, including an Anonymous one, sees and reads the entire address space.
  • HistoryRead — all four overrides (HistoryReadRawModified, HistoryReadProcessed, HistoryReadAtTime, HistoryReadEvents) run without an identity check.
  • CreateMonitoredItems / subscriptions / TransferSubscriptions.
  • Non-Value attribute writes — the gate hangs off OnWriteValue.

And what the write gate does not distinguish:

  • WriteTune and WriteConfigure are never checked. They exist in the role vocabulary and in NodePermissions, but every write — whatever the tag's security classification — is gated on the single WriteOperate string.
  • AlarmAcknowledge / AlarmConfirm / AlarmShelve are not distinguished. One AlarmAck role covers all five alarm methods; the operation argument is passed to the router but never consulted by the gate.
  • There is no per-node granularity. A session that may write one tag may write every writable tag on the node.

The gate is realm-aware in one narrow sense: it is attached per-node during materialization for both the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself takes no realm and no node.

Designed but not wired: NodeAcl + PermissionTrie

Everything from here to Role grant source describes a subsystem that exists in the tree, is covered by 30 unit tests, and never executes. It is kept because the design is sound and the remaining work is wiring plus one genuine design gap — not because it runs.

What is real about it:

  • Operators can author NodeAcl rows in the Admin UI (/clusters/{id}/acls), and those rows are validated, versioned and persisted.
  • ConfigComposer does snapshot every NodeAcl row into every deployment artifact, so an ACL edit shifts the artifact's RevisionHash and ships to every node.
  • The node side never deserializes themDeploymentArtifact has no ACL reader. The bytes arrive and are dropped.

So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour to expect until the tracking issue below is closed.

The four things blocking a wire-up (all verified, none of them merely "call the evaluator"):

  1. Raw-realm nodes have no representable scope. NodeHierarchyKind has exactly one member, Equipment. Raw NodeIds are Folder→Driver→Device→TagGroup→Tag RawPaths, which NodeScope cannot express — yet the write gate is attached to raw tags. Roughly half the writable address space has no scope to evaluate. This is a design decision, not wiring.
  2. The identity carries roles, not groups. The trie matches NodeAcl.LdapGroup against the session's LDAP groups, but OpcUaUserAuthResult carries only mapped role strings; the group list is discarded inside LdapOpcUaUserAuthenticator.
  3. PermissionTrieBuilder's scopePaths argument has no producer. Without it every sub-cluster grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have to be derived from the artifact's UNS relations.
  4. No session lifecycle exists. UserAuthorizationState has the freshness/staleness predicates but nothing in production constructs one, stamps a generation, or drives a refresh.

Two smaller inconsistencies inside the subsystem itself: NodeAclScopeKind.FolderSegment is offered in the authoring dropdown but the trie walker has no level to match it at, and NodePermissions.AlarmRead has no OpcUaOperation mapping, so it is grantable but unreachable.

Hierarchy (design)

ACLs are evaluated against the node's scope path. NodeScope (src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs) carries a Kind that selects between two hierarchy shapes:

Equipment (UNS) kind:        Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag
SystemPlatform (Galaxy) kind: Cluster → Namespace → FolderSegment(s) → Tag

On the Galaxy/SystemPlatform path each folder segment takes one trie level, so a deeply-nested Galaxy folder reaches the same depth as a full UNS path. Unset mid-path levels leave the corresponding id null and the evaluator walks only as far as the scope goes.

Each level can carry NodeAcl rows (src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs) that grant a permission bundle to a set of LdapGroups.

Permission flags

NodePermissions (src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs), stored as an int bitmask in NodeAcl.PermissionFlags:

[Flags]
public enum NodePermissions : int
{
    None = 0,

    Browse            = 1 << 0,
    Read              = 1 << 1,
    Subscribe         = 1 << 2,
    HistoryRead       = 1 << 3,
    WriteOperate      = 1 << 4,
    WriteTune         = 1 << 5,
    WriteConfigure    = 1 << 6,
    AlarmRead         = 1 << 7,
    AlarmAcknowledge  = 1 << 8,
    AlarmConfirm      = 1 << 9,
    AlarmShelve       = 1 << 10,
    MethodCall        = 1 << 11,
    HistoryUpdate     = 1 << 12,   // OPC UA annotation / insert / delete (separate from HistoryRead)

    ReadOnly  = Browse | Read | Subscribe | HistoryRead | AlarmRead,
    Operator  = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
    Engineer  = Operator | WriteTune | AlarmShelve,
    Admin     = Engineer | WriteConfigure | MethodCall,
}

The three Write tiers map to Galaxy's v1 SecurityClassificationFreeAccess/OperateWriteOperate, TuneWriteTune, ConfigureWriteConfigure. SecuredWrite / VerifiedWrite / ViewOnly classifications remain read-only from OPC UA regardless of grant.

HistoryUpdate (bit 12) is a separate permission from HistoryRead so a read-only grant cannot also authorize OPC UA HistoryUpdate (annotation / insert / delete) operations. TriePermissionEvaluator maps OpcUaOperation.HistoryUpdate to this bit, closing the read⇒update hole that existed when the only history permission was HistoryRead. Note: HistoryUpdate is not included in any composite bundle (ReadOnly / Operator / Engineer / Admin) because the HistoryUpdate service surface (the actual insert/replace/delete backend RPC) is not yet implemented — a client calling HistoryUpdate still receives the SDK's default reject regardless of the grant.

Evaluator — PermissionTrie

src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/:

Class Role
PermissionTrie Cluster-scoped trie; each node carries (GroupId → NodePermissions) grants.
PermissionTrieBuilder Builds a trie from the current NodeAcl rows in one pass and installs it into the cache.
PermissionTrieCache Process-singleton cache keyed on (ClusterId, GenerationId). Generation-sealed: Install(trie) adds a new generation + advances the "current" pointer; older generations are retained (in-flight requests still resolve) and GC'd by Prune. Invalidate(clusterId) drops every cached trie for a cluster. There is no AclChangeNotifier — a publish installs a new generation rather than signalling an invalidation.
TriePermissionEvaluator Implements IPermissionEvaluator.Authorize(session, operation, scope). Walks the cluster trie for the supplied NodeScope, unions grants along the path, and returns an AuthorizationDecision. Evaluates against the session's bound generation (session.AuthGenerationId), not just "current", so a grant added/removed in a newer generation cannot take effect mid-session.

NodeScope is described above (Equipment-kind vs SystemPlatform-kind). The evaluator unions the matched grants along the path — a tag-level ACL and an area-level ACL both contribute.

Dispatch gate — IPermissionEvaluator (design; no such gate exists)

IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope) (default impl TriePermissionEvaluator at src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs) returns an AuthorizationDecision.

⚠️ The intended dispatch path — "calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call" — was never built. Authorize has zero production call sites. What the dispatch path really does is in What is enforced today.

Intended properties, of which only the last is true today:

  • Driver-agnostic. No driver-level code participates in authorization decisions. Drivers report SecurityClassification as metadata on tag discovery; everything else was to flow through the evaluator.
  • Strictly fail-closed (default-deny). Every guard path returns NotGranted — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. ⚠️ This describes the evaluator's internal behaviour when called. Because nothing calls it, the effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW.
  • Evaluator stays pure. True — TriePermissionEvaluator has no OPC UA stack dependency and is tested directly from xUnit. That purity is also why it was easy to leave unwired.

Full model

See docs/v2/acl-design.md for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). Read it as a design document for unshipped work, not as a description of running behaviour.

Note also that the SystemPlatform hierarchy kind named above was retired with the v3 dual-namespace address space; NodeHierarchyKind now has only Equipment.

Role grant source (data-plane)

Data-plane roles come from Security:Ldap:GroupToRole (appsettings), not from the Config-DB LdapGroupRoleMapping table. That table's Role column is the AdminRole enum (Administrator/Designer/Viewer) and supplies control-plane roles only — it cannot emit the data-plane role strings the OPC UA gates read (ReadOnly, WriteOperate, WriteTune, WriteConfigure, AlarmAck). A deployment therefore must map its data-plane LDAP groups to those role strings via GroupToRole, e.g.:

"GroupToRole": {
  "ot-operators":  "WriteOperate",
  "ot-alarm-ack":  "AlarmAck"
}

⚠️ Only two role strings do anything. OpcUaDataPlaneRoles declares exactly WriteOperate and AlarmAck, and those are the only values any gate compares against. ReadOnly, WriteTune and WriteConfigure appear in the permission vocabulary but no code path reads them — mapping a group to WriteTune grants nothing, and mapping one to ReadOnly restricts nothing (reads are ungated for everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was wrong for three of them.

If this mapping is absent, writes and alarm acknowledgement default-deny: they return BadUserAccessDenied even for users who authenticate successfully. (The same requirement gates both the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) Reads, browses, subscribes and history reads are unaffected — they succeed with or without any GroupToRole entry.

The two live role strings are exact and case-insensitive. In particular the alarm-ack role is AlarmAck, not AlarmAcknowledge (that spelling is the PermissionFlags ACL bit, a different vocabulary); a GroupToRole value of AlarmAcknowledge silently never satisfies the ack gate.


Control-Plane Authorization

Control-plane authorization governs the Admin UI — who can view fleet config, edit drafts, publish generations, manage cluster nodes + credentials.

Per decision #150 control-plane roles are deliberately independent of data-plane ACLs. An operator who can read every OPC UA tag in production may not be allowed to edit cluster config; conversely a Designer may not have any data-plane grants at all.

Roles

The AdminRole enum (src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs) defines three roles. Task 1.7 standardized the member names on the canonical ZB.MOM.WW.Auth CanonicalRole vocabulary (ConfigViewer → Viewer, ConfigEditor → Designer, FleetAdmin → Administrator); a data migration (CanonicalizeAdminRoles) rewrote existing rows. This was a rename, not a permission change.

Role Capabilities
Viewer Read-only access to drafts, generations, audit log, fleet status. (Was ConfigViewer.)
Designer Viewer plus draft authoring (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. (Was ConfigEditor.)
Administrator Designer plus publish, cluster/node CRUD, credential management, role-grant management. Satisfies both the FleetAdmin and DriverOperator authorization policies. (Was FleetAdmin.)

DriverOperator is an authorization policy name (kept stable), not an AdminRole member. It gates Reconnect / Restart commands against live driver instances from the Admin UI DriverStatusPanel and requires the canonical role Operator or Administrator (policy.RequireRole("Operator", "Administrator") in AddAuthorization, src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs). Operator is an appsettings-only string role (not an AdminRole member); map an LDAP group to it via GroupToRole, e.g. "ot-driver-operator": "Operator". The FleetAdmin policy requires the Administrator role.

AdminUI page authorization policies (R2-05)

Every routable AdminUI page carries an explicit named-policy @attribute [Authorize(Policy = …)] — there are no bare [Authorize] (authenticated-only) config pages and no per-page Roles="…" strings. The policy names are constants on AdminUiPolicies (src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs), the single source of truth referenced by pages, AuthorizeView blocks, imperative IAuthorizationService.AuthorizeAsync checks, and endpoint groups (never string literals). All four are registered by AddOtOpcUaAuth:

Policy Satisfied by roles Gates
AdminUiPolicies.ConfigEditor Administrator, Designer The config-authoring surface: /uns, equipment, cluster/node/namespace/ACL editors, all 8 driver pages (which author live retry/breaker ResilienceConfig), Deployments, Scripts, ScriptEdit, and the /api/script-analysis/* endpoint group.
AdminUiPolicies.FleetAdmin Administrator RoleGrants page; the Certificates Trust/Untrust/Delete actions (AuthorizeView + server-side re-check).
AdminUiPolicies.DriverOperator Operator, Administrator Imperative live-ops actions only (Alerts ack/shelve, DriverStatusPanel reconnect/restart, live address pickers).
AdminUiPolicies.AuthenticatedRead any authenticated user The read-only dashboards, lists, and live tails (Home, Fleet, Hosts, Alerts, Certificates, cluster read views, etc.). Semantically identical to the FallbackPolicy; named so read pages carry a non-bare attribute.

Login is the only [AllowAnonymous] page (Admin-001). A reflection guard — PageAuthorizationGuardTests (tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs) — enumerates every routable type in the AdminUI assembly and fails the build if any page carries a bare [Authorize], a Roles="…" idiom, an unknown policy name, or is missing from the classification map. This is the anti-drift anchor (the repo has no bUnit): a new page fails the test until its author consciously classifies it. AdminUiPoliciesTests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs) pins each policy's role semantics.

In v2 the authentication + authorization stack is wired centrally by AddOtOpcUaAuth (src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs), which also installs a FallbackPolicy that requires an authenticated user. Razor pages gate inline with the named-policy attribute above, e.g. @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]. Nav-menu sections hide via <AuthorizeView>.

Role grant source

Admin reads LdapGroupRoleMapping rows from the Config DB (src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs) — the same pattern as the data-plane NodeAcl but scoped to Admin roles + (optionally) one cluster for multi-site fleets (a system-wide row, IsSystemWide = true, stacks additively with cluster-scoped rows). The RoleGrants.razor page lets Administrators edit these mappings without leaving the UI.

Headless deploy API (POST /api/deployments)

For CI / scripts that need to trigger a deployment without driving the Blazor "Deploy current configuration" button, admin-role nodes expose POST /api/deployments (DeployApiEndpoints, src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs). It forwards to the same IAdminOperationsClient.StartDeploymentAsync the button calls.

Auth is a single configured secret checked from the X-Api-Key header in fixed time — deliberately orthogonal to the cookie-only web auth (OPC UA Authentication above) so automation needs no LDAP login round-trip. The endpoint is AllowAnonymous so the FallbackPolicy doesn't 401 it, and enforces the key itself. It self-disables (503) until Security:DeployApiKey is set, so it is never open by default.

curl -X POST https://<admin-host>/api/deployments \
     -H 'X-Api-Key: <Security:DeployApiKey>' \
     -H 'Content-Type: application/json' \
     -d '{"createdBy":"ci-bot"}'

Responses: 202 Accepted ({ outcome, deploymentId, revisionHash }) when a deployment was sealed, 200 for NoChanges, 409 when another deployment is in flight, 422 when rejected, 401 for a missing/wrong key, 503 when unconfigured. Set the secret via Security:DeployApiKey (env Security__DeployApiKey) on admin nodes only; treat it like any deploy credential (rotate, keep out of source).


OTOPCUA0001 Analyzer — Compile-Time Guard

Per-capability resilience (retry, timeout, circuit-breaker) is applied by CapabilityInvoker in src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/. A driver-capability call made outside the invoker bypasses resilience entirely — which in production looks like inconsistent timeouts, un-wrapped retries, and unbounded blocking.

OTOPCUA0001 (Roslyn analyzer at src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs) fires with category OtOpcUa.Resilience and default severity Warning (per AnalyzerReleases.Shipped.md) when a method on one of the seven guarded capability interfaces (IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider — all in ZB.MOM.WW.OtOpcUa.Core.Abstractions) is invoked outside a lambda passed to CapabilityInvoker.ExecuteAsync / ExecuteWriteAsync. AlarmSurfaceInvoker is not a wrapper home — its own implementation is covered transitively because it routes through the inner CapabilityInvoker.ExecuteAsync. The analyzer walks up the syntax tree from the call site, finds any enclosing invoker invocation, and verifies the call lives transitively inside that invocation's anonymous-function argument — a sibling pattern (do the call, then invoke ExecuteAsync on something unrelated nearby) does not satisfy the rule.

The xunit.v3 + Shouldly suite at tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs covers the common fail/pass shapes + the sibling-pattern regression guard.

The rule is intentionally scoped to async surfaces — pure in-memory accessors like IHostConnectivityProbe.GetHostStatuses() return synchronously and do not require the invoker wrap.


Write-Outcome Self-Correction

When an OPC UA client writes a value and the driver reports a failure, the server automatically reverts the variable node to its prior value and surfaces the Bad-quality status code to the client. This prevents the node from showing a phantom Good value after a device-level write error. The revert is local to the node that received the write — no cluster coordination is required.

Additionally, an AuditWriteUpdateEvent is emitted on every write attempt (success or failure), consistent with OPC UA Part 4 audit requirements. The event carries the source node id, the requested value, and the final status code so audit log consumers can trace write outcomes without polling the node.

(Implemented in master 1d797c1c.)


Audit Logging

  • Server: authentication, certificate-validation, write, and write-denial events are logged through the regular Serilog rolling file sink; write outcomes also emit an AuditWriteUpdateEvent (see Write-Outcome Self-Correction).
  • Admin: AuditWriterActor (src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs) writes ConfigAuditLog rows (src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs) to the Config DB for publish, rollback, cluster-node CRUD, and credential rotation. Visible on the cluster Audit page (ClusterAudit.razor) for operators with Viewer or above.

Config secrets (${secret:} delivery)

OtOpcUa never commits secret values to appsettings*.json. Every real secret is supplied at deploy time — either as a plain environment variable, or as a ${secret:<name>} token backed by the shared ZB.MOM.WW.Secrets encrypted store.

A pre-host expander (SecretReferenceExpander.ExpandConfigurationAsync, wired in OtOpcUa.Host/Program.cs) walks the assembled configuration and rewrites every ${secret:<name>} token into its resolved plaintext before the host is built — so every downstream binder/validator (AddZbSerilog, AddOtOpcUaConfigDb, the first ValidateOnStart) sees resolved values. The store's key-encryption key (KEK) comes from the ZB_SECRETS_MASTER_KEY environment variable (Secrets:MasterKey:Source=Environment); the encrypted SQLite store lives at Secrets:SqlitePath.

The expander is fail-closed and section-agnostic: a ${secret:<name>} token whose secret is absent throws SecretNotFoundException at startup, regardless of which feature owns the key or whether that feature is enabled. Keys prefixed with _ (comment keys) are skipped, so a ${secret:...} example inside a _…Comment value is never resolved.

The five config secrets and their canonical secret names:

Config key Owner Secret name
Security:Jwt:SigningKey JwtOptions otopcua/jwt/signing-key
Security:Ldap:ServiceAccountPassword LdapOptions otopcua/ldap/service-account-password
Security:DeployApiKey DeployApiEndpoints otopcua/deploy/api-key
ConnectionStrings:ConfigDb AddOtOpcUaConfigDb otopcua/sql/configdb-connstr
ServerHistorian:ApiKey ServerHistorianOptions otopcua/historian/api-key

Delivery. By default these are delivered as plain environment variables (e.g. ServerHistorian__ApiKey=histgw_…), never committed. To deliver one from the encrypted store instead, seed it once with the secret CLI (from ZB.MOM.WW.Secrets), then supply the token in place of the literal — via env var or a deployment appsettings overlay:

secret set otopcua/historian/api-key <value>       # seed the encrypted store first
ServerHistorian__ApiKey='${secret:otopcua/historian/api-key}'

Only switch a value to a ${secret:} token after the secret is seeded — an unseeded token fails the boot fail-closed. Do not commit the KEK (ZB_SECRETS_MASTER_KEY) or any secret value.

For the production posture needed so every clustered node (admin, driver, and any fused role) resolves the same secrets from the same store, see docs/operations/2026-07-16-secrets-clustered-master-key.md.


Troubleshooting

Certificate trust failure

Check {PkiStoreRoot}/rejected/ for the client's cert. As a FleetAdmin, use the Admin UI Certificates page's Trust action to move it into trusted/ (or copy the .der to trusted/certs/ by hand); the SDK trust list reloads on the next handshake.

LDAP users can connect but fail authorization

Verify (a) Security:Ldap:GroupAttribute (default memberOf) returns the user's groups, (b) Security:Ldap:GroupToRole maps those groups to the expected roles, and (c) a NodeAcl grant exists at some level of the node's scope path that unions to the required permission. The data-plane evaluator is strictly default-deny — there is no fail-open mode to fall back on.

LDAP bind rejected as "insecure"

Set Security:Ldap:Transport = "Ldaps" (or "StartTls") with the matching port (636 for AD Ldaps), or temporarily set Security:Ldap:AllowInsecure = true in dev. Production Active Directory increasingly refuses plain-LDAP bind under LDAP-signing enforcement.

Stale ACL trie after a publish

A publish installs a new generation into PermissionTrieCache via PermissionTrieBuilder rather than signalling an invalidation; the evaluator binds each session to a generation. If grants appear stale, confirm the new generation was installed (publish completed) and that sessions re-resolved their auth state — a session past its staleness ceiling fails closed and must re-authenticate. As a last resort PermissionTrieCache.Invalidate(clusterId) drops a cluster's cached tries.