fix(security): preflight and /raw banner check the merged driver+device config (#850)

The deploy gate's merged-config path check is now one helper,
CredentialReferenceDeployChecks.MergedOnlyProblems (problems the runtime merge
reveals that the per-config checks do not), shared by the gate and
CredentialReferencePreflight.Scan - so an AuthType switch in one config and a
UserCertificatePath in the other is listed as a Draft finding '(merged with its
devices)' on the banner before a deploy refuses it.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-11-11 06:24:59 -05:00
parent d08e59eea7
commit 3dbfe6ec0a
3 changed files with 89 additions and 22 deletions
@@ -74,35 +74,44 @@ public static class CredentialReferenceDeployChecks
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}{MigrationHint}", driver.DriverInstanceId));
var reported = new HashSet<string>(StringComparer.Ordinal);
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
reported.Add(problem);
foreach (var device in devices)
{
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig))
{
reported.Add(problem);
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}{MigrationHint}", device.DeviceId));
}
}
// #836 review L-1: a path field is exempt only in an object that explicitly switches it off (OpcUaClient
// AuthType ≠ Certificate), and the switch and the path may sit in different configs (driver vs device). Check
// the config exactly as the runtime merges it, so a split can never deploy a path the driver will load.
if (rules.PathFieldList.Count > 0)
{
var merged = DriverDeviceConfigMerger.Merge(
driver.DriverConfig,
devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig)).ToList(),
[]);
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, merged))
{
if (reported.Add(problem))
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}' (merged with its devices): {problem}{MigrationHint}", driver.DriverInstanceId));
}
}
foreach (var problem in MergedOnlyProblems(
driver.DriverType, driver.DriverConfig, devices.Select(d => (d.Name, (string?)d.DeviceConfig)).ToList()))
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}' (merged with its devices): {problem}{MigrationHint}", driver.DriverInstanceId));
}
return errors;
}
/// <summary>
/// #836 review L-1 / lmxopcua#850: a path field is exempt only in an object that explicitly switches it off
/// (OpcUaClient <c>AuthType</c> ≠ Certificate), and the switch and the path may sit in different configs (driver vs
/// device). This checks the config exactly as the runtime merges it (<see cref="DriverDeviceConfigMerger"/>) and
/// returns the problems the merge reveals that the driver and device configs, checked one by one, do not — so a
/// split can never hide a path the driver will load. The ONE implementation the deploy gate and the upgrade
/// preflight / <c>/raw</c> banner share. Empty for a type without path fields.
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="driverConfig">The stored DriverConfig.</param>
/// <param name="devices">Its devices (name, DeviceConfig).</param>
/// <returns>The merged-only problems.</returns>
public static IReadOnlyList<string> MergedOnlyProblems(
string? driverType, string? driverConfig, IReadOnlyList<(string Name, string? DeviceConfig)> devices)
{
ArgumentNullException.ThrowIfNull(devices);
if (!DriverCredentialPolicy.TryGet(driverType, out var rules) || rules.PathFieldList.Count == 0) return [];
var reported = new HashSet<string>(DriverCredentialPolicy.DescribeProblems(driverType, driverConfig), StringComparer.Ordinal);
foreach (var (_, deviceConfig) in devices)
reported.UnionWith(DriverCredentialPolicy.DescribeProblems(driverType, deviceConfig));
var merged = DriverDeviceConfigMerger.Merge(
driverConfig ?? "{}",
devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig ?? "{}")).ToList(),
[]);
return DriverCredentialPolicy.DescribeProblems(driverType, merged).Where(reported.Add).ToList();
}
}