feat(acl): keys + ACLs reach driver-only nodes via the artifact (#520 stage 4)

A driver-only node has had no ConfigDb since per-cluster mesh Phase 4, so it
registered NullOpcUaUserAuthenticator and denied every UserName token. Its keys
now ride the deployment artifact instead.

- ConfigComposer snapshots ApiKeys alongside the NodeAcls it already carried.
  Only the peppered HMAC ships, never a secret, and the pepper is node config
  that is deliberately not in the artifact.
- DeploymentArtifact.ParseDataPlaneAuthority decodes both node-side. Grants are
  scoped to the node's own cluster; keys are not, because ApiKey carries no
  ClusterId — a key is a fleet-wide credential and the ACL rows are what bind it.
- OpcUaPublishActor installs them on DataPlaneAuthorityState on every apply,
  BEFORE its plan.IsEmpty early-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 an unrelated structural change redeployed.
- ArtifactApiKeyStore serves them to session activation; a node with a ConfigDb
  keeps ConfigDbApiKeyStore, whose per-request read is what makes revocation
  immediate there.
- OtOpcUaNodeManager gains DataPlaneAuthority — the cluster identity blocker #4
  named. NodeScope.ClusterId is required and the evaluator fails closed on a
  mismatch, so until now no scope it built could have evaluated. Nothing reads it
  until stages 5/6.

The generation is a node-local COUNTER driven by the RevisionHash, not derived
from its bits. PermissionTrieCache treats the highest GenerationId as current, so
a generation taken from hash bytes would install a lower number about half the
time and the cache would go on serving the previous deployment's ACLs, silently.

Sessions are not stamped with it yet — that belongs with the first seam that
reads a UserAuthorizationState, in stage 5, rather than being built now and
consulted by nothing.

Live-gated on docker-dev against site-a-1 (driver-only, no ConfigDb): key
authenticates, wrong secret denied, both halves of the revocation asymmetry
observed, and a SITE-A grant confirmed not to reach site-b-1. Key minted with an
independent Python HMAC so the verifier was checked against something other than
itself. Rig left clean.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 14:37:18 -04:00
parent c1771dc271
commit a438ac1640
21 changed files with 1505 additions and 48 deletions
@@ -110,23 +110,68 @@ mismatch).
Each stage is one commit, builds clean, and leaves the tree shippable.
1. **Foundation — principal model.** `NodeAcl.LdapGroup` → `Principal` (+ migration); `ApiKey` entity
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
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
3. **Raw scope.** *(not started — sequenced after stage 4 at the owner's request)* `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`.
4. **Artifact + lifecycle.** ✅ **Done.** `ConfigComposer` snapshots `ApiKeys` alongside the `NodeAcls`
it already carried; `DeploymentArtifact.ParseDataPlaneAuthority` decodes 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); `OpcUaPublishActor` installs them on `DataPlaneAuthorityState` on every apply;
`ArtifactApiKeyStore` serves them to session activation on a driver-only node, which until now
denied every UserName token; and `OtOpcUaNodeManager` gains a `DataPlaneAuthority` property, 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: `PermissionTrieCache` treats the
highest `GenerationId` as current (`ClusterEntry.WithAdditional`) and `Prune` keeps 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 `UserAuthorizationState` per 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`'s `plan.IsEmpty` early-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.
5. **Enforce write + alarms + HistoryRead** behind `Security:Acl:Enabled`, using the seams that
already exist.
already exist. Includes building the per-session `UserAuthorizationState` and stamping it with the
generation stage 4 now supplies, plus widening both `IApiKeyStore` implementations from
Cluster-scoped grants to the full grant set (they narrow today because
`ApiKeyInterimRoleMapper` cannot honour scope).
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
7. **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.
8. **Rollout.** Docs, the ScadaBridge key hand-off, and flipping `Security:Acl:Enabled` on 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`: