fix(config): refuse to store a Sql credential in a node config override (#499)
`ClusterNode.DriverConfigOverridesJson` is the third surface a Sql driver's config is persisted on, and the only one nothing gated. It is a map keyed by `DriverInstanceId` merged onto the cluster-level `DriverConfig`, so a literal `connectionString` pasted there leaks exactly as one pasted into the driver — the leak #498 closed for `DriverInstance.DriverConfig` and `Device.DeviceConfig`. Gated at the SAVE, not at the deploy — a deliberate departure from both options the issue offered: - `DraftSnapshot` carries no `ClusterNode` rows, and adding them would widen the snapshot and every builder of one for a single rule about a value that never enters the artifact. - More to the point, a deploy gate is the wrong instrument here. Node overrides are not in the artifact, so blocking a deploy would not stop the credential being stored — it is already in the database and replicated by then. Refusing the save is the only point where "refuse to store it" is literally true, which is #498's own framing: discarding a secret on read is not the same as refusing to store it. The check moves into a shared `SqlCredentialGuard` so the two enforcement points cannot drift; `DraftValidator` now calls it instead of its own private helper. Kept deliberately narrow — the `Sql` driver's `connectionString` only, and only for instance ids that ARE Sql drivers. The issue asked whether to generalise to credential-shaped keys across every driver type; not doing that, for the same reason #498 did not: a broader sweep would start refusing configs that are legitimate today for drivers which never made an indirect-credential guarantee, which is a regression rather than defence in depth. Widen per driver, as each gains its own contract. The error names the offending driver instance(s) and NEVER the value — it reaches the AdminUI and the audit trail. 14 new cases cover both halves, including the case variants (System.Text.Json binds `ConnectionString` to `connectionString`, so a case variant is the same key, not a bypass), non-Sql ids being ignored, and every blank/malformed/non-object shape.
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@@ -178,6 +180,33 @@ else
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
// #499 — the third Sql-credential surface. DriverConfigOverridesJson is merged onto the
|
||||
// cluster-level DriverConfig, so a literal connectionString pasted here leaks exactly as one
|
||||
// pasted into the driver itself. The deploy gate cannot see it (DraftSnapshot carries no
|
||||
// ClusterNode rows) and would be the wrong place anyway: node overrides never enter the
|
||||
// artifact, so by the time a deploy ran the credential would already be stored. Refuse the
|
||||
// save instead. The message names the driver instance but NEVER the value.
|
||||
if (!string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson))
|
||||
{
|
||||
var sqlDriverIds = await db.DriverInstances
|
||||
.Where(d => d.ClusterId == ClusterId && d.DriverType == DriverTypeNames.Sql)
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.ToListAsync();
|
||||
|
||||
var offenders = SqlCredentialGuard.FindNodeOverrideViolations(
|
||||
_form.DriverConfigOverridesJson, sqlDriverIds);
|
||||
if (offenders.Count > 0)
|
||||
{
|
||||
_error =
|
||||
$"Driver config overrides carry a '{SqlCredentialGuard.ForbiddenKey}' for Sql driver " +
|
||||
$"instance(s) {string.Join(", ", offenders)}. A Sql driver must name its credentials " +
|
||||
"indirectly via 'connectionStringRef', which resolves from the environment / secret " +
|
||||
"store at Initialize; a literal connection string here would be stored in the config " +
|
||||
"database, and the runtime ignores it anyway. Remove the key.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId))
|
||||
|
||||
Reference in New Issue
Block a user