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:
@@ -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`:
|
||||
|
||||
Reference in New Issue
Block a user