feat(adminui): audit cert Trust/Untrust/Delete to ConfigAuditLog
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;
|
||||||
|
|
||||||
@@ -25,31 +27,61 @@ public sealed class CertificateStoreManager
|
|||||||
new(StringComparer.OrdinalIgnoreCase) { "trusted", "rejected" };
|
new(StringComparer.OrdinalIgnoreCase) { "trusted", "rejected" };
|
||||||
|
|
||||||
private readonly string _pkiRoot;
|
private readonly string _pkiRoot;
|
||||||
|
private readonly IAuditWriter _audit;
|
||||||
|
|
||||||
/// <summary>Production ctor — reads <c>OpcUa:PkiStoreRoot</c> (default <c>pki</c>).</summary>
|
/// <summary>Production ctor — reads <c>OpcUa:PkiStoreRoot</c> (default <c>pki</c>).</summary>
|
||||||
/// <param name="config">App configuration.</param>
|
/// <param name="config">App configuration.</param>
|
||||||
public CertificateStoreManager(IConfiguration config)
|
/// <param name="audit">The audit writer that persists Trust/Untrust/Delete actions to <c>ConfigAuditLog</c>.</param>
|
||||||
=> _pkiRoot = config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";
|
public CertificateStoreManager(IConfiguration config, IAuditWriter audit)
|
||||||
|
{
|
||||||
|
_pkiRoot = config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Test ctor — explicit PKI root.</summary>
|
/// <summary>Test ctor — explicit PKI root.</summary>
|
||||||
/// <param name="pkiRoot">The PKI store root directory.</param>
|
/// <param name="pkiRoot">The PKI store root directory.</param>
|
||||||
internal CertificateStoreManager(string pkiRoot) => _pkiRoot = pkiRoot;
|
/// <param name="audit">Optional audit writer; defaults to a no-op writer when omitted.</param>
|
||||||
|
internal CertificateStoreManager(string pkiRoot, IAuditWriter? audit = null)
|
||||||
|
{
|
||||||
|
_pkiRoot = pkiRoot;
|
||||||
|
_audit = audit ?? NoOpAuditWriter.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Moves a rejected peer cert into the trusted store.</summary>
|
/// <summary>Moves a rejected peer cert into the trusted store.</summary>
|
||||||
/// <param name="thumbprint">The cert thumbprint.</param>
|
/// <param name="thumbprint">The cert thumbprint.</param>
|
||||||
|
/// <param name="actor">The identity performing the action; recorded in the audit log.</param>
|
||||||
/// <returns>The action result.</returns>
|
/// <returns>The action result.</returns>
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Moves a trusted peer cert back into the rejected store.</summary>
|
/// <summary>Moves a trusted peer cert back into the rejected store.</summary>
|
||||||
/// <param name="thumbprint">The cert thumbprint.</param>
|
/// <param name="thumbprint">The cert thumbprint.</param>
|
||||||
|
/// <param name="actor">The identity performing the action; recorded in the audit log.</param>
|
||||||
/// <returns>The action result.</returns>
|
/// <returns>The action result.</returns>
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Deletes a cert from the named mutable store (<c>trusted</c> or <c>rejected</c>).</summary>
|
/// <summary>Deletes a cert from the named mutable store (<c>trusted</c> or <c>rejected</c>).</summary>
|
||||||
/// <param name="store">The store name.</param>
|
/// <param name="store">The store name.</param>
|
||||||
/// <param name="thumbprint">The cert thumbprint.</param>
|
/// <param name="thumbprint">The cert thumbprint.</param>
|
||||||
|
/// <param name="actor">The identity performing the action; recorded in the audit log.</param>
|
||||||
/// <returns>The action result.</returns>
|
/// <returns>The action result.</returns>
|
||||||
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 (!MutableStores.Contains(store)) return CertActionResult.Fail($"unknown store '{store}'");
|
||||||
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
|
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)
|
private CertActionResult Move(string fromSub, string toSub, string thumbprint)
|
||||||
{
|
{
|
||||||
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
|
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
|
||||||
|
|||||||
@@ -191,14 +191,15 @@ else
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var actor = authState.User.Identity?.Name ?? "system";
|
||||||
var result = p.Verb switch
|
var result = p.Verb switch
|
||||||
{
|
{
|
||||||
"trust" => CertManager.Trust(p.Thumbprint),
|
"trust" => CertManager.Trust(p.Thumbprint, actor),
|
||||||
"untrust" => CertManager.Untrust(p.Thumbprint),
|
"untrust" => CertManager.Untrust(p.Thumbprint, actor),
|
||||||
"delete" => p.Kind switch
|
"delete" => p.Kind switch
|
||||||
{
|
{
|
||||||
StoreKind.Trusted => CertManager.Delete("trusted", p.Thumbprint),
|
StoreKind.Trusted => CertManager.Delete("trusted", p.Thumbprint, actor),
|
||||||
StoreKind.Rejected => CertManager.Delete("rejected", p.Thumbprint),
|
StoreKind.Rejected => CertManager.Delete("rejected", p.Thumbprint, actor),
|
||||||
_ => CertActionResult.Fail($"cannot delete from {p.Kind}"),
|
_ => CertActionResult.Fail($"cannot delete from {p.Kind}"),
|
||||||
},
|
},
|
||||||
_ => CertActionResult.Fail("unknown action"),
|
_ => CertActionResult.Fail("unknown action"),
|
||||||
|
|||||||
+105
-10
@@ -9,12 +9,27 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Certificates;
|
|||||||
public sealed class CertificateStoreManagerTests : IDisposable
|
public sealed class CertificateStoreManagerTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _root;
|
private readonly string _root;
|
||||||
|
private readonly RecordingAuditWriter _audit;
|
||||||
private readonly CertificateStoreManager _sut;
|
private readonly CertificateStoreManager _sut;
|
||||||
|
|
||||||
public CertificateStoreManagerTests()
|
public CertificateStoreManagerTests()
|
||||||
{
|
{
|
||||||
_root = Path.Combine(Path.GetTempPath(), $"pki-test-{Guid.NewGuid():N}");
|
_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<ZB.MOM.WW.Audit.AuditEvent> Events { get; } = new();
|
||||||
|
|
||||||
|
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Events.Add(evt);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -84,7 +99,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
|
|||||||
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
|
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
|
||||||
var tp = SeedCert(rejectedCerts, "TestClient1");
|
var tp = SeedCert(rejectedCerts, "TestClient1");
|
||||||
|
|
||||||
var result = _sut.Trust(tp);
|
var result = _sut.Trust(tp, "tester");
|
||||||
|
|
||||||
result.Success.ShouldBeTrue(result.Error);
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should have left rejected");
|
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 trustedCerts = Path.Combine(_root, "trusted", "certs");
|
||||||
var tp = SeedCert(trustedCerts, "TestClient2");
|
var tp = SeedCert(trustedCerts, "TestClient2");
|
||||||
|
|
||||||
var result = _sut.Untrust(tp);
|
var result = _sut.Untrust(tp, "tester");
|
||||||
|
|
||||||
result.Success.ShouldBeTrue(result.Error);
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should have left trusted");
|
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 rejectedCerts = Path.Combine(_root, "rejected", "certs");
|
||||||
var tp = SeedCert(rejectedCerts, "TestClient3");
|
var tp = SeedCert(rejectedCerts, "TestClient3");
|
||||||
|
|
||||||
var result = _sut.Delete("rejected", tp);
|
var result = _sut.Delete("rejected", tp, "tester");
|
||||||
|
|
||||||
result.Success.ShouldBeTrue(result.Error);
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should be gone from rejected");
|
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 trustedCerts = Path.Combine(_root, "trusted", "certs");
|
||||||
var tp = SeedCert(trustedCerts, "TestClient4");
|
var tp = SeedCert(trustedCerts, "TestClient4");
|
||||||
|
|
||||||
var result = _sut.Delete("trusted", tp);
|
var result = _sut.Delete("trusted", tp, "tester");
|
||||||
|
|
||||||
result.Success.ShouldBeTrue(result.Error);
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should be gone from trusted");
|
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 tp = new string('A', 40);
|
||||||
|
|
||||||
var result = _sut.Trust(tp);
|
var result = _sut.Trust(tp, "tester");
|
||||||
|
|
||||||
result.Success.ShouldBeFalse();
|
result.Success.ShouldBeFalse();
|
||||||
CertCountInDir(Path.Combine(_root, "trusted", "certs")).ShouldBe(0);
|
CertCountInDir(Path.Combine(_root, "trusted", "certs")).ShouldBe(0);
|
||||||
@@ -142,7 +157,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Trust_PathTraversalThumbprint_Rejected()
|
public void Trust_PathTraversalThumbprint_Rejected()
|
||||||
{
|
{
|
||||||
var result = _sut.Trust("../../x");
|
var result = _sut.Trust("../../x", "tester");
|
||||||
|
|
||||||
result.Success.ShouldBeFalse();
|
result.Success.ShouldBeFalse();
|
||||||
result.Error.ShouldNotBeNull();
|
result.Error.ShouldNotBeNull();
|
||||||
@@ -158,7 +173,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
|
|||||||
var ownCerts = Path.Combine(_root, "own", "certs");
|
var ownCerts = Path.Combine(_root, "own", "certs");
|
||||||
var tp = SeedCert(ownCerts, "OwnCert");
|
var tp = SeedCert(ownCerts, "OwnCert");
|
||||||
|
|
||||||
var result = _sut.Delete("own", tp);
|
var result = _sut.Delete("own", tp, "tester");
|
||||||
|
|
||||||
result.Success.ShouldBeFalse();
|
result.Success.ShouldBeFalse();
|
||||||
result.Error.ShouldNotBeNull();
|
result.Error.ShouldNotBeNull();
|
||||||
@@ -178,7 +193,7 @@ public sealed class CertificateStoreManagerTests : IDisposable
|
|||||||
var srcFile = Directory.GetFiles(rejectedCerts).Single();
|
var srcFile = Directory.GetFiles(rejectedCerts).Single();
|
||||||
File.Copy(srcFile, Path.Combine(trustedCerts, Path.GetFileName(srcFile)));
|
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);
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("source (rejected) should no longer have it");
|
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("source (rejected) should no longer have it");
|
||||||
@@ -188,9 +203,89 @@ public sealed class CertificateStoreManagerTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Trust_NullOrEmptyThumbprint_Fails()
|
public void Trust_NullOrEmptyThumbprint_Fails()
|
||||||
{
|
{
|
||||||
var result = _sut.Trust("");
|
var result = _sut.Trust("", "tester");
|
||||||
|
|
||||||
result.Success.ShouldBeFalse();
|
result.Success.ShouldBeFalse();
|
||||||
result.Error.ShouldNotBeNull();
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user