fix(security): warn-only compatibility for deployed drivers + upgrade preflight (#815)
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>
This commit is contained in:
+12
-2
@@ -20,6 +20,16 @@ public static class CredentialReferenceDeployChecks
|
||||
/// <summary>A credential field holding a reference outside the driver's namespace (or a refused literal).</summary>
|
||||
public const string Code = "CredentialReferenceOutOfScope";
|
||||
|
||||
/// <summary>
|
||||
/// Appended to every error: a config deployed before #815 is still RUNNING in warn-only compatibility mode, and
|
||||
/// this deploy is where it stops — so the message names the migration, not just the rule.
|
||||
/// </summary>
|
||||
public const string MigrationHint =
|
||||
" Migrate it before deploying: create the value under the allowed name (secret store via /admin/secrets or the "
|
||||
+ "secret CLI, or the environment variable on every node that runs this driver AND the admin nodes) and point the "
|
||||
+ "field at it. A driver deployed before this upgrade keeps resolving the old reference (warn-only, deprecated) "
|
||||
+ "only until this deploy succeeds. See docs/security.md §\"Credential references are namespaced per driver\".";
|
||||
|
||||
/// <summary>Runs the gate.</summary>
|
||||
/// <param name="draft">The draft.</param>
|
||||
/// <returns>The errors.</returns>
|
||||
@@ -32,12 +42,12 @@ public static class CredentialReferenceDeployChecks
|
||||
if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue;
|
||||
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
|
||||
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}", driver.DriverInstanceId));
|
||||
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}{MigrationHint}", driver.DriverInstanceId));
|
||||
|
||||
foreach (var device in draft.Devices.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal)))
|
||||
{
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig))
|
||||
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}", device.DeviceId));
|
||||
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}{MigrationHint}", device.DeviceId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ DEPRECATED compatibility path (lmxopcua#815) — the ONE switch between "enforce" and "warn-only" credential
|
||||
/// resolution, and it is chosen by CALL SITE, never by a global flag: a deployed, running driver (Galaxy
|
||||
/// <c>InitializeAsync</c>/<c>ReopenAsync</c> + ack fallback, OpcUaClient session open) passes its instance's tracker
|
||||
/// and so keeps resolving a pre-#815 out-of-namespace reference with its OLD semantics, warning instead of
|
||||
/// faulting; every admin-node path (Test Connect probe, AdminUI live browse) and Heidenhain pass
|
||||
/// <see langword="null"/> and stay hard-fail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Why it is safe to keep for running drivers: the AdminUI save path and the deploy gate refuse every
|
||||
/// out-of-namespace reference, so a Designer can no longer CREATE one — what remains are configs deployed
|
||||
/// before the upgrade, whose endpoint and reference an administrator already deployed. Such a config runs
|
||||
/// only until its next deploy (which the gate refuses until it is migrated).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Removing it (the planned hard-fail release): delete this type, the per-driver <c>Legacy…</c> resolve
|
||||
/// methods it gates and the <c>legacy</c> parameters; every caller then takes the enforce branch it already
|
||||
/// has. Nothing else changes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each use is reported through <paramref name="warn"/> at most once per <see cref="WarnInterval"/> per field
|
||||
/// (a Galaxy gateway reconnect re-resolves every time), and is exposed as <see cref="Diagnostics"/> so the
|
||||
/// driver's <see cref="IDriverDiagnostics"/> bag carries it to the AdminUI (<c>/hosts</c> and the <c>/raw</c>
|
||||
/// credential banner). Messages name the driver, field and allowed forms — never the value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="scope">The driver's scope (for the allowed forms).</param>
|
||||
/// <param name="driverInstanceId">The driver instance (for messages).</param>
|
||||
/// <param name="warn">Sink for a rate-limited warning line.</param>
|
||||
/// <param name="clock">Clock (tests); null ⇒ <see cref="DateTime.UtcNow"/>.</param>
|
||||
public sealed class LegacyCredentialReferenceTracker(
|
||||
CredentialScope scope, string driverInstanceId, Action<string> warn, Func<DateTime>? clock = null)
|
||||
{
|
||||
/// <summary>The <see cref="Diagnostics"/> key prefix (one entry per field).</summary>
|
||||
public const string DiagnosticsKeyPrefix = "credential-compat:";
|
||||
|
||||
/// <summary>Minimum spacing between two warnings for the same field.</summary>
|
||||
public static readonly TimeSpan WarnInterval = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, DateTime> _lastWarned = new(StringComparer.Ordinal);
|
||||
private readonly SortedDictionary<string, string> _diagnostics = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The scope this tracker reports against.</summary>
|
||||
public CredentialScope Scope => scope;
|
||||
|
||||
/// <summary>The current legacy uses, keyed <c>credential-compat:<field></c>.</summary>
|
||||
public IReadOnlyDictionary<string, string> Diagnostics
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate) return new Dictionary<string, string>(_diagnostics, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records that <paramref name="field"/> was resolved through the legacy path (warns, rate-limited).</summary>
|
||||
/// <param name="field">The field label.</param>
|
||||
public void Report(string field)
|
||||
{
|
||||
var forms = $"secret:{scope.SecretNamespaces[0]}<name> or env:{scope.EnvVariablePrefixes[0]}<NAME>";
|
||||
var now = (clock ?? (() => DateTime.UtcNow))();
|
||||
bool log;
|
||||
lock (_gate)
|
||||
{
|
||||
_diagnostics[DiagnosticsKeyPrefix + field] =
|
||||
$"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}";
|
||||
log = !_lastWarned.TryGetValue(field, out var last) || now - last >= WarnInterval;
|
||||
if (log) _lastWarned[field] = now;
|
||||
}
|
||||
|
||||
if (log)
|
||||
{
|
||||
warn($"{scope.Label} driver '{driverInstanceId}': {field} is a credential reference outside the {scope.Label} "
|
||||
+ $"namespace. It is still resolved for this already-deployed driver (lmxopcua#815 warn-only "
|
||||
+ $"compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: migrate "
|
||||
+ $"it to {forms} before the next deploy. See docs/security.md §\"Credential references are namespaced per driver\".");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user