Merge #848 #849 #850 credential name claim/pin, env: per-instance rule, merged preflight

This commit is contained in:
Joseph Doherty
2026-11-11 06:36:06 -05:00
17 changed files with 958 additions and 85 deletions
@@ -74,35 +74,44 @@ public static class CredentialReferenceDeployChecks
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}{MigrationHint}", driver.DriverInstanceId));
var reported = new HashSet<string>(StringComparer.Ordinal);
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
reported.Add(problem);
foreach (var device in devices)
{
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig))
{
reported.Add(problem);
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}{MigrationHint}", device.DeviceId));
}
}
// #836 review L-1: a path field is exempt only in an object that explicitly switches it off (OpcUaClient
// AuthType ≠ Certificate), and the switch and the path may sit in different configs (driver vs device). Check
// the config exactly as the runtime merges it, so a split can never deploy a path the driver will load.
if (rules.PathFieldList.Count > 0)
{
var merged = DriverDeviceConfigMerger.Merge(
driver.DriverConfig,
devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig)).ToList(),
[]);
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, merged))
{
if (reported.Add(problem))
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}' (merged with its devices): {problem}{MigrationHint}", driver.DriverInstanceId));
}
}
foreach (var problem in MergedOnlyProblems(
driver.DriverType, driver.DriverConfig, devices.Select(d => (d.Name, (string?)d.DeviceConfig)).ToList()))
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}' (merged with its devices): {problem}{MigrationHint}", driver.DriverInstanceId));
}
return errors;
}
/// <summary>
/// #836 review L-1 / lmxopcua#850: a path field is exempt only in an object that explicitly switches it off
/// (OpcUaClient <c>AuthType</c> ≠ Certificate), and the switch and the path may sit in different configs (driver vs
/// device). This checks the config exactly as the runtime merges it (<see cref="DriverDeviceConfigMerger"/>) and
/// returns the problems the merge reveals that the driver and device configs, checked one by one, do not — so a
/// split can never hide a path the driver will load. The ONE implementation the deploy gate and the upgrade
/// preflight / <c>/raw</c> banner share. Empty for a type without path fields.
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="driverConfig">The stored DriverConfig.</param>
/// <param name="devices">Its devices (name, DeviceConfig).</param>
/// <returns>The merged-only problems.</returns>
public static IReadOnlyList<string> MergedOnlyProblems(
string? driverType, string? driverConfig, IReadOnlyList<(string Name, string? DeviceConfig)> devices)
{
ArgumentNullException.ThrowIfNull(devices);
if (!DriverCredentialPolicy.TryGet(driverType, out var rules) || rules.PathFieldList.Count == 0) return [];
var reported = new HashSet<string>(DriverCredentialPolicy.DescribeProblems(driverType, driverConfig), StringComparer.Ordinal);
foreach (var (_, deviceConfig) in devices)
reported.UnionWith(DriverCredentialPolicy.DescribeProblems(driverType, deviceConfig));
var merged = DriverDeviceConfigMerger.Merge(
driverConfig ?? "{}",
devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig ?? "{}")).ToList(),
[]);
return DriverCredentialPolicy.DescribeProblems(driverType, merged).Where(reported.Add).ToList();
}
}
@@ -86,9 +86,118 @@ public static class DriverInstanceIdentities
];
}
/// <summary>
/// lmxopcua#848: the problems of giving a driver the name <paramref name="newName"/> (create, or a rename via the
/// /raw rename or the driver config modal). Two rules, both about a name being the instance part of a stored
/// credential reference (<see cref="DriverCredentialPolicy.ReferenceNamesName"/>):
/// <list type="bullet">
/// <item>
/// <b>Claim</b> — the new name may not be named by a reference STORED on another same-type driver (its
/// DriverConfig or any DeviceConfig). Otherwise that reference would start naming "another instance" and the
/// other driver's next deploy fails (naming a driver <c>gw</c> when others share <c>secret:galaxy/gw/…</c>).
/// </item>
/// <item>
/// <b>Pin</b> — a driver may not be renamed AWAY from a name its own references are named after (unless they
/// also name its immutable id or the new name): renaming would free the old name, and a reference to it would
/// then look like an unclaimed shared name to every other driver's Test Connect, browse and save.
/// </item>
/// </list>
/// A case-only rename changes nothing the rule matches, so it is never refused. Messages name drivers, devices and
/// field paths — never a credential value.
/// </summary>
/// <param name="db">The config DB.</param>
/// <param name="driverType">The driver's type.</param>
/// <param name="ownDriverInstanceId">The driver being renamed; null when it is being created.</param>
/// <param name="currentName">Its stored name; null when it is being created.</param>
/// <param name="newName">The name about to be stored.</param>
/// <param name="ownDriverConfig">
/// The DriverConfig being saved with the rename (the config modal saves both at once); null ⇒ the stored one.
/// </param>
/// <param name="ct">Cancellation.</param>
/// <returns>The problems; empty when the name may be stored.</returns>
public static async Task<IReadOnlyList<string>> DescribeNameChangeProblemsAsync(
OtOpcUaConfigDbContext db, string? driverType, string? ownDriverInstanceId, string? currentName, string newName,
string? ownDriverConfig, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(db);
if (!DriverCredentialPolicy.TryGet(driverType, out var rules)) return [];
if (currentName is not null && string.Equals(currentName, newName, StringComparison.OrdinalIgnoreCase)) return [];
// #849 review: a name in the system-generated id form (env form 'DRV' or 'DRV_<12 HEX>…') would shadow the id
// convention every driver's own references are recommended to use. The matcher already ignores such a name for
// id-shaped references (so a pre-existing one breaks nothing); this keeps new ones from being created at all.
if (DriverCredentialPolicy.IsReservedInstanceName(newName))
{
return
[
$"The name '{newName}' has the form of a system-generated driver id ('{DriverCredentialPolicy.DriverIdPrefix}' or "
+ $"'{DriverCredentialPolicy.DriverIdPrefix}-<12 hex>…'), which {rules.Scope.Label} credential references use to name a "
+ "driver by its id — choose another name (lmxopcua#848/#849).",
];
}
// Same shape as LoadAsync: the type filter stays client-side (case-insensitive), the device read is a plain IN.
var drivers = (await db.DriverInstances.AsNoTracking()
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType, d.DriverConfig })
.ToListAsync(ct).ConfigureAwait(false))
.Where(d => string.Equals(d.DriverType, rules.DriverType, StringComparison.OrdinalIgnoreCase))
.ToList();
var ids = drivers.Select(d => d.DriverInstanceId).ToList();
var devices = ids.Count == 0
? []
: await db.Devices.AsNoTracking()
.Where(d => ids.Contains(d.DriverInstanceId))
.Select(d => new { d.DriverInstanceId, d.Name, d.DeviceConfig })
.ToListAsync(ct).ConfigureAwait(false);
var problems = new List<string>();
foreach (var driver in drivers)
{
var own = string.Equals(driver.DriverInstanceId, ownDriverInstanceId, StringComparison.Ordinal);
var configs = new List<(string? Device, string? Json)>
{
(null, own && ownDriverConfig is not null ? ownDriverConfig : driver.DriverConfig),
};
configs.AddRange(devices
.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal))
.Select(d => ((string?)d.Name, (string?)d.DeviceConfig)));
foreach (var (device, json) in configs)
{
var where = device is null ? "" : $" device '{device}'";
foreach (var reference in DriverCredentialPolicy.CollectInstanceReferences(driverType, json))
{
if (!own && DriverCredentialPolicy.ReferenceNamesName(reference, newName))
{
problems.Add(
$"The name '{newName}' is the instance part of the credential reference {reference.FieldPath} on "
+ $"{rules.Scope.Label} driver '{driver.Name}' ({driver.DriverInstanceId}){where} — naming this driver "
+ "so would make that reference name another driver instance and fail that driver's next deploy. "
+ "Choose another name, or move that reference first (lmxopcua#848).");
}
else if (own && currentName is not null
&& DriverCredentialPolicy.ReferenceNamesName(reference, currentName)
&& !DriverCredentialPolicy.ReferenceNamesId(reference, ownDriverInstanceId)
&& !DriverCredentialPolicy.ReferenceNamesName(reference, newName))
{
problems.Add(
$"This driver's credential reference {reference.FieldPath}{where} is named after its current name "
+ $"'{currentName}' — renaming would free that name, and any other {rules.Scope.Label} driver could then "
+ "reference the same credential. Move the reference to this driver's id first "
+ $"(secret:{rules.Scope.SecretNamespaces[0]}{ownDriverInstanceId}/… or "
+ $"env:{rules.Scope.EnvVariablePrefixes[0]}{DriverCredentialPolicy.NormalizeEnvironmentIdentifier(ownDriverInstanceId)}_…), "
+ "then rename (lmxopcua#848).");
}
}
}
}
return problems;
}
/// <summary>
/// The cross-instance problems of <paramref name="configJson"/> — reading the ConfigDb only when the config carries
/// an in-scope <c>secret:</c> reference at all.
/// an in-scope <c>secret:</c> or <c>env:</c> reference at all.
/// </summary>
/// <param name="db">The config DB.</param>
/// <param name="driverType">The owning driver's type.</param>
@@ -211,8 +211,8 @@ public static class DriverCredentialPolicy
/// <summary>
/// The instance-identifying segment of an in-scope <c>secret:</c> reference: the first path segment after the
/// matched namespace (<c>secret:galaxy/&lt;X&gt;/apikey</c> ⇒ <c>X</c>; <c>secret:otopcua/galaxy/&lt;X&gt;</c> ⇒
/// <c>X</c>). Null for anything else — <c>env:</c> references carry no instance convention, and literals and
/// out-of-scope references are the #815 rule's business.
/// <c>X</c>). Null for anything else — <c>env:</c> references use <see cref="EnvironmentInstancePart"/>, and literals
/// and out-of-scope references are the #815 rule's business.
/// </summary>
/// <param name="value">The authored field value.</param>
/// <param name="scope">The driver's scope.</param>
@@ -232,24 +232,147 @@ public static class DriverCredentialPolicy
}
/// <summary>
/// True when the config carries at least one in-scope <c>secret:</c> reference — i.e. the cross-instance rule
/// (<see cref="DescribeOtherInstanceProblems"/>) has something to check, so a seam must load the instance list.
/// Lets seams skip the ConfigDb read otherwise.
/// lmxopcua#849: the part of an in-scope <c>env:</c> reference after the matched (longest) prefix —
/// <c>env:OTOPCUA_GALAXY_&lt;REST&gt;</c> ⇒ <c>REST</c>. The per-instance convention is
/// <c>env:&lt;PREFIX&gt;&lt;INSTANCE&gt;_…</c>, INSTANCE being a driver id or name in
/// <see cref="NormalizeEnvironmentIdentifier"/> form; the part NAMES an instance when it equals that form or starts
/// with it followed by <c>_</c> (see <see cref="ReferenceNamesId"/> / <see cref="ReferenceNamesName"/>). Null for anything but an in-scope <c>env:</c>.
/// </summary>
/// <param name="value">The authored field value.</param>
/// <param name="scope">The driver's scope.</param>
/// <returns>The remainder, or null.</returns>
public static string? EnvironmentInstancePart(string? value, CredentialScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
if (CredentialReferences.Parse(value, scope, out var name) != CredentialReferenceKind.Environment) return null;
var prefix = scope.EnvVariablePrefixes
.Where(p => name.StartsWith(p, StringComparison.Ordinal))
.OrderByDescending(p => p.Length)
.FirstOrDefault();
return prefix is null ? null : name[prefix.Length..];
}
/// <summary>
/// lmxopcua#849: a driver id or name in environment-variable form — ASCII letters upper-cased, digits kept, every
/// other character <c>_</c> (<c>DRV-1a2b</c> ⇒ <c>DRV_1A2B</c>, <c>Line 1</c> ⇒ <c>LINE_1</c>). Empty when the
/// identifier has no letter or digit (such an identifier names no variable).
/// </summary>
/// <param name="identifier">The id or name.</param>
/// <returns>The normalised form.</returns>
public static string NormalizeEnvironmentIdentifier(string? identifier)
{
if (string.IsNullOrEmpty(identifier) || !identifier.Any(char.IsAsciiLetterOrDigit)) return "";
return string.Create(identifier.Length, identifier, static (span, id) =>
{
for (var i = 0; i < id.Length; i++)
span[i] = char.IsAsciiLetterOrDigit(id[i]) ? char.ToUpperInvariant(id[i]) : '_';
});
}
/// <summary>The prefix of every system-generated driver instance id (<c>DRV-</c> + 12 hex, <c>RawTreeService.NewId</c>).</summary>
public const string DriverIdPrefix = "DRV";
private const int DriverIdHexLength = 12;
/// <summary>
/// Whether an instance reference (<see cref="CollectInstanceReferences"/>) names the driver id
/// <paramref name="driverInstanceId"/>: a <c>secret:</c> segment equal to it (case-insensitive), or an <c>env:</c>
/// part equal to its <see cref="NormalizeEnvironmentIdentifier"/> form or starting with that form plus <c>_</c>.
/// </summary>
/// <param name="reference">The reference.</param>
/// <param name="driverInstanceId">A driver instance id.</param>
/// <returns>Whether it names it.</returns>
public static bool ReferenceNamesId(InstanceReference reference, string? driverInstanceId) =>
Matches(reference, driverInstanceId);
/// <summary>
/// Whether an instance reference names the driver NAME <paramref name="name"/> — as <see cref="ReferenceNamesId"/>,
/// except that a reference whose instance part has the shape of a system-generated id
/// (<see cref="IsIdShaped"/>: <c>secret:…/DRV-&lt;12 hex&gt;/…</c>, <c>env:&lt;PREFIX&gt;DRV_&lt;12 HEX&gt;[_…]</c>) is
/// matched by id only. Otherwise a driver NAMED <c>drv</c> (env form <c>DRV</c>) would "own" the id-form variable of
/// every other driver of its type, and the recommended id convention would break fleet-wide — including for a
/// name that pre-dates the rule (lmxopcua#849 review).
/// </summary>
/// <param name="reference">The reference.</param>
/// <param name="name">A driver name.</param>
/// <returns>Whether it names it.</returns>
public static bool ReferenceNamesName(InstanceReference reference, string? name) =>
!IsIdShaped(reference) && Matches(reference, name);
/// <summary>
/// Whether the reference's instance part has the shape of a system-generated driver id: a <c>secret:</c> segment
/// <c>DRV-&lt;12 hex&gt;</c> (case-insensitive), or an <c>env:</c> part <c>DRV_&lt;12 HEX&gt;</c> alone or followed by <c>_</c>.
/// </summary>
/// <param name="reference">The reference.</param>
/// <returns>Whether it is id-shaped.</returns>
public static bool IsIdShaped(InstanceReference reference) =>
reference.Kind == CredentialReferenceKind.Secret
? reference.Part.Length == DriverIdPrefix.Length + 1 + DriverIdHexLength && StartsWithIdForm(reference.Part, '-', ignoreCase: true)
: StartsWithIdForm(reference.Part, '_', ignoreCase: false)
&& (reference.Part.Length == DriverIdPrefix.Length + 1 + DriverIdHexLength
|| reference.Part[DriverIdPrefix.Length + 1 + DriverIdHexLength] == '_');
/// <summary>
/// lmxopcua#849 review: a driver NAME that may not be given to a credential-bearing driver — one whose environment
/// form (<see cref="NormalizeEnvironmentIdentifier"/>) is <c>DRV</c> or starts with an id form <c>DRV_&lt;12 HEX&gt;</c>.
/// Such a name would collide with the system-generated id convention every driver's own references are
/// recommended to use.
/// </summary>
/// <param name="name">The proposed name.</param>
/// <returns>Whether the name is reserved.</returns>
public static bool IsReservedInstanceName(string? name)
{
var env = NormalizeEnvironmentIdentifier(name);
return env == DriverIdPrefix
|| IsIdShaped(new InstanceReference("", CredentialReferenceKind.Environment, env));
}
private static bool StartsWithIdForm(string part, char separator, bool ignoreCase)
{
var length = DriverIdPrefix.Length + 1 + DriverIdHexLength;
if (part.Length < length
|| !part.StartsWith(DriverIdPrefix, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)
|| part[DriverIdPrefix.Length] != separator)
return false;
for (var i = DriverIdPrefix.Length + 1; i < length; i++)
{
var c = part[i];
if (!(char.IsAsciiDigit(c) || c is >= 'A' and <= 'F' || (ignoreCase && c is >= 'a' and <= 'f'))) return false;
}
return true;
}
private static bool Matches(InstanceReference reference, string? identifier)
{
if (string.IsNullOrEmpty(identifier)) return false;
if (reference.Kind == CredentialReferenceKind.Secret)
return string.Equals(reference.Part, identifier, StringComparison.OrdinalIgnoreCase);
var normalized = NormalizeEnvironmentIdentifier(identifier);
return normalized.Length > 0
&& (string.Equals(reference.Part, normalized, StringComparison.Ordinal)
|| reference.Part.StartsWith(normalized + "_", StringComparison.Ordinal));
}
/// <summary>
/// True when the config carries at least one in-scope <c>secret:</c> or <c>env:</c> reference — i.e. the
/// cross-instance rule (<see cref="DescribeOtherInstanceProblems"/>) has something to check, so a seam must load
/// the instance list. Lets seams skip the ConfigDb read otherwise.
/// </summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>Whether any instance segment is present.</returns>
/// <returns>Whether any instance reference is present.</returns>
public static bool HasInstanceSegments(string? driverType, string? json) =>
CollectSegments(driverType, json).Count > 0;
CollectInstanceReferences(driverType, json).Count > 0;
/// <summary>
/// lmxopcua#835: inside one namespace, a reference whose instance-identifying segment (see
/// <see cref="InstanceSegment"/>) names a DIFFERENT existing instance of the same driver type — by its
/// <see cref="DriverInstanceIdentity.DriverInstanceId"/> or its <see cref="DriverInstanceIdentity.Name"/>,
/// case-insensitively — is refused: otherwise a Designer could create (or Test Connect / browse) a driver that
/// points at their own endpoint with <c>secret:galaxy/&lt;other-instance&gt;/apikey</c> and capture that
/// instance's key. Shared names that are no other instance's id or name (<c>secret:galaxy/gw/apikey</c>,
/// <c>secret:galaxy/apikey</c>) stay valid, as does a reference naming the instance itself.
/// lmxopcua#835/#849: inside one namespace, a reference that names (see <see cref="ReferenceNamesId"/> / <see cref="ReferenceNamesName"/>) a DIFFERENT
/// existing instance of the same driver type — by its <see cref="DriverInstanceIdentity.DriverInstanceId"/> or its
/// <see cref="DriverInstanceIdentity.Name"/> — is refused: otherwise a Designer could create (or Test Connect /
/// browse) a driver that points at their own endpoint with <c>secret:galaxy/&lt;other-instance&gt;/apikey</c> (or
/// <c>env:OTOPCUA_GALAXY_&lt;OTHER_INSTANCE&gt;_KEY</c>) and capture that instance's key. Shared names that name no
/// other instance (<c>secret:galaxy/gw/apikey</c>, <c>env:GALAXY_MXGW_API_KEY</c>) stay valid, as does a reference
/// naming the instance itself.
/// </summary>
/// <remarks>
/// A match against ANOTHER instance wins over a match against the owner — names are not unique, and renaming
@@ -267,24 +390,26 @@ public static class DriverCredentialPolicy
ArgumentNullException.ThrowIfNull(instances);
var problems = new List<string>();
if (!TryGet(driverType, out var rules)) return problems;
var segments = CollectSegments(driverType, json);
if (segments.Count == 0) return problems;
var references = CollectInstanceReferences(driverType, json);
if (references.Count == 0) return problems;
var others = instances
.Where(i => string.Equals(i.DriverType, rules.DriverType, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(i.DriverInstanceId, ownDriverInstanceId, StringComparison.Ordinal))
.ToList();
foreach (var (fieldPath, segment) in segments)
foreach (var reference in references)
{
var other = others.FirstOrDefault(o =>
string.Equals(o.DriverInstanceId, segment, StringComparison.OrdinalIgnoreCase)
|| string.Equals(o.Name, segment, StringComparison.OrdinalIgnoreCase));
var other = others.FirstOrDefault(o => ReferenceNamesId(reference, o.DriverInstanceId) || ReferenceNamesName(reference, o.Name));
if (other is null) continue;
problems.Add(
$"{fieldPath} names a secret under '{segment}/', which is another {rules.Scope.Label} driver instance "
+ $"('{other.Name}', {other.DriverInstanceId}) — a driver may reference only its own secrets "
+ $"(secret:{rules.Scope.SecretNamespaces[0]}<this-driver-id>/…) or a shared name that is no other "
+ "instance's id or name (lmxopcua#835).");
problems.Add(reference.Kind == CredentialReferenceKind.Secret
? $"{reference.FieldPath} names a secret under '{reference.Part}/', which is another {rules.Scope.Label} driver "
+ $"instance ('{other.Name}', {other.DriverInstanceId}) — a driver may reference only its own secrets "
+ $"(secret:{rules.Scope.SecretNamespaces[0]}<this-driver-id>/…) or a shared name that is no other "
+ "instance's id or name (lmxopcua#835)."
: $"{reference.FieldPath} names an environment variable of another {rules.Scope.Label} driver instance "
+ $"('{other.Name}', {other.DriverInstanceId}) — a driver may reference only its own variables "
+ $"(env:{rules.Scope.EnvVariablePrefixes[0]}<THIS_DRIVER_ID>_…, the id upper-cased with every other "
+ "character as '_') or a shared name that starts with no other instance's id or name (lmxopcua#849).");
}
return problems;
@@ -397,29 +522,36 @@ public static class DriverCredentialPolicy
using (doc) use(doc.RootElement);
}
private static List<(string FieldPath, string Segment)> CollectSegments(string? driverType, string? json)
/// <summary>
/// Every in-scope instance reference of a config (lmxopcua#835 <c>secret:</c> segments + #849 <c>env:</c> parts), at
/// any depth. Empty for an unreadable config or a type without rules.
/// </summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>The references, in document order.</returns>
public static IReadOnlyList<InstanceReference> CollectInstanceReferences(string? driverType, string? json)
{
var found = new List<(string, string)>();
var found = new List<InstanceReference>();
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return found;
JsonDocument doc;
try
// Unreadable JSON is reported by DescribeProblems.
WithDocument(json, root => Visit(root, "", rules, 0, (fieldPath, value, field, _) =>
{
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
}
catch (JsonException)
{
return found; // DescribeProblems reports unreadable JSON.
}
using (doc)
{
Visit(doc.RootElement, "", rules, 0, (fieldPath, value, field, _) =>
{
if (field is null || value.ValueKind != JsonValueKind.String) return;
if (InstanceSegment(value.GetString(), rules.Scope) is { Length: > 0 } segment) found.Add((fieldPath, segment));
});
}
if (field is null || value.ValueKind != JsonValueKind.String) return;
var text = value.GetString();
if (InstanceSegment(text, rules.Scope) is { Length: > 0 } segment)
found.Add(new(fieldPath, CredentialReferenceKind.Secret, segment));
else if (EnvironmentInstancePart(text, rules.Scope) is { Length: > 0 } part)
found.Add(new(fieldPath, CredentialReferenceKind.Environment, part));
}));
return found;
}
}
/// <summary>The instance-identifying part of one in-scope credential reference (lmxopcua#835 / #849).</summary>
/// <param name="FieldPath">Where it sits in the config (never the value).</param>
/// <param name="Kind"><see cref="CredentialReferenceKind.Secret"/> or <see cref="CredentialReferenceKind.Environment"/>.</param>
/// <param name="Part">
/// The <c>secret:</c> instance segment (<see cref="DriverCredentialPolicy.InstanceSegment"/>) or the <c>env:</c> remainder
/// after the prefix (<see cref="DriverCredentialPolicy.EnvironmentInstancePart"/>).
/// </param>
public readonly record struct InstanceReference(string FieldPath, CredentialReferenceKind Kind, string Part);
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <param name="authenticationStateProvider">Supplies the calling circuit's user.</param>
/// <param name="dbFactory">
/// The config DB, for the cross-instance credential rule (lmxopcua#835). Null (test graphs) ⇒ a config carrying an
/// in-scope <c>secret:</c> reference cannot be checked and is refused (fail-closed).
/// in-scope <c>secret:</c>/<c>env:</c> reference cannot be checked and is refused (fail-closed).
/// </param>
/// <param name="audit">The credential-use audit sink (#835 review I-1); null ⇒ the structured log line only.</param>
public sealed class BrowserSessionService(
@@ -73,7 +73,7 @@ public sealed class BrowserSessionService(
if (DriverCredentialPolicy.HasInstanceSegments(driverType, configJson))
{
if (dbFactory is null)
return new(false, "Browse refused: the driver-instance list needed to check this config's secret: "
return new(false, "Browse refused: the driver-instance list needed to check this config's secret:/env: "
+ "references is unavailable (lmxopcua#835, fail-closed).", Guid.Empty);
try
{
@@ -88,7 +88,7 @@ public sealed class BrowserSessionService(
// Review L-2: a ConfigDb fault must not become a circuit error — refuse (fail-closed) with a message.
logger.LogWarning(ex, "Browse credential check failed for driverType={DriverType}", driverType);
return new(false, "Browse refused: the driver-instance list could not be read to check this config's "
+ "secret: references (lmxopcua#835, fail-closed). Try again.", Guid.Empty);
+ "secret:/env: references (lmxopcua#835/#849, fail-closed). Try again.", Guid.Empty);
}
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -85,13 +86,28 @@ public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfig
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, p));
}
foreach (var dev in devices)
var deviceList = devices.ToList();
foreach (var dev in deviceList)
{
if (!byId.TryGetValue(dev.DriverInstanceId, out var owner)) continue;
foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig, owner.DriverInstanceId, draftInstances))
findings.Add(new(CredentialFindingSource.Draft, owner.DriverType, owner.Name, owner.DriverInstanceId, dev.Name, p));
}
// lmxopcua#850: what the deploy gate checks on the runtime-merged driver+device config (an AuthType switch in one
// config and a UserCertificatePath in the other) — the same helper, so the banner shows it before a deploy does.
// Device order follows DraftSnapshotFactory (the deploy gate's input): by DeviceId.
foreach (var d in driverList)
{
var own = deviceList
.Where(dev => string.Equals(dev.DriverInstanceId, d.DriverInstanceId, StringComparison.Ordinal))
.OrderBy(dev => dev.DeviceId, StringComparer.Ordinal)
.Select(dev => (dev.Name, (string?)dev.DeviceConfig))
.ToList();
foreach (var p in CredentialReferenceDeployChecks.MergedOnlyProblems(d.DriverType, d.DriverConfig, own))
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, $"(merged with its devices) {p}"));
}
var deployedList = deployed.ToList();
var deployedInstances = deployedList.Select(s => new DriverInstanceIdentity(s.DriverInstanceId, s.Name, s.DriverType)).ToList();
foreach (var s in deployedList)
@@ -34,7 +34,7 @@ public static class DriverConfigSaveGate
/// plus the cross-instance rule (lmxopcua#835 — a <c>secret:galaxy/&lt;X&gt;/…</c> reference may not name ANOTHER
/// existing instance of the same driver type), which needs the stored instance list.
/// </summary>
/// <param name="db">The config DB (read only when the config carries an in-scope <c>secret:</c> reference).</param>
/// <param name="db">The config DB (read only when the config carries an in-scope <c>secret:</c>/<c>env:</c> reference).</param>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="configJson">The DriverConfig or DeviceConfig JSON about to be stored.</param>
/// <param name="ownDriverInstanceId">The owning driver; null when it is being created.</param>
@@ -48,4 +48,28 @@ public static class DriverConfigSaveGate
.DescribeOtherInstanceProblemsAsync(db, driverType, configJson, ownDriverInstanceId, ct).ConfigureAwait(false);
return problems.Count == 0 ? null : string.Join(" ", problems);
}
/// <summary>
/// The authority check <see cref="RawTreeService"/> runs on every driver create and rename (lmxopcua#848,
/// <see cref="DriverInstanceIdentities.DescribeNameChangeProblemsAsync"/>): the new name may not be the instance part
/// of another same-type driver's stored credential reference, and a rename may not free a name the driver's own
/// references are named after.
/// </summary>
/// <param name="db">The config DB.</param>
/// <param name="driverType">The driver's type.</param>
/// <param name="ownDriverInstanceId">The driver being renamed; null on create.</param>
/// <param name="currentName">Its stored name; null on create.</param>
/// <param name="newName">The name about to be stored.</param>
/// <param name="ownDriverConfig">The DriverConfig saved with it; null ⇒ the stored one.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The refusal (never containing a credential value), or null.</returns>
public static async Task<string?> ValidateNameAsync(
OtOpcUaConfigDbContext db, string? driverType, string? ownDriverInstanceId, string? currentName, string newName,
string? ownDriverConfig, CancellationToken ct)
{
var problems = await DriverInstanceIdentities
.DescribeNameChangeProblemsAsync(db, driverType, ownDriverInstanceId, currentName, newName, ownDriverConfig, ct)
.ConfigureAwait(false);
return problems.Count == 0 ? null : string.Join(" ", problems);
}
}
@@ -492,6 +492,10 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (await DriverConfigSaveGate.ValidateAsync(db, driverType, driverConfigJson, null, ct) is { } instanceRefusal)
return new UnsMutationResult(false, instanceRefusal);
// lmxopcua#848: nor may its NAME be the instance part of another driver's stored reference.
if (await DriverConfigSaveGate.ValidateNameAsync(db, driverType, null, null, name, driverConfigJson, ct) is { } nameRefusal)
return new UnsMutationResult(false, nameRefusal);
if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct))
return new UnsMutationResult(false, $"Cluster '{clusterId}' not found.");
@@ -549,6 +553,12 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
&& d.Name == newName && d.DriverInstanceId != driverInstanceId, ct))
return RawRenameResult.Failure($"A driver named '{newName}' already exists here.");
// lmxopcua#848: the new name may not be another driver's credential segment, nor may this rename free a name
// the driver's own credential references are named after.
if (await DriverConfigSaveGate.ValidateNameAsync(
db, entity.DriverType, entity.DriverInstanceId, entity.Name, newName, null, ct) is { } nameRefusal)
return RawRenameResult.Failure(nameRefusal);
var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDriverAsync(db, driverInstanceId, ct), ct);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
@@ -1129,6 +1139,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
&& d.Name == name && d.DriverInstanceId != driverInstanceId, ct))
return new UnsMutationResult(false, $"A driver named '{name}' already exists here.");
// lmxopcua#848 — as RenameDriverAsync, judged against the config being saved with the rename.
if (await DriverConfigSaveGate.ValidateNameAsync(
db, entity.DriverType, entity.DriverInstanceId, entity.Name, name, driverConfigJson, ct) is { } nameRefusal)
return new UnsMutationResult(false, nameRefusal);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.Name = name;
entity.DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson;
@@ -611,9 +611,9 @@ public sealed class AdminOperationsActor : ReceiveActor
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
// lmxopcua#835: a reference naming ANOTHER driver instance's secret (secret:galaxy/<other>/…) is refused
// lmxopcua#835/#849: a reference naming ANOTHER driver instance's secret (secret:galaxy/<other>/…, env:OTOPCUA_GALAXY_<OTHER>_…) is refused
// before the probe resolves it — off the mailbox, since it reads the ConfigDb (only when the config
// carries an in-scope secret: reference at all).
// carries an in-scope secret:/env: reference at all).
if (DriverCredentialPolicy.HasInstanceSegments(msg.DriverType, msg.ConfigJson))
{
await using var db = await _dbFactory.CreateDbContextAsync(cts.Token);