diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs
index 561f3527..8ec96f77 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs
@@ -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 scadabridge audit command group. Provides read access to
/// the centralized append-only Audit Log via the REST endpoints
/// (GET /api/audit/query, GET /api/audit/export,
-/// GET /api/audit/tree), plus a v1 no-op verify-chain placeholder
-/// for the deferred hash-chain tamper-evidence feature.
+/// GET /api/audit/tree), plus verify-chain, which sends the
+/// VerifyAuditChain management command (hash-chain tamper evidence, ScadaBridge#41).
///
public static class AuditCommands
{
@@ -288,11 +289,14 @@ public static class AuditCommands
private static Command BuildVerifyChain(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
{
- var monthOption = new Option("--month") { Description = "Month to verify (YYYY-MM)", Required = true };
+ var monthOption = new Option("--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;
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditVerifyChainHelpers.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditVerifyChainHelpers.cs
index cc6dcb38..3e3e142f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditVerifyChainHelpers.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditVerifyChainHelpers.cs
@@ -1,14 +1,29 @@
using System.Globalization;
+using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
///
-/// Helpers for the audit verify-chain subcommand. v1 is a no-op: hash-chain
-/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still
-/// validates its --month argument so the surface is stable for v1.x.
+/// Helpers for the audit verify-chain subcommand (ScadaBridge#41). The command sends
+/// the VerifyAuditChain management command for one UTC calendar month and reports
+/// whether that month's AuditLog hash chain is intact, or the first break (chain partition,
+/// ChainSeq, EventId, kind).
///
+///
+/// Exit codes: 0 intact (or nothing chained yet), when
+/// the chain is broken, 1 on a request error, 2 on an authorization failure.
+///
public static class AuditVerifyChainHelpers
{
+ /// Process exit code when verification found a break (tamper evidence).
+ public const int BrokenExitCode = 3;
+
+ ///
+ /// 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.
+ ///
+ public static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(6);
+
///
/// Returns true if is a well-formed YYYY-MM value
/// with a real month (01-12). A malformed month (e.g. 2026-13) is rejected.
@@ -19,4 +34,53 @@ public static class AuditVerifyChainHelpers
=> !string.IsNullOrWhiteSpace(month)
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
DateTimeStyles.None, out _);
+
+ ///
+ /// Renders a VerifyAuditChain result and maps it to the process exit code.
+ /// json prints the server body verbatim; table prints a short report.
+ ///
+ /// The success body (an AuditChainVerificationResult, camelCase).
+ /// Resolved output format.
+ /// Writer for the report.
+ /// 0 when intact / not chained, when broken.
+ 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;
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs
index 2d00ca7c..8a024ae2 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs
@@ -23,3 +23,11 @@ public record QueryAuditLogCommand(
/// The page of configuration-change audit entries.
/// Total number of rows matching the filters (unpaged).
public record AuditLogQueryResult(IReadOnlyList Entries, int TotalCount);
+
+///
+/// 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.
+///
+/// The month to verify, YYYY-MM.
+public record VerifyAuditChainCommand(string Month);
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index a212173a..cafd61c4 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -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);
}
+ ///
+ /// Verifies one month of the AuditLog hash chain (ScadaBridge#41). The month must be
+ /// YYYY-MM; a site-scoped, non-Administrator caller is refused because the chain
+ /// covers every site's rows and a break report names rows by EventId.
+ ///
+ private static async Task