fix(security): warn-only compatibility for deployed drivers + upgrade preflight (#815)

User decision: save, Test Connect, live browse and the deploy gate keep
refusing out-of-namespace credential references, but an ALREADY-DEPLOYED
driver must not fault at the upgrade restart or (Galaxy) at the next
gateway reconnect, since ReopenAsync re-resolves Gateway.ApiKeySecretRef.

- LegacyCredentialReferenceTracker (Core.Abstractions): the one, deprecated,
  call-site-chosen compatibility path. Running Galaxy (InitializeAsync,
  ReopenAsync, ack fallback) and OpcUaClient (session open) pass it and keep
  the pre-#815 semantics (case-insensitive prefixes, Galaxy file:, OpcUaClient
  literal pass-through) with a rate-limited Warning (driver/field/allowed
  form, never the value) and a credential-compat:<field> diagnostics entry
  (both drivers now implement IDriverDiagnostics). Browsers and probes pass
  none and stay hard-fail; Heidenhain has none.
- Deploy-gate message names the migration.
- Upgrade preflight: CredentialReferencePreflightService (admin nodes, once
  after ApplicationStarted, ConfigDb only, resolves nothing) logs a Warning
  per Draft row / Deployed-artifact finding; the /raw banner shows them plus
  Running drivers reporting warn-only use (driver-health diagnostics).
- docs/security.md: exact replacement table (incl. the old form default
  env:MX_API_KEY, file:, un-namespaced secret:, casing changes), the
  ReopenAsync reconnect behaviour, the warn-only window and its removal.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-11-11 02:07:33 -05:00
parent 9742a66a9e
commit 4563940792
22 changed files with 980 additions and 34 deletions
+5
View File
@@ -326,6 +326,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
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.
`docs/security.md` §"Credential references are namespaced per driver".
- **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`
+1 -1
View File
@@ -81,7 +81,7 @@ Project root files:
`DriverConfig` JSON binds to `Config/GalaxyDriverOptions.cs`. The four sections are:
- **`Gateway`** — endpoint, API key secret ref (`secret:galaxy/<name>` · `secret:otopcua/galaxy/<name>` · `env:OTOPCUA_GALAXY_<NAME>` · legacy `env:GALAXY_MXGW_<NAME>` · `dev:KEY` · literal; anything else, incl. `file:`, is refused — #815, [`docs/security.md`](../security.md) §"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime.
- **`Gateway`** — endpoint, API key secret ref (`secret:galaxy/<name>` · `secret:otopcua/galaxy/<name>` · `env:OTOPCUA_GALAXY_<NAME>` · legacy `env:GALAXY_MXGW_<NAME>` · `dev:KEY` · literal; anything else, incl. `file:`, is refused — #815 (an already-deployed driver keeps resolving a pre-#815 ref on init and every gateway reconnect in deprecated warn-only mode until its next deploy), [`docs/security.md`](../security.md) §"Credential references are namespaced per driver"), TLS knobs, connect/call/stream timeouts. `StreamTimeoutSeconds = 0` keeps the long-lived `StreamEvents` RPC open for the driver's lifetime.
- **`MxAccess`** — `ClientName` (must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time), `PublishingIntervalMs` (forwarded as `buffered_update_interval_ms` on subscribe), `WriteUserId` for ArchestrA secured-write, `EventPumpChannelCapacity` (default 50_000 — one second of headroom at 50k tags / 1Hz; tune via the `galaxy.events.dropped` metric).
- **`Repository`** — `DiscoverPageSize`, `WatchDeployEvents`.
- **`Reconnect`** — `InitialBackoffMs`, `MaxBackoffMs`, `ReplayOnSessionLost` (calls the gateway's `ReplaySubscriptions` RPC after reconnect rather than re-issuing subscribe-bulk for every tag).
+2 -1
View File
@@ -84,7 +84,8 @@ factory, the Test-Connect probe and the browse picker (unknown keys are skipped)
**Credentials (#815).** `Password` / `UserCertificatePassword` are a literal, `secret:opcuaclient/<name>` (the node's
secret store) or `env:OTOPCUA_OPCUACLIENT_<NAME>`. They are resolved on the node and sent to the configured endpoint,
so any other `secret:`/`env:` reference (and `file:`) is refused at save, deploy (`CredentialReferenceOutOfScope`), Test
Connect, browse and again at session open, before anything is read. Migrate an existing `secret:<name>` by re-creating
Connect, browse and (for the AdminUI browser) again before anything is read; an already-deployed driver's session open
keeps the pre-#815 semantics in deprecated warn-only mode until its next deploy. Migrate an existing `secret:<name>` by re-creating
the secret as `opcuaclient/<name>`. See [`docs/security.md`](../security.md) §"Credential references are namespaced per driver".
`ProbeTimeoutSeconds` (default 15, AdminUI "Probe timeout") caps the Test-Connect probe's deadline — it can shorten
+46 -15
View File
@@ -1178,28 +1178,59 @@ Enforced, fail-closed, at every seam — the earlier ones exist for a clear mess
`HeidenhainCredentialPersisted`).
3. **Test Connect** — `AdminOperationsActor` refuses before any probe runs (`"Test Connect refused: …"`).
4. **Live browse** — `BrowserSessionService.OpenAsync` refuses before any bespoke or universal browser opens.
5. **Resolve time** — `GalaxySecretRef`, `GalaxyAckCredentialResolver`, `OpcUaUserIdentityFactory` (driver +
browser) and `HeidenhainCredentials` all resolve through `CredentialReferences`, which re-checks the scope
**before** any secret-store or environment read.
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.
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).** After the upgrade, a stored config with an out-of-namespace reference fails
its next deploy (`CredentialReferenceOutOfScope`), its Test Connect / browse, and — for a running driver — its
next (re)initialise, which faults the driver with the same message. To migrate:
**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
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):
- **Galaxy API key** — `env:GALAXY_MXGW_API_KEY` (the docker-dev rig and docs' name) keeps working. Any other
variable: rename it to `OTOPCUA_GALAXY_<NAME>` on every node that runs the driver *and* the admin nodes
(Test Connect / browse), and set `env:OTOPCUA_GALAXY_<NAME>`. A store secret already named `galaxy/…` keeps
working; otherwise re-create it as `galaxy/<inst>/apikey` (`/admin/secrets` or the `secret` CLI). A
`file:<path>` key must move into the store or a namespaced variable.
- **Galaxy fallback ack password** — the documented `secret:otopcua/galaxy/…` name keeps working; `env:` / `file:`
references move as above.
- **OpcUaClient** — re-create `secret:<name>` as `opcuaclient/<name>` (and update the field), or move an
environment value to `OTOPCUA_OPCUACLIENT_<NAME>`. Literal passwords are unaffected.
| Old value | Why it is refused | Replace with |
|---|---|---|
| Galaxy `env:MX_API_KEY` — the **old AdminUI Galaxy form default** (pre-#815 `GalaxyDriverForm.razor`), so the likeliest value in a real DB | variable outside the namespace | rename the variable to `OTOPCUA_GALAXY_API_KEY` and set `env:OTOPCUA_GALAXY_API_KEY` (or store it and use `secret:galaxy/<inst>/apikey`) |
| Galaxy `env:<ANY_OTHER_NAME>` (e.g. `env:MY_API_KEY`) | same | variable `OTOPCUA_GALAXY_<NAME>` → `env:OTOPCUA_GALAXY_<NAME>`. `env:GALAXY_MXGW_API_KEY` (docker-dev, parity docs) needs **no** change |
| Galaxy `file:<path>` — `docs/Configuration.md` used to recommend it for production | `file:` is refused everywhere | put the file's contents in the store as `galaxy/<inst>/apikey` → `secret:galaxy/<inst>/apikey` (or a variable `OTOPCUA_GALAXY_<NAME>`) |
| Galaxy `secret:<name>` not under `galaxy/` (e.g. the old `docs/v2/driver-specs.md` example `secret:galaxy-gw-api-key`) | secret outside the namespace | re-create it as `galaxy/<name>` (e.g. `galaxy/gw/apikey`) → `secret:galaxy/gw/apikey`. Names already under `galaxy/` or `otopcua/galaxy/` need no change |
| Galaxy `Env:X`, `SECRET:x`, `File:x`, … (any non-lower-case prefix) — **pre-#815 resolved these case-insensitively** | prefixes are now exact | the lower-case namespaced form from the rows above |
| Galaxy `AlarmAck.FallbackPasswordSecretRef` = `env:` / `file:` / `secret:` outside the namespace | same | as above; the documented `secret:otopcua/galaxy/<name>` needs no change |
| OpcUaClient `Password` / `UserCertificatePassword` = `secret:<name>` not under `opcuaclient/` (e.g. `secret:opcua/plant/pw`) | secret outside the namespace | re-create the secret as `opcuaclient/<name>` → `secret:opcuaclient/<name>` |
| OpcUaClient `SECRET:x` / `Secret:x` — pre-#815 resolved case-insensitively | prefixes are now exact | `secret:opcuaclient/<name>` |
| OpcUaClient **literal** password that happens to start with `env:`, `file:`, `secret:`/`${secret:` in any casing (e.g. a real password `Env:Prod1`) — pre-#815 sent it verbatim | it now parses as a reference | store it: `secret:opcuaclient/<name>` (a literal that does not start with one of those prefixes is unaffected) |
**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.
**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.
Residual (accepted): within one namespace, a Designer can still point one driver instance at another
instance's secret (e.g. `secret:galaxy/<other-inst>/apikey`) — the value then reaches the endpoint the Designer
@@ -20,6 +20,16 @@ public static class CredentialReferenceDeployChecks
/// <summary>A credential field holding a reference outside the driver's namespace (or a refused literal).</summary>
public const string Code = "CredentialReferenceOutOfScope";
/// <summary>
/// Appended to every error: a config deployed before #815 is still RUNNING in warn-only compatibility mode, and
/// this deploy is where it stops — so the message names the migration, not just the rule.
/// </summary>
public const string MigrationHint =
" Migrate it before deploying: create the value under the allowed name (secret store via /admin/secrets or the "
+ "secret CLI, or the environment variable on every node that runs this driver AND the admin nodes) and point the "
+ "field at it. A driver deployed before this upgrade keeps resolving the old reference (warn-only, deprecated) "
+ "only until this deploy succeeds. See docs/security.md §\"Credential references are namespaced per driver\".";
/// <summary>Runs the gate.</summary>
/// <param name="draft">The draft.</param>
/// <returns>The errors.</returns>
@@ -32,12 +42,12 @@ public static class CredentialReferenceDeployChecks
if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue;
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}", driver.DriverInstanceId));
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}{MigrationHint}", driver.DriverInstanceId));
foreach (var device in draft.Devices.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal)))
{
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig))
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}", device.DeviceId));
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}{MigrationHint}", device.DeviceId));
}
}
@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// ⚠️ DEPRECATED compatibility path (lmxopcua#815) — the ONE switch between "enforce" and "warn-only" credential
/// resolution, and it is chosen by CALL SITE, never by a global flag: a deployed, running driver (Galaxy
/// <c>InitializeAsync</c>/<c>ReopenAsync</c> + ack fallback, OpcUaClient session open) passes its instance's tracker
/// and so keeps resolving a pre-#815 out-of-namespace reference with its OLD semantics, warning instead of
/// faulting; every admin-node path (Test Connect probe, AdminUI live browse) and Heidenhain pass
/// <see langword="null"/> and stay hard-fail.
/// </summary>
/// <remarks>
/// <para>
/// Why it is safe to keep for running drivers: the AdminUI save path and the deploy gate refuse every
/// out-of-namespace reference, so a Designer can no longer CREATE one — what remains are configs deployed
/// before the upgrade, whose endpoint and reference an administrator already deployed. Such a config runs
/// only until its next deploy (which the gate refuses until it is migrated).
/// </para>
/// <para>
/// Removing it (the planned hard-fail release): delete this type, the per-driver <c>Legacy…</c> resolve
/// methods it gates and the <c>legacy</c> parameters; every caller then takes the enforce branch it already
/// has. Nothing else changes.
/// </para>
/// <para>
/// Each use is reported through <paramref name="warn"/> at most once per <see cref="WarnInterval"/> per field
/// (a Galaxy gateway reconnect re-resolves every time), and is exposed as <see cref="Diagnostics"/> so the
/// driver's <see cref="IDriverDiagnostics"/> bag carries it to the AdminUI (<c>/hosts</c> and the <c>/raw</c>
/// credential banner). Messages name the driver, field and allowed forms — never the value.
/// </para>
/// </remarks>
/// <param name="scope">The driver's scope (for the allowed forms).</param>
/// <param name="driverInstanceId">The driver instance (for messages).</param>
/// <param name="warn">Sink for a rate-limited warning line.</param>
/// <param name="clock">Clock (tests); null ⇒ <see cref="DateTime.UtcNow"/>.</param>
public sealed class LegacyCredentialReferenceTracker(
CredentialScope scope, string driverInstanceId, Action<string> warn, Func<DateTime>? clock = null)
{
/// <summary>The <see cref="Diagnostics"/> key prefix (one entry per field).</summary>
public const string DiagnosticsKeyPrefix = "credential-compat:";
/// <summary>Minimum spacing between two warnings for the same field.</summary>
public static readonly TimeSpan WarnInterval = TimeSpan.FromHours(1);
private readonly object _gate = new();
private readonly Dictionary<string, DateTime> _lastWarned = new(StringComparer.Ordinal);
private readonly SortedDictionary<string, string> _diagnostics = new(StringComparer.Ordinal);
/// <summary>The scope this tracker reports against.</summary>
public CredentialScope Scope => scope;
/// <summary>The current legacy uses, keyed <c>credential-compat:&lt;field&gt;</c>.</summary>
public IReadOnlyDictionary<string, string> Diagnostics
{
get
{
lock (_gate) return new Dictionary<string, string>(_diagnostics, StringComparer.Ordinal);
}
}
/// <summary>Records that <paramref name="field"/> was resolved through the legacy path (warns, rate-limited).</summary>
/// <param name="field">The field label.</param>
public void Report(string field)
{
var forms = $"secret:{scope.SecretNamespaces[0]}<name> or env:{scope.EnvVariablePrefixes[0]}<NAME>";
var now = (clock ?? (() => DateTime.UtcNow))();
bool log;
lock (_gate)
{
_diagnostics[DiagnosticsKeyPrefix + field] =
$"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}";
log = !_lastWarned.TryGetValue(field, out var last) || now - last >= WarnInterval;
if (log) _lastWarned[field] = now;
}
if (log)
{
warn($"{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\".");
}
}
}
@@ -36,19 +36,32 @@ 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)
Func<string, string?>? environment = null,
LegacyCredentialReferenceTracker? legacy = null)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
ArgumentNullException.ThrowIfNull(resolver);
var scope = DriverCredentialPolicy.GalaxyScope;
if (CredentialReferences.Parse(secretRef, scope, out _) == CredentialReferenceKind.Literal)
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 (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase))
{
@@ -74,4 +87,49 @@ 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,8 +38,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// </para>
/// </remarks>
public sealed class GalaxyDriver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDisposable, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDriverDiagnostics, 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();
@@ -342,6 +351,8 @@ 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;
@@ -349,7 +360,7 @@ public sealed class GalaxyDriver
_alarmAcknowledger = alarmAcknowledger;
_alarmFeed = alarmFeed;
_ackCredentialResolver = ackCredentialResolver
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger);
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials);
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
// are authored (live-browse path), leaving every translation an identity no-op.
@@ -1098,7 +1109,9 @@ public sealed class GalaxyDriver
}
}
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
// 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.
internal async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
{
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
@@ -1108,7 +1121,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)
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken, legacy: _legacyCredentials)
.ConfigureAwait(false);
return new MxGatewayClientOptions
{
@@ -22,6 +22,7 @@ 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>
@@ -29,11 +30,13 @@ 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)
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
{
_environment = environment;
_legacy = legacy;
_driverInstanceId = driverInstanceId;
_options = options ?? new GalaxyAlarmAckOptions();
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
@@ -156,6 +159,13 @@ 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,
@@ -169,6 +179,28 @@ 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);
}
@@ -115,17 +115,22 @@ 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>
/// <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)
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
{
ArgumentNullException.ThrowIfNull(options);
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment)
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy)
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment)
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment, legacy)
.ConfigureAwait(false);
if (ReferenceEquals(password, options.Password)
@@ -139,12 +144,18 @@ public static class OpcUaUserIdentityFactory
private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct,
Func<string, string?>? environment)
Func<string, string?>? environment, LegacyCredentialReferenceTracker? legacy)
{
// 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.
return await CredentialReferences.ResolveAsync(
@@ -156,4 +167,24 @@ 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,8 +29,16 @@ 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, IDisposable, IAsyncDisposable
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDriverDiagnostics, 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>
@@ -82,6 +90,8 @@ 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;
}
@@ -554,7 +564,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).ConfigureAwait(false);
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken, _legacyCredentials).ConfigureAwait(false);
var identity = BuildUserIdentity(resolvedOptions);
@@ -31,16 +31,18 @@ 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)
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct,
Core.Abstractions.LegacyCredentialReferenceTracker? legacy = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(resolver);
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct, legacy: legacy);
}
}
@@ -23,6 +23,8 @@
<span class="text-muted small">Changes apply on the next deployment.</span>
</div>
<CredentialReferenceBanner />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Project tree</span>
@@ -0,0 +1,85 @@
@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. *@
@if (_findings.Count > 0 || _error is not null)
{
<div class="alert alert-warning 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".
</div>
<button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button>
</div>
@if (_error is not null)
{
<div class="text-danger mt-1">@_error</div>
}
<ul class="mb-0 mt-2">
@foreach (var f in _findings.Take(MaxRows))
{
<li>
<span class="badge bg-secondary me-1">@f.Source</span>
@if (!string.IsNullOrEmpty(f.DriverType)) { <span>@f.DriverType </span> }
driver <span class="mono">@f.DriverName</span>
@if (f.DeviceName is not null) { <span> device <span class="mono">@f.DeviceName</span></span> }
— @f.Problem
</li>
}
</ul>
@if (_findings.Count > MaxRows)
{
<div class="mt-1 text-muted">…and @(_findings.Count - MaxRows) more (see the node log).</div>
}
</div>
}
@code {
private const int MaxRows = 25;
private IReadOnlyList<CredentialReferenceFinding> _findings = [];
private string? _error;
private bool _busy;
protected override void OnInitialized() => Compose(State.Current);
private void Compose(CredentialReferencePreflightState.Snapshot snapshot)
{
_error = snapshot.Error;
_findings = snapshot.Findings
.Concat(CredentialReferencePreflight.RunningFindings(DriverStatus.GetAll()))
.ToList();
}
private async Task RecheckAsync()
{
var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>;
if (factory is null) return;
_busy = true;
try
{
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
var snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
State.Set(snapshot);
Compose(snapshot);
}
catch (Exception)
{
_error = "The credential-reference preflight could not read the config DB.";
}
finally
{
_busy = false;
}
}
}
@@ -0,0 +1,209 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
/// <summary>Where a credential-reference finding came from.</summary>
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>
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="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>
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.
/// </summary>
/// <param name="dbFactory">The config DB.</param>
public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
/// <summary>Scans the draft rows and the latest deployment artifact.</summary>
/// <param name="ct">Cancellation.</param>
/// <returns>The findings.</returns>
public async Task<IReadOnlyList<CredentialReferenceFinding>> ScanAsync(CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var drivers = await db.DriverInstances.AsNoTracking().ToListAsync(ct).ConfigureAwait(false);
var ruled = drivers.Where(d => DriverCredentialPolicy.TryGet(d.DriverType, out _)).ToList();
var ids = ruled.Select(d => d.DriverInstanceId).ToHashSet(StringComparer.Ordinal);
var devices = ids.Count == 0
? []
: await db.Devices.AsNoTracking().Where(d => ids.Contains(d.DriverInstanceId)).ToListAsync(ct).ConfigureAwait(false);
var latestBlob = await db.Deployments.AsNoTracking()
.Where(d => d.ArtifactBlob.Length > 0)
.OrderByDescending(d => d.CreatedAtUtc)
.Select(d => d.ArtifactBlob)
.FirstOrDefaultAsync(ct).ConfigureAwait(false);
var deployed = latestBlob is null ? [] : DeploymentArtifact.ParseDriverInstances(latestBlob);
return Scan(ruled, devices, deployed);
}
/// <summary>The pure scan (tests drive it directly).</summary>
/// <param name="drivers">Stored driver rows.</param>
/// <param name="devices">Stored device rows.</param>
/// <param name="deployed">The latest artifact's driver specs.</param>
/// <returns>The findings.</returns>
public static IReadOnlyList<CredentialReferenceFinding> Scan(
IEnumerable<DriverInstance> drivers, IEnumerable<Device> devices, IEnumerable<DriverInstanceSpec> deployed)
{
var findings = new List<CredentialReferenceFinding>();
var driverList = drivers.ToList();
var byId = driverList.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
foreach (var d in driverList)
{
foreach (var p in DriverCredentialPolicy.DescribeProblems(d.DriverType, d.DriverConfig))
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, p));
}
foreach (var dev in devices)
{
if (!byId.TryGetValue(dev.DriverInstanceId, out var owner)) continue;
foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig))
findings.Add(new(CredentialFindingSource.Draft, owner.DriverType, owner.Name, owner.DriverInstanceId, dev.Name, p));
}
foreach (var s in deployed)
{
foreach (var p in DriverCredentialPolicy.DescribeProblems(s.DriverType, s.DriverConfig))
findings.Add(new(CredentialFindingSource.Deployed, s.DriverType, s.Name, s.DriverInstanceId, null, p));
}
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}")))
.ToList();
}
/// <summary>The last preflight result, shown by the AdminUI <c>/raw</c> credential banner.</summary>
public sealed class CredentialReferencePreflightState
{
private volatile Snapshot _current = new([], null, null);
/// <summary>An immutable result.</summary>
/// <param name="Findings">The findings.</param>
/// <param name="ScannedAtUtc">When the scan ran (null = not yet).</param>
/// <param name="Error">A scan failure, if any.</param>
public sealed record Snapshot(IReadOnlyList<CredentialReferenceFinding> Findings, DateTime? ScannedAtUtc, string? Error);
/// <summary>The current result.</summary>
public Snapshot Current => _current;
/// <summary>Replaces the result.</summary>
/// <param name="snapshot">The new result.</param>
public void Set(Snapshot snapshot) => _current = snapshot;
}
/// <summary>
/// Runs <see cref="CredentialReferencePreflight"/> once after host start on an admin node and logs one Warning 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>
/// <param name="services">Resolves the ConfigDb factory lazily (absent ⇒ the preflight is skipped).</param>
/// <param name="lifetime">Host lifetime.</param>
/// <param name="state">Where the result is kept for the AdminUI.</param>
/// <param name="logger">Logger.</param>
public sealed class CredentialReferencePreflightService(
IServiceProvider services,
IHostApplicationLifetime lifetime,
CredentialReferencePreflightState state,
ILogger<CredentialReferencePreflightService> logger) : BackgroundService
{
/// <summary>Delay after <c>ApplicationStarted</c> before the scan (tests set it to zero).</summary>
public TimeSpan StartDelay { get; init; } = TimeSpan.FromSeconds(15);
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using (lifetime.ApplicationStarted.Register(() => started.TrySetResult()))
using (stoppingToken.Register(() => started.TrySetCanceled(stoppingToken)))
{
try
{
await started.Task.ConfigureAwait(false);
if (StartDelay > TimeSpan.Zero) await Task.Delay(StartDelay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
await RunOnceAsync(stoppingToken).ConfigureAwait(false);
}
/// <summary>One scan + log pass (internal so tests drive it directly).</summary>
/// <param name="ct">Cancellation.</param>
/// <returns>The findings.</returns>
internal async Task<IReadOnlyList<CredentialReferenceFinding>> RunOnceAsync(CancellationToken ct)
{
var dbFactory = services.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
if (dbFactory is null)
{
logger.LogDebug("Credential-reference preflight skipped: no ConfigDb on this node.");
return [];
}
try
{
var findings = await new CredentialReferencePreflight(dbFactory).ScanAsync(ct).ConfigureAwait(false);
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\".",
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.");
return findings;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Credential-reference preflight (lmxopcua#815) could not read the config DB.");
state.Set(new([], DateTime.UtcNow, "The credential-reference preflight could not read the config DB."));
return [];
}
}
}
@@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
services.AddSingleton<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>();
// lmxopcua#815 upgrade preflight: one ConfigDb scan after start (no secret resolution), WARN per
// out-of-namespace credential reference, shown by the /raw credential banner.
services.AddSingleton<Deployments.CredentialReferencePreflightState>();
services.AddHostedService<Deployments.CredentialReferencePreflightService>();
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddScoped<IUnsTreeService, UnsTreeService>();
services.AddScoped<IRawTreeService, RawTreeService>();
@@ -32,6 +32,7 @@ public sealed class DraftValidatorCredentialReferenceTests
e.Context.ShouldBe("drv");
e.Message.ShouldContain("secret:");
e.Message.ShouldContain("lmxopcua#815");
e.Message.ShouldContain("Migrate it before deploying");
e.Message.ShouldNotContain("ConnectionStrings__ConfigDb");
}
@@ -54,6 +54,23 @@ public sealed class GalaxyDriverBrowserTests
ex.Message.ShouldContain("ClientName");
}
/// <summary>
/// lmxopcua#815: the browser runs on the admin node against an operator-chosen endpoint with no deploy, so it
/// never gets the running driver's warn-only compatibility — a legacy ref (the old form default
/// <c>env:MX_API_KEY</c>, a <c>file:</c> path, another subsystem's secret) is refused before any connect.
/// </summary>
[Theory]
[InlineData("env:MX_API_KEY")]
[InlineData("file:appsettings.json")]
[InlineData("secret:otopcua/historian/api-key")]
public async Task OpenAsync_refuses_an_out_of_namespace_api_key_ref_with_no_warn_only_fallback(string reference)
{
var json = "{\"Gateway\":{\"Endpoint\":\"http://127.0.0.1:1\",\"ApiKeySecretRef\":\"" + reference
+ "\"},\"MxAccess\":{\"ClientName\":\"X\"},\"Repository\":{},\"Reconnect\":{}}";
await Should.ThrowAsync<ZB.MOM.WW.OtOpcUa.Core.Abstractions.CredentialReferenceException>(
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
}
/// <summary>A JSON literal that deserializes to null must fail fast with a
/// "deserialized to null" message — never a downstream NRE.</summary>
[Fact]
@@ -0,0 +1,146 @@
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)));
}
}
}
@@ -124,6 +124,35 @@ 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; }
@@ -0,0 +1,177 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
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.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.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CredentialReferencePreflightTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const string LegacyGalaxy = """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""";
private static string SeedLegacy()
{
var name = RawTreeTestDb.SeedFresh();
using var db = RawTreeTestDb.CreateNamed(name);
// Written straight to the DB, as a pre-#815 build would have — the save gate now refuses it.
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "gx-old", ClusterId = RawTreeTestDb.ClusterId, Name = "Galaxy main",
DriverType = DriverTypeNames.Galaxy, DriverConfig = LegacyGalaxy,
});
db.Devices.Add(new Device
{
DeviceId = "ouc-dev", DriverInstanceId = "gx-old", Name = "gw1",
DeviceConfig = """{"AlarmAck":{"FallbackPasswordSecretRef":"file:C:\\keys\\ack.txt"}}""",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "mb", ClusterId = RawTreeTestDb.ClusterId, Name = "modbus",
DriverType = DriverTypeNames.Modbus, DriverConfig = """{"Password":"env:ConnectionStrings__ConfigDb"}""",
});
var artifact = """
{"DriverInstances":[{"DriverInstanceId":"ouc","Name":"Upstream","DriverType":"OpcUaClient",
"DriverConfig":"{\"Password\":\"secret:plant/opcua-pw\"}"}]}
""";
db.Deployments.Add(new Deployment { RevisionHash = "abc", CreatedBy = "t", ArtifactBlob = Encoding.UTF8.GetBytes(artifact) });
db.SaveChanges();
return name;
}
[Fact]
public async Task Scan_lists_draft_rows_devices_and_the_deployed_artifact_without_values()
{
var findings = await new CredentialReferencePreflight(RawTreeTestDb.Factory(SeedLegacy())).ScanAsync(Ct);
findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DriverName == "Galaxy main"
&& f.DeviceName == null && f.Problem.Contains("Gateway.ApiKeySecretRef")
&& f.Problem.Contains("env:OTOPCUA_GALAXY_<NAME>"));
findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DeviceName == "gw1"
&& f.Problem.Contains("AlarmAck.FallbackPasswordSecretRef"));
findings.ShouldContain(f => f.Source == CredentialFindingSource.Deployed && f.DriverName == "Upstream"
&& f.Problem.Contains("secret:opcuaclient/<name>"));
findings.ShouldNotContain(f => f.DriverInstanceId == "mb"); // not a credential-resolving driver
findings.ShouldAllBe(f => !f.Problem.Contains("MX_API_KEY") && !f.Problem.Contains("plant/opcua-pw")
&& !f.Problem.Contains("ack.txt"));
}
[Fact]
public async Task A_clean_database_has_no_findings()
=> (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()
{
var services = new ServiceCollection()
.AddSingleton(RawTreeTestDb.Factory(SeedLegacy()))
.BuildServiceProvider();
var state = new CredentialReferencePreflightState();
var logger = new CaptureLogger();
var svc = new CredentialReferencePreflightService(services, new FakeLifetime(), state, logger) { StartDelay = TimeSpan.Zero };
var findings = await svc.RunOnceAsync(Ct);
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"));
}
[Fact]
public async Task A_node_without_a_config_db_skips_quietly()
{
var svc = new CredentialReferencePreflightService(
new ServiceCollection().BuildServiceProvider(), new FakeLifetime(), new CredentialReferencePreflightState(), new CaptureLogger());
(await svc.RunOnceAsync(Ct)).ShouldBeEmpty();
}
[Fact]
public async Task The_hosted_service_waits_for_ApplicationStarted_before_scanning()
{
var services = new ServiceCollection().AddSingleton(RawTreeTestDb.Factory(SeedLegacy())).BuildServiceProvider();
var lifetime = new FakeLifetime();
var state = new CredentialReferencePreflightState();
var svc = new CredentialReferencePreflightService(services, lifetime, state, new CaptureLogger()) { StartDelay = TimeSpan.Zero };
await svc.StartAsync(Ct);
await Task.Delay(100, Ct);
state.Current.ScannedAtUtc.ShouldBeNull(); // nothing before the host has started
lifetime.Start();
var deadline = DateTime.UtcNow.AddSeconds(10);
while (state.Current.ScannedAtUtc is null && DateTime.UtcNow < deadline) await Task.Delay(20, Ct);
state.Current.Findings.Count.ShouldBe(3);
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();
public CancellationToken ApplicationStarted => _started.Token;
public CancellationToken ApplicationStopping => CancellationToken.None;
public CancellationToken ApplicationStopped => CancellationToken.None;
public void Start() => _started.Cancel();
public void StopApplication()
{
}
}
private sealed class CaptureLogger : ILogger<CredentialReferencePreflightService>
{
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)));
}
}
}
@@ -17,6 +17,7 @@ public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActor
{
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"file:appsettings.json"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","Password":"secret:otopcua/historian/api-key"}""")]