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 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 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. 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 Resolve time is hard-fail for EVERY caller, running drivers included (Galaxy init / every `ReopenAsync` / ack fallback,
OpcUaClient (session open) driver passes its `LegacyCredentialReferenceTracker` and so still resolves a pre-#815 OpcUaClient session open) — the #815 warn-only window (`LegacyCredentialReferenceTracker`) was deleted by #834; do not
out-of-namespace ref with the OLD semantics + a rate-limited Warning + a `credential-compat:<field>` diagnostics entry; reintroduce a compatibility path. Preflight (hard error, ConfigDb only): `CredentialReferencePreflightService` + the
browsers/probes pass none (hard-fail), Heidenhain never has one. Chosen by call site — do not add a global flag, and do red `/raw` banner (Draft = next deploy refused, Deployed = driver not connecting).
not hand the tracker to an admin-node path. Upgrade preflight: `CredentialReferencePreflightService` + the `/raw` banner.
`docs/security.md` §"Credential references are namespaced per driver". `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 - **…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`), (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; `Drivers:OpcUaClient:UserCertificateDirectory` (`DriverCredentialPolicy` path field + `CredentialFilePaths`, all seams;
load-time `ResolveWithin` = lexical + link-resolved containment, before the PFX password resolves). `LoadCertificate` 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 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 - **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` 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 (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. 4. **Live browse** — `BrowserSessionService.OpenAsync` refuses before any bespoke or universal browser opens.
5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` and 5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` and
`HeidenhainCredentials` all resolve through `CredentialReferences`, which re-checks the scope **before** any `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 secret-store or environment read. This is hard-fail on **every** path — the running drivers (Galaxy
AdminUI browsers, every Test Connect probe) and everywhere for Heidenhain. The one exception is the deprecated `InitializeAsync` / every gateway `ReopenAsync` / the ack fallback, OpcUaClient session open), the AdminUI
warn-only compatibility path for already-deployed drivers, below. 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 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 `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 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. 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 **Migration (existing configs).** A stored config with an out-of-namespace reference is listed by the
preflight (below), refused by its next deploy (`CredentialReferenceOutOfScope` — the message names the credential-reference preflight (below), refused by its next deploy (`CredentialReferenceOutOfScope` — the message
migration), and refused by Test Connect / live browse. Values most likely to be in real config databases, with 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 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): 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 `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) | | 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 **The compatibility window is over (lmxopcua#834, 2026-11-13).** The first #815 release let an already-deployed
at resolve time would fault running plant drivers at the upgrade restart — and, for Galaxy, at **every gateway Galaxy / OpcUaClient driver keep resolving a pre-#815 out-of-namespace reference with the old semantics, with a
reconnect**: `GalaxyDriver.ReopenAsync` (the `ReconnectSupervisor` callback) rebuilds the client options through rate-limited Warning and a `credential-compat:<field>` diagnostics entry (`LegacyCredentialReferenceTracker`). That
`BuildClientOptionsAsync`, which re-resolves `Gateway.ApiKeySecretRef` each time, so a running driver would fault path is deleted: every value in the table above — `env:MX_API_KEY` (the old Galaxy form default), any other
at its next reconnect, not at a deploy. So, for one compatibility window, the running-driver call sites — Galaxy out-of-namespace `env:` / `secret:`, every `file:` and every non-lower-case prefix — now fails at resolve time with
`InitializeAsync` / `ReopenAsync` / the ack-fallback resolver, and OpcUaClient session open — resolve an the same `CredentialReferenceException` the save path, the deploy gate, Test Connect and browse raise, before any
out-of-namespace reference with the **old** semantics (Galaxy: case-insensitive `env:`/`file:`/`dev:`/`secret:`, store, environment or file read. For Galaxy that includes **every gateway reconnect** (`ReopenAsync` rebuilds the
literal otherwise; OpcUaClient: case-insensitive `secret:`, literal otherwise) and log a Warning naming the client options through `BuildClientOptionsAsync`) and the ack fallback password (the acknowledge is refused); for
driver, field and allowed form (never the value), at most once per field per hour. The use also rides the OpcUaClient it is the session open, before any endpoint is dialled. Removal was gated on the DEV and QA ConfigDbs
driver's diagnostics bag (`credential-compat:<field>`) to `/hosts` and to the `/raw` credential banner. This is holding no Galaxy / OpcUaClient driver (checked 2026-11-13); no production install exists, so any future one gets the
chosen **by call site**, not by a flag (`LegacyCredentialReferenceTracker`): the AdminUI browsers and every probe strict behaviour from its first deploy. An upgrade from a pre-#815 build with such a config must migrate it **before**
never get the tracker and stay hard-fail, and Heidenhain (new driver, no legacy configs) is hard-fail everywhere. the upgrade — the preflight below lists it.
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.
**Upgrade preflight.** On an admin node, `CredentialReferencePreflightService` runs once, 15 s after **Credential-reference preflight (a hard error).** On an admin node, `CredentialReferencePreflightService` runs
`ApplicationStarted` (never inside an Akka starter, and it resolves nothing — ConfigDb reads only). It scans once, 15 s after `ApplicationStarted` (never inside an Akka starter, and it resolves nothing — ConfigDb reads only).
every credential-resolving driver's stored `DriverConfig` / `DeviceConfig` rows (**Draft**) and the latest It scans every credential-resolving driver's stored `DriverConfig` / `DeviceConfig` rows (**Draft** — a deploy the
deployment artifact (**Deployed** — what the running drivers were given) and logs one Warning per finding: gate will refuse) and the latest sealed deployment artifact (**Deployed** — a driver that refuses its credential and
driver, device, field, allowed form, never the value. The same list, plus running drivers that reported a does not connect) and logs one **Error** per finding: driver, device, field, allowed form, never the value. The same
warn-only resolution (**Running**, from driver-health diagnostics, fleet-wide), is shown as a banner on `/raw`, 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
with a Re-check button. 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) #### 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 - **The admin nodes and the driver nodes must hold the same relative layout** (the browse loads the certificate on the
admin node). admin node).
- `UserCertificatePassword` stays covered by #815 (`secret:opcuaclient/…` / `env:OTOPCUA_OPCUACLIENT_…` / literal). - `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 - **Already-running drivers:** the #836 warn-only window ended with lmxopcua#834 — a deployed driver whose path is a
keeps loading it, with a rate-limited Warning (driver + field, never the path) and a pre-#836 **absolute** path now refuses it at session open (`CredentialReferenceException`), exactly like the browse and
`credential-compat:UserCertificatePath` diagnostics entry, until its next deploy (which the gate refuses). The AdminUI the deploy gate. Migration: move the PFX under the directory on every node that runs the driver **and** the admin
browse never gets the tracker (hard-fail). Migration: move the PFX under the directory on every node that runs the nodes, then set the field to its relative path and redeploy.
driver **and** the admin nodes, then set the field to its relative path.
### Role grant source ### Role grant source
@@ -428,8 +428,8 @@ CROSS APPLY (
/* --------------------------------------------------------------------------------------------- /* ---------------------------------------------------------------------------------------------
Check 6 — #815 CredentialReferenceOutOfScope (+ #836 UserCertificatePath) (BLOCKING) Check 6 — #815 CredentialReferenceOutOfScope (+ #836 UserCertificatePath) (BLOCKING)
master: Configuration/Validation/CredentialReferenceDeployChecks.cs (a224fd63 #815; 63331b04 #836; 3dbfe6ec #850). 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 Since lmxopcua#834 a RUNNING driver also refuses such a reference at resolve time (it does not connect), and the
is refused until the field is migrated. Same rule the AdminUI /raw banner and CredentialReferencePreflightService 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). 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 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/ 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="logger">Optional logger for warning on cleartext keys.</param>
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</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="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> /// <returns>The resolved API-key string.</returns>
public static async Task<string> ResolveApiKeyAsync( public static async Task<string> ResolveApiKeyAsync(
string secretRef, string secretRef,
ISecretResolver resolver, ISecretResolver resolver,
ILogger? logger = null, ILogger? logger = null,
CancellationToken ct = default, CancellationToken ct = default,
Func<string, string?>? environment = null, Func<string, string?>? environment = null)
LegacyCredentialReferenceTracker? legacy = null)
{ {
ArgumentException.ThrowIfNullOrEmpty(secretRef); ArgumentException.ThrowIfNullOrEmpty(secretRef);
ArgumentNullException.ThrowIfNull(resolver); ArgumentNullException.ThrowIfNull(resolver);
var scope = DriverCredentialPolicy.GalaxyScope; var scope = DriverCredentialPolicy.GalaxyScope;
var kind = CredentialReferences.Parse(secretRef, scope, out _); if (CredentialReferences.Parse(secretRef, scope, out _) == CredentialReferenceKind.Literal)
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 (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase)) if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase))
{ {
@@ -77,7 +64,8 @@ public static class GalaxySecretRef
return secretRef; 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( return await CredentialReferences.ResolveAsync(
secretRef, secretRef,
scope, scope,
@@ -87,49 +75,4 @@ public static class GalaxySecretRef
environment, environment,
ct).ConfigureAwait(false); 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> /// </para>
/// </remarks> /// </remarks>
public sealed class GalaxyDriver 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 /// <summary>Authored tags this driver could not dial — a TagConfig with no usable
/// <c>attributeRef</c> has no MXAccess address to bind.</summary> /// <c>attributeRef</c> has no MXAccess address to bind.</summary>
private readonly SkippedTagLedger _skippedTags = new(); private readonly SkippedTagLedger _skippedTags = new();
@@ -391,8 +382,6 @@ public sealed class GalaxyDriver
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger<GalaxyDriver>.Instance; _logger = logger ?? NullLogger<GalaxyDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance; _secretResolver = secretResolver ?? NullSecretResolver.Instance;
_legacyCredentials = new LegacyCredentialReferenceTracker(
DriverCredentialPolicy.GalaxyScope, _driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message));
_hierarchySource = hierarchySource; _hierarchySource = hierarchySource;
_dataReader = dataReader; _dataReader = dataReader;
_dataWriter = dataWriter; _dataWriter = dataWriter;
@@ -400,7 +389,7 @@ public sealed class GalaxyDriver
_alarmAcknowledger = alarmAcknowledger; _alarmAcknowledger = alarmAcknowledger;
_alarmFeed = alarmFeed; _alarmFeed = alarmFeed;
_ackCredentialResolver = ackCredentialResolver _ackCredentialResolver = ackCredentialResolver
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials); ?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger);
_ackConfirmations = new GalaxyAckConfirmationTracker( _ackConfirmations = new GalaxyAckConfirmationTracker(
_options.AlarmAck?.ConfirmTimeout ?? GalaxyAckConfirmationTracker.DefaultWindow, _logger, _driverInstanceId); _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 // 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( internal async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
GalaxyGatewayOptions gw, CancellationToken cancellationToken) GalaxyGatewayOptions gw, CancellationToken cancellationToken)
{ {
@@ -1499,7 +1489,7 @@ public sealed class GalaxyDriver
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one // (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
// implementation; the secret: arm resolves through the shared ISecretResolver. // implementation; the secret: arm resolves through the shared ISecretResolver.
var apiKey = await GalaxySecretRef var apiKey = await GalaxySecretRef
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken, legacy: _legacyCredentials) .ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return new MxGatewayClientOptions return new MxGatewayClientOptions
{ {
@@ -22,7 +22,6 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
private readonly ISecretResolver _secrets; private readonly ISecretResolver _secrets;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Func<string, string?>? _environment; private readonly Func<string, string?>? _environment;
private readonly LegacyCredentialReferenceTracker? _legacy;
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredentialResolver"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="GalaxyAckCredentialResolver"/> class.</summary>
/// <param name="driverInstanceId">The owning driver instance id (for messages).</param> /// <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="secrets">The shared secret resolver.</param>
/// <param name="logger">Logger; never receives a secret.</param> /// <param name="logger">Logger; never receives a secret.</param>
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</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( public GalaxyAckCredentialResolver(
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger, string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger,
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null) Func<string, string?>? environment = null)
{ {
_environment = environment; _environment = environment;
_legacy = legacy;
_driverInstanceId = driverInstanceId; _driverInstanceId = driverInstanceId;
_options = options ?? new GalaxyAlarmAckOptions(); _options = options ?? new GalaxyAlarmAckOptions();
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets)); _secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
@@ -159,13 +156,6 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
/// </summary> /// </summary>
internal async Task<string?> ResolvePasswordRefAsync(string reference, CancellationToken ct) 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( var resolved = await CredentialReferences.ResolveOrNullAsync(
reference, reference,
DriverCredentialPolicy.GalaxyScope, DriverCredentialPolicy.GalaxyScope,
@@ -179,28 +169,6 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
: resolved; : 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. // 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); internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference);
} }
@@ -41,26 +41,16 @@ public static class OpcUaUserCertificatePaths
/// <summary> /// <summary>
/// Resolves an authored <c>UserCertificatePath</c> to the file to load, fail-closed /// 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 /// (<see cref="CredentialReferenceException"/>): anything that is not an acceptable relative path — a
/// <paramref name="legacy"/> is given (the RUNNING driver only — deprecated warn-only window), in which case it is /// fully-qualified (pre-#836) path included — is refused, for the running driver too (lmxopcua#834 ended the
/// reported and returned verbatim, exactly as before #836. Anything else that is not an acceptable relative path is /// #836 warn-only window).
/// always refused.
/// </summary> /// </summary>
/// <param name="value">The authored path.</param> /// <param name="value">The authored path.</param>
/// <param name="directory">The directory; null ⇒ <see cref="CurrentDirectory"/>.</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> /// <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; 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( return CredentialFilePaths.ResolveWithin(
value, directory ?? CurrentDirectory, $"OpcUaClientDriverOptions.{field.Name}", field.DirectoryDescription); 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="resolver">The shared secret resolver, or null when none is available.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</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"> /// <param name="userCertificateDirectory">
/// lmxopcua#836: the directory a <see cref="OpcUaAuthType.Certificate"/> identity's /// lmxopcua#836: the directory a <see cref="OpcUaAuthType.Certificate"/> identity's
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/> is resolved inside (relative path, lexical + /// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/> is resolved inside (relative path, lexical +
/// link-resolved containment); null ⇒ <see cref="OpcUaUserCertificatePaths.CurrentDirectory"/>. The returned /// 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"/> /// options carry the resolved full path. An absolute (pre-#836) path is refused (lmxopcua#834: for the running
/// is given, in which case it is loaded as before with a warn-only report. /// driver too).
/// </param> /// </param>
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns> /// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync( public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct, OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct,
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null, Func<string, string?>? environment = null, string? userCertificateDirectory = null)
string? userCertificateDirectory = null)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
// #836 first: a refused certificate path fails before any secret/env read. // #836 first: a refused certificate path fails before any secret/env read.
var certPath = options.AuthType == OpcUaAuthType.Certificate && !string.IsNullOrWhiteSpace(options.UserCertificatePath) var certPath = options.AuthType == OpcUaAuthType.Certificate && !string.IsNullOrWhiteSpace(options.UserCertificatePath)
? OpcUaUserCertificatePaths.Resolve(options.UserCertificatePath, userCertificateDirectory, legacy) ? OpcUaUserCertificatePaths.Resolve(options.UserCertificatePath, userCertificateDirectory)
: options.UserCertificatePath; : 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); .ConfigureAwait(false);
var certPassword = await ResolveFieldAsync( var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment, legacy) options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment)
.ConfigureAwait(false); .ConfigureAwait(false);
if (ReferenceEquals(password, options.Password) if (ReferenceEquals(password, options.Password)
@@ -184,20 +178,15 @@ public static class OpcUaUserIdentityFactory
private static async Task<string?> ResolveFieldAsync( private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct, 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. // A literal (or blank) passes through verbatim, reference-equal — the author already knows it.
var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _); var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _);
if (kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) return value; 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 // 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( return await CredentialReferences.ResolveAsync(
value, value,
DriverCredentialPolicy.OpcUaClientScope, DriverCredentialPolicy.OpcUaClientScope,
@@ -207,24 +196,4 @@ public static class OpcUaUserIdentityFactory
environment, environment,
ct).ConfigureAwait(false); 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. /// back on its own recovers in place.
/// </para> /// </para>
/// </remarks> /// </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 /// <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> /// 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> /// 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); _diag = new OpcUaClientDiagnostics(driverInstanceId);
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance; _logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.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); _sdkTelemetry = logger is null ? SdkTelemetry.Context : SdkTelemetry.ForLogger(logger);
PostRecoveryReconcileDelay = () => _options.Timeout + _options.KeepAliveInterval; 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 // in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
// single resolve covers both paths. // single resolve covers both paths.
var resolvedOptions = await OpcUaClientSecretResolution var resolvedOptions = await OpcUaClientSecretResolution
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken, _legacyCredentials).ConfigureAwait(false); .ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
var identity = BuildUserIdentity(resolvedOptions); 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="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="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="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> /// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed). /// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
/// </exception> /// </exception>
internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync( internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct, OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
Core.Abstractions.LegacyCredentialReferenceTracker? legacy = null)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(resolver); ArgumentNullException.ThrowIfNull(resolver);
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session). // 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 Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments @using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@inject CredentialReferencePreflightState State @inject CredentialReferencePreflightState State
@inject IDriverStatusSnapshotStore DriverStatus
@inject IServiceProvider Services @inject IServiceProvider Services
@* lmxopcua#815 upgrade preflight surface: stored / deployed configs whose credential references sit outside their @* lmxopcua#815 credential-reference preflight surface — a hard error since #834: stored / deployed configs whose
driver's namespace (the boot scan, re-runnable here) plus running drivers that reported resolving one through the credential references sit outside their driver's namespace (the boot scan, re-runnable here). The warn-only
deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value. compatibility path for running drivers is gone, so a Deployed finding is a driver that refuses the reference at
The two sources are independent (lmxopcua#847): a failed ConfigDb scan shows a note, and the running findings resolve time and does not connect; a Draft finding is a deploy the gate will refuse. Never shows a value.
— read from the in-process health store — still render. See CredentialReferenceBannerModel. *@ 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 class="d-flex justify-content-between align-items-start gap-2">
<div> <div>
<strong>Credential references outside their driver's namespace (lmxopcua#815).</strong> <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 The deploy gate refuses these, and a deployed driver refuses them when it resolves its credentials, so it
deprecated warn-only mode until its next deploy. Migrate each to the allowed form — see does not connect. Migrate each to the allowed form and redeploy — see docs/security.md
docs/security.md "Credential references are namespaced per driver". "Credential references are namespaced per driver".
</div> </div>
<button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button> <button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button>
</div> </div>
@if (_model.ScanError is not null) @if (_snapshot.Error is not null)
{ {
<div class="text-danger mt-1" data-testid="credential-scan-error"> <div class="mt-1" data-testid="credential-scan-error">
@_model.ScanError Stored and deployed configs were not checked @_snapshot.Error Stored and deployed configs were not checked
@(_model.ScanFindings.Count > 0 ? "(the list below is from the last successful scan)" : "") @(_snapshot.Findings.Count > 0 ? "(the list below is from the last successful scan)" : "").
— running-driver findings below are still live.
</div> </div>
} }
@if (_model.ScanFindings.Count > 0) @if (_snapshot.Findings.Count > 0)
{ {
<ul class="mb-0 mt-2"> <ul class="mb-0 mt-2">
@foreach (var f in _model.ScanFindings.Take(MaxRows)) @foreach (var f in _snapshot.Findings.Take(MaxRows))
{ {
<li> <li>
<span class="badge bg-secondary me-1">@f.Source</span> <span class="badge bg-secondary me-1">@f.Source</span>
@@ -46,26 +43,9 @@
</li> </li>
} }
</ul> </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> <div class="mt-1 text-muted">…and @(_snapshot.Findings.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> </div>
@@ -73,13 +53,10 @@
@code { @code {
private const int MaxRows = 25; private const int MaxRows = 25;
private CredentialReferenceBannerModel _model = new([], [], null, null); private CredentialReferencePreflightState.Snapshot _snapshot = new([], null, null);
private bool _busy; private bool _busy;
protected override void OnInitialized() => Compose(State.Current); protected override void OnInitialized() => _snapshot = State.Current;
private void Compose(CredentialReferencePreflightState.Snapshot snapshot)
=> _model = CredentialReferenceBannerModel.Compose(snapshot, DriverStatus.GetAll());
private async Task RecheckAsync() private async Task RecheckAsync()
{ {
@@ -87,30 +64,24 @@
try try
{ {
var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>; var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>;
CredentialReferencePreflightState.Snapshot snapshot;
if (factory is null) if (factory is null)
{ {
snapshot = State.Current with { Error = "No config DB on this node." }; _snapshot = State.Current with { Error = "No config DB on this node." };
return;
} }
else
{
try try
{ {
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None); var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null); _snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
State.Set(snapshot); State.Set(_snapshot);
} }
catch (Exception) catch (Exception)
{ {
// Keep the last successful scan's findings (flagged as such by the note) — and, above all, // Keep the last successful scan's findings, flagged as such by the note.
// recompose the RUNNING findings from the live health store rather than leaving whatever the _snapshot = State.Current with { Error = "The credential-reference preflight could not read the config DB." };
// last render had (lmxopcua#847).
snapshot = State.Current with { Error = "The credential-reference preflight could not read the config DB." };
} }
} }
Compose(snapshot);
}
finally finally
{ {
_busy = false; _busy = false;
@@ -17,17 +17,18 @@ public enum CredentialFindingSource
/// <summary>A stored DriverConfig / DeviceConfig row (what the next deploy will gate).</summary> /// <summary>A stored DriverConfig / DeviceConfig row (what the next deploy will gate).</summary>
Draft, 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, 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> /// <summary>One out-of-namespace credential reference (never carries the value).</summary>
/// <param name="Source">Where it was found.</param> /// <param name="Source">Where it was found.</param>
/// <param name="DriverType">The driver type.</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="DriverInstanceId">The driver instance id.</param>
/// <param name="DeviceName">The device, when the reference sits in a DeviceConfig.</param> /// <param name="DeviceName">The device, when the reference sits in a DeviceConfig.</param>
/// <param name="Problem">The field and the allowed forms.</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); CredentialFindingSource Source, string DriverType, string DriverName, string DriverInstanceId, string? DeviceName, string Problem);
/// <summary> /// <summary>
/// Upgrade preflight for lmxopcua#815: lists every stored or deployed DriverConfig / DeviceConfig whose credential /// Credential-reference preflight (lmxopcua#815, a hard error since #834): lists every stored or deployed
/// fields carry a reference outside the driver's namespace — configs written before the namespaces existed that /// DriverConfig / DeviceConfig whose credential fields carry a reference outside the driver's namespace (or a
/// the deploy gate will now refuse (and that a running driver resolves only in warn-only compatibility mode). /// refused key-material path, #836/#850, or a cross-instance reference, #835) — configs written before the
/// Reads the ConfigDb only; resolves nothing, so it never touches the secret store or the environment. /// 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> /// </summary>
/// <param name="dbFactory">The config DB.</param> /// <param name="dbFactory">The config DB.</param>
public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
@@ -118,60 +122,6 @@ public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfig
return findings; 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> /// <summary>The last preflight result, shown by the AdminUI <c>/raw</c> credential banner.</summary>
@@ -194,7 +144,7 @@ public sealed class CredentialReferencePreflightState
} }
/// <summary> /// <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 /// 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>. /// short delay, off every Akka starter; it needs only the ConfigDb, never <c>ISecretResolver</c>.
/// </summary> /// </summary>
@@ -250,20 +200,20 @@ public sealed class CredentialReferencePreflightService(
state.Set(new(findings, DateTime.UtcNow, null)); state.Set(new(findings, DateTime.UtcNow, null));
foreach (var f in findings) foreach (var f in findings)
{ {
logger.LogWarning( logger.LogError(
"Credential-reference preflight (lmxopcua#815): {Source} {DriverType} driver '{DriverName}'{Device}: {Problem} " "Credential-reference preflight (lmxopcua#815/#834): {Source} {DriverType} driver '{DriverName}'{Device}: {Problem} "
+ "The deploy gate refuses this; a running driver resolves it only in deprecated warn-only mode. " + "The deploy gate refuses this, and a running driver refuses it at resolve time (the driver does not "
+ "See docs/security.md \"Credential references are namespaced per driver\".", + "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); f.Source, f.DriverType, f.DriverName, f.DeviceName is null ? "" : $" device '{f.DeviceName}'", f.Problem);
} }
if (findings.Count == 0) 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; return findings;
} }
catch (Exception ex) when (ex is not OperationCanceledException) 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.")); state.Set(new([], DateTime.UtcNow, "The credential-reference preflight could not read the config DB."));
return []; 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"); 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 private sealed class CountingResolver : ISecretResolver
{ {
public int Calls { get; private set; } public int Calls { get; private set; }
@@ -88,32 +88,6 @@ public sealed class OpcUaClientUserCertificatePathTests : IDisposable
Cert("user.pfx"), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir)); 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] [Fact]
public async Task A_non_certificate_auth_type_leaves_the_path_alone() public async Task A_non_certificate_auth_type_leaves_the_path_alone()
{ {
@@ -6,7 +6,6 @@ using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments; using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; 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;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums; 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; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments;
/// <summary> /// <summary>
/// lmxopcua#815 upgrade preflight: stored (draft) and deployed configs with out-of-namespace credential references /// lmxopcua#815 credential-reference preflight (a hard error since #834): stored (draft) and deployed configs with
/// are listed and logged — driver, device, field, allowed form — and never the value; nothing is resolved. /// out-of-namespace credential references are listed and logged as errors — driver, device, field, allowed form —
/// and never the value; nothing is resolved.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
public sealed class CredentialReferencePreflightTests public sealed class CredentialReferencePreflightTests
@@ -85,7 +85,7 @@ public sealed class CredentialReferencePreflightTests
=> (await new CredentialReferencePreflight(RawTreeTestDb.Factory(RawTreeTestDb.SeedFresh())).ScanAsync(Ct)).ShouldBeEmpty(); => (await new CredentialReferencePreflight(RawTreeTestDb.Factory(RawTreeTestDb.SeedFresh())).ScanAsync(Ct)).ShouldBeEmpty();
[Fact] [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() var services = new ServiceCollection()
.AddSingleton(RawTreeTestDb.Factory(SeedLegacy())) .AddSingleton(RawTreeTestDb.Factory(SeedLegacy()))
@@ -99,10 +99,13 @@ public sealed class CredentialReferencePreflightTests
findings.Count.ShouldBe(3); findings.Count.ShouldBe(3);
state.Current.Findings.Count.ShouldBe(3); state.Current.Findings.Count.ShouldBe(3);
state.Current.ScannedAtUtc.ShouldNotBeNull(); state.Current.ScannedAtUtc.ShouldNotBeNull();
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList(); var errors = logger.Entries.Where(e => e.Level == LogLevel.Error).ToList();
warnings.Count.ShouldBe(3); errors.Count.ShouldBe(3);
warnings.ShouldAllBe(w => w.Message.Contains("lmxopcua#815") && !w.Message.Contains("MX_API_KEY")); errors.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")); 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] [Fact]
@@ -132,28 +135,6 @@ public sealed class CredentialReferencePreflightTests
await svc.StopAsync(Ct); 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 sealed class FakeLifetime : IHostApplicationLifetime
{ {
private readonly CancellationTokenSource _started = new(); private readonly CancellationTokenSource _started = new();
@@ -184,48 +165,4 @@ public sealed class CredentialReferencePreflightTests
lock (Entries) Entries.Add((logLevel, formatter(state, exception))); 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();
}
} }