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\".");
}
}
}
@@ -64,9 +64,11 @@ public sealed record GalaxyDriverOptions(
/// </list>
/// </para>
/// <para>
/// <see cref="FallbackPasswordSecretRef"/> must be a reference — <c>secret:NAME</c> (production),
/// <c>env:NAME</c>, <c>file:PATH</c> or <c>dev:VALUE</c> (rigs only). A bare literal is rejected so a
/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB.
/// <see cref="FallbackPasswordSecretRef"/> must be a Galaxy-namespaced reference (lmxopcua#815) —
/// <c>secret:galaxy/NAME</c> / <c>secret:otopcua/galaxy/NAME</c> (production), <c>env:OTOPCUA_GALAXY_NAME</c>
/// / <c>env:GALAXY_MXGW_NAME</c>, or <c>dev:VALUE</c> (rigs only). A bare literal, <c>file:</c> and any other
/// reference are rejected so a Galaxy password never sits in cleartext in the config DB and the field can
/// never name another subsystem's secret.
/// </para>
/// </remarks>
/// <param name="FallbackUser">Galaxy user the fallback acknowledges as; null/empty = no fallback.</param>
@@ -84,20 +86,16 @@ public sealed record GalaxyAlarmAckOptions(
/// <summary>
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
/// supported, in priority order:
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. References are confined
/// to the Galaxy namespace (lmxopcua#815 — the key is sent to the authored <c>Endpoint</c>):
/// <list type="bullet">
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
/// production; the central config DB holds only the indirection, not the key).</item>
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
/// <item><c>secret:NAME</c> — resolved through the shared <c>ZB.MOM.WW.Secrets</c>
/// <item><c>secret:galaxy/NAME</c> (or <c>secret:otopcua/galaxy/NAME</c>) — the shared <c>ZB.MOM.WW.Secrets</c>
/// encrypted store; fail-closed if absent (the production path).</item>
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
/// no startup warning.</item>
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
/// The resolver emits a <c>Warning</c> at startup so an operator who accidentally
/// committed a cleartext key sees it; production should migrate to <c>env:</c>
/// or <c>file:</c>.</item>
/// <item><c>env:OTOPCUA_GALAXY_NAME</c> (or legacy <c>env:GALAXY_MXGW_NAME</c>) — an environment variable.</item>
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests; no startup warning.</item>
/// <item>Any other unprefixed string — a literal cleartext API key for back-compat, with a startup
/// <c>Warning</c>.</item>
/// <item>Any other <c>secret:</c>/<c>env:</c> reference, and every <c>file:</c> reference — refused.</item>
/// </list>
/// </summary>
// PR 6.5 tuning notes:
@@ -1,33 +1,24 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <summary>
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
/// forms supported, evaluated in order:
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. The reference is resolved on the node
/// and sent to the operator-authored <c>Gateway.Endpoint</c>, so references are confined to the Galaxy
/// namespace (lmxopcua#815, <see cref="DriverCredentialPolicy.GalaxyScope"/>) and the check is repeated here, at
/// resolve time, before any store or environment read:
/// <list type="number">
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
/// Throws when the variable is unset, so a misconfigured deployment fails
/// fast rather than silently sending an empty key.</item>
/// <item><c>file:PATH</c> — reads UTF-8 text from <c>PATH</c>, trimming
/// whitespace. Lets operators stash the key in an ACL'd file outside the
/// repo (the same pattern as the legacy <c>.local/galaxy-host-secret.txt</c>).</item>
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
/// doesn't emit a warning; production should never use this arm.</item>
/// <item><c>secret:NAME</c> — resolves NAME through the shared
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/> (the encrypted-at-rest
/// store). Fail-closed: a <c>secret:</c> ref whose secret is absent/tombstoned
/// throws rather than falling through to the literal arm — the production path
/// that retires the cleartext <c>dev:</c>/literal-in-DB model.</item>
/// <item>Anything else — used as the literal API key for back-compat with
/// configs that pre-date this resolver. When a logger is supplied the
/// resolver emits a startup warning so an operator who accidentally
/// committed a cleartext key sees it.</item>
/// <item><c>secret:galaxy/NAME</c> (or <c>${secret:galaxy/NAME}</c>) — resolved through the shared
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/>; fail-closed when absent.</item>
/// <item><c>env:OTOPCUA_GALAXY_NAME</c> / legacy <c>env:GALAXY_MXGW_NAME</c> — the process environment;
/// fail-closed when unset.</item>
/// <item><c>dev:KEY</c> — explicit cleartext literal (dev box, parity rig); no warning.</item>
/// <item>Any other unprefixed string — the literal key (back-compat), with a warning when a logger is supplied.</item>
/// <item>A <c>secret:</c>/<c>env:</c> reference OUTSIDE the namespace, and every <c>file:</c> reference (it
/// read any file the node could open) — refused with <see cref="CredentialReferenceException"/>.</item>
/// </list>
/// A future PR can swap any of these arms for a different backing store without
/// changing the call site.
/// </summary>
/// <remarks>
/// Lives in the Contracts project so both the runtime <c>GalaxyDriver</c> and the
@@ -37,80 +28,108 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
public static class GalaxySecretRef
{
/// <summary>
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
/// is absent). When the ref falls through to the back-compat literal arm (an
/// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
/// <paramref name="logger"/> is supplied, emits a <see cref="LogLevel.Warning"/>.
/// The <c>dev:</c> prefix is the explicit opt-in path that doesn't warn.
/// Resolves the supplied API-key reference (see the type remarks). Fail-closed for an out-of-namespace
/// reference, a <c>file:</c> reference, an absent secret and an unset variable.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
/// <param name="legacy">
/// ⚠️ Deprecated #815 compatibility: the RUNNING driver passes its tracker, so a pre-#815 out-of-namespace
/// reference is resolved with the old semantics and warned about instead of failing. The AdminUI browser
/// passes null (hard-fail). See <see cref="LegacyCredentialReferenceTracker"/>.
/// </param>
/// <returns>The resolved API-key string.</returns>
public static async Task<string> ResolveApiKeyAsync(
string secretRef,
ISecretResolver resolver,
ILogger? logger = null,
CancellationToken ct = default)
CancellationToken ct = default,
Func<string, string?>? environment = null,
LegacyCredentialReferenceTracker? legacy = null)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
ArgumentNullException.ThrowIfNull(resolver);
var scope = DriverCredentialPolicy.GalaxyScope;
var kind = CredentialReferences.Parse(secretRef, scope, out _);
if (kind == CredentialReferenceKind.OutOfScope && legacy is not null)
{
legacy.Report("Gateway.ApiKeySecretRef");
return await LegacyResolveApiKeyAsync(secretRef, resolver, logger, ct, environment).ConfigureAwait(false);
}
if (kind == CredentialReferenceKind.Literal)
{
if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a cleartext literal.
return secretRef[CredentialReferences.DevPrefix.Length..];
}
// Back-compat literal arm. Warn so an operator who accidentally committed a cleartext key sees it.
logger?.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer secret:galaxy/<name> or env:OTOPCUA_GALAXY_<NAME>, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
// secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read.
return await CredentialReferences.ResolveAsync(
secretRef,
scope,
"Galaxy.Gateway.ApiKeySecretRef",
DriverCredentialPolicy.GalaxyApiKeyField.Literals,
async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
environment,
ct).ConfigureAwait(false);
}
/// <summary>
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
/// verbatim — case-insensitive <c>env:</c> / <c>file:</c> / <c>dev:</c> / <c>secret:</c>, anything else a
/// literal. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver.
/// </summary>
private static async Task<string> LegacyResolveApiKeyAsync(
string secretRef, ISecretResolver resolver, ILogger? logger, CancellationToken ct, Func<string, string?>? environment)
{
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
var value = (environment ?? Environment.GetEnvironmentVariable)(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
$"Galaxy.Gateway.ApiKeySecretRef resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef points at '{path}', which doesn't exist.");
var contents = (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
: throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return secretRef[4..];
if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
{
// Production path: resolve the name through the shared encrypted secret store.
// Fail-closed — an absent/tombstoned secret throws rather than falling through
// to the literal arm (which would silently treat the ref string as the key).
var name = secretRef["secret:".Length..];
var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed).");
$"Galaxy.Gateway.ApiKeySecretRef resolves secret '{name}', but it is absent from the store (fail-closed).");
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
// this warning when the literal is intentional.
logger?.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
logger?.LogWarning("Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key.");
return secretRef;
}
}
@@ -38,8 +38,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// </para>
/// </remarks>
public sealed class GalaxyDriver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDisposable, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable
{
// ⚠️ lmxopcua#815 warn-only compatibility (deprecated): this RUNNING driver keeps resolving a pre-#815
// out-of-namespace credential reference (API key on InitializeAsync AND every gateway ReopenAsync, plus the
// ack fallback password) with the old semantics, warning rate-limited and surfacing it on the diagnostics bag.
// The AdminUI browser and the probe never get a tracker, so they stay hard-fail.
private readonly LegacyCredentialReferenceTracker _legacyCredentials;
/// <inheritdoc />
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics;
/// <summary>Authored tags this driver could not dial — a TagConfig with no usable
/// <c>attributeRef</c> has no MXAccess address to bind.</summary>
private readonly SkippedTagLedger _skippedTags = new();
@@ -342,6 +351,8 @@ public sealed class GalaxyDriver
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
_legacyCredentials = new LegacyCredentialReferenceTracker(
DriverCredentialPolicy.GalaxyScope, _driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message));
_hierarchySource = hierarchySource;
_dataReader = dataReader;
_dataWriter = dataWriter;
@@ -349,7 +360,7 @@ public sealed class GalaxyDriver
_alarmAcknowledger = alarmAcknowledger;
_alarmFeed = alarmFeed;
_ackCredentialResolver = ackCredentialResolver
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger);
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials);
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
// are authored (live-browse path), leaving every translation an identity no-op.
@@ -1098,7 +1109,9 @@ public sealed class GalaxyDriver
}
}
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
// Internal: the ONE client-options build InitializeAsync, ReopenAsync (every gateway reconnect) and the
// repository/alarm clients share — the #815 warn-only tests drive it directly to prove a reconnect re-resolves.
internal async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
{
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
@@ -1108,7 +1121,7 @@ public sealed class GalaxyDriver
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
// implementation; the secret: arm resolves through the shared ISecretResolver.
var apiKey = await GalaxySecretRef
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken, legacy: _legacyCredentials)
.ConfigureAwait(false);
return new MxGatewayClientOptions
{
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
@@ -20,15 +21,22 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
private readonly GalaxyAlarmAckOptions _options;
private readonly ISecretResolver _secrets;
private readonly ILogger _logger;
private readonly Func<string, string?>? _environment;
private readonly LegacyCredentialReferenceTracker? _legacy;
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredentialResolver"/> class.</summary>
/// <param name="driverInstanceId">The owning driver instance id (for messages).</param>
/// <param name="options">The driver's ack-credential options.</param>
/// <param name="secrets">The shared secret resolver.</param>
/// <param name="logger">Logger; never receives a secret.</param>
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
/// <param name="legacy">⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail.</param>
public GalaxyAckCredentialResolver(
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger)
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger,
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
{
_environment = environment;
_legacy = legacy;
_driverInstanceId = driverInstanceId;
_options = options ?? new GalaxyAlarmAckOptions();
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
@@ -142,56 +150,57 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
}
/// <summary>
/// Resolves a password reference. Accepts <c>secret:NAME</c>, <c>${secret:NAME}</c>, <c>env:NAME</c>,
/// <c>file:PATH</c> and <c>dev:VALUE</c>; returns null when the referenced value is absent; throws
/// <see cref="FormatException"/> for a bare literal (a cleartext password in DriverConfig).
/// Resolves the fallback password reference through the shared lmxopcua#815 resolver: <c>secret:galaxy/NAME</c>
/// (or <c>secret:otopcua/galaxy/NAME</c>, <c>${secret:…}</c>), <c>env:OTOPCUA_GALAXY_NAME</c> /
/// <c>env:GALAXY_MXGW_NAME</c>, or <c>dev:VALUE</c>; returns null when the referenced value is absent; throws
/// <see cref="CredentialReferenceException"/> — before any store/environment read — for a bare literal, a
/// <c>file:</c> reference, or a reference outside the Galaxy namespace (the password is sent to the
/// operator-authored gateway endpoint, so it must never be able to name another subsystem's secret).
/// </summary>
internal async Task<string?> ResolvePasswordRefAsync(string reference, CancellationToken ct)
{
if (_legacy is not null
&& CredentialReferences.Parse(reference, DriverCredentialPolicy.GalaxyScope, out _) == CredentialReferenceKind.OutOfScope)
{
_legacy.Report("AlarmAck.FallbackPasswordSecretRef");
return await LegacyResolvePasswordRefAsync(reference, ct).ConfigureAwait(false);
}
var resolved = await CredentialReferences.ResolveOrNullAsync(
reference,
DriverCredentialPolicy.GalaxyScope,
"AlarmAck.FallbackPasswordSecretRef",
DriverCredentialPolicy.GalaxyAckPasswordField.Literals,
async (name, c) => await _secrets.GetAsync(new SecretName(name), c).ConfigureAwait(false),
_environment,
ct).ConfigureAwait(false);
return resolved is not null && reference.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase)
? resolved[CredentialReferences.DevPrefix.Length..]
: resolved;
}
/// <summary>
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
/// verbatim. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver.
/// </summary>
private async Task<string?> LegacyResolvePasswordRefAsync(string reference, CancellationToken ct)
{
if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}'))
{
reference = "secret:" + reference["${secret:".Length..^1];
}
if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
{
return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false);
}
if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
return Environment.GetEnvironmentVariable(reference[4..]);
}
return (_environment ?? Environment.GetEnvironmentVariable)(reference[4..]);
if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = reference[5..];
return File.Exists(path)
? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n')
: null;
return File.Exists(path) ? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n') : null;
}
if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
return reference[4..];
}
throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/file:/dev: reference, not a literal.");
if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return reference[4..];
throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/dev: reference, not a literal.");
}
// Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal.
internal static string DescribeRef(string reference)
{
var r = reference.Trim();
if (r.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase)
|| r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)
|| r.StartsWith("env:", StringComparison.OrdinalIgnoreCase)
|| r.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
return r;
}
return "<literal redacted>";
}
internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference);
}
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
@@ -21,14 +22,14 @@ public static class HeidenhainOptionsParser
/// exfiltration guard: the reference is resolved on the node and sent in cleartext in <c>A_LG</c> to a Host
/// the author chose, so it must never be able to name another subsystem's secret.
/// </summary>
public const string SecretNamespace = "heidenhain/";
public static readonly string SecretNamespace = DriverCredentialPolicy.HeidenhainScope.SecretNamespaces[0];
/// <summary>
/// The ONLY environment variables a Heidenhain password may name: <c>env:OTOPCUA_HEIDENHAIN_&lt;NAME&gt;</c>
/// (exact upper-case prefix; <c>NAME</c> is <c>[A-Z0-9_]+</c>) — never <c>ConnectionStrings__*</c>,
/// <c>ServerHistorian__ApiKey</c> or any other process variable.
/// </summary>
public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_";
public static readonly string EnvVariablePrefix = DriverCredentialPolicy.HeidenhainScope.EnvVariablePrefixes[0];
/// <summary>The password fields a Heidenhain DriverConfig/DeviceConfig may carry.</summary>
public static readonly IReadOnlyList<string> PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"];
@@ -164,105 +165,41 @@ public static class HeidenhainOptionsParser
/// <returns>True for blank or a namespaced reference.</returns>
public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name)
{
kind = CredentialKind.None;
name = "";
if (string.IsNullOrEmpty(value)) return true;
if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
// The shared lmxopcua#815 parser — one rule for every driver that resolves credential references.
switch (CredentialReferences.Parse(value, DriverCredentialPolicy.HeidenhainScope, out name))
{
var secret = value[SecretPrefix.Length..];
if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false;
var rest = secret[SecretNamespace.Length..];
if (rest.Length is 0 or > 200) return false;
foreach (var c in rest)
if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false;
if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false;
kind = CredentialKind.Secret;
name = secret;
return true;
}
if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
{
var variable = value[EnvPrefix.Length..];
if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false;
var rest = variable[EnvVariablePrefix.Length..];
if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
case CredentialReferenceKind.Blank:
kind = CredentialKind.None;
return true;
case CredentialReferenceKind.Secret:
kind = CredentialKind.Secret;
return true;
case CredentialReferenceKind.Environment:
kind = CredentialKind.Environment;
return true;
default:
kind = CredentialKind.None;
name = "";
return false;
kind = CredentialKind.Environment;
name = variable;
return true;
}
return false;
}
/// <summary>
/// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not
/// namespaced references — at the top level (a device / merged config), under <c>Access</c>, and under every
/// <c>Devices[]</c> entry; keys matched case-insensitively, as the parser binds them. The messages name the
/// field, never the value. Unreadable JSON is reported, since it cannot be checked.
/// namespaced references — at any depth (top level, <c>Access</c>, <c>Devices[]</c>…); keys matched
/// case-insensitively, as the parser binds them. The messages name the field, never the value. Unreadable
/// JSON is reported, since it cannot be checked. Delegates to the shared <see cref="DriverCredentialPolicy"/>.
/// </summary>
/// <param name="json">The config JSON.</param>
/// <returns>The problems; empty when every password field is acceptable.</returns>
public static IReadOnlyList<string> DescribeCredentialProblems(string? json)
{
var problems = new List<string>();
if (string.IsNullOrWhiteSpace(json)) return problems;
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
}
catch (JsonException)
{
problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked.");
return problems;
}
using (doc)
{
if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems;
ScanObject(doc.RootElement, "", problems);
foreach (var p in doc.RootElement.EnumerateObject())
{
if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object)
ScanObject(p.Value, "Access.", problems);
if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array)
{
var i = 0;
foreach (var d in p.Value.EnumerateArray())
{
if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems);
i++;
}
}
}
}
return problems;
}
public static IReadOnlyList<string> DescribeCredentialProblems(string? json) =>
DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Heidenhain, json);
/// <summary>The one wording of the credential rule (never includes the value).</summary>
/// <param name="field">The field.</param>
/// <returns>The message.</returns>
public static string CredentialRuleMessage(string field) =>
$"{field} must be blank, 'secret:{SecretNamespace}<name>' or 'env:{EnvVariablePrefix}<NAME>' — a literal "
+ "password, or a reference outside the Heidenhain namespace, is refused.";
private static void ScanObject(JsonElement obj, string prefix, List<string> problems)
{
foreach (var p in obj.EnumerateObject())
{
if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue;
var ok = p.Value.ValueKind switch
{
JsonValueKind.Null => true,
JsonValueKind.String => IsCredentialReference(p.Value.GetString()),
_ => false,
};
if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name));
}
}
CredentialReferences.RuleMessage(field, DriverCredentialPolicy.HeidenhainScope, CredentialLiteralPolicy.Refused);
private static void CheckRef(List<string> errors, string field, string? value)
{
@@ -1,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
@@ -31,26 +32,23 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<stri
return await ResolveReferenceAsync(reference, role, ct).ConfigureAwait(false);
}
/// <summary>Resolves one reference.</summary>
/// <summary>Resolves one reference through the shared lmxopcua#815 resolver (scope re-checked before any read).</summary>
public async ValueTask<string> ResolveReferenceAsync(string? reference, string role, CancellationToken ct)
{
if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name))
throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)");
switch (kind)
try
{
case HeidenhainOptionsParser.CredentialKind.None:
return "";
case HeidenhainOptionsParser.CredentialKind.Environment:
return _environment(name) is { Length: > 0 } value
? value
: throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed).");
default:
if (resolver is null)
throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed).");
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(resolved)
? resolved
: throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (fail-closed).");
return await CredentialReferences.ResolveAsync(
reference,
DriverCredentialPolicy.HeidenhainScope,
$"{role} password",
CredentialLiteralPolicy.Refused,
resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
_environment,
ct).ConfigureAwait(false);
}
catch (CredentialReferenceException ex)
{
throw new HeidenhainCredentialException(ex.Message);
}
}
}
@@ -1,5 +1,6 @@
using Opc.Ua;
using Opc.Ua.Security.Certificates;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
@@ -12,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// </summary>
public static class OpcUaUserIdentityFactory
{
private const string SecretPrefix = "secret:";
/// <summary>
/// Build the user-identity token from options whose credential fields are already resolved plaintext
@@ -103,25 +103,34 @@ public static class OpcUaUserIdentityFactory
}
/// <summary>
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
/// <see cref="OpcUaClientDriverOptions.Password"/> / <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/>
/// resolved through <paramref name="resolver"/>. Non-secret / null / empty fields pass through unchanged (the
/// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
/// <c>secret:</c> ref with no resolver available, throws rather than sending the literal as a credential.
/// Return a copy of <paramref name="options"/> with a <c>secret:opcuaclient/…</c> or
/// <c>env:OTOPCUA_OPCUACLIENT_…</c> <see cref="OpcUaClientDriverOptions.Password"/> /
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved (lmxopcua#815 namespace). Literal /
/// null / empty fields pass through unchanged (the reference-equal original is returned when nothing changed).
/// Fail-closed: a reference outside the namespace (or <c>file:</c>) throws before any read, and an absent
/// secret, an unset variable, or a <c>secret:</c> ref with no resolver available throws rather than sending
/// the reference string as a credential.
/// </summary>
/// <param name="options">The raw options (credential fields may carry <c>secret:</c> refs).</param>
/// <param name="resolver">The shared secret resolver, or null when none is available.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
/// <param name="legacy">
/// ⚠️ Deprecated #815 compatibility: the RUNNING driver's session open passes its tracker, so a pre-#815
/// out-of-namespace value keeps the old semantics (a case-insensitive <c>secret:</c> resolves, anything else is
/// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail).
/// </param>
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct,
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
{
ArgumentNullException.ThrowIfNull(options);
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy)
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment, legacy)
.ConfigureAwait(false);
if (ReferenceEquals(password, options.Password)
@@ -134,24 +143,48 @@ public static class OpcUaUserIdentityFactory
}
private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct,
Func<string, string?>? environment, LegacyCredentialReferenceTracker? legacy)
{
if (string.IsNullOrEmpty(value)
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
// A literal (or blank) passes through verbatim, reference-equal — the author already knows it.
var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _);
if (kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) return value;
if (kind == CredentialReferenceKind.OutOfScope && legacy is not null)
{
return value;
legacy.Report(fieldName);
return await LegacyResolveFieldAsync(value!, fieldName, resolver, ct).ConfigureAwait(false);
}
var name = value[SecretPrefix.Length..];
// lmxopcua#815: the value is sent to the operator-authored endpoint, so only secret:opcuaclient/… and
// env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read.
return await CredentialReferences.ResolveAsync(
value,
DriverCredentialPolicy.OpcUaClientScope,
$"OpcUaClientDriverOptions.{fieldName}",
CredentialLiteralPolicy.Allowed,
resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
environment,
ct).ConfigureAwait(false);
}
/// <summary>
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
/// verbatim — a case-insensitive <c>secret:</c> resolves (fail-closed), anything else passes through as a
/// literal. Reached ONLY for an out-of-namespace value in an already-deployed, running driver.
/// </summary>
private static async Task<string?> LegacyResolveFieldAsync(
string value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
{
if (!value.StartsWith(CredentialReferences.SecretPrefix, StringComparison.OrdinalIgnoreCase)) return value;
var name = value[CredentialReferences.SecretPrefix.Length..];
if (resolver is null)
throw new InvalidOperationException(
$"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " +
"available here (fail-closed).");
$"OpcUaClientDriverOptions.{fieldName} references secret '{name}', but no secret store is available here (fail-closed).");
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(resolved)
? resolved
: throw new InvalidOperationException(
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
"absent from the store (fail-closed).");
$"OpcUaClientDriverOptions.{fieldName} resolves secret '{name}', but it is absent from the store (fail-closed).");
}
}
@@ -29,8 +29,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// back on its own recovers in place.
/// </para>
/// </remarks>
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDisposable, IAsyncDisposable
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDriverDiagnostics, IDisposable, IAsyncDisposable
{
// ⚠️ lmxopcua#815 warn-only compatibility (deprecated): the session open of this RUNNING driver keeps
// resolving a pre-#815 out-of-namespace credential with the old semantics, warning rate-limited and surfacing
// it on the diagnostics bag. The AdminUI browser never gets a tracker, so it stays hard-fail.
private readonly LegacyCredentialReferenceTracker _legacyCredentials;
/// <inheritdoc />
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics;
/// <summary>Authored tags whose TagConfig carried no usable <c>nodeId</c>, so there is no upstream node
/// to dial. This driver's skip path was the quietest of the twelve: <c>NamespaceMap</c>
/// drops such an entry with a bare <c>continue</c> and not even a log line.</summary>
@@ -82,6 +90,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_diag = new OpcUaClientDiagnostics(driverInstanceId);
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
_legacyCredentials = new LegacyCredentialReferenceTracker(
DriverCredentialPolicy.OpcUaClientScope, driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message));
_sdkTelemetry = logger is null ? SdkTelemetry.Context : SdkTelemetry.ForLogger(logger);
PostRecoveryReconcileDelay = () => _options.Timeout + _options.KeepAliveInterval;
}
@@ -554,7 +564,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
// single resolve covers both paths.
var resolvedOptions = await OpcUaClientSecretResolution
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken, _legacyCredentials).ConfigureAwait(false);
var identity = BuildUserIdentity(resolvedOptions);
@@ -31,16 +31,18 @@ internal static class OpcUaClientSecretResolution
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
/// <param name="ct">Cancellation token for the async secret resolution.</param>
/// <param name="legacy">⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail.</param>
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
/// <exception cref="InvalidOperationException">
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
/// </exception>
internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct,
Core.Abstractions.LegacyCredentialReferenceTracker? legacy = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(resolver);
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct, legacy: legacy);
}
}
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
@@ -36,6 +37,13 @@ public sealed class BrowserSessionService(
/// <inheritdoc />
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
// lmxopcua#815: a live browse resolves the config's credential references and sends them to the
// author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the
// Discover-backed universal browser) is opened. Nothing is read from the store or environment here.
var credentialProblems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
if (credentialProblems.Count > 0)
return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty);
if (!_browsersByType.TryGetValue(driverType, out var browser))
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
try
@@ -23,6 +23,8 @@
<span class="text-muted small">Changes apply on the next deployment.</span>
</div>
<CredentialReferenceBanner />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Project tree</span>
@@ -21,8 +21,8 @@
<div class="col-md-6">
<label class="form-label">API key secret ref</label>
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="env:MX_API_KEY" />
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
placeholder="env:OTOPCUA_GALAXY_API_KEY" />
<div class="form-text">Forms: <code>secret:galaxy/NAME</code>, <code>env:OTOPCUA_GALAXY_NAME</code> (or legacy <code>env:GALAXY_MXGW_NAME</code>), <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning. References outside the Galaxy namespace and <code>file:</code> are refused (#815).</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
@@ -107,7 +107,7 @@
<label class="form-label">Fallback password secret ref</label>
<InputText @bind-Value="_form.Galaxy.AckFallbackPasswordSecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="secret:otopcua/galaxy/ack-fallback-password" />
<div class="form-text">Forms: <code>secret:NAME</code> (production), <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:VALUE</code>. A bare literal is refused.</div>
<div class="form-text">Forms: <code>secret:galaxy/NAME</code> or <code>secret:otopcua/galaxy/NAME</code> (production), <code>env:OTOPCUA_GALAXY_NAME</code>, <code>dev:VALUE</code>. A bare literal, <code>file:</code>, or a reference outside the Galaxy namespace is refused (#815).</div>
</div>
</div>
</div>
@@ -220,7 +220,7 @@
}
private static GalaxyDriverOptions CreateDefaultOptions() => new(
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"),
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
Repository: new GalaxyRepositoryOptions(),
Reconnect: new GalaxyReconnectOptions());
@@ -241,7 +241,7 @@
{
// GalaxyGatewayOptions
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
public string GatewayApiKeySecretRef { get; set; } = "env:OTOPCUA_GALAXY_API_KEY";
public bool GatewayUseTls { get; set; } = true;
public string? GatewayCaCertificatePath { get; set; }
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
@@ -273,7 +273,7 @@
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
{
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY");
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
var repo = r.Repository ?? new GalaxyRepositoryOptions();
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
@@ -138,6 +138,7 @@
<div class="col-md-4">
<label class="form-label">Password</label>
<InputText @bind-Value="_form.OpcUa.Password" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>. Other references are refused (#815).</div>
</div>
}
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
@@ -150,6 +151,7 @@
<div class="col-md-4">
<label class="form-label">Certificate password (if PFX-locked)</label>
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>.</div>
</div>
}
</div>
@@ -0,0 +1,85 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Configuration
@inject CredentialReferencePreflightState State
@inject IDriverStatusSnapshotStore DriverStatus
@inject IServiceProvider Services
@* lmxopcua#815 upgrade preflight surface: stored / deployed configs whose credential references sit outside their
driver's namespace (the boot scan, re-runnable here) plus running drivers that reported resolving one through the
deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value. *@
@if (_findings.Count > 0 || _error is not null)
{
<div class="alert alert-warning small mb-3" role="alert">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<strong>Credential references outside their driver's namespace (lmxopcua#815).</strong>
The deploy gate refuses these; a driver deployed before the upgrade keeps resolving them in
deprecated warn-only mode until its next deploy. Migrate each to the allowed form — see
docs/security.md "Credential references are namespaced per driver".
</div>
<button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button>
</div>
@if (_error is not null)
{
<div class="text-danger mt-1">@_error</div>
}
<ul class="mb-0 mt-2">
@foreach (var f in _findings.Take(MaxRows))
{
<li>
<span class="badge bg-secondary me-1">@f.Source</span>
@if (!string.IsNullOrEmpty(f.DriverType)) { <span>@f.DriverType </span> }
driver <span class="mono">@f.DriverName</span>
@if (f.DeviceName is not null) { <span> device <span class="mono">@f.DeviceName</span></span> }
— @f.Problem
</li>
}
</ul>
@if (_findings.Count > MaxRows)
{
<div class="mt-1 text-muted">…and @(_findings.Count - MaxRows) more (see the node log).</div>
}
</div>
}
@code {
private const int MaxRows = 25;
private IReadOnlyList<CredentialReferenceFinding> _findings = [];
private string? _error;
private bool _busy;
protected override void OnInitialized() => Compose(State.Current);
private void Compose(CredentialReferencePreflightState.Snapshot snapshot)
{
_error = snapshot.Error;
_findings = snapshot.Findings
.Concat(CredentialReferencePreflight.RunningFindings(DriverStatus.GetAll()))
.ToList();
}
private async Task RecheckAsync()
{
var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>;
if (factory is null) return;
_busy = true;
try
{
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
var snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
State.Set(snapshot);
Compose(snapshot);
}
catch (Exception)
{
_error = "The credential-reference preflight could not read the config DB.";
}
finally
{
_busy = false;
}
}
}
@@ -0,0 +1,209 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
/// <summary>Where a credential-reference finding came from.</summary>
public enum CredentialFindingSource
{
/// <summary>A stored DriverConfig / DeviceConfig row (what the next deploy will gate).</summary>
Draft,
/// <summary>The latest deployment artifact (what the running drivers were given).</summary>
Deployed,
/// <summary>A running driver that reported resolving it through the #815 warn-only compatibility path.</summary>
Running,
}
/// <summary>One out-of-namespace credential reference (never carries the value).</summary>
/// <param name="Source">Where it was found.</param>
/// <param name="DriverType">The driver type.</param>
/// <param name="DriverName">The driver's display name (or instance id for a running driver).</param>
/// <param name="DriverInstanceId">The driver instance id.</param>
/// <param name="DeviceName">The device, when the reference sits in a DeviceConfig.</param>
/// <param name="Problem">The field and the allowed forms.</param>
public sealed record CredentialReferenceFinding(
CredentialFindingSource Source, string DriverType, string DriverName, string DriverInstanceId, string? DeviceName, string Problem);
/// <summary>
/// Upgrade preflight for lmxopcua#815: lists every stored or deployed DriverConfig / DeviceConfig whose credential
/// fields carry a reference outside the driver's namespace — configs written before the namespaces existed that
/// the deploy gate will now refuse (and that a running driver resolves only in warn-only compatibility mode).
/// Reads the ConfigDb only; resolves nothing, so it never touches the secret store or the environment.
/// </summary>
/// <param name="dbFactory">The config DB.</param>
public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
/// <summary>Scans the draft rows and the latest deployment artifact.</summary>
/// <param name="ct">Cancellation.</param>
/// <returns>The findings.</returns>
public async Task<IReadOnlyList<CredentialReferenceFinding>> ScanAsync(CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var drivers = await db.DriverInstances.AsNoTracking().ToListAsync(ct).ConfigureAwait(false);
var ruled = drivers.Where(d => DriverCredentialPolicy.TryGet(d.DriverType, out _)).ToList();
var ids = ruled.Select(d => d.DriverInstanceId).ToHashSet(StringComparer.Ordinal);
var devices = ids.Count == 0
? []
: await db.Devices.AsNoTracking().Where(d => ids.Contains(d.DriverInstanceId)).ToListAsync(ct).ConfigureAwait(false);
var latestBlob = await db.Deployments.AsNoTracking()
.Where(d => d.ArtifactBlob.Length > 0)
.OrderByDescending(d => d.CreatedAtUtc)
.Select(d => d.ArtifactBlob)
.FirstOrDefaultAsync(ct).ConfigureAwait(false);
var deployed = latestBlob is null ? [] : DeploymentArtifact.ParseDriverInstances(latestBlob);
return Scan(ruled, devices, deployed);
}
/// <summary>The pure scan (tests drive it directly).</summary>
/// <param name="drivers">Stored driver rows.</param>
/// <param name="devices">Stored device rows.</param>
/// <param name="deployed">The latest artifact's driver specs.</param>
/// <returns>The findings.</returns>
public static IReadOnlyList<CredentialReferenceFinding> Scan(
IEnumerable<DriverInstance> drivers, IEnumerable<Device> devices, IEnumerable<DriverInstanceSpec> deployed)
{
var findings = new List<CredentialReferenceFinding>();
var driverList = drivers.ToList();
var byId = driverList.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
foreach (var d in driverList)
{
foreach (var p in DriverCredentialPolicy.DescribeProblems(d.DriverType, d.DriverConfig))
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, p));
}
foreach (var dev in devices)
{
if (!byId.TryGetValue(dev.DriverInstanceId, out var owner)) continue;
foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig))
findings.Add(new(CredentialFindingSource.Draft, owner.DriverType, owner.Name, owner.DriverInstanceId, dev.Name, p));
}
foreach (var s in deployed)
{
foreach (var p in DriverCredentialPolicy.DescribeProblems(s.DriverType, s.DriverConfig))
findings.Add(new(CredentialFindingSource.Deployed, s.DriverType, s.Name, s.DriverInstanceId, null, p));
}
return findings;
}
/// <summary>
/// Running drivers that reported a warn-only legacy resolution on their diagnostics bag
/// (<see cref="LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix"/>), fleet-wide via the driver-health feed.
/// </summary>
/// <param name="snapshots">The driver-health snapshots.</param>
/// <returns>The findings.</returns>
public static IReadOnlyList<CredentialReferenceFinding> RunningFindings(IEnumerable<Commons.Messages.Drivers.DriverHealthChanged> snapshots) =>
snapshots
.Where(s => s.Diagnostics is not null)
.SelectMany(s => s.Diagnostics!
.Where(kv => kv.Key.StartsWith(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix, StringComparison.Ordinal))
.Select(kv => new CredentialReferenceFinding(
CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null,
$"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}")))
.ToList();
}
/// <summary>The last preflight result, shown by the AdminUI <c>/raw</c> credential banner.</summary>
public sealed class CredentialReferencePreflightState
{
private volatile Snapshot _current = new([], null, null);
/// <summary>An immutable result.</summary>
/// <param name="Findings">The findings.</param>
/// <param name="ScannedAtUtc">When the scan ran (null = not yet).</param>
/// <param name="Error">A scan failure, if any.</param>
public sealed record Snapshot(IReadOnlyList<CredentialReferenceFinding> Findings, DateTime? ScannedAtUtc, string? Error);
/// <summary>The current result.</summary>
public Snapshot Current => _current;
/// <summary>Replaces the result.</summary>
/// <param name="snapshot">The new result.</param>
public void Set(Snapshot snapshot) => _current = snapshot;
}
/// <summary>
/// Runs <see cref="CredentialReferencePreflight"/> once after host start on an admin node and logs one Warning per
/// finding (driver, device, field, allowed forms — never a value). Scheduled after <c>ApplicationStarted</c> plus a
/// short delay, off every Akka starter; it needs only the ConfigDb, never <c>ISecretResolver</c>.
/// </summary>
/// <param name="services">Resolves the ConfigDb factory lazily (absent ⇒ the preflight is skipped).</param>
/// <param name="lifetime">Host lifetime.</param>
/// <param name="state">Where the result is kept for the AdminUI.</param>
/// <param name="logger">Logger.</param>
public sealed class CredentialReferencePreflightService(
IServiceProvider services,
IHostApplicationLifetime lifetime,
CredentialReferencePreflightState state,
ILogger<CredentialReferencePreflightService> logger) : BackgroundService
{
/// <summary>Delay after <c>ApplicationStarted</c> before the scan (tests set it to zero).</summary>
public TimeSpan StartDelay { get; init; } = TimeSpan.FromSeconds(15);
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using (lifetime.ApplicationStarted.Register(() => started.TrySetResult()))
using (stoppingToken.Register(() => started.TrySetCanceled(stoppingToken)))
{
try
{
await started.Task.ConfigureAwait(false);
if (StartDelay > TimeSpan.Zero) await Task.Delay(StartDelay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
await RunOnceAsync(stoppingToken).ConfigureAwait(false);
}
/// <summary>One scan + log pass (internal so tests drive it directly).</summary>
/// <param name="ct">Cancellation.</param>
/// <returns>The findings.</returns>
internal async Task<IReadOnlyList<CredentialReferenceFinding>> RunOnceAsync(CancellationToken ct)
{
var dbFactory = services.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
if (dbFactory is null)
{
logger.LogDebug("Credential-reference preflight skipped: no ConfigDb on this node.");
return [];
}
try
{
var findings = await new CredentialReferencePreflight(dbFactory).ScanAsync(ct).ConfigureAwait(false);
state.Set(new(findings, DateTime.UtcNow, null));
foreach (var f in findings)
{
logger.LogWarning(
"Credential-reference preflight (lmxopcua#815): {Source} {DriverType} driver '{DriverName}'{Device}: {Problem} "
+ "The deploy gate refuses this; a running driver resolves it only in deprecated warn-only mode. "
+ "See docs/security.md \"Credential references are namespaced per driver\".",
f.Source, f.DriverType, f.DriverName, f.DeviceName is null ? "" : $" device '{f.DeviceName}'", f.Problem);
}
if (findings.Count == 0)
logger.LogInformation("Credential-reference preflight (lmxopcua#815): every stored and deployed reference is namespaced.");
return findings;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Credential-reference preflight (lmxopcua#815) could not read the config DB.");
state.Set(new([], DateTime.UtcNow, "The credential-reference preflight could not read the config DB."));
return [];
}
}
}
@@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
services.AddSingleton<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>();
// lmxopcua#815 upgrade preflight: one ConfigDb scan after start (no secret resolution), WARN per
// out-of-namespace credential reference, shown by the /raw credential banner.
services.AddSingleton<Deployments.CredentialReferencePreflightState>();
services.AddHostedService<Deployments.CredentialReferencePreflightService>();
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddScoped<IUnsTreeService, UnsTreeService>();
services.AddScoped<IRawTreeService, RawTreeService>();
@@ -1,5 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -10,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// block Save while the form is invalid.
/// </summary>
/// <remarks>
/// Today it carries one rule: a Heidenhain password field must be blank or a reference in the Heidenhain
/// namespace (<see cref="HeidenhainOptionsParser.DescribeCredentialProblems"/>) — otherwise a literal password
/// would be persisted in a draft (and replicated) even though the deploy gate would later refuse it.
/// Today it carries one rule, for every driver that resolves credential references
/// (<see cref="DriverCredentialPolicy"/> — Heidenhain, Galaxy, OpcUaClient): a credential field must be blank,
/// an acceptable literal for that field, or a reference inside the driver's namespace (lmxopcua#803/#815) —
/// otherwise an exfiltration-shaped reference (or a Heidenhain literal password) would be persisted in a draft
/// (and replicated) even though the deploy gate and every resolver would later refuse it.
/// </remarks>
public static class DriverConfigSaveGate
{
@@ -22,8 +23,7 @@ public static class DriverConfigSaveGate
/// <returns>The refusal (never containing a credential value), or null.</returns>
public static string? Validate(string? driverType, string? configJson)
{
if (!string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) return null;
var problems = HeidenhainOptionsParser.DescribeCredentialProblems(configJson);
var problems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
return problems.Count == 0 ? null : string.Join(" ", problems);
}
}
@@ -585,6 +585,20 @@ public sealed class AdminOperationsActor : ReceiveActor
return;
}
// lmxopcua#815: refuse a credential reference outside the driver's namespace BEFORE any probe runs — the
// probe would otherwise be the seam that resolves it and sends it to the author-chosen endpoint. Nothing
// is read from the secret store or the environment on this path.
var credentialProblems = DriverCredentialPolicy.DescribeProblems(msg.DriverType, msg.ConfigJson);
if (credentialProblems.Count > 0)
{
replyTo.Tell(new TestDriverConnectResult(
false,
"Test Connect refused: " + string.Join(" ", credentialProblems),
null,
msg.CorrelationId));
return;
}
Task.Run(() => ProbeAsync(probe, msg)).PipeTo(replyTo);
}