feat(audit): M5.1 audit tree endpoint + CLI audit tree (T8)

Add GET /api/audit/tree endpoint that accepts executionId query param,
authenticates via HTTP Basic + LDAP (OperationalAudit permission), calls
IAuditLogRepository.GetExecutionTreeAsync, and returns a JSON array of
ExecutionTreeNode. Returns 400 for missing/invalid GUID, 401/403 as normal.

Add `scadabridge audit tree --execution-id <guid> [--format table|json]`
CLI subcommand in AuditCommands.Build(). Adds AuditTreeHelpers with:
  - BuildUrl: constructs the relative URL + query string
  - RunTreeAsync: calls the endpoint, dispatches to table or JSON renderer
  - WriteTable: indented ASCII tree (root → children, [*] marks queried node)
  - WriteJson: pretty-printed JSON array pass-through

Tests: 7 new ManagementService endpoint tests (valid id, empty, 400, 401,
403, Viewer allowed, wrong role), 18 new CLI tests (parse, render, HTTP
error codes, JSON output, multi-level indentation, queried-node marker).
This commit is contained in:
Joseph Doherty
2026-06-16 21:20:54 -04:00
parent 0e9bcbb676
commit 0569c5ff23
5 changed files with 822 additions and 5 deletions
@@ -6,13 +6,15 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// The <c>scadabridge audit</c> command group (Audit Log #23 M8). Provides read access to
/// the centralized append-only Audit Log via the Bundle B REST endpoints
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>), plus a v1 no-op
/// <c>verify-chain</c> placeholder for the deferred hash-chain tamper-evidence feature.
/// (<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.
/// </summary>
public static class AuditCommands
{
/// <summary>
/// Builds the <c>audit</c> command group with query, export, and verify-chain sub-commands.
/// Builds the <c>audit</c> command group with query, export, tree, and verify-chain
/// sub-commands.
/// </summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
@@ -25,6 +27,7 @@ public static class AuditCommands
command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildTree(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption));
return command;
@@ -224,6 +227,44 @@ public static class AuditCommands
return cmd;
}
private static Command BuildTree(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var executionIdOption = new Option<string>("--execution-id")
{
Description = "Execution ID (GUID) to look up — may be any node in the chain",
Required = true,
};
var cmd = new Command("tree") { Description = "Display the full execution-chain tree for an audit execution" };
cmd.Add(executionIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var connection = AuditCommandHelpers.ResolveConnection(result, urlOption, usernameOption, passwordOption);
if (connection.Error != null)
{
OutputFormatter.WriteError(connection.Error, connection.ErrorCode!);
return 1;
}
var rawId = result.GetValue(executionIdOption);
if (!Guid.TryParse(rawId, out var executionId))
{
OutputFormatter.WriteError(
$"Invalid execution ID '{rawId}'. Expected a GUID (e.g. 11111111-1111-1111-1111-111111111111).",
"INVALID_ARGUMENT");
return 1;
}
var format = AuditCommandHelpers.ResolveFormat(result, formatOption);
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
return await AuditTreeHelpers.RunTreeAsync(client, executionId, format, Console.Out);
});
return cmd;
}
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 };