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
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-keyin mxaccessgw):otua_<keyId>_<secret>. ThekeyIdprefix 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
NodeAclrows 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.FolderSegmentis offered in the authoring dropdown but the trie walker has no level to match it at — fixed by blocker #1'sRawkind.NodePermissions.AlarmReadhas noOpcUaOperationmapping — grantable but unreachable.NodeAcl.ScopeIdhas no FK and no existence check, so scope ids dangle silently.ZB.MOM.WW.OtOpcUa.OpcUaServer.csprojdoes not referenceCoreat all — the evaluator is not even on the node manager's compile-time reference graph.NodePermissions.HistoryUpdateis deliberately in no bundle until HistoryUpdate exists. Leave it.
Stages
Each stage is one commit, builds clean, and leaves the tree shippable.
- Foundation — principal model.
NodeAcl.LdapGroup→Principal(+ migration);ApiKeyentity- store + hashing;
OpcUaServer.csprojreferencesCore. No behaviour change.
- store + hashing;
- Identity cutover.
ApiKeyOpcUaUserAuthenticatorreplacesLdapOpcUaUserAuthenticatoron the OPC UA data plane; Anonymous resolves to theanonymousprincipal or is rejected per setting. AdminUI LDAP untouched. - Raw scope.
NodeHierarchyKind.Raw+ RawPath scope paths;scopePathsproducer built from the artifact; coverage for the production hierarchy path that today has none. - Artifact + lifecycle. Node-side ACL/key parsing out of the artifact; cluster identity on the
node manager;
AuthGenerationIdfromRevisionHash. - Enforce write + alarms + HistoryRead behind
Security:Acl:Enabled, using the seams that already exist. - Enforce read/browse/subscribe — the new seam, with per-session decision caching.
- 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.
- Rollout. Docs, the ScadaBridge key hand-off, and flipping
Security:Acl:Enabledon docker-dev as the live gate.
Live gate (stage 8)
On docker-dev, with Security:Acl:Enabled=true:
- A key granted
ReadOnlyon one equipment reads its tags and is denied a tag outside the grant. - The same key is denied a write; a key granted
Operatorwrites successfully. - Anonymous with the setting off cannot activate a session; with it on and a
ReadOnlygrant, it reads and is denied writes. - Revoking a key denies the next request after a deploy — see the caveat below.
- 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.