diff --git a/CLAUDE.md b/CLAUDE.md index 11bb5bc3..9947f054 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -622,11 +622,23 @@ Two things to know before relying on any of this: the same `RoleCarryingUserIdentity` a key produces. Set it `false` to make keys mandatory. A key may **not** use the `anonymous` principal, and a failure to read anonymous's grants **denies** rather than admitting with no roles. -- ⚠️ **A driver-only node has no key source yet.** `ConfigDbApiKeyStore` needs a ConfigDb, which Phase 4 - removed from driver-only nodes; until the artifact-backed store lands they register - `NullOpcUaUserAuthenticator` and deny every UserName token. Note `OtOpcUaServerHostedService` takes - `IOpcUaUserAuthenticator` as a **required** ctor parameter — leaving it unregistered fails DI and the - node does not boot, so the deny-all must be registered explicitly. +- **Keys reach a driver-only node inside the deployment artifact** (stage 4). `ConfigComposer` snapshots + the `ApiKey` rows — the peppered HMAC only, never a secret, and the pepper is node config that is + deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them + node-side alongside the cluster's `NodeAcl` rows. `OpcUaPublishActor` installs both on + `IDataPlaneAuthoritySource` on every apply, **before** its `plan.IsEmpty` early-return, so a deployment + that only revokes a key still lands. A node with a ConfigDb keeps `ConfigDbApiKeyStore` (per-request + read); a driver-only node gets `ArtifactApiKeyStore`. ⚠️ **The consequence is asymmetric revocation: + immediate on a ConfigDb node, next-deployment on a driver-only one.** +- ⚠️ **The auth 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 with nothing logged. Pinned by + `DataPlaneAuthorityStateTests.The_generation_only_ever_increases_even_when_the_hash_decreases`. +- Note `OtOpcUaServerHostedService` takes `IOpcUaUserAuthenticator` **and** `IDataPlaneAuthoritySource` + as **required** ctor parameters — the built-in DI container does not fall back to a default value for + an unregistered service, so both must be registered explicitly on every node (deny-all / empty on a + node with neither role) or it does not boot. Remaining stages, and the rationale for every decision above, are in `docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. See `docs/security.md` for the full guide. diff --git a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md index d37daf25..7c2e9c00 100644 --- a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md +++ b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md @@ -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`: diff --git a/docs/security.md b/docs/security.md index d1c0c1ee..c9b26f35 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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` (`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__` 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` diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs new file mode 100644 index 00000000..2d199ea6 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -0,0 +1,235 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; + +/// +/// The data-plane authorization inputs a node is currently serving: the API keys that may +/// authenticate, the ACL grants naming their principals, the cluster the node belongs to, and the +/// generation those were resolved at (#520, stage 4). +/// +/// +/// +/// Immutable. A new deployment produces a whole new snapshot which is swapped in atomically, so +/// a request that started against the previous generation finishes against a coherent set +/// rather than a half-updated one. +/// +/// +/// The lookups are precomputed because the consumer is OPC UA session activation, which the SDK +/// raises synchronously under a SessionManager-wide lock — every session in the +/// server serializes behind it. A dictionary probe is the whole cost. +/// +/// +public sealed class DataPlaneAuthoritySnapshot +{ + /// The state of a node that has not yet applied an artifact. Authenticates nothing. + public static readonly DataPlaneAuthoritySnapshot Empty = Create(null, string.Empty, 0, [], []); + + private DataPlaneAuthoritySnapshot( + string? clusterId, + string revisionHash, + long generationId, + IReadOnlyList keys, + IReadOnlyList grants, + IReadOnlyDictionary keysById, + IReadOnlyDictionary> grantsByPrincipal) + { + ClusterId = clusterId; + RevisionHash = revisionHash; + GenerationId = generationId; + Keys = keys; + Grants = grants; + _keysById = keysById; + _grantsByPrincipal = grantsByPrincipal; + } + + private readonly IReadOnlyDictionary _keysById; + private readonly IReadOnlyDictionary> _grantsByPrincipal; + + /// + /// The cluster this node belongs to per the artifact's ClusterNode row, or null on a + /// single-cluster / legacy artifact where no scoping applies. + /// + public string? ClusterId { get; } + + /// SHA-256 of the artifact these inputs came from; empty before the first apply. + public string RevisionHash { get; } + + /// + /// Monotonic, node-local generation stamp. Zero means "nothing applied yet"; it increases by one + /// each time an artifact with a different is applied. + /// + public long GenerationId { get; } + + /// Every key in the artifact, revoked and expired ones included — filtering is the lookup's job. + public IReadOnlyList Keys { get; } + + /// Every ACL grant scoped to this node's cluster. + public IReadOnlyList Grants { get; } + + /// True once a real artifact has been applied. + public bool HasArtifact => GenerationId > 0; + + /// + /// Finds a key that is enabled and unexpired at . A revoked or expired + /// key returns null rather than a "found but unusable" result, mirroring + /// ConfigDbApiKeyStore, which filters both in SQL for the same reason: a key that must + /// not authenticate should never reach a caller that might treat a non-null return as a match. + /// + /// The public key id parsed from the presented credential. + /// Current UTC time, for the expiry check. + /// The usable key, or null. + public ApiKey? FindUsableKey(string apiKeyId, DateTime utcNow) + { + if (string.IsNullOrEmpty(apiKeyId)) return null; + if (!_keysById.TryGetValue(apiKeyId, out var key)) return null; + if (!key.Enabled) return null; + if (key.ExpiresUtc is { } expires && expires <= utcNow) return null; + return key; + } + + /// Returns the grants naming a principal; empty when none. + /// The principal to look up. + /// The matching grants. + public IReadOnlyList GrantsFor(string principal) => + !string.IsNullOrEmpty(principal) && _grantsByPrincipal.TryGetValue(principal, out var g) + ? g + : []; + + /// Builds a snapshot, precomputing the key and grant lookups. + /// The node's cluster, or null when the artifact is single-cluster. + /// SHA-256 of the source artifact. + /// The node-local generation stamp. + /// The API keys carried by the artifact. + /// The ACL grants for this node's cluster. + /// The built snapshot. + public static DataPlaneAuthoritySnapshot Create( + string? clusterId, + string revisionHash, + long generationId, + IReadOnlyList keys, + IReadOnlyList grants) + { + ArgumentNullException.ThrowIfNull(revisionHash); + ArgumentNullException.ThrowIfNull(keys); + ArgumentNullException.ThrowIfNull(grants); + + // OrdinalIgnoreCase throughout: key ids are minted as lowercase hex, but a client hand-copying a + // key out of the AdminUI can upper-case it, and the principal comparison must agree with the + // evaluator's (TriePermissionEvaluator matches principals OrdinalIgnoreCase). + var byId = new Dictionary(keys.Count, StringComparer.OrdinalIgnoreCase); + foreach (var key in keys) + { + if (string.IsNullOrWhiteSpace(key.ApiKeyId)) continue; + // A duplicate id cannot come from the database (UX_ApiKey_ApiKeyId is unique), so this only + // fires on a corrupt artifact. Keep the FIRST and drop the rest rather than throwing: the + // alternative is a node that refuses to authenticate anything because one row is malformed. + byId.TryAdd(key.ApiKeyId, key); + } + + var byPrincipal = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var grant in grants) + { + if (string.IsNullOrWhiteSpace(grant.Principal)) continue; + if (!byPrincipal.TryGetValue(grant.Principal, out var list)) + byPrincipal[grant.Principal] = list = []; + list.Add(grant); + } + + var frozen = byPrincipal.ToDictionary( + kv => kv.Key, + kv => (IReadOnlyList)kv.Value, + StringComparer.OrdinalIgnoreCase); + + return new DataPlaneAuthoritySnapshot(clusterId, revisionHash, generationId, keys, grants, byId, frozen); + } +} + +/// Read side of the node's current data-plane authorization inputs. +public interface IDataPlaneAuthoritySource +{ + /// The snapshot in force right now. Never null; before the first apply. + DataPlaneAuthoritySnapshot Current { get; } +} + +/// +/// The source bound when nothing has been wired: permanently empty, so it authenticates no key and +/// grants no permission. The safe default for a node that is not serving a data plane. +/// +public sealed class EmptyDataPlaneAuthoritySource : IDataPlaneAuthoritySource +{ + /// The shared instance. + public static readonly EmptyDataPlaneAuthoritySource Instance = new(); + + private EmptyDataPlaneAuthoritySource() { } + + /// + public DataPlaneAuthoritySnapshot Current => DataPlaneAuthoritySnapshot.Empty; +} + +/// +/// Write side, fed once per deployment from the node's artifact-apply path. +/// +public interface IDataPlaneAuthoritySink +{ + /// + /// Installs the authorization inputs carried by a newly applied artifact. Idempotent: re-applying + /// the same leaves the generation stamp untouched. + /// + /// The node's cluster per the artifact, or null when single-cluster. + /// SHA-256 of the artifact bytes. + /// The API keys the artifact carries. + /// The ACL grants for this node's cluster. + /// The installed snapshot. + DataPlaneAuthoritySnapshot Apply( + string? clusterId, + string revisionHash, + IReadOnlyList keys, + IReadOnlyList grants); +} + +/// +/// Process-singleton holder of . Registered on every +/// driver-role node; the artifact-apply path writes it and OPC UA session activation reads it. +/// +public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPlaneAuthoritySink +{ + private readonly Lock _gate = new(); + private DataPlaneAuthoritySnapshot _current = DataPlaneAuthoritySnapshot.Empty; + + /// + public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current); + + /// + public DataPlaneAuthoritySnapshot Apply( + string? clusterId, + string revisionHash, + IReadOnlyList keys, + IReadOnlyList grants) + { + ArgumentNullException.ThrowIfNull(revisionHash); + + lock (_gate) + { + var previous = _current; + + // The generation is a node-local counter driven BY the revision hash, not derived FROM it. + // Deriving a long from hash bytes was the obvious reading of "stamp the generation from the + // RevisionHash", and it is wrong: PermissionTrieCache treats the highest GenerationId as the + // current one (ClusterEntry.WithAdditional) and Prune keeps the highest. 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 — a silent stale-authorization bug with + // no error anywhere. A counter is monotonic by construction. + // + // It resets to zero on restart, which is safe: sessions do not survive a restart and the trie + // cache is in-process, so there is no older generation left to be out-ordered by. + var generation = string.Equals(previous.RevisionHash, revisionHash, StringComparison.Ordinal) + && previous.HasArtifact + ? previous.GenerationId + : previous.GenerationId + 1; + + var next = DataPlaneAuthoritySnapshot.Create(clusterId, revisionHash, generation, keys, grants); + Volatile.Write(ref _current, next); + return next; + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs index 3c3e501b..15905821 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs @@ -13,10 +13,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; /// deployment artifact (driver-only nodes have no ConfigDb since Phase 4), so the only /// thing that can change a session's grants is a new deploy — which already bumps /// . The generation stamp, not the freshness timer, is the -/// real invalidation signal. Revisit whether -/// / earn their keep -/// when the session lifecycle lands (design doc stage 4); they are retained unchanged here -/// so this rename stays behaviour-neutral. +/// real invalidation signal. Stage 4 built that signal — +/// advances a monotonic generation on every artifact carrying a new RevisionHash — but +/// deliberately did NOT construct this type per session: nothing reads a +/// until the stage 5 enforcement seams exist, and a +/// per-session object that nothing consults is dormant wiring. So the question of whether +/// / earn their keep moves +/// to stage 5, where sessions are actually stamped. /// /// The membership is bounded by /// (default 5 min). After that, the next hot-path authz call re-resolves the principal diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs index 261b2ea7..4c1f22fc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs @@ -49,6 +49,13 @@ public static class ConfigComposer UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct), UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct), NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct), + // #520 stage 4: the data-plane credentials travel with the config, because a driver-only node + // has no ConfigDb to read them from (per-cluster mesh Phase 4) and would otherwise deny every + // UserName token. Only the peppered HMAC of each secret ships — never the secret — and the + // pepper is node configuration that is deliberately NOT in the artifact, so a stolen blob + // still cannot be turned into a working credential. This is also why revoking a key reaches a + // driver-only node only on the next deployment; see the ApiKey entity's remarks. + ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct), Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct), VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct), ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ArtifactApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ArtifactApiKeyStore.cs new file mode 100644 index 00000000..477bda4f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ArtifactApiKeyStore.cs @@ -0,0 +1,80 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +// Fully-qualified rather than relative: this file's own namespace root contains a `Configuration` +// namespace (ZB.MOM.WW.OtOpcUa.Host.Configuration), so a relative `Configuration.Enums` binds there and +// fails to compile. +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// served from the deployment artifact (#520, stage 4). This is the key +/// source for a driver-only node, which has had no ConfigDb since per-cluster mesh Phase 4 and +/// until now denied every UserName token. +/// +/// +/// +/// Purely in-memory: decodes the keys and grants once per applied +/// artifact and installs them on . That matters because the +/// SDK raises the impersonation callback synchronously, under a SessionManager-wide +/// lock — an I/O-bound lookup here would serialize every session activation in the server +/// behind one disk or network round trip. +/// +/// +/// ⚠️ Consequence: revocation lands on the next deployment, not immediately. A node reading +/// the ConfigDb sees Enabled = 0 on the next request; this one sees it only once a new +/// artifact arrives. That asymmetry is inherent to a node with no config database and is +/// documented for operators on the AdminUI key page. +/// +/// +/// Before the first artifact is applied the snapshot is empty, so every key is denied. That is the +/// safe direction, and it is also honest — a node that has not been deployed to has no configured +/// keys. +/// +/// +public sealed class ArtifactApiKeyStore : IApiKeyStore +{ + private readonly IDataPlaneAuthoritySource _authority; + + /// Initializes a new instance of the class. + /// The node's current data-plane authorization inputs. + public ArtifactApiKeyStore(IDataPlaneAuthoritySource authority) + { + ArgumentNullException.ThrowIfNull(authority); + _authority = authority; + } + + /// + public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) + { + // Read the snapshot ONCE. Re-reading Current for the grants would let a deploy land between the + // two reads and pair a key from one generation with grants from another. + var snapshot = _authority.Current; + var key = snapshot.FindUsableKey(apiKeyId, utcNow); + return Task.FromResult(key is null + ? null + : new ApiKeyLookup(key, ClusterGrants(snapshot, key.Principal))); + } + + /// + public Task> FindGrantsAsync(string principal, CancellationToken ct) => + Task.FromResult(ClusterGrants(_authority.Current, principal)); + + /// + /// Narrows a principal's grants to the Cluster-scoped ones, matching ConfigDbApiKeyStore. + /// Only those confer an interim role (see ); stage 5 widens + /// both stores to the full grant set once scope is actually enforced. + /// + private static IReadOnlyList ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal) + { + var all = snapshot.GrantsFor(principal); + if (all.Count == 0) return []; + + var scoped = new List(all.Count); + foreach (var grant in all) + { + if (grant.ScopeKind == NodeAclScopeKind.Cluster) scoped.Add(grant); + } + return scoped; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs index c1f83f14..0c55c575 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs @@ -10,12 +10,12 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; /// that has a ConfigDb — i.e. one carrying the admin role, including a fused admin+driver node. /// /// -/// ⚠️ A driver-only node has no ConfigDb since per-cluster mesh Phase 4, so this store cannot -/// be registered there and such a node has no key source until the artifact-backed store lands in -/// stage 4. Until then a driver-only node authenticates no UserName tokens at all — it falls to -/// , which denies every attempt. That is -/// the safe direction, and anonymous sessions (still permitted, and still unrestricted until -/// enforcement lands in stages 5–6) are unaffected. +/// A driver-only node has no ConfigDb since per-cluster mesh Phase 4, so this store cannot be +/// registered there; that node uses instead (#520 stage 4). +/// +/// Reading per request is what makes revocation immediate here, which is why this store is +/// preferred wherever a ConfigDb exists — including on a fused admin+driver node. +/// /// public sealed class ConfigDbApiKeyStore : IApiKeyStore { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs index 6a01089b..6a13c448 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs @@ -7,9 +7,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; /// /// /// A seam rather than a direct ConfigDb read because the two sources differ by node role. A fused -/// admin+driver node reads the config database directly; a driver-only node has no ConfigDb -/// at all since per-cluster mesh Phase 4, so its keys must come out of the deployment artifact. -/// Stage 2 ships the ConfigDb implementation; the artifact-backed one lands in stage 4. +/// admin+driver node reads the config database directly (); a +/// driver-only node has no ConfigDb at all since per-cluster mesh Phase 4, so its keys come out +/// of the deployment artifact (). +/// +/// The two differ in one operator-visible way: the ConfigDb store sees a revocation on the next +/// request, the artifact store only once a new deployment reaches the node. +/// /// public interface IApiKeyStore { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index fe582630..19b9cb58 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using ZB.MOM.WW.OtOpcUa.Runtime; @@ -34,6 +35,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl private readonly DeferredServiceLevelPublisher _deferredServiceLevel; private readonly IOpcUaUserAuthenticator _userAuthenticator; private readonly IHistorianDataSource _historianDataSource; + private readonly IDataPlaneAuthoritySource _dataPlaneAuthority; private readonly ServerHistorianOptions _serverHistorianOptions; private readonly Func _actorSystemAccessor; private readonly ActorRegistry _actorRegistry; @@ -55,6 +57,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl /// node, the same source the address-space sink + node-write gateway come from), or the configured /// HistorianGateway read client when AddServerHistorian enabled it. Wired onto the node manager in /// . + /// #520 stage 4 — the node's data-plane authorization inputs (its + /// cluster, the current auth generation, and the ACL grants), refreshed from every applied deployment + /// artifact. Wired onto the node manager in so the stage 5/6 enforcement seams + /// have a cluster identity to build a NodeScope with. Resolves to the empty source on a node with + /// no driver role. /// App configuration; the ServerHistorian section is bound here to /// read for the node manager. Bound directly /// (not via IOptions) so the wiring is self-contained in this hosted service — the option's @@ -73,6 +80,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl DeferredServiceLevelPublisher deferredServiceLevel, IOpcUaUserAuthenticator userAuthenticator, IHistorianDataSource historianDataSource, + IDataPlaneAuthoritySource dataPlaneAuthority, IConfiguration configuration, Func actorSystemAccessor, ActorRegistry actorRegistry, @@ -83,6 +91,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _deferredServiceLevel = deferredServiceLevel; _userAuthenticator = userAuthenticator; _historianDataSource = historianDataSource; + _dataPlaneAuthority = dataPlaneAuthority; // Bind the ServerHistorian section directly (defaults survive when it's absent) — we only need // MaxTieClusterOverfetch here, and binding it ourselves keeps the wiring in this one file. _serverHistorianOptions = @@ -216,6 +225,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl // The node manager's HistoryRead overrides block-bridge to whatever source is set here. _server.SetHistorianDataSource(_historianDataSource); + // #520 stage 4 — hand the node manager the node's data-plane authorization inputs. Nothing reads + // them until the stage 5/6 enforcement seams land, so this changes no behaviour today; it exists + // because the node manager had NO cluster identity, and NodeScope.ClusterId is required while the + // evaluator fails closed on a mismatch — every scope it could have built would have been denied. + _server.SetDataPlaneAuthority(_dataPlaneAuthority); + // Propagate the configured tie-cluster over-fetch bound onto the node manager so HistoryRead-Raw // can page WITHIN an oversized tie cluster (more samples sharing one SourceTimestamp than the page // cap) rather than failing the read. The option's default (65536) survives when the ServerHistorian @@ -257,6 +272,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _server?.SetNativeAlarmAckRouter(null); // Restore the Null historian so a late HistoryRead doesn't hit a disposed read client. _server?.SetHistorianDataSource(null); + // Restore the empty authority so a late session activation cannot authorize against a stopping node. + _server?.SetDataPlaneAuthority(null); return Task.CompletedTask; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index ef6c3c62..f098d719 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -351,12 +351,31 @@ if (hasDriver) // (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP // registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP. // Only IOpcUaUserAuthenticator moves. + // #520 stage 4 — the node's data-plane authorization inputs (keys + ACL grants + cluster + auth + // generation), refreshed by OpcUaPublishActor from every applied artifact. Registered on any + // driver-role node because that is what serves an OPC UA data plane; the two interfaces resolve to + // the SAME instance, since the writer and the readers must see one snapshot. + if (hasDriver) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + } + else + { + // The SOURCE half must always resolve — OtOpcUaServerHostedService takes it as a required ctor + // parameter, and the built-in DI container does not fall back to a default value for a parameter + // whose service is unregistered; it throws and the node fails to start. Same trap, and the same + // fix, as the NullOpcUaUserAuthenticator registration below. Only the SINK is genuinely optional + // (nothing publishes artifacts on a node with no driver role). + builder.Services.AddSingleton(EmptyDataPlaneAuthoritySource.Instance); + } + if (hasAdmin) { - // ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin. - // A driver-only node therefore has no key source until the artifact-backed store lands in - // stage 4; it falls through to NullOpcUaUserAuthenticator, which denies every UserName token. - // Safe direction, and anonymous sessions are unaffected. + // A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect + // on the very next request rather than on the next deployment. Preferred wherever it is available, + // including on a fused admin+driver node. builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -365,11 +384,19 @@ if (hasDriver) sp.GetRequiredService>(), sp.GetRequiredService>().Value.Pepper)); } + else if (hasDriver) + { + // Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact. + // Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName + // token — safe, but it meant no machine client could authenticate against a site node at all. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } else { - // MUST be registered explicitly, not left absent: OtOpcUaServerHostedService takes - // IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so an unregistered seam is not a - // quiet fallback to deny-all — it fails DI and the node does not start at all. + // Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent — + // OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so + // an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start. builder.Services.AddSingleton(NullOpcUaUserAuthenticator.Instance); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index c9c1b7c2..9b4fb06b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -3,6 +3,7 @@ using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; // The SDK's HistoryRead service result (the value the override fills + hands back) and the historian // data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous: @@ -243,6 +244,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 set => _historianDataSource = value ?? NullHistorianDataSource.Instance; } + /// + /// #520 stage 4 — the node's current data-plane authorization inputs: which cluster it serves, + /// the generation those were resolved at, and the ACL grants themselves. + /// + /// + /// + /// This closes the fourth #520 blocker: NodeScope.ClusterId is required and + /// TriePermissionEvaluator fails closed when it disagrees with the session's, but the + /// node manager had no idea what cluster it was serving — so no NodeScope it built + /// could ever have evaluated. It is fed here rather than at construction because the SDK + /// constructs this manager during StartAsync, before any artifact has been applied, + /// and the value changes on every deploy. + /// + /// + /// Stage 4 only supplies it. The enforcement seams that consume it — the write gate, the alarm + /// gates, HistoryRead, and (stage 6) read/browse/subscribe — land in stages 5 and 6, so + /// nothing reads this yet and authorization behaviour is unchanged. + /// + /// + /// Backed by a volatile field for the same reason as : + /// written on the deploy path, read on SDK request threads. Assigning null restores the + /// empty source, which authenticates and authorizes nothing. + /// + /// + public IDataPlaneAuthoritySource DataPlaneAuthority + { + get => _dataPlaneAuthority; + set => _dataPlaneAuthority = value ?? EmptyDataPlaneAuthoritySource.Instance; + } + + private volatile IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance; + /// /// The upper bound on the bounded over-fetch uses to page WITHIN an /// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs index 5c464248..8bad65bd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs @@ -2,6 +2,7 @@ using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -103,6 +104,28 @@ public sealed class OtOpcUaSdkServer : StandardServer return true; } + /// + /// Wire the node's data-plane authorization inputs (#520 stage 4) onto the created + /// — the cluster it serves, the current auth generation, and the + /// ACL grants. The host calls this after start with the DI-resolved + /// , which the artifact-apply path refreshes on every + /// deploy. Passing null restores the empty source (authorizes nothing). No-op (returns + /// false) when the node manager has not been created yet, so the caller can detect a + /// too-early call (mirrors ). + /// + /// The node's data-plane authorization inputs; may be null to restore the + /// empty default. + /// true when the source was set on a live node manager; false when no node + /// manager exists yet. + public bool SetDataPlaneAuthority(IDataPlaneAuthoritySource? source) + { + if (_otOpcUaNodeManager is null) return false; + // The DataPlaneAuthority setter null-coalesces to the empty source, so a null is intentional; + // forgive the nullable-in here. + _otOpcUaNodeManager.DataPlaneAuthority = source!; + return true; + } + /// protected override MasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index d79d0b08..a385b468 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -37,6 +37,24 @@ public enum ClusterFilterMode Suppress, } +/// +/// The data-plane authorization inputs decoded out of a deployment artifact for one node (#520). +/// +/// The node's cluster per its ClusterNode row, or null on a +/// single-cluster / legacy artifact where no scoping applies. +/// Every ApiKey the artifact carries, revoked and expired included — the +/// lookup filters those, so that a revocation is visible rather than indistinguishable from a +/// missing key. +/// The NodeAcl rows scoped to . +public sealed record DataPlaneAuthorityProjection( + string? ClusterId, + IReadOnlyList Keys, + IReadOnlyList Grants) +{ + /// The projection of an absent or unparseable artifact: no cluster, no keys, no grants. + public static readonly DataPlaneAuthorityProjection Empty = new(null, [], []); +} + /// Resolved scoping decision for a node against an artifact. /// /// None = apply everything (single-cluster / legacy); ScopeTo = filter to ; @@ -534,6 +552,170 @@ public static class DeploymentArtifact : all; } + /// + /// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions: + /// the ApiKeys the artifact carries and the NodeAcls granting their principals + /// permissions (#520, stage 4). + /// + /// + /// + /// This is the ONLY key source a driver-only node has — per-cluster mesh Phase 4 left + /// it with no ConfigDb, so without this it denies every UserName token. + /// + /// + /// 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 it + /// is the ACL rows that bind it to a cluster's nodes. A + /// scope — the node's row is absent from a multi-cluster artifact — yields NO grants, matching + /// the driver-spawn path's reading that an unlisted node was not deployed to. + /// + /// + /// Lenient like every other parser here: an empty or malformed blob yields empty lists. The + /// caller must NOT treat that as "no keys are valid" and install it — see issue #485; the + /// apply paths only reach this with bytes that already passed their own emptiness guard. + /// + /// + /// The deployment artifact blob to parse. + /// The node's identity, used to resolve cluster scope for the grants. + /// The node's cluster (null when single-cluster), its keys, and its cluster's grants. + public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan blob, string nodeId) + { + if (blob.IsEmpty) return DataPlaneAuthorityProjection.Empty; + + var scope = ResolveClusterScope(blob, nodeId); + + try + { + using var doc = JsonDocument.Parse(blob.ToArray()); + var root = doc.RootElement; + + var keys = new List(); + foreach (var el in EnumerateArray(root, "ApiKeys")) + { + var key = TryReadApiKey(el); + if (key is not null) keys.Add(key); + } + + var grants = new List(); + foreach (var el in EnumerateArray(root, "NodeAcls")) + { + var acl = TryReadNodeAcl(el); + if (acl is null) continue; + if (scope.Mode == ClusterFilterMode.Suppress) continue; + if (scope.Mode == ClusterFilterMode.ScopeTo + && !string.Equals(acl.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + grants.Add(acl); + } + + return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants); + } + catch (JsonException) + { + return DataPlaneAuthorityProjection.Empty; + } + } + + /// Reads one ApiKeys element; null when a field the credential check depends on is + /// missing, so a half-written row can never authenticate. + private static ApiKey? TryReadApiKey(JsonElement el) + { + var apiKeyId = ReadString(el, "ApiKeyId"); + var secretHash = ReadString(el, "SecretHash"); + var principal = ReadString(el, "Principal"); + if (string.IsNullOrWhiteSpace(apiKeyId) + || string.IsNullOrWhiteSpace(secretHash) + || string.IsNullOrWhiteSpace(principal)) + { + return null; + } + + return new ApiKey + { + ApiKeyId = apiKeyId, + Name = ReadString(el, "Name") ?? apiKeyId, + SecretHash = secretHash, + Principal = principal, + // A row whose Enabled flag did not survive serialisation is treated as REVOKED. The other + // direction would resurrect a revoked key out of a malformed artifact. + Enabled = ReadBool(el, "Enabled") ?? false, + CreatedUtc = ReadDateTime(el, "CreatedUtc") ?? default, + ExpiresUtc = ReadDateTime(el, "ExpiresUtc"), + Notes = ReadNullableString(el, "Notes"), + }; + } + + /// Reads one NodeAcls element; null when identity, cluster or scope kind is unreadable. + private static NodeAcl? TryReadNodeAcl(JsonElement el) + { + var nodeAclId = ReadString(el, "NodeAclId"); + var clusterId = ReadString(el, "ClusterId"); + var principal = ReadString(el, "Principal"); + if (string.IsNullOrWhiteSpace(nodeAclId) + || string.IsNullOrWhiteSpace(clusterId) + || string.IsNullOrWhiteSpace(principal)) + { + return null; + } + + // ConfigComposer serialises enums as NUMBERS (its JsonSerializerOptions carries no + // JsonStringEnumConverter), so these read as int. Keep the two sides in step — a string-enum + // converter added to the composer would silently drop every grant here. + var scopeKind = ReadInt32(el, "ScopeKind"); + if (scopeKind is null || !Enum.IsDefined(typeof(NodeAclScopeKind), scopeKind.Value)) return null; + + return new NodeAcl + { + NodeAclId = nodeAclId, + ClusterId = clusterId, + Principal = principal, + ScopeKind = (NodeAclScopeKind)scopeKind.Value, + ScopeId = ReadNullableString(el, "ScopeId"), + // An unreadable bitmask grants nothing rather than defaulting to something permissive. + PermissionFlags = (NodePermissions)(ReadInt32(el, "PermissionFlags") ?? 0), + Notes = ReadNullableString(el, "Notes"), + }; + } + + /// Reads a boolean property; null when absent or not a JSON boolean. + private static bool? ReadBool(JsonElement el, string property) => + el.TryGetProperty(property, out var p) + ? p.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, + } + : null; + + /// Reads an int property; null when absent, not a number, or out of int range. + private static int? ReadInt32(JsonElement el, string property) => + el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.Number + && p.TryGetInt32(out var v) + ? v + : null; + + /// Reads a UTC DateTime property; null when absent, JSON null, or unparseable. + private static DateTime? ReadDateTime(JsonElement el, string property) + { + if (!el.TryGetProperty(property, out var p) || p.ValueKind != JsonValueKind.String) return null; + if (!p.TryGetDateTime(out var v)) return null; + + // Normalise to UTC, because every consumer compares against DateTime.UtcNow and an expiry that + // silently shifted by the node's timezone is a key that outlives (or predeceases) its expiry. + // Unspecified must be SpecifyKind, NOT ToUniversalTime: EF materialises datetime2 as Unspecified, + // so the composer writes these UTC instants without an offset and ToUniversalTime would subtract + // the node's local offset from a value that was already UTC. + return v.Kind switch + { + DateTimeKind.Utc => v, + DateTimeKind.Local => v.ToUniversalTime(), + _ => DateTime.SpecifyKind(v, DateTimeKind.Utc), + }; + } + /// Reads a string property, tolerating a wrong JSON type: a property that is present but /// NOT a JSON string (number / bool / object / array) yields null rather than throwing /// from GetString(). Keeps the artifact-decode lenient diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index e2e0e174..8053d95a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Health; @@ -95,6 +96,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private readonly NodeId? _localNode; private readonly IDbContextFactory? _dbFactory; private readonly AddressSpaceApplier? _applier; + /// #520 stage 4 — receives the API keys + ACL grants carried by each applied artifact, so + /// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node + /// that is not serving an OPC UA data plane). + private readonly IDataPlaneAuthoritySink? _dataPlaneAuthority; private readonly IActorRef? _dbHealthProbe; /// Per-cluster mesh Phase 4: this is a driver-only node with NO ConfigDb (LocalDb is its /// config store). There is no DB to probe, so the ServiceLevel DB-reachability input is retired — @@ -166,7 +171,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? staleWindow = null, TimeSpan? probeFreshnessWindow = null, TimeSpan? healthTickInterval = null, - bool dbLess = false) => + bool dbLess = false, + IDataPlaneAuthoritySink? dataPlaneAuthority = null) => Akka.Actor.Props.Create(() => new OpcUaPublishActor( sink ?? NullOpcUaAddressSpaceSink.Instance, serviceLevel ?? NullServiceLevelPublisher.Instance, @@ -178,7 +184,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers staleWindow, probeFreshnessWindow, healthTickInterval, - dbLess)).WithDispatcher(DispatcherId); + dbLess, + dataPlaneAuthority)).WithDispatcher(DispatcherId); /// Test-only Props that omits the pinned-dispatcher requirement and skips the /// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster. @@ -214,7 +221,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? staleWindow = null, TimeSpan? probeFreshnessWindow = null, TimeSpan? healthTickInterval = null, - bool dbLess = false) => + bool dbLess = false, + IDataPlaneAuthoritySink? dataPlaneAuthority = null) => Akka.Actor.Props.Create(() => new OpcUaPublishActor( sink ?? NullOpcUaAddressSpaceSink.Instance, serviceLevel ?? NullServiceLevelPublisher.Instance, @@ -226,7 +234,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers staleWindow, probeFreshnessWindow, healthTickInterval, - dbLess)); + dbLess, + dataPlaneAuthority)); /// Initializes a new instance of the class. /// The OPC UA address space sink. @@ -259,9 +268,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? staleWindow = null, TimeSpan? probeFreshnessWindow = null, TimeSpan? healthTickInterval = null, - bool dbLess = false) + bool dbLess = false, + IDataPlaneAuthoritySink? dataPlaneAuthority = null) { _sink = sink; + _dataPlaneAuthority = dataPlaneAuthority; _serviceLevel = serviceLevel; _subscribeRedundancyTopic = subscribeRedundancyTopic; _localNode = localNode; @@ -412,6 +423,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers return; } + // #520 stage 4 — install this artifact's API keys + ACL grants BEFORE the address-space work + // below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a + // key or edits a grant without touching a single node produces an EMPTY address-space plan, so + // feeding this after that return would leave a revoked key authenticating until some unrelated + // structural change happened to redeploy. Authorization and topology change on their own + // schedules. + FeedDataPlaneAuthority(artifact); + var composition = _localNode is { } ln ? DeploymentArtifact.ParseComposition(artifact, ln.Value, inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency)) @@ -491,6 +510,43 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } + /// + /// Decode the artifact's data-plane authorization inputs (API keys + this cluster's ACL grants) + /// and install them for OPC UA session activation (#520 stage 4). No-op when nothing consumes + /// them. + /// + /// + /// Wrapped so a decode failure can never break a deploy — the same discipline as the historised-ref + /// feed. The cost of that choice is explicit: the node keeps serving the PREVIOUS key set, which is + /// the safe direction for availability but means a revocation carried by an artifact this node + /// could not decode has not taken effect. It is logged at Error for exactly that reason. + /// + private void FeedDataPlaneAuthority(byte[] artifact) + { + if (_dataPlaneAuthority is null) return; + + try + { + var nodeId = _localNode?.Value ?? string.Empty; + var projection = DeploymentArtifact.ParseDataPlaneAuthority(artifact, nodeId); + // The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses, + // which is what lets a node recognise a re-applied revision without the hash riding the wire. + var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifact)); + var snapshot = _dataPlaneAuthority.Apply( + projection.ClusterId, revision, projection.Keys, projection.Grants); + + _log.Info( + "OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants})", + snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12], + projection.Keys.Count, projection.Grants.Count); + } + catch (Exception ex) + { + _log.Error(ex, + "OpcUaPublish: failed to install data-plane authority from the applied artifact — the node keeps the PREVIOUS keys and grants, so any revocation in this deployment has NOT taken effect"); + } + } + /// Read a specific deployment's artifact blob from ConfigDb (the one just applied, /// which may not be Sealed yet). Empty array on any failure — treats that as /// "no answer" and abandons the rebuild, leaving the served address space untouched (issue #485). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 793088f0..851a2b8c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.Scripting; @@ -467,6 +468,11 @@ public static class ServiceCollectionExtensions loggerFactory.CreateLogger(), provisioning: provisioning, historizedSubscriptions: historizedSubscriptions); + // #520 stage 4 — the sink that receives each applied artifact's API keys + ACL grants, so OPC + // UA session activation can authenticate. Optional: GetService (not GetRequiredService), because + // a node with no driver role never registers it and test harnesses build this graph without it; + // the actor treats null as "nothing consumes authorization inputs" and skips the decode. + var dataPlaneAuthority = resolver.GetService(); var publishActor = system.ActorOf( OpcUaPublishActor.Props( sink: addressSpaceSink, @@ -475,7 +481,8 @@ public static class ServiceCollectionExtensions dbFactory: dbFactory, applier: applier, dbHealthProbe: dbHealth, - dbLess: dbFactory is null), + dbLess: dbFactory is null, + dataPlaneAuthority: dataPlaneAuthority), OpcUaPublishActorName); registry.Register(publishActor); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj index 92445c84..094b2e67 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj @@ -21,6 +21,10 @@ + +