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 };
@@ -0,0 +1,208 @@
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Arguments for an <c>audit tree</c> invocation.
/// </summary>
public sealed class AuditTreeArgs
{
/// <summary>
/// The execution ID (GUID) to look up. May be any node in the chain — the
/// server walks to the root and returns the full tree.
/// </summary>
public string ExecutionId { get; set; } = string.Empty;
}
/// <summary>
/// Represents one execution node as returned by <c>GET /api/audit/tree</c>.
/// Property names match the server's camelCase JSON serialisation of
/// <c>ExecutionTreeNode</c>.
/// </summary>
internal sealed class AuditTreeNodeDto
{
public Guid ExecutionId { get; init; }
public Guid? ParentExecutionId { get; init; }
public int RowCount { get; init; }
public string[] Channels { get; init; } = Array.Empty<string>();
public string[] Statuses { get; init; } = Array.Empty<string>();
public string? SourceSiteId { get; init; }
public string? SourceInstanceId { get; init; }
public DateTime? FirstOccurredAtUtc { get; init; }
public DateTime? LastOccurredAtUtc { get; init; }
}
/// <summary>
/// Pure helpers for the <c>audit tree</c> subcommand: builds the query string,
/// calls <c>GET /api/audit/tree</c>, and renders the result as either an
/// indented ASCII tree (table format) or raw JSON. Kept separate from the
/// command wiring so each piece is unit-testable without standing up the
/// command tree.
/// </summary>
public static class AuditTreeHelpers
{
private static readonly JsonSerializerOptions JsonReadOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private static readonly JsonSerializerOptions JsonWriteOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
/// <summary>
/// Builds the query string for <c>GET /api/audit/tree</c>.
/// </summary>
/// <param name="executionId">The execution ID GUID.</param>
/// <returns>A relative path + query string ready to append to the base URL.</returns>
public static string BuildUrl(Guid executionId)
=> $"api/audit/tree?executionId={executionId:D}";
/// <summary>
/// Executes the tree lookup: GETs <c>/api/audit/tree</c> and renders the result
/// in the requested format. Returns the process exit code (0 = success,
/// 1 = error, 2 = authorization failure).
/// </summary>
/// <param name="client">The management HTTP client.</param>
/// <param name="executionId">The execution ID to look up.</param>
/// <param name="format">"table" (default) or "json".</param>
/// <param name="output">The output writer for results.</param>
/// <returns>A task that resolves to the process exit code.</returns>
public static async Task<int> RunTreeAsync(
ManagementHttpClient client,
Guid executionId,
string format,
TextWriter output)
{
var url = BuildUrl(executionId);
var response = await client.SendGetAsync(url, TimeSpan.FromSeconds(30));
if (response.JsonData == null)
{
OutputFormatter.WriteError(
response.Error ?? "Audit tree request failed.", response.ErrorCode ?? "ERROR");
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
}
var nodes = ParseNodes(response.JsonData);
if (format == "json")
{
WriteJson(nodes, output);
}
else
{
WriteTable(nodes, executionId, output);
}
output.Flush();
return 0;
}
/// <summary>
/// Parses the JSON array from the server into an array of
/// <see cref="AuditTreeNodeDto"/>.
/// </summary>
/// <param name="json">The raw JSON response body.</param>
/// <returns>An array of deserialized tree nodes (empty on parse failure).</returns>
internal static AuditTreeNodeDto[] ParseNodes(string json)
{
try
{
return JsonSerializer.Deserialize<AuditTreeNodeDto[]>(json, JsonReadOptions)
?? Array.Empty<AuditTreeNodeDto>();
}
catch (JsonException)
{
return Array.Empty<AuditTreeNodeDto>();
}
}
/// <summary>
/// Renders the nodes as pretty-printed JSON to <paramref name="output"/>.
/// </summary>
internal static void WriteJson(AuditTreeNodeDto[] nodes, TextWriter output)
{
output.WriteLine(JsonSerializer.Serialize(nodes, JsonWriteOptions));
}
/// <summary>
/// Renders the nodes as an indented ASCII tree. The root node (null
/// <c>ParentExecutionId</c>) is printed first; each child is indented
/// two spaces per depth level. The queried/entry-point node is marked
/// with <c> [*]</c>.
/// </summary>
internal static void WriteTable(
AuditTreeNodeDto[] nodes,
Guid queriedExecutionId,
TextWriter output)
{
if (nodes.Length == 0)
{
output.WriteLine("(no execution tree found)");
return;
}
// Build a parent → children lookup (keyed by non-null parent Guid).
// Nodes whose ParentExecutionId is null are roots and are not placed in
// the lookup; they are identified separately below.
var childrenOf = new Dictionary<Guid, List<AuditTreeNodeDto>>();
foreach (var node in nodes)
{
if (node.ParentExecutionId is { } parentId)
{
if (!childrenOf.ContainsKey(parentId))
childrenOf[parentId] = new List<AuditTreeNodeDto>();
childrenOf[parentId].Add(node);
}
}
// Identify roots: nodes whose ParentExecutionId is null, or whose parent
// is not present in the node set (stub-root case).
var nodeIds = new HashSet<Guid>(nodes.Select(n => n.ExecutionId));
var roots = nodes
.Where(n => n.ParentExecutionId == null || !nodeIds.Contains(n.ParentExecutionId.Value))
.ToList();
// Render depth-first.
var sb = new StringBuilder();
foreach (var root in roots)
{
RenderNode(root, depth: 0, childrenOf, queriedExecutionId, sb);
}
output.Write(sb.ToString());
}
private static void RenderNode(
AuditTreeNodeDto node,
int depth,
Dictionary<Guid, List<AuditTreeNodeDto>> childrenOf,
Guid queriedExecutionId,
StringBuilder sb)
{
var indent = new string(' ', depth * 2);
var marker = node.ExecutionId == queriedExecutionId ? " [*]" : string.Empty;
var channels = node.Channels.Length > 0 ? string.Join(",", node.Channels) : "-";
var statuses = node.Statuses.Length > 0 ? string.Join(",", node.Statuses) : "-";
var site = node.SourceSiteId ?? "-";
var instance = node.SourceInstanceId ?? "-";
var first = node.FirstOccurredAtUtc.HasValue
? node.FirstOccurredAtUtc.Value.ToString("yyyy-MM-ddTHH:mm:ssZ")
: "-";
sb.AppendLine(
$"{indent}{node.ExecutionId:D}{marker} rows={node.RowCount} channels=[{channels}] statuses=[{statuses}] site={site} instance={instance} first={first}");
if (childrenOf.TryGetValue(node.ExecutionId, out var children))
{
foreach (var child in children)
{
RenderNode(child, depth + 1, childrenOf, queriedExecutionId, sb);
}
}
}
}
@@ -18,13 +18,17 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService;
/// <summary>
/// Minimal-API endpoints exposing the central Audit Log (#23) over HTTP for the
/// ScadaBridge CLI (M8). Two routes:
/// ScadaBridge CLI (M8). Three routes:
/// <list type="bullet">
/// <item><c>GET /api/audit/query</c> — keyset-paged JSON page, gated on the
/// <see cref="AuthorizationPolicies.OperationalAudit"/> permission.</item>
/// <item><c>GET /api/audit/export</c> — streamed bulk export (csv / jsonl;
/// parquet returns HTTP 501), gated on the
/// <see cref="AuthorizationPolicies.AuditExport"/> permission.</item>
/// <item><c>GET /api/audit/tree</c> — execution-chain tree rooted at the
/// topmost ancestor of a given <c>executionId</c>, returned as a JSON array
/// of <see cref="ExecutionTreeNode"/>; gated on
/// <see cref="AuthorizationPolicies.OperationalAudit"/>.</item>
/// </list>
///
/// <para>
@@ -86,7 +90,8 @@ public static class AuditEndpoints
};
/// <summary>
/// Registers the <c>/api/audit/query</c> and <c>/api/audit/export</c> minimal-API endpoints.
/// Registers the <c>/api/audit/query</c>, <c>/api/audit/export</c>, and
/// <c>/api/audit/tree</c> minimal-API endpoints.
/// </summary>
/// <param name="endpoints">The endpoint route builder to register routes on.</param>
/// <returns>The same <paramref name="endpoints"/> builder, for chaining.</returns>
@@ -94,6 +99,7 @@ public static class AuditEndpoints
{
endpoints.MapGet("/api/audit/query", (Delegate)HandleQuery);
endpoints.MapGet("/api/audit/export", (Delegate)HandleExport);
endpoints.MapGet("/api/audit/tree", (Delegate)HandleTree);
return endpoints;
}
@@ -232,6 +238,47 @@ public static class AuditEndpoints
return Results.Empty;
}
// ─────────────────────────────────────────────────────────────────────
// GET /api/audit/tree
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Handles <c>GET /api/audit/tree?executionId=...</c>: authenticates, checks the
/// OperationalAudit permission, and returns the full execution-chain tree rooted at
/// the topmost ancestor of the supplied <c>executionId</c>. The response is a JSON
/// array of <see cref="ExecutionTreeNode"/> objects (empty array when the id is
/// not found). Returns HTTP 400 when <c>executionId</c> is absent or not a valid
/// GUID.
/// </summary>
/// <param name="context">The HTTP context for the current request.</param>
/// <returns>A task that resolves to the HTTP result (200 JSON array, 400, 401, or 403).</returns>
internal static async Task<IResult> HandleTree(HttpContext context)
{
var auth = await AuthenticateAsync(context);
if (auth.Failure is not null)
{
return auth.Failure;
}
if (!HasAnyRole(auth.User!, AuthorizationPolicies.OperationalAuditRoles))
{
return Forbidden("OperationalAudit");
}
var raw = context.Request.Query["executionId"].ToString();
if (string.IsNullOrWhiteSpace(raw) || !Guid.TryParse(raw, out var executionId))
{
return Results.Json(
new { error = "Missing or invalid 'executionId' query parameter (expected a GUID).", code = "BAD_REQUEST" },
statusCode: 400);
}
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
var nodes = await repo.GetExecutionTreeAsync(executionId, context.RequestAborted);
return Results.Json(nodes, JsonOptions);
}
/// <summary>
/// Streams every matching row as RFC 4180 CSV, paging the repository with its
/// keyset cursor and flushing after each page so a large export starts