Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Clients/AdminProbeService.cs
T
Joseph Doherty 271b96a644 fix(security): #835/#836 review — unsaved endpoints never borrow the owner's secrets; audit credential use; path rule follows AuthType
I-1: Test Connect / browse configs may carry unsaved edits (the device modal merges
the stored DriverConfig with the typed device config). The server
(DriverInstanceIdentities.EffectiveOwnerAsync, used by AdminOperationsActor and
BrowserSessionService) honours the named owner only when every endpoint value
(new DriverCredentialRules.EndpointFields: Galaxy Endpoint, OpcUaClient
EndpointUrl/EndpointUrls/DiscoveryUrl, Heidenhain Host/Port) is one that driver
stores; otherwise the owner is null and its instance-named secrets are refused.
Every Test Connect / browse whose config carries an in-scope secret:/env: reference
is recorded (CredentialUseAudit: IAuditWriter event category CredentialUse + a
structured Information line: user, driver, endpoint hosts, outcome).

L-1: the UserCertificatePath rule is skipped only in an object that explicitly sets
AuthType to non-Certificate (string or ordinal); the deploy gate also checks the
runtime-merged driver+device config so the switch cannot be split from the path; the
form no longer persists the hidden field.
L-2: a ConfigDb fault in the browse check is a refusal; RawBrowseModal catches open
failures into its error message.
L-4: a relative UserCertificateDirectory resolves against AppContext.BaseDirectory.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 04:12:40 -05:00

81 lines
4.1 KiB
C#

using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// <summary>
/// Thin AdminUI-side wrapper for the Test Connect operation. Dispatches a
/// <see cref="TestDriverConnect"/> through <c>IAdminOperationsClient</c>, applies a
/// 65-second outer wall (the actor itself clamps to [1,60]s; this guards against the
/// Ask never replying), and surfaces a friendly result for the Razor button to render.
/// </summary>
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>
/// <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,
/// waiting up to 65 seconds for a reply before surfacing a timeout failure.
/// </summary>
/// <param name="driverType">Driver type key (must match an installed <c>IDriverProbe.DriverType</c>).</param>
/// <param name="configJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
/// <param name="timeoutSeconds">Per-probe timeout; actor clamps to [1, 60].</param>
/// <param name="ct">Optional cancellation token from the caller.</param>
/// <param name="driverInstanceId">The owning driver instance (lmxopcua#835); null ⇒ a not-yet-created one.</param>
/// <returns>The probe result, or a timeout failure if no reply arrives in time.</returns>
public async Task<TestDriverConnectResult> TestAsync(
string driverType,
string configJson,
int timeoutSeconds,
CancellationToken ct = default,
string? driverInstanceId = null)
{
var correlationId = Guid.NewGuid();
var msg = new TestDriverConnect(driverType, configJson, timeoutSeconds, correlationId, driverInstanceId);
// 65s outer guard — the actor's CTS clamps to 60s; if the Ask never returns we still want
// a deterministic failure surface for the UI.
using var outerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
outerCts.CancelAfter(TimeSpan.FromSeconds(65));
TestDriverConnectResult result;
try
{
result = await _client.AskAsync<TestDriverConnectResult>(msg, outerCts.Token);
}
catch (OperationCanceledException)
{
result = new TestDriverConnectResult(false, "Probe request did not return within 65s.", null, correlationId);
}
catch (Exception ex)
{
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;
}
}