fix(security): running drivers refuse out-of-namespace credential refs at resolve time (#834)
v2-ci / unit-tests (push) Has been cancelled

The #815 warn-only compatibility window is over. A running Galaxy driver
(InitializeAsync, every gateway ReopenAsync, the ack fallback password) and a
running OpcUaClient driver (session open) now refuse an out-of-namespace
credential reference with the same CredentialReferenceException that save,
deploy (CredentialReferenceOutOfScope), Test Connect and browse already raise,
before any store / environment / file read and before any endpoint is dialled.
The one existing scope check (CredentialReferences.Parse/ResolveAsync) is now
the only path; nothing new was added.

Removed:
- LegacyCredentialReferenceTracker (Core.Abstractions), its rate-limited WARN
  and the credential-compat:<field> diagnostics entry.
- The per-driver Legacy* resolve methods and every `legacy` parameter
  (GalaxySecretRef, GalaxyAckCredentialResolver, OpcUaUserIdentityFactory,
  OpcUaUserCertificatePaths, OpcUaClientSecretResolution).
- IDriverDiagnostics on GalaxyDriver / OpcUaClientDriver (it only carried the
  tracker's entries).
- The tracker also kept a pre-#836 absolute UserCertificatePath loading on a
  running OpcUaClient driver; that is refused now too.

Preflight / banner decision: KEPT, as a hard error. The Running source
(driver-health credential-compat entries) and CredentialReferenceBannerModel
are gone with the tracker, but the ConfigDb scan still tells an operator
something no other surface does: a Deployed finding is now a driver that is
not connecting, a Draft finding is a deploy the gate will refuse - named
before anyone deploys, e.g. after restoring an old ConfigDb backup or
importing a pre-#815 config. It also carries the #835 cross-instance and
#836/#850 path checks. CredentialReferencePreflightService now logs one Error
per finding (was Warning) with no warn-only wording, and the /raw banner is
alert-danger.

DEV and QA ConfigDbs hold no Galaxy/OpcUaClient driver (checked 2026-11-13);
no production install exists.

Tests: GalaxyDriverLegacyCredentialTests replaced by
GalaxyDriverCredentialScopeTests; new OpcUaClientDriverCredentialScopeTests;
tracker tests in OpcUaClientSecretResolutionTests /
OpcUaClientUserCertificatePathTests deleted; AdminUI preflight tests drop the
Running/banner-model cases and assert Error level. Against the old code the
new refusal tests fail (Galaxy 5/6, OpcUaClient 4/5; the two positive controls
pass on both).

docs/security.md: the migration table now says running drivers refuse; the
warn-only section is replaced by "The compatibility window is over".

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 02:04:18 -05:00
parent 2cb8a2c317
commit f0c6cf9640
19 changed files with 433 additions and 732 deletions
+5 -6
View File
@@ -382,11 +382,10 @@ panel with the NAT/IPv6-zone/loose-match caveats) / CLI `--validate-host-name`.
probe), browse (`BrowserSessionService`, before the browser) and at resolve time before any store/env read; `file:` is
refused everywhere. A NEW driver that resolves a `secret:`/`env:` field and sends it to an authored endpoint MUST add a
row there and resolve through `CredentialReferences` — never `ISecretResolver.GetAsync` on a raw authored name.
⚠️ **Deprecated warn-only window:** a RUNNING Galaxy (init, every `ReopenAsync` gateway reconnect, ack fallback) or
OpcUaClient (session open) driver passes its `LegacyCredentialReferenceTracker` and so still resolves a pre-#815
out-of-namespace ref with the OLD semantics + a rate-limited Warning + a `credential-compat:<field>` diagnostics entry;
browsers/probes pass none (hard-fail), Heidenhain never has one. Chosen by call site — do not add a global flag, and do
not hand the tracker to an admin-node path. Upgrade preflight: `CredentialReferencePreflightService` + the `/raw` banner.
Resolve time is hard-fail for EVERY caller, running drivers included (Galaxy init / every `ReopenAsync` / ack fallback,
OpcUaClient session open) — the #815 warn-only window (`LegacyCredentialReferenceTracker`) was deleted by #834; do not
reintroduce a compatibility path. Preflight (hard error, ConfigDb only): `CredentialReferencePreflightService` + the
red `/raw` banner (Draft = next deploy refused, Deployed = driver not connecting).
`docs/security.md` §"Credential references are namespaced per driver".
- **…and per instance (#835):** a `secret:<ns>/<X>/…` whose first segment `X` is ANOTHER same-type driver's id or name
(case-insensitive; another-instance match beats an own-name match) is refused at save (`DriverConfigSaveGate.ValidateAsync`),
@@ -405,7 +404,7 @@ panel with the NAT/IPv6-zone/loose-match caveats) / CLI `--validate-host-name`.
`Drivers:OpcUaClient:UserCertificateDirectory` (`DriverCredentialPolicy` path field + `CredentialFilePaths`, all seams;
load-time `ResolveWithin` = lexical + link-resolved containment, before the PFX password resolves). `LoadCertificate`
refuses an unresolved (relative) path — build identities only from `ResolveSecretRefsAsync` output. A running driver's
pre-#836 ABSOLUTE path stays warn-only (`LegacyCredentialReferenceTracker.ReportPath`).
pre-#836 ABSOLUTE path is refused too (warn-only window removed by #834).
- **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's
live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply`
(called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and
+32 -34
View File
@@ -1453,18 +1453,20 @@ Enforced, fail-closed, at every seam — the earlier ones exist for a clear mess
4. **Live browse** — `BrowserSessionService.OpenAsync` refuses before any bespoke or universal browser opens.
5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` and
`HeidenhainCredentials` all resolve through `CredentialReferences`, which re-checks the scope **before** any
secret-store or environment read. This is hard-fail on every admin-node path (the Galaxy and OpcUaClient
AdminUI browsers, every Test Connect probe) and everywhere for Heidenhain. The one exception is the deprecated
warn-only compatibility path for already-deployed drivers, below.
secret-store or environment read. This is hard-fail on **every** path — the running drivers (Galaxy
`InitializeAsync` / every gateway `ReopenAsync` / the ack fallback, OpcUaClient session open), the AdminUI
browsers and every Test Connect probe. The #815 warn-only window for already-deployed drivers is **over**
(lmxopcua#834, below).
Messages name the field and the allowed forms, never the value. Out of scope by design: `${secret:}` tokens in
`appsettings.json` (host config, not operator-authored), and the `Sql` driver's `connectionStringRef`, which is
already a fixed prefix (`Sql__ConnectionStrings__<ref>`) whose value carries its own server — a Designer can
choose *which* provisioned database a ref names, but cannot redirect its credentials to another host.
**Migration (existing configs).** A stored config with an out-of-namespace reference is listed by the upgrade
preflight (below), refused by its next deploy (`CredentialReferenceOutOfScope` — the message names the
migration), and refused by Test Connect / live browse. Values most likely to be in real config databases, with
**Migration (existing configs).** A stored config with an out-of-namespace reference is listed by the
credential-reference preflight (below), refused by its next deploy (`CredentialReferenceOutOfScope` — the message
names the migration), refused by Test Connect / live browse, and — since lmxopcua#834 — refused by a **running**
driver at resolve time: it does not connect until the field is migrated and redeployed. Values most likely to be in real config databases, with
the exact replacement (create the new secret / variable first — on every node that runs the driver **and** on
the admin nodes, which run Test Connect and browse — then edit the field):
@@ -1480,30 +1482,27 @@ the admin nodes, which run Test Connect and browse — then edit the field):
| OpcUaClient `SECRET:x` / `Secret:x` — pre-#815 resolved case-insensitively | prefixes are now exact | `secret:opcuaclient/<name>` |
| OpcUaClient **literal** password that happens to start with `env:`, `file:`, `secret:`/`${secret:` in any casing (e.g. a real password `Env:Prod1`) — pre-#815 sent it verbatim | it now parses as a reference | store it: `secret:opcuaclient/<name>` (a literal that does not start with one of those prefixes is unaffected) |
**Warn-only compatibility for already-deployed drivers (⚠️ deprecated — a later release fails closed).** Refusing
at resolve time would fault running plant drivers at the upgrade restart — and, for Galaxy, at **every gateway
reconnect**: `GalaxyDriver.ReopenAsync` (the `ReconnectSupervisor` callback) rebuilds the client options through
`BuildClientOptionsAsync`, which re-resolves `Gateway.ApiKeySecretRef` each time, so a running driver would fault
at its next reconnect, not at a deploy. So, for one compatibility window, the running-driver call sites — Galaxy
`InitializeAsync` / `ReopenAsync` / the ack-fallback resolver, and OpcUaClient session open — resolve an
out-of-namespace reference with the **old** semantics (Galaxy: case-insensitive `env:`/`file:`/`dev:`/`secret:`,
literal otherwise; OpcUaClient: case-insensitive `secret:`, literal otherwise) and log a Warning naming the
driver, field and allowed form (never the value), at most once per field per hour. The use also rides the
driver's diagnostics bag (`credential-compat:<field>`) to `/hosts` and to the `/raw` credential banner. This is
chosen **by call site**, not by a flag (`LegacyCredentialReferenceTracker`): the AdminUI browsers and every probe
never get the tracker and stay hard-fail, and Heidenhain (new driver, no legacy configs) is hard-fail everywhere.
It cannot be used to exfiltrate: save, Test Connect, browse and the deploy gate all refuse such a reference, so it
can only be the reference an administrator deployed **before** the upgrade, and it lives only until that driver's
next successful deploy (which requires migrating it). The hard-fail release deletes the tracker, the per-driver
`Legacy…` resolve methods and their `legacy` parameters — nothing else changes.
**The compatibility window is over (lmxopcua#834, 2026-11-13).** The first #815 release let an already-deployed
Galaxy / OpcUaClient driver keep resolving a pre-#815 out-of-namespace reference with the old semantics, with a
rate-limited Warning and a `credential-compat:<field>` diagnostics entry (`LegacyCredentialReferenceTracker`). That
path is deleted: every value in the table above — `env:MX_API_KEY` (the old Galaxy form default), any other
out-of-namespace `env:` / `secret:`, every `file:` and every non-lower-case prefix — now fails at resolve time with
the same `CredentialReferenceException` the save path, the deploy gate, Test Connect and browse raise, before any
store, environment or file read. For Galaxy that includes **every gateway reconnect** (`ReopenAsync` rebuilds the
client options through `BuildClientOptionsAsync`) and the ack fallback password (the acknowledge is refused); for
OpcUaClient it is the session open, before any endpoint is dialled. Removal was gated on the DEV and QA ConfigDbs
holding no Galaxy / OpcUaClient driver (checked 2026-11-13); no production install exists, so any future one gets the
strict behaviour from its first deploy. An upgrade from a pre-#815 build with such a config must migrate it **before**
the upgrade — the preflight below lists it.
**Upgrade preflight.** On an admin node, `CredentialReferencePreflightService` runs once, 15 s after
`ApplicationStarted` (never inside an Akka starter, and it resolves nothing — ConfigDb reads only). It scans
every credential-resolving driver's stored `DriverConfig` / `DeviceConfig` rows (**Draft**) and the latest
deployment artifact (**Deployed** — what the running drivers were given) and logs one Warning per finding:
driver, device, field, allowed form, never the value. The same list, plus running drivers that reported a
warn-only resolution (**Running**, from driver-health diagnostics, fleet-wide), is shown as a banner on `/raw`,
with a Re-check button.
**Credential-reference preflight (a hard error).** On an admin node, `CredentialReferencePreflightService` runs
once, 15 s after `ApplicationStarted` (never inside an Akka starter, and it resolves nothing — ConfigDb reads only).
It scans every credential-resolving driver's stored `DriverConfig` / `DeviceConfig` rows (**Draft** — a deploy the
gate will refuse) and the latest sealed deployment artifact (**Deployed** — a driver that refuses its credential and
does not connect) and logs one **Error** per finding: driver, device, field, allowed form, never the value. The same
list is shown as a red banner on `/raw`, with a Re-check button. It stays because it is the one place that names a
broken config *before* someone deploys it or wonders why a driver is down — e.g. after restoring an old ConfigDb
backup or importing a pre-#815 config — and it also carries the #835 cross-instance and #836/#850 path checks.
#### Per-instance credential references (#835, #848, #849)
@@ -1660,11 +1659,10 @@ service account can read (another product's client certificate, a host certifica
- **The admin nodes and the driver nodes must hold the same relative layout** (the browse loads the certificate on the
admin node).
- `UserCertificatePassword` stays covered by #815 (`secret:opcuaclient/…` / `env:OTOPCUA_OPCUACLIENT_…` / literal).
- **Already-running drivers (deprecated warn-only window):** a deployed driver whose path is a pre-#836 **absolute** path
keeps loading it, with a rate-limited Warning (driver + field, never the path) and a
`credential-compat:UserCertificatePath` diagnostics entry, until its next deploy (which the gate refuses). The AdminUI
browse never gets the tracker (hard-fail). Migration: move the PFX under the directory on every node that runs the
driver **and** the admin nodes, then set the field to its relative path.
- **Already-running drivers:** the #836 warn-only window ended with lmxopcua#834 — a deployed driver whose path is a
pre-#836 **absolute** path now refuses it at session open (`CredentialReferenceException`), exactly like the browse and
the deploy gate. Migration: move the PFX under the directory on every node that runs the driver **and** the admin
nodes, then set the field to its relative path and redeploy.
### Role grant source
@@ -428,8 +428,8 @@ CROSS APPLY (
/* ---------------------------------------------------------------------------------------------
Check 6 — #815 CredentialReferenceOutOfScope (+ #836 UserCertificatePath) (BLOCKING)
master: Configuration/Validation/CredentialReferenceDeployChecks.cs (a224fd63 #815; 63331b04 #836; 3dbfe6ec #850).
A RUNNING driver keeps resolving an old reference warn-only (LegacyCredentialReferenceTracker), but the NEXT deploy
is refused until the field is migrated. Same rule the AdminUI /raw banner and CredentialReferencePreflightService
Since lmxopcua#834 a RUNNING driver also refuses such a reference at resolve time (it does not connect), and the
NEXT deploy is refused until the field is migrated. Same rule the AdminUI /raw banner and CredentialReferencePreflightService
report (#834). Also lists config blobs that are not valid JSON (the gate reports those too).
UserCertificatePath: must be blank or a relative path of [A-Za-z0-9._-] segments inside
Drivers:OpcUaClient:UserCertificateDirectory; only checked where AuthType in the SAME object is absent/null/
@@ -1,104 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// ⚠️ DEPRECATED compatibility path (lmxopcua#815) — the ONE switch between "enforce" and "warn-only" credential
/// resolution, and it is chosen by CALL SITE, never by a global flag: a deployed, running driver (Galaxy
/// <c>InitializeAsync</c>/<c>ReopenAsync</c> + ack fallback, OpcUaClient session open) passes its instance's tracker
/// and so keeps resolving a pre-#815 out-of-namespace reference with its OLD semantics, warning instead of
/// faulting; every admin-node path (Test Connect probe, AdminUI live browse) and Heidenhain pass
/// <see langword="null"/> and stay hard-fail.
/// </summary>
/// <remarks>
/// <para>
/// Why it is safe to keep for running drivers: the AdminUI save path and the deploy gate refuse every
/// out-of-namespace reference, so a Designer can no longer CREATE one — what remains are configs deployed
/// before the upgrade, whose endpoint and reference an administrator already deployed. Such a config runs
/// only until its next deploy (which the gate refuses until it is migrated).
/// </para>
/// <para>
/// Removing it (the planned hard-fail release): delete this type, the per-driver <c>Legacy…</c> resolve
/// methods it gates and the <c>legacy</c> parameters; every caller then takes the enforce branch it already
/// has. Nothing else changes.
/// </para>
/// <para>
/// Each use is reported through <paramref name="warn"/> at most once per <see cref="WarnInterval"/> per field
/// (a Galaxy gateway reconnect re-resolves every time), and is exposed as <see cref="Diagnostics"/> so the
/// driver's <see cref="IDriverDiagnostics"/> bag carries it to the AdminUI (<c>/hosts</c> and the <c>/raw</c>
/// credential banner). Messages name the driver, field and allowed forms — never the value.
/// </para>
/// </remarks>
/// <param name="scope">The driver's scope (for the allowed forms).</param>
/// <param name="driverInstanceId">The driver instance (for messages).</param>
/// <param name="warn">Sink for a rate-limited warning line.</param>
/// <param name="clock">Clock (tests); null ⇒ <see cref="DateTime.UtcNow"/>.</param>
public sealed class LegacyCredentialReferenceTracker(
CredentialScope scope, string driverInstanceId, Action<string> warn, Func<DateTime>? clock = null)
{
/// <summary>The <see cref="Diagnostics"/> key prefix (one entry per field).</summary>
public const string DiagnosticsKeyPrefix = "credential-compat:";
/// <summary>Minimum spacing between two warnings for the same field.</summary>
public static readonly TimeSpan WarnInterval = TimeSpan.FromHours(1);
private readonly object _gate = new();
private readonly Dictionary<string, DateTime> _lastWarned = new(StringComparer.Ordinal);
private readonly SortedDictionary<string, string> _diagnostics = new(StringComparer.Ordinal);
/// <summary>The scope this tracker reports against.</summary>
public CredentialScope Scope => scope;
/// <summary>The current legacy uses, keyed <c>credential-compat:&lt;field&gt;</c>.</summary>
public IReadOnlyDictionary<string, string> Diagnostics
{
get
{
lock (_gate) return new Dictionary<string, string>(_diagnostics, StringComparer.Ordinal);
}
}
/// <summary>Records that <paramref name="field"/> was resolved through the legacy path (warns, rate-limited).</summary>
/// <param name="field">The field label.</param>
public void Report(string field)
{
var forms = $"secret:{scope.SecretNamespaces[0]}<name> or env:{scope.EnvVariablePrefixes[0]}<NAME>";
Record(
field,
$"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}",
$"{scope.Label} driver '{driverInstanceId}': {field} is a credential reference outside the {scope.Label} "
+ $"namespace. It is still resolved for this already-deployed driver (lmxopcua#815 warn-only "
+ $"compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: migrate "
+ $"it to {forms} before the next deploy. See docs/security.md §\"Credential references are namespaced per driver\".");
}
/// <summary>
/// Records that the key-material file field <paramref name="field"/> (lmxopcua#836, e.g. OpcUaClient
/// <c>UserCertificatePath</c>) was loaded from a path outside the node's certificate directory through the same
/// warn-only window (warns, rate-limited). Never carries the path.
/// </summary>
/// <param name="field">The field label.</param>
/// <param name="directoryDescription">How the operator configures the directory.</param>
public void ReportPath(string field, string directoryDescription) =>
Record(
field,
$"path outside the certificate directory still loaded (#836 warn-only, deprecated); next deploy refuses it — "
+ $"move the file under {directoryDescription} and author it as a relative path",
$"{scope.Label} driver '{driverInstanceId}': {field} is not a relative path inside the node's certificate "
+ $"directory ({directoryDescription}). It is still loaded for this already-deployed driver (lmxopcua#836 "
+ "warn-only compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: move "
+ "the file into that directory and set the field to its relative path before the next deploy. See "
+ "docs/security.md §\"Key-material file paths are confined to a node directory\".");
private void Record(string field, string diagnostic, string warning)
{
var now = (clock ?? (() => DateTime.UtcNow))();
bool log;
lock (_gate)
{
_diagnostics[DiagnosticsKeyPrefix + field] = diagnostic;
log = !_lastWarned.TryGetValue(field, out var last) || now - last >= WarnInterval;
if (log) _lastWarned[field] = now;
}
if (log) warn(warning);
}
}
@@ -36,32 +36,19 @@ public static class GalaxySecretRef
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
/// <param name="legacy">
/// ⚠️ Deprecated #815 compatibility: the RUNNING driver passes its tracker, so a pre-#815 out-of-namespace
/// reference is resolved with the old semantics and warned about instead of failing. The AdminUI browser
/// passes null (hard-fail). See <see cref="LegacyCredentialReferenceTracker"/>.
/// </param>
/// <returns>The resolved API-key string.</returns>
public static async Task<string> ResolveApiKeyAsync(
string secretRef,
ISecretResolver resolver,
ILogger? logger = null,
CancellationToken ct = default,
Func<string, string?>? environment = null,
LegacyCredentialReferenceTracker? legacy = null)
Func<string, string?>? environment = null)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
ArgumentNullException.ThrowIfNull(resolver);
var scope = DriverCredentialPolicy.GalaxyScope;
var kind = CredentialReferences.Parse(secretRef, scope, out _);
if (kind == CredentialReferenceKind.OutOfScope && legacy is not null)
{
legacy.Report("Gateway.ApiKeySecretRef");
return await LegacyResolveApiKeyAsync(secretRef, resolver, logger, ct, environment).ConfigureAwait(false);
}
if (kind == CredentialReferenceKind.Literal)
if (CredentialReferences.Parse(secretRef, scope, out _) == CredentialReferenceKind.Literal)
{
if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase))
{
@@ -77,7 +64,8 @@ public static class GalaxySecretRef
return secretRef;
}
// secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read.
// secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read — for
// every caller, the running driver included (lmxopcua#834 ended the #815 warn-only window).
return await CredentialReferences.ResolveAsync(
secretRef,
scope,
@@ -87,49 +75,4 @@ public static class GalaxySecretRef
environment,
ct).ConfigureAwait(false);
}
/// <summary>
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
/// verbatim — case-insensitive <c>env:</c> / <c>file:</c> / <c>dev:</c> / <c>secret:</c>, anything else a
/// literal. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver.
/// </summary>
private static async Task<string> LegacyResolveApiKeyAsync(
string secretRef, ISecretResolver resolver, ILogger? logger, CancellationToken ct, Func<string, string?>? environment)
{
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = (environment ?? Environment.GetEnvironmentVariable)(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef points at '{path}', which doesn't exist.");
var contents = (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return secretRef[4..];
if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef["secret:".Length..];
var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef resolves secret '{name}', but it is absent from the store (fail-closed).");
}
logger?.LogWarning("Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key.");
return secretRef;
}
}
@@ -38,17 +38,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// </para>
/// </remarks>
public sealed class GalaxyDriver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, IAlarmAcknowledgeConfirmation, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, IAlarmAcknowledgeConfirmation, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
// ⚠️ lmxopcua#815 warn-only compatibility (deprecated): this RUNNING driver keeps resolving a pre-#815
// out-of-namespace credential reference (API key on InitializeAsync AND every gateway ReopenAsync, plus the
// ack fallback password) with the old semantics, warning rate-limited and surfacing it on the diagnostics bag.
// The AdminUI browser and the probe never get a tracker, so they stay hard-fail.
private readonly LegacyCredentialReferenceTracker _legacyCredentials;
/// <inheritdoc />
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics;
/// <summary>Authored tags this driver could not dial — a TagConfig with no usable
/// <c>attributeRef</c> has no MXAccess address to bind.</summary>
private readonly SkippedTagLedger _skippedTags = new();
@@ -391,8 +382,6 @@ public sealed class GalaxyDriver
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
_legacyCredentials = new LegacyCredentialReferenceTracker(
DriverCredentialPolicy.GalaxyScope, _driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message));
_hierarchySource = hierarchySource;
_dataReader = dataReader;
_dataWriter = dataWriter;
@@ -400,7 +389,7 @@ public sealed class GalaxyDriver
_alarmAcknowledger = alarmAcknowledger;
_alarmFeed = alarmFeed;
_ackCredentialResolver = ackCredentialResolver
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials);
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger);
_ackConfirmations = new GalaxyAckConfirmationTracker(
_options.AlarmAck?.ConfirmTimeout ?? GalaxyAckConfirmationTracker.DefaultWindow, _logger, _driverInstanceId);
@@ -1488,7 +1477,8 @@ public sealed class GalaxyDriver
}
// Internal: the ONE client-options build InitializeAsync, ReopenAsync (every gateway reconnect) and the
// repository/alarm clients share — the #815 warn-only tests drive it directly to prove a reconnect re-resolves.
// repository/alarm clients share — the lmxopcua#834 tests drive it directly to prove a reconnect refuses an
// out-of-namespace reference exactly like the first init.
internal async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
{
@@ -1499,7 +1489,7 @@ public sealed class GalaxyDriver
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
// implementation; the secret: arm resolves through the shared ISecretResolver.
var apiKey = await GalaxySecretRef
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken, legacy: _legacyCredentials)
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
.ConfigureAwait(false);
return new MxGatewayClientOptions
{
@@ -22,7 +22,6 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
private readonly ISecretResolver _secrets;
private readonly ILogger _logger;
private readonly Func<string, string?>? _environment;
private readonly LegacyCredentialReferenceTracker? _legacy;
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredentialResolver"/> class.</summary>
/// <param name="driverInstanceId">The owning driver instance id (for messages).</param>
@@ -30,13 +29,11 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
/// <param name="secrets">The shared secret resolver.</param>
/// <param name="logger">Logger; never receives a secret.</param>
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
/// <param name="legacy">⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail.</param>
public GalaxyAckCredentialResolver(
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger,
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
Func<string, string?>? environment = null)
{
_environment = environment;
_legacy = legacy;
_driverInstanceId = driverInstanceId;
_options = options ?? new GalaxyAlarmAckOptions();
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
@@ -159,13 +156,6 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
/// </summary>
internal async Task<string?> ResolvePasswordRefAsync(string reference, CancellationToken ct)
{
if (_legacy is not null
&& CredentialReferences.Parse(reference, DriverCredentialPolicy.GalaxyScope, out _) == CredentialReferenceKind.OutOfScope)
{
_legacy.Report("AlarmAck.FallbackPasswordSecretRef");
return await LegacyResolvePasswordRefAsync(reference, ct).ConfigureAwait(false);
}
var resolved = await CredentialReferences.ResolveOrNullAsync(
reference,
DriverCredentialPolicy.GalaxyScope,
@@ -179,28 +169,6 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
: resolved;
}
/// <summary>
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
/// verbatim. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver.
/// </summary>
private async Task<string?> LegacyResolvePasswordRefAsync(string reference, CancellationToken ct)
{
if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}'))
reference = "secret:" + reference["${secret:".Length..^1];
if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false);
if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
return (_environment ?? Environment.GetEnvironmentVariable)(reference[4..]);
if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = reference[5..];
return File.Exists(path) ? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n') : null;
}
if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return reference[4..];
throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/dev: reference, not a literal.");
}
// Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal.
internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference);
}
@@ -41,26 +41,16 @@ public static class OpcUaUserCertificatePaths
/// <summary>
/// Resolves an authored <c>UserCertificatePath</c> to the file to load, fail-closed
/// (<see cref="CredentialReferenceException"/>). A fully-qualified (pre-#836) path is refused unless
/// <paramref name="legacy"/> is given (the RUNNING driver only — deprecated warn-only window), in which case it is
/// reported and returned verbatim, exactly as before #836. Anything else that is not an acceptable relative path is
/// always refused.
/// (<see cref="CredentialReferenceException"/>): anything that is not an acceptable relative path — a
/// fully-qualified (pre-#836) path included — is refused, for the running driver too (lmxopcua#834 ended the
/// #836 warn-only window).
/// </summary>
/// <param name="value">The authored path.</param>
/// <param name="directory">The directory; null ⇒ <see cref="CurrentDirectory"/>.</param>
/// <param name="legacy">The running driver's tracker; null ⇒ hard-fail.</param>
/// <returns>The path to load.</returns>
public static string Resolve(string value, string? directory = null, LegacyCredentialReferenceTracker? legacy = null)
public static string Resolve(string value, string? directory = null)
{
var field = DriverCredentialPolicy.OpcUaClientUserCertificatePathField;
// Only a fully-qualified path is a pre-#836 value worth keeping alive: a relative one resolved against the
// service's working directory, which was never a meaningful configuration.
if (legacy is not null && !CredentialFilePaths.IsAcceptableRelative(value) && Path.IsPathFullyQualified(value))
{
legacy.ReportPath(field.Name, field.DirectoryDescription);
return value;
}
return CredentialFilePaths.ResolveWithin(
value, directory ?? CurrentDirectory, $"OpcUaClientDriverOptions.{field.Name}", field.DirectoryDescription);
}
@@ -141,35 +141,29 @@ public static class OpcUaUserIdentityFactory
/// <param name="resolver">The shared secret resolver, or null when none is available.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
/// <param name="legacy">
/// ⚠️ Deprecated #815 compatibility: the RUNNING driver's session open passes its tracker, so a pre-#815
/// out-of-namespace value keeps the old semantics (a case-insensitive <c>secret:</c> resolves, anything else is
/// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail).
/// </param>
/// <param name="userCertificateDirectory">
/// lmxopcua#836: the directory a <see cref="OpcUaAuthType.Certificate"/> identity's
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/> is resolved inside (relative path, lexical +
/// link-resolved containment); null ⇒ <see cref="OpcUaUserCertificatePaths.CurrentDirectory"/>. The returned
/// options carry the resolved full path. An absolute (pre-#836) path is refused unless <paramref name="legacy"/>
/// is given, in which case it is loaded as before with a warn-only report.
/// options carry the resolved full path. An absolute (pre-#836) path is refused (lmxopcua#834: for the running
/// driver too).
/// </param>
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct,
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null,
string? userCertificateDirectory = null)
Func<string, string?>? environment = null, string? userCertificateDirectory = null)
{
ArgumentNullException.ThrowIfNull(options);
// #836 first: a refused certificate path fails before any secret/env read.
var certPath = options.AuthType == OpcUaAuthType.Certificate && !string.IsNullOrWhiteSpace(options.UserCertificatePath)
? OpcUaUserCertificatePaths.Resolve(options.UserCertificatePath, userCertificateDirectory, legacy)
? OpcUaUserCertificatePaths.Resolve(options.UserCertificatePath, userCertificateDirectory)
: options.UserCertificatePath;
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy)
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment)
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment, legacy)
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment)
.ConfigureAwait(false);
if (ReferenceEquals(password, options.Password)
@@ -184,20 +178,15 @@ public static class OpcUaUserIdentityFactory
private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct,
Func<string, string?>? environment, LegacyCredentialReferenceTracker? legacy)
Func<string, string?>? environment)
{
// A literal (or blank) passes through verbatim, reference-equal — the author already knows it.
var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _);
if (kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) return value;
if (kind == CredentialReferenceKind.OutOfScope && legacy is not null)
{
legacy.Report(fieldName);
return await LegacyResolveFieldAsync(value!, fieldName, resolver, ct).ConfigureAwait(false);
}
// lmxopcua#815: the value is sent to the operator-authored endpoint, so only secret:opcuaclient/… and
// env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read.
// env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read —
// for the running driver's session open too (lmxopcua#834 ended the #815 warn-only window).
return await CredentialReferences.ResolveAsync(
value,
DriverCredentialPolicy.OpcUaClientScope,
@@ -207,24 +196,4 @@ public static class OpcUaUserIdentityFactory
environment,
ct).ConfigureAwait(false);
}
/// <summary>
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
/// verbatim — a case-insensitive <c>secret:</c> resolves (fail-closed), anything else passes through as a
/// literal. Reached ONLY for an out-of-namespace value in an already-deployed, running driver.
/// </summary>
private static async Task<string?> LegacyResolveFieldAsync(
string value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
{
if (!value.StartsWith(CredentialReferences.SecretPrefix, StringComparison.OrdinalIgnoreCase)) return value;
var name = value[CredentialReferences.SecretPrefix.Length..];
if (resolver is null)
throw new InvalidOperationException(
$"OpcUaClientDriverOptions.{fieldName} references secret '{name}', but no secret store is available here (fail-closed).");
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(resolved)
? resolved
: throw new InvalidOperationException(
$"OpcUaClientDriverOptions.{fieldName} resolves secret '{name}', but it is absent from the store (fail-closed).");
}
}
@@ -29,16 +29,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// back on its own recovers in place.
/// </para>
/// </remarks>
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDriverDiagnostics, IDisposable, IAsyncDisposable
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDisposable, IAsyncDisposable
{
// ⚠️ lmxopcua#815 warn-only compatibility (deprecated): the session open of this RUNNING driver keeps
// resolving a pre-#815 out-of-namespace credential with the old semantics, warning rate-limited and surfacing
// it on the diagnostics bag. The AdminUI browser never gets a tracker, so it stays hard-fail.
private readonly LegacyCredentialReferenceTracker _legacyCredentials;
/// <inheritdoc />
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics;
/// <summary>Authored tags whose TagConfig carried no usable <c>nodeId</c>, so there is no upstream node
/// to dial. This driver's skip path was the quietest of the twelve: <c>NamespaceMap</c>
/// drops such an entry with a bare <c>continue</c> and not even a log line.</summary>
@@ -90,8 +82,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_diag = new OpcUaClientDiagnostics(driverInstanceId);
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
_legacyCredentials = new LegacyCredentialReferenceTracker(
DriverCredentialPolicy.OpcUaClientScope, driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message));
_sdkTelemetry = logger is null ? SdkTelemetry.Context : SdkTelemetry.ForLogger(logger);
PostRecoveryReconcileDelay = () => _options.Timeout + _options.KeepAliveInterval;
}
@@ -564,7 +554,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
// single resolve covers both paths.
var resolvedOptions = await OpcUaClientSecretResolution
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken, _legacyCredentials).ConfigureAwait(false);
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
var identity = BuildUserIdentity(resolvedOptions);
@@ -31,18 +31,16 @@ internal static class OpcUaClientSecretResolution
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
/// <param name="ct">Cancellation token for the async secret resolution.</param>
/// <param name="legacy">⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail.</param>
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
/// <exception cref="InvalidOperationException">
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
/// </exception>
internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct,
Core.Abstractions.LegacyCredentialReferenceTracker? legacy = null)
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(resolver);
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct, legacy: legacy);
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
}
}
@@ -1,41 +1,38 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Configuration
@inject CredentialReferencePreflightState State
@inject IDriverStatusSnapshotStore DriverStatus
@inject IServiceProvider Services
@* lmxopcua#815 upgrade preflight surface: stored / deployed configs whose credential references sit outside their
driver's namespace (the boot scan, re-runnable here) plus running drivers that reported resolving one through the
deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value.
The two sources are independent (lmxopcua#847): a failed ConfigDb scan shows a note, and the running findings
— read from the in-process health store — still render. See CredentialReferenceBannerModel. *@
@* lmxopcua#815 credential-reference preflight surface — a hard error since #834: stored / deployed configs whose
credential references sit outside their driver's namespace (the boot scan, re-runnable here). The warn-only
compatibility path for running drivers is gone, so a Deployed finding is a driver that refuses the reference at
resolve time and does not connect; a Draft finding is a deploy the gate will refuse. Never shows a value.
A failed re-check keeps the last successful scan's findings and says so. *@
@if (_model.Visible)
@if (_snapshot.Findings.Count > 0 || _snapshot.Error is not null)
{
<div class="alert alert-warning small mb-3" role="alert">
<div class="alert alert-danger small mb-3" role="alert">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<strong>Credential references outside their driver's namespace (lmxopcua#815).</strong>
The deploy gate refuses these; a driver deployed before the upgrade keeps resolving them in
deprecated warn-only mode until its next deploy. Migrate each to the allowed form — see
docs/security.md "Credential references are namespaced per driver".
The deploy gate refuses these, and a deployed driver refuses them when it resolves its credentials, so it
does not connect. Migrate each to the allowed form and redeploy — see docs/security.md
"Credential references are namespaced per driver".
</div>
<button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button>
</div>
@if (_model.ScanError is not null)
@if (_snapshot.Error is not null)
{
<div class="text-danger mt-1" data-testid="credential-scan-error">
@_model.ScanError Stored and deployed configs were not checked
@(_model.ScanFindings.Count > 0 ? "(the list below is from the last successful scan)" : "")
— running-driver findings below are still live.
<div class="mt-1" data-testid="credential-scan-error">
@_snapshot.Error Stored and deployed configs were not checked
@(_snapshot.Findings.Count > 0 ? "(the list below is from the last successful scan)" : "").
</div>
}
@if (_model.ScanFindings.Count > 0)
@if (_snapshot.Findings.Count > 0)
{
<ul class="mb-0 mt-2">
@foreach (var f in _model.ScanFindings.Take(MaxRows))
@foreach (var f in _snapshot.Findings.Take(MaxRows))
{
<li>
<span class="badge bg-secondary me-1">@f.Source</span>
@@ -46,26 +43,9 @@
</li>
}
</ul>
@if (_model.ScanFindings.Count > MaxRows)
@if (_snapshot.Findings.Count > MaxRows)
{
<div class="mt-1 text-muted">…and @(_model.ScanFindings.Count - MaxRows) more (see the node log).</div>
}
}
@if (_model.RunningFindings.Count > 0)
{
<ul class="mb-0 mt-2" data-testid="credential-running-findings">
@foreach (var f in _model.RunningFindings.Take(MaxRows))
{
<li>
<span class="badge bg-secondary me-1">@f.Source</span>
driver <span class="mono">@f.DriverName</span>
— @f.Problem
</li>
}
</ul>
@if (_model.RunningFindings.Count > MaxRows)
{
<div class="mt-1 text-muted">…and @(_model.RunningFindings.Count - MaxRows) more running.</div>
<div class="mt-1 text-muted">…and @(_snapshot.Findings.Count - MaxRows) more (see the node log).</div>
}
}
</div>
@@ -73,13 +53,10 @@
@code {
private const int MaxRows = 25;
private CredentialReferenceBannerModel _model = new([], [], null, null);
private CredentialReferencePreflightState.Snapshot _snapshot = new([], null, null);
private bool _busy;
protected override void OnInitialized() => Compose(State.Current);
private void Compose(CredentialReferencePreflightState.Snapshot snapshot)
=> _model = CredentialReferenceBannerModel.Compose(snapshot, DriverStatus.GetAll());
protected override void OnInitialized() => _snapshot = State.Current;
private async Task RecheckAsync()
{
@@ -87,29 +64,23 @@
try
{
var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>;
CredentialReferencePreflightState.Snapshot snapshot;
if (factory is null)
{
snapshot = State.Current with { Error = "No config DB on this node." };
}
else
{
try
{
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
State.Set(snapshot);
}
catch (Exception)
{
// Keep the last successful scan's findings (flagged as such by the note) — and, above all,
// recompose the RUNNING findings from the live health store rather than leaving whatever the
// last render had (lmxopcua#847).
snapshot = State.Current with { Error = "The credential-reference preflight could not read the config DB." };
}
_snapshot = State.Current with { Error = "No config DB on this node." };
return;
}
Compose(snapshot);
try
{
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
_snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
State.Set(_snapshot);
}
catch (Exception)
{
// Keep the last successful scan's findings, flagged as such by the note.
_snapshot = State.Current with { Error = "The credential-reference preflight could not read the config DB." };
}
}
finally
{
@@ -17,17 +17,18 @@ public enum CredentialFindingSource
/// <summary>A stored DriverConfig / DeviceConfig row (what the next deploy will gate).</summary>
Draft,
/// <summary>The latest deployment artifact (what the running drivers were given).</summary>
/// <summary>
/// The latest sealed deployment artifact (what the running drivers were given). Since lmxopcua#834 a driver
/// refuses such a reference at resolve time, so this driver is already failing, or fails on its next
/// initialise / reconnect.
/// </summary>
Deployed,
/// <summary>A running driver that reported resolving it through the #815 warn-only compatibility path.</summary>
Running,
}
/// <summary>One out-of-namespace credential reference (never carries the value).</summary>
/// <param name="Source">Where it was found.</param>
/// <param name="DriverType">The driver type.</param>
/// <param name="DriverName">The driver's display name (or instance id for a running driver).</param>
/// <param name="DriverName">The driver's display name.</param>
/// <param name="DriverInstanceId">The driver instance id.</param>
/// <param name="DeviceName">The device, when the reference sits in a DeviceConfig.</param>
/// <param name="Problem">The field and the allowed forms.</param>
@@ -35,10 +36,13 @@ public sealed record CredentialReferenceFinding(
CredentialFindingSource Source, string DriverType, string DriverName, string DriverInstanceId, string? DeviceName, string Problem);
/// <summary>
/// Upgrade preflight for lmxopcua#815: lists every stored or deployed DriverConfig / DeviceConfig whose credential
/// fields carry a reference outside the driver's namespace — configs written before the namespaces existed that
/// the deploy gate will now refuse (and that a running driver resolves only in warn-only compatibility mode).
/// Reads the ConfigDb only; resolves nothing, so it never touches the secret store or the environment.
/// Credential-reference preflight (lmxopcua#815, a hard error since #834): lists every stored or deployed
/// DriverConfig / DeviceConfig whose credential fields carry a reference outside the driver's namespace (or a
/// refused key-material path, #836/#850, or a cross-instance reference, #835) — configs written before the
/// namespaces existed, restored from a backup, or imported. A Draft finding is a deploy the gate will refuse; a
/// Deployed finding is a driver that refuses the reference at resolve time (it does not start, or stops on its
/// next reconnect). Reads the ConfigDb only; resolves nothing, so it never touches the secret store or the
/// environment.
/// </summary>
/// <param name="dbFactory">The config DB.</param>
public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
@@ -118,60 +122,6 @@ public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfig
return findings;
}
/// <summary>
/// Running drivers that reported a warn-only legacy resolution on their diagnostics bag
/// (<see cref="LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix"/>), fleet-wide via the driver-health feed.
/// </summary>
/// <param name="snapshots">The driver-health snapshots.</param>
/// <returns>The findings.</returns>
public static IReadOnlyList<CredentialReferenceFinding> RunningFindings(IEnumerable<Commons.Messages.Drivers.DriverHealthChanged> snapshots) =>
snapshots
.Where(s => s.Diagnostics is not null)
.SelectMany(s => s.Diagnostics!
.Where(kv => kv.Key.StartsWith(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix, StringComparison.Ordinal))
.Select(kv => new CredentialReferenceFinding(
CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null,
$"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}")))
// Both nodes of a redundant pair run the same driver and report the same finding; the health store
// keeps one snapshot per (driver, node) since lmxopcua#847, so collapse the identical pair here.
.Distinct()
.ToList();
}
/// <summary>
/// What the <c>/raw</c> credential banner renders (lmxopcua#847), composed from two INDEPENDENT sources: the
/// ConfigDb scan (draft + deployed rows) and the live driver-health feed (running drivers' warn-only
/// <c>credential-compat:*</c> resolutions). A failed ConfigDb scan must never hide the running findings — they
/// come from the in-process health store and are exactly what is still true while the database is unreachable.
/// </summary>
/// <param name="ScanFindings">Draft / Deployed findings from the last successful scan (empty when it failed).</param>
/// <param name="RunningFindings">Running-driver findings from the live health feed.</param>
/// <param name="ScanError">The scan failure note, or null when the scan succeeded (or has not run).</param>
/// <param name="ScannedAtUtc">When the scan last ran.</param>
public sealed record CredentialReferenceBannerModel(
IReadOnlyList<CredentialReferenceFinding> ScanFindings,
IReadOnlyList<CredentialReferenceFinding> RunningFindings,
string? ScanError,
DateTime? ScannedAtUtc)
{
/// <summary>Whether the banner renders at all.</summary>
public bool Visible => ScanFindings.Count > 0 || RunningFindings.Count > 0 || ScanError is not null;
/// <summary>Composes the banner from the last scan result and the current driver-health snapshots.</summary>
/// <param name="scan">The last preflight result.</param>
/// <param name="healthSnapshots">The live driver-health snapshots.</param>
/// <returns>The banner model.</returns>
public static CredentialReferenceBannerModel Compose(
CredentialReferencePreflightState.Snapshot scan,
IEnumerable<Commons.Messages.Drivers.DriverHealthChanged> healthSnapshots)
{
ArgumentNullException.ThrowIfNull(scan);
var running = CredentialReferencePreflight.RunningFindings(healthSnapshots ?? []);
// Running-source rows only ever come from the health feed; a scan result never carries them.
var scanned = scan.Findings.Where(f => f.Source != CredentialFindingSource.Running).ToList();
return new(scanned, running, scan.Error, scan.ScannedAtUtc);
}
}
/// <summary>The last preflight result, shown by the AdminUI <c>/raw</c> credential banner.</summary>
@@ -194,7 +144,7 @@ public sealed class CredentialReferencePreflightState
}
/// <summary>
/// Runs <see cref="CredentialReferencePreflight"/> once after host start on an admin node and logs one Warning per
/// Runs <see cref="CredentialReferencePreflight"/> once after host start on an admin node and logs one Error per
/// finding (driver, device, field, allowed forms — never a value). Scheduled after <c>ApplicationStarted</c> plus a
/// short delay, off every Akka starter; it needs only the ConfigDb, never <c>ISecretResolver</c>.
/// </summary>
@@ -250,20 +200,20 @@ public sealed class CredentialReferencePreflightService(
state.Set(new(findings, DateTime.UtcNow, null));
foreach (var f in findings)
{
logger.LogWarning(
"Credential-reference preflight (lmxopcua#815): {Source} {DriverType} driver '{DriverName}'{Device}: {Problem} "
+ "The deploy gate refuses this; a running driver resolves it only in deprecated warn-only mode. "
+ "See docs/security.md \"Credential references are namespaced per driver\".",
logger.LogError(
"Credential-reference preflight (lmxopcua#815/#834): {Source} {DriverType} driver '{DriverName}'{Device}: {Problem} "
+ "The deploy gate refuses this, and a running driver refuses it at resolve time (the driver does not "
+ "connect). See docs/security.md \"Credential references are namespaced per driver\".",
f.Source, f.DriverType, f.DriverName, f.DeviceName is null ? "" : $" device '{f.DeviceName}'", f.Problem);
}
if (findings.Count == 0)
logger.LogInformation("Credential-reference preflight (lmxopcua#815): every stored and deployed reference is namespaced.");
logger.LogInformation("Credential-reference preflight (lmxopcua#815/#834): every stored and deployed reference is namespaced.");
return findings;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Credential-reference preflight (lmxopcua#815) could not read the config DB.");
logger.LogWarning(ex, "Credential-reference preflight (lmxopcua#815/#834) could not read the config DB.");
state.Set(new([], DateTime.UtcNow, "The credential-reference preflight could not read the config DB."));
return [];
}
@@ -0,0 +1,190 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// lmxopcua#834 (follow-up to #815): the warn-only compatibility window is over. A RUNNING Galaxy driver whose
/// stored config carries an out-of-namespace credential reference — the old AdminUI form default
/// <c>env:MX_API_KEY</c>, any <c>file:</c> reference, a <c>secret:</c> outside <c>galaxy/</c> — now refuses it at
/// resolve time (<see cref="CredentialReferenceException"/>, before any store / environment / file read), on
/// <c>InitializeAsync</c>, on every gateway reconnect, and on the ack fallback password — exactly as save, deploy,
/// Test Connect and browse already did.
/// </summary>
public sealed class GalaxyDriverCredentialScopeTests
{
private const string LegacyVariable = "MX_API_KEY_834_TEST";
private static GalaxyDriverOptions Opts(string apiKeyRef, string? ackRef = null) => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", apiKeyRef),
new GalaxyMxAccessOptions("Scope834"),
new GalaxyRepositoryOptions(WatchDeployEvents: false),
new GalaxyReconnectOptions())
{
AlarmAck = ackRef is null ? new GalaxyAlarmAckOptions() : new GalaxyAlarmAckOptions("ack-svc", null, ackRef),
};
/// <summary>
/// The old Galaxy form default (<c>env:MX_API_KEY</c>-style, an unprefixed variable) is refused on init and on
/// every reconnect — both build the client options through <see cref="GalaxyDriver.BuildClientOptionsAsync"/> —
/// even with the variable set, and the value never reaches the message.
/// </summary>
[Fact]
public async Task A_running_driver_refuses_an_out_of_namespace_env_ref_on_init_and_every_reconnect()
{
Environment.SetEnvironmentVariable(LegacyVariable, "the-real-key");
try
{
var logger = new CaptureLogger();
var options = Opts($"env:{LegacyVariable}");
using var driver = new GalaxyDriver("gx-legacy", options, new CountingResolver(), logger);
var ct = TestContext.Current.CancellationToken;
for (var i = 0; i < 2; i++)
{
var ex = await Should.ThrowAsync<CredentialReferenceException>(
() => driver.BuildClientOptionsAsync(options.Gateway, ct));
ex.Message.ShouldContain("Gateway.ApiKeySecretRef");
ex.Message.ShouldNotContain("the-real-key");
}
logger.AllText.ShouldNotContain("the-real-key");
}
finally
{
Environment.SetEnvironmentVariable(LegacyVariable, null);
}
}
/// <summary>A <c>file:</c> reference is refused before the file is opened, even when it exists.</summary>
[Fact]
public async Task A_running_driver_refuses_a_file_ref_even_when_the_file_exists()
{
var path = Path.Combine(Path.GetTempPath(), $"gx-834-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, "key-from-file");
try
{
var options = Opts($"file:{path}");
using var driver = new GalaxyDriver("gx-legacy-file", options, new CountingResolver());
var ex = await Should.ThrowAsync<CredentialReferenceException>(
() => driver.BuildClientOptionsAsync(options.Gateway, TestContext.Current.CancellationToken));
ex.Message.ShouldNotContain("key-from-file");
}
finally
{
File.Delete(path);
}
}
/// <summary>A <c>secret:</c> outside <c>galaxy/</c> (and a case-variant prefix) is refused without a store read.</summary>
[Theory]
[InlineData("secret:otopcua/configdb-password")]
[InlineData("SECRET:galaxy/api-key")]
public async Task A_running_driver_refuses_an_out_of_namespace_secret_ref_without_reading_the_store(string reference)
{
var store = new CountingResolver();
var options = Opts(reference);
using var driver = new GalaxyDriver("gx-legacy-secret", options, store);
await Should.ThrowAsync<CredentialReferenceException>(
() => driver.BuildClientOptionsAsync(options.Gateway, TestContext.Current.CancellationToken));
store.Calls.ShouldBe(0);
}
/// <summary>
/// The running driver's ack fallback password: an out-of-namespace reference refuses the acknowledge — nothing
/// reaches the gateway — instead of resolving it with the old semantics.
/// </summary>
[Fact]
public async Task A_running_driver_refuses_an_ack_with_an_out_of_namespace_fallback_password_ref()
{
Environment.SetEnvironmentVariable("LEGACY_ACK_PW_834", "pw");
try
{
var ack = new RecordingAcknowledger();
var logger = new CaptureLogger();
using var driver = new GalaxyDriver(
"gx-ack", Opts("dev:k", "env:LEGACY_ACK_PW_834"), hierarchySource: null, alarmAcknowledger: ack,
logger: logger, secretResolver: new CountingResolver());
await Should.ThrowAsync<DriverOperationRefusedException>(() => driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("src", "Tank01.Level.HiHi", null, OperatorUser: "anonymous")],
TestContext.Current.CancellationToken));
ack.Calls.ShouldBe(0);
logger.AllText.ShouldNotContain("lmxopcua#815 warn-only");
}
finally
{
Environment.SetEnvironmentVariable("LEGACY_ACK_PW_834", null);
}
}
/// <summary>Positive control: a namespaced reference still resolves through the same running-driver path.</summary>
[Fact]
public async Task A_namespaced_ref_still_resolves()
{
Environment.SetEnvironmentVariable("OTOPCUA_GALAXY_KEY_834", "namespaced-key");
try
{
var options = Opts("env:OTOPCUA_GALAXY_KEY_834");
using var driver = new GalaxyDriver("gx-ok", options, new CountingResolver());
(await driver.BuildClientOptionsAsync(options.Gateway, TestContext.Current.CancellationToken))
.ApiKey.ShouldBe("namespaced-key");
}
finally
{
Environment.SetEnvironmentVariable("OTOPCUA_GALAXY_KEY_834", null);
}
}
private sealed class CountingResolver : ISecretResolver
{
public int Calls { get; private set; }
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
Calls++;
return Task.FromResult<string?>("LEAKED");
}
}
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
{
public int Calls { get; private set; }
public Task<GalaxyAckOutcome> AcknowledgeAsync(
string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
{
Calls++;
return Task.FromResult(GalaxyAckOutcome.Applied);
}
}
private sealed class CaptureLogger : ILogger<GalaxyDriver>
{
private readonly List<string> _entries = [];
public string AllText
{
get
{
lock (_entries) return string.Join("\n", _entries);
}
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (_entries) _entries.Add(formatter(state, exception));
}
}
}
@@ -1,146 +0,0 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// lmxopcua#815 warn-only compatibility (deprecated): an ALREADY-DEPLOYED Galaxy driver whose config predates the
/// namespaces — e.g. the old AdminUI form default <c>env:MX_API_KEY</c> — keeps resolving it on init and on every
/// gateway reconnect (<c>ReopenAsync</c> → <c>BuildClientOptionsAsync</c>), warning rate-limited and surfacing it
/// on the diagnostics bag; while the admin-node paths (browser) stay hard-fail.
/// </summary>
public sealed class GalaxyDriverLegacyCredentialTests
{
private const string LegacyVariable = "MX_API_KEY_815_TEST";
private static GalaxyDriverOptions Opts(string apiKeyRef, string? ackRef = null) => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", apiKeyRef),
new GalaxyMxAccessOptions("Compat815"),
new GalaxyRepositoryOptions(WatchDeployEvents: false),
new GalaxyReconnectOptions())
{
AlarmAck = ackRef is null ? new GalaxyAlarmAckOptions() : new GalaxyAlarmAckOptions("ack-svc", null, ackRef),
};
[Fact]
public async Task A_running_driver_with_a_legacy_env_ref_resolves_on_init_and_every_reconnect_and_warns_once()
{
Environment.SetEnvironmentVariable(LegacyVariable, "the-real-key");
try
{
var logger = new CaptureLogger();
var driver = new GalaxyDriver("gx-legacy", Opts($"env:{LegacyVariable}"), new NullResolver(), logger);
var ct = TestContext.Current.CancellationToken;
// InitializeAsync and each ReopenAsync (gateway reconnect) build the client options through this one method.
(await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key");
(await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key");
(await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key");
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("lmxopcua#815")).ToList();
warnings.Count.ShouldBe(1); // rate-limited: one per field per hour, not one per reconnect
warnings[0].Message.ShouldContain("Gateway.ApiKeySecretRef");
warnings[0].Message.ShouldContain("env:OTOPCUA_GALAXY_<NAME>");
warnings[0].Message.ShouldNotContain("the-real-key");
var diagnostics = ((IDriverDiagnostics)driver).GetDiagnostics();
diagnostics.Keys.ShouldContain(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef");
diagnostics.Values.ShouldAllBe(v => !v.Contains("the-real-key"));
DriverDiagnostics.Bound(diagnostics).Values.ShouldAllBe(v => v.Length <= DriverDiagnostics.MaxValueLength);
}
finally
{
Environment.SetEnvironmentVariable(LegacyVariable, null);
}
}
[Fact]
public async Task A_running_driver_keeps_the_old_case_insensitive_and_file_semantics()
{
var path = Path.Combine(Path.GetTempPath(), $"gx-815-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " key-from-file \n");
try
{
var driver = new GalaxyDriver("gx-legacy-file", Opts($"file:{path}"), new NullResolver());
(await driver.BuildClientOptionsAsync(Opts($"file:{path}").Gateway, TestContext.Current.CancellationToken))
.ApiKey.ShouldBe("key-from-file");
((IDriverDiagnostics)driver).GetDiagnostics().ShouldNotBeEmpty();
}
finally
{
File.Delete(path);
}
}
[Fact]
public async Task A_namespaced_ref_resolves_without_a_compatibility_warning()
{
var logger = new CaptureLogger();
var driver = new GalaxyDriver("gx-ok", Opts("dev:k"), new NullResolver(), logger);
(await driver.BuildClientOptionsAsync(Opts("dev:k").Gateway, TestContext.Current.CancellationToken)).ApiKey.ShouldBe("k");
logger.Entries.ShouldNotContain(e => e.Message.Contains("lmxopcua#815"));
((IDriverDiagnostics)driver).GetDiagnostics().ShouldBeEmpty();
}
/// <summary>The same legacy ref through the admin-node browser path (no tracker) is refused before any read.</summary>
[Fact]
public async Task The_same_ref_without_a_tracker_is_refused()
{
var reads = 0;
await Should.ThrowAsync<CredentialReferenceException>(() => GalaxySecretRef.ResolveApiKeyAsync(
"env:MX_API_KEY", new NullResolver(), ct: TestContext.Current.CancellationToken,
environment: _ => { reads++; return "the-real-key"; }));
reads.ShouldBe(0);
}
[Fact]
public async Task The_running_ack_fallback_keeps_a_legacy_ref_and_warns()
{
var warnings = new List<string>();
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.GalaxyScope, "gx", warnings.Add);
var resolver = new GalaxyAckCredentialResolver(
"gx", new GalaxyAlarmAckOptions("ack-svc", null, "env:LEGACY_ACK_PW"), new NullResolver(), new CaptureLogger(),
n => n == "LEGACY_ACK_PW" ? "pw" : null, tracker);
(await resolver.ResolveAsync(null, TestContext.Current.CancellationToken)).Credential.ShouldNotBeNull().Password.ShouldBe("pw");
warnings.ShouldHaveSingleItem().ShouldContain("AlarmAck.FallbackPasswordSecretRef");
}
[Fact]
public void The_tracker_re_warns_after_the_interval()
{
var now = new DateTime(2026, 11, 11, 0, 0, 0, DateTimeKind.Utc);
var warnings = new List<string>();
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.GalaxyScope, "gx", warnings.Add, () => now);
tracker.Report("F");
tracker.Report("F");
now += LegacyCredentialReferenceTracker.WarnInterval;
tracker.Report("F");
tracker.Report("G");
warnings.Count.ShouldBe(3);
}
private sealed class NullResolver : ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
private sealed class CaptureLogger : ILogger<GalaxyDriver>
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
}
}
@@ -0,0 +1,113 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// lmxopcua#834 (follow-up to #815 / #836): the warn-only compatibility window is over. A RUNNING OpcUaClient
/// driver whose stored config carries an out-of-namespace credential reference, or a user-certificate path outside
/// the node's certificate directory, now refuses it at resolve time in the session open
/// (<see cref="CredentialReferenceException"/>, before any store / environment read and before any endpoint is
/// dialled) — exactly as save, deploy, Test Connect and browse already did.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientDriverCredentialScopeTests
{
private const string PlainPassword = "resolved-plaintext";
private static OpcUaClientDriverOptions Username(string password) => new()
{
EndpointUrl = "opc.tcp://127.0.0.1:1",
AuthType = OpcUaAuthType.Username,
Username = "plant-user",
Password = password,
};
/// <summary>
/// Out-of-namespace values that the pre-#834 running driver resolved with the old semantics: a <c>secret:</c>
/// outside <c>opcuaclient/</c>, a case-variant <c>SECRET:</c> prefix, and an <c>env:</c> outside
/// <c>OTOPCUA_OPCUACLIENT_</c> (which it sent verbatim as a literal password).
/// </summary>
[Theory]
[InlineData("secret:otopcua/configdb-password")]
[InlineData("SECRET:opcuaclient/inst/password")]
[InlineData("env:NOT_A_REF")]
public async Task A_running_driver_refuses_an_out_of_namespace_password_ref_before_reading_or_dialling(string reference)
{
var store = new CountingResolver();
using var driver = new OpcUaClientDriver(Username(reference), "ouc-834", secretResolver: store);
var opened = 0;
driver.OpenSessionForTest = (_, _) =>
{
opened++;
throw new InvalidOperationException("no server in this test");
};
var ex = await Should.ThrowAsync<CredentialReferenceException>(
() => driver.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Password");
ex.Message.ShouldNotContain(PlainPassword);
store.Calls.ShouldBe(0);
opened.ShouldBe(0, "the credential is refused before any endpoint is dialled");
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>lmxopcua#836: a fully-qualified (pre-#836) user-certificate path is refused, not loaded.</summary>
[Fact]
public async Task A_running_driver_refuses_an_absolute_user_certificate_path()
{
var abs = Path.Combine(Path.GetTempPath(), $"ouc-834-{Guid.NewGuid():N}.pfx");
var options = new OpcUaClientDriverOptions
{
EndpointUrl = "opc.tcp://127.0.0.1:1",
AuthType = OpcUaAuthType.Certificate,
UserCertificatePath = abs,
UserCertificatePassword = "pw",
};
using var driver = new OpcUaClientDriver(options, "ouc-834-cert", secretResolver: new CountingResolver());
var opened = 0;
driver.OpenSessionForTest = (_, _) =>
{
opened++;
throw new InvalidOperationException("no server in this test");
};
await Should.ThrowAsync<CredentialReferenceException>(
() => driver.InitializeAsync("{}", TestContext.Current.CancellationToken));
opened.ShouldBe(0);
}
/// <summary>Positive control: a namespaced reference resolves and the session open is reached.</summary>
[Fact]
public async Task A_namespaced_password_ref_resolves_and_the_session_open_is_reached()
{
var store = new CountingResolver();
using var driver = new OpcUaClientDriver(
Username("secret:opcuaclient/inst/password"), "ouc-834-ok", secretResolver: store);
var opened = 0;
driver.OpenSessionForTest = (_, _) =>
{
opened++;
throw new InvalidOperationException("no server in this test");
};
await Should.ThrowAsync<AggregateException>(
() => driver.InitializeAsync("{}", TestContext.Current.CancellationToken));
store.Calls.ShouldBe(1);
opened.ShouldBe(1);
}
private sealed class CountingResolver : ISecretResolver
{
public int Calls { get; private set; }
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
Calls++;
return Task.FromResult<string?>(PlainPassword);
}
}
}
@@ -124,35 +124,6 @@ public sealed class OpcUaClientSecretResolutionTests
resolved.Password.ShouldBe("pw");
}
/// <summary>
/// lmxopcua#815 warn-only compatibility (deprecated): the RUNNING driver's session open keeps the old semantics
/// for an out-of-namespace value — a case-insensitive <c>secret:</c> resolves, anything else is a literal — and
/// warns once; the browser (no tracker) refuses the same value (see the theory above).
/// </summary>
[Fact]
public async Task The_running_driver_keeps_old_semantics_for_a_legacy_value_and_warns()
{
var warnings = new List<string>();
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", warnings.Add);
var legacyOptions = new OpcUaClientDriverOptions
{
Password = $"SECRET:{KnownPwName}", // pre-#815: case-insensitive secret: resolved
UserCertificatePassword = "env:NOT_A_REF", // pre-#815: only secret: resolved — a literal password
};
for (var i = 0; i < 3; i++)
{
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
legacyOptions, FakeResolver(), CancellationToken.None, tracker);
resolved.Password.ShouldBe(KnownPwValue);
resolved.UserCertificatePassword.ShouldBe("env:NOT_A_REF");
}
warnings.Count.ShouldBe(2); // one per field, rate-limited across the three session opens
warnings.ShouldAllBe(w => !w.Contains(KnownPwValue));
tracker.Diagnostics.Count.ShouldBe(2);
}
private sealed class CountingResolver : ISecretResolver
{
public int Calls { get; private set; }
@@ -88,32 +88,6 @@ public sealed class OpcUaClientUserCertificatePathTests : IDisposable
Cert("user.pfx"), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir));
}
[Fact]
public async Task The_running_driver_keeps_loading_a_legacy_absolute_path_and_warns()
{
var abs = Path.Combine(_outside, "legacy.pfx");
WritePfx(abs);
var warnings = new List<string>();
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", warnings.Add);
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
Cert(abs), null, TestContext.Current.CancellationToken, legacy: tracker, userCertificateDirectory: _dir);
resolved.UserCertificatePath.ShouldBe(abs);
OpcUaUserIdentityFactory.Build(resolved).TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
warnings.ShouldHaveSingleItem().ShouldContain("lmxopcua#836");
warnings[0].ShouldNotContain(abs);
tracker.Diagnostics.ShouldContainKey(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "UserCertificatePath");
}
[Fact]
public async Task The_legacy_tracker_does_not_relax_a_relative_path_that_escapes()
{
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", _ => { });
await Should.ThrowAsync<CredentialReferenceException>(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
Cert("../x.pfx"), null, TestContext.Current.CancellationToken, legacy: tracker, userCertificateDirectory: _dir));
}
[Fact]
public async Task A_non_certificate_auth_type_leaves_the_path_alone()
{
@@ -6,7 +6,6 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -15,8 +14,9 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments;
/// <summary>
/// lmxopcua#815 upgrade preflight: stored (draft) and deployed configs with out-of-namespace credential references
/// are listed and logged — driver, device, field, allowed form — and never the value; nothing is resolved.
/// lmxopcua#815 credential-reference preflight (a hard error since #834): stored (draft) and deployed configs with
/// out-of-namespace credential references are listed and logged as errors — driver, device, field, allowed form —
/// and never the value; nothing is resolved.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CredentialReferencePreflightTests
@@ -85,7 +85,7 @@ public sealed class CredentialReferencePreflightTests
=> (await new CredentialReferencePreflight(RawTreeTestDb.Factory(RawTreeTestDb.SeedFresh())).ScanAsync(Ct)).ShouldBeEmpty();
[Fact]
public async Task The_boot_pass_logs_one_warning_per_finding_and_publishes_the_state()
public async Task The_boot_pass_logs_one_error_per_finding_and_publishes_the_state()
{
var services = new ServiceCollection()
.AddSingleton(RawTreeTestDb.Factory(SeedLegacy()))
@@ -99,10 +99,13 @@ public sealed class CredentialReferencePreflightTests
findings.Count.ShouldBe(3);
state.Current.Findings.Count.ShouldBe(3);
state.Current.ScannedAtUtc.ShouldNotBeNull();
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
warnings.Count.ShouldBe(3);
warnings.ShouldAllBe(w => w.Message.Contains("lmxopcua#815") && !w.Message.Contains("MX_API_KEY"));
warnings.ShouldContain(w => w.Message.Contains("Galaxy main") && w.Message.Contains("Gateway.ApiKeySecretRef"));
var errors = logger.Entries.Where(e => e.Level == LogLevel.Error).ToList();
errors.Count.ShouldBe(3);
errors.ShouldAllBe(w => w.Message.Contains("lmxopcua#815") && !w.Message.Contains("MX_API_KEY"));
errors.ShouldContain(w => w.Message.Contains("Galaxy main") && w.Message.Contains("Gateway.ApiKeySecretRef"));
// #834: no warn-only wording survives — a running driver refuses these.
errors.ShouldAllBe(w => !w.Message.Contains("warn-only") && w.Message.Contains("refuses it at resolve time"));
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
[Fact]
@@ -132,28 +135,6 @@ public sealed class CredentialReferencePreflightTests
await svc.StopAsync(Ct);
}
[Fact]
public void Running_drivers_reporting_warn_only_resolution_are_surfaced_from_driver_health()
{
var snapshots = new[]
{
new DriverHealthChanged("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow,
Diagnostics: new Dictionary<string, string>
{
[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated …",
["s7.pdu"] = "480",
}),
new DriverHealthChanged("c", "mb", "Healthy", null, null, 0, DateTime.UtcNow),
};
var running = CredentialReferencePreflight.RunningFindings(snapshots);
running.ShouldHaveSingleItem().ShouldSatisfyAllConditions(
f => f.Source.ShouldBe(CredentialFindingSource.Running),
f => f.DriverInstanceId.ShouldBe("gx-old"),
f => f.Problem.ShouldStartWith("Gateway.ApiKeySecretRef"));
}
private sealed class FakeLifetime : IHostApplicationLifetime
{
private readonly CancellationTokenSource _started = new();
@@ -184,48 +165,4 @@ public sealed class CredentialReferencePreflightTests
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
}
private static DriverHealthChanged LegacyRunning(string node) =>
new("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow,
Diagnostics: new Dictionary<string, string>
{
[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated",
},
ReportingNodeId: node);
[Fact]
public void Banner_keeps_running_findings_when_the_config_db_scan_failed_847()
{
// lmxopcua#847: a failed ConfigDb scan must not hide what the live health feed says is still running.
var failed = new CredentialReferencePreflightState.Snapshot([], DateTime.UtcNow, "could not read the config DB.");
var model = CredentialReferenceBannerModel.Compose(failed, [LegacyRunning("central-1")]);
model.Visible.ShouldBeTrue();
model.ScanError.ShouldNotBeNull();
model.ScanFindings.ShouldBeEmpty();
model.RunningFindings.ShouldHaveSingleItem().DriverInstanceId.ShouldBe("gx-old");
}
[Fact]
public void Banner_shows_a_scan_error_alone_and_hides_when_nothing_to_say_847()
{
CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, "db down"), []).ShouldSatisfyAllConditions(
m => m.Visible.ShouldBeTrue(),
m => m.RunningFindings.ShouldBeEmpty());
CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, null), []).Visible.ShouldBeFalse();
}
[Fact]
public void Banner_separates_scan_from_running_findings_and_dedups_the_redundant_pair_847()
{
var draft = new CredentialReferenceFinding(CredentialFindingSource.Draft, "Galaxy", "gx", "gx", null, "p");
var model = CredentialReferenceBannerModel.Compose(
new([draft], DateTime.UtcNow, null), [LegacyRunning("central-1"), LegacyRunning("central-2")]);
model.ScanFindings.ShouldHaveSingleItem().ShouldBe(draft);
// Both nodes run the driver and report the same thing — one line, not two.
model.RunningFindings.ShouldHaveSingleItem();
model.ScanError.ShouldBeNull();
}
}