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:
+105
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user