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