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
This commit is contained in:
Joseph Doherty
2026-07-31 12:12:35 -04:00
parent 112f62e5aa
commit 804c32e5c0
@@ -0,0 +1,147 @@
# 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.