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
+17 -5
View File
@@ -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.
@@ -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`:
+32 -10
View File
@@ -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`
@@ -0,0 +1,235 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The lookups are precomputed because the consumer is OPC UA session activation, which the SDK
/// raises <b>synchronously under a <c>SessionManager</c>-wide lock</b> — every session in the
/// server serializes behind it. A dictionary probe is the whole cost.
/// </para>
/// </remarks>
public sealed class DataPlaneAuthoritySnapshot
{
/// <summary>The state of a node that has not yet applied an artifact. Authenticates nothing.</summary>
public static readonly DataPlaneAuthoritySnapshot Empty = Create(null, string.Empty, 0, [], []);
private DataPlaneAuthoritySnapshot(
string? clusterId,
string revisionHash,
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants,
IReadOnlyDictionary<string, ApiKey> keysById,
IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> grantsByPrincipal)
{
ClusterId = clusterId;
RevisionHash = revisionHash;
GenerationId = generationId;
Keys = keys;
Grants = grants;
_keysById = keysById;
_grantsByPrincipal = grantsByPrincipal;
}
private readonly IReadOnlyDictionary<string, ApiKey> _keysById;
private readonly IReadOnlyDictionary<string, IReadOnlyList<NodeAcl>> _grantsByPrincipal;
/// <summary>
/// The cluster this node belongs to per the artifact's <c>ClusterNode</c> row, or null on a
/// single-cluster / legacy artifact where no scoping applies.
/// </summary>
public string? ClusterId { get; }
/// <summary>SHA-256 of the artifact these inputs came from; empty before the first apply.</summary>
public string RevisionHash { get; }
/// <summary>
/// Monotonic, node-local generation stamp. Zero means "nothing applied yet"; it increases by one
/// each time an artifact with a <em>different</em> <see cref="RevisionHash"/> is applied.
/// </summary>
public long GenerationId { get; }
/// <summary>Every key in the artifact, revoked and expired ones included — filtering is the lookup's job.</summary>
public IReadOnlyList<ApiKey> Keys { get; }
/// <summary>Every ACL grant scoped to this node's cluster.</summary>
public IReadOnlyList<NodeAcl> Grants { get; }
/// <summary>True once a real artifact has been applied.</summary>
public bool HasArtifact => GenerationId > 0;
/// <summary>
/// Finds a key that is enabled and unexpired at <paramref name="utcNow"/>. A revoked or expired
/// key returns null rather than a "found but unusable" result, mirroring
/// <c>ConfigDbApiKeyStore</c>, 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.
/// </summary>
/// <param name="apiKeyId">The public key id parsed from the presented credential.</param>
/// <param name="utcNow">Current UTC time, for the expiry check.</param>
/// <returns>The usable key, or null.</returns>
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;
}
/// <summary>Returns the grants naming a principal; empty when none.</summary>
/// <param name="principal">The principal to look up.</param>
/// <returns>The matching grants.</returns>
public IReadOnlyList<NodeAcl> GrantsFor(string principal) =>
!string.IsNullOrEmpty(principal) && _grantsByPrincipal.TryGetValue(principal, out var g)
? g
: [];
/// <summary>Builds a snapshot, precomputing the key and grant lookups.</summary>
/// <param name="clusterId">The node's cluster, or null when the artifact is single-cluster.</param>
/// <param name="revisionHash">SHA-256 of the source artifact.</param>
/// <param name="generationId">The node-local generation stamp.</param>
/// <param name="keys">The API keys carried by the artifact.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <returns>The built snapshot.</returns>
public static DataPlaneAuthoritySnapshot Create(
string? clusterId,
string revisionHash,
long generationId,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> 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<string, ApiKey>(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<string, List<NodeAcl>>(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<NodeAcl>)kv.Value,
StringComparer.OrdinalIgnoreCase);
return new DataPlaneAuthoritySnapshot(clusterId, revisionHash, generationId, keys, grants, byId, frozen);
}
}
/// <summary>Read side of the node's current data-plane authorization inputs.</summary>
public interface IDataPlaneAuthoritySource
{
/// <summary>The snapshot in force right now. Never null; <see cref="DataPlaneAuthoritySnapshot.Empty"/> before the first apply.</summary>
DataPlaneAuthoritySnapshot Current { get; }
}
/// <summary>
/// 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.
/// </summary>
public sealed class EmptyDataPlaneAuthoritySource : IDataPlaneAuthoritySource
{
/// <summary>The shared instance.</summary>
public static readonly EmptyDataPlaneAuthoritySource Instance = new();
private EmptyDataPlaneAuthoritySource() { }
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Current => DataPlaneAuthoritySnapshot.Empty;
}
/// <summary>
/// Write side, fed once per deployment from the node's artifact-apply path.
/// </summary>
public interface IDataPlaneAuthoritySink
{
/// <summary>
/// Installs the authorization inputs carried by a newly applied artifact. Idempotent: re-applying
/// the same <paramref name="revisionHash"/> leaves the generation stamp untouched.
/// </summary>
/// <param name="clusterId">The node's cluster per the artifact, or null when single-cluster.</param>
/// <param name="revisionHash">SHA-256 of the artifact bytes.</param>
/// <param name="keys">The API keys the artifact carries.</param>
/// <param name="grants">The ACL grants for this node's cluster.</param>
/// <returns>The installed snapshot.</returns>
DataPlaneAuthoritySnapshot Apply(
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> grants);
}
/// <summary>
/// Process-singleton holder of <see cref="DataPlaneAuthoritySnapshot"/>. Registered on every
/// driver-role node; the artifact-apply path writes it and OPC UA session activation reads it.
/// </summary>
public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPlaneAuthoritySink
{
private readonly Lock _gate = new();
private DataPlaneAuthoritySnapshot _current = DataPlaneAuthoritySnapshot.Empty;
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current);
/// <inheritdoc />
public DataPlaneAuthoritySnapshot Apply(
string? clusterId,
string revisionHash,
IReadOnlyList<ApiKey> keys,
IReadOnlyList<NodeAcl> 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;
}
}
}
@@ -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
/// <see cref="AuthGenerationId"/>. The generation stamp, not the freshness timer, is the
/// real invalidation signal. Revisit whether
/// <see cref="MembershipFreshnessInterval"/> / <see cref="NeedsRefresh"/> 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 — <see cref="DataPlaneAuthorityState"/>
/// advances a monotonic generation on every artifact carrying a new <c>RevisionHash</c> — but
/// deliberately did NOT construct this type per session: nothing reads a
/// <see cref="UserAuthorizationState"/> until the stage 5 enforcement seams exist, and a
/// per-session object that nothing consults is dormant wiring. So the question of whether
/// <see cref="MembershipFreshnessInterval"/> / <see cref="NeedsRefresh"/> earn their keep moves
/// to stage 5, where sessions are actually stamped.
///
/// The membership is bounded by <see cref="MembershipFreshnessInterval"/>
/// (default 5 min). After that, the next hot-path authz call re-resolves the principal
@@ -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),
@@ -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;
/// <summary>
/// <see cref="IApiKeyStore"/> served from the deployment artifact (#520, stage 4). This is the key
/// source for a <b>driver-only</b> node, which has had no ConfigDb since per-cluster mesh Phase 4 and
/// until now denied every UserName token.
/// </summary>
/// <remarks>
/// <para>
/// Purely in-memory: <see cref="OpcUaPublishActor"/> decodes the keys and grants once per applied
/// artifact and installs them on <see cref="IDataPlaneAuthoritySource"/>. That matters because the
/// SDK raises the impersonation callback <b>synchronously, under a <c>SessionManager</c>-wide
/// lock</b> — an I/O-bound lookup here would serialize every session activation in the server
/// behind one disk or network round trip.
/// </para>
/// <para>
/// ⚠️ <b>Consequence: revocation lands on the next deployment, not immediately.</b> A node reading
/// the ConfigDb sees <c>Enabled = 0</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed class ArtifactApiKeyStore : IApiKeyStore
{
private readonly IDataPlaneAuthoritySource _authority;
/// <summary>Initializes a new instance of the <see cref="ArtifactApiKeyStore"/> class.</summary>
/// <param name="authority">The node's current data-plane authorization inputs.</param>
public ArtifactApiKeyStore(IDataPlaneAuthoritySource authority)
{
ArgumentNullException.ThrowIfNull(authority);
_authority = authority;
}
/// <inheritdoc />
public Task<ApiKeyLookup?> 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)));
}
/// <inheritdoc />
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult(ClusterGrants(_authority.Current, principal));
/// <summary>
/// Narrows a principal's grants to the Cluster-scoped ones, matching <c>ConfigDbApiKeyStore</c>.
/// Only those confer an interim role (see <see cref="ApiKeyInterimRoleMapper"/>); stage 5 widens
/// both stores to the full grant set once scope is actually enforced.
/// </summary>
private static IReadOnlyList<NodeAcl> ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal)
{
var all = snapshot.GrantsFor(principal);
if (all.Count == 0) return [];
var scoped = new List<NodeAcl>(all.Count);
foreach (var grant in all)
{
if (grant.ScopeKind == NodeAclScopeKind.Cluster) scoped.Add(grant);
}
return scoped;
}
}
@@ -10,12 +10,12 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// that has a ConfigDb — i.e. one carrying the <c>admin</c> role, including a fused admin+driver node.
/// </summary>
/// <remarks>
/// ⚠️ <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, 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
/// <see cref="OpcUaServer.Security.NullOpcUaUserAuthenticator"/>, 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.
/// <b>A driver-only node has no ConfigDb since per-cluster mesh Phase 4</b>, so this store cannot be
/// registered there; that node uses <see cref="ArtifactApiKeyStore"/> instead (#520 stage 4).
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed class ConfigDbApiKeyStore : IApiKeyStore
{
@@ -7,9 +7,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// </summary>
/// <remarks>
/// 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 <b>driver-only</b> 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 (<see cref="ConfigDbApiKeyStore"/>); a
/// <b>driver-only</b> node has no ConfigDb at all since per-cluster mesh Phase 4, so its keys come out
/// of the deployment artifact (<see cref="ArtifactApiKeyStore"/>).
/// <para>
/// 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.
/// </para>
/// </remarks>
public interface IApiKeyStore
{
@@ -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<ActorSystem> _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 <c>AddServerHistorian</c> enabled it. Wired onto the node manager in
/// <see cref="StartAsync"/>.</param>
/// <param name="dataPlaneAuthority">#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 <see cref="StartAsync"/> so the stage 5/6 enforcement seams
/// have a cluster identity to build a <c>NodeScope</c> with. Resolves to the empty source on a node with
/// no driver role.</param>
/// <param name="configuration">App configuration; the <c>ServerHistorian</c> section is bound here to
/// read <see cref="ServerHistorianOptions.MaxTieClusterOverfetch"/> for the node manager. Bound directly
/// (not via <c>IOptions</c>) 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<ActorSystem> 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;
}
+34 -7
View File
@@ -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<DataPlaneAuthorityState>();
builder.Services.AddSingleton<IDataPlaneAuthoritySource>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
builder.Services.AddSingleton<IDataPlaneAuthoritySink>(sp => sp.GetRequiredService<DataPlaneAuthorityState>());
}
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<IDataPlaneAuthoritySource>(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<IApiKeyStore, ConfigDbApiKeyStore>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
@@ -365,11 +384,19 @@ if (hasDriver)
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
sp.GetRequiredService<IOptions<ApiKeyOptions>>().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<IApiKeyStore, ArtifactApiKeyStore>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
}
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<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
}
@@ -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;
}
/// <summary>
/// #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.
/// </summary>
/// <remarks>
/// <para>
/// This closes the fourth #520 blocker: <c>NodeScope.ClusterId</c> is <c>required</c> and
/// <c>TriePermissionEvaluator</c> fails closed when it disagrees with the session's, but the
/// node manager had no idea what cluster it was serving — so no <c>NodeScope</c> it built
/// could ever have evaluated. It is fed here rather than at construction because the SDK
/// constructs this manager during <c>StartAsync</c>, before any artifact has been applied,
/// and the value changes on every deploy.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Backed by a <c>volatile</c> field for the same reason as <see cref="HistorianDataSource"/>:
/// written on the deploy path, read on SDK request threads. Assigning <c>null</c> restores the
/// empty source, which authenticates and authorizes nothing.
/// </para>
/// </remarks>
public IDataPlaneAuthoritySource DataPlaneAuthority
{
get => _dataPlaneAuthority;
set => _dataPlaneAuthority = value ?? EmptyDataPlaneAuthoritySource.Instance;
}
private volatile IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance;
/// <summary>
/// The upper bound on the bounded over-fetch <see cref="ServeRawPaged"/> uses to page WITHIN an
/// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page
@@ -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;
}
/// <summary>
/// Wire the node's data-plane authorization inputs (#520 stage 4) onto the created
/// <see cref="OtOpcUaNodeManager"/> — the cluster it serves, the current auth generation, and the
/// ACL grants. The host calls this after start with the DI-resolved
/// <see cref="IDataPlaneAuthoritySource"/>, which the artifact-apply path refreshes on every
/// deploy. Passing <c>null</c> restores the empty source (authorizes nothing). No-op (returns
/// <c>false</c>) when the node manager has not been created yet, so the caller can detect a
/// too-early call (mirrors <see cref="SetHistorianDataSource"/>).
/// </summary>
/// <param name="source">The node's data-plane authorization inputs; may be <c>null</c> to restore the
/// empty default.</param>
/// <returns><c>true</c> when the source was set on a live node manager; <c>false</c> when no node
/// manager exists yet.</returns>
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;
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)
@@ -37,6 +37,24 @@ public enum ClusterFilterMode
Suppress,
}
/// <summary>
/// The data-plane authorization inputs decoded out of a deployment artifact for one node (#520).
/// </summary>
/// <param name="ClusterId">The node's cluster per its <c>ClusterNode</c> row, or null on a
/// single-cluster / legacy artifact where no scoping applies.</param>
/// <param name="Keys">Every <c>ApiKey</c> the artifact carries, revoked and expired included — the
/// lookup filters those, so that a revocation is visible rather than indistinguishable from a
/// missing key.</param>
/// <param name="Grants">The <c>NodeAcl</c> rows scoped to <paramref name="ClusterId"/>.</param>
public sealed record DataPlaneAuthorityProjection(
string? ClusterId,
IReadOnlyList<ApiKey> Keys,
IReadOnlyList<NodeAcl> Grants)
{
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
public static readonly DataPlaneAuthorityProjection Empty = new(null, [], []);
}
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
/// <param name="Mode">
/// None = apply everything (single-cluster / legacy); ScopeTo = filter to <paramref name="ClusterId"/>;
@@ -534,6 +552,170 @@ public static class DeploymentArtifact
: all;
}
/// <summary>
/// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions:
/// the <c>ApiKeys</c> the artifact carries and the <c>NodeAcls</c> granting their principals
/// permissions (#520, stage 4).
/// </summary>
/// <remarks>
/// <para>
/// This is the ONLY key source a <b>driver-only</b> node has — per-cluster mesh Phase 4 left
/// it with no ConfigDb, so without this it denies every UserName token.
/// </para>
/// <para>
/// Grants are scoped to the node's own cluster (<see cref="ResolveClusterScope"/>); keys are
/// not, because <c>ApiKey</c> 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 <see cref="ClusterFilterMode.Suppress"/>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <param name="nodeId">The node's identity, used to resolve cluster scope for the grants.</param>
/// <returns>The node's cluster (null when single-cluster), its keys, and its cluster's grants.</returns>
public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan<byte> 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<ApiKey>();
foreach (var el in EnumerateArray(root, "ApiKeys"))
{
var key = TryReadApiKey(el);
if (key is not null) keys.Add(key);
}
var grants = new List<NodeAcl>();
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;
}
}
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
/// missing, so a half-written row can never authenticate.</summary>
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"),
};
}
/// <summary>Reads one <c>NodeAcls</c> element; null when identity, cluster or scope kind is unreadable.</summary>
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"),
};
}
/// <summary>Reads a boolean property; null when absent or not a JSON boolean.</summary>
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;
/// <summary>Reads an int property; null when absent, not a number, or out of int range.</summary>
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;
/// <summary>Reads a UTC DateTime property; null when absent, JSON null, or unparseable.</summary>
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),
};
}
/// <summary>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
/// <see cref="InvalidOperationException"/> from <c>GetString()</c>. Keeps the artifact-decode lenient
@@ -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<OtOpcUaConfigDbContext>? _dbFactory;
private readonly AddressSpaceApplier? _applier;
/// <summary>#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).</summary>
private readonly IDataPlaneAuthoritySink? _dataPlaneAuthority;
private readonly IActorRef? _dbHealthProbe;
/// <summary>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);
/// <summary>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.</summary>
@@ -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));
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
@@ -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
}
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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");
}
}
/// <summary>Read a specific deployment's artifact blob from ConfigDb (the one just applied,
/// which may not be Sealed yet). Empty array on any failure — <see cref="HandleRebuild"/> treats that as
/// "no answer" and abandons the rebuild, leaving the served address space untouched (issue #485).</summary>
@@ -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<AddressSpaceApplier>(),
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<IDataPlaneAuthoritySink>();
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<OpcUaPublishActorKey>(publishActor);
@@ -21,6 +21,10 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
<!-- #520 stage 4: the artifact-apply path feeds IDataPlaneAuthoritySink. Core already arrives
transitively via OpcUaServer, but a transitive reference is not a stated dependency — make it
explicit so removing it from OpcUaServer does not silently break this one. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<!--
Concrete OtOpcUa.Driver.* assemblies are loaded reflectively at runtime (Phase 6,
Task 41+) and intentionally NOT project-referenced here. The driver contracts live in
@@ -0,0 +1,190 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
/// <summary>
/// The node-local holder of the data-plane authorization inputs (#520 stage 4): generation stamping,
/// and the key/grant lookups session activation runs against.
/// </summary>
public sealed class DataPlaneAuthorityStateTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Before_any_artifact_the_node_authenticates_nothing()
{
var sut = new DataPlaneAuthorityState();
sut.Current.HasArtifact.ShouldBeFalse();
sut.Current.GenerationId.ShouldBe(0);
sut.Current.FindUsableKey("anything", Now).ShouldBeNull();
sut.Current.GrantsFor("anyone").ShouldBeEmpty();
}
[Fact]
public void Each_new_revision_advances_the_generation()
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-2", [], []).GenerationId.ShouldBe(2);
}
[Fact]
public void Re_applying_the_same_revision_does_not_advance_the_generation()
{
// A node re-applies the same artifact on restart and on a redundant redeploy. Bumping the
// generation there would invalidate every cached authorization decision for no reason.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
sut.Apply(null, "rev-1", [], []).GenerationId.ShouldBe(1);
}
[Fact]
public void The_generation_only_ever_increases_even_when_the_hash_decreases()
{
// This is the whole reason the generation is a COUNTER rather than bits taken from the revision
// hash. PermissionTrieCache treats the highest GenerationId as current, so a generation derived
// from an unordered hash would, about half the time, install a LOWER number than the deployment
// it replaces — and the cache would go on serving the PREVIOUS deployment's ACLs with nothing
// logged. Applying hashes in descending order must still produce ascending generations.
var sut = new DataPlaneAuthorityState();
var generations = new[] { "ffff", "cccc", "aaaa", "0001" }
.Select(rev => sut.Apply(null, rev, [], []).GenerationId)
.ToArray();
generations.ShouldBe([1, 2, 3, 4]);
}
[Fact]
public void A_usable_key_is_found_by_id()
{
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
sut.Current.FindUsableKey("abc", Now)!.Principal.ShouldBe("p");
}
[Fact]
public void A_key_id_matches_case_insensitively()
{
// Key ids are minted lowercase hex, but an operator copying one out of the AdminUI can upper-case
// it. A case-sensitive miss would read to them as "the key was revoked".
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("abc", "p")], []);
sut.Current.FindUsableKey("ABC", Now).ShouldNotBeNull();
}
[Fact]
public void A_revoked_key_is_not_found()
{
var revoked = Key("abc", "p");
revoked.Enabled = false;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [revoked], []);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull();
}
[Fact]
public void An_expired_key_is_not_found_but_the_same_key_is_found_before_its_expiry()
{
// The pair matters: asserting only the expired case passes just as well against a lookup that
// rejects every key with an ExpiresUtc set at all.
var expiring = Key("abc", "p");
expiring.ExpiresUtc = Now;
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [expiring], []);
sut.Current.FindUsableKey("abc", Now).ShouldBeNull("expiry is inclusive — at the instant it expires it is gone");
sut.Current.FindUsableKey("abc", Now.AddSeconds(-1)).ShouldNotBeNull();
}
[Fact]
public void Grants_are_returned_for_the_named_principal_only()
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [],
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "bob", NodePermissions.WriteOperate),
]);
sut.Current.GrantsFor("alice").ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-1");
sut.Current.GrantsFor("nobody").ShouldBeEmpty();
}
[Fact]
public void Several_grants_for_one_principal_are_all_returned()
{
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [],
[
Acl("acl-1", "alice", NodePermissions.Read),
Acl("acl-2", "alice", NodePermissions.WriteOperate),
]);
sut.Current.GrantsFor("alice").Count.ShouldBe(2);
}
[Fact]
public void Applying_a_new_revision_replaces_the_previous_keys_rather_than_merging_them()
{
// Merging would make revocation impossible: a key dropped from the artifact would live on in the
// node's memory forever.
var sut = new DataPlaneAuthorityState();
sut.Apply(null, "rev-1", [Key("old", "p")], []);
sut.Apply(null, "rev-2", [Key("new", "p")], []);
sut.Current.FindUsableKey("old", Now).ShouldBeNull();
sut.Current.FindUsableKey("new", Now).ShouldNotBeNull();
}
[Fact]
public void A_duplicate_key_id_does_not_throw()
{
// Cannot happen from the database (the id is uniquely indexed), so this only guards a corrupt
// artifact — where refusing to build the snapshot would leave the node authenticating nothing.
var sut = new DataPlaneAuthorityState();
Should.NotThrow(() => sut.Apply(null, "rev-1", [Key("dup", "a"), Key("dup", "b")], []));
sut.Current.FindUsableKey("dup", Now).ShouldNotBeNull();
}
[Fact]
public void The_cluster_id_is_carried_through()
{
// The node manager's only source of cluster identity; NodeScope.ClusterId is required and the
// evaluator fails closed when it disagrees with the session's.
var sut = new DataPlaneAuthorityState();
sut.Apply("cluster-a", "rev-1", [], []);
sut.Current.ClusterId.ShouldBe("cluster-a");
}
private static ApiKey Key(string id, string principal) => new()
{
ApiKeyId = id,
Name = id,
SecretHash = "hash",
Principal = principal,
Enabled = true,
CreatedUtc = Now.AddDays(-1),
};
private static NodeAcl Acl(string id, string principal, NodePermissions flags) => new()
{
NodeAclId = id,
ClusterId = "cluster-a",
Principal = principal,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = flags,
};
}
@@ -0,0 +1,272 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// End-to-end contract between the two halves of #520 stage 4: <see cref="ConfigComposer"/> writes the
/// API keys + ACL grants into the artifact, and <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/>
/// reads them back out on the node.
/// </summary>
/// <remarks>
/// These deliberately compose a REAL artifact rather than hand-writing the JSON. A hand-written fixture
/// pins the parser against my own idea of the format, not against the producer — the failure it would
/// miss is exactly the one that matters here: someone changes how the composer serialises (a naming
/// policy, a <c>JsonStringEnumConverter</c>) and every key and grant silently stops arriving at the
/// node, with no error anywhere and no test going red.
/// </remarks>
public sealed class DataPlaneAuthorityArtifactTests : ControlPlaneActorTestBase
{
private const string NodeA = "node-a:4840";
private const string NodeB = "node-b:4840";
[Fact]
public async Task A_key_survives_the_round_trip_through_a_composed_artifact()
{
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ApiKeys.Add(NewKey("abc123", "scadabridge-dcl"));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var key = parsed.Keys.ShouldHaveSingleItem();
key.ApiKeyId.ShouldBe("abc123");
key.Principal.ShouldBe("scadabridge-dcl");
key.SecretHash.ShouldBe("hash-of-abc123");
key.Enabled.ShouldBeTrue();
}
[Fact]
public async Task A_grant_survives_the_round_trip_with_its_scope_kind_and_permission_flags()
{
// The enum fields are the fragile part: ConfigComposer writes them as NUMBERS, so the parser reads
// int. If that ever diverges the grant is dropped and a key silently authenticates with no
// permissions — which looks exactly like a correctly-unprivileged key.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.NodeAcls.Add(NewAcl("acl-1", "cluster-a", "scadabridge-dcl",
NodeAclScopeKind.Cluster, NodePermissions.Read | NodePermissions.WriteOperate));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var grant = parsed.Grants.ShouldHaveSingleItem();
grant.Principal.ShouldBe("scadabridge-dcl");
grant.ScopeKind.ShouldBe(NodeAclScopeKind.Cluster);
grant.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
}
[Fact]
public async Task A_non_cluster_scope_kind_round_trips_as_itself()
{
// Falsifies "the enum happens to work because Cluster is 0". Equipment is 4, so a broken
// enum read would surface as Cluster here rather than as a dropped row.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
var acl = NewAcl("acl-1", "cluster-a", "p", NodeAclScopeKind.Equipment, NodePermissions.Read);
acl.ScopeId = "EQ-1";
db.NodeAcls.Add(acl);
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var grant = parsed.Grants.ShouldHaveSingleItem();
grant.ScopeKind.ShouldBe(NodeAclScopeKind.Equipment);
grant.ScopeId.ShouldBe("EQ-1");
}
[Fact]
public async Task An_expiry_round_trips_as_the_same_UTC_instant()
{
// Covers the round trip only. It does NOT cover the timezone hazard: the in-memory EF provider
// preserves DateTimeKind.Utc, so the composer writes a "Z" here and the parser's Unspecified
// branch never runs — verified by breaking that branch and watching this test still pass. SQL
// Server's datetime2 DOES materialise as Unspecified, so the branch is real in production and is
// pinned directly at the parser by
// DeploymentArtifactDataPlaneAuthorityTests.An_expiry_written_without_an_offset_is_read_as_UTC.
var expires = new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc);
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
var key = NewKey("abc123", "p");
key.ExpiresUtc = expires;
db.ApiKeys.Add(key);
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
var parsedKey = parsed.Keys.ShouldHaveSingleItem();
parsedKey.ExpiresUtc.ShouldNotBeNull();
parsedKey.ExpiresUtc!.Value.ShouldBe(expires);
parsedKey.ExpiresUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
}
[Fact]
public async Task Grants_are_scoped_to_the_nodes_own_cluster_but_keys_are_not()
{
// Multi-cluster artifact: node-a belongs to cluster-a, so it must not receive cluster-b's grants.
// Keys carry no ClusterId — a key is a fleet-wide credential, and it is the ACL rows that decide
// what it can reach — so BOTH keys arrive.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ServerClusters.Add(NewCluster("cluster-b"));
db.ClusterNodes.Add(NewNode(NodeA, "cluster-a"));
db.ClusterNodes.Add(NewNode(NodeB, "cluster-b"));
db.ApiKeys.Add(NewKey("key-a", "principal-a"));
db.ApiKeys.Add(NewKey("key-b", "principal-b"));
db.NodeAcls.Add(NewAcl("acl-a", "cluster-a", "principal-a", NodeAclScopeKind.Cluster, NodePermissions.Read));
db.NodeAcls.Add(NewAcl("acl-b", "cluster-b", "principal-b", NodeAclScopeKind.Cluster, NodePermissions.Read));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
parsed.ClusterId.ShouldBe("cluster-a");
parsed.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-a");
parsed.Keys.Select(k => k.ApiKeyId).OrderBy(x => x).ShouldBe(["key-a", "key-b"]);
}
[Fact]
public async Task A_node_absent_from_a_multi_cluster_artifact_receives_no_grants()
{
// ResolveClusterScope reads an unlisted node as Suppress — it was not deployed to. Handing it
// another cluster's grants would be the worst possible reading of that.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ServerClusters.Add(NewCluster("cluster-b"));
db.ClusterNodes.Add(NewNode(NodeB, "cluster-b"));
db.NodeAcls.Add(NewAcl("acl-b", "cluster-b", "principal-b", NodeAclScopeKind.Cluster, NodePermissions.Read));
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, "node-nowhere:4840");
parsed.Grants.ShouldBeEmpty();
}
[Fact]
public async Task A_revoked_key_still_ships_so_the_node_can_see_the_revocation()
{
// Filtering revoked keys out of the artifact would make a revocation indistinguishable from a key
// that was never deployed — and worse, would leave the node's previous snapshot as the last word
// on that id. The key ships with Enabled=false and the lookup denies it.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
var key = NewKey("abc123", "p");
key.Enabled = false;
db.ApiKeys.Add(key);
await db.SaveChangesAsync();
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var parsed = DeploymentArtifact.ParseDataPlaneAuthority(artifact.Blob, NodeA);
parsed.Keys.ShouldHaveSingleItem().Enabled.ShouldBeFalse();
}
[Fact]
public void An_empty_or_malformed_blob_yields_nothing()
{
DeploymentArtifact.ParseDataPlaneAuthority(ReadOnlySpan<byte>.Empty, NodeA)
.Keys.ShouldBeEmpty();
DeploymentArtifact.ParseDataPlaneAuthority("not json at all"u8, NodeA)
.Grants.ShouldBeEmpty();
}
[Fact]
public async Task Adding_a_key_changes_the_revision_hash()
{
// The node's generation stamp is driven by this hash, so a key change that did not move it would
// leave the node believing it had already applied the new authorization inputs.
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-a"));
await db.SaveChangesAsync();
}
var before = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash;
await using (var db = f.CreateDbContext())
{
db.ApiKeys.Add(NewKey("abc123", "p"));
await db.SaveChangesAsync();
}
var after = (await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext())).RevisionHash;
after.ShouldNotBe(before);
}
private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static ServerCluster NewCluster(string id) => new()
{
ClusterId = id,
Name = id,
Enterprise = "ent",
Site = "site",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
CreatedAt = FixedTimestamp,
};
private static ClusterNode NewNode(string nodeId, string clusterId) => new()
{
NodeId = nodeId,
ClusterId = clusterId,
Host = nodeId.Split(':')[0],
ApplicationUri = $"urn:{nodeId}",
CreatedBy = "test",
CreatedAt = FixedTimestamp,
};
private static ApiKey NewKey(string apiKeyId, string principal) => new()
{
ApiKeyRowId = Guid.NewGuid(),
ApiKeyId = apiKeyId,
Name = $"key {apiKeyId}",
SecretHash = $"hash-of-{apiKeyId}",
Principal = principal,
Enabled = true,
CreatedUtc = FixedTimestamp,
};
private static NodeAcl NewAcl(
string id, string clusterId, string principal, NodeAclScopeKind scopeKind, NodePermissions flags) => new()
{
NodeAclRowId = Guid.NewGuid(),
NodeAclId = id,
ClusterId = clusterId,
Principal = principal,
ScopeKind = scopeKind,
PermissionFlags = flags,
};
}
@@ -0,0 +1,109 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// The key source for a driver-only node (#520 stage 4) — the one with no ConfigDb, which until this
/// stage denied every UserName token.
/// </summary>
public sealed class ArtifactApiKeyStoreTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task A_node_that_has_not_been_deployed_to_authenticates_nothing()
{
var sut = new ArtifactApiKeyStore(new DataPlaneAuthorityState());
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
}
[Fact]
public async Task A_deployed_key_resolves_with_its_principals_cluster_grants()
{
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "scadabridge-dcl")],
[Acl("acl-1", "scadabridge-dcl", NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)]);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
found.ShouldNotBeNull();
found!.Key.Principal.ShouldBe("scadabridge-dcl");
found.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
}
[Fact]
public async Task Only_cluster_scoped_grants_are_returned()
{
// Matches ConfigDbApiKeyStore, which filters the same way in SQL. Narrower scopes are not yet
// enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately under-grants rather than
// promoting a scoped grant's flags to server-wide authority.
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1",
[Key("abc", "p")],
[
Acl("acl-cluster", "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
]);
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
}
[Fact]
public async Task A_revoked_key_is_denied_once_the_revoking_artifact_arrives()
{
// The whole revocation story for a driver-only node: it takes effect on the next deployment, and
// this is that deployment landing.
var state = new DataPlaneAuthorityState();
var sut = new ArtifactApiKeyStore(state);
state.Apply("cluster-a", "rev-1", [Key("abc", "p")], []);
(await sut.FindAsync("abc", Now, default)).ShouldNotBeNull();
var revoked = Key("abc", "p");
revoked.Enabled = false;
state.Apply("cluster-a", "rev-2", [revoked], []);
(await sut.FindAsync("abc", Now, default)).ShouldBeNull();
}
[Fact]
public async Task Anonymous_grants_resolve_without_a_key()
{
// The anonymous principal has grants but obviously no credential, so FindGrantsAsync is its path.
var state = new DataPlaneAuthorityState();
state.Apply("cluster-a", "rev-1", [],
[Acl("acl-anon", OpcUaPrincipals.Anonymous, NodeAclScopeKind.Cluster, NodePermissions.Read)]);
var grants = await new ArtifactApiKeyStore(state).FindGrantsAsync(OpcUaPrincipals.Anonymous, default);
grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.Read);
}
private static ApiKey Key(string id, string principal) => new()
{
ApiKeyId = id,
Name = id,
SecretHash = "hash",
Principal = principal,
Enabled = true,
CreatedUtc = Now.AddDays(-1),
};
private static NodeAcl Acl(string id, string principal, NodeAclScopeKind scope, NodePermissions flags) => new()
{
NodeAclId = id,
ClusterId = "cluster-a",
Principal = principal,
ScopeKind = scope,
PermissionFlags = flags,
};
}
@@ -0,0 +1,129 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Parser-level coverage for <see cref="DeploymentArtifact.ParseDataPlaneAuthority"/> (#520 stage 4)
/// over hand-written JSON — the shapes a composed artifact cannot produce from the in-memory EF
/// provider, plus the malformed rows the lenient-decode contract has to survive.
/// </summary>
/// <remarks>
/// The happy path is deliberately NOT tested here. It is tested in
/// <c>ControlPlane.Tests/DataPlaneAuthorityArtifactTests</c> against an artifact a real
/// <c>ConfigComposer</c> produced, because a hand-written fixture pins the parser against this file's
/// idea of the format rather than against the producer.
/// </remarks>
public sealed class DeploymentArtifactDataPlaneAuthorityTests
{
private const string NodeId = "node-a:4840";
[Fact]
public void An_expiry_written_without_an_offset_is_read_as_UTC()
{
// SQL Server's datetime2 carries no offset, so EF materialises it as DateTimeKind.Unspecified and
// the composer writes "2030-06-15T12:00:00" with no Z. Calling ToUniversalTime() on that would
// SUBTRACT the node's local offset from a value that is already UTC, moving every key's expiry by
// hours — earlier in the Americas, so keys would appear to expire before they should. The
// in-memory provider preserves Kind and cannot reproduce this, which is why it is pinned here.
var parsed = Parse($$"""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p","Enabled":true,
"CreatedUtc":"2026-01-01T00:00:00","ExpiresUtc":"2030-06-15T12:00:00"}]}
""");
var key = parsed.Keys.ShouldHaveSingleItem();
key.ExpiresUtc.ShouldBe(new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc));
key.ExpiresUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
}
[Fact]
public void An_expiry_written_with_a_Z_is_also_read_as_the_same_instant()
{
// The positive control for the test above: without it, a parser that ignored ExpiresUtc entirely
// and returned a hardcoded value would satisfy neither, but one that mangled only the Z-suffixed
// form would slip through.
var parsed = Parse("""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p","Enabled":true,
"ExpiresUtc":"2030-06-15T12:00:00Z"}]}
""");
parsed.Keys.ShouldHaveSingleItem().ExpiresUtc
.ShouldBe(new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc));
}
[Fact]
public void A_key_row_missing_its_secret_hash_is_dropped_rather_than_admitted()
{
// A half-written row must never become a credential. Dropping it denies that key id; keeping it
// with an empty hash would hand the verifier something to compare against.
var parsed = Parse("""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","Principal":"p","Enabled":true}]}
""");
parsed.Keys.ShouldBeEmpty();
}
[Fact]
public void A_key_row_with_no_Enabled_field_is_treated_as_revoked()
{
// The other direction would resurrect a revoked key out of a malformed artifact.
var parsed = Parse("""
{"ApiKeys":[{"ApiKeyId":"abc","Name":"k","SecretHash":"h","Principal":"p"}]}
""");
parsed.Keys.ShouldHaveSingleItem().Enabled.ShouldBeFalse();
}
[Fact]
public void An_out_of_range_scope_kind_drops_the_grant()
{
// Casting an undefined int straight to the enum would produce a scope the trie walker can never
// match, i.e. a grant that is silently unreachable rather than visibly absent.
var parsed = Parse("""
{"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":99,"PermissionFlags":1}]}
""");
parsed.Grants.ShouldBeEmpty();
}
[Fact]
public void A_grant_with_an_unreadable_permission_mask_grants_nothing()
{
// Defaulting a missing bitmask to anything other than None would invent authority.
var parsed = Parse("""
{"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":0}]}
""");
parsed.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.None);
}
[Fact]
public void A_string_valued_scope_kind_drops_the_grant()
{
// Guards the enum-serialization drift this repo has been bitten by before: if the composer ever
// gained a JsonStringEnumConverter, grants would arrive as "Cluster" instead of 0. That must fail
// loudly in tests rather than silently drop every grant in production — the ControlPlane
// round-trip test is what goes red; this one documents the shape the parser refuses.
var parsed = Parse("""
{"NodeAcls":[{"NodeAclId":"acl-1","ClusterId":"c","Principal":"p","ScopeKind":"Cluster","PermissionFlags":1}]}
""");
parsed.Grants.ShouldBeEmpty();
}
[Fact]
public void An_artifact_with_neither_section_yields_empty_lists_not_a_throw()
{
var parsed = Parse("""{"DriverInstances":[]}""");
parsed.Keys.ShouldBeEmpty();
parsed.Grants.ShouldBeEmpty();
parsed.ClusterId.ShouldBeNull();
}
private static DataPlaneAuthorityProjection Parse(string json) =>
DeploymentArtifact.ParseDataPlaneAuthority(Encoding.UTF8.GetBytes(json), NodeId);
}