Merge fix/815-credential-ref-scoping

# Conflicts:
#	.claude/rules/drivers.md
#	docs/drivers/Heidenhain.md
This commit is contained in:
Joseph Doherty
2026-11-11 02:08:24 -05:00
43 changed files with 2268 additions and 304 deletions
@@ -0,0 +1,56 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// Deploy gate for credential references (lmxopcua#815): every DriverConfig and DeviceConfig of a driver type
/// listed in <see cref="DriverCredentialPolicy"/> must carry its credential fields as blank, an acceptable
/// literal, or a reference inside that driver's namespace (e.g. Galaxy <c>secret:galaxy/…</c> /
/// <c>env:OTOPCUA_GALAXY_…</c>). The same table the AdminUI save path, Test Connect, live browse and every
/// driver's resolve-time check read — so the gate and the runtime cannot disagree.
/// </summary>
/// <remarks>
/// Heidenhain is excluded here only because <see cref="HeidenhainDeployChecks"/> already reports the same rule
/// (as <see cref="HeidenhainDeployChecks.CredentialCode"/>) through the shared parser; reporting it twice would
/// duplicate every error. The message names the field and the allowed forms, never the value.
/// </remarks>
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>
public static IReadOnlyList<ValidationError> Errors(DraftSnapshot draft)
{
var errors = new List<ValidationError>();
foreach (var driver in draft.DriverInstances)
{
if (string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) continue;
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}{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}{MigrationHint}", device.DeviceId));
}
}
return errors;
}
}
@@ -47,6 +47,7 @@ public static class DraftValidator
errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft));
ValidateMTConnectSingleDevice(draft, errors);
errors.AddRange(HeidenhainDeployChecks.Errors(draft));
errors.AddRange(CredentialReferenceDeployChecks.Errors(draft));
ValidateNodeAclScopes(draft, errors);
return errors;
}
@@ -0,0 +1,302 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// The namespace a driver's operator-authored credential references are confined to (lmxopcua#815): the
/// secret-store prefix a <c>secret:</c> reference must start with, and the environment-variable prefixes an
/// <c>env:</c> reference must start with.
/// </summary>
/// <remarks>
/// The scope is the exfiltration guard. A Designer authors the endpoint AND the credential reference, and the
/// node resolves the reference and sends the value to that endpoint (Test Connect, live browse, deploy). An
/// unscoped reference could name any secret or process variable the node can read —
/// <c>env:ConnectionStrings__ConfigDb</c>, <c>env:ServerHistorian__ApiKey</c>, the mesh PSK — and send it in
/// cleartext to a host the Designer controls. Inside its own namespace a reference can only name values that
/// were provisioned to be sent to that driver's kind of endpoint.
/// </remarks>
/// <param name="Label">Driver label used in messages (e.g. <c>Galaxy</c>).</param>
/// <param name="SecretNamespaces">
/// The ONLY secret-store prefixes allowed, each ending in <c>/</c> (e.g. <c>galaxy/</c>). The first entry is the
/// documented form.
/// </param>
/// <param name="EnvVariablePrefixes">
/// The ONLY environment-variable prefixes allowed (exact, upper-case, e.g. <c>OTOPCUA_GALAXY_</c>); the remainder
/// must be <c>[A-Z0-9_]+</c>. The first entry is the documented form.
/// </param>
public sealed record CredentialScope(string Label, IReadOnlyList<string> SecretNamespaces, IReadOnlyList<string> EnvVariablePrefixes);
/// <summary>Whether a credential field may carry a literal (non-reference) value.</summary>
public enum CredentialLiteralPolicy
{
/// <summary>A literal is refused — the field must be blank or a namespaced reference.</summary>
Refused,
/// <summary>A literal is accepted and used verbatim (the author already knows it; nothing is resolved).</summary>
Allowed,
/// <summary>Only the explicit <c>dev:VALUE</c> literal is accepted (Galaxy's opt-in cleartext marker).</summary>
DevPrefixOnly,
}
/// <summary>The kind of a parsed credential field value.</summary>
public enum CredentialReferenceKind
{
/// <summary>Null or empty.</summary>
Blank,
/// <summary>A <c>secret:</c> (or <c>${secret:…}</c>) reference inside the scope's namespace.</summary>
Secret,
/// <summary>An <c>env:</c> reference inside the scope's prefixes.</summary>
Environment,
/// <summary>Not a reference: a literal value (acceptability depends on the field's <see cref="CredentialLiteralPolicy"/>).</summary>
Literal,
/// <summary>
/// A reference (<c>secret:</c>, <c>${secret:…}</c>, <c>env:</c>, <c>file:</c>) that is outside the scope or
/// malformed. Never resolved; always refused.
/// </summary>
OutOfScope,
}
/// <summary>A credential reference that may not (or cannot) be resolved. The message never carries a resolved value.</summary>
/// <param name="message">The message.</param>
public sealed class CredentialReferenceException(string message) : InvalidOperationException(message);
/// <summary>
/// The one parser + resolver of operator-authored credential references (<c>secret:</c> / <c>env:</c>), shared by
/// every driver, probe, browser, the AdminUI save path and the deploy gate (lmxopcua#815, generalising #803).
/// Resolution is fail-closed and re-checks the scope itself, so a reference that skipped every earlier gate is
/// still never looked up.
/// </summary>
public static class CredentialReferences
{
/// <summary>Prefix of a secret-store reference.</summary>
public const string SecretPrefix = "secret:";
/// <summary>Prefix of an environment-variable reference.</summary>
public const string EnvPrefix = "env:";
/// <summary>Prefix of a file reference — never allowed in operator-authored config (it reads any node file).</summary>
public const string FilePrefix = "file:";
/// <summary>Galaxy's explicit cleartext marker.</summary>
public const string DevPrefix = "dev:";
private const string TokenSecretPrefix = "${secret:";
/// <summary>
/// Parses <paramref name="value"/> against <paramref name="scope"/>. A prefix in another casing
/// (<c>SECRET:x</c>) is <see cref="CredentialReferenceKind.OutOfScope"/> — never a literal sent verbatim and
/// never resolved; the prefix, the namespace and the name are matched exactly.
/// </summary>
/// <param name="value">The authored field value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="name">The full secret name (incl. the namespace) or the environment variable; empty otherwise.</param>
/// <returns>The kind.</returns>
public static CredentialReferenceKind Parse(string? value, CredentialScope scope, out string name)
{
ArgumentNullException.ThrowIfNull(scope);
name = "";
if (string.IsNullOrEmpty(value)) return CredentialReferenceKind.Blank;
// A prefix in any casing is a reference (never a literal sent verbatim), but only the exact lower-case
// spelling is ever resolved — 'SECRET:galaxy/x' is refused, not silently honoured.
if (HasPrefixOtherCase(value, TokenSecretPrefix) || HasPrefixOtherCase(value, SecretPrefix)
|| HasPrefixOtherCase(value, EnvPrefix))
return CredentialReferenceKind.OutOfScope;
string? secret = null;
if (value.StartsWith(TokenSecretPrefix, StringComparison.Ordinal))
{
if (!value.EndsWith('}')) return CredentialReferenceKind.OutOfScope;
secret = value[TokenSecretPrefix.Length..^1];
}
else if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
{
secret = value[SecretPrefix.Length..];
}
if (secret is not null)
{
if (!IsScopedSecretName(secret, scope)) return CredentialReferenceKind.OutOfScope;
name = secret;
return CredentialReferenceKind.Secret;
}
if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
{
var variable = value[EnvPrefix.Length..];
if (!IsScopedVariable(variable, scope)) return CredentialReferenceKind.OutOfScope;
name = variable;
return CredentialReferenceKind.Environment;
}
return value.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase)
? CredentialReferenceKind.OutOfScope
: CredentialReferenceKind.Literal;
}
/// <summary>True when <paramref name="value"/> may be stored/deployed in a field with the given literal policy.</summary>
/// <param name="value">The authored value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="literals">The field's literal policy.</param>
/// <returns>Whether it is acceptable.</returns>
public static bool IsAcceptable(string? value, CredentialScope scope, CredentialLiteralPolicy literals) =>
Parse(value, scope, out _) switch
{
CredentialReferenceKind.Blank or CredentialReferenceKind.Secret or CredentialReferenceKind.Environment => true,
CredentialReferenceKind.Literal => literals switch
{
CredentialLiteralPolicy.Allowed => true,
CredentialLiteralPolicy.DevPrefixOnly => value!.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase),
_ => false,
},
_ => false,
};
/// <summary>The one wording of the rule — names the allowed forms, never the value.</summary>
/// <param name="field">The field.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="literals">The field's literal policy.</param>
/// <returns>The message.</returns>
public static string RuleMessage(string field, CredentialScope scope, CredentialLiteralPolicy literals)
{
ArgumentNullException.ThrowIfNull(scope);
var env = string.Join(" or ", scope.EnvVariablePrefixes.Select(p => $"'env:{p}<NAME>'"));
var secrets = string.Join(", ", scope.SecretNamespaces.Select(n => $"'secret:{n}<name>'"));
var forms = $"{secrets} or {env}";
return literals switch
{
CredentialLiteralPolicy.Allowed =>
$"{field} must be a literal value, {forms} — a secret:/env:/file: reference outside the {scope.Label} "
+ "namespace is refused (lmxopcua#815).",
CredentialLiteralPolicy.DevPrefixOnly =>
$"{field} must be blank, {forms} (or 'dev:<value>' on a dev rig) — a literal, a file: reference, or a "
+ $"reference outside the {scope.Label} namespace is refused (lmxopcua#815).",
_ =>
$"{field} must be blank, {forms} — a literal password, or a reference outside the {scope.Label} "
+ "namespace, is refused (lmxopcua#815).",
};
}
/// <summary>
/// Resolves a field value, fail-closed. Blank returns <c>""</c>; an acceptable literal returns verbatim; an
/// out-of-scope reference or a refused literal throws <see cref="CredentialReferenceException"/> BEFORE any
/// store or environment read; an absent secret / unset variable returns <see langword="null"/>.
/// </summary>
/// <param name="value">The authored value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="field">Field label for messages.</param>
/// <param name="literals">The field's literal policy.</param>
/// <param name="secretLookup">Secret-store lookup (by full name); null ⇒ a <c>secret:</c> reference throws.</param>
/// <param name="environment">Environment reader; null ⇒ <see cref="System.Environment.GetEnvironmentVariable(string)"/>.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The value, or null when the referenced value is absent.</returns>
public static async ValueTask<string?> ResolveOrNullAsync(
string? value,
CredentialScope scope,
string field,
CredentialLiteralPolicy literals,
Func<string, CancellationToken, ValueTask<string?>>? secretLookup,
Func<string, string?>? environment,
CancellationToken ct)
{
var kind = Parse(value, scope, out var name);
if (!IsAcceptable(value, scope, literals))
throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)");
switch (kind)
{
case CredentialReferenceKind.Blank:
return "";
case CredentialReferenceKind.Literal:
return value;
case CredentialReferenceKind.Environment:
return (environment ?? System.Environment.GetEnvironmentVariable)(name);
case CredentialReferenceKind.Secret:
if (secretLookup is null)
throw new CredentialReferenceException(
$"{field} references secret '{name}' but no secret store is available (fail-closed).");
return await secretLookup(name, ct).ConfigureAwait(false);
default:
throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)");
}
}
/// <summary>
/// As <see cref="ResolveOrNullAsync"/>, but an absent secret / unset (or empty) variable throws
/// <see cref="CredentialReferenceException"/> naming the secret or variable (never a value).
/// </summary>
/// <param name="value">The authored value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="field">Field label for messages.</param>
/// <param name="literals">The field's literal policy.</param>
/// <param name="secretLookup">Secret-store lookup; null ⇒ a <c>secret:</c> reference throws.</param>
/// <param name="environment">Environment reader; null ⇒ the process environment.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The value (<c>""</c> for blank).</returns>
public static async ValueTask<string> ResolveAsync(
string? value,
CredentialScope scope,
string field,
CredentialLiteralPolicy literals,
Func<string, CancellationToken, ValueTask<string?>>? secretLookup,
Func<string, string?>? environment,
CancellationToken ct)
{
var resolved = await ResolveOrNullAsync(value, scope, field, literals, secretLookup, environment, ct).ConfigureAwait(false);
var kind = Parse(value, scope, out var name);
if (!string.IsNullOrEmpty(resolved) || kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal)
return resolved ?? "";
throw kind == CredentialReferenceKind.Environment
? new CredentialReferenceException($"{field} environment variable '{name}' is not set (fail-closed).")
: new CredentialReferenceException($"{field} secret '{name}' is absent from the store (fail-closed).");
}
/// <summary>A print-safe form of a value: the reference itself, or a redaction for a literal / dev value.</summary>
/// <param name="value">The authored value.</param>
/// <returns>A string safe to log.</returns>
public static string Describe(string? value)
{
if (string.IsNullOrEmpty(value)) return "<blank>";
var v = value.Trim();
if (v.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
return v.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase)
|| v.StartsWith(TokenSecretPrefix, StringComparison.OrdinalIgnoreCase)
|| v.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase)
|| v.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase)
? v
: "<literal redacted>";
}
private static bool HasPrefixOtherCase(string value, string prefix) =>
value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && !value.StartsWith(prefix, StringComparison.Ordinal);
private static bool IsScopedSecretName(string secret, CredentialScope scope)
{
foreach (var ns in scope.SecretNamespaces)
{
if (!secret.StartsWith(ns, StringComparison.Ordinal)) continue;
var rest = secret[ns.Length..];
if (rest.Length is 0 or > 200) continue;
if (!rest.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) continue;
if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) continue;
return true;
}
return false;
}
private static bool IsScopedVariable(string variable, CredentialScope scope)
{
foreach (var prefix in scope.EnvVariablePrefixes)
{
if (!variable.StartsWith(prefix, StringComparison.Ordinal)) continue;
var rest = variable[prefix.Length..];
if (rest.Length is > 0 and <= 100 && rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
return true;
}
return false;
}
}
@@ -0,0 +1,148 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>One credential field of a driver config and whether it may carry a literal.</summary>
/// <param name="Name">The JSON property name (matched case-insensitively, as every driver binds).</param>
/// <param name="Literals">The literal policy.</param>
public sealed record CredentialField(string Name, CredentialLiteralPolicy Literals);
/// <summary>The credential scope + fields of one driver type.</summary>
/// <param name="DriverType">The <see cref="DriverTypeNames"/> value.</param>
/// <param name="Scope">The namespace its references are confined to.</param>
/// <param name="Fields">The credential fields its DriverConfig/DeviceConfig may carry.</param>
public sealed record DriverCredentialRules(string DriverType, CredentialScope Scope, IReadOnlyList<CredentialField> Fields);
/// <summary>
/// The per-driver credential namespaces (lmxopcua#815) — the single table the AdminUI save path, the deploy gate,
/// Test Connect, live browse and every driver's resolve-time check read.
/// </summary>
/// <remarks>
/// Every driver that RESOLVES a <c>secret:</c>/<c>env:</c> reference from operator-authored config and sends the
/// value to an operator-chosen endpoint is listed here. Drivers that resolve nothing (literal credentials only —
/// MQTT, MTConnect URI user-info) or whose resolved value carries its own endpoint (Sql's
/// <c>Sql__ConnectionStrings__&lt;ref&gt;</c>, already a fixed prefix) are not.
/// </remarks>
public static class DriverCredentialPolicy
{
/// <summary>Heidenhain LSV2 passwords: <c>secret:heidenhain/…</c> / <c>env:OTOPCUA_HEIDENHAIN_…</c> (#803).</summary>
public static readonly CredentialScope HeidenhainScope = new("Heidenhain", ["heidenhain/"], ["OTOPCUA_HEIDENHAIN_"]);
/// <summary>
/// Galaxy gateway API key + fallback ack password: <c>secret:galaxy/…</c> (or <c>secret:otopcua/galaxy/…</c>, the
/// form the ack-fallback docs used) / <c>env:OTOPCUA_GALAXY_…</c>, plus the
/// legacy <c>env:GALAXY_MXGW_…</c> prefix the docker-dev rig and the parity-rig docs already use
/// (<c>GALAXY_MXGW_API_KEY</c>) — a variable provisioned to be sent to an mxaccessgw.
/// </summary>
public static readonly CredentialScope GalaxyScope = new("Galaxy", ["galaxy/", "otopcua/galaxy/"], ["OTOPCUA_GALAXY_", "GALAXY_MXGW_"]);
/// <summary>OPC UA Client user / certificate passwords: <c>secret:opcuaclient/…</c> / <c>env:OTOPCUA_OPCUACLIENT_…</c>.</summary>
public static readonly CredentialScope OpcUaClientScope = new("OpcUaClient", ["opcuaclient/"], ["OTOPCUA_OPCUACLIENT_"]);
/// <summary>Galaxy <c>Gateway.ApiKeySecretRef</c> (literal accepted for back-compat, with a warning at resolve).</summary>
public static readonly CredentialField GalaxyApiKeyField = new("ApiKeySecretRef", CredentialLiteralPolicy.Allowed);
/// <summary>Galaxy <c>AlarmAck.FallbackPasswordSecretRef</c> (reference or explicit <c>dev:</c> only).</summary>
public static readonly CredentialField GalaxyAckPasswordField = new("FallbackPasswordSecretRef", CredentialLiteralPolicy.DevPrefixOnly);
private static readonly IReadOnlyDictionary<string, DriverCredentialRules> Rules =
new Dictionary<string, DriverCredentialRules>(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Heidenhain] = new(DriverTypeNames.Heidenhain, HeidenhainScope,
[
new("InspectPassword", CredentialLiteralPolicy.Refused),
new("DncPassword", CredentialLiteralPolicy.Refused),
new("DataPassword", CredentialLiteralPolicy.Refused),
]),
[DriverTypeNames.Galaxy] = new(DriverTypeNames.Galaxy, GalaxyScope, [GalaxyApiKeyField, GalaxyAckPasswordField]),
[DriverTypeNames.OpcUaClient] = new(DriverTypeNames.OpcUaClient, OpcUaClientScope,
[
new("Password", CredentialLiteralPolicy.Allowed),
new("UserCertificatePassword", CredentialLiteralPolicy.Allowed),
]),
};
/// <summary>The rules for a driver type, if it resolves credential references.</summary>
/// <param name="driverType">The driver type.</param>
/// <param name="rules">The rules.</param>
/// <returns>Whether the type has rules.</returns>
public static bool TryGet(string? driverType, out DriverCredentialRules rules)
{
if (driverType is not null && Rules.TryGetValue(driverType, out var r))
{
rules = r;
return true;
}
rules = null!;
return false;
}
/// <summary>
/// Scans a RAW DriverConfig / DeviceConfig / merged config document for credential fields of
/// <paramref name="driverType"/> whose value is not acceptable — at ANY depth (a field is bound wherever the
/// driver's merge places it), keys matched case-insensitively. Messages name the field path, never the value.
/// Unreadable JSON is reported for a type with rules (it cannot be checked); other types return empty.
/// </summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>The problems; empty when acceptable or the type has no credential rules.</returns>
public static IReadOnlyList<string> DescribeProblems(string? driverType, string? json)
{
var problems = new List<string>();
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return problems;
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
}
catch (JsonException)
{
problems.Add($"{rules.Scope.Label} config is not valid JSON, so its credential fields cannot be checked.");
return problems;
}
using (doc)
{
Scan(doc.RootElement, "", rules, problems, 0);
}
return problems;
}
private static void Scan(JsonElement element, string path, DriverCredentialRules rules, List<string> problems, int depth)
{
if (depth > 32) return;
if (element.ValueKind == JsonValueKind.Array)
{
var i = 0;
foreach (var item in element.EnumerateArray())
{
Scan(item, $"{path}[{i}]", rules, problems, depth + 1);
i++;
}
return;
}
if (element.ValueKind != JsonValueKind.Object) return;
foreach (var p in element.EnumerateObject())
{
var fieldPath = path.Length == 0 ? p.Name : $"{path}.{p.Name}";
var field = rules.Fields.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase));
if (field is null)
{
Scan(p.Value, fieldPath, rules, problems, depth + 1);
continue;
}
var ok = p.Value.ValueKind switch
{
JsonValueKind.Null => true,
JsonValueKind.String => CredentialReferences.IsAcceptable(p.Value.GetString(), rules.Scope, field.Literals),
_ => false,
};
if (!ok) problems.Add(CredentialReferences.RuleMessage(fieldPath, rules.Scope, field.Literals));
}
}
}
@@ -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:&lt;field&gt;</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\".");
}
}
}