Files
lmxopcua/docs/plans/2026-07-31-apikey-acl-enforcement-design.md
T
Joseph Doherty 804c32e5c0 docs(acl): design for API-key identity + node ACL enforcement (#520)
Owner decisions 2026-07-31: the OPC UA data plane is machine-to-machine, so
identity is an API key with a static username rather than an LDAP user; LDAP is
replaced entirely on the data plane (AdminUI login keeps its own); and Anonymous
becomes a normal grantable principal behind an enable setting rather than a
special case in the evaluator.

Re-verified all six of #520's claims on master first — they still hold.

The identity decision dissolves blocker #2 (session carries roles, not groups)
rather than solving it. Blocker #1 (raw-realm nodes have no representable
NodeScope) turns out to be tractable: NodeAclScopeKind already carries Namespace
and FolderSegment, so adding NodeHierarchyKind.Raw over the RawPath segments
makes FolderSegment matchable for the first time.

Seam survey corrects #520 in both directions: HistoryRead already has four
overrides to hang a check on, but Read/Browse/Subscribe genuinely have none and
sit on the hot publish path, so decisions must be cached per session+node.

Records two things the issue did not: enforcement ships behind a default-off
Security:Acl:Enabled dark switch (otherwise the first deploy silently denies
ScadaBridge, which consumes this address space from another repo), and because
Phase 4 leaves driver-only nodes with no ConfigDb, keys ride the deployment
artifact — so key revocation requires a deploy.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 12:12:35 -04:00

9.4 KiB

API-key identity + node ACL enforcement (Gitea #520)

Status: design agreed 2026-07-31, not yet implemented. Supersedes: docs/v2/acl-design.md (LDAP-group-keyed model, never wired).

Why this exists

IPermissionEvaluator + TriePermissionEvaluator + PermissionTrieCache + PermissionTrieBuilder have lived in src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ with 30 passing unit tests and zero production call sites since v2. Operators can author NodeAcl rows at /clusters/{id}/acls; ConfigComposer snapshots every row into every deployment artifact, so an ACL edit shifts the artifact RevisionHash and ships fleet-wide — and DeploymentArtifact never deserializes them. The bytes arrive and are dropped.

Actual enforcement today is two server-wide role checks: WriteOperate on Value writes (either realm) and AlarmAck on the five Part 9 alarm methods. Read, Browse, HistoryRead and CreateMonitoredItems have no authorization check at all, so any admitted session — including Anonymous — reads the entire address space.

Re-verified on master 2026-07-31 (all six claims still true). Both AdminUI warning banners and the corrected docs from 2026-07-27 are in place, so the trap is signposted but still a trap.

Decisions taken (owner, 2026-07-31)

# Decision Consequence
D1 The OPC UA data plane is machine-to-machine. Identity is an API key presented with a static username, not an LDAP user. NodeAcl keys on a principal, not an LDAP group — blocker #2 dissolves rather than being solved.
D2 Replace LDAP entirely on the OPC UA data plane. LdapOpcUaUserAuthenticator and the OPC UA half of Security:Ldap are deleted. AdminUI login keeps its own LDAP — untouched.
D3 Anonymous is a normal, grantable principal, gated by a new enable setting. When enabled, Anonymous resolves to the reserved principal anonymous and can be granted read or write like any key. When disabled, anonymous session activation is rejected.

D3 is the neat one: it removes Anonymous as a special case in the evaluator. There is no "anonymous bypass" branch to get wrong — an ungranted anonymous session is denied by the same fail-closed path as an ungranted key.

Assumption I am making explicit (not asked)

Enforcement ships behind Security:Acl:Enabled, default false. This is the dark-switch discipline MeshTransport / ConfigSource / Telemetry all shipped under: both sides are wired in both modes, the flip is a config change rather than a redeploy. Without it, the first deploy carrying this code would silently start denying every client that has no key — including ScadaBridge's Data Connection Layer, which consumes this address space from another repo. The authenticator swap (D2) is not dark-switchable in the same way and is a hard cutover; that asymmetry is deliberate and called out in the rollout stage.

Identity model

OPC UA UserName token            Anonymous token
  username = <static>              (no credential)
  password = <api key secret>
        |                               |
        v                               v
  ApiKeyOpcUaUserAuthenticator    Security:Acl:AllowAnonymous ?
        |                               |
        v                               v
     principal set  <-------------  "anonymous"
        |
        v
  PermissionTrie (per cluster, per artifact revision)
  • Key format mirrors the family convention (histgw_<id>_<secret> in HistorianGateway, apikey create-key in mxaccessgw): otua_<keyId>_<secret>. The keyId prefix makes lookup an index hit rather than a scan over every stored hash.
  • Storage is a hash, never the secret — HMAC-SHA256 under a server pepper, compared with CryptographicOperations.FixedTimeEquals. The plaintext is shown once at creation in AdminUI and is unrecoverable afterwards.
  • A key maps to one or more named principals, and NodeAcl rows key on the principal name. The indirection is deliberate: many keys (one per consuming system, independently revocable) share one grant set, and rotating a key never touches an ACL row.

NodeAcl.LdapGroup → NodeAcl.Principal

A rename + migration, not a new column. The trie walker is unchanged — it already matches an opaque string; only what produces that string changes.

The four blockers, re-scoped

# #520's blocker Resolution under this design
1 Raw-realm nodes have no representable NodeScope (NodeHierarchyKind has one member, Equipment) Add NodeHierarchyKind.Raw, whose path is the RawPath segments Folder/Driver/Device/TagGroup/Tag. NodeAclScopeKind already carries Namespace and FolderSegment — FolderSegment becomes matchable for the first time, at raw-folder level. This is the one genuine design item, not wiring.
2 Session carries roles, not LDAP groups Dissolved by D1 — the principal comes from the key, not from a directory.
3 PermissionTrieBuilder's scopePaths has no producer Built node-side from the artifact's UNS hierarchy + raw tree. Note every existing evaluator unit test runs in the no-scopePaths "deterministic test mode", so the production hierarchy path is currently untested — it needs its own coverage, not just a producer.
4 No session lifecycle / AuthGenerationId Stamp the generation from the artifact RevisionHash at session activation; a new deploy invalidates cached decisions. UserAuthorizationState already has the freshness predicates.

Plus, from the same audit: the node never parses ACLs out of the artifact, and OtOpcUaNodeManager has no cluster identity (NodeScope.ClusterId is required and the evaluator fails closed on mismatch).

Enforcement seams

Operation Seam today Work
Write (Value) EvaluateEquipmentWriteGate — pure, fail-closed, unit-testable Swap the role check for an ACL decision. Runs under the node-manager Lock, so the check must stay non-blocking — the evaluator is pure, which fits.
Alarm ack/confirm/shelve two AlarmAck role gates Same swap.
HistoryRead HistoryReadRawModified / Processed / AtTime / Events overrides already exist Add the check inside them.
Read / Browse / Subscribe none The real gap. Needs a Read override or per-variable OnSimpleReadValue, on the hot publish path — so the decision must be cached per (session, node), not evaluated per value.

Smaller inconsistencies to settle en route

  • NodeAclScopeKind.FolderSegment is offered in the authoring dropdown but the trie walker has no level to match it at — fixed by blocker #1's Raw kind.
  • NodePermissions.AlarmRead has no OpcUaOperation mapping — grantable but unreachable.
  • NodeAcl.ScopeId has no FK and no existence check, so scope ids dangle silently.
  • ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj does not reference Core at all — the evaluator is not even on the node manager's compile-time reference graph.
  • NodePermissions.HistoryUpdate is deliberately in no bundle until HistoryUpdate exists. Leave it.

Stages

Each stage is one commit, builds clean, and leaves the tree shippable.

  1. Foundation — principal model. NodeAcl.LdapGroup → Principal (+ migration); ApiKey entity
    • store + hashing; OpcUaServer.csproj references Core. No behaviour change.
  2. Identity cutover. ApiKeyOpcUaUserAuthenticator replaces LdapOpcUaUserAuthenticator on the OPC UA data plane; Anonymous resolves to the anonymous principal or is rejected per setting. AdminUI LDAP untouched.
  3. Raw scope. NodeHierarchyKind.Raw + RawPath scope paths; scopePaths producer built from the artifact; coverage for the production hierarchy path that today has none.
  4. Artifact + lifecycle. Node-side ACL/key parsing out of the artifact; cluster identity on the node manager; AuthGenerationId from RevisionHash.
  5. Enforce write + alarms + HistoryRead behind Security:Acl:Enabled, using the seams that already exist.
  6. Enforce read/browse/subscribe — the new seam, with per-session decision caching.
  7. AdminUI. Key management (create/revoke, show-once), ACL authoring against principals, and remove the two "not enforced" warning banners — which is the honest signal that this is done.
  8. Rollout. Docs, the ScadaBridge key hand-off, and flipping Security:Acl:Enabled on docker-dev as the live gate.

Live gate (stage 8)

On docker-dev, with Security:Acl:Enabled=true:

  1. A key granted ReadOnly on one equipment reads its tags and is denied a tag outside the grant.
  2. The same key is denied a write; a key granted Operator writes successfully.
  3. Anonymous with the setting off cannot activate a session; with it on and a ReadOnly grant, it reads and is denied writes.
  4. Revoking a key denies the next request after a deploy — see the caveat below.
  5. A raw-realm grant (NodeHierarchyKind.Raw) and a UNS grant both evaluate, proving blocker #1.

Known consequence to design around

Driver-only nodes have no ConfigDb (Phase 4). Keys and ACLs must therefore reach them inside the deployment artifact, exactly as NodeAcl rows already do. That means key revocation requires a deploy — there is no out-of-band revocation channel to a site node. This is a real operational sharp edge and must be documented rather than discovered; if immediate revocation is needed later, the Phase 5 gRPC telemetry stream is the existing node-reachable channel to carry it.