From f5eed5e8fd236c307be88c1e57b25f37d396b16d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 15:17:03 -0500 Subject: [PATCH] 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 --- docs/requirements/Component-AuditLog.md | 4 +- .../Services/CertManagementService.cs | 62 ++++++++--- .../Messages/Management/CertTrustCommands.cs | 5 + .../Types/Audit/CertTrustAuditEvents.cs | 46 ++++++++ .../Types/Enums/AuditChannel.cs | 9 +- .../Types/Enums/AuditKind.cs | 16 ++- .../ManagementActor.cs | 48 +++++++- .../CertManagementServiceAuditTests.cs | 105 ++++++++++++++++++ .../Types/Enums/AuditEnumTests.cs | 9 +- .../ManagementActorTests.cs | 40 +++++++ 10 files changed, 322 insertions(+), 22 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/CertTrustAuditEvents.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CertManagementServiceAuditTests.cs diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index db32bac3..b071462c 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -93,7 +93,7 @@ row per lifecycle event across all channels. | `EventId` | `uniqueidentifier` PK | Generated where the event originates (site or central). Idempotency key. | | `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). | | `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). | -| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound` \| `SecuredWrite`. | +| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound` \| `SecuredWrite` \| `Cluster` \| `ScriptTrace` \| `Security`. | | `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). | | `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. | | `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. | @@ -144,6 +144,8 @@ row per lifecycle event across all channels. | `SecuredWriteApprove` | A verifier wins the approval CAS for a pending secured write. | | `SecuredWriteReject` | A verifier rejects a pending secured write (`Status=Discarded`). | | `SecuredWriteExecute` | The approved write was relayed to the site MxGateway — terminal outcome (`Delivered`-equivalent on success, `Failed` on error). | +| `CertTrust` | Central-direct row on the `Security` channel (Gitea #43): an administrator trusted an OPC UA server certificate at a site. Written by both central entry points (management actor, Central UI `CertManagementService`) after the site outcome is known — `Delivered` when every reachable site node acked, `Failed` otherwise. `Target` = `/`; `Extra` carries thumbprint, site and connection name. | +| `CertTrustRemove` | As `CertTrust`, for removing a trusted certificate. | | `ReconciliationAbandoned` | Central-direct synthetic row (`Status=Failed`) written when a reconciliation pull row failed to insert on every retry up to the permanent-abandon threshold and central advanced its cursor past it. `Extra` carries the abandoned `EventId`, the source site, and the final error — so the permanent loss is queryable, not only in the Critical log line. | Inbound API is intentionally collapsed to a single `InboundRequest` (or diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CertManagementService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CertManagementService.cs index 51f80079..dc8a5e3a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CertManagementService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CertManagementService.cs @@ -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? _logger; /// /// Initializes a new instance of the . /// /// Central-side cluster communication service. /// Authentication state provider used for the role guards. - public CertManagementService(CommunicationService communication, AuthenticationStateProvider auth) + /// Central audit writer for trust-decision rows (Gitea #43); optional so hosts without one still work. + /// Logger for best-effort audit failures. + public CertManagementService( + CommunicationService communication, + AuthenticationStateProvider auth, + ICentralAuditWriter? audit = null, + ILogger? 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; } /// @@ -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; + } + + /// + /// 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. + /// + 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); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs index 20450de6..b9ed380f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs @@ -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. // ───────────────────────────────────────────────────────────────────────────── /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/CertTrustAuditEvents.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/CertTrustAuditEvents.cs new file mode 100644 index 00000000..f10e3208 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/CertTrustAuditEvents.cs @@ -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; + +/// +/// Builds the central 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. +/// +public static class CertTrustAuditEvents +{ + /// Builds the audit row for a trust or remove decision after its outcome is known. + /// True for a trust removal, false for a trust grant. + /// The authenticated user who made the decision. + /// The site whose PKI stores were targeted. + /// The certificate thumbprint. + /// The data connection the certificate was captured from (trust only). + /// The aggregate site outcome, or null when the relay threw before an outcome. + /// The relay failure message when is null. + /// The audit event, ready for ICentralAuditWriter.WriteAsync. + 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 })); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs index 069d896d..92346905 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditChannel.cs @@ -29,5 +29,12 @@ public enum AuditChannel /// (plus the always-on ). Appended last so stored /// ordinals of existing channels never move. /// - ScriptTrace + ScriptTrace, + + /// + /// 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) + /// + Security } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs index ca706630..d7c977a0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs @@ -64,5 +64,19 @@ public enum AuditKind AttributeWait, /// 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). - ScriptLog + ScriptLog, + + /// + /// An administrator trusted an OPC UA server certificate for a site's data connection + /// (). One row per decision, written after the site + /// reports the outcome: when every reachable site node + /// acked, otherwise (Gitea #43). + /// + CertTrust, + + /// + /// An administrator removed trust for an OPC UA server certificate at a site + /// (); same status semantics as (Gitea #43). + /// + CertTrustRemove } diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 08a4790b..ded272f4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -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(); - 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 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(); - return await commService.RemoveServerCertAsync(site, cmd); + return await RelayCertTrustAsync(sp, user, remove: true, site, cmd.Thumbprint, connectionName: null, + () => commService.RemoveServerCertAsync(site, cmd)); + } + + /// + /// 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. + /// + private static async Task RelayCertTrustAsync( + IServiceProvider sp, AuthenticatedUser user, bool remove, string site, string thumbprint, + string? connectionName, Func> 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().WriteAsync(evt); + } + catch (Exception ex) + { + sp.GetService>()?.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 HandleListServerCerts(IServiceProvider sp, ListServerCertsCommand cmd, AuthenticatedUser user) diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CertManagementServiceAuditTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CertManagementServiceAuditTests.cs new file mode 100644 index 00000000..d9f63419 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CertManagementServiceAuditTests.cs @@ -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; + +/// Gitea #43: the Central UI cert-trust facade records every trust decision in the central audit log. +public class CertManagementServiceAuditTests +{ + private readonly ICentralAuditWriter _audit = Substitute.For(); + + private sealed class FakeComm(CertTrustResult? result, Exception? throws = null) + : CommunicationService(Options.Create(new CommunicationOptions()), NullLogger.Instance) + { + public override Task TrustServerCertAsync(string siteId, TrustServerCertCommand command, CancellationToken cancellationToken = default) + => throws is null ? Task.FromResult(result!) : Task.FromException(throws); + + public override Task RemoveServerCertAsync(string siteId, RemoveServerCertCommand command, CancellationToken cancellationToken = default) + => throws is null ? Task.FromResult(result!) : Task.FromException(throws); + } + + private sealed class AuthStub(ClaimsPrincipal user) : AuthenticationStateProvider + { + public override Task 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.Instance); + + private AuditEvent Captured() => Assert.Single(_audit.ReceivedCalls() + .Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync)) + .Select(c => (AuditEvent)c.GetArguments()[0]!)); + + /// A successful trust writes a Delivered CertTrust row naming the actor, site and thumbprint. + [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); + } + + /// A failed relay is audited as Failed with the error, and the page still gets its inline result. + [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); + } + + /// A removal writes a CertTrustRemove row. + [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); + } + + /// An unauthorized caller never reaches the site and writes no audit row. + [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()); + } + + /// An audit-writer failure never changes the trust result. + [Fact] + public async Task Trust_AuditWriterThrows_ResultUnaffected() + { + _audit.WriteAsync(Arg.Any(), Arg.Any()).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); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs index a1864a89..6dfa376c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs @@ -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() .Select(x => x.ToString()) @@ -23,9 +23,9 @@ public class AuditEnumTests Assert.Equal(expected, actual); } - /// Verifies that audit kind has exactly twenty two expected members. + /// Verifies that audit kind has exactly twenty four expected members. [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() .Select(x => x.ToString()) .ToArray(); - Assert.Equal(22, actual.Length); + Assert.Equal(24, actual.Length); Assert.Equal(expected, actual); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 8c90853c..e4c50e93 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -5621,6 +5621,46 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("SITE1", stub.LastTrustSite); } + /// Gitea #43: an admin trust decision is persisted to the central audit log with the real actor and outcome. + [Fact] + public void TrustServerCert_Admin_WritesCentralAuditRow() + { + var writer = Substitute.For(); + _services.AddSingleton(writer); + _services.AddSingleton(new StubCommunicationService()); + var actor = CreateActor(); + + actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Administrator")); + + ExpectMsg(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)); + } + + /// Gitea #43: a removal is audited too. + [Fact] + public void RemoveServerCert_Admin_WritesCentralAuditRow() + { + var writer = Substitute.For(); + _services.AddSingleton(writer); + _services.AddSingleton(new StubCommunicationService()); + var actor = CreateActor(); + + actor.Tell(Envelope(new RemoveServerCertCommand("AB12", "SITE1"), "Administrator")); + + ExpectMsg(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); + } + /// Verifies that remove server cert, when admin, relays to site. [Fact] public void RemoveServerCert_Admin_RelaysToSite()