diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs
index faba0946..15180c94 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs
@@ -1,6 +1,8 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;
@@ -25,31 +27,61 @@ public sealed class CertificateStoreManager
new(StringComparer.OrdinalIgnoreCase) { "trusted", "rejected" };
private readonly string _pkiRoot;
+ private readonly IAuditWriter _audit;
/// Production ctor — reads OpcUa:PkiStoreRoot (default pki).
/// App configuration.
- public CertificateStoreManager(IConfiguration config)
- => _pkiRoot = config.GetValue("OpcUa:PkiStoreRoot") ?? "pki";
+ /// The audit writer that persists Trust/Untrust/Delete actions to ConfigAuditLog.
+ public CertificateStoreManager(IConfiguration config, IAuditWriter audit)
+ {
+ _pkiRoot = config.GetValue("OpcUa:PkiStoreRoot") ?? "pki";
+ _audit = audit;
+ }
/// Test ctor — explicit PKI root.
/// The PKI store root directory.
- internal CertificateStoreManager(string pkiRoot) => _pkiRoot = pkiRoot;
+ /// Optional audit writer; defaults to a no-op writer when omitted.
+ internal CertificateStoreManager(string pkiRoot, IAuditWriter? audit = null)
+ {
+ _pkiRoot = pkiRoot;
+ _audit = audit ?? NoOpAuditWriter.Instance;
+ }
/// Moves a rejected peer cert into the trusted store.
/// The cert thumbprint.
+ /// The identity performing the action; recorded in the audit log.
/// The action result.
- public CertActionResult Trust(string thumbprint) => Move("rejected", "trusted", thumbprint);
+ public CertActionResult Trust(string thumbprint, string actor)
+ {
+ var r = Move("rejected", "trusted", thumbprint);
+ Audit("Trust", "rejected→trusted", thumbprint, actor, r);
+ return r;
+ }
/// Moves a trusted peer cert back into the rejected store.
/// The cert thumbprint.
+ /// The identity performing the action; recorded in the audit log.
/// The action result.
- public CertActionResult Untrust(string thumbprint) => Move("trusted", "rejected", thumbprint);
+ public CertActionResult Untrust(string thumbprint, string actor)
+ {
+ var r = Move("trusted", "rejected", thumbprint);
+ Audit("Untrust", "trusted→rejected", thumbprint, actor, r);
+ return r;
+ }
/// Deletes a cert from the named mutable store (trusted or rejected).
/// The store name.
/// The cert thumbprint.
+ /// The identity performing the action; recorded in the audit log.
/// The action result.
- public CertActionResult Delete(string store, string thumbprint)
+ public CertActionResult Delete(string store, string thumbprint, string actor)
+ {
+ var r = DeleteCore(store, thumbprint);
+ Audit("Delete", store, thumbprint, actor, r);
+ return r;
+ }
+
+ private CertActionResult DeleteCore(string store, string thumbprint)
{
if (!MutableStores.Contains(store)) return CertActionResult.Fail($"unknown store '{store}'");
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
@@ -66,6 +98,12 @@ public sealed class CertificateStoreManager
}
}
+ // Fire-and-forget: the writer enqueues synchronously and returns a completed task, so the
+ // store mutation stays synchronous. Every return path (success + every failure) is audited
+ // because Trust/Untrust/Delete each route their single result through here.
+ private void Audit(string action, string store, string thumbprint, string actor, CertActionResult r)
+ => _audit.WriteAsync(CertAuditEvents.Build(action, store, thumbprint, actor, r.Success, r.Error));
+
private CertActionResult Move(string fromSub, string toSub, string thumbprint)
{
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor
index b0be8210..3f2b6c25 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor
@@ -191,14 +191,15 @@ else
return;
}
+ var actor = authState.User.Identity?.Name ?? "system";
var result = p.Verb switch
{
- "trust" => CertManager.Trust(p.Thumbprint),
- "untrust" => CertManager.Untrust(p.Thumbprint),
+ "trust" => CertManager.Trust(p.Thumbprint, actor),
+ "untrust" => CertManager.Untrust(p.Thumbprint, actor),
"delete" => p.Kind switch
{
- StoreKind.Trusted => CertManager.Delete("trusted", p.Thumbprint),
- StoreKind.Rejected => CertManager.Delete("rejected", p.Thumbprint),
+ StoreKind.Trusted => CertManager.Delete("trusted", p.Thumbprint, actor),
+ StoreKind.Rejected => CertManager.Delete("rejected", p.Thumbprint, actor),
_ => CertActionResult.Fail($"cannot delete from {p.Kind}"),
},
_ => CertActionResult.Fail("unknown action"),
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs
index 97b35ead..3e9ecd1b 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs
@@ -9,12 +9,27 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Certificates;
public sealed class CertificateStoreManagerTests : IDisposable
{
private readonly string _root;
+ private readonly RecordingAuditWriter _audit;
private readonly CertificateStoreManager _sut;
public CertificateStoreManagerTests()
{
_root = Path.Combine(Path.GetTempPath(), $"pki-test-{Guid.NewGuid():N}");
- _sut = new CertificateStoreManager(_root);
+ _audit = new RecordingAuditWriter();
+ _sut = new CertificateStoreManager(_root, _audit);
+ }
+
+ // ── Recording audit-writer fake ───────────────────────────────────────────
+
+ private sealed class RecordingAuditWriter : ZB.MOM.WW.Audit.IAuditWriter
+ {
+ public List Events { get; } = new();
+
+ public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default)
+ {
+ Events.Add(evt);
+ return Task.CompletedTask;
+ }
}
public void Dispose()
@@ -84,7 +99,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
var tp = SeedCert(rejectedCerts, "TestClient1");
- var result = _sut.Trust(tp);
+ var result = _sut.Trust(tp, "tester");
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should have left rejected");
@@ -97,7 +112,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
var trustedCerts = Path.Combine(_root, "trusted", "certs");
var tp = SeedCert(trustedCerts, "TestClient2");
- var result = _sut.Untrust(tp);
+ var result = _sut.Untrust(tp, "tester");
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should have left trusted");
@@ -110,7 +125,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
var tp = SeedCert(rejectedCerts, "TestClient3");
- var result = _sut.Delete("rejected", tp);
+ var result = _sut.Delete("rejected", tp, "tester");
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should be gone from rejected");
@@ -122,7 +137,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
var trustedCerts = Path.Combine(_root, "trusted", "certs");
var tp = SeedCert(trustedCerts, "TestClient4");
- var result = _sut.Delete("trusted", tp);
+ var result = _sut.Delete("trusted", tp, "tester");
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should be gone from trusted");
@@ -133,7 +148,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
{
var tp = new string('A', 40);
- var result = _sut.Trust(tp);
+ var result = _sut.Trust(tp, "tester");
result.Success.ShouldBeFalse();
CertCountInDir(Path.Combine(_root, "trusted", "certs")).ShouldBe(0);
@@ -142,7 +157,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
[Fact]
public void Trust_PathTraversalThumbprint_Rejected()
{
- var result = _sut.Trust("../../x");
+ var result = _sut.Trust("../../x", "tester");
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
@@ -158,7 +173,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
var ownCerts = Path.Combine(_root, "own", "certs");
var tp = SeedCert(ownCerts, "OwnCert");
- var result = _sut.Delete("own", tp);
+ var result = _sut.Delete("own", tp, "tester");
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
@@ -178,7 +193,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
var srcFile = Directory.GetFiles(rejectedCerts).Single();
File.Copy(srcFile, Path.Combine(trustedCerts, Path.GetFileName(srcFile)));
- var result = _sut.Trust(tp);
+ var result = _sut.Trust(tp, "tester");
result.Success.ShouldBeTrue(result.Error);
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("source (rejected) should no longer have it");
@@ -188,9 +203,89 @@ public sealed class CertificateStoreManagerTests : IDisposable
[Fact]
public void Trust_NullOrEmptyThumbprint_Fails()
{
- var result = _sut.Trust("");
+ var result = _sut.Trust("", "tester");
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
}
+
+ // ── Audit tests ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Trust_success_writes_one_success_audit_event()
+ {
+ var rejectedCerts = Path.Combine(_root, "rejected", "certs");
+ var tp = SeedCert(rejectedCerts, "AuditTrust");
+
+ var result = _sut.Trust(tp, "alice");
+
+ result.Success.ShouldBeTrue(result.Error);
+ var evt = _audit.Events.ShouldHaveSingleItem();
+ evt.Action.ShouldBe("Trust");
+ evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Success);
+ evt.SourceNode.ShouldBe(tp);
+ evt.Actor.ShouldBe("alice");
+ }
+
+ [Fact]
+ public void Untrust_success_writes_one_success_audit_event()
+ {
+ var trustedCerts = Path.Combine(_root, "trusted", "certs");
+ var tp = SeedCert(trustedCerts, "AuditUntrust");
+
+ var result = _sut.Untrust(tp, "alice");
+
+ result.Success.ShouldBeTrue(result.Error);
+ var evt = _audit.Events.ShouldHaveSingleItem();
+ evt.Action.ShouldBe("Untrust");
+ evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Success);
+ evt.SourceNode.ShouldBe(tp);
+ evt.Actor.ShouldBe("alice");
+ }
+
+ [Fact]
+ public void Delete_success_writes_one_success_audit_event()
+ {
+ var rejectedCerts = Path.Combine(_root, "rejected", "certs");
+ var tp = SeedCert(rejectedCerts, "AuditDelete");
+
+ var result = _sut.Delete("rejected", tp, "alice");
+
+ result.Success.ShouldBeTrue(result.Error);
+ var evt = _audit.Events.ShouldHaveSingleItem();
+ evt.Action.ShouldBe("Delete");
+ evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Success);
+ evt.SourceNode.ShouldBe(tp);
+ evt.Actor.ShouldBe("alice");
+ }
+
+ [Fact]
+ public void Delete_not_found_writes_one_failure_audit_event()
+ {
+ var absent = new string('0', 40);
+
+ var result = _sut.Delete("trusted", absent, "bob");
+
+ result.Success.ShouldBeFalse();
+ var evt = _audit.Events.ShouldHaveSingleItem();
+ evt.Action.ShouldBe("Delete");
+ evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Failure);
+ evt.SourceNode.ShouldBe(absent);
+ evt.Actor.ShouldBe("bob");
+ }
+
+ [Fact]
+ public void Trust_not_found_writes_one_failure_audit_event()
+ {
+ var absent = new string('0', 40);
+
+ var result = _sut.Trust(absent, "bob");
+
+ result.Success.ShouldBeFalse();
+ var evt = _audit.Events.ShouldHaveSingleItem();
+ evt.Action.ShouldBe("Trust");
+ evt.Outcome.ShouldBe(ZB.MOM.WW.Audit.AuditOutcome.Failure);
+ evt.SourceNode.ShouldBe(absent);
+ evt.Actor.ShouldBe("bob");
+ }
}