using System.Text.Json; namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; /// /// The one place that decides whether a persisted config blob carries a literal Sql /// connectionString (Gitea #498 / #499). /// /// /// /// A Sql driver names its credentials indirectly — connectionStringRef resolves from /// the environment / secret store at Initialize — so nothing persisted should ever contain a /// database password. The typed SqlDriverConfigDto already drops a connectionString /// key on read, but discarding a secret on read is not the same as refusing to store it: /// config blobs are schemaless JSON columns authored through raw-JSON textareas, so the key can /// be written even though the runtime ignores it. /// /// /// There are three surfaces a Sql driver's config is persisted on, and they need different /// enforcement points: /// /// DriverInstance.DriverConfig and Device.DeviceConfig — both in the /// deployed artifact, both visible to DraftSnapshot, both gated by /// DraftValidator. /// ClusterNode.DriverConfigOverridesJson — the per-node override map. It is /// not in DraftSnapshot, and deliberately does not go there: adding a /// ClusterNode collection would widen the snapshot (and every builder of one) for a /// single rule about a value that is not part of the artifact at all. More to the point, a /// deploy gate is the wrong instrument here — the artifact never carries node overrides, so /// blocking a deploy would not stop the credential being stored. It is already in the /// database by then. This surface is therefore gated at the save, which is the only /// point where "refuse to store it" is literally true. /// /// /// /// Deliberately narrow. This checks the Sql driver's connectionString key /// only — not a general "credential-shaped key" sweep over every driver type. A broader rule /// would start refusing configs that are legitimate today for drivers that never made this /// guarantee, turning defence-in-depth into a regression. Widen it per driver, as each driver /// gains an indirect-credential contract of its own. /// /// public static class SqlCredentialGuard { /// The config key a Sql driver must never persist. Matched case-insensitively. public const string ForbiddenKey = "connectionString"; /// /// Whether is a JSON object carrying at /// its top level. /// /// /// Matched case-insensitively: System.Text.Json binds ConnectionString to a /// connectionString property by default, so a case variant is the same key, not a /// different one. Only the top level is scanned — the DTO is flat, so a nested occurrence cannot /// bind and is not the credential-shaped mistake this exists to catch. /// /// The config blob to inspect. /// when the key is present at the top level. public static bool CarriesLiteralConnectionString(string? configJson) { if (string.IsNullOrWhiteSpace(configJson)) return false; try { using var doc = JsonDocument.Parse(configJson); return doc.RootElement.ValueKind == JsonValueKind.Object && HasTopLevelKey(doc.RootElement, ForbiddenKey); } catch (JsonException) { // A malformed blob simply has no keys. Shaping the config JSON is another rule's job, and // throwing here would turn a formatting mistake into a credential-guard failure. return false; } } /// /// The DriverInstanceIds in a ClusterNode.DriverConfigOverridesJson map whose /// override object carries a literal connection string, restricted to instances that are actually /// Sql drivers. /// /// /// The override map is shaped { "<DriverInstanceId>": { …driver config keys… } } and is /// merged onto the cluster-level DriverConfig, so a credential pasted into one lands in /// exactly the place already refuses on the driver /// itself. /// /// The node's override map. Null / blank / malformed yields no /// violations. /// The ids of driver instances whose DriverType is /// Sql. Keys outside this set are ignored — a non-Sql driver never made the indirect-credential /// guarantee, so flagging it would be a regression, not defence in depth. /// The offending driver-instance ids, in document order; empty when there are none. public static IReadOnlyList FindNodeOverrideViolations( string? overridesJson, IReadOnlyCollection sqlDriverInstanceIds) { if (string.IsNullOrWhiteSpace(overridesJson) || sqlDriverInstanceIds.Count == 0) { return []; } var sqlIds = sqlDriverInstanceIds as IReadOnlySet ?? sqlDriverInstanceIds.ToHashSet(StringComparer.Ordinal); try { using var doc = JsonDocument.Parse(overridesJson); if (doc.RootElement.ValueKind != JsonValueKind.Object) return []; List? offenders = null; foreach (var entry in doc.RootElement.EnumerateObject()) { if (!sqlIds.Contains(entry.Name)) continue; if (entry.Value.ValueKind != JsonValueKind.Object) continue; if (!HasTopLevelKey(entry.Value, ForbiddenKey)) continue; (offenders ??= []).Add(entry.Name); } return (IReadOnlyList?)offenders ?? []; } catch (JsonException) { return []; } } /// Whether carries at its top level, /// case-insensitively. private static bool HasTopLevelKey(JsonElement obj, string key) { foreach (var property in obj.EnumerateObject()) { if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true; } return false; } }