fix(security): scope operator-authored credential references per driver (#815)
A Designer could point a Galaxy / OpcUaClient endpoint at a host they control, set a credential field to any reference the node can resolve (env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, secret:<any>, Galaxy file:<any path>) and click Test Connect / live browse; the node resolved the value and sent it to that host. One shared rule in Core.Abstractions (CredentialReferences + DriverCredentialPolicy) generalises the #803 Heidenhain namespace: - Galaxy: secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... | legacy env:GALAXY_MXGW_...; file: refused; dev:/literal API key unchanged. - OpcUaClient: secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...; literals unchanged. - Heidenhain adopts the shared parser/resolver (namespace unchanged). Enforced at the AdminUI save path (DriverConfigSaveGate), the deploy gate (CredentialReferenceDeployChecks, code CredentialReferenceOutOfScope), Test Connect (AdminOperationsActor, before any probe), live browse (BrowserSessionService, before any browser) and again at resolve time in every driver/browser (fail closed, before any store/env read). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+46
@@ -0,0 +1,46 @@
|
||||
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>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}", 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}", 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__<ref></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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,50 @@ 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>
|
||||
/// <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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
var scope = DriverCredentialPolicy.GalaxyScope;
|
||||
if (CredentialReferences.Parse(secretRef, scope, out _) == CredentialReferenceKind.Literal)
|
||||
{
|
||||
var name = secretRef[4..];
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
return !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
|
||||
}
|
||||
|
||||
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = secretRef[5..];
|
||||
if (!File.Exists(path))
|
||||
if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
|
||||
// Explicit dev opt-in — no warning, the operator deliberately chose a cleartext literal.
|
||||
return secretRef[CredentialReferences.DevPrefix.Length..];
|
||||
}
|
||||
var contents = File.ReadAllText(path).Trim();
|
||||
return !string.IsNullOrEmpty(contents)
|
||||
? contents
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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("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).");
|
||||
}
|
||||
|
||||
// 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.");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-47
@@ -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,19 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
|
||||
private readonly GalaxyAlarmAckOptions _options;
|
||||
private readonly ISecretResolver _secrets;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Func<string, string?>? _environment;
|
||||
|
||||
/// <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>
|
||||
public GalaxyAckCredentialResolver(
|
||||
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger)
|
||||
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger,
|
||||
Func<string, string?>? environment = null)
|
||||
{
|
||||
_environment = environment;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_options = options ?? new GalaxyAlarmAckOptions();
|
||||
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
|
||||
@@ -142,56 +147,28 @@ 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 (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..]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.");
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
+23
-86
@@ -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_<NAME></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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-26
@@ -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,29 @@ 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>
|
||||
/// <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)
|
||||
{
|
||||
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)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
@@ -134,24 +138,22 @@ 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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
// 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;
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
if (resolver is null)
|
||||
throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' 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).");
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-6
@@ -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();
|
||||
|
||||
+2
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user