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:
Joseph Doherty
2026-11-09 15:17:03 -05:00
parent f98369a786
commit f5eed5e8fd
10 changed files with 322 additions and 22 deletions
@@ -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);
}
}
@@ -12,6 +12,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
// singleton (active node only). For trust/remove it broadcasts the
// corresponding per-node internal message to the per-node CertStoreActor on
// EVERY site cluster node; for list it answers from the local node.
//
// The decision itself IS recorded centrally (Gitea #43): both central entry points (the
// management actor and the Central UI CertManagementService) write an AuditChannel.Security
// AuditLog row (AuditKind.CertTrust / CertTrustRemove) naming the actor, site and thumbprint,
// with Delivered/Failed per the aggregate site outcome. Only the certificate itself stays site-local.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
@@ -0,0 +1,46 @@
using System.Text.Json;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Builds the central <see cref="AuditEvent"/> recording an OPC UA server-certificate trust
/// decision (Gitea #43). The trusted-peer PKI store is site-local and each decision is already
/// broadcast to both site nodes; this is the central, queryable record of WHO made the decision.
/// Shared by the two central entry points (the management actor and the Central UI service) so
/// both emit the identical row shape.
/// </summary>
public static class CertTrustAuditEvents
{
/// <summary>Builds the audit row for a trust or remove decision after its outcome is known.</summary>
/// <param name="remove">True for a trust removal, false for a trust grant.</param>
/// <param name="actor">The authenticated user who made the decision.</param>
/// <param name="siteIdentifier">The site whose PKI stores were targeted.</param>
/// <param name="thumbprint">The certificate thumbprint.</param>
/// <param name="connectionName">The data connection the certificate was captured from (trust only).</param>
/// <param name="result">The aggregate site outcome, or null when the relay threw before an outcome.</param>
/// <param name="exceptionMessage">The relay failure message when <paramref name="result"/> is null.</param>
/// <returns>The audit event, ready for <c>ICentralAuditWriter.WriteAsync</c>.</returns>
public static AuditEvent Create(
bool remove,
string actor,
string siteIdentifier,
string thumbprint,
string? connectionName,
CertTrustResult? result,
string? exceptionMessage = null)
{
var succeeded = result?.Success == true;
return ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.Security,
kind: remove ? AuditKind.CertTrustRemove : AuditKind.CertTrust,
status: succeeded ? AuditStatus.Delivered : AuditStatus.Failed,
actor: actor,
target: $"{siteIdentifier}/{thumbprint}",
sourceSiteId: siteIdentifier,
errorMessage: succeeded ? null : (result?.Error ?? exceptionMessage ?? "Unknown error."),
extra: JsonSerializer.Serialize(new { thumbprint, siteIdentifier, connectionName }));
}
}
@@ -29,5 +29,12 @@ public enum AuditChannel
/// (plus the always-on <see cref="AuditKind.RouteCall"/>). Appended last so stored
/// ordinals of existing channels never move.
/// </summary>
ScriptTrace
ScriptTrace,
/// <summary>
/// Operator-initiated security-trust decisions made through the central management surface
/// (UI, CLI, HTTP Management API) — currently OPC UA server-certificate trust and removal.
/// Appended last so stored ordinals of existing channels never move. (Gitea #43)
/// </summary>
Security
}
@@ -64,5 +64,19 @@ public enum AuditKind
AttributeWait,
/// <summary>One log line a traced script (or the runtime on its behalf) wrote; folded into its owning step by the Flow Debugger, never a node (design 2026-09-09 D1).</summary>
ScriptLog
ScriptLog,
/// <summary>
/// An administrator trusted an OPC UA server certificate for a site's data connection
/// (<see cref="AuditChannel.Security"/>). One row per decision, written after the site
/// reports the outcome: <see cref="AuditStatus.Delivered"/> when every reachable site node
/// acked, <see cref="AuditStatus.Failed"/> otherwise (Gitea #43).
/// </summary>
CertTrust,
/// <summary>
/// An administrator removed trust for an OPC UA server certificate at a site
/// (<see cref="AuditChannel.Security"/>); same status semantics as <see cref="CertTrust"/> (Gitea #43).
/// </summary>
CertTrustRemove
}
@@ -4095,7 +4095,8 @@ public class ManagementActor : ReceiveActor
?? throw new ManagementCommandException("SiteIdentifier is required when trusting a server certificate via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.TrustServerCertAsync(site, cmd);
return await RelayCertTrustAsync(sp, user, remove: false, site, cmd.Thumbprint, cmd.ConnectionName,
() => commService.TrustServerCertAsync(site, cmd));
}
private static async Task<object?> HandleRemoveServerCert(IServiceProvider sp, RemoveServerCertCommand cmd, AuthenticatedUser user)
@@ -4104,7 +4105,50 @@ public class ManagementActor : ReceiveActor
?? throw new ManagementCommandException("SiteIdentifier is required when removing a server certificate via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.RemoveServerCertAsync(site, cmd);
return await RelayCertTrustAsync(sp, user, remove: true, site, cmd.Thumbprint, connectionName: null,
() => commService.RemoveServerCertAsync(site, cmd));
}
/// <summary>
/// Runs a cert trust/remove relay and records the decision in the central AuditLog
/// (Gitea #43). The row is written AFTER the outcome is known, with the real status; a relay
/// exception is audited as Failed and rethrown unchanged. Audit is best-effort and never alters
/// the relay's result.
/// </summary>
private static async Task<CertTrustResult> RelayCertTrustAsync(
IServiceProvider sp, AuthenticatedUser user, bool remove, string site, string thumbprint,
string? connectionName, Func<Task<CertTrustResult>> relay)
{
CertTrustResult result;
try
{
result = await relay();
}
catch (Exception ex)
{
await WriteCertTrustAuditAsync(sp, user, remove, site, thumbprint, connectionName, null, ex.Message);
throw;
}
await WriteCertTrustAuditAsync(sp, user, remove, site, thumbprint, connectionName, result, null);
return result;
}
private static async Task WriteCertTrustAuditAsync(
IServiceProvider sp, AuthenticatedUser user, bool remove, string site, string thumbprint,
string? connectionName, CertTrustResult? result, string? exceptionMessage)
{
try
{
var evt = ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.CertTrustAuditEvents.Create(
remove, user.Username, site, thumbprint, connectionName, result, exceptionMessage);
await sp.GetRequiredService<ICentralAuditWriter>().WriteAsync(evt);
}
catch (Exception ex)
{
sp.GetService<ILogger<ManagementActor>>()?.LogWarning(
ex, "Best-effort cert-trust audit emission failed (site={Site}, thumbprint={Thumbprint}); the trust decision itself is unaffected.",
site, thumbprint);
}
}
private static async Task<object?> HandleListServerCerts(IServiceProvider sp, ListServerCertsCommand cmd, AuthenticatedUser user)