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 []; } } }