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