From 933dd1a87499440ae05e4d853d92a20ab424fe20 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 09:53:12 -0400 Subject: [PATCH] feat(audit): OtOpcUa adopt canonical ZB.MOM.WW.Audit.AuditEvent + AuditWriterActor:IAuditWriter + Outcome derivation (Task 2.1) Deep-adopt the shared audit record. Deletes the bespoke 8-field positional Commons AuditEvent and repoints the writer path at ZB.MOM.WW.Audit.AuditEvent (0.1.0, feed-mapped via dohertj2-gitea). Adds the package reference to both Commons and ControlPlane. - AuditWriterActor now implements IAuditWriter: WriteAsync(evt, ct) is a best-effort, never-throwing entry point that Self.Tell()s the event onto the same batching/dedup/flush pipeline and returns Task.CompletedTask. Existing Receive + 500/5s batching + two-layer dedup unchanged. - Flush mapping updated for the canonical field types: OccurredAtUtc is now DateTimeOffset (.UtcDateTime into the datetime2 column), SourceNode is string? (was NodeId.Value), CorrelationId is Guid? (stored null when null). Outcome is NOT yet persisted (column lands in Task 2.2). - New AuditOutcomeMapper.FromAction maps the OtOpcUa action vocabulary to the required canonical Outcome: OpcUaAccessDenied / CrossClusterNamespaceAttempt -> Denied; config verbs (DraftCreated/Edited, Published, RolledBack, NodeApplied, ClusterCreated, NodeAdded, CredentialAdded/Disabled, ExternalIdReleased) -> Success. OtOpcUa emits no Failure events. The Akka message shape changed, but the structured audit path is dormant (zero production emit/Tell sites; all live audit flows through the bespoke SP path), so there is no rolling-deploy wire-compat concern. Tested-not-exercised by design. ControlPlane.Tests: 44/44 green (AuditWriterActor suite rewritten to construct the canonical record + assert the Outcome derivation table + the WriteAsync best-effort/mailbox-routing contract + null SourceNode/CorrelationId handling). --- .../Messages/Audit/AuditEvent.cs | 17 --- .../ZB.MOM.WW.OtOpcUa.Commons.csproj | 1 + .../Audit/AuditOutcomeMapper.cs | 44 +++++++ .../Audit/AuditWriterActor.cs | 32 ++++- .../ZB.MOM.WW.OtOpcUa.ControlPlane.csproj | 1 + .../AuditWriterActorTests.cs | 111 ++++++++++++++++-- 6 files changed, 173 insertions(+), 33 deletions(-) delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditOutcomeMapper.cs 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); }