diff --git a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md new file mode 100644 index 00000000..d37daf25 --- /dev/null +++ b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md @@ -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 = (no credential) + password = + | | + 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__` in HistorianGateway, + `apikey create-key` in mxaccessgw): `otua__`. 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.