feat(management): Admin-gated actor dispatch for cert-trust commands (arch-review C4)

Made CommunicationService.TrustServerCertAsync/RemoveServerCertAsync/ListServerCertsAsync virtual to support the test seam (mirrors existing virtual WriteTagAsync).

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:29:57 -04:00
parent 722063638b
commit 5415e6566f
4 changed files with 154 additions and 5 deletions
@@ -432,7 +432,7 @@ public class CommunicationService
/// <param name="command">The trust-server-cert command (connection name + DER + thumbprint).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cert-trust result (per-node aggregate success + first error).</returns>
public Task<CertTrustResult> TrustServerCertAsync(
public virtual Task<CertTrustResult> TrustServerCertAsync(
string siteId,
TrustServerCertCommand command,
CancellationToken cancellationToken = default)
@@ -453,7 +453,7 @@ public class CommunicationService
/// <param name="command">The list-server-certs command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cert-trust result carrying the listed certificates on success.</returns>
public Task<CertTrustResult> ListServerCertsAsync(
public virtual Task<CertTrustResult> ListServerCertsAsync(
string siteId,
ListServerCertsCommand command,
CancellationToken cancellationToken = default)
@@ -474,7 +474,7 @@ public class CommunicationService
/// <param name="command">The remove-server-cert command (thumbprint).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cert-trust result (per-node aggregate success + first error).</returns>
public Task<CertTrustResult> RemoveServerCertAsync(
public virtual Task<CertTrustResult> RemoveServerCertAsync(
string siteId,
RemoveServerCertCommand command,
CancellationToken cancellationToken = default)
@@ -212,7 +212,12 @@ public class ManagementActor : ReceiveActor
// gate must match — otherwise a Designer blocked in the UI could still
// rotate a production credential via the CLI/Management API.
or UpdateSmtpConfigCommand
or UpdateSmsConfigCommand => AdminOnly,
or UpdateSmsConfigCommand
// Server-certificate trust (arch-review C4). These mutate the site's
// trusted-peer PKI store — Admin, matching the Admin-gated UI cert
// page. Site-side reconcile semantics are owned by the site
// CertStoreActor (plan 03); this handler only relays.
or TrustServerCertCommand or RemoveServerCertCommand or ListServerCertsCommand => AdminOnly,
// Area management — any-of [Designer, Deployer] (arch-review C6).
// Exposed both in the Designer tooling and inside the Central UI
@@ -443,6 +448,9 @@ public class ManagementActor : ReceiveActor
BrowseNodeCommand cmd => await HandleBrowseNode(sp, cmd, user),
SearchAddressSpaceCommand cmd => await HandleSearchAddressSpace(sp, cmd, user),
VerifyEndpointCommand cmd => await HandleVerifyEndpoint(sp, cmd, user),
TrustServerCertCommand cmd => await HandleTrustServerCert(sp, cmd, user),
RemoveServerCertCommand cmd => await HandleRemoveServerCert(sp, cmd, user),
ListServerCertsCommand cmd => await HandleListServerCerts(sp, cmd, user),
QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd, user),
RetryParkedMessageCommand cmd => await HandleRetryParkedMessage(sp, cmd, user),
DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
@@ -2955,6 +2963,39 @@ public class ManagementActor : ReceiveActor
return await commService.VerifyEndpointAsync(site, cmd);
}
// Server-certificate trust (arch-review C4). Admin-gated at the role check
// above. These mutate the site's trusted-peer PKI store — the handler only
// relays; the site-side broadcast/reconcile across both nodes is owned by
// the site CertStoreActor (plan 03). SiteIdentifier is REQUIRED so the actor
// knows which site to route to.
private static async Task<object?> HandleTrustServerCert(IServiceProvider sp, TrustServerCertCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when trusting a server certificate via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.TrustServerCertAsync(site, cmd);
}
private static async Task<object?> HandleRemoveServerCert(IServiceProvider sp, RemoveServerCertCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when removing a server certificate via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.RemoveServerCertAsync(site, cmd);
}
private static async Task<object?> HandleListServerCerts(IServiceProvider sp, ListServerCertsCommand cmd, AuthenticatedUser user)
{
var site = cmd.SiteIdentifier
?? throw new ManagementCommandException("SiteIdentifier is required when listing server certificates via the management API.");
await EnforceSiteScopeForIdentifier(sp, user, site);
var commService = sp.GetRequiredService<CommunicationService>();
return await commService.ListServerCertsAsync(site, cmd);
}
private static async Task<object?> HandleQueryParkedMessages(IServiceProvider sp, QueryParkedMessagesCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);