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
@@ -1,5 +1,6 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
@@ -7,8 +8,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// The <c>scadabridge audit</c> command group. Provides read access to
/// the centralized append-only Audit Log via the REST endpoints
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>,
/// <c>GET /api/audit/tree</c>), plus a v1 no-op <c>verify-chain</c> placeholder
/// for the deferred hash-chain tamper-evidence feature.
/// <c>GET /api/audit/tree</c>), plus <c>verify-chain</c>, which sends the
/// <c>VerifyAuditChain</c> management command (hash-chain tamper evidence, ScadaBridge#41).
/// </summary>
public static class AuditCommands
{
@@ -288,11 +289,14 @@ public static class AuditCommands
private static Command BuildVerifyChain(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var monthOption = new Option<string>("--month") { Description = "Month to verify (YYYY-MM)", Required = true };
var monthOption = new Option<string>("--month") { Description = "UTC calendar month to verify (YYYY-MM)", Required = true };
var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" };
var cmd = new Command("verify-chain")
{
Description = "Verify the audit log hash chain for a month; exit 3 and report the first break if it was tampered with",
};
cmd.Add(monthOption);
cmd.SetAction((ParseResult result) =>
cmd.SetAction(async (ParseResult result) =>
{
var month = result.GetValue(monthOption)!;
if (!AuditVerifyChainHelpers.IsValidMonth(month))
@@ -302,10 +306,12 @@ public static class AuditCommands
return 1;
}
Console.WriteLine(
"Hash-chain tamper-evidence is not enabled in this release. "
+ "See Component-AuditLog.md (Security & Tamper-Evidence) for the v1.x roadmap.");
return 0;
var format = AuditCommandHelpers.ResolveFormat(result, formatOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new VerifyAuditChainCommand(month),
timeout: AuditVerifyChainHelpers.RequestTimeout,
onSuccess: json => AuditVerifyChainHelpers.Render(json, format, Console.Out));
});
return cmd;
}
@@ -1,14 +1,29 @@
using System.Globalization;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Helpers for the <c>audit verify-chain</c> subcommand. v1 is a no-op: hash-chain
/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still
/// validates its <c>--month</c> argument so the surface is stable for v1.x.
/// Helpers for the <c>audit verify-chain</c> subcommand (ScadaBridge#41). The command sends
/// the <c>VerifyAuditChain</c> management command for one UTC calendar month and reports
/// whether that month's AuditLog hash chain is intact, or the first break (chain partition,
/// <c>ChainSeq</c>, <c>EventId</c>, kind).
/// </summary>
/// <remarks>
/// Exit codes: <c>0</c> intact (or nothing chained yet), <see cref="BrokenExitCode"/> when
/// the chain is broken, <c>1</c> on a request error, <c>2</c> on an authorization failure.
/// </remarks>
public static class AuditVerifyChainHelpers
{
/// <summary>Process exit code when verification found a break (tamper evidence).</summary>
public const int BrokenExitCode = 3;
/// <summary>
/// Client deadline. The server bounds the call with the long-running management Ask
/// (5 min); the client waits a little longer so the server's answer always arrives.
/// </summary>
public static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(6);
/// <summary>
/// Returns true if <paramref name="month"/> is a well-formed <c>YYYY-MM</c> value
/// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
@@ -19,4 +34,53 @@ public static class AuditVerifyChainHelpers
=> !string.IsNullOrWhiteSpace(month)
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
DateTimeStyles.None, out _);
/// <summary>
/// Renders a <c>VerifyAuditChain</c> result and maps it to the process exit code.
/// <c>json</c> prints the server body verbatim; <c>table</c> prints a short report.
/// </summary>
/// <param name="json">The success body (an <c>AuditChainVerificationResult</c>, camelCase).</param>
/// <param name="format">Resolved output format.</param>
/// <param name="output">Writer for the report.</param>
/// <returns><c>0</c> when intact / not chained, <see cref="BrokenExitCode"/> when broken.</returns>
public static int Render(string json, string format, TextWriter output)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var status = root.TryGetProperty("status", out var s) ? s.GetString() ?? "" : "";
var broken = string.Equals(status, "Broken", StringComparison.OrdinalIgnoreCase);
if (!string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
{
output.WriteLine(json);
output.Flush();
return broken ? BrokenExitCode : 0;
}
output.WriteLine($"Partition : {Str(root, "partition")}");
output.WriteLine($"Status : {status}");
output.WriteLine($"Rows verified : {Num(root, "rowsVerified")}");
output.WriteLine($"Tombstones applied: {Num(root, "tombstonesApplied")}");
output.WriteLine($"Purged through : {Num(root, "purgedThroughSeq", "-")}");
output.WriteLine($"Head sequence : {Num(root, "headSeq", "-")}");
output.WriteLine($"Unchained rows : {Num(root, "unchainedRows")}");
if (root.TryGetProperty("firstBreak", out var brk) && brk.ValueKind == JsonValueKind.Object)
{
output.WriteLine("First break:");
output.WriteLine($" partition : {Str(brk, "partition")}");
output.WriteLine($" chainSeq : {Num(brk, "chainSeq")}");
output.WriteLine($" eventId : {Str(brk, "eventId", "-")}");
output.WriteLine($" kind : {Str(brk, "kind")}");
output.WriteLine($" detail : {Str(brk, "detail")}");
}
output.Flush();
return broken ? BrokenExitCode : 0;
}
private static string Str(JsonElement e, string name, string missing = "")
=> e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? missing : missing;
private static string Num(JsonElement e, string name, string missing = "0")
=> e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt64().ToString(CultureInfo.InvariantCulture) : missing;
}
@@ -23,3 +23,11 @@ public record QueryAuditLogCommand(
/// <param name="Entries">The page of configuration-change audit entries.</param>
/// <param name="TotalCount">Total number of rows matching the filters (unpaged).</param>
public record AuditLogQueryResult(IReadOnlyList<AuditLogEntry> Entries, int TotalCount);
/// <summary>
/// Verifies the central AuditLog hash chain for one UTC calendar month (audit roadmap T1,
/// ScadaBridge#41). Read-only; gated on the OperationalAudit roles and refused for a
/// site-scoped caller, because a month's chain spans every site.
/// </summary>
/// <param name="Month">The month to verify, <c>YYYY-MM</c>.</param>
public record VerifyAuditChainCommand(string Month);
@@ -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
// ========================================================================
@@ -60,6 +60,9 @@ public static class ManagementEndpoints
// script entry (design 2026-09-08): a direct call lasts up to the script's timeout
// (+ grace), a write kind up to its wait window.
typeof(FlowDebugRunScriptCommand),
// AuditLog hash-chain verification (ScadaBridge#41) recomputes one hash per row of
// a whole month — millions of rows on a busy central.
typeof(VerifyAuditChainCommand),
];
/// <summary>