Razor page layout: two tables (Rejected / Trusted) with Subject / Issuer / Thumbprint / Valid-window / Actions columns, status banner after each action with success or warning kind ('file missing' = another admin handled it), FleetAdmin-only via [Authorize(Roles=AdminRoles.FleetAdmin)]. Each action invokes LogActionAsync which Serilog-logs the authenticated admin user + thumbprint + action for an audit trail — DB-level ConfigAuditLog persistence is deferred because its schema is cluster-scoped and cert actions are cluster-agnostic; Serilog + CertTrustService's filesystem-op info logs give the forensic trail in the meantime. Sidebar link added to MainLayout between Reservations and the future Account page.
Tests — CertTrustServiceTests (9 new unit cases): ListRejected parses Subject + Thumbprint + store kind from a self-signed test cert written into rejected/certs/; rejected and trusted stores are kept separate; TrustRejected moves the file and the Rejected list is empty afterwards; TrustRejected with a thumbprint not in rejected returns false without touching trusted; DeleteRejected removes the file; UntrustCert removes from trusted only; thumbprint match is case-insensitive (operator UX); missing store directories produce empty lists instead of throwing DirectoryNotFoundException (pristine-install tolerance); a junk .der in the store is logged + skipped and the valid certs still surface (one bad file doesn't break the page). Full Admin.Tests Unit suite: 23 pass / 0 fail (14 prior + 9 new). Full Admin build clean — 0 errors, 0 warnings.
lmx-followups.md #3 marked DONE with a cross-reference to this PR and a note that flipping AutoAcceptUntrustedClientCertificates to false as the production default is a deployment-config follow-up, not a code gap — the Admin UI is now ready to be the trust gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
5.5 KiB
Plaintext
155 lines
5.5 KiB
Plaintext
@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<Certificates> Log
|
|
|
|
<h1 class="mb-4">Certificate trust</h1>
|
|
|
|
<div class="alert alert-info small mb-4">
|
|
PKI store root <code>@Certs.PkiStoreRoot</code>. 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.
|
|
</div>
|
|
|
|
@if (_status is not null)
|
|
{
|
|
<div class="alert alert-@_statusKind alert-dismissible">
|
|
@_status
|
|
<button type="button" class="btn-close" @onclick="ClearStatus"></button>
|
|
</div>
|
|
}
|
|
|
|
<h2 class="h4">Rejected (@_rejected.Count)</h2>
|
|
@if (_rejected.Count == 0)
|
|
{
|
|
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-sm align-middle">
|
|
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
|
<tbody>
|
|
@foreach (var c in _rejected)
|
|
{
|
|
<tr>
|
|
<td>@c.Subject</td>
|
|
<td>@c.Issuer</td>
|
|
<td><code class="small">@c.Thumbprint</code></td>
|
|
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
|
<td class="text-end">
|
|
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRejectedAsync(c)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
|
|
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2>
|
|
@if (_trusted.Count == 0)
|
|
{
|
|
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-sm align-middle">
|
|
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
|
<tbody>
|
|
@foreach (var c in _trusted)
|
|
{
|
|
<tr>
|
|
<td>@c.Subject</td>
|
|
<td>@c.Issuer</td>
|
|
<td><code class="small">@c.Thumbprint</code></td>
|
|
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
|
<td class="text-end">
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
|
|
@code {
|
|
private IReadOnlyList<CertInfo> _rejected = [];
|
|
private IReadOnlyList<CertInfo> _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;
|
|
}
|