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