diff --git a/docs/v2/lmx-followups.md b/docs/v2/lmx-followups.md
index 878e681..47c729f 100644
--- a/docs/v2/lmx-followups.md
+++ b/docs/v2/lmx-followups.md
@@ -41,17 +41,22 @@ can't write a `Tune` attribute unless it also carries `WriteTune`.
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
that authz stays at the server layer and never delegates to driver-specific auth.
-## 3. Admin UI client-cert trust management
+## 3. Admin UI client-cert trust management — **DONE (PR 28)**
-**Status**: Server side auto-accepts untrusted client certs when the
-`AutoAcceptUntrustedClientCertificates` option is true (dev default).
-Production deployments want operator-controlled trust via the Admin UI.
+PR 28 shipped `/certificates` in the Admin UI. `CertTrustService` reads the OPC
+UA server's PKI store root (`OpcUaServerOptions.PkiStoreRoot` — default
+`%ProgramData%\OtOpcUa\pki`) and lists rejected + trusted certs by parsing the
+`.der` files directly, so it has no `Opc.Ua` dependency and runs on any
+Admin host that can reach the shared PKI directory.
-**To do**:
-- Surface the server's rejected-certificate store in the Admin UI.
-- Page to move certs between `rejected` / `trusted`.
-- Flip `AutoAcceptUntrustedClientCertificates` to false once Admin UI is the
- trust gate.
+Operator actions: Trust (moves `rejected/certs/*.der` → `trusted/certs/*.der`),
+Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on
+each new client handshake, so no explicit reload signal is needed —
+operators retry the rejected client's connection after trusting.
+
+Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
+deployment default. That's a production-hardening config change, not a code
+gap — the Admin UI is now ready to be the trust gate.
## 4. Live-LDAP integration test
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor
index 922da9c..f93b85f 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor
@@ -8,6 +8,7 @@
Fleet status
Clusters
Reservations
+ Certificates
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
new file mode 100644
index 0000000..8a2ee6b
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
@@ -0,0 +1,154 @@
+@page "/certificates"
+@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)]
+@using ZB.MOM.WW.OtOpcUa.Admin.Services
+@inject CertTrustService Certs
+@inject AuthenticationStateProvider AuthState
+@inject ILogger
Log
+
+Certificate trust
+
+
+ PKI store root @Certs.PkiStoreRoot. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
+
+
+@if (_status is not null)
+{
+
+ @_status
+
+
+}
+
+Rejected (@_rejected.Count)
+@if (_rejected.Count == 0)
+{
+ No rejected certificates. Clients that fail to handshake with an untrusted cert land here.
+}
+else
+{
+
+ Subject Issuer Thumbprint Valid Actions
+
+ @foreach (var c in _rejected)
+ {
+
+ @c.Subject
+ @c.Issuer
+ @c.Thumbprint
+ @c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")
+
+ TrustAsync(c)">Trust
+ DeleteRejectedAsync(c)">Delete
+
+
+ }
+
+
+}
+
+Trusted (@_trusted.Count)
+@if (_trusted.Count == 0)
+{
+ No client certs have been explicitly trusted. The server's own application cert lives in own/ and is not listed here.
+}
+else
+{
+
+ Subject Issuer Thumbprint Valid Actions
+
+ @foreach (var c in _trusted)
+ {
+
+ @c.Subject
+ @c.Issuer
+ @c.Thumbprint
+ @c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")
+
+ UntrustAsync(c)">Revoke
+
+
+ }
+
+
+}
+
+@code {
+ private IReadOnlyList _rejected = [];
+ private IReadOnlyList _trusted = [];
+ private string? _status;
+ private string _statusKind = "success";
+
+ protected override void OnInitialized() => Reload();
+
+ private void Reload()
+ {
+ _rejected = Certs.ListRejected();
+ _trusted = Certs.ListTrusted();
+ }
+
+ private async Task TrustAsync(CertInfo c)
+ {
+ if (Certs.TrustRejected(c.Thumbprint))
+ {
+ await LogActionAsync("cert.trust", c);
+ Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
+ }
+ else
+ {
+ Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning");
+ }
+ Reload();
+ }
+
+ private async Task DeleteRejectedAsync(CertInfo c)
+ {
+ if (Certs.DeleteRejected(c.Thumbprint))
+ {
+ await LogActionAsync("cert.delete.rejected", c);
+ Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
+ }
+ else
+ {
+ Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning");
+ }
+ Reload();
+ }
+
+ private async Task UntrustAsync(CertInfo c)
+ {
+ if (Certs.UntrustCert(c.Thumbprint))
+ {
+ await LogActionAsync("cert.untrust", c);
+ Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success");
+ }
+ else
+ {
+ Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning");
+ }
+ Reload();
+ }
+
+ private async Task LogActionAsync(string action, CertInfo c)
+ {
+ // Cert trust changes are operator-initiated and security-sensitive — Serilog captures the
+ // user + thumbprint trail. CertTrustService also logs at Information on each filesystem
+ // move/delete; this line ties the action to the authenticated admin user so the two logs
+ // correlate. DB-level ConfigAuditLog persistence is deferred — its schema is
+ // cluster-scoped and cert actions are cluster-agnostic.
+ var state = await AuthState.GetAuthenticationStateAsync();
+ var user = state.User.Identity?.Name ?? "(anonymous)";
+ Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}",
+ user, action, c.Thumbprint, c.Subject);
+ }
+
+ private void Set(string message, string kind)
+ {
+ _status = message;
+ _statusKind = kind;
+ }
+
+ private void ClearStatus() => _status = null;
+
+ private static string Short(string thumbprint) =>
+ thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint;
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
index 0e37fa3..00c7c25 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
@@ -48,6 +48,12 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
+// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
+// filesystem operations.
+builder.Services.Configure(builder.Configuration.GetSection(CertTrustOptions.SectionName));
+builder.Services.AddSingleton();
+
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
builder.Services.Configure(
builder.Configuration.GetSection("Authentication:Ldap"));
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs
new file mode 100644
index 0000000..b1f3072
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs
@@ -0,0 +1,22 @@
+namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+///
+/// Points the Admin UI at the OPC UA Server's PKI store root so
+/// can list and move certs between the
+/// rejected/ and trusted/ directories the server maintains. Must match the
+/// OpcUaServer:PkiStoreRoot the Server process is configured with.
+///
+public sealed class CertTrustOptions
+{
+ public const string SectionName = "CertTrust";
+
+ ///
+ /// Absolute path to the PKI root. Defaults to
+ /// %ProgramData%\OtOpcUa\pki — matches OpcUaServerOptions.PkiStoreRoot 's
+ /// default so a standard side-by-side install needs no override.
+ ///
+ public string PkiStoreRoot { get; init; } =
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "OtOpcUa", "pki");
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
new file mode 100644
index 0000000..016852b
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
@@ -0,0 +1,135 @@
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Options;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+///
+/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
+/// is the absolute path of the DER/CRT file the stack created when it
+/// rejected the cert (for ) or when an operator trusted
+/// it (for ).
+///
+public sealed record CertInfo(
+ string Thumbprint,
+ string Subject,
+ string Issuer,
+ DateTime NotBefore,
+ DateTime NotAfter,
+ string FilePath,
+ CertStoreKind Store);
+
+public enum CertStoreKind
+{
+ Rejected,
+ Trusted,
+}
+
+///
+/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
+/// Directory-typed store — each cert is a .der file under {root}/{store}/certs/
+/// with a filename derived from subject + thumbprint. This service exposes operators for the
+/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
+/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
+///
+///
+/// The Admin process is separate from the Server process; this service deliberately has no
+/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
+/// host even when the Server isn't installed locally, as long as the PKI root is reachable
+/// (typical deployment has Admin + Server side-by-side on the same machine).
+///
+/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
+/// the Directory store on each new incoming connection, so there's no explicit signal
+/// needed — the next client handshake picks up the change. Operators should retry the
+/// rejected client's connection after trusting.
+///
+public sealed class CertTrustService
+{
+ private readonly CertTrustOptions _options;
+ private readonly ILogger _logger;
+
+ public CertTrustService(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public string PkiStoreRoot => _options.PkiStoreRoot;
+
+ public IReadOnlyList ListRejected() => ListStore(CertStoreKind.Rejected);
+ public IReadOnlyList ListTrusted() => ListStore(CertStoreKind.Trusted);
+
+ ///
+ /// Move the cert with from the rejected store to the
+ /// trusted store. No-op returns false if the rejected file doesn't exist (already moved
+ /// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
+ /// silently — idempotent.
+ ///
+ public bool TrustRejected(string thumbprint)
+ {
+ var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
+ if (cert is null) return false;
+
+ var trustedDir = CertsDir(CertStoreKind.Trusted);
+ Directory.CreateDirectory(trustedDir);
+ var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
+ File.Move(cert.FilePath, destPath, overwrite: true);
+ _logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
+ cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
+ return true;
+ }
+
+ public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
+ public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
+
+ private bool DeleteFromStore(CertStoreKind store, string thumbprint)
+ {
+ var cert = FindInStore(store, thumbprint);
+ if (cert is null) return false;
+ File.Delete(cert.FilePath);
+ _logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
+ cert.Thumbprint, cert.Subject, store);
+ return true;
+ }
+
+ private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
+ ListStore(store).FirstOrDefault(c =>
+ string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
+
+ private IReadOnlyList ListStore(CertStoreKind store)
+ {
+ var dir = CertsDir(store);
+ if (!Directory.Exists(dir)) return [];
+
+ var results = new List();
+ foreach (var path in Directory.EnumerateFiles(dir))
+ {
+ // Skip CRL sidecars + private-key files — trust operations only concern public certs.
+ var ext = Path.GetExtension(path);
+ if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
+ !ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
+ !ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ try
+ {
+ var cert = X509CertificateLoader.LoadCertificateFromFile(path);
+ results.Add(new CertInfo(
+ cert.Thumbprint, cert.Subject, cert.Issuer,
+ cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
+ path, store));
+ }
+ catch (Exception ex)
+ {
+ // A malformed file in the store shouldn't take down the page. Surface it in logs
+ // but skip — operators see the other certs and can clean the bad file manually.
+ _logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
+ }
+ }
+ return results;
+ }
+
+ private string CertsDir(CertStoreKind store) =>
+ Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/CertTrustServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/CertTrustServiceTests.cs
new file mode 100644
index 0000000..a7a1fea
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/CertTrustServiceTests.cs
@@ -0,0 +1,153 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class CertTrustServiceTests : IDisposable
+{
+ private readonly string _root;
+
+ public CertTrustServiceTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), $"otopcua-cert-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(Path.Combine(_root, "rejected", "certs"));
+ Directory.CreateDirectory(Path.Combine(_root, "trusted", "certs"));
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
+ }
+
+ private CertTrustService Service() => new(
+ Options.Create(new CertTrustOptions { PkiStoreRoot = _root }),
+ NullLogger.Instance);
+
+ private X509Certificate2 WriteTestCert(CertStoreKind kind, string subject)
+ {
+ using var rsa = RSA.Create(2048);
+ var req = new CertificateRequest($"CN={subject}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
+ var dir = Path.Combine(_root, kind == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
+ var path = Path.Combine(dir, $"{subject} [{cert.Thumbprint}].der");
+ File.WriteAllBytes(path, cert.Export(X509ContentType.Cert));
+ return cert;
+ }
+
+ [Fact]
+ public void ListRejected_returns_parsed_cert_info_for_each_der_in_rejected_certs_dir()
+ {
+ var c = WriteTestCert(CertStoreKind.Rejected, "test-client-A");
+
+ var rows = Service().ListRejected();
+
+ rows.Count.ShouldBe(1);
+ rows[0].Thumbprint.ShouldBe(c.Thumbprint);
+ rows[0].Subject.ShouldContain("test-client-A");
+ rows[0].Store.ShouldBe(CertStoreKind.Rejected);
+ }
+
+ [Fact]
+ public void ListTrusted_is_separate_from_rejected()
+ {
+ WriteTestCert(CertStoreKind.Rejected, "rej");
+ WriteTestCert(CertStoreKind.Trusted, "trust");
+
+ var svc = Service();
+ svc.ListRejected().Count.ShouldBe(1);
+ svc.ListTrusted().Count.ShouldBe(1);
+ svc.ListRejected()[0].Subject.ShouldContain("rej");
+ svc.ListTrusted()[0].Subject.ShouldContain("trust");
+ }
+
+ [Fact]
+ public void TrustRejected_moves_file_from_rejected_to_trusted()
+ {
+ var c = WriteTestCert(CertStoreKind.Rejected, "promoteme");
+ var svc = Service();
+
+ svc.TrustRejected(c.Thumbprint).ShouldBeTrue();
+
+ svc.ListRejected().ShouldBeEmpty();
+ var trusted = svc.ListTrusted();
+ trusted.Count.ShouldBe(1);
+ trusted[0].Thumbprint.ShouldBe(c.Thumbprint);
+ }
+
+ [Fact]
+ public void TrustRejected_returns_false_when_thumbprint_not_in_rejected()
+ {
+ var svc = Service();
+ svc.TrustRejected("00DEADBEEF00DEADBEEF00DEADBEEF00DEADBEEF").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void DeleteRejected_removes_the_file()
+ {
+ var c = WriteTestCert(CertStoreKind.Rejected, "killme");
+ var svc = Service();
+
+ svc.DeleteRejected(c.Thumbprint).ShouldBeTrue();
+ svc.ListRejected().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void UntrustCert_removes_from_trusted_only()
+ {
+ var c = WriteTestCert(CertStoreKind.Trusted, "revoke");
+ var svc = Service();
+
+ svc.UntrustCert(c.Thumbprint).ShouldBeTrue();
+ svc.ListTrusted().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Thumbprint_match_is_case_insensitive()
+ {
+ var c = WriteTestCert(CertStoreKind.Rejected, "case");
+ var svc = Service();
+
+ // X509Certificate2.Thumbprint is upper-case hex; operators pasting from logs often
+ // lowercase it. IsAllowed-style case-insensitive match keeps the UX forgiving.
+ svc.TrustRejected(c.Thumbprint.ToLowerInvariant()).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Missing_store_directories_produce_empty_lists_not_exceptions()
+ {
+ // Fresh root with no certs subfolder — service should tolerate a pristine install.
+ var altRoot = Path.Combine(Path.GetTempPath(), $"otopcua-cert-empty-{Guid.NewGuid():N}");
+ try
+ {
+ var svc = new CertTrustService(
+ Options.Create(new CertTrustOptions { PkiStoreRoot = altRoot }),
+ NullLogger.Instance);
+ svc.ListRejected().ShouldBeEmpty();
+ svc.ListTrusted().ShouldBeEmpty();
+ }
+ finally
+ {
+ if (Directory.Exists(altRoot)) Directory.Delete(altRoot, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Malformed_file_is_skipped_not_fatal()
+ {
+ // Drop junk bytes that don't parse as a cert into the rejected/certs directory. The
+ // service must skip it and still return the valid certs — one bad file can't take the
+ // whole management page offline.
+ File.WriteAllText(Path.Combine(_root, "rejected", "certs", "junk.der"), "not a cert");
+ var c = WriteTestCert(CertStoreKind.Rejected, "valid");
+
+ var rows = Service().ListRejected();
+ rows.Count.ShouldBe(1);
+ rows[0].Thumbprint.ShouldBe(c.Thumbprint);
+ }
+}