From c6c243cc06bfccf2f5fb50007700a57952bf942a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 03:42:01 -0500 Subject: [PATCH] fix(security): /raw credential banner keeps running findings when the ConfigDb scan fails (#847) --- .../Raw/CredentialReferenceBanner.razor | 104 ++++++++++++------ .../CredentialReferencePreflight.cs | 38 +++++++ .../CredentialReferencePreflightTests.cs | 44 ++++++++ 3 files changed, 151 insertions(+), 35 deletions(-) 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 index 90a3fedf1..b3ff3b55d 100644 --- 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 @@ -8,9 +8,11 @@ @* 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. *@ + deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value. + The two sources are independent (lmxopcua#847): a failed ConfigDb scan shows a note, and the running findings + — read from the in-process health store — still render. See CredentialReferenceBannerModel. *@ -@if (_findings.Count > 0 || _error is not null) +@if (_model.Visible) { - @if (_error is not null) + @if (_model.ScanError is not null) { -
@_error
+
+ @_model.ScanError Stored and deployed configs were not checked + @(_model.ScanFindings.Count > 0 ? "(the list below is from the last successful scan)" : "") + — running-driver findings below are still live. +
} - - @if (_findings.Count > MaxRows) + @if (_model.ScanFindings.Count > 0) { -
…and @(_findings.Count - MaxRows) more (see the node log).
+ + @if (_model.ScanFindings.Count > MaxRows) + { +
…and @(_model.ScanFindings.Count - MaxRows) more (see the node log).
+ } + } + @if (_model.RunningFindings.Count > 0) + { + + @if (_model.RunningFindings.Count > MaxRows) + { +
…and @(_model.RunningFindings.Count - MaxRows) more running.
+ } } } @code { private const int MaxRows = 25; - private IReadOnlyList _findings = []; - private string? _error; + private CredentialReferenceBannerModel _model = new([], [], null, null); 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(); - } + => _model = CredentialReferenceBannerModel.Compose(snapshot, DriverStatus.GetAll()); 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); + var factory = Services.GetService(typeof(IDbContextFactory)) as IDbContextFactory; + CredentialReferencePreflightState.Snapshot snapshot; + if (factory is null) + { + snapshot = State.Current with { Error = "No config DB on this node." }; + } + else + { + try + { + var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None); + snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null); + State.Set(snapshot); + } + catch (Exception) + { + // Keep the last successful scan's findings (flagged as such by the note) — and, above all, + // recompose the RUNNING findings from the live health store rather than leaving whatever the + // last render had (lmxopcua#847). + snapshot = State.Current with { Error = "The credential-reference preflight could not read the config DB." }; + } + } + 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 index b553bce25..169377ad5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Deployments/CredentialReferencePreflight.cs @@ -113,9 +113,47 @@ public sealed class CredentialReferencePreflight(IDbContextFactory new CredentialReferenceFinding( CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null, $"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}"))) + // Both nodes of a redundant pair run the same driver and report the same finding; the health store + // keeps one snapshot per (driver, node) since lmxopcua#847, so collapse the identical pair here. + .Distinct() .ToList(); } +/// +/// What the /raw credential banner renders (lmxopcua#847), composed from two INDEPENDENT sources: the +/// ConfigDb scan (draft + deployed rows) and the live driver-health feed (running drivers' warn-only +/// credential-compat:* resolutions). A failed ConfigDb scan must never hide the running findings — they +/// come from the in-process health store and are exactly what is still true while the database is unreachable. +/// +/// Draft / Deployed findings from the last successful scan (empty when it failed). +/// Running-driver findings from the live health feed. +/// The scan failure note, or null when the scan succeeded (or has not run). +/// When the scan last ran. +public sealed record CredentialReferenceBannerModel( + IReadOnlyList ScanFindings, + IReadOnlyList RunningFindings, + string? ScanError, + DateTime? ScannedAtUtc) +{ + /// Whether the banner renders at all. + public bool Visible => ScanFindings.Count > 0 || RunningFindings.Count > 0 || ScanError is not null; + + /// Composes the banner from the last scan result and the current driver-health snapshots. + /// The last preflight result. + /// The live driver-health snapshots. + /// The banner model. + public static CredentialReferenceBannerModel Compose( + CredentialReferencePreflightState.Snapshot scan, + IEnumerable healthSnapshots) + { + ArgumentNullException.ThrowIfNull(scan); + var running = CredentialReferencePreflight.RunningFindings(healthSnapshots ?? []); + // Running-source rows only ever come from the health feed; a scan result never carries them. + var scanned = scan.Findings.Where(f => f.Source != CredentialFindingSource.Running).ToList(); + return new(scanned, running, scan.Error, scan.ScannedAtUtc); + } +} + /// The last preflight result, shown by the AdminUI /raw credential banner. public sealed class CredentialReferencePreflightState { 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 index 553f9b17f..8eeb5e81e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs @@ -184,4 +184,48 @@ public sealed class CredentialReferencePreflightTests lock (Entries) Entries.Add((logLevel, formatter(state, exception))); } } + + private static DriverHealthChanged LegacyRunning(string node) => + new("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow, + Diagnostics: new Dictionary + { + [LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated", + }, + ReportingNodeId: node); + + [Fact] + public void Banner_keeps_running_findings_when_the_config_db_scan_failed_847() + { + // lmxopcua#847: a failed ConfigDb scan must not hide what the live health feed says is still running. + var failed = new CredentialReferencePreflightState.Snapshot([], DateTime.UtcNow, "could not read the config DB."); + + var model = CredentialReferenceBannerModel.Compose(failed, [LegacyRunning("central-1")]); + + model.Visible.ShouldBeTrue(); + model.ScanError.ShouldNotBeNull(); + model.ScanFindings.ShouldBeEmpty(); + model.RunningFindings.ShouldHaveSingleItem().DriverInstanceId.ShouldBe("gx-old"); + } + + [Fact] + public void Banner_shows_a_scan_error_alone_and_hides_when_nothing_to_say_847() + { + CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, "db down"), []).ShouldSatisfyAllConditions( + m => m.Visible.ShouldBeTrue(), + m => m.RunningFindings.ShouldBeEmpty()); + CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, null), []).Visible.ShouldBeFalse(); + } + + [Fact] + public void Banner_separates_scan_from_running_findings_and_dedups_the_redundant_pair_847() + { + var draft = new CredentialReferenceFinding(CredentialFindingSource.Draft, "Galaxy", "gx", "gx", null, "p"); + var model = CredentialReferenceBannerModel.Compose( + new([draft], DateTime.UtcNow, null), [LegacyRunning("central-1"), LegacyRunning("central-2")]); + + model.ScanFindings.ShouldHaveSingleItem().ShouldBe(draft); + // Both nodes run the driver and report the same thing — one line, not two. + model.RunningFindings.ShouldHaveSingleItem(); + model.ScanError.ShouldBeNull(); + } }