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; /// /// #835 review I-1: every Test Connect / live browse whose config carries an in-scope credential reference /// (secret: / env:) is recorded — who, which driver, which endpoint host(s), and the outcome — as an /// (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. /// public static class CredentialUseAudit { /// The audit category. public const string Category = "CredentialUse"; /// The Test Connect action. public const string TestConnectAction = "TestConnect"; /// The live-browse action. public const string BrowseAction = "Browse"; /// /// The endpoint hosts of a config, print-safe: host[:port] for a URI (user-info dropped), the trimmed value /// otherwise (capped). /// /// The driver type. /// The config. /// The hosts. public static IReadOnlyList 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(); /// Builds the audit event. /// or . /// The user. /// The driver type. /// The driver the caller named, or null. /// The endpoint hosts. /// Whether the operation succeeded. /// The refusal / failure message, if any. /// The event. public static AuditEvent Build( string action, string actor, string driverType, string? driverInstanceId, IReadOnlyList 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 }), }; /// /// 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). /// /// The audit writer, or null (log only). /// The circuit's authentication state, or null. /// The logger. /// The action. /// The driver type. /// The config. /// The driver the caller named. /// Whether it succeeded. /// The refusal / failure message. /// A task. 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 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"; } }