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
@@ -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