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
@@ -0,0 +1,105 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>Gitea #43: the Central UI cert-trust facade records every trust decision in the central audit log.</summary>
public class CertManagementServiceAuditTests
{
private readonly ICentralAuditWriter _audit = Substitute.For<ICentralAuditWriter>();
private sealed class FakeComm(CertTrustResult? result, Exception? throws = null)
: CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
{
public override Task<CertTrustResult> TrustServerCertAsync(string siteId, TrustServerCertCommand command, CancellationToken cancellationToken = default)
=> throws is null ? Task.FromResult(result!) : Task.FromException<CertTrustResult>(throws);
public override Task<CertTrustResult> RemoveServerCertAsync(string siteId, RemoveServerCertCommand command, CancellationToken cancellationToken = default)
=> throws is null ? Task.FromResult(result!) : Task.FromException<CertTrustResult>(throws);
}
private sealed class AuthStub(ClaimsPrincipal user) : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(user));
}
private static ClaimsPrincipal Admin() => new(new ClaimsIdentity(
[
new Claim(JwtTokenService.UsernameClaimType, "alice"),
new Claim(JwtTokenService.RoleClaimType, Roles.Administrator),
], "test"));
private CertManagementService Sut(CommunicationService comm, ClaimsPrincipal? user = null)
=> new(comm, new AuthStub(user ?? Admin()), _audit, NullLogger<CertManagementService>.Instance);
private AuditEvent Captured() => Assert.Single(_audit.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync))
.Select(c => (AuditEvent)c.GetArguments()[0]!));
/// <summary>A successful trust writes a Delivered CertTrust row naming the actor, site and thumbprint.</summary>
[Fact]
public async Task Trust_Success_WritesDeliveredRow()
{
var r = await Sut(new FakeComm(new CertTrustResult(true, null, null))).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
Assert.True(r.Success);
var evt = Captured();
Assert.Equal("alice", evt.Actor);
Assert.Equal("SITE1/AB12", evt.Target);
Assert.Contains("\"kind\":\"CertTrust\"", evt.DetailsJson);
Assert.Contains("\"status\":\"Delivered\"", evt.DetailsJson);
}
/// <summary>A failed relay is audited as Failed with the error, and the page still gets its inline result.</summary>
[Fact]
public async Task Trust_RelayThrows_WritesFailedRow_AndReturnsInlineError()
{
var r = await Sut(new FakeComm(null, new TimeoutException("site unreachable"))).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
Assert.False(r.Success);
var evt = Captured();
Assert.Contains("\"status\":\"Failed\"", evt.DetailsJson);
Assert.Contains("site unreachable", evt.DetailsJson);
}
/// <summary>A removal writes a CertTrustRemove row.</summary>
[Fact]
public async Task Remove_Success_WritesRemoveRow()
{
await Sut(new FakeComm(new CertTrustResult(true, null, null))).RemoveAsync("SITE1", "AB12");
Assert.Contains("\"kind\":\"CertTrustRemove\"", Captured().DetailsJson);
}
/// <summary>An unauthorized caller never reaches the site and writes no audit row.</summary>
[Fact]
public async Task Trust_Unauthorized_WritesNothing()
{
var viewer = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtTokenService.UsernameClaimType, "bob")], "test"));
var r = await Sut(new FakeComm(new CertTrustResult(true, null, null)), viewer).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
Assert.False(r.Success);
Assert.Empty(_audit.ReceivedCalls());
}
/// <summary>An audit-writer failure never changes the trust result.</summary>
[Fact]
public async Task Trust_AuditWriterThrows_ResultUnaffected()
{
_audit.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()).Returns(Task.FromException(new InvalidOperationException("db down")));
var r = await Sut(new FakeComm(new CertTrustResult(true, null, null))).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
Assert.True(r.Success);
}
}
@@ -13,7 +13,7 @@ public class AuditEnumTests
[Fact]
public void AuditChannel_HasExactlyExpectedMembers()
{
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound", "SecuredWrite", "Cluster", "ScriptTrace" };
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound", "SecuredWrite", "Cluster", "ScriptTrace", "Security" };
var actual = Enum.GetValues(typeof(AuditChannel))
.Cast<AuditChannel>()
.Select(x => x.ToString())
@@ -23,9 +23,9 @@ public class AuditEnumTests
Assert.Equal(expected, actual);
}
/// <summary>Verifies that audit kind has exactly twenty two expected members.</summary>
/// <summary>Verifies that audit kind has exactly twenty four expected members.</summary>
[Fact]
public void AuditKind_HasExactlyTwentyTwoExpectedMembers()
public void AuditKind_HasExactlyTwentyFourExpectedMembers()
{
var expected = new[]
{
@@ -37,13 +37,14 @@ public class AuditEnumTests
"ReconciliationAbandoned",
"ManualFailover",
"RouteCall", "ScriptRun", "AttributeWrite", "AttributeWait", "ScriptLog",
"CertTrust", "CertTrustRemove",
};
var actual = Enum.GetValues(typeof(AuditKind))
.Cast<AuditKind>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(22, actual.Length);
Assert.Equal(24, actual.Length);
Assert.Equal(expected, actual);
}
@@ -5621,6 +5621,46 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Equal("SITE1", stub.LastTrustSite);
}
/// <summary>Gitea #43: an admin trust decision is persisted to the central audit log with the real actor and outcome.</summary>
[Fact]
public void TrustServerCert_Admin_WritesCentralAuditRow()
{
var writer = Substitute.For<ICentralAuditWriter>();
_services.AddSingleton(writer);
_services.AddSingleton<CommunicationService>(new StubCommunicationService());
var actor = CreateActor();
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var evt = Assert.Single(writer.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync))
.Select(c => (ZB.MOM.WW.Audit.AuditEvent)c.GetArguments()[0]!));
Assert.Equal("Security", evt.Category);
Assert.Contains("\"kind\":\"CertTrust\"", evt.DetailsJson);
Assert.Contains("\"status\":\"Delivered\"", evt.DetailsJson);
Assert.Equal("SITE1/AB12", evt.Target);
Assert.False(string.IsNullOrEmpty(evt.Actor));
}
/// <summary>Gitea #43: a removal is audited too.</summary>
[Fact]
public void RemoveServerCert_Admin_WritesCentralAuditRow()
{
var writer = Substitute.For<ICentralAuditWriter>();
_services.AddSingleton(writer);
_services.AddSingleton<CommunicationService>(new StubCommunicationService());
var actor = CreateActor();
actor.Tell(Envelope(new RemoveServerCertCommand("AB12", "SITE1"), "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var evt = Assert.Single(writer.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync))
.Select(c => (ZB.MOM.WW.Audit.AuditEvent)c.GetArguments()[0]!));
Assert.Contains("\"kind\":\"CertTrustRemove\"", evt.DetailsJson);
}
/// <summary>Verifies that remove server cert, when admin, relays to site.</summary>
[Fact]
public void RemoveServerCert_Admin_RelaysToSite()