diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs
deleted file mode 100644
index ed12a26f..00000000
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using ZB.MOM.WW.OtOpcUa.Commons.Types;
-
-namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit;
-
-///
-/// Cluster-broadcast audit event consumed by the AuditWriterActor singleton, which
-/// batches and idempotently inserts into ConfigAuditLog.
-///
-public sealed record AuditEvent(
- Guid EventId,
- string Category,
- string Action,
- string Actor,
- DateTime OccurredAtUtc,
- string? DetailsJson,
- NodeId SourceNode,
- CorrelationId CorrelationId);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj
index e92789a1..f90281ea 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditOutcomeMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditOutcomeMapper.cs
new file mode 100644
index 00000000..c3e15072
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditOutcomeMapper.cs
@@ -0,0 +1,44 @@
+using ZB.MOM.WW.Audit;
+
+namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
+
+///
+/// Maps OtOpcUa's audit Action vocabulary onto the canonical
+/// . The vocabulary is the set of values documented on
+/// ConfigAuditLog.EventType: config verbs are ,
+/// the two authorization-rejection events are . OtOpcUa
+/// emits no events today.
+///
+///
+/// Pure function — no live emit sites construct an in production
+/// (the structured audit path is dormant; all live audit flows through the bespoke stored
+/// procedure path). This helper exists so that when the structured path is wired up, the
+/// required Outcome field is derived consistently from the action verb. Tested, not
+/// yet exercised in production.
+///
+public static class AuditOutcomeMapper
+{
+ ///
+ /// Derives the canonical for an OtOpcUa audit action verb.
+ /// Unknown verbs default to (config writes are the
+ /// overwhelming majority and the only non-success cases are the two explicit
+ /// authorization rejections enumerated below).
+ ///
+ /// The audit action verb (e.g. DraftCreated, OpcUaAccessDenied).
+ /// The mapped outcome.
+ public static AuditOutcome FromAction(string action) => action switch
+ {
+ "OpcUaAccessDenied" or "CrossClusterNamespaceAttempt" => AuditOutcome.Denied,
+ "DraftCreated"
+ or "DraftEdited"
+ or "Published"
+ or "RolledBack"
+ or "NodeApplied"
+ or "ClusterCreated"
+ or "NodeAdded"
+ or "CredentialAdded"
+ or "CredentialDisabled"
+ or "ExternalIdReleased" => AuditOutcome.Success,
+ _ => AuditOutcome.Success,
+ };
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs
index 96922799..00099bca 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs
@@ -1,7 +1,7 @@
using Akka.Actor;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -19,8 +19,13 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
/// UX_ConfigAuditLog_EventId (cross-restart safety — a retry of an already-flushed
/// batch hits the constraint and we drop the duplicate insert without losing the rest of
/// the batch).
+///
+/// Implements the shared seam: is a
+/// best-effort, never-throwing entry point that simply Tells this actor and returns
+/// a completed task, so non-Akka callers can emit canonical audit events through the same
+/// batching/dedup pipeline as in-cluster Tell traffic.
///
-public sealed class AuditWriterActor : ReceiveActor, IWithTimers
+public sealed class AuditWriterActor : ReceiveActor, IWithTimers, IAuditWriter
{
public const int FlushBatchSize = 500;
public static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(5);
@@ -52,6 +57,23 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers
Timers.StartPeriodicTimer("flush", Flush.Instance, FlushInterval);
}
+ ///
+ /// seam. Best-effort and never throws: routes the event onto this
+ /// actor's mailbox via Tell (thread-safe from any caller) so it flows through the same
+ /// batching + dedup pipeline as in-cluster traffic, then returns immediately. The actual
+ /// persistence happens asynchronously on the next flush; a write failure there is logged and
+ /// the batch dropped (per the best-effort audit contract).
+ ///
+ /// The canonical audit event to persist.
+ /// Unused — enqueue is synchronous and non-blocking.
+ /// A completed task.
+ public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
+ {
+ // Akka Tell is safe to call from any thread and never throws to the caller.
+ Self.Tell(evt);
+ return Task.CompletedTask;
+ }
+
private void HandleEvent(AuditEvent evt)
{
// In-buffer dedup. Last write wins on duplicate EventId within the batch — events
@@ -74,13 +96,13 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers
{
db.ConfigAuditLogs.Add(new ConfigAuditLog
{
- Timestamp = evt.OccurredAtUtc,
+ Timestamp = evt.OccurredAtUtc.UtcDateTime,
Principal = evt.Actor,
EventType = $"{evt.Category}:{evt.Action}",
- NodeId = evt.SourceNode.Value,
+ NodeId = evt.SourceNode,
DetailsJson = evt.DetailsJson,
EventId = evt.EventId,
- CorrelationId = evt.CorrelationId.Value,
+ CorrelationId = evt.CorrelationId,
});
}
db.SaveChanges();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
index 57565199..6993960c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
@@ -15,6 +15,7 @@
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs
index 60a5002c..685e2cba 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AuditWriterActorTests.cs
@@ -1,8 +1,7 @@
using Akka.Actor;
using Shouldly;
using Xunit;
-using ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit;
-using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
@@ -11,15 +10,18 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
{
private static AuditEvent NewEvent(Guid eventId, string action = "Edit", string actor = "joe") =>
- new(
- eventId,
- "Config",
- action,
- actor,
- DateTime.UtcNow,
- DetailsJson: "{\"field\":\"value\"}",
- SourceNode: NodeId.Parse("node-a"),
- CorrelationId: CorrelationId.NewId());
+ new()
+ {
+ EventId = eventId,
+ Category = "Config",
+ Action = action,
+ Actor = actor,
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Outcome = AuditOutcomeMapper.FromAction(action),
+ DetailsJson = "{\"field\":\"value\"}",
+ SourceNode = "node-a",
+ CorrelationId = Guid.NewGuid(),
+ };
/// Verifies that buffered events flush when count threshold is reached.
[Fact]
@@ -102,4 +104,91 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
row.EventType.ShouldBe("Config:Edit");
row.NodeId.ShouldBe("node-a");
}
+
+ /// Verifies that a null SourceNode/CorrelationId on the canonical event persists as null
+ /// (the canonical fields are now nullable; the actor must not assume they are set).
+ [Fact]
+ public void Null_sourceNode_and_correlationId_persist_as_null()
+ {
+ var dbFactory = NewInMemoryDbFactory();
+ var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
+
+ actor.Tell(new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ Category = "Config",
+ Action = "Published",
+ Actor = "joe",
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Outcome = AuditOutcome.Success,
+ SourceNode = null,
+ CorrelationId = null,
+ });
+
+ Watch(actor);
+ actor.Tell(PoisonPill.Instance);
+ ExpectTerminated(actor);
+
+ using var db = dbFactory.CreateDbContext();
+ var row = db.ConfigAuditLogs.Single();
+ row.NodeId.ShouldBeNull();
+ row.CorrelationId.ShouldBeNull();
+ }
+
+ /// Verifies the IAuditWriter.WriteAsync seam is best-effort: it completes
+ /// synchronously, never throws, and routes the event onto the actor's own mailbox
+ /// (Self.Tell) — i.e. the same buffer + dedup + flush pipeline asserted by the Tell
+ /// tests above. Reaches the concrete instance via a TestActorRef.
+ [Fact]
+ public async Task WriteAsync_is_best_effort_and_routes_onto_the_actor_mailbox()
+ {
+ var dbFactory = NewInMemoryDbFactory();
+ var testRef = ActorOfAsTestActorRef(AuditWriterActor.Props(dbFactory));
+ IAuditWriter writer = testRef.UnderlyingActor;
+
+ var task = writer.WriteAsync(NewEvent(Guid.NewGuid(), action: "Published"));
+ task.IsCompletedSuccessfully.ShouldBeTrue("WriteAsync must be best-effort and complete synchronously");
+ await Should.NotThrowAsync(async () => await task);
+ }
+
+ /// Verifies that an AuditEvent delivered to the actor's mailbox — which is exactly
+ /// what the WriteAsync seam does via Self.Tell — is buffered and persisted with the canonical
+ /// fields intact.
+ [Fact]
+ public void Mailbox_delivery_persists_the_canonical_fields()
+ {
+ var dbFactory = NewInMemoryDbFactory();
+ var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
+
+ var eventId = Guid.NewGuid();
+ actor.Tell(NewEvent(eventId, action: "Published"));
+
+ Watch(actor);
+ actor.Tell(PoisonPill.Instance);
+ ExpectTerminated(actor);
+
+ using var db = dbFactory.CreateDbContext();
+ var row = db.ConfigAuditLogs.Single();
+ row.EventId.ShouldBe(eventId);
+ row.EventType.ShouldBe("Config:Published");
+ row.NodeId.ShouldBe("node-a");
+ }
+
+ /// Verifies the Outcome derivation table: config verbs → Success, the two
+ /// authorization-rejection events → Denied.
+ [Theory]
+ [InlineData("DraftCreated", AuditOutcome.Success)]
+ [InlineData("DraftEdited", AuditOutcome.Success)]
+ [InlineData("Published", AuditOutcome.Success)]
+ [InlineData("RolledBack", AuditOutcome.Success)]
+ [InlineData("NodeApplied", AuditOutcome.Success)]
+ [InlineData("ClusterCreated", AuditOutcome.Success)]
+ [InlineData("NodeAdded", AuditOutcome.Success)]
+ [InlineData("CredentialAdded", AuditOutcome.Success)]
+ [InlineData("CredentialDisabled", AuditOutcome.Success)]
+ [InlineData("ExternalIdReleased", AuditOutcome.Success)]
+ [InlineData("OpcUaAccessDenied", AuditOutcome.Denied)]
+ [InlineData("CrossClusterNamespaceAttempt", AuditOutcome.Denied)]
+ public void Outcome_is_derived_from_the_action_vocabulary(string action, AuditOutcome expected) =>
+ AuditOutcomeMapper.FromAction(action).ShouldBe(expected);
}