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
@@ -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);