feat(cli): working audit verify-chain via the VerifyAuditChain management command (ScadaBridge#41)

verify-chain was a no-op stub that printed "not enabled" and exited 0 without
contacting the server. It now sends VerifyAuditChain(Month) over
POST /management and maps the verdict to the exit code: 0 intact or not
chained, 3 broken (first break: partition, ChainSeq, EventId, kind), 1 error,
2 unauthorized. Table and JSON renderings.

ManagementActor: new HandleVerifyAuditChain arm, gated on the OperationalAudit
roles (Administrator, Viewer) and refusing a site-scoped caller because a
month's chain spans every site; INVALID_ARGUMENT for a bad month, CHAIN_BUSY
when the chain lock is held; long-running Ask timeout. Result enums serialise
as names.

Parity: RequiredRoleMatrixTests freezes the gate; ManagementSurfaceParityTests
now probes `audit verify-chain` (the one audit leaf that is a management
contract) instead of skipping the whole audit group.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 16:28:54 -05:00
parent fd4be80913
commit 548bb9a725
9 changed files with 333 additions and 27 deletions
@@ -246,6 +246,9 @@ public class ManagementActor : ReceiveActor
// three Live run commands stay DeployerOnly.
private static readonly string[] FlowDebuggerReplayRoles = AuthorizationPolicies.FlowDebuggerAccessRoles;
// ScadaBridge#41: verify-chain is an audit read — the OperationalAudit role set.
private static readonly string[] OperationalAuditReaders = AuthorizationPolicies.OperationalAuditRoles;
// arch-review C6: area management is reconciled to any-of [Designer, Deployer].
// Areas are authored on both real surfaces — the Designer template/topology
// tooling AND the Central UI Deployment Topology workflow (Topology.razor is
@@ -384,6 +387,12 @@ public class ManagementActor : ReceiveActor
// (arch-review UA1).
ListSecuredWritesCommand => SecuredWriteReaders,
// AuditLog hash-chain verification (ScadaBridge#41): an audit read, so the
// OperationalAudit roles (alog.md §15.1 — verify-chain carries audit query's
// permission). A site-scoped caller is refused in the handler: a month's chain
// spans every site.
VerifyAuditChainCommand => OperationalAuditReaders,
// Read-only queries -- any authenticated user
_ => null
};
@@ -554,6 +563,7 @@ public class ManagementActor : ReceiveActor
// Audit Log
QueryAuditLogCommand cmd => await HandleQueryAuditLog(sp, cmd),
VerifyAuditChainCommand cmd => await HandleVerifyAuditChain(sp, cmd, user),
// Health
GetHealthSummaryCommand => HandleGetHealthSummary(sp),
@@ -3210,6 +3220,42 @@ public class ManagementActor : ReceiveActor
return new AuditLogQueryResult(entries, totalCount);
}
/// <summary>
/// Verifies one month of the AuditLog hash chain (ScadaBridge#41). The month must be
/// <c>YYYY-MM</c>; a site-scoped, non-Administrator caller is refused because the chain
/// covers every site's rows and a break report names rows by EventId.
/// </summary>
private static async Task<object?> HandleVerifyAuditChain(
IServiceProvider sp, VerifyAuditChainCommand cmd, AuthenticatedUser user)
{
if (string.IsNullOrWhiteSpace(cmd.Month)
|| !DateTime.TryParseExact(cmd.Month, "yyyy-MM", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var month))
{
throw new ManagementCommandException(
$"Invalid month '{cmd.Month}'. Expected YYYY-MM (e.g. 2026-05).", "INVALID_ARGUMENT");
}
if (user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
throw new SiteScopeViolationException(
"Access denied: the audit hash chain spans every site; verify-chain needs an "
+ "OperationalAudit role that is not scoped to specific sites.");
}
var chain = sp.GetRequiredService<IAuditLogHashChain>();
try
{
return await chain.VerifyMonthAsync(
new DateTime(month.Year, month.Month, 1, 0, 0, 0, DateTimeKind.Utc));
}
catch (TimeoutException ex)
{
throw new ManagementCommandException(ex.Message, "CHAIN_BUSY");
}
}
// ========================================================================
// Health handlers
// ========================================================================