feat(adminui): IAuditWriter adapter + cert-action audit-event factory
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// AdminUI-side <see cref="IAuditWriter" /> adapter. Forwards canonical
|
||||
/// <see cref="AuditEvent" />s to the ControlPlane <see cref="AuditWriterActorKey" /> cluster
|
||||
/// singleton (resolved from <see cref="ActorRegistry" /> at construction, mirroring
|
||||
/// <c>AdminOperationsClient</c>), which batches and persists them to <c>ConfigAuditLog</c>.
|
||||
/// Emission is fire-and-forget via <c>Tell</c>: best-effort and never blocks or throws to the
|
||||
/// caller, matching the audit-writer contract.
|
||||
/// </summary>
|
||||
public sealed class ActorAuditWriter : IAuditWriter
|
||||
{
|
||||
private readonly IActorRef _proxy;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ActorAuditWriter" /> class.</summary>
|
||||
/// <param name="registry">The actor registry used to resolve the AuditWriter singleton proxy.</param>
|
||||
public ActorAuditWriter(ActorRegistry registry)
|
||||
{
|
||||
_proxy = registry.Get<AuditWriterActorKey>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards the event to the AuditWriter singleton via <c>Tell</c> and returns immediately.
|
||||
/// The actual persistence happens asynchronously on the actor's next flush.
|
||||
/// </summary>
|
||||
/// <param name="evt">The canonical audit event to persist.</param>
|
||||
/// <param name="ct">Unused — enqueue is synchronous and non-blocking.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
_proxy.Tell(evt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Pure factory that maps a certificate-store action (Trust / Untrust / Delete of a peer
|
||||
/// certificate) into a canonical <see cref="AuditEvent" /> for persistence via the shared
|
||||
/// <see cref="IAuditWriter" /> seam. No side effects: callers build the event then hand it to
|
||||
/// the writer.
|
||||
/// </summary>
|
||||
public static class CertAuditEvents
|
||||
{
|
||||
/// <summary>The audit <see cref="AuditEvent.Category" /> stamped onto every certificate event.</summary>
|
||||
public const string Category = "Certificate";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a canonical <see cref="AuditEvent" /> describing a certificate-store action.
|
||||
/// </summary>
|
||||
/// <param name="action">The action verb — e.g. <c>Trust</c>, <c>Untrust</c>, or <c>Delete</c>.
|
||||
/// Stamped onto <see cref="AuditEvent.Action" />.</param>
|
||||
/// <param name="store">The certificate store the action targeted (carried in the details payload).</param>
|
||||
/// <param name="thumbprint">The subject certificate's thumbprint; stamped onto
|
||||
/// <see cref="AuditEvent.SourceNode" /> and echoed in the details payload.</param>
|
||||
/// <param name="actor">The identity that performed the action; stamped onto
|
||||
/// <see cref="AuditEvent.Actor" />.</param>
|
||||
/// <param name="success"><see langword="true" /> if the action succeeded (Outcome
|
||||
/// <see cref="AuditOutcome.Success" />); otherwise <see langword="false" /> (Outcome
|
||||
/// <see cref="AuditOutcome.Failure" />).</param>
|
||||
/// <param name="error">On failure, the error text carried in the details payload; ignored on
|
||||
/// success (the details payload serializes <see langword="null" /> for the error field).</param>
|
||||
/// <returns>A fully populated <see cref="AuditEvent" /> with a fresh <see cref="AuditEvent.EventId" />
|
||||
/// and <see cref="AuditEvent.OccurredAtUtc" /> set to now (UTC).</returns>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,9 @@ public static class EndpointRouteBuilderExtensions
|
||||
// Certificate-store actions (trust/untrust/delete) for the /certificates page.
|
||||
services.AddSingleton<Certificates.CertificateStoreManager>();
|
||||
|
||||
// Structured audit-event sink: forwards AuditEvents to the ControlPlane AuditWriter singleton.
|
||||
services.AddSingleton<ZB.MOM.WW.Audit.IAuditWriter, Audit.ActorAuditWriter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ActorAuditWriter" />, the AdminUI-side <see cref="IAuditWriter" />
|
||||
/// adapter that forwards canonical audit events to the cluster-singleton
|
||||
/// <see cref="AuditWriterActorKey" /> proxy resolved from the <see cref="ActorRegistry" />.
|
||||
/// </summary>
|
||||
public sealed class ActorAuditWriterTests : TestKit
|
||||
{
|
||||
/// <summary>WriteAsync forwards the event verbatim to the registered AuditWriter singleton.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAsync_tells_the_registered_singleton()
|
||||
{
|
||||
var probe = CreateTestProbe("audit-writer");
|
||||
var registry = new ActorRegistry();
|
||||
registry.Register<AuditWriterActorKey>(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<AuditEvent>(TimeSpan.FromSeconds(3));
|
||||
received.ShouldBe(evt);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CertAuditEvents" />, the pure factory that maps a certificate
|
||||
/// trust/untrust/delete action into a canonical <see cref="AuditEvent" />.
|
||||
/// </summary>
|
||||
public sealed class CertAuditEventsTests
|
||||
{
|
||||
private const string Thumb = "AABBCCDDEEFF00112233445566778899AABBCCDD";
|
||||
|
||||
/// <summary>A successful Trust maps Category/Action/SourceNode/Actor and Success, with the
|
||||
/// thumbprint carried in the details payload.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A successful Untrust carries the Untrust action and Success outcome.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A successful Delete carries the Delete action and Success outcome.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A failed Trust maps the Failure outcome and surfaces the error text in the details
|
||||
/// payload.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>A failed Delete maps the Failure outcome.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>A failed Untrust maps the Failure outcome.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>On success the error text is omitted from the details payload (it serializes null).</summary>
|
||||
[Fact]
|
||||
public void Build_success_omits_error_text()
|
||||
{
|
||||
var evt = CertAuditEvents.Build("Trust", "rejected", Thumb, "alice", success: true, error: "ignored");
|
||||
|
||||
evt.DetailsJson!.ShouldNotContain("ignored");
|
||||
}
|
||||
|
||||
/// <summary>The public Category constant matches the value stamped onto built events.</summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<!-- Akka.TestKit.Xunit2 ties this project to xunit v2 (TestKit isn't ported to v3 yet);
|
||||
used by the IAuditWriter adapter test to register a TestProbe in an ActorRegistry. -->
|
||||
<PackageReference Include="Akka.Hosting"/>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
Reference in New Issue
Block a user