feat(centralui): cert-management UI + Trust action + site relay (T17)

This commit is contained in:
Joseph Doherty
2026-06-18 03:53:32 -04:00
parent 2d139442ba
commit 384204b71a
12 changed files with 858 additions and 13 deletions
@@ -3,7 +3,9 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
@using ZB.MOM.WW.ScadaBridge.Security
@inject IEndpointVerificationService VerificationService
@inject ICertManagementService CertManagementService
<div class="opcua-endpoint-editor">
<h6 class="text-muted border-bottom pb-1">@Title</h6>
@@ -91,9 +93,44 @@
<dt class="col-sm-3">Not after</dt>
<dd class="col-sm-9">@cert.NotAfterUtc.ToString("u")</dd>
</dl>
<div class="text-muted fst-italic">
Use cert management to trust this certificate.
</div>
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized>
<button type="button" class="btn btn-outline-warning btn-sm mt-1"
data-test="trust-cert-btn"
disabled="@_trusting"
@onclick="() => TrustCert(cert)">
@if (_trusting)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Trusting…</span>
}
else
{
<span>Trust certificate</span>
}
</button>
<div class="text-muted fst-italic mt-1">
Trusting adds this certificate to every node of the site's
trusted-peer store (node-wide), then re-runs Verify.
</div>
</Authorized>
<NotAuthorized>
<div class="text-muted fst-italic">
An Administrator must trust this certificate (cert management).
</div>
</NotAuthorized>
</AuthorizeView>
@if (_trustError is { } trustError)
{
<div class="text-danger small mt-1" data-test="trust-cert-error">@trustError</div>
}
@if (_trustSucceeded)
{
<div class="text-success small mt-1" data-test="trust-cert-success">
&#10003; Certificate trusted.
</div>
}
</div>
}
}
@@ -322,11 +359,25 @@
private bool _verifying;
private VerifyEndpointResult? _verifyResult;
private bool _trusting;
private bool _trustSucceeded;
private string? _trustError;
private async Task VerifyEndpoint()
private Task VerifyEndpoint() => VerifyEndpoint(clearTrustNotes: true);
private async Task VerifyEndpoint(bool clearTrustNotes)
{
_verifying = true;
_verifyResult = null;
// A fresh (user-initiated) verify supersedes any prior trust outcome — clear
// the inline notes so a stale "trusted" / error banner doesn't linger across
// probes. The trust-triggered re-verify keeps the success note so the operator
// still sees confirmation even if the re-probe also surfaces a (different) cert.
if (clearTrustNotes)
{
_trustSucceeded = false;
_trustError = null;
}
try
{
_verifyResult = await VerificationService.VerifyAsync(
@@ -343,6 +394,42 @@
}
}
// M7 T17: trust the captured untrusted server certificate at every node of the
// owning site, then re-run Verify (which should now succeed and clear the cert
// panel). Administrator-gated by the AuthorizeView wrapping the button; the
// CertManagementService enforces the same role server-side-of-the-trust-boundary.
private async Task TrustCert(ServerCertInfo cert)
{
_trusting = true;
_trustError = null;
_trustSucceeded = false;
try
{
var result = await CertManagementService.TrustAsync(
SiteIdentifier, ConnectionName, cert.DerBase64, cert.Thumbprint);
if (result.Success)
{
_trustSucceeded = true;
// Re-run Verify so the editor reflects the now-trusted state (and
// clears the cert panel on the expected success). Preserve the
// success note (clearTrustNotes:false) so confirmation is shown.
await VerifyEndpoint(clearTrustNotes: false);
}
else
{
_trustError = result.Error ?? "Failed to trust certificate.";
}
}
catch (Exception ex)
{
_trustError = ex.Message;
}
finally
{
_trusting = false;
}
}
private void EnableHeartbeat() =>
Config.Heartbeat = new OpcUaHeartbeatConfig();
@@ -0,0 +1,183 @@
@page "/design/connections/{Id:int}/certificates"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject ICertManagementService CertManagementService
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3" data-test="connection-certificates">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">Server Certificates@(string.IsNullOrEmpty(_connectionName) ? "" : $" — {_connectionName}")</h4>
</div>
<div class="alert alert-info py-2 small">
The trusted-peer certificate store is <strong>node-wide for the site</strong>
(shared by every site node), not per data connection. Trusting or removing a
certificate affects all OPC UA connections at this site.
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_loadError is { } loadError)
{
<div class="text-danger small" data-test="cert-load-error">@loadError</div>
}
else
{
@if (_actionError is { } actionError)
{
<div class="text-danger small mb-2" data-test="cert-action-error">@actionError</div>
}
@if (_certs.Count == 0)
{
<div class="text-muted small" data-test="cert-empty">
No certificates are present in this site's trusted-peer or rejected stores.
</div>
}
else
{
<table class="table table-sm table-hover align-middle">
<thead>
<tr>
<th>Subject</th>
<th>Issuer</th>
<th>Thumbprint</th>
<th>Not before</th>
<th>Not after</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var cert in _certs)
{
<tr data-test="cert-row">
<td class="small"><code>@cert.Subject</code></td>
<td class="small"><code>@cert.Issuer</code></td>
<td class="small"><code>@cert.Thumbprint</code></td>
<td class="small">@cert.NotBeforeUtc.ToString("u")</td>
<td class="small">@cert.NotAfterUtc.ToString("u")</td>
<td class="small">
@if (cert.Rejected)
{
<span class="badge bg-warning text-dark">Rejected</span>
}
else
{
<span class="badge bg-success">Trusted</span>
}
</td>
<td class="text-end">
<button type="button" class="btn btn-outline-danger btn-sm"
data-test="cert-remove-btn"
disabled="@_removing"
@onclick="() => RemoveCert(cert.Thumbprint)">
Remove
</button>
</td>
</tr>
}
</tbody>
</table>
}
}
</div>
@code {
[Parameter] public int Id { get; set; }
private bool _loading = true;
private bool _removing;
private string? _loadError;
private string? _actionError;
private string _connectionName = string.Empty;
private string _siteIdentifier = string.Empty;
private IReadOnlyList<TrustedCertInfo> _certs = Array.Empty<TrustedCertInfo>();
protected override async Task OnInitializedAsync()
{
try
{
// Resolve the connection's owning site so the cert relay targets the
// right site (the trusted-peer store is node-wide PER SITE node).
var connection = await SiteRepository.GetDataConnectionByIdAsync(Id);
if (connection is null)
{
_loadError = $"Data connection {Id} not found.";
return;
}
_connectionName = connection.Name;
var site = await SiteRepository.GetSiteByIdAsync(connection.SiteId);
if (site is null)
{
_loadError = $"Site {connection.SiteId} not found.";
return;
}
_siteIdentifier = site.SiteIdentifier;
await LoadCertsAsync();
}
catch (Exception ex)
{
_loadError = $"Failed to load: {ex.Message}";
}
finally
{
_loading = false;
}
}
private async Task LoadCertsAsync()
{
var result = await CertManagementService.ListAsync(_siteIdentifier);
if (result.Success)
{
_certs = result.Certs ?? Array.Empty<TrustedCertInfo>();
_loadError = null;
}
else
{
_loadError = result.Error ?? "Failed to list certificates.";
}
}
private async Task RemoveCert(string thumbprint)
{
_removing = true;
_actionError = null;
try
{
var result = await CertManagementService.RemoveAsync(_siteIdentifier, thumbprint);
if (result.Success)
{
// Re-list so the table reflects the removal (the store is node-wide,
// so the authoritative list comes back from the site, not local state).
await LoadCertsAsync();
}
else
{
_actionError = result.Error ?? "Failed to remove certificate.";
}
}
catch (Exception ex)
{
_actionError = ex.Message;
}
finally
{
_removing = false;
}
}
private void GoBack() =>
NavigationManager.NavigateTo($"/design/connections/{Id}/edit");
}
@@ -72,6 +72,21 @@
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
@if (Id.HasValue && _protocol == "OpcUa")
{
<div class="mb-2">
<a class="btn btn-outline-secondary btn-sm"
data-test="manage-certificates-link"
href="@($"/design/connections/{Id}/certificates")">
Manage certificates
</a>
<div class="form-text">
View, trust, and remove OPC UA server certificates in the
site's node-wide trusted-peer store.
</div>
</div>
}
<h6 class="text-muted mt-3">Primary endpoint</h6>
@if (_protocol == "MxGateway")
{