Merge #835/#836 review fixes (owner check on unsaved endpoints, credential-use audit)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user