Merge #835/#836 review fixes (owner check on unsaved endpoints, credential-use audit)

This commit is contained in:
Joseph Doherty
2026-11-11 04:13:02 -05:00
21 changed files with 790 additions and 32 deletions
@@ -53,7 +53,7 @@ public static class CredentialReferenceDeployChecks
.ToList();
foreach (var driver in draft.DriverInstances)
{
if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue;
if (!DriverCredentialPolicy.TryGet(driver.DriverType, out var rules)) continue;
var devices = draft.Devices
.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal))
.ToList();
@@ -74,10 +74,32 @@ 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));
}
}
}
@@ -28,6 +28,64 @@ public static class DriverInstanceIdentities
.ToList();
}
/// <summary>
/// #835 review I-1: the owner a Test Connect / browse config may claim. The caller names a driver, but the config can
/// carry UNSAVED edits (the device modal merges the stored DriverConfig with the typed device config), so a Designer
/// could type their own host as the endpoint of a victim's device and Test Connect it with the victim's own
/// <c>secret:galaxy/&lt;victim-id&gt;/…</c>. The claim is honoured only when the driver exists, has the same type, and every
/// endpoint value of the config is one it already STORES (DriverConfig or any DeviceConfig) — otherwise the owner is
/// null, i.e. the strict "new driver" treatment (no instance-named secrets at all).
/// </summary>
/// <param name="db">The config DB.</param>
/// <param name="driverType">The driver type of the request.</param>
/// <param name="configJson">The config about to be probed / browsed.</param>
/// <param name="claimedOwner">The driver the caller says the config belongs to.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The owner to use, or null.</returns>
public static async Task<string?> EffectiveOwnerAsync(
OtOpcUaConfigDbContext db, string? driverType, string? configJson, string? claimedOwner, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(db);
if (string.IsNullOrEmpty(claimedOwner) || driverType is null) return null;
var driver = await db.DriverInstances.AsNoTracking()
.Where(d => d.DriverInstanceId == claimedOwner)
.Select(d => new { d.DriverType, d.DriverConfig })
.FirstOrDefaultAsync(ct).ConfigureAwait(false);
if (driver is null || !string.Equals(driver.DriverType, driverType, StringComparison.OrdinalIgnoreCase)) return null;
var deviceConfigs = await db.Devices.AsNoTracking()
.Where(d => d.DriverInstanceId == claimedOwner)
.Select(d => d.DeviceConfig)
.ToListAsync(ct).ConfigureAwait(false);
return DriverCredentialPolicy.EndpointsMatchStored(driverType, configJson, [driver.DriverConfig, .. deviceConfigs])
? claimedOwner
: null;
}
/// <summary>
/// The cross-instance problems of a Test Connect / browse config: <see cref="EffectiveOwnerAsync"/> first (an
/// unsaved endpoint does not get the owner's own secrets), then <see cref="DescribeOtherInstanceProblemsAsync"/>.
/// </summary>
/// <param name="db">The config DB.</param>
/// <param name="driverType">The driver type.</param>
/// <param name="configJson">The config.</param>
/// <param name="claimedOwner">The driver the caller names.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The problems.</returns>
public static async Task<IReadOnlyList<string>> DescribeProbeProblemsAsync(
OtOpcUaConfigDbContext db, string? driverType, string? configJson, string? claimedOwner, CancellationToken ct)
{
if (driverType is null || !DriverCredentialPolicy.HasInstanceSegments(driverType, configJson)) return [];
var owner = await EffectiveOwnerAsync(db, driverType, configJson, claimedOwner, ct).ConfigureAwait(false);
var problems = await DescribeOtherInstanceProblemsAsync(db, driverType, configJson, owner, ct).ConfigureAwait(false);
if (problems.Count == 0 || owner is not null || string.IsNullOrEmpty(claimedOwner)) return problems;
return
[
.. problems,
$"(The endpoint differs from what driver {claimedOwner} has saved, so its own secrets are not sent to it from an "
+ "unsaved form — save the change first; lmxopcua#835.)",
];
}
/// <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.
@@ -8,7 +8,38 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// </summary>
/// <param name="Name">The JSON property name (matched case-insensitively, as the driver binds it).</param>
/// <param name="DirectoryDescription">How the operator configures the directory (for messages).</param>
public sealed record CredentialPathField(string Name, string DirectoryDescription);
/// <param name="ActiveWhenKey">
/// Optional sibling key that switches the field on (OpcUaClient <c>AuthType</c>): the field is checked UNLESS the same
/// JSON object sets this key to something other than <paramref name="ActiveWhenValue"/> /
/// <paramref name="ActiveWhenOrdinal"/>. An absent key keeps the field checked (conservative — the driver's merge may
/// supply the switch from another config), so only an EXPLICITLY inactive object is exempt.
/// </param>
/// <param name="ActiveWhenValue">The string value of <paramref name="ActiveWhenKey"/> that activates the field.</param>
/// <param name="ActiveWhenOrdinal">The numeric (enum ordinal) value that activates the field.</param>
public sealed record CredentialPathField(
string Name, string DirectoryDescription, string? ActiveWhenKey = null, string? ActiveWhenValue = null, int? ActiveWhenOrdinal = null)
{
/// <summary>Whether the field is in force in <paramref name="owner"/> (the JSON object holding it).</summary>
/// <param name="owner">The object.</param>
/// <returns>False only when the object explicitly switches the field off.</returns>
public bool IsActiveIn(System.Text.Json.JsonElement owner)
{
if (ActiveWhenKey is null || owner.ValueKind != System.Text.Json.JsonValueKind.Object) return true;
foreach (var p in owner.EnumerateObject())
{
if (!string.Equals(p.Name, ActiveWhenKey, StringComparison.OrdinalIgnoreCase)) continue;
return p.Value.ValueKind switch
{
System.Text.Json.JsonValueKind.Null => true,
System.Text.Json.JsonValueKind.String => string.Equals(p.Value.GetString(), ActiveWhenValue, StringComparison.OrdinalIgnoreCase),
System.Text.Json.JsonValueKind.Number => !p.Value.TryGetInt32(out var n) || n == ActiveWhenOrdinal,
_ => true,
};
}
return true;
}
}
/// <summary>
/// The one rule for <see cref="CredentialPathField"/> values (lmxopcua#836): authored as a RELATIVE path inside a
@@ -12,11 +12,19 @@ public sealed record CredentialField(string Name, CredentialLiteralPolicy Litera
/// <param name="Scope">The namespace its references are confined to.</param>
/// <param name="Fields">The credential fields its DriverConfig/DeviceConfig may carry.</param>
/// <param name="PathFields">Fields naming a node-local key-material file, confined to a node directory (lmxopcua#836).</param>
/// <param name="EndpointFields">
/// The keys (any depth, case-insensitive) that say WHERE the driver sends its credentials — the endpoint a Test Connect
/// or browse dials (#835 review I-1: an unsaved endpoint may not borrow the stored driver's own secrets).
/// </param>
public sealed record DriverCredentialRules(
string DriverType, CredentialScope Scope, IReadOnlyList<CredentialField> Fields, IReadOnlyList<CredentialPathField>? PathFields = null)
string DriverType, CredentialScope Scope, IReadOnlyList<CredentialField> Fields, IReadOnlyList<CredentialPathField>? PathFields = null,
IReadOnlyList<string>? EndpointFields = null)
{
/// <summary>The path fields (never null).</summary>
public IReadOnlyList<CredentialPathField> PathFieldList => PathFields ?? [];
/// <summary>The endpoint fields (never null).</summary>
public IReadOnlyList<string> EndpointFieldList => EndpointFields ?? [];
}
/// <summary>The identity of one driver instance, as the cross-instance rule (lmxopcua#835) matches it.</summary>
@@ -57,7 +65,8 @@ public static class DriverCredentialPolicy
/// <summary>OpcUaClient <c>UserCertificatePath</c>: a relative path inside the node's user-certificate directory (#836).</summary>
public static readonly CredentialPathField OpcUaClientUserCertificatePathField = new(
"UserCertificatePath",
OpcUaClientUserCertificateDirectoryKey + ", default <LocalAppData>/OtOpcUa/pki/user-certificates");
OpcUaClientUserCertificateDirectoryKey + ", default <LocalAppData>/OtOpcUa/pki/user-certificates",
ActiveWhenKey: "AuthType", ActiveWhenValue: "Certificate", ActiveWhenOrdinal: 2);
/// <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);
@@ -73,13 +82,14 @@ public static class DriverCredentialPolicy
new("InspectPassword", CredentialLiteralPolicy.Refused),
new("DncPassword", CredentialLiteralPolicy.Refused),
new("DataPassword", CredentialLiteralPolicy.Refused),
]),
[DriverTypeNames.Galaxy] = new(DriverTypeNames.Galaxy, GalaxyScope, [GalaxyApiKeyField, GalaxyAckPasswordField]),
], EndpointFields: ["Host", "Port"]),
[DriverTypeNames.Galaxy] = new(DriverTypeNames.Galaxy, GalaxyScope, [GalaxyApiKeyField, GalaxyAckPasswordField],
EndpointFields: ["Endpoint"]),
[DriverTypeNames.OpcUaClient] = new(DriverTypeNames.OpcUaClient, OpcUaClientScope,
[
new("Password", CredentialLiteralPolicy.Allowed),
new("UserCertificatePassword", CredentialLiteralPolicy.Allowed),
], [OpcUaClientUserCertificatePathField]),
], [OpcUaClientUserCertificatePathField], ["EndpointUrl", "EndpointUrls", "DiscoveryUrl"]),
};
/// <summary>The rules for a driver type, if it resolves credential references.</summary>
@@ -187,6 +197,9 @@ public static class DriverCredentialPolicy
continue;
}
// #836 review L-1: a path field explicitly switched off in its own object (OpcUaClient AuthType ≠ Certificate)
// is never loaded by the driver, so it is not checked; an absent switch keeps it checked.
if (pathField is not null && !pathField.IsActiveIn(element)) continue;
onField(fieldPath, p.Value, field, pathField);
}
}
@@ -290,6 +303,100 @@ public static class DriverCredentialPolicy
string? driverType, string? json, string? ownDriverInstanceId, IEnumerable<DriverInstanceIdentity> instances) =>
[.. DescribeProblems(driverType, json), .. DescribeOtherInstanceProblems(driverType, json, ownDriverInstanceId, instances)];
/// <summary>
/// True when the config carries at least one in-scope credential REFERENCE (<c>secret:</c> or <c>env:</c>) — the
/// Test Connect / browse audit trigger (#835 review I-1).
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>Whether a reference that would be resolved is present.</returns>
public static bool HasCredentialReferences(string? driverType, string? json)
{
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return false;
var found = false;
WithDocument(json, root => Visit(root, "", rules, 0, (_, value, field, _) =>
{
if (field is null || value.ValueKind != JsonValueKind.String) return;
if (CredentialReferences.Parse(value.GetString(), rules.Scope, out _) is CredentialReferenceKind.Secret or CredentialReferenceKind.Environment)
found = true;
}));
return found;
}
/// <summary>
/// The endpoint values (see <see cref="DriverCredentialRules.EndpointFields"/>) of a config — every string / number
/// under an endpoint key at any depth (arrays flattened), trimmed. Empty for an unreadable config or a type without
/// rules.
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>The values, in document order.</returns>
public static IReadOnlyList<string> EndpointValues(string? driverType, string? json)
{
var values = new List<string>();
if (!TryGet(driverType, out var rules) || rules.EndpointFieldList.Count == 0 || string.IsNullOrWhiteSpace(json)) return values;
WithDocument(json, root => CollectEndpoints(root, rules, values, 0, false));
return values;
}
/// <summary>
/// #835 review I-1: whether every endpoint value of <paramref name="submittedJson"/> (a Test Connect / browse config,
/// possibly carrying UNSAVED edits) is one the driver already stores (its DriverConfig or any of its DeviceConfigs).
/// If not, the caller must not treat the stored driver as the config's owner: its own secrets would otherwise be sent
/// to an endpoint nobody saved. A type without endpoint fields never matches (conservative).
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="submittedJson">The config about to be probed / browsed.</param>
/// <param name="storedJsons">The owner's stored DriverConfig + DeviceConfigs.</param>
/// <returns>Whether the submitted endpoints are all stored ones.</returns>
public static bool EndpointsMatchStored(string? driverType, string? submittedJson, IEnumerable<string?> storedJsons)
{
ArgumentNullException.ThrowIfNull(storedJsons);
if (!TryGet(driverType, out var rules) || rules.EndpointFieldList.Count == 0) return false;
var allowed = new HashSet<string>(storedJsons.SelectMany(s => EndpointValues(driverType, s)), StringComparer.OrdinalIgnoreCase);
return EndpointValues(driverType, submittedJson).All(allowed.Contains);
}
private static void CollectEndpoints(JsonElement element, DriverCredentialRules rules, List<string> values, int depth, bool under)
{
if (depth > 32) return;
switch (element.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray()) CollectEndpoints(item, rules, values, depth + 1, under);
return;
case JsonValueKind.Object:
foreach (var p in element.EnumerateObject())
{
var isEndpoint = rules.EndpointFieldList.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase));
CollectEndpoints(p.Value, rules, values, depth + 1, isEndpoint);
}
return;
case JsonValueKind.String when under:
if (element.GetString() is { } s && s.Trim().Length > 0) values.Add(s.Trim());
return;
case JsonValueKind.Number when under:
values.Add(element.GetRawText());
return;
}
}
private static void WithDocument(string json, Action<JsonElement> use)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
}
catch (JsonException)
{
return;
}
using (doc) use(doc.RootElement);
}
private static List<(string FieldPath, string Segment)> CollectSegments(string? driverType, string? json)
{
var found = new List<(string, string)>();
@@ -24,10 +24,20 @@ public static class OpcUaUserCertificatePaths
/// <summary>The directory in force (configured, else <see cref="DefaultDirectory"/>).</summary>
public static string CurrentDirectory => _configured ?? DefaultDirectory;
/// <summary>Sets the process-wide directory (blank ⇒ the default). Called by the host at startup.</summary>
/// <summary>
/// Sets the process-wide directory (blank ⇒ the default). Called by the host at startup. A RELATIVE value is
/// resolved against the application's install directory (<see cref="AppContext.BaseDirectory"/>), never the
/// working directory — a Windows service starts in <c>System32</c> (review L-4).
/// </summary>
/// <param name="directory">The configured directory.</param>
public static void Configure(string? directory) =>
_configured = string.IsNullOrWhiteSpace(directory) ? null : Path.GetFullPath(directory);
public static void Configure(string? directory) => _configured = Normalize(directory, AppContext.BaseDirectory);
/// <summary>The directory a configured value means: blank ⇒ null; relative ⇒ under <paramref name="baseDirectory"/>.</summary>
/// <param name="directory">The configured value.</param>
/// <param name="baseDirectory">The base a relative value is resolved against.</param>
/// <returns>The absolute directory, or null.</returns>
public static string? Normalize(string? directory, string baseDirectory) =>
string.IsNullOrWhiteSpace(directory) ? null : Path.GetFullPath(directory.Trim(), baseDirectory);
/// <summary>
/// Resolves an authored <c>UserCertificatePath</c> to the file to load, fail-closed
@@ -0,0 +1,107 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
/// <summary>
/// #835 review I-1: every Test Connect / live browse whose config carries an in-scope credential reference
/// (<c>secret:</c> / <c>env:</c>) is recorded — who, which driver, which endpoint host(s), and the outcome — as an
/// <see cref="AuditEvent"/> (category <see cref="Category"/>) AND a structured Information log line, because those are
/// the two operations that resolve a stored credential and send it to an endpoint typed into an unsaved form. Never the
/// reference value or the credential.
/// </summary>
public static class CredentialUseAudit
{
/// <summary>The audit category.</summary>
public const string Category = "CredentialUse";
/// <summary>The Test Connect action.</summary>
public const string TestConnectAction = "TestConnect";
/// <summary>The live-browse action.</summary>
public const string BrowseAction = "Browse";
/// <summary>
/// The endpoint hosts of a config, print-safe: <c>host[:port]</c> for a URI (user-info dropped), the trimmed value
/// otherwise (capped).
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="configJson">The config.</param>
/// <returns>The hosts.</returns>
public static IReadOnlyList<string> Hosts(string? driverType, string? configJson) =>
DriverCredentialPolicy.EndpointValues(driverType, configJson)
.Select(v => Uri.TryCreate(v, UriKind.Absolute, out var u) && !string.IsNullOrEmpty(u.Host)
? (u.IsDefaultPort || u.Port < 0 ? u.Host : $"{u.Host}:{u.Port}")
: (v.Length > 128 ? v[..128] : v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
/// <summary>Builds the audit event.</summary>
/// <param name="action"><see cref="TestConnectAction"/> or <see cref="BrowseAction"/>.</param>
/// <param name="actor">The user.</param>
/// <param name="driverType">The driver type.</param>
/// <param name="driverInstanceId">The driver the caller named, or null.</param>
/// <param name="hosts">The endpoint hosts.</param>
/// <param name="ok">Whether the operation succeeded.</param>
/// <param name="message">The refusal / failure message, if any.</param>
/// <returns>The event.</returns>
public static AuditEvent Build(
string action, string actor, string driverType, string? driverInstanceId, IReadOnlyList<string> hosts, bool ok, string? message) =>
new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = actor,
Action = action,
Category = Category,
Target = driverInstanceId ?? "(unsaved driver)",
Outcome = ok ? AuditOutcome.Success : AuditOutcome.Failure,
DetailsJson = JsonSerializer.Serialize(new { driverType, endpoints = hosts, message }),
};
/// <summary>
/// Records one operation when the config carries an in-scope credential reference; a no-op otherwise. Never throws
/// (an audit failure is logged, not surfaced).
/// </summary>
/// <param name="audit">The audit writer, or null (log only).</param>
/// <param name="auth">The circuit's authentication state, or null.</param>
/// <param name="logger">The logger.</param>
/// <param name="action">The action.</param>
/// <param name="driverType">The driver type.</param>
/// <param name="configJson">The config.</param>
/// <param name="driverInstanceId">The driver the caller named.</param>
/// <param name="ok">Whether it succeeded.</param>
/// <param name="message">The refusal / failure message.</param>
/// <returns>A task.</returns>
public static async Task RecordAsync(
IAuditWriter? audit, AuthenticationStateProvider? auth, ILogger logger, string action, string driverType, string configJson,
string? driverInstanceId, bool ok, string? message)
{
if (!DriverCredentialPolicy.HasCredentialReferences(driverType, configJson)) return;
try
{
var actor = await ActorAsync(auth).ConfigureAwait(false);
var hosts = Hosts(driverType, configJson);
logger.LogInformation(
"Credential use: {Action} by {User} driverType={DriverType} driver={DriverInstanceId} endpoints={Endpoints} outcome={Outcome} {Message}",
action, actor, driverType, driverInstanceId ?? "(unsaved)", string.Join(",", hosts), ok ? "Success" : "Failure", message);
if (audit is not null)
await audit.WriteAsync(Build(action, actor, driverType, driverInstanceId, hosts, ok, message)).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Credential-use audit for {Action} could not be written.", action);
}
}
private static async Task<string> ActorAsync(AuthenticationStateProvider? auth)
{
if (auth is null) return "unknown";
var state = await auth.GetAuthenticationStateAsync().ConfigureAwait(false);
return state.User.Identity?.Name ?? state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown";
}
}
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
@@ -27,6 +28,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// 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).
/// </param>
/// <param name="audit">The credential-use audit sink (#835 review I-1); null ⇒ the structured log line only.</param>
public sealed class BrowserSessionService(
IEnumerable<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
@@ -34,7 +36,8 @@ public sealed class BrowserSessionService(
IUniversalDriverBrowser universalBrowser,
IAuthorizationService authorizationService,
AuthenticationStateProvider authenticationStateProvider,
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null) : IBrowserSessionService
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
ZB.MOM.WW.Audit.IAuditWriter? audit = null) : IBrowserSessionService
{
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
@@ -48,6 +51,15 @@ public sealed class BrowserSessionService(
/// <inheritdoc />
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, string? driverInstanceId, CancellationToken ct)
{
var result = await OpenCheckedAsync(driverType, configJson, driverInstanceId, ct).ConfigureAwait(false);
// #835 review I-1: who browsed which endpoint with which driver's credential reference, and how it went.
await CredentialUseAudit.RecordAsync(audit, authenticationStateProvider, logger, CredentialUseAudit.BrowseAction,
driverType, configJson, driverInstanceId, result.Ok, result.Message).ConfigureAwait(false);
return result;
}
private async Task<BrowseOpenResult> OpenCheckedAsync(string driverType, string configJson, string? driverInstanceId, 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
@@ -56,17 +68,28 @@ public sealed class BrowserSessionService(
if (credentialProblems.Count > 0)
return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty);
// lmxopcua#835: nor may an in-namespace reference name ANOTHER driver instance's secret.
// lmxopcua#835: nor may an in-namespace reference name ANOTHER driver instance's secret — and the named owner's
// own secrets count only while the config's endpoint is one that driver has SAVED (review I-1).
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: "
+ "references is unavailable (lmxopcua#835, fail-closed).", Guid.Empty);
await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var instanceProblems = await DriverInstanceIdentities
.DescribeOtherInstanceProblemsAsync(db, driverType, configJson, driverInstanceId, ct).ConfigureAwait(false);
if (instanceProblems.Count > 0)
return new(false, "Browse refused: " + string.Join(" ", instanceProblems), Guid.Empty);
try
{
await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var instanceProblems = await DriverInstanceIdentities
.DescribeProbeProblemsAsync(db, driverType, configJson, driverInstanceId, ct).ConfigureAwait(false);
if (instanceProblems.Count > 0)
return new(false, "Browse refused: " + string.Join(" ", instanceProblems), Guid.Empty);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
// 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);
}
}
if (!_browsersByType.TryGetValue(driverType, out var browser))
@@ -12,10 +12,26 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
public sealed class AdminProbeService
{
private readonly IAdminOperationsClient _client;
private readonly ZB.MOM.WW.Audit.IAuditWriter? _audit;
private readonly Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider? _auth;
private readonly Microsoft.Extensions.Logging.ILogger _logger;
/// <summary>Initializes a new instance of the <see cref="AdminProbeService"/>.</summary>
/// <param name="client">The admin operations client used to dispatch probe requests.</param>
public AdminProbeService(IAdminOperationsClient client) => _client = client;
/// <param name="audit">Credential-use audit sink (#835 review I-1); null ⇒ the structured log line only.</param>
/// <param name="auth">The circuit's user (for the audit record); null ⇒ "unknown".</param>
/// <param name="logger">Logger; null ⇒ none.</param>
public AdminProbeService(
IAdminOperationsClient client,
ZB.MOM.WW.Audit.IAuditWriter? audit = null,
Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider? auth = null,
Microsoft.Extensions.Logging.ILogger<AdminProbeService>? logger = null)
{
_client = client;
_audit = audit;
_auth = auth;
_logger = (Microsoft.Extensions.Logging.ILogger?)logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
}
/// <summary>
/// Dispatches a Test Connect probe for the supplied driver type and config JSON,
@@ -42,17 +58,23 @@ public sealed class AdminProbeService
using var outerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
outerCts.CancelAfter(TimeSpan.FromSeconds(65));
TestDriverConnectResult result;
try
{
return await _client.AskAsync<TestDriverConnectResult>(msg, outerCts.Token);
result = await _client.AskAsync<TestDriverConnectResult>(msg, outerCts.Token);
}
catch (OperationCanceledException)
{
return new TestDriverConnectResult(false, "Probe request did not return within 65s.", null, correlationId);
result = new TestDriverConnectResult(false, "Probe request did not return within 65s.", null, correlationId);
}
catch (Exception ex)
{
return new TestDriverConnectResult(false, $"Probe dispatch failed: {ex.Message}", null, correlationId);
result = new TestDriverConnectResult(false, $"Probe dispatch failed: {ex.Message}", null, correlationId);
}
// #835 review I-1: who Test-Connected which endpoint with which driver's credential reference, and how it went.
await Audit.CredentialUseAudit.RecordAsync(_audit, _auth, _logger, Audit.CredentialUseAudit.TestConnectAction,
driverType, configJson, driverInstanceId, result.Ok, result.Message).ConfigureAwait(false);
return result;
}
}
@@ -424,7 +424,8 @@
AuthType = AuthType,
Username = string.IsNullOrWhiteSpace(Username) ? null : Username,
Password = string.IsNullOrWhiteSpace(Password) ? null : Password,
UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
// #836 review L-1: the field is hidden (and never loaded) unless AuthType is Certificate — do not persist it then.
UserCertificatePath = AuthType != OpcUaAuthType.Certificate || string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword,
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
SubscriptionPublishingInterval = SubscriptionPublishingIntervalMs is { } ms and > 0
@@ -488,6 +488,12 @@
}
else { _openError = result.Message; }
}
catch (Exception ex)
{
// Review L-2: a ConfigDb fault (owner lookup) or any other open failure is a message on the modal, never a
// circuit error that takes the whole page down.
if (!_disposed && !IsStale(generation)) { _openError = $"Browse could not be opened: {ex.Message}"; }
}
finally
{
_opening = false;
@@ -617,7 +617,7 @@ public sealed class AdminOperationsActor : ReceiveActor
if (DriverCredentialPolicy.HasInstanceSegments(msg.DriverType, msg.ConfigJson))
{
await using var db = await _dbFactory.CreateDbContextAsync(cts.Token);
var instanceProblems = await DriverInstanceIdentities.DescribeOtherInstanceProblemsAsync(
var instanceProblems = await DriverInstanceIdentities.DescribeProbeProblemsAsync(
db, msg.DriverType, msg.ConfigJson, msg.DriverInstanceId, cts.Token);
if (instanceProblems.Count > 0)
return new TestDriverConnectResult(