Merge fix/815-credential-ref-scoping
# Conflicts: # .claude/rules/drivers.md # docs/drivers/Heidenhain.md
This commit is contained in:
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
@@ -36,6 +37,13 @@ public sealed class BrowserSessionService(
|
||||
/// <inheritdoc />
|
||||
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
||||
{
|
||||
// lmxopcua#815: a live browse resolves the config's credential references and sends them to the
|
||||
// author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the
|
||||
// Discover-backed universal browser) is opened. Nothing is read from the store or environment here.
|
||||
var credentialProblems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
|
||||
if (credentialProblems.Count > 0)
|
||||
return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty);
|
||||
|
||||
if (!_browsersByType.TryGetValue(driverType, out var browser))
|
||||
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
|
||||
try
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<span class="text-muted small">Changes apply on the next deployment.</span>
|
||||
</div>
|
||||
|
||||
<CredentialReferenceBanner />
|
||||
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<span>Project tree</span>
|
||||
|
||||
+6
-6
@@ -21,8 +21,8 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">API key secret ref</label>
|
||||
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="env:MX_API_KEY" />
|
||||
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
|
||||
placeholder="env:OTOPCUA_GALAXY_API_KEY" />
|
||||
<div class="form-text">Forms: <code>secret:galaxy/NAME</code>, <code>env:OTOPCUA_GALAXY_NAME</code> (or legacy <code>env:GALAXY_MXGW_NAME</code>), <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning. References outside the Galaxy namespace and <code>file:</code> are refused (#815).</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
@@ -107,7 +107,7 @@
|
||||
<label class="form-label">Fallback password secret ref</label>
|
||||
<InputText @bind-Value="_form.Galaxy.AckFallbackPasswordSecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="secret:otopcua/galaxy/ack-fallback-password" />
|
||||
<div class="form-text">Forms: <code>secret:NAME</code> (production), <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:VALUE</code>. A bare literal is refused.</div>
|
||||
<div class="form-text">Forms: <code>secret:galaxy/NAME</code> or <code>secret:otopcua/galaxy/NAME</code> (production), <code>env:OTOPCUA_GALAXY_NAME</code>, <code>dev:VALUE</code>. A bare literal, <code>file:</code>, or a reference outside the Galaxy namespace is refused (#815).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@
|
||||
}
|
||||
|
||||
private static GalaxyDriverOptions CreateDefaultOptions() => new(
|
||||
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
|
||||
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"),
|
||||
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
|
||||
Repository: new GalaxyRepositoryOptions(),
|
||||
Reconnect: new GalaxyReconnectOptions());
|
||||
@@ -241,7 +241,7 @@
|
||||
{
|
||||
// GalaxyGatewayOptions
|
||||
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
|
||||
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
|
||||
public string GatewayApiKeySecretRef { get; set; } = "env:OTOPCUA_GALAXY_API_KEY";
|
||||
public bool GatewayUseTls { get; set; } = true;
|
||||
public string? GatewayCaCertificatePath { get; set; }
|
||||
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
|
||||
@@ -273,7 +273,7 @@
|
||||
|
||||
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
|
||||
{
|
||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
|
||||
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY");
|
||||
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
|
||||
var repo = r.Repository ?? new GalaxyRepositoryOptions();
|
||||
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
|
||||
|
||||
+2
@@ -138,6 +138,7 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText @bind-Value="_form.OpcUa.Password" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>. Other references are refused (#815).</div>
|
||||
</div>
|
||||
}
|
||||
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
|
||||
@@ -150,6 +151,7 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Certificate password (if PFX-locked)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
|
||||
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>.</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
+85
@@ -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)
|
||||
{
|
||||
<div class="alert alert-warning small mb-3" role="alert">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<div>
|
||||
<strong>Credential references outside their driver's namespace (lmxopcua#815).</strong>
|
||||
The deploy gate refuses these; a driver deployed before the upgrade keeps resolving them in
|
||||
deprecated warn-only mode until its next deploy. Migrate each to the allowed form — see
|
||||
docs/security.md "Credential references are namespaced per driver".
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button>
|
||||
</div>
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div class="text-danger mt-1">@_error</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)
|
||||
{
|
||||
<div class="mt-1 text-muted">…and @(_findings.Count - MaxRows) more (see the node log).</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int MaxRows = 25;
|
||||
private IReadOnlyList<CredentialReferenceFinding> _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<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);
|
||||
Compose(snapshot);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_error = "The credential-reference preflight could not read the config DB.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions
|
||||
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
|
||||
services.AddSingleton<Browsing.BrowseSessionRegistry>();
|
||||
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
||||
// 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<Deployments.CredentialReferencePreflightState>();
|
||||
services.AddHostedService<Deployments.CredentialReferencePreflightService>();
|
||||
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
|
||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||
services.AddScoped<IRawTreeService, RawTreeService>();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
@@ -10,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
/// block Save while the form is invalid.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Today it carries one rule: a Heidenhain password field must be blank or a reference in the Heidenhain
|
||||
/// namespace (<see cref="HeidenhainOptionsParser.DescribeCredentialProblems"/>) — otherwise a literal password
|
||||
/// would be persisted in a draft (and replicated) even though the deploy gate would later refuse it.
|
||||
/// Today it carries one rule, for every driver that resolves credential references
|
||||
/// (<see cref="DriverCredentialPolicy"/> — Heidenhain, Galaxy, OpcUaClient): a credential field must be blank,
|
||||
/// an acceptable literal for that field, or a reference inside the driver's namespace (lmxopcua#803/#815) —
|
||||
/// otherwise an exfiltration-shaped reference (or a Heidenhain literal password) would be persisted in a draft
|
||||
/// (and replicated) even though the deploy gate and every resolver would later refuse it.
|
||||
/// </remarks>
|
||||
public static class DriverConfigSaveGate
|
||||
{
|
||||
@@ -22,8 +23,7 @@ public static class DriverConfigSaveGate
|
||||
/// <returns>The refusal (never containing a credential value), or null.</returns>
|
||||
public static string? Validate(string? driverType, string? configJson)
|
||||
{
|
||||
if (!string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var problems = HeidenhainOptionsParser.DescribeCredentialProblems(configJson);
|
||||
var problems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
|
||||
return problems.Count == 0 ? null : string.Join(" ", problems);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,6 +585,20 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
return;
|
||||
}
|
||||
|
||||
// lmxopcua#815: refuse a credential reference outside the driver's namespace BEFORE any probe runs — the
|
||||
// probe would otherwise be the seam that resolves it and sends it to the author-chosen endpoint. Nothing
|
||||
// is read from the secret store or the environment on this path.
|
||||
var credentialProblems = DriverCredentialPolicy.DescribeProblems(msg.DriverType, msg.ConfigJson);
|
||||
if (credentialProblems.Count > 0)
|
||||
{
|
||||
replyTo.Tell(new TestDriverConnectResult(
|
||||
false,
|
||||
"Test Connect refused: " + string.Join(" ", credentialProblems),
|
||||
null,
|
||||
msg.CorrelationId));
|
||||
return;
|
||||
}
|
||||
|
||||
Task.Run(() => ProbeAsync(probe, msg)).PipeTo(replyTo);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user