Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
Joseph Doherty a25593a9c6 chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:55:28 -04:00

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;
}