fix(security): /raw credential banner keeps running findings when the ConfigDb scan fails (#847)
This commit is contained in:
+69
-35
@@ -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)
|
||||
{
|
||||
<div class="alert alert-warning small mb-3" role="alert">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
@@ -22,61 +24,93 @@
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button>
|
||||
</div>
|
||||
@if (_error is not null)
|
||||
@if (_model.ScanError is not null)
|
||||
{
|
||||
<div class="text-danger mt-1">@_error</div>
|
||||
<div class="text-danger mt-1" data-testid="credential-scan-error">
|
||||
@_model.ScanError Stored and deployed configs were not checked
|
||||
@(_model.ScanFindings.Count > 0 ? "(the list below is from the last successful scan)" : "")
|
||||
— running-driver findings below are still live.
|
||||
</div>
|
||||
}
|
||||
<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)
|
||||
@if (_model.ScanFindings.Count > 0)
|
||||
{
|
||||
<div class="mt-1 text-muted">…and @(_findings.Count - MaxRows) more (see the node log).</div>
|
||||
<ul class="mb-0 mt-2">
|
||||
@foreach (var f in _model.ScanFindings.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 (_model.ScanFindings.Count > MaxRows)
|
||||
{
|
||||
<div class="mt-1 text-muted">…and @(_model.ScanFindings.Count - MaxRows) more (see the node log).</div>
|
||||
}
|
||||
}
|
||||
@if (_model.RunningFindings.Count > 0)
|
||||
{
|
||||
<ul class="mb-0 mt-2" data-testid="credential-running-findings">
|
||||
@foreach (var f in _model.RunningFindings.Take(MaxRows))
|
||||
{
|
||||
<li>
|
||||
<span class="badge bg-secondary me-1">@f.Source</span>
|
||||
driver <span class="mono">@f.DriverName</span>
|
||||
— @f.Problem
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (_model.RunningFindings.Count > MaxRows)
|
||||
{
|
||||
<div class="mt-1 text-muted">…and @(_model.RunningFindings.Count - MaxRows) more running.</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int MaxRows = 25;
|
||||
private IReadOnlyList<CredentialReferenceFinding> _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<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);
|
||||
var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>;
|
||||
CredentialReferencePreflightState.Snapshot snapshot;
|
||||
if (factory is null)
|
||||
{
|
||||
snapshot = State.Current with { Error = "No config DB on this node." };
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
|
||||
snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
|
||||
State.Set(snapshot);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Keep the last successful scan's findings (flagged as such by the note) — and, above all,
|
||||
// recompose the RUNNING findings from the live health store rather than leaving whatever the
|
||||
// last render had (lmxopcua#847).
|
||||
snapshot = State.Current with { Error = "The credential-reference preflight could not read the config DB." };
|
||||
}
|
||||
}
|
||||
|
||||
Compose(snapshot);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_error = "The credential-reference preflight could not read the config DB.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
|
||||
@@ -113,9 +113,47 @@ public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfig
|
||||
.Select(kv => new CredentialReferenceFinding(
|
||||
CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null,
|
||||
$"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}")))
|
||||
// Both nodes of a redundant pair run the same driver and report the same finding; the health store
|
||||
// keeps one snapshot per (driver, node) since lmxopcua#847, so collapse the identical pair here.
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the <c>/raw</c> credential banner renders (lmxopcua#847), composed from two INDEPENDENT sources: the
|
||||
/// ConfigDb scan (draft + deployed rows) and the live driver-health feed (running drivers' warn-only
|
||||
/// <c>credential-compat:*</c> resolutions). A failed ConfigDb scan must never hide the running findings — they
|
||||
/// come from the in-process health store and are exactly what is still true while the database is unreachable.
|
||||
/// </summary>
|
||||
/// <param name="ScanFindings">Draft / Deployed findings from the last successful scan (empty when it failed).</param>
|
||||
/// <param name="RunningFindings">Running-driver findings from the live health feed.</param>
|
||||
/// <param name="ScanError">The scan failure note, or null when the scan succeeded (or has not run).</param>
|
||||
/// <param name="ScannedAtUtc">When the scan last ran.</param>
|
||||
public sealed record CredentialReferenceBannerModel(
|
||||
IReadOnlyList<CredentialReferenceFinding> ScanFindings,
|
||||
IReadOnlyList<CredentialReferenceFinding> RunningFindings,
|
||||
string? ScanError,
|
||||
DateTime? ScannedAtUtc)
|
||||
{
|
||||
/// <summary>Whether the banner renders at all.</summary>
|
||||
public bool Visible => ScanFindings.Count > 0 || RunningFindings.Count > 0 || ScanError is not null;
|
||||
|
||||
/// <summary>Composes the banner from the last scan result and the current driver-health snapshots.</summary>
|
||||
/// <param name="scan">The last preflight result.</param>
|
||||
/// <param name="healthSnapshots">The live driver-health snapshots.</param>
|
||||
/// <returns>The banner model.</returns>
|
||||
public static CredentialReferenceBannerModel Compose(
|
||||
CredentialReferencePreflightState.Snapshot scan,
|
||||
IEnumerable<Commons.Messages.Drivers.DriverHealthChanged> healthSnapshots)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scan);
|
||||
var running = CredentialReferencePreflight.RunningFindings(healthSnapshots ?? []);
|
||||
// Running-source rows only ever come from the health feed; a scan result never carries them.
|
||||
var scanned = scan.Findings.Where(f => f.Source != CredentialFindingSource.Running).ToList();
|
||||
return new(scanned, running, scan.Error, scan.ScannedAtUtc);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The last preflight result, shown by the AdminUI <c>/raw</c> credential banner.</summary>
|
||||
public sealed class CredentialReferencePreflightState
|
||||
{
|
||||
|
||||
+44
@@ -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<string, string>
|
||||
{
|
||||
[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated",
|
||||
},
|
||||
ReportingNodeId: node);
|
||||
|
||||
[Fact]
|
||||
public void Banner_keeps_running_findings_when_the_config_db_scan_failed_847()
|
||||
{
|
||||
// lmxopcua#847: a failed ConfigDb scan must not hide what the live health feed says is still running.
|
||||
var failed = new CredentialReferencePreflightState.Snapshot([], DateTime.UtcNow, "could not read the config DB.");
|
||||
|
||||
var model = CredentialReferenceBannerModel.Compose(failed, [LegacyRunning("central-1")]);
|
||||
|
||||
model.Visible.ShouldBeTrue();
|
||||
model.ScanError.ShouldNotBeNull();
|
||||
model.ScanFindings.ShouldBeEmpty();
|
||||
model.RunningFindings.ShouldHaveSingleItem().DriverInstanceId.ShouldBe("gx-old");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Banner_shows_a_scan_error_alone_and_hides_when_nothing_to_say_847()
|
||||
{
|
||||
CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, "db down"), []).ShouldSatisfyAllConditions(
|
||||
m => m.Visible.ShouldBeTrue(),
|
||||
m => m.RunningFindings.ShouldBeEmpty());
|
||||
CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, null), []).Visible.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Banner_separates_scan_from_running_findings_and_dedups_the_redundant_pair_847()
|
||||
{
|
||||
var draft = new CredentialReferenceFinding(CredentialFindingSource.Draft, "Galaxy", "gx", "gx", null, "p");
|
||||
var model = CredentialReferenceBannerModel.Compose(
|
||||
new([draft], DateTime.UtcNow, null), [LegacyRunning("central-1"), LegacyRunning("central-2")]);
|
||||
|
||||
model.ScanFindings.ShouldHaveSingleItem().ShouldBe(draft);
|
||||
// Both nodes run the driver and report the same thing — one line, not two.
|
||||
model.RunningFindings.ShouldHaveSingleItem();
|
||||
model.ScanError.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user