diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index ba4bf4094..9456c6b89 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -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:` 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` diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md index 39a97d5b6..b768aaed8 100644 --- a/docs/drivers/Galaxy.md +++ b/docs/drivers/Galaxy.md @@ -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/` · `secret:otopcua/galaxy/` · `env:OTOPCUA_GALAXY_` · legacy `env:GALAXY_MXGW_` · `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/` · `secret:otopcua/galaxy/` · `env:OTOPCUA_GALAXY_` · legacy `env:GALAXY_MXGW_` · `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). diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 2e2b2b92a..0e69a6442 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -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/` (the node's secret store) or `env:OTOPCUA_OPCUACLIENT_`. 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:` 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:` by re-creating the secret as `opcuaclient/`. 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 diff --git a/docs/security.md b/docs/security.md index 20e59cfa4..e8b491025 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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__`) 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_` on every node that runs the driver *and* the admin nodes - (Test Connect / browse), and set `env:OTOPCUA_GALAXY_`. A store secret already named `galaxy/…` keeps - working; otherwise re-create it as `galaxy//apikey` (`/admin/secrets` or the `secret` CLI). A - `file:` 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:` as `opcuaclient/` (and update the field), or move an - environment value to `OTOPCUA_OPCUACLIENT_`. 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//apikey`) | +| Galaxy `env:` (e.g. `env:MY_API_KEY`) | same | variable `OTOPCUA_GALAXY_` → `env:OTOPCUA_GALAXY_`. `env:GALAXY_MXGW_API_KEY` (docker-dev, parity docs) needs **no** change | +| Galaxy `file:` — `docs/Configuration.md` used to recommend it for production | `file:` is refused everywhere | put the file's contents in the store as `galaxy//apikey` → `secret:galaxy//apikey` (or a variable `OTOPCUA_GALAXY_`) | +| Galaxy `secret:` 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/` (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/` needs no change | +| OpcUaClient `Password` / `UserCertificatePassword` = `secret:` not under `opcuaclient/` (e.g. `secret:opcua/plant/pw`) | secret outside the namespace | re-create the secret as `opcuaclient/` → `secret:opcuaclient/` | +| OpcUaClient `SECRET:x` / `Secret:x` — pre-#815 resolved case-insensitively | prefixes are now exact | `secret:opcuaclient/` | +| 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/` (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:`) 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//apikey`) — the value then reaches the endpoint the Designer diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs index 5badc1b7f..6b62bf087 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs @@ -20,6 +20,16 @@ public static class CredentialReferenceDeployChecks /// A credential field holding a reference outside the driver's namespace (or a refused literal). public const string Code = "CredentialReferenceOutOfScope"; + /// + /// 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. + /// + 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\"."; + /// Runs the gate. /// The draft. /// The errors. @@ -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)); } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs new file mode 100644 index 000000000..4138e552f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs @@ -0,0 +1,82 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// ⚠️ 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 +/// InitializeAsync/ReopenAsync + 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 +/// and stay hard-fail. +/// +/// +/// +/// 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). +/// +/// +/// Removing it (the planned hard-fail release): delete this type, the per-driver Legacy… resolve +/// methods it gates and the legacy parameters; every caller then takes the enforce branch it already +/// has. Nothing else changes. +/// +/// +/// Each use is reported through at most once per per field +/// (a Galaxy gateway reconnect re-resolves every time), and is exposed as so the +/// driver's bag carries it to the AdminUI (/hosts and the /raw +/// credential banner). Messages name the driver, field and allowed forms — never the value. +/// +/// +/// The driver's scope (for the allowed forms). +/// The driver instance (for messages). +/// Sink for a rate-limited warning line. +/// Clock (tests); null ⇒ . +public sealed class LegacyCredentialReferenceTracker( + CredentialScope scope, string driverInstanceId, Action warn, Func? clock = null) +{ + /// The key prefix (one entry per field). + public const string DiagnosticsKeyPrefix = "credential-compat:"; + + /// Minimum spacing between two warnings for the same field. + public static readonly TimeSpan WarnInterval = TimeSpan.FromHours(1); + + private readonly object _gate = new(); + private readonly Dictionary _lastWarned = new(StringComparer.Ordinal); + private readonly SortedDictionary _diagnostics = new(StringComparer.Ordinal); + + /// The scope this tracker reports against. + public CredentialScope Scope => scope; + + /// The current legacy uses, keyed credential-compat:<field>. + public IReadOnlyDictionary Diagnostics + { + get + { + lock (_gate) return new Dictionary(_diagnostics, StringComparer.Ordinal); + } + } + + /// Records that was resolved through the legacy path (warns, rate-limited). + /// The field label. + public void Report(string field) + { + var forms = $"secret:{scope.SecretNamespaces[0]} or env:{scope.EnvVariablePrefixes[0]}"; + 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\"."); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs index 5e685ad6d..7af820c19 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs @@ -36,19 +36,32 @@ public static class GalaxySecretRef /// Optional logger for warning on cleartext keys. /// Cancellation token for the async secret: resolution. /// Environment reader (tests); null ⇒ the process environment. + /// + /// ⚠️ 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 . + /// /// The resolved API-key string. public static async Task ResolveApiKeyAsync( string secretRef, ISecretResolver resolver, ILogger? logger = null, CancellationToken ct = default, - Func? environment = null) + Func? 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); } + + /// + /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, + /// verbatim — case-insensitive env: / file: / dev: / secret:, anything else a + /// literal. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver. + /// + private static async Task LegacyResolveApiKeyAsync( + string secretRef, ISecretResolver resolver, ILogger? logger, CancellationToken ct, Func? 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; + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 72e5b4a70..d1be1bb04 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -38,8 +38,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; /// /// 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; + + /// + IReadOnlyDictionary IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics; + /// Authored tags this driver could not dial — a TagConfig with no usable /// attributeRef has no MXAccess address to bind. private readonly SkippedTagLedger _skippedTags = new(); @@ -342,6 +351,8 @@ public sealed class GalaxyDriver _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? NullLogger.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 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 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 { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs index 77b2ad751..6dc58a0a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs @@ -22,6 +22,7 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver private readonly ISecretResolver _secrets; private readonly ILogger _logger; private readonly Func? _environment; + private readonly LegacyCredentialReferenceTracker? _legacy; /// Initializes a new instance of the class. /// The owning driver instance id (for messages). @@ -29,11 +30,13 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver /// The shared secret resolver. /// Logger; never receives a secret. /// Environment reader (tests); null ⇒ the process environment. + /// ⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail. public GalaxyAckCredentialResolver( string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger, - Func? environment = null) + Func? 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 /// internal async Task 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; } + /// + /// ⚠️ 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. + /// + private async Task 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); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs index f1433c253..e3e111f90 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs @@ -115,17 +115,22 @@ public static class OpcUaUserIdentityFactory /// The shared secret resolver, or null when none is available. /// Cancellation token. /// Environment reader (tests); null ⇒ the process environment. + /// + /// ⚠️ 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 secret: resolves, anything else is + /// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail). + /// /// An options copy whose credential fields carry resolved plaintext. public static async Task ResolveSecretRefsAsync( OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct, - Func? environment = null) + Func? 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 ResolveFieldAsync( string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct, - Func? environment) + Func? 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); } + + /// + /// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver, + /// verbatim — a case-insensitive secret: resolves (fail-closed), anything else passes through as a + /// literal. Reached ONLY for an out-of-namespace value in an already-deployed, running driver. + /// + private static async Task 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)."); + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 4d1820985..150a131e1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -29,8 +29,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// back on its own recovers in place. /// /// -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; + + /// + IReadOnlyDictionary IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics; + /// Authored tags whose TagConfig carried no usable nodeId, so there is no upstream node /// to dial. This driver's skip path was the quietest of the twelve: NamespaceMap /// drops such an entry with a bare continue and not even a log line. @@ -82,6 +90,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit _diag = new OpcUaClientDiagnostics(driverInstanceId); _logger = logger ?? NullLogger.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); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs index bda9a35dd..8e5eb290d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientSecretResolution.cs @@ -31,16 +31,18 @@ internal static class OpcUaClientSecretResolution /// The raw driver options (credential fields may carry secret: refs). /// The shared secret resolver used by the secret: arm. /// Cancellation token for the async secret resolution. + /// ⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail. /// An options copy whose credential fields carry resolved plaintext. /// /// A secret: ref names a secret that is absent/tombstoned in the store (fail-closed). /// internal static Task 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); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor index 7ef8633ac..07fd06670 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor @@ -23,6 +23,8 @@ Changes apply on the next deployment. + +
Project tree diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor new file mode 100644 index 000000000..90a3fedf1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/CredentialReferenceBanner.razor @@ -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) +{ + +} + +@code { + private const int MaxRows = 25; + private IReadOnlyList _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)) as IDbContextFactory; + 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; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs new file mode 100644 index 000000000..a87f361f7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs @@ -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; + +/// Where a credential-reference finding came from. +public enum CredentialFindingSource +{ + /// A stored DriverConfig / DeviceConfig row (what the next deploy will gate). + Draft, + + /// The latest deployment artifact (what the running drivers were given). + Deployed, + + /// A running driver that reported resolving it through the #815 warn-only compatibility path. + Running, +} + +/// One out-of-namespace credential reference (never carries the value). +/// Where it was found. +/// The driver type. +/// The driver's display name (or instance id for a running driver). +/// The driver instance id. +/// The device, when the reference sits in a DeviceConfig. +/// The field and the allowed forms. +public sealed record CredentialReferenceFinding( + CredentialFindingSource Source, string DriverType, string DriverName, string DriverInstanceId, string? DeviceName, string Problem); + +/// +/// 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. +/// +/// The config DB. +public sealed class CredentialReferencePreflight(IDbContextFactory dbFactory) +{ + /// Scans the draft rows and the latest deployment artifact. + /// Cancellation. + /// The findings. + public async Task> 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); + } + + /// The pure scan (tests drive it directly). + /// Stored driver rows. + /// Stored device rows. + /// The latest artifact's driver specs. + /// The findings. + public static IReadOnlyList Scan( + IEnumerable drivers, IEnumerable devices, IEnumerable deployed) + { + var findings = new List(); + 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; + } + + /// + /// Running drivers that reported a warn-only legacy resolution on their diagnostics bag + /// (), fleet-wide via the driver-health feed. + /// + /// The driver-health snapshots. + /// The findings. + public static IReadOnlyList RunningFindings(IEnumerable 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(); +} + +/// The last preflight result, shown by the AdminUI /raw credential banner. +public sealed class CredentialReferencePreflightState +{ + private volatile Snapshot _current = new([], null, null); + + /// An immutable result. + /// The findings. + /// When the scan ran (null = not yet). + /// A scan failure, if any. + public sealed record Snapshot(IReadOnlyList Findings, DateTime? ScannedAtUtc, string? Error); + + /// The current result. + public Snapshot Current => _current; + + /// Replaces the result. + /// The new result. + public void Set(Snapshot snapshot) => _current = snapshot; +} + +/// +/// Runs once after host start on an admin node and logs one Warning per +/// finding (driver, device, field, allowed forms — never a value). Scheduled after ApplicationStarted plus a +/// short delay, off every Akka starter; it needs only the ConfigDb, never ISecretResolver. +/// +/// Resolves the ConfigDb factory lazily (absent ⇒ the preflight is skipped). +/// Host lifetime. +/// Where the result is kept for the AdminUI. +/// Logger. +public sealed class CredentialReferencePreflightService( + IServiceProvider services, + IHostApplicationLifetime lifetime, + CredentialReferencePreflightState state, + ILogger logger) : BackgroundService +{ + /// Delay after ApplicationStarted before the scan (tests set it to zero). + public TimeSpan StartDelay { get; init; } = TimeSpan.FromSeconds(15); + + /// + 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); + } + + /// One scan + log pass (internal so tests drive it directly). + /// Cancellation. + /// The findings. + internal async Task> RunOnceAsync(CancellationToken ct) + { + var dbFactory = services.GetService>(); + 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 []; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index f085754c5..faa797df2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions // Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md services.AddSingleton(); services.AddHostedService(); + // 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(); + services.AddHostedService(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs index a682e54aa..68dc92637 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialReferenceTests.cs @@ -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"); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs index 5ba065861..e0deaaa77 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs @@ -54,6 +54,23 @@ public sealed class GalaxyDriverBrowserTests ex.Message.ShouldContain("ClientName"); } + /// + /// 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 + /// env:MX_API_KEY, a file: path, another subsystem's secret) is refused before any connect. + /// + [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( + () => _sut.OpenAsync(json, TestContext.Current.CancellationToken)); + } + /// A JSON literal that deserializes to null must fail fast with a /// "deserialized to null" message — never a downstream NRE. [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverLegacyCredentialTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverLegacyCredentialTests.cs new file mode 100644 index 000000000..ce42c5796 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverLegacyCredentialTests.cs @@ -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; + +/// +/// lmxopcua#815 warn-only compatibility (deprecated): an ALREADY-DEPLOYED Galaxy driver whose config predates the +/// namespaces — e.g. the old AdminUI form default env:MX_API_KEY — keeps resolving it on init and on every +/// gateway reconnect (ReopenAsync → BuildClientOptionsAsync), warning rate-limited and surfacing it +/// on the diagnostics bag; while the admin-node paths (browser) stay hard-fail. +/// +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_"); + 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(); + } + + /// The same legacy ref through the admin-node browser path (no tracker) is refused before any read. + [Fact] + public async Task The_same_ref_without_a_tracker_is_refused() + { + var reads = 0; + await Should.ThrowAsync(() => 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(); + 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(); + 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 GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null); + } + + private sealed class CaptureLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs index d319072e3..f52bac365 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSecretResolutionTests.cs @@ -124,6 +124,35 @@ public sealed class OpcUaClientSecretResolutionTests resolved.Password.ShouldBe("pw"); } + /// + /// 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 secret: resolves, anything else is a literal — and + /// warns once; the browser (no tracker) refuses the same value (see the theory above). + /// + [Fact] + public async Task The_running_driver_keeps_old_semantics_for_a_legacy_value_and_warns() + { + var warnings = new List(); + 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; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs new file mode 100644 index 000000000..dbc05518c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs @@ -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; + +/// +/// 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. +/// +[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_")); + 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/")); + 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 + { + [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 + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Entries) Entries.Add((logLevel, formatter(state, exception))); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs index 0303f9991..86780d72d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs @@ -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"}""")]