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); }