diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Audit/ActorAuditWriter.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Audit/ActorAuditWriter.cs new file mode 100644 index 00000000..c860aa6a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Audit/ActorAuditWriter.cs @@ -0,0 +1,39 @@ +using Akka.Actor; +using Akka.Hosting; +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.OtOpcUa.ControlPlane; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Audit; + +/// +/// AdminUI-side adapter. Forwards canonical +/// s to the ControlPlane cluster +/// singleton (resolved from at construction, mirroring +/// AdminOperationsClient), which batches and persists them to ConfigAuditLog. +/// Emission is fire-and-forget via Tell: best-effort and never blocks or throws to the +/// caller, matching the audit-writer contract. +/// +public sealed class ActorAuditWriter : IAuditWriter +{ + private readonly IActorRef _proxy; + + /// Initializes a new instance of the class. + /// The actor registry used to resolve the AuditWriter singleton proxy. + public ActorAuditWriter(ActorRegistry registry) + { + _proxy = registry.Get(); + } + + /// + /// Forwards the event to the AuditWriter singleton via Tell and returns immediately. + /// The actual persistence happens asynchronously on the actor's next flush. + /// + /// The canonical audit event to persist. + /// Unused — enqueue is synchronous and non-blocking. + /// A completed task. + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + _proxy.Tell(evt); + return Task.CompletedTask; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Audit/CertAuditEvents.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Audit/CertAuditEvents.cs new file mode 100644 index 00000000..910bfb2b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Audit/CertAuditEvents.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using ZB.MOM.WW.Audit; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Audit; + +/// +/// Pure factory that maps a certificate-store action (Trust / Untrust / Delete of a peer +/// certificate) into a canonical for persistence via the shared +/// seam. No side effects: callers build the event then hand it to +/// the writer. +/// +public static class CertAuditEvents +{ + /// The audit stamped onto every certificate event. + public const string Category = "Certificate"; + + /// + /// Builds a canonical describing a certificate-store action. + /// + /// The action verb — e.g. Trust, Untrust, or Delete. + /// Stamped onto . + /// The certificate store the action targeted (carried in the details payload). + /// The subject certificate's thumbprint; stamped onto + /// and echoed in the details payload. + /// The identity that performed the action; stamped onto + /// . + /// if the action succeeded (Outcome + /// ); otherwise (Outcome + /// ). + /// On failure, the error text carried in the details payload; ignored on + /// success (the details payload serializes for the error field). + /// A fully populated with a fresh + /// and set to now (UTC). + public static AuditEvent Build( + string action, string store, string thumbprint, string actor, bool success, string? error) + { + var detailsJson = JsonSerializer.Serialize(new + { + store, + thumbprint, + error = success ? null : error, + }); + + return new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTimeOffset.UtcNow, + Actor = actor, + Action = action, + Category = Category, + SourceNode = thumbprint, + Outcome = success ? AuditOutcome.Success : AuditOutcome.Failure, + DetailsJson = detailsJson, + }; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 422a7dab..1b5fda01 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -54,6 +54,9 @@ public static class EndpointRouteBuilderExtensions // Certificate-store actions (trust/untrust/delete) for the /certificates page. services.AddSingleton(); + // Structured audit-event sink: forwards AuditEvents to the ControlPlane AuditWriter singleton. + services.AddSingleton(); + return services; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Audit/ActorAuditWriterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Audit/ActorAuditWriterTests.cs new file mode 100644 index 00000000..56f264c0 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Audit/ActorAuditWriterTests.cs @@ -0,0 +1,44 @@ +using Akka.Hosting; +using Akka.TestKit.Xunit2; +using Shouldly; +using Xunit; +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Audit; +using ZB.MOM.WW.OtOpcUa.ControlPlane; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Audit; + +/// +/// Unit tests for , the AdminUI-side +/// adapter that forwards canonical audit events to the cluster-singleton +/// proxy resolved from the . +/// +public sealed class ActorAuditWriterTests : TestKit +{ + /// WriteAsync forwards the event verbatim to the registered AuditWriter singleton. + [Fact] + public async Task WriteAsync_tells_the_registered_singleton() + { + var probe = CreateTestProbe("audit-writer"); + var registry = new ActorRegistry(); + registry.Register(probe.Ref); + + var writer = new ActorAuditWriter(registry); + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTimeOffset.UtcNow, + Actor = "alice", + Action = "Trust", + Category = "Certificate", + SourceNode = "AABBCC", + Outcome = AuditOutcome.Success, + DetailsJson = "{}", + }; + + await writer.WriteAsync(evt); + + var received = probe.ExpectMsg(TimeSpan.FromSeconds(3)); + received.ShouldBe(evt); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Audit/CertAuditEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Audit/CertAuditEventsTests.cs new file mode 100644 index 00000000..68cf6f7b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Audit/CertAuditEventsTests.cs @@ -0,0 +1,108 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Audit; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Audit; + +/// +/// Unit tests for , the pure factory that maps a certificate +/// trust/untrust/delete action into a canonical . +/// +public sealed class CertAuditEventsTests +{ + private const string Thumb = "AABBCCDDEEFF00112233445566778899AABBCCDD"; + + /// A successful Trust maps Category/Action/SourceNode/Actor and Success, with the + /// thumbprint carried in the details payload. + [Fact] + public void Build_trust_success_maps_core_fields() + { + var evt = CertAuditEvents.Build("Trust", "rejected→trusted", Thumb, "alice", success: true, error: null); + + evt.Category.ShouldBe("Certificate"); + evt.Action.ShouldBe("Trust"); + evt.SourceNode.ShouldBe(Thumb); + evt.Actor.ShouldBe("alice"); + evt.Outcome.ShouldBe(AuditOutcome.Success); + evt.EventId.ShouldNotBe(Guid.Empty); + evt.DetailsJson.ShouldNotBeNull(); + evt.DetailsJson!.ShouldContain(Thumb); + } + + /// A successful Untrust carries the Untrust action and Success outcome. + [Fact] + public void Build_untrust_success_maps_action_and_outcome() + { + var evt = CertAuditEvents.Build("Untrust", "trusted", Thumb, "bob", success: true, error: null); + + evt.Action.ShouldBe("Untrust"); + evt.Outcome.ShouldBe(AuditOutcome.Success); + evt.DetailsJson!.ShouldContain(Thumb); + } + + /// A successful Delete carries the Delete action and Success outcome. + [Fact] + public void Build_delete_success_maps_action_and_outcome() + { + var evt = CertAuditEvents.Build("Delete", "rejected", Thumb, "carol", success: true, error: null); + + evt.Action.ShouldBe("Delete"); + evt.Outcome.ShouldBe(AuditOutcome.Success); + } + + /// A failed Trust maps the Failure outcome and surfaces the error text in the details + /// payload. + [Fact] + public void Build_trust_failure_maps_failure_and_carries_error() + { + var evt = CertAuditEvents.Build( + "Trust", "rejected", Thumb, "alice", success: false, error: "certificate not found in rejected"); + + evt.Action.ShouldBe("Trust"); + evt.Outcome.ShouldBe(AuditOutcome.Failure); + evt.DetailsJson!.ShouldContain("certificate not found in rejected"); + } + + /// A failed Delete maps the Failure outcome. + [Fact] + public void Build_delete_failure_maps_failure() + { + var evt = CertAuditEvents.Build( + "Delete", "rejected", Thumb, "dave", success: false, error: "delete failed: access denied"); + + evt.Action.ShouldBe("Delete"); + evt.Outcome.ShouldBe(AuditOutcome.Failure); + evt.DetailsJson!.ShouldContain("delete failed: access denied"); + } + + /// A failed Untrust maps the Failure outcome. + [Fact] + public void Build_untrust_failure_maps_failure() + { + var evt = CertAuditEvents.Build( + "Untrust", "trusted", Thumb, "erin", success: false, error: "store write failed"); + + evt.Action.ShouldBe("Untrust"); + evt.Outcome.ShouldBe(AuditOutcome.Failure); + evt.DetailsJson!.ShouldContain("store write failed"); + } + + /// On success the error text is omitted from the details payload (it serializes null). + [Fact] + public void Build_success_omits_error_text() + { + var evt = CertAuditEvents.Build("Trust", "rejected", Thumb, "alice", success: true, error: "ignored"); + + evt.DetailsJson!.ShouldNotContain("ignored"); + } + + /// The public Category constant matches the value stamped onto built events. + [Fact] + public void Category_constant_matches_built_event() + { + CertAuditEvents.Category.ShouldBe("Certificate"); + CertAuditEvents.Build("Trust", "s", Thumb, "a", success: true, error: null).Category + .ShouldBe(CertAuditEvents.Category); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj index d6b5f887..4ccd43cf 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj @@ -12,6 +12,10 @@ + + + all runtime; build; native; contentfiles; analyzers; buildtransitive