fix(security): wrap the ConfigDb key store on admin nodes + registration guard

This commit is contained in:
Joseph Doherty
2026-08-20 09:45:08 -04:00
parent 6048257dfe
commit 39a10a277a
7 changed files with 100 additions and 9 deletions
@@ -645,3 +645,15 @@ for the orchestrator.
constructs one, and xUnit never runs two tests of one class in parallel. Adding a second class that
exercises the decorator means adding a discriminating tag.
- 14 tests in the class, green.
### T5 — wire the decorator + registration guard
- **RED observed:** `error CS1061: 'BoundedApiKeyStore' does not contain a definition for 'Inner'` /
`… for 'Clock'` — the guard could not see inside the wrapper because nothing was wrapping yet.
- **DEVIATION (small):** `BoundedApiKeyStore` gained public `Inner` and `Clock` properties, for the same
reason `DataPlaneAuthorizer.Clock` did in T1 — a guard that cannot see what is inside the wrapper cannot
tell a correctly-wired node from one wrapping the wrong store.
- Doc-drift half of the finding landed here: the authenticator's "no network I/O on this path at all"
remark is now qualified by node role and points at the bound, and `ConfigDbApiKeyStore` records both
that it is wrapped in production and that "revocation on the next request" is **activation**-scoped.
- Host.Tests: 123 green (was 106 at base).
@@ -35,7 +35,7 @@
{
"id": "T5",
"subject": "Wire BoundedApiKeyStore on admin/fused nodes in Program.cs + registration guard test; correct the authenticator's 'no network I/O at all' remark",
"status": "pending",
"status": "done",
"blockedBy": [
"T4"
]
@@ -67,9 +67,20 @@ public static class DataPlaneAuthRegistration
// NOT from IClusterRoleInfo: that depends on the ActorSystem, and the same options-not-services
// rule is what keeps the redundancy-singleton registration in Program.cs from recursing into
// ActorSystem construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter.
services.AddSingleton<IApiKeyStore>(sp => new ConfigDbApiKeyStore(
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
ConfigDbApiKeyStore.ResolveClusterId(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)));
//
// arch-review 03/S18 — and it is WRAPPED. The lookup runs inline in the SDK's synchronous
// impersonation callback, under a SessionManager-wide lock, with CancellationToken.None: an
// unanswered ConfigDb query therefore stalls every session activation in the server. The
// decorator adds the deadline that bounds that, plus a short TTL that turns a burst of
// activations into one round trip. It cannot admit anything the inner store did not.
services.AddSingleton<IApiKeyStore>(sp => new BoundedApiKeyStore(
new ConfigDbApiKeyStore(
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
ConfigDbApiKeyStore.ResolveClusterId(
sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)),
sp.GetRequiredService<IOptions<ApiKeyOptions>>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger<BoundedApiKeyStore>(),
sp.GetRequiredService<TimeProvider>()));
services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
@@ -18,9 +18,18 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <b>This runs inline on session activation.</b> The SDK raises <c>ImpersonateUser</c>
/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not
/// stall one request — it serializes every session activation server-wide. That is why
/// verification is a single indexed lookup plus one HMAC, and why there is no directory call,
/// no retry, and no network I/O on this path at all. It is the main practical advantage of keys
/// over the LDAP bind this replaces.
/// verification is a single indexed lookup plus one HMAC, with no directory call and no retry —
/// the main practical advantage of keys over the LDAP bind this replaces.
/// </para>
/// <para>
/// ⚠️ <b>"No network I/O" is TRUE ONLY ON A DRIVER-ONLY NODE</b> (arch-review 03/S18). The claim
/// describes <see cref="ArtifactApiKeyStore"/>, which is two dictionary probes on an in-memory
/// snapshot. On an <b>admin or fused</b> node <see cref="IApiKeyStore"/> is
/// <see cref="ConfigDbApiKeyStore"/>, and a UserName activation costs <b>two</b> EF round trips
/// (the key row, then the principal's <c>NodeAcl</c> rows) — inside this same lock. What bounds
/// that is <see cref="BoundedApiKeyStore"/>, which wraps it in production; the deadline it applies
/// surfaces here as an ordinary throw and is denied by the catch-all below, like any other store
/// failure.
/// </para>
/// <para>
/// <b>Fail closed, and say little.</b> Every rejection returns the same opaque message. The
@@ -81,6 +81,18 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
_time = time;
}
/// <summary>The store being bounded.</summary>
/// <remarks>
/// Exposed so the composition root's wiring can be asserted. A decorator registered in one branch
/// of a role conditional is the "built but never wired" shape, and a guard that cannot see what is
/// inside the wrapper cannot tell a correctly-wired node from one wrapping the wrong store.
/// </remarks>
public IApiKeyStore Inner => _inner;
/// <summary>The clock this bound's deadline and TTL are measured on.</summary>
/// <remarks>Same reason as <see cref="Inner"/>: the shared-clock invariant is only pinnable by reference.</remarks>
public TimeProvider Clock => _time;
/// <inheritdoc />
/// <remarks>
/// Keyed by <paramref name="apiKeyId"/> alone. <paramref name="utcNow"/> is deliberately not part
@@ -14,8 +14,19 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <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.
/// Reading per activation is what makes revocation prompt here, which is why this store is
/// preferred wherever a ConfigDb exists — including on a fused admin+driver node. ⚠️ It reaches a
/// <b>new session activation</b> only: an already-open session re-checks its key against the
/// in-memory artifact snapshot on every operation, not against this store, so clearing
/// <c>Enabled</c> here does not stop an open session until the next deployment. See
/// <c>docs/security.md</c>.
/// </para>
/// <para>
/// ⚠️ <b>In production this store is WRAPPED by <see cref="BoundedApiKeyStore"/></b> (arch-review
/// 03/S18), because these queries run inline in the SDK's impersonation callback under a
/// SessionManager-wide lock. A call may therefore be cancelled by the wrapper's deadline even
/// though the caller passed <see cref="CancellationToken.None"/> — the resulting
/// <see cref="OperationCanceledException"/> is a denial, not a bug.
/// </para>
/// <para>
/// ⚠️ <b>The ConfigDb holds the WHOLE fleet's ACL rows, not just this node's cluster's.</b> That is
@@ -7,6 +7,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
@@ -40,6 +41,41 @@ public sealed class ApiKeyStoreRegistrationTests
authorizer.Clock.ShouldBeSameAs(registered);
}
/// <summary>
/// An admin (or fused admin+driver) node's key store is the <see cref="BoundedApiKeyStore"/>
/// decorator, wrapping the ConfigDb store.
/// </summary>
/// <remarks>
/// The decorator lives in ONE branch of a role conditional, which is the "built but never wired"
/// shape OVERALL theme 3 names. Unwired, every unit test in <c>BoundedApiKeyStoreTests</c> still
/// passes and the deadline protects nothing.
/// </remarks>
[Fact]
public void An_admin_node_wraps_the_ConfigDb_store_in_the_bound()
{
using var sp = BuildNode(hasAdmin: true, hasDriver: true);
var store = sp.GetRequiredService<IApiKeyStore>().ShouldBeOfType<BoundedApiKeyStore>();
store.Inner.ShouldBeOfType<ConfigDbApiKeyStore>();
store.Clock.ShouldBeSameAs(sp.GetRequiredService<TimeProvider>());
}
/// <summary>
/// A driver-only node's key store is the bare artifact store — deliberately NOT wrapped.
/// </summary>
/// <remarks>
/// It is two dictionary probes on an in-memory snapshot, so it can neither hang nor be worth
/// caching; wrapping it would add a deadline to a call that cannot miss one and a second copy of
/// data the snapshot already holds.
/// </remarks>
[Fact]
public void A_driver_only_node_gets_the_bare_artifact_store()
{
using var sp = BuildNode(hasAdmin: false, hasDriver: true);
sp.GetRequiredService<IApiKeyStore>().ShouldBeOfType<ArtifactApiKeyStore>();
}
/// <summary>Builds the data-plane authentication wiring for one node role combination.</summary>
/// <param name="hasAdmin">Whether the node carries the <c>admin</c> role.</param>
/// <param name="hasDriver">Whether the node carries the <c>driver</c> role.</param>