4563940792
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>
210 lines
10 KiB
C#
210 lines
10 KiB
C#
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 [];
|
|
}
|
|
}
|
|
}
|