`Security:Acl:Enabled` (default false) makes scoped `NodeAcl` grants decide data-plane authorization at the three seams that have one, and gives HistoryRead a check it never had. Read, Browse and Subscribe stay ungated — stage 6. The ACL decision REPLACES the server-wide role check rather than chaining with it. Chained, a grant would have to confer the role as well as pass the scope check, and only Cluster-scoped grants confer a role — so every narrow grant would have stayed inert and the flag would have looked inert too. The load-bearing test is an ALLOW: a session carrying no roles at all 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. Found en route: the principal never reached the session. The authenticator passed it as OpcUaUserAuthResult.DisplayName, but RoleCarryingUserIdentity derives DisplayName from the presented token — for a key-bearing client that is the static username `apikey`, identical fleet-wide. So the trie's matching key was dropped at the identity boundary, and the audit ClientUserId on a rejected write recorded `apikey` for every consuming system. Principal and ApiKeyId are now carried explicitly. Beyond the stage as sketched: - Revocation reaches an OPEN session. Every decision re-checks the authenticating key against the current snapshot; without it a key revoked by a deployment kept its authority until the client chose to reconnect, which for a machine-to-machine consumer can be indefinitely. - UserAuthorizationState is built per CALL, not per session. The per-session shape exists so an LDAP session can re-resolve membership that changed out of band; nothing can change out of band under API keys, so the generation stamp is the whole invalidation signal and a per-call record picks it up for free. IsStale/NeedsRefresh therefore cannot fire here and are documented as vestigial. - The trie is built inside DataPlaneAuthorityState.Apply, under the same generation as the snapshot, rather than by a second wiring at the publish actor. An uninstalled trie denies everything — indistinguishable from working enforcement. Deleting the install turns 13 of the 22 new tests red. - The deploy log reports what the BUILD dropped instead of re-deriving it. The old count agreed with the builder on one of its four drop reasons; a rule with a blank ScopeId, a retired scope kind, or a path not starting at a realm vanished with the log reading zero. Both IApiKeyStore implementations widened to the full grant set. The narrowing was redundant — ApiKeyInterimRoleMapper applies the same filter itself — and became a trap once scope was enforced. Pinned by asserting the roles are unchanged, with the two grants carrying different flags so it cannot pass by accident. Falsified by mutation: enforcement never engaging, the write seam ignoring the ACL, the realm dropped from the scope, the revocation check removed, and the trie never installed each turn the intended tests red and no others. Full solution suite green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
21 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. ⚠️ This cell was wrong about the shape — it described the path as the five-tuple Folder/Driver/Device/TagGroup/Tag, but folders and tag groups both self-nest, so the real path is folder… / Driver / Device / group… / Tag with unbounded depth on two axes. The fix was therefore not an enum member but a variable-depth NodeScope + segment-loop walk. FolderSegment was not repurposed as the raw-folder level; it is retired, and RawFolder/RawDriver/RawDevice/RawTagGroup were appended instead. Correctly identified as the one genuine design item. |
| 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 |
Done in stage 3 — AclScopePathResolver, built node-side from the artifact's raw + UNS topology (no DB read, so a driver-only node can build it). The coverage note was right in substance but imprecise: six tests did pass a scopePaths dictionary, but every one hand-wrote it in the test body with exactly the segments the walker expected, so nothing pinned a producer — and there was none to pin. That gap is closed by an artifact → parse → build → walk test. |
| 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 — settled in stage 3, but not the way this line assumed. It is a retired v2 concept (it described theSystemPlatform/Galaxy hierarchy v3 deleted), so rather than being given a level it is now explicitly unresolvable, withRawFolderas its v3 replacement. It survives in the enum only because the artifact stores the ordinal, so removing it would renumber every later member in every deployed artifact. The dropdown labels it retired, and the deploy gate names it. ⚠️ The old claim that the walker could not match it was slightly overstated:Buildrouted it throughDescendlike any other kind, so given a path it would have matched and been reported under whatever level it happened to land on. It was unlabellable, not unplaceable.NodePermissions.AlarmReadhas noOpcUaOperationmapping — grantable but unreachable.NodeAcl.ScopeIdhas no FK and no existence check, so scope ids dangle silently.— stale; stage 1 added the reference. The line read as an open item long after it was closed.ZB.MOM.WW.OtOpcUa.OpcUaServer.csprojdoes not referenceCoreat allNodePermissions.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. ✅ Done.
NodeHierarchyKind.Raw, a variable-depth scope model, thescopePathsproducer (AclScopePathResolver) fed from the artifact, a deploy-time gate for ACL scopes, and the first coverage of the production hierarchy path.It was substantially larger than this line originally implied, because the sketch had the shape of the raw tree wrong. The plan said the Raw path is the five-tuple
Folder/Driver/Device/TagGroup/Tag. It is not:RawFolder.ParentRawFolderIdandTagGroup.ParentTagGroupIdare both self-referencing, so folders and tag groups each nest without bound and a real RawPath isfolder… / Driver / Device / group… / Tag.NodeScopewas fixed-arity (five nullable level fields) andPermissionTrie.CollectMatcheswas five hardcoded steps, so the scope model could not address a real raw tree at all.NodeScopeis now{ClusterId, Kind, IReadOnlyList<string> Segments}and the walk is a segment loop. Good news found en route: the trie structure was already variable-depth (NodeAclPath.Segmentsis a list;PermissionTrieNode.Childrenis a recursive dictionary), so only the walker and the scope record had to change.Four decisions worth keeping:
- The namespace level became the realm. Level 1 of the walk gated on
NodeScope.NamespaceId, but v3 has noNamespaceentity — the snapshot was retired when the address space went dual-namespace, and the identifier appeared nowhere in production outsideNodeScope. So level 1 could only ever fail to match, which silently made every sub-cluster grant unreachable while cluster grants kept working. It is now the realm token (raw/uns), derived from the scope'sKindbyNodeAclRealmsrather than carried on it, so the walk and the path producer cannot disagree about its spelling.NodeAclScopeKind.Namespacebecomes meaningful again as "a whole realm". - Provenance comes from the granting row's
ScopeKind, recorded onTrieGrantat build time, not from the depth the grant sits at. Depth-derived provenance could not name a raw level at all (the depth of "Device" varies with folder nesting) and misreported any path whose length disagreed with its kind. - The trie comparer moved from
OrdinalIgnoreCasetoOrdinal. Raw sibling-name uniqueness is ordinal, soSpeedandspeedcan both exist under one device and a grant on either would have matched both — a silent widening. Case-sensitive is also the fail-closed direction. - The null-
scopePaths"deterministic test mode" is deleted. It planted unresolvable rows at a direct child of the trie root, a position the walker cannot reach — so the grant looked placed and could never match, and every test using that mode proved nothing about production placement. An unresolvable row is now dropped with a diagnostic, andscopePathsis required.
A scope resolves to a list of paths, not one: a raw tag projected into N equipments is reachable at N+1 NodeIds and a write through any of them lands on the same driver reference, so a Tag-scoped grant that covered only the raw node would be bypassable through a UNS one. The lookup is keyed on
(ScopeKind, ScopeId)rather thanScopeIdalone, becauseScopeIdhas no FK and no type discriminator — ids are prefixed only by convention, and an id collision across entity types would hand a grant the wrong node's paths.Also closed here:
NodeAclrows had no deploy-time validation of any kind and were not even reachable fromDraftValidator(DraftSnapshotcarried noNodeAclscollection). They are now gated for dangling ids, cross-cluster scopes, blank/contradictoryScopeId, the retiredFolderSegmentkind, and undefined ordinals. That gate is what substitutes for the ACL scope picker until stage 7 —ScopeIdis still a free-text box. - The namespace level became the realm. Level 1 of the walk gated on
-
Artifact + lifecycle. ✅ Done.
ConfigComposersnapshotsApiKeysalongside theNodeAclsit already carried;DeploymentArtifact.ParseDataPlaneAuthoritydecodes both node-side (grants scoped to the node's own cluster, keys not — a key is a fleet-wide credential and the ACL rows are what bind it);OpcUaPublishActorinstalls them onDataPlaneAuthorityStateon every apply;ArtifactApiKeyStoreserves them to session activation on a driver-only node, which until now denied every UserName token; andOtOpcUaNodeManagergains aDataPlaneAuthorityproperty, which is the cluster identity blocker #4 named.Two things went differently from the sketch, both deliberately:
- The generation is a counter, not the hash. "Stamp the generation from the
RevisionHash" reads as "derive a long from the hash bytes", and that is wrong:PermissionTrieCachetreats the highestGenerationIdas current (ClusterEntry.WithAdditional) andPrunekeeps the highest, while hash fragments are unordered — so roughly half of all deploys would install a lower generation and the cache would keep serving the previous deployment's ACLs, silently. The hash still drives the stamp (a new hash advances it, a re-applied one does not); it just does not become it. - Sessions are not stamped yet. Building a
UserAuthorizationStateper session belongs with the first seam that reads one, in stage 5. Constructing one now would be a per-session object nothing consults — the dormant-wiring failure mode this repo has been bitten by before.
Also settled here: the feed runs before
OpcUaPublishActor'splan.IsEmptyearly-return. An artifact that only revokes a key produces an empty address-space plan, so feeding after that return would leave the revoked key working until some unrelated structural change happened to redeploy. - The generation is a counter, not the hash. "Stamp the generation from the
-
Enforce write + alarms + HistoryRead. ✅ Done.
AclOptions(Security:Acl:Enabled, default false);DataPlaneAuthorizerprojects a NodeId + realm onto aNodeScopeand evaluates it through the existingTriePermissionEvaluator; the trie is built and installed insideDataPlaneAuthorityState.Apply; bothIApiKeyStoreimplementations widened to the full grant set.Five things went differently from this line, all deliberate:
- The role check is REPLACED, not supplemented. Running both in series would require a grant 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 have stayed inert and the flag would have looked inert too. Narrow grants working is the point of the stage. Pinned by a test that writes successfully from a session carrying no roles at all; the deny-side test alone passes against a build where the ACL is never consulted, because the role gate denies too. UserAuthorizationStateis built per CALL, not per session. The per-session shape was LDAP-inherited: it exists so a long-lived session can re-resolve group membership that changed out of band. Nothing can change out of band under API keys, so the generation stamp is the whole invalidation signal and a per-call record picks it up for free — where a cached one would need explicit invalidation to do the same job.IsStale/NeedsRefreshtherefore cannot fire here and are documented as vestigial rather than left looking load-bearing. Stage 6 is where caching earns its keep, and it wants a per-(session, node)decision cache, not this type.- Revocation now reaches an OPEN session, which the sketch did not ask for. Every decision re-checks the authenticating key against the current snapshot. Without it a key revoked by a deployment kept its open session's authority until the client chose to reconnect — indefinitely, for a machine-to-machine consumer — which would have made the documented "revocation requires a deploy" sharp edge considerably sharper than documented.
- The trie is built inside
DataPlaneAuthorityState.Apply, not by a separate install step at the publish actor. As two wirings, the trie install is the one that silently does not run, and an absent trie denies everything — indistinguishable from working enforcement until someone checks that an ALLOW is possible. Falsified: deleting the install turns 13 of the 22 stage-5 tests red. - The deploy log now reports what the BUILD dropped instead of re-deriving it. The previous
unresolvedScopescount agreed with the builder on one of its four drop reasons; a rule with a blankScopeId, a retired scope kind, or a scope path not starting at a realm vanished with the log reading zero.
Also found and fixed en route: the principal never reached the session. The authenticator passed it as
OpcUaUserAuthResult.DisplayName, butRoleCarryingUserIdentityderivesDisplayNamefrom the presented token — which for every key-bearing client is the static usernameapikey. So the trie's matching key was being dropped at the identity boundary, and the auditClientUserIdon a rejected write recordedapikeyfor every consuming system.PrincipalandApiKeyIdare now carried explicitly. - The role check is REPLACED, not supplemented. Running both in series would require a grant to
confer the server-wide role as well as pass the scope check — and only
-
Enforce read/browse/subscribe — the new seam, with per-session decision caching.
-
AdminUI. Key management (create/revoke, show-once) ✅ done early, in stage 4's predecessor; 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.
Stage 4 live gate — PASSED (docker-dev, 2026-07-31)
Run against the six-node docker-dev rig, with site-a-1 as the subject: Cluster:Roles = driver,cluster-SITE-A, no ConnectionStrings:ConfigDb, ConfigSource:Mode=FetchAndCache. Before this
stage that node registered NullOpcUaUserAuthenticator and denied every UserName token.
The key was minted with an independent Python HMAC rather than by the C# minter, so the verifier was checked against something other than itself.
| # | Leg | Result |
|---|---|---|
| 1 | Boot from LocalDb cache installs an authority generation | generation 1 installed (cluster=SITE-A, revision=3f4a908c, keys=0, grants=0) — cluster identity resolved with no ConfigDb |
| 2 | Deploy a key + a SITE-A cluster grant | generation 2 installed (revision=cb165478, keys=1, grants=1); revision matches the deploy response |
| 3 | The key authenticates on the driver-only node | ApiKeyAuth: granted key Stage4 Live Gate (a1b2c3d4e5f60011) as principal stage4-gate with roles [WriteOperate] |
| 4 | A wrong secret is denied there | denied — secret mismatch for key id a1b2c3d4e5f60011 |
| 5 | Revocation asymmetry, both halves | Enabled=0 in SQL, no deploy: central-1 denies on the next request (no enabled, unexpired key…) while site-a-1 still accepts. After deploying: generation 3, and site-a-1 denies. This is the documented sharp edge, observed rather than asserted. |
| 6 | Grants do not leak across clusters | site-b-1 installed keys=1, grants=0 from the same artifact — it received the key but not SITE-A's grant |
Rig left clean (0 keys, 0 ACL rows, redeployed, all six nodes healthy).
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.