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