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:
+32
-10
@@ -113,10 +113,22 @@ The Server accepts three OPC UA identity-token types:
|
||||
| Token | Handler | Notes |
|
||||
|---|---|---|
|
||||
| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ An anonymous session carries **no roles**, so it is refused every write and every alarm ack — but it can **Browse, Read, Subscribe and HistoryRead the entire address space**. There is no per-node ACL check on those operations (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. |
|
||||
| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) and the **roles** are attached to the OPC UA session identity as a `RoleCarryingUserIdentity`. The raw group list is discarded at that point and never reaches the data plane. |
|
||||
| UserName/Password | `ApiKeyOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by an `IApiKeyStore`. | The username is the literal `apikey` and the password is an `otua_<keyId>_<secret>` credential (#520). One indexed lookup plus one peppered HMAC-SHA256, no network I/O. The key resolves to a **principal**, whose `NodeAcl` grants become roles on a `RoleCarryingUserIdentity`. **LDAP is not on this path** — it backs AdminUI login only. |
|
||||
| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow). It carries **no** roles, so a cert-only session is read-everything / write-nothing. |
|
||||
|
||||
When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`.
|
||||
When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`;
|
||||
the Host wires the real `ApiKeyOpcUaUserAuthenticator` as a singleton in `Program.cs`. Which
|
||||
**`IApiKeyStore`** backs it depends on the node's role:
|
||||
|
||||
| Node | Store | Revocation takes effect |
|
||||
|---|---|---|
|
||||
| `admin` (including fused `admin,driver`) | `ConfigDbApiKeyStore` — reads the config database per request | On the **next request** |
|
||||
| `driver`-only | `ArtifactApiKeyStore` — reads the keys carried by the deployment artifact, held in memory | On the **next deployment** |
|
||||
| neither role | `NullOpcUaUserAuthenticator` — denies every UserName token | n/a |
|
||||
|
||||
The deny-all is registered **explicitly**, not left absent: `OtOpcUaServerHostedService` takes
|
||||
`IOpcUaUserAuthenticator` as a required constructor parameter, so an unregistered seam fails DI and the
|
||||
node does not boot at all.
|
||||
|
||||
### LDAP bind flow (`OtOpcUaLdapAuthService`)
|
||||
|
||||
@@ -311,11 +323,17 @@ What is real about it:
|
||||
validated, versioned and persisted.
|
||||
- `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL
|
||||
edit shifts the artifact's `RevisionHash` and ships to every node.
|
||||
- The node side **never deserializes them** — `DeploymentArtifact` has no ACL reader. The bytes arrive
|
||||
and are dropped.
|
||||
- The node side **does** deserialize them as of #520 stage 4 —
|
||||
`DeploymentArtifact.ParseDataPlaneAuthority` reads the artifact's `NodeAcls` (scoped to the node's own
|
||||
cluster) and `ApiKeys`, and `OpcUaPublishActor` installs both on the node's
|
||||
`IDataPlaneAuthoritySource` on every apply. The node manager holds that source, so it finally has a
|
||||
cluster identity.
|
||||
- **Nothing evaluates them yet.** The grants are decoded and available; the enforcement seams that would
|
||||
consult them land in stages 5 and 6.
|
||||
|
||||
So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour
|
||||
to expect until the tracking issue below is closed.
|
||||
So an operator can still author a grant, deploy it green, and have it change nothing at the node — but
|
||||
the grant now reaches the node rather than being dropped at its door. That is the behaviour to expect
|
||||
until the tracking issue below is closed.
|
||||
|
||||
**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"):
|
||||
|
||||
@@ -323,14 +341,18 @@ to expect until the tracking issue below is closed.
|
||||
`Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot
|
||||
express — yet the write gate is attached to raw tags. Roughly half the writable address space has
|
||||
no scope to evaluate. This is a design decision, not wiring.
|
||||
2. **The identity carries roles, not groups.** The trie matches `NodeAcl.LdapGroup` against the
|
||||
session's LDAP groups, but `OpcUaUserAuthResult` carries only mapped role strings; the group list
|
||||
is discarded inside `LdapOpcUaUserAuthenticator`.
|
||||
2. ~~**The identity carries roles, not groups.**~~ **Dissolved by #520 stage 2.** The column is now
|
||||
`NodeAcl.Principal` and the principal comes from the presented API key (or the reserved `anonymous`),
|
||||
not from a directory — so there is no group list to lose.
|
||||
3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster
|
||||
grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have
|
||||
to be derived from the artifact's UNS relations.
|
||||
4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but
|
||||
nothing in production constructs one, stamps a generation, or drives a refresh.
|
||||
nothing in production constructs one per session. **Half-closed by stage 4:** the *generation* now
|
||||
exists and advances — `DataPlaneAuthorityState` stamps a monotonic `GenerationId` each time an
|
||||
artifact with a new `RevisionHash` is applied — so there is something to stamp a session with. Doing
|
||||
the stamping is stage 5's job, because until an enforcement seam reads it, a per-session state object
|
||||
would be constructed and never consulted.
|
||||
|
||||
Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in
|
||||
the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead`
|
||||
|
||||
Reference in New Issue
Block a user