Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Audit/CredentialUseAudit.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

108 lines
5.4 KiB
C#

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";
}
}