feat(audit): persist OPC UA certificate-trust decisions to the central audit log (ScadaBridge#43)
New AuditChannel.Security with AuditKind.CertTrust / CertTrustRemove (appended, ordinals stable). Both central entry points - the management actor handlers and the Central UI CertManagementService - write a best-effort ICentralAuditWriter row after the site outcome is known (Delivered when all reachable site nodes acked, Failed otherwise, incl. relay timeouts), naming actor, site, thumbprint and connection. Shared builder: CertTrustAuditEvents. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
@@ -26,14 +29,24 @@ public sealed class CertManagementService : ICertManagementService
|
||||
{
|
||||
private readonly CommunicationService _communication;
|
||||
private readonly AuthenticationStateProvider _auth;
|
||||
private readonly ICentralAuditWriter? _audit;
|
||||
private readonly ILogger<CertManagementService>? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CertManagementService"/>.
|
||||
/// </summary>
|
||||
/// <param name="communication">Central-side cluster communication service.</param>
|
||||
/// <param name="auth">Authentication state provider used for the role guards.</param>
|
||||
public CertManagementService(CommunicationService communication, AuthenticationStateProvider auth)
|
||||
/// <param name="audit">Central audit writer for trust-decision rows (Gitea #43); optional so hosts without one still work.</param>
|
||||
/// <param name="logger">Logger for best-effort audit failures.</param>
|
||||
public CertManagementService(
|
||||
CommunicationService communication,
|
||||
AuthenticationStateProvider auth,
|
||||
ICentralAuditWriter? audit = null,
|
||||
ILogger<CertManagementService>? logger = null)
|
||||
{
|
||||
_audit = audit;
|
||||
_logger = logger;
|
||||
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
|
||||
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
||||
}
|
||||
@@ -54,17 +67,14 @@ public sealed class CertManagementService : ICertManagementService
|
||||
return new CertTrustResult(false, "Not authorized.", null);
|
||||
}
|
||||
|
||||
CertTrustResult result;
|
||||
try
|
||||
{
|
||||
return await _communication.TrustServerCertAsync(
|
||||
result = await _communication.TrustServerCertAsync(
|
||||
siteIdentifier,
|
||||
new TrustServerCertCommand(connectionName, derBase64, thumbprint),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Caller-initiated cancel — propagate so Blazor can drop the response
|
||||
@@ -73,8 +83,12 @@ public sealed class CertManagementService : ICertManagementService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
// Includes TimeoutException: collapse into a non-success result.
|
||||
result = new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
|
||||
await AuditAsync(remove: false, siteIdentifier, thumbprint, connectionName, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -122,22 +136,44 @@ public sealed class CertManagementService : ICertManagementService
|
||||
return new CertTrustResult(false, "Not authorized.", null);
|
||||
}
|
||||
|
||||
CertTrustResult result;
|
||||
try
|
||||
{
|
||||
return await _communication.RemoveServerCertAsync(
|
||||
result = await _communication.RemoveServerCertAsync(
|
||||
siteIdentifier, new RemoveServerCertCommand(thumbprint), cancellationToken);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CertTrustResult(false, ex.Message, null);
|
||||
result = new CertTrustResult(false, ex.Message, null);
|
||||
}
|
||||
|
||||
await AuditAsync(remove: true, siteIdentifier, thumbprint, connectionName: null, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the trust decision in the central AuditLog (Gitea #43) once its outcome is known.
|
||||
/// Best-effort: never alters the result returned to the page.
|
||||
/// </summary>
|
||||
private async Task AuditAsync(bool remove, string siteIdentifier, string thumbprint, string? connectionName, CertTrustResult result)
|
||||
{
|
||||
if (_audit is null) return;
|
||||
try
|
||||
{
|
||||
var state = await _auth.GetAuthoritativeStateAsync();
|
||||
var evt = CertTrustAuditEvents.Create(
|
||||
remove, state.User.GetUsername(), siteIdentifier, thumbprint, connectionName, result);
|
||||
await _audit.WriteAsync(evt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex,
|
||||
"Best-effort cert-trust audit emission failed (site={Site}, thumbprint={Thumbprint}); the trust decision itself is unaffected.",
|
||||
siteIdentifier, thumbprint);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user